Initial project setup for PhaseFlow

Set up Next.js 16 project with TypeScript for a training decision app
that integrates menstrual cycle phases with Garmin biometrics for
Hashimoto's thyroiditis management.

Stack: Next.js 16, React 19, Tailwind/shadcn, PocketBase, Drizzle,
Zod, Resend, Vitest, Biome, Lefthook, Nix dev environment.

Includes:
- 7 page routes (dashboard, login, settings, calendar, history, plan)
- 12 API endpoints (garmin, user, cycle, calendar, overrides, cron)
- Core lib utilities (decision engine, cycle phases, nutrition, ICS)
- Type definitions and component scaffolding
- Python script for Garmin token bootstrapping
- Initial unit tests for cycle utilities

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-09 16:50:39 +00:00
commit f15e093254
63 changed files with 6061 additions and 0 deletions

21
.env.example Normal file
View File

@@ -0,0 +1,21 @@
# ABOUTME: Example environment variables for PhaseFlow.
# ABOUTME: Copy to .env and fill in your values.
# App
APP_URL=https://phaseflow.yourdomain.com
NODE_ENV=development
# PocketBase
POCKETBASE_URL=http://localhost:8090
# Email (Resend)
RESEND_API_KEY=re_xxxxxxxxxxxx
EMAIL_FROM=phaseflow@yourdomain.com
# Encryption (for Garmin tokens)
# Generate with: openssl rand -hex 16
ENCRYPTION_KEY=your-32-character-encryption-key
# Cron secret (for protected endpoints)
# Generate with: openssl rand -hex 32
CRON_SECRET=your-cron-secret-here

1
.envrc Normal file
View File

@@ -0,0 +1 @@
use flake

53
.gitignore vendored Normal file
View File

@@ -0,0 +1,53 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files
.env
.env.local
.env.*.local
!.env.example
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
# nix
result
.direnv/
# python
__pycache__/
*.pyc
.venv/

36
README.md Normal file
View File

@@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.

46
biome.json Normal file
View File

@@ -0,0 +1,46 @@
{
"$schema": "https://biomejs.dev/schemas/2.3.11/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
},
"files": {
"ignoreUnknown": false
},
"formatter": {
"enabled": true,
"indentStyle": "space"
},
"linter": {
"enabled": true,
"rules": {
"recommended": true
}
},
"javascript": {
"formatter": {
"quoteStyle": "double"
}
},
"css": {
"parser": {
"cssModules": true,
"tailwindDirectives": true
},
"linter": {
"enabled": false
},
"formatter": {
"enabled": false
}
},
"assist": {
"enabled": true,
"actions": {
"source": {
"organizeImports": "on"
}
}
}
}

22
components.json Normal file
View File

@@ -0,0 +1,22 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {}
}

27
flake.lock generated Normal file
View File

@@ -0,0 +1,27 @@
{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1767892417,
"narHash": "sha256-dhhvQY67aboBk8b0/u0XB6vwHdgbROZT3fJAjyNh5Ww=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "3497aa5c9457a9d88d71fa93a4a8368816fbeeba",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}

23
flake.nix Normal file
View File

@@ -0,0 +1,23 @@
# ABOUTME: Nix flake for PhaseFlow development environment.
# ABOUTME: Provides Node.js 24, pnpm, turbo, and lefthook.
{
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
outputs = { nixpkgs, ... }:
let
system = "x86_64-linux";
pkgs = nixpkgs.legacyPackages.${system};
in {
devShells.${system}.default = pkgs.mkShell {
packages = with pkgs; [
nodejs_24
pnpm
turbo
lefthook
];
# For native modules (sharp, better-sqlite3, etc.)
LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath [ pkgs.stdenv.cc.cc ];
};
};
}

9
lefthook.yml Normal file
View File

@@ -0,0 +1,9 @@
# ABOUTME: Pre-commit hooks configuration for code quality enforcement.
# ABOUTME: Runs biome linting and vitest tests before allowing commits.
pre-commit:
parallel: true
commands:
lint:
run: pnpm biome check --staged --no-errors-on-unmatched
test:
run: pnpm test:run

7
next.config.ts Normal file
View File

@@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;

46
package.json Normal file
View File

@@ -0,0 +1,46 @@
{
"name": "phaseflow",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "biome check .",
"lint:fix": "biome check --write .",
"test": "vitest",
"test:run": "vitest run"
},
"dependencies": {
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"drizzle-orm": "^0.45.1",
"ics": "^3.8.1",
"lucide-react": "^0.562.0",
"next": "16.1.1",
"node-cron": "^4.2.1",
"pocketbase": "^0.26.5",
"react": "19.2.3",
"react-dom": "19.2.3",
"resend": "^6.7.0",
"tailwind-merge": "^3.4.0",
"zod": "^4.3.5"
},
"devDependencies": {
"@biomejs/biome": "2.3.11",
"@tailwindcss/postcss": "^4",
"@testing-library/dom": "^10.4.1",
"@testing-library/react": "^16.3.1",
"@types/node": "^20",
"@types/node-cron": "^3.0.11",
"@types/react": "^19",
"@types/react-dom": "^19",
"@vitejs/plugin-react": "^5.1.2",
"drizzle-kit": "^0.31.8",
"jsdom": "^27.4.0",
"tailwindcss": "^4",
"tw-animate-css": "^1.4.0",
"typescript": "^5",
"vitest": "^4.0.16"
}
}

3570
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

5
pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,5 @@
packages:
- .
ignoredBuiltDependencies:
- sharp
- unrs-resolver

7
postcss.config.mjs Normal file
View File

@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

1
public/file.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

1
public/globe.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

1
public/next.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

1
public/vercel.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

1
public/window.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

37
scripts/garmin_auth.py Normal file
View File

@@ -0,0 +1,37 @@
#!/usr/bin/env python3
# ABOUTME: PhaseFlow Garmin Token Generator using garth library.
# ABOUTME: Run this locally to authenticate with Garmin (supports MFA).
"""
PhaseFlow Garmin Token Generator
Run this locally to authenticate with Garmin (supports MFA)
Usage:
pip install garth
python3 garmin_auth.py
"""
import json
from getpass import getpass
try:
import garth
except ImportError:
print("Error: garth library not installed.")
print("Please install it with: pip install garth")
exit(1)
email = input("Garmin email: ")
password = getpass("Garmin password: ")
# MFA handled automatically - prompts if needed
garth.login(email, password)
tokens = {
"oauth1": garth.client.oauth1_token.serialize(),
"oauth2": garth.client.oauth2_token.serialize(),
"expires_at": garth.client.oauth2_token.expires_at.isoformat()
}
print("\n--- Copy everything below this line ---")
print(json.dumps(tokens, indent=2))
print("--- Copy everything above this line ---")
print(f"\nTokens expire: {tokens['expires_at']}")

855
spec.md Normal file
View File

@@ -0,0 +1,855 @@
# PhaseFlow - MVP Specification
## Overview
A self-hosted webapp that automates training decisions for individuals with Hashimoto's thyroiditis by integrating menstrual cycle phases with biometric data from Garmin wearables. Provides daily actionable guidance via email notifications and calendar integration.
**Core Value Proposition**: Zero daily effort. Data-driven, body-respectful training decisions delivered automatically each morning.
---
## Tech Stack
| Layer | Choice |
|-------|--------|
| Language | TypeScript (strict) |
| Runtime | Node.js 24 LTS |
| Framework | Next.js 16 (App Router) |
| Database | PocketBase (SQLite, auth, real-time) |
| ORM | Drizzle (for any direct queries) |
| Validation | Zod |
| UI | Tailwind + shadcn/ui |
| Scheduling | Node-cron or PocketBase hooks |
| Email | Resend or Nodemailer |
| Calendar | ICS feed (user subscribes via URL) |
| Garmin | Python `garth` library for token bootstrap |
| Deployment | Nomad + Traefik (homelab) |
---
## Core Features
### 1. Garmin Integration
**Data Points Fetched Daily**:
| Metric | Source | Purpose |
|--------|--------|---------|
| Body Battery (current) | Garmin Connect | Assess readiness to train |
| Body Battery (yesterday's low) | Garmin Connect | Detect recovery adequacy |
| HRV Status | Garmin Connect | Detect autonomic stress |
| Weekly Intensity Minutes | Garmin Connect | Track cumulative load |
#### Authentication Strategy
The npm `garmin-connect` library does **not** support MFA. The Python `garth` library does. Since MFA is required, we use a hybrid approach:
**Token Bootstrap Flow**:
1. User runs a provided Python script locally (or via container exec):
```bash
# One-time setup script included with PhaseFlow
python3 garmin_auth.py
```
2. Script uses `garth` to authenticate (prompts for MFA code)
3. Script outputs OAuth1 + OAuth2 tokens as JSON
4. User pastes tokens into PhaseFlow settings page
5. App stores tokens encrypted in PocketBase
6. App uses tokens directly for Garmin API calls (no library needed for data fetch, just HTTP with Bearer auth)
**Token Lifecycle**:
- Tokens valid for ~90 days
- App tracks expiry, warns user 7 days before
- User re-runs bootstrap script when needed
- Future: auto-refresh if garth adds programmatic refresh support
**Garmin API Calls** (with stored tokens):
```typescript
// Direct HTTP calls using stored OAuth2 token
const response = await fetch('https://connect.garmin.com/modern/proxy/usersummary-service/stats/heartRate/daily/2025-01-09', {
headers: {
'Authorization': `Bearer ${oauth2Token}`,
'NK': 'NT'
}
});
```
**Provided Bootstrap Script** (`scripts/garmin_auth.py`):
```python
#!/usr/bin/env python3
"""
PhaseFlow Garmin Token Generator
Run this locally to authenticate with Garmin (supports MFA)
"""
import garth
import json
from getpass import getpass
email = input("Garmin email: ")
password = getpass("Garmin password: ")
# MFA handled automatically - prompts if needed
garth.login(email, password)
tokens = {
"oauth1": garth.client.oauth1_token.serialize(),
"oauth2": garth.client.oauth2_token.serialize(),
"expires_at": garth.client.oauth2_token.expires_at.isoformat()
}
print("\n--- Copy everything below this line ---")
print(json.dumps(tokens, indent=2))
print("--- Copy everything above this line ---")
print(f"\nTokens expire: {tokens['expires_at']}")
```
### 2. Cycle Phase Engine
**Phase Definitions** (based on cycle day from last period):
| Phase | Days | Weekly Limit | Daily Avg | Training Type |
|-------|------|--------------|-----------|---------------|
| MENSTRUAL | 1-3 | 30 min | 10 min | Gentle rebounding only |
| FOLLICULAR | 4-14 | 120 min | 17 min | Strength + rebounding |
| OVULATION | 15-16 | 80 min | 40 min | Peak performance |
| EARLY LUTEAL | 17-24 | 100 min | 14 min | Moderate training |
| **LATE LUTEAL** ⚠️ | 25-31 | 50 min | 8 min | **Gentle rebounding ONLY** |
**Configuration**:
- Last period date (user updates when period arrives)
- Average cycle length (default: 31 days, adjustable)
- Cycle day formula: `((daysSinceLastPeriod) % cycleLength) + 1`
### 3. Training Decision Engine
**Decision Priority (evaluated in order)**:
```typescript
type DecisionStatus = 'REST' | 'GENTLE' | 'LIGHT' | 'REDUCED' | 'TRAIN';
interface Decision {
status: DecisionStatus;
reason: string;
icon: string;
}
function getTrainingDecision(data: DailyData): Decision {
const { hrvStatus, bbYesterdayLow, phase, weekIntensity, phaseLimit, bbCurrent } = data;
if (hrvStatus === 'Unbalanced')
return { status: 'REST', reason: 'HRV Unbalanced', icon: '🛑' };
if (bbYesterdayLow < 30)
return { status: 'REST', reason: 'BB too depleted', icon: '🛑' };
if (phase === 'LATE_LUTEAL')
return { status: 'GENTLE', reason: 'Gentle rebounding only (10-15min)', icon: '🟡' };
if (phase === 'MENSTRUAL')
return { status: 'GENTLE', reason: 'Gentle rebounding only (10min)', icon: '🟡' };
if (weekIntensity >= phaseLimit)
return { status: 'REST', reason: 'WEEKLY LIMIT REACHED - Rest', icon: '🛑' };
if (bbCurrent < 75)
return { status: 'LIGHT', reason: 'Light activity only - BB not recovered', icon: '🟡' };
if (bbCurrent < 85)
return { status: 'REDUCED', reason: 'Reduce intensity 25%', icon: '🟡' };
return { status: 'TRAIN', reason: 'OK to train - follow phase plan', icon: '✅' };
}
```
### 4. Daily Email Notification
**Delivery**: Email at user-configured time (default 7:00 AM local)
**Content Structure**:
```
Subject: Today's Training: ✅ TRAIN (or 🛑 REST or 🟡 GENTLE)
Good morning!
📅 CYCLE DAY: 12 (FOLLICULAR)
💪 TODAY'S PLAN:
✅ OK to train - follow phase plan
📊 YOUR DATA:
• Body Battery Now: 95
• Yesterday's Low: 42
• HRV Status: Balanced
• Week Intensity: 85 / 120 minutes
• Remaining: 35 minutes
🏋️ EXERCISE:
Great day for strength training! Do your full workout.
🌱 SEEDS: Flax (1-2 tbsp) + Pumpkin (1-2 tbsp)
🍽️ MACROS: Variable (can go low 20-100g).
🥑 KETO: OPTIONAL - this is your optimal keto window if you want to try!
Days 7-10: Transition in | Days 11-14: Full keto | Day 14 evening: Transition out
---
Auto-generated by PhaseFlow
```
### 5. Nutrition Guidance
**Seed Cycling** (included in daily email):
| Days | Seeds |
|------|-------|
| 1-14 | Flax (1-2 tbsp) + Pumpkin (1-2 tbsp) |
| 15+ | Sesame (1-2 tbsp) + Sunflower (1-2 tbsp) |
Day 15 alert: "🌱 SWITCH TODAY! Start Sesame + Sunflower"
**Macro Guidance by Phase**:
| Days | Carb Range | Keto Guidance |
|------|------------|---------------|
| 1-3 | 100-150g | No - body needs carbs during menstruation |
| 4-6 | 75-100g | No - transition phase |
| 7-14 | 20-100g | OPTIONAL - optimal keto window |
| 15-16 | 100-150g | No - exit keto, need carbs for ovulation |
| 17-24 | 75-125g | No - progesterone needs carbs |
| 25-31 | 100-150g+ | NEVER - mood/hormones need carbs for PMS |
### 6. Calendar Integration (ICS Feed)
**Approach**: Serve a dynamic ICS/iCal file that users subscribe to in any calendar app.
**Why ICS Feed vs Google Calendar API**:
- No Google OAuth required
- Works with Google Calendar, Apple Calendar, Outlook, etc.
- No Google Cloud project setup
- User just adds URL: "Add calendar from URL"
- Calendar apps poll automatically (Google: ~12-24 hours)
- When period date changes, feed regenerates - picked up on next poll
**Feed URL**: `https://phaseflow.yourdomain.com/api/calendar/{userId}/{token}.ics`
- `userId`: identifies the user
- `token`: random secret token for security (regeneratable)
**ICS Content**:
```ics
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//PhaseFlow//Cycle Calendar//EN
NAME:PhaseFlow Cycle
X-WR-CALNAME:PhaseFlow Cycle
BEGIN:VEVENT
DTSTART;VALUE=DATE:20250105
DTEND;VALUE=DATE:20250108
SUMMARY:🔵 MENSTRUAL
DESCRIPTION:Gentle rebounding only (10 min max)
COLOR:blue
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20250108
DTEND;VALUE=DATE:20250119
SUMMARY:🟢 FOLLICULAR
DESCRIPTION:Strength training + rebounding (15-20 min/day avg)
COLOR:green
END:VEVENT
...
BEGIN:VEVENT
DTSTART;VALUE=DATE:20250126
DTEND;VALUE=DATE:20250126
SUMMARY:⚠️ Late Luteal Starts in 3 Days
DESCRIPTION:Begin reducing training intensity
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20250129
DTEND;VALUE=DATE:20250129
SUMMARY:🔴 CRITICAL PHASE - Gentle Rebounding Only!
DESCRIPTION:Late luteal phase - protect your cycle
END:VEVENT
END:VCALENDAR
```
**Phase Colors** (as emoji prefix since ICS color support varies):
| Phase | Emoji | Description |
|-------|-------|-------------|
| MENSTRUAL | 🔵 | Blue |
| FOLLICULAR | 🟢 | Green |
| OVULATION | 🟣 | Purple |
| EARLY LUTEAL | 🟡 | Yellow |
| LATE LUTEAL | 🔴 | Red |
**Warning Events**:
- Day 22: "⚠️ Late Luteal Phase Starts in 3 Days - Reduce Training 50%"
- Day 25: "🔴 CRITICAL PHASE - Gentle Rebounding Only!"
### 7. In-App Calendar View
**Display**:
- Monthly calendar showing phase colors
- Current day highlighted
- Click day to see that day's data/decision
- Visual indicator when approaching late luteal
### 8. Period Logging
**User Flow**:
1. Click "Period Started" button
2. Confirm date (defaults to today, can select past date)
3. System recalculates all phase dates
4. ICS feed automatically reflects new dates
5. Confirmation email sent
**Confirmation Email**:
```
Subject: 🔵 Period Tracking Updated
Your cycle has been reset. Last period: [date]
Phase calendar updated for next [cycle_length] days.
Your calendar will update automatically within 24 hours.
```
### 9. Emergency Modifications
User-triggered overrides accessible from dashboard:
| Override | Effect | Duration |
|----------|--------|----------|
| Hashimoto's flare | All training → gentle rebounding only | Until deactivated |
| High stress week | Reduce all intensity limits by 50% | Until deactivated |
| Poor sleep | Replace strength with gentle rebounding | Until deactivated |
| PMS symptoms | Extra rebounding, no strength training | Until deactivated |
These overrides apply on top of the normal decision engine.
### 10. Exercise Plan Reference
**Accessible in-app** - the full monthly exercise plan:
#### Week 1: Menstrual Phase (Days 1-7)
- Morning: 10-15 min gentle rebounding
- Evening: 15-20 min restorative movement
- No strength training
#### Week 2: Follicular Phase (Days 8-14)
- Mon/Wed/Fri: Strength training (20-25 min)
- Squats: 3x8-12
- Push-ups: 3x5-10
- Single-leg deadlifts: 3x6-8 each
- Plank: 3x20-45s
- Kettlebell swings: 2x10-15
- Tue/Thu: Active recovery rebounding (20 min)
- Weekend: Choose your adventure
#### Week 3: Ovulation + Early Luteal (Days 15-24)
- Days 15-16: Peak performance (25-30 min strength)
- Days 17-21: Modified strength (reduce intensity 10-20%)
#### Week 4: Late Luteal (Days 22-28)
- Daily: Gentle rebounding only (15-20 min)
- Optional light bodyweight Mon/Wed if feeling good
- Rest days: Tue/Thu/Sat/Sun
**Rebounding Techniques by Phase**:
- Menstrual: Health bounce, lymphatic drainage
- Follicular: Strength bounce, intervals
- Ovulation: Maximum intensity, plyometric
- Luteal: Therapeutic, stress relief
---
## Data Model
### Users (PocketBase collection)
```typescript
interface User {
id: string;
email: string;
// Garmin
garminConnected: boolean;
garminOauth1Token: string; // encrypted JSON
garminOauth2Token: string; // encrypted JSON
garminTokenExpiresAt: Date;
// Calendar
calendarToken: string; // random secret for ICS URL
// Cycle
lastPeriodDate: Date;
cycleLength: number; // default: 31
// Preferences
notificationTime: string; // "07:00"
timezone: string;
// Overrides
activeOverrides: string[]; // ['flare', 'stress', 'sleep', 'pms']
created: Date;
updated: Date;
}
```
### Daily Logs (PocketBase collection)
```typescript
interface DailyLog {
id: string;
user: string; // relation
date: Date;
cycleDay: number;
phase: 'MENSTRUAL' | 'FOLLICULAR' | 'OVULATION' | 'EARLY_LUTEAL' | 'LATE_LUTEAL';
bodyBatteryCurrent: number | null;
bodyBatteryYesterdayLow: number | null;
hrvStatus: 'Balanced' | 'Unbalanced' | 'Unknown';
weekIntensityMinutes: number;
phaseLimit: number;
remainingMinutes: number;
trainingDecision: string;
decisionReason: string;
notificationSentAt: Date | null;
created: Date;
}
```
### Period Logs (PocketBase collection)
```typescript
interface PeriodLog {
id: string;
user: string; // relation
startDate: Date;
created: Date;
}
```
---
## API Routes
```
# Auth (PocketBase handles user auth)
POST /api/auth/login
POST /api/auth/register
POST /api/auth/logout
# Garmin Token Management
POST /api/garmin/tokens - Store tokens from bootstrap script
DELETE /api/garmin/tokens - Clear stored tokens
GET /api/garmin/status - Check connection & token expiry
# User Settings
GET /api/user - Get profile + settings
PATCH /api/user - Update settings
# Cycle
POST /api/cycle/period - Log period start
GET /api/cycle/current - Current phase info
# Daily
GET /api/today - Today's decision + all data
GET /api/history - Historical logs
# Calendar
GET /api/calendar/:userId/:token.ics - ICS feed (public, token-protected)
POST /api/calendar/regenerate-token - Generate new calendar token
# Overrides
POST /api/overrides - Set active overrides
DELETE /api/overrides/:type - Remove override
# Cron (internal, protected)
POST /api/cron/garmin-sync - Fetch Garmin data (6 AM)
POST /api/cron/notifications - Send emails (7 AM)
```
---
## Scheduled Jobs
| Job | Schedule | Function |
|-----|----------|----------|
| Garmin Sync | Daily at user's configured time (default 6:00) | Fetch BB, HRV, intensity |
| Morning Email | Daily at user's configured time + 1hr (default 7:00) | Send training decision |
| Token Expiry Check | Daily | Warn user if tokens expire within 7 days |
**Implementation**:
- Nomad periodic jobs hitting internal API endpoints
- Or: node-cron within the Next.js app
- Or: PocketBase hooks + external cron
---
## Page Structure
```
src/app/
page.tsx # Dashboard (today's decision, quick actions)
login/page.tsx # Auth
settings/page.tsx # Profile, Garmin tokens, notification time
settings/garmin/page.tsx # Garmin token paste UI + instructions
calendar/page.tsx # In-app calendar view + ICS subscription instructions
history/page.tsx # Historical data table
plan/page.tsx # Exercise plan reference
api/
auth/...
garmin/tokens/route.ts
garmin/status/route.ts
user/route.ts
cycle/period/route.ts
cycle/current/route.ts
today/route.ts
history/route.ts
calendar/[userId]/[token].ics/route.ts
calendar/regenerate-token/route.ts
overrides/route.ts
cron/garmin-sync/route.ts
cron/notifications/route.ts
```
---
## Dashboard Layout
```
┌─────────────────────────────────────────────────────────────┐
│ PhaseFlow [Settings]│
├─────────────────────────────────────────────────────────────┤
│ │
│ 📅 Day 12 of 31 • FOLLICULAR │
│ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ (progress bar) │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ ✅ OK TO TRAIN │ │
│ │ Follow your phase plan - strength training day! │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ YOUR DATA NUTRITION TODAY │
│ ┌──────────────────────┐ ┌──────────────────────┐│
│ │ Body Battery: 95 │ │ 🌱 Flax + Pumpkin ││
│ │ Yesterday Low: 42 │ │ 🍽️ Carbs: 20-100g ││
│ │ HRV: Balanced │ │ 🥑 Keto: Optional ││
│ │ Week: 85/120 min │ └──────────────────────┘│
│ │ Remaining: 35 min │ │
│ └──────────────────────┘ QUICK ACTIONS │
│ ┌──────────────────────┐ │
│ OVERRIDES │ [Period Started] │ │
│ ┌──────────────────────┐ │ [View Plan] │ │
│ │ ○ Flare Mode │ │ [Calendar] │ │
│ │ ○ High Stress │ └──────────────────────┘ │
│ │ ○ Poor Sleep │ │
│ │ ○ PMS Symptoms │ │
│ └──────────────────────┘ │
│ │
│ [Mini Calendar - current month with phase colors] │
│ │
└─────────────────────────────────────────────────────────────┘
```
---
## Garmin Settings Page
```
┌─────────────────────────────────────────────────────────────┐
│ Settings > Garmin Connection │
├─────────────────────────────────────────────────────────────┤
│ │
│ STATUS: 🟢 Connected │
│ Tokens expire: March 15, 2025 (65 days) │
│ │
│ ───────────────────────────────────────────────────────── │
│ │
│ To connect or refresh your Garmin tokens: │
│ │
│ 1. Run this command on your computer: │
│ ┌─────────────────────────────────────────────────┐ │
│ │ curl -O https://phaseflow.../garmin_auth.py │ │
│ │ python3 garmin_auth.py │ │
│ └─────────────────────────────────────────────────┘ │
│ │
│ 2. Enter your Garmin credentials when prompted │
│ (MFA code will be requested if enabled) │
│ │
│ 3. Paste the JSON output here: │
│ ┌─────────────────────────────────────────────────┐ │
│ │ │ │
│ │ │ │
│ │ │ │
│ └─────────────────────────────────────────────────┘ │
│ │
│ [Save Tokens] │
│ │
│ ───────────────────────────────────────────────────────── │
│ │
│ [Disconnect Garmin] │
│ │
└─────────────────────────────────────────────────────────────┘
```
---
## Calendar Settings Page
```
┌─────────────────────────────────────────────────────────────┐
│ Settings > Calendar │
├─────────────────────────────────────────────────────────────┤
│ │
│ Subscribe to your cycle calendar in any calendar app: │
│ │
│ Calendar URL: │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ https://phaseflow.example.com/api/calendar/abc123/ │ │
│ │ x7f9k2m4n8.ics [📋] │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ Instructions: │
│ │
│ Google Calendar: │
│ 1. Open Google Calendar settings │
│ 2. Click "Add calendar" → "From URL" │
│ 3. Paste the URL above │
│ │
│ Apple Calendar: │
│ 1. File → New Calendar Subscription │
│ 2. Paste the URL above │
│ │
│ Note: Calendar updates within 12-24 hours of changes. │
│ │
│ ───────────────────────────────────────────────────────── │
│ │
│ [Regenerate URL] (invalidates old URL) │
│ │
└─────────────────────────────────────────────────────────────┘
```
---
## Environment Variables
```env
# App
APP_URL=https://phaseflow.yourdomain.com
NODE_ENV=production
# PocketBase
POCKETBASE_URL=http://localhost:8090
# Email (Resend)
RESEND_API_KEY=xxx
EMAIL_FROM=phaseflow@yourdomain.com
# Encryption (for Garmin tokens)
ENCRYPTION_KEY=xxx # 32-byte key for AES-256
# Cron secret (for protected endpoints)
CRON_SECRET=xxx
```
---
## Deployment (Nomad)
```hcl
job "phaseflow" {
datacenters = ["dc1"]
type = "service"
group "web" {
count = 1
network {
port "http" { to = 3000 }
}
service {
name = "phaseflow"
port = "http"
tags = [
"traefik.enable=true",
"traefik.http.routers.phaseflow.rule=Host(`phaseflow.yourdomain.com`)",
"traefik.http.routers.phaseflow.tls.certresolver=letsencrypt"
]
}
task "nextjs" {
driver = "docker"
config {
image = "phaseflow:latest"
ports = ["http"]
}
env {
NODE_ENV = "production"
}
template {
data = <<EOF
{{ with nomadVar "nomad/jobs/phaseflow" }}
APP_URL={{ .app_url }}
POCKETBASE_URL={{ .pocketbase_url }}
RESEND_API_KEY={{ .resend_key }}
ENCRYPTION_KEY={{ .encryption_key }}
CRON_SECRET={{ .cron_secret }}
{{ end }}
EOF
destination = "secrets/.env"
env = true
}
resources {
cpu = 256
memory = 512
}
}
}
group "pocketbase" {
count = 1
network {
port "pb" { to = 8090 }
}
volume "pb_data" {
type = "host"
source = "pocketbase-data"
}
task "pocketbase" {
driver = "docker"
config {
image = "ghcr.io/muchobien/pocketbase:latest"
ports = ["pb"]
}
volume_mount {
volume = "pb_data"
destination = "/pb_data"
}
resources {
cpu = 128
memory = 256
}
}
}
# Periodic job for daily sync
group "cron" {
type = "batch"
periodic {
cron = "0 6 * * *" # 6 AM daily
prohibit_overlap = true
}
task "garmin-sync" {
driver = "exec"
config {
command = "curl"
args = [
"-X", "POST",
"-H", "Authorization: Bearer ${CRON_SECRET}",
"${APP_URL}/api/cron/garmin-sync"
]
}
}
}
}
```
---
## Error Handling
| Scenario | Handling |
|----------|----------|
| Garmin API unavailable | Use last known values, note in email |
| Garmin tokens expired | Email user with re-auth instructions |
| No Garmin data yet | Use phase-only decision |
| User hasn't set period date | Prompt in dashboard, block email until set |
| Email delivery failure | Log, retry once |
| Invalid ICS request | Return 404 |
---
## Security Considerations
- Garmin tokens encrypted at rest (AES-256)
- ICS feed URL contains random token (not guessable)
- Cron endpoints protected by secret header
- PocketBase handles user auth
- HTTPS enforced via Traefik
---
## File Structure
```
phaseflow/
├── scripts/
│ └── garmin_auth.py # Token bootstrap script
├── src/
│ ├── app/
│ │ ├── page.tsx
│ │ ├── login/page.tsx
│ │ ├── settings/
│ │ │ ├── page.tsx
│ │ │ └── garmin/page.tsx
│ │ ├── calendar/page.tsx
│ │ ├── history/page.tsx
│ │ ├── plan/page.tsx
│ │ ├── layout.tsx
│ │ └── api/
│ │ ├── garmin/
│ │ │ ├── tokens/route.ts
│ │ │ └── status/route.ts
│ │ ├── user/route.ts
│ │ ├── cycle/
│ │ │ ├── period/route.ts
│ │ │ └── current/route.ts
│ │ ├── today/route.ts
│ │ ├── history/route.ts
│ │ ├── calendar/
│ │ │ ├── [userId]/[token].ics/route.ts
│ │ │ └── regenerate-token/route.ts
│ │ ├── overrides/route.ts
│ │ └── cron/
│ │ ├── garmin-sync/route.ts
│ │ └── notifications/route.ts
│ ├── components/
│ │ ├── dashboard/
│ │ │ ├── decision-card.tsx
│ │ │ ├── data-panel.tsx
│ │ │ ├── nutrition-panel.tsx
│ │ │ ├── override-toggles.tsx
│ │ │ └── mini-calendar.tsx
│ │ ├── calendar/
│ │ │ ├── month-view.tsx
│ │ │ └── day-cell.tsx
│ │ └── ui/ # shadcn components
│ ├── lib/
│ │ ├── pocketbase.ts
│ │ ├── garmin.ts # API calls with stored tokens
│ │ ├── decision-engine.ts
│ │ ├── cycle.ts
│ │ ├── nutrition.ts
│ │ ├── email.ts
│ │ ├── ics.ts # ICS feed generation
│ │ └── encryption.ts
│ └── types/
│ └── index.ts
├── flake.nix
├── .envrc
├── biome.json
├── vitest.config.ts
├── lefthook.yml
└── package.json
```

View File

@@ -0,0 +1,23 @@
// ABOUTME: API route for ICS calendar feed generation.
// ABOUTME: Returns subscribable iCal feed with cycle phases and warnings.
import { type NextRequest, NextResponse } from "next/server";
interface RouteParams {
params: Promise<{
userId: string;
token: string;
}>;
}
export async function GET(_request: NextRequest, { params }: RouteParams) {
const { userId, token } = await params;
void token; // Token will be used for validation
// TODO: Implement ICS feed generation
// Validate token, generate ICS content, return with correct headers
return new NextResponse(`ICS feed for user ${userId} not implemented`, {
status: 501,
headers: {
"Content-Type": "text/calendar; charset=utf-8",
},
});
}

View File

@@ -0,0 +1,8 @@
// ABOUTME: API route for regenerating calendar subscription token.
// ABOUTME: Creates new random token, invalidating old calendar URLs.
import { NextResponse } from "next/server";
export async function POST() {
// TODO: Implement token regeneration
return NextResponse.json({ message: "Not implemented" }, { status: 501 });
}

View File

@@ -0,0 +1,16 @@
// ABOUTME: Cron endpoint for syncing Garmin data.
// ABOUTME: Fetches body battery, HRV, and intensity minutes for all users.
import { NextResponse } from "next/server";
export async function POST(request: Request) {
// Verify cron secret
const authHeader = request.headers.get("authorization");
const expectedSecret = process.env.CRON_SECRET;
if (!expectedSecret || authHeader !== `Bearer ${expectedSecret}`) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// TODO: Implement Garmin data sync
return NextResponse.json({ message: "Not implemented" }, { status: 501 });
}

View File

@@ -0,0 +1,16 @@
// ABOUTME: Cron endpoint for sending daily email notifications.
// ABOUTME: Sends morning training decision emails to all users.
import { NextResponse } from "next/server";
export async function POST(request: Request) {
// Verify cron secret
const authHeader = request.headers.get("authorization");
const expectedSecret = process.env.CRON_SECRET;
if (!expectedSecret || authHeader !== `Bearer ${expectedSecret}`) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// TODO: Implement notification sending
return NextResponse.json({ message: "Not implemented" }, { status: 501 });
}

View File

@@ -0,0 +1,8 @@
// ABOUTME: API route for current cycle phase information.
// ABOUTME: Returns current cycle day, phase, and phase limits.
import { NextResponse } from "next/server";
export async function GET() {
// TODO: Implement current cycle info
return NextResponse.json({ message: "Not implemented" }, { status: 501 });
}

View File

@@ -0,0 +1,8 @@
// ABOUTME: API route for logging period start dates.
// ABOUTME: Recalculates all phase dates when period is logged.
import { NextResponse } from "next/server";
export async function POST() {
// TODO: Implement period logging
return NextResponse.json({ message: "Not implemented" }, { status: 501 });
}

View File

@@ -0,0 +1,8 @@
// ABOUTME: API route for checking Garmin connection status.
// ABOUTME: Returns connection state and token expiry information.
import { NextResponse } from "next/server";
export async function GET() {
// TODO: Implement status check
return NextResponse.json({ message: "Not implemented" }, { status: 501 });
}

View File

@@ -0,0 +1,13 @@
// ABOUTME: API route for storing Garmin OAuth tokens.
// ABOUTME: Accepts tokens from the bootstrap script and encrypts them for storage.
import { NextResponse } from "next/server";
export async function POST() {
// TODO: Implement token storage
return NextResponse.json({ message: "Not implemented" }, { status: 501 });
}
export async function DELETE() {
// TODO: Implement token deletion
return NextResponse.json({ message: "Not implemented" }, { status: 501 });
}

View File

@@ -0,0 +1,8 @@
// ABOUTME: API route for historical daily logs.
// ABOUTME: Returns paginated list of past training decisions and data.
import { NextResponse } from "next/server";
export async function GET() {
// TODO: Implement history retrieval
return NextResponse.json({ message: "Not implemented" }, { status: 501 });
}

View File

@@ -0,0 +1,13 @@
// ABOUTME: API route for managing training overrides.
// ABOUTME: Handles flare, stress, sleep, and PMS override toggles.
import { NextResponse } from "next/server";
export async function POST() {
// TODO: Implement override setting
return NextResponse.json({ message: "Not implemented" }, { status: 501 });
}
export async function DELETE() {
// TODO: Implement override removal
return NextResponse.json({ message: "Not implemented" }, { status: 501 });
}

View File

@@ -0,0 +1,8 @@
// ABOUTME: API route for today's training decision and data.
// ABOUTME: Returns complete daily snapshot with decision, biometrics, and nutrition.
import { NextResponse } from "next/server";
export async function GET() {
// TODO: Implement today's data retrieval
return NextResponse.json({ message: "Not implemented" }, { status: 501 });
}

13
src/app/api/user/route.ts Normal file
View File

@@ -0,0 +1,13 @@
// ABOUTME: API route for user profile management.
// ABOUTME: Handles GET for profile retrieval and PATCH for updates.
import { NextResponse } from "next/server";
export async function GET() {
// TODO: Implement user profile retrieval
return NextResponse.json({ message: "Not implemented" }, { status: 501 });
}
export async function PATCH() {
// TODO: Implement user profile update
return NextResponse.json({ message: "Not implemented" }, { status: 501 });
}

11
src/app/calendar/page.tsx Normal file
View File

@@ -0,0 +1,11 @@
// ABOUTME: In-app calendar view showing cycle phases.
// ABOUTME: Displays monthly calendar with color-coded phases and ICS subscription info.
export default function CalendarPage() {
return (
<div className="container mx-auto p-8">
<h1 className="text-2xl font-bold mb-8">Calendar</h1>
{/* Calendar view will be implemented here */}
<p className="text-gray-500">Calendar view placeholder</p>
</div>
);
}

BIN
src/app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

125
src/app/globals.css Normal file
View File

@@ -0,0 +1,125 @@
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--radius-2xl: calc(var(--radius) + 8px);
--radius-3xl: calc(var(--radius) + 12px);
--radius-4xl: calc(var(--radius) + 16px);
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}

11
src/app/history/page.tsx Normal file
View File

@@ -0,0 +1,11 @@
// ABOUTME: Historical data view for past training decisions.
// ABOUTME: Shows table of daily logs with biometrics and decisions.
export default function HistoryPage() {
return (
<div className="container mx-auto p-8">
<h1 className="text-2xl font-bold mb-8">History</h1>
{/* History table will be implemented here */}
<p className="text-gray-500">History table placeholder</p>
</div>
);
}

37
src/app/layout.tsx Normal file
View File

@@ -0,0 +1,37 @@
// ABOUTME: Root layout for PhaseFlow application.
// ABOUTME: Configures fonts, metadata, and global styles.
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "PhaseFlow",
description:
"Automated training decisions for Hashimoto's thyroiditis based on menstrual cycle phases and Garmin biometrics",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
{children}
</body>
</html>
);
}

13
src/app/login/page.tsx Normal file
View File

@@ -0,0 +1,13 @@
// ABOUTME: Login page for user authentication.
// ABOUTME: Provides email/password login form using PocketBase auth.
export default function LoginPage() {
return (
<div className="flex min-h-screen items-center justify-center">
<div className="w-full max-w-md space-y-8 p-8">
<h1 className="text-2xl font-bold text-center">PhaseFlow Login</h1>
{/* Login form will be implemented here */}
<p className="text-center text-gray-500">Login form placeholder</p>
</div>
</div>
);
}

28
src/app/page.tsx Normal file
View File

@@ -0,0 +1,28 @@
// ABOUTME: Main dashboard page for PhaseFlow.
// ABOUTME: Displays today's training decision, biometrics, and quick actions.
export default function Dashboard() {
return (
<div className="min-h-screen bg-zinc-50 dark:bg-black">
<header className="border-b bg-white dark:bg-zinc-900 px-6 py-4">
<div className="container mx-auto flex justify-between items-center">
<h1 className="text-xl font-bold">PhaseFlow</h1>
<a
href="/settings"
className="text-sm text-zinc-600 hover:text-zinc-900"
>
Settings
</a>
</div>
</header>
<main className="container mx-auto p-6">
<div className="text-center py-12">
<p className="text-zinc-500">Dashboard placeholder</p>
<p className="text-sm text-zinc-400 mt-2">
Connect your Garmin and set your period date to get started.
</p>
</div>
</main>
</div>
);
}

11
src/app/plan/page.tsx Normal file
View File

@@ -0,0 +1,11 @@
// ABOUTME: Exercise plan reference page.
// ABOUTME: Displays the full monthly exercise plan by phase.
export default function PlanPage() {
return (
<div className="container mx-auto p-8">
<h1 className="text-2xl font-bold mb-8">Exercise Plan</h1>
{/* Exercise plan content will be implemented here */}
<p className="text-gray-500">Exercise plan placeholder</p>
</div>
);
}

View File

@@ -0,0 +1,13 @@
// ABOUTME: Garmin connection settings page.
// ABOUTME: Allows users to paste OAuth tokens from the bootstrap script.
export default function GarminSettingsPage() {
return (
<div className="container mx-auto p-8">
<h1 className="text-2xl font-bold mb-8">
Settings &gt; Garmin Connection
</h1>
{/* Garmin token input will be implemented here */}
<p className="text-gray-500">Garmin settings placeholder</p>
</div>
);
}

11
src/app/settings/page.tsx Normal file
View File

@@ -0,0 +1,11 @@
// ABOUTME: User settings page for profile and preferences.
// ABOUTME: Allows configuration of notification time, timezone, and cycle length.
export default function SettingsPage() {
return (
<div className="container mx-auto p-8">
<h1 className="text-2xl font-bold mb-8">Settings</h1>
{/* Settings form will be implemented here */}
<p className="text-gray-500">Settings form placeholder</p>
</div>
);
}

View File

@@ -0,0 +1,38 @@
// ABOUTME: Individual day cell for calendar views.
// ABOUTME: Shows day number with phase-appropriate background color.
import type { CyclePhase } from "@/types";
interface DayCellProps {
date: Date;
cycleDay: number;
phase: CyclePhase;
isToday: boolean;
onClick?: () => void;
}
const PHASE_COLORS: Record<CyclePhase, string> = {
MENSTRUAL: "bg-blue-100",
FOLLICULAR: "bg-green-100",
OVULATION: "bg-purple-100",
EARLY_LUTEAL: "bg-yellow-100",
LATE_LUTEAL: "bg-red-100",
};
export function DayCell({
date,
cycleDay,
phase,
isToday,
onClick,
}: DayCellProps) {
return (
<button
type="button"
onClick={onClick}
className={`p-2 rounded ${PHASE_COLORS[phase]} ${isToday ? "ring-2 ring-black" : ""}`}
>
<span className="text-sm font-medium">{date.getDate()}</span>
<span className="text-xs text-gray-500 block">Day {cycleDay}</span>
</button>
);
}

View File

@@ -0,0 +1,32 @@
// ABOUTME: Full month calendar view component.
// ABOUTME: Displays calendar grid with phase colors and day details.
interface MonthViewProps {
year: number;
month: number;
lastPeriodDate: Date;
cycleLength: number;
}
export function MonthView({
year,
month,
lastPeriodDate,
cycleLength,
}: MonthViewProps) {
return (
<div className="rounded-lg border p-4">
<h2 className="text-xl font-bold mb-4">
{new Date(year, month).toLocaleDateString("en-US", {
month: "long",
year: "numeric",
})}
</h2>
{/* Calendar grid will be implemented here */}
<p className="text-gray-500">Month view placeholder</p>
<p className="text-xs text-gray-400">
Cycle length: {cycleLength} days, Last period:{" "}
{lastPeriodDate.toLocaleDateString()}
</p>
</div>
);
}

View File

@@ -0,0 +1,34 @@
// ABOUTME: Dashboard panel showing biometric data.
// ABOUTME: Displays body battery, HRV, and intensity minutes.
interface DataPanelProps {
bodyBatteryCurrent: number | null;
bodyBatteryYesterdayLow: number | null;
hrvStatus: string;
weekIntensity: number;
phaseLimit: number;
remainingMinutes: number;
}
export function DataPanel({
bodyBatteryCurrent,
bodyBatteryYesterdayLow,
hrvStatus,
weekIntensity,
phaseLimit,
remainingMinutes,
}: DataPanelProps) {
return (
<div className="rounded-lg border p-4">
<h3 className="font-semibold mb-4">YOUR DATA</h3>
<ul className="space-y-2 text-sm">
<li>Body Battery: {bodyBatteryCurrent ?? "N/A"}</li>
<li>Yesterday Low: {bodyBatteryYesterdayLow ?? "N/A"}</li>
<li>HRV: {hrvStatus}</li>
<li>
Week: {weekIntensity}/{phaseLimit} min
</li>
<li>Remaining: {remainingMinutes} min</li>
</ul>
</div>
);
}

View File

@@ -0,0 +1,17 @@
// ABOUTME: Dashboard card displaying today's training decision.
// ABOUTME: Shows decision status, icon, and reason prominently.
import type { Decision } from "@/types";
interface DecisionCardProps {
decision: Decision;
}
export function DecisionCard({ decision }: DecisionCardProps) {
return (
<div className="rounded-lg border p-6">
<div className="text-4xl mb-2">{decision.icon}</div>
<h2 className="text-2xl font-bold">{decision.status}</h2>
<p className="text-gray-600">{decision.reason}</p>
</div>
);
}

View File

@@ -0,0 +1,29 @@
// ABOUTME: Compact calendar widget for the dashboard.
// ABOUTME: Shows current month with color-coded cycle phases.
interface MiniCalendarProps {
currentDate: Date;
cycleDay: number;
phase: string;
}
export function MiniCalendar({
currentDate,
cycleDay,
phase,
}: MiniCalendarProps) {
return (
<div className="rounded-lg border p-4">
<h3 className="font-semibold mb-4">
Day {cycleDay} {phase}
</h3>
<p className="text-sm text-gray-500">
{currentDate.toLocaleDateString("en-US", {
month: "long",
year: "numeric",
})}
</p>
{/* Full calendar grid will be implemented here */}
<p className="text-gray-400 text-xs mt-4">Calendar grid placeholder</p>
</div>
);
}

View File

@@ -0,0 +1,20 @@
// ABOUTME: Dashboard panel showing nutrition guidance.
// ABOUTME: Displays seed cycling and macro recommendations.
import type { NutritionGuidance } from "@/types";
interface NutritionPanelProps {
nutrition: NutritionGuidance;
}
export function NutritionPanel({ nutrition }: NutritionPanelProps) {
return (
<div className="rounded-lg border p-4">
<h3 className="font-semibold mb-4">NUTRITION TODAY</h3>
<ul className="space-y-2 text-sm">
<li>🌱 {nutrition.seeds}</li>
<li>🍽 Carbs: {nutrition.carbRange}</li>
<li>🥑 Keto: {nutrition.ketoGuidance}</li>
</ul>
</div>
);
}

View File

@@ -0,0 +1,41 @@
// ABOUTME: Dashboard component for emergency training overrides.
// ABOUTME: Provides toggles for flare, stress, sleep, and PMS modes.
import type { OverrideType } from "@/types";
interface OverrideTogglesProps {
activeOverrides: OverrideType[];
onToggle: (override: OverrideType) => void;
}
const OVERRIDE_OPTIONS: { type: OverrideType; label: string }[] = [
{ type: "flare", label: "Flare Mode" },
{ type: "stress", label: "High Stress" },
{ type: "sleep", label: "Poor Sleep" },
{ type: "pms", label: "PMS Symptoms" },
];
export function OverrideToggles({
activeOverrides,
onToggle,
}: OverrideTogglesProps) {
return (
<div className="rounded-lg border p-4">
<h3 className="font-semibold mb-4">OVERRIDES</h3>
<ul className="space-y-2">
{OVERRIDE_OPTIONS.map(({ type, label }) => (
<li key={type}>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={activeOverrides.includes(type)}
onChange={() => onToggle(type)}
className="rounded"
/>
{label}
</label>
</li>
))}
</ul>
</div>
);
}

62
src/lib/cycle.test.ts Normal file
View File

@@ -0,0 +1,62 @@
// ABOUTME: Unit tests for cycle phase calculation utilities.
// ABOUTME: Tests getCycleDay, getPhase, and phase limit functions.
import { describe, expect, it } from "vitest";
import { getCycleDay, getPhase, getPhaseLimit } from "./cycle";
describe("getCycleDay", () => {
it("returns 1 on the first day of the cycle", () => {
const lastPeriod = new Date("2025-01-01");
const currentDate = new Date("2025-01-01");
expect(getCycleDay(lastPeriod, 31, currentDate)).toBe(1);
});
it("returns correct day within cycle", () => {
const lastPeriod = new Date("2025-01-01");
const currentDate = new Date("2025-01-15");
expect(getCycleDay(lastPeriod, 31, currentDate)).toBe(15);
});
it("wraps around after cycle length", () => {
const lastPeriod = new Date("2025-01-01");
const currentDate = new Date("2025-02-01"); // 31 days later
expect(getCycleDay(lastPeriod, 31, currentDate)).toBe(1);
});
});
describe("getPhase", () => {
it("returns MENSTRUAL for days 1-3", () => {
expect(getPhase(1)).toBe("MENSTRUAL");
expect(getPhase(3)).toBe("MENSTRUAL");
});
it("returns FOLLICULAR for days 4-14", () => {
expect(getPhase(4)).toBe("FOLLICULAR");
expect(getPhase(14)).toBe("FOLLICULAR");
});
it("returns OVULATION for days 15-16", () => {
expect(getPhase(15)).toBe("OVULATION");
expect(getPhase(16)).toBe("OVULATION");
});
it("returns EARLY_LUTEAL for days 17-24", () => {
expect(getPhase(17)).toBe("EARLY_LUTEAL");
expect(getPhase(24)).toBe("EARLY_LUTEAL");
});
it("returns LATE_LUTEAL for days 25-31", () => {
expect(getPhase(25)).toBe("LATE_LUTEAL");
expect(getPhase(31)).toBe("LATE_LUTEAL");
});
});
describe("getPhaseLimit", () => {
it("returns correct weekly limits for each phase", () => {
expect(getPhaseLimit("MENSTRUAL")).toBe(30);
expect(getPhaseLimit("FOLLICULAR")).toBe(120);
expect(getPhaseLimit("OVULATION")).toBe(80);
expect(getPhaseLimit("EARLY_LUTEAL")).toBe(100);
expect(getPhaseLimit("LATE_LUTEAL")).toBe(50);
});
});

73
src/lib/cycle.ts Normal file
View File

@@ -0,0 +1,73 @@
// ABOUTME: Cycle phase calculation utilities.
// ABOUTME: Determines current cycle day and phase from last period date.
import type { CyclePhase, PhaseConfig } from "@/types";
export const PHASE_CONFIGS: PhaseConfig[] = [
{
name: "MENSTRUAL",
days: [1, 3],
weeklyLimit: 30,
dailyAvg: 10,
trainingType: "Gentle rebounding only",
},
{
name: "FOLLICULAR",
days: [4, 14],
weeklyLimit: 120,
dailyAvg: 17,
trainingType: "Strength + rebounding",
},
{
name: "OVULATION",
days: [15, 16],
weeklyLimit: 80,
dailyAvg: 40,
trainingType: "Peak performance",
},
{
name: "EARLY_LUTEAL",
days: [17, 24],
weeklyLimit: 100,
dailyAvg: 14,
trainingType: "Moderate training",
},
{
name: "LATE_LUTEAL",
days: [25, 31],
weeklyLimit: 50,
dailyAvg: 8,
trainingType: "Gentle rebounding ONLY",
},
];
export function getCycleDay(
lastPeriodDate: Date,
cycleLength: number,
currentDate: Date = new Date(),
): number {
const diffMs = currentDate.getTime() - lastPeriodDate.getTime();
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
return (diffDays % cycleLength) + 1;
}
export function getPhase(cycleDay: number): CyclePhase {
for (const config of PHASE_CONFIGS) {
if (cycleDay >= config.days[0] && cycleDay <= config.days[1]) {
return config.name;
}
}
// Default to late luteal for any days beyond 31
return "LATE_LUTEAL";
}
export function getPhaseConfig(phase: CyclePhase): PhaseConfig {
const config = PHASE_CONFIGS.find((c) => c.name === phase);
if (!config) {
throw new Error(`Unknown phase: ${phase}`);
}
return config;
}
export function getPhaseLimit(phase: CyclePhase): number {
return getPhaseConfig(phase).weeklyLimit;
}

View File

@@ -0,0 +1,64 @@
// ABOUTME: Training decision engine based on biometric and cycle data.
// ABOUTME: Implements priority-based rules for daily training recommendations.
import type { DailyData, Decision } from "@/types";
export function getTrainingDecision(data: DailyData): Decision {
const {
hrvStatus,
bbYesterdayLow,
phase,
weekIntensity,
phaseLimit,
bbCurrent,
} = data;
if (hrvStatus === "Unbalanced") {
return { status: "REST", reason: "HRV Unbalanced", icon: "🛑" };
}
if (bbYesterdayLow < 30) {
return { status: "REST", reason: "BB too depleted", icon: "🛑" };
}
if (phase === "LATE_LUTEAL") {
return {
status: "GENTLE",
reason: "Gentle rebounding only (10-15min)",
icon: "🟡",
};
}
if (phase === "MENSTRUAL") {
return {
status: "GENTLE",
reason: "Gentle rebounding only (10min)",
icon: "🟡",
};
}
if (weekIntensity >= phaseLimit) {
return {
status: "REST",
reason: "WEEKLY LIMIT REACHED - Rest",
icon: "🛑",
};
}
if (bbCurrent < 75) {
return {
status: "LIGHT",
reason: "Light activity only - BB not recovered",
icon: "🟡",
};
}
if (bbCurrent < 85) {
return { status: "REDUCED", reason: "Reduce intensity 25%", icon: "🟡" };
}
return {
status: "TRAIN",
reason: "OK to train - follow phase plan",
icon: "✅",
};
}

83
src/lib/email.ts Normal file
View File

@@ -0,0 +1,83 @@
// ABOUTME: Email sending utilities using Resend.
// ABOUTME: Sends daily training notifications and period confirmation emails.
import { Resend } from "resend";
const resend = new Resend(process.env.RESEND_API_KEY);
const EMAIL_FROM = process.env.EMAIL_FROM || "phaseflow@example.com";
export interface DailyEmailData {
to: string;
cycleDay: number;
phase: string;
decision: {
status: string;
reason: string;
icon: string;
};
bodyBatteryCurrent: number | null;
bodyBatteryYesterdayLow: number | null;
hrvStatus: string;
weekIntensity: number;
phaseLimit: number;
remainingMinutes: number;
seeds: string;
carbRange: string;
ketoGuidance: string;
}
export async function sendDailyEmail(data: DailyEmailData): Promise<void> {
const subject = `Today's Training: ${data.decision.icon} ${data.decision.status}`;
const body = `Good morning!
📅 CYCLE DAY: ${data.cycleDay} (${data.phase})
💪 TODAY'S PLAN:
${data.decision.icon} ${data.decision.reason}
📊 YOUR DATA:
• Body Battery Now: ${data.bodyBatteryCurrent ?? "N/A"}
• Yesterday's Low: ${data.bodyBatteryYesterdayLow ?? "N/A"}
• HRV Status: ${data.hrvStatus}
• Week Intensity: ${data.weekIntensity} / ${data.phaseLimit} minutes
• Remaining: ${data.remainingMinutes} minutes
🌱 SEEDS: ${data.seeds}
🍽️ MACROS: ${data.carbRange}
🥑 KETO: ${data.ketoGuidance}
---
Auto-generated by PhaseFlow`;
await resend.emails.send({
from: EMAIL_FROM,
to: data.to,
subject,
text: body,
});
}
export async function sendPeriodConfirmationEmail(
to: string,
lastPeriodDate: Date,
cycleLength: number,
): Promise<void> {
const subject = "🔵 Period Tracking Updated";
const body = `Your cycle has been reset. Last period: ${lastPeriodDate.toLocaleDateString()}
Phase calendar updated for next ${cycleLength} days.
Your calendar will update automatically within 24 hours.
---
Auto-generated by PhaseFlow`;
await resend.emails.send({
from: EMAIL_FROM,
to,
subject,
text: body,
});
}

49
src/lib/encryption.ts Normal file
View File

@@ -0,0 +1,49 @@
// ABOUTME: AES-256 encryption utilities for sensitive data like Garmin tokens.
// ABOUTME: Provides encrypt/decrypt functions using environment-based keys.
import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
const ALGORITHM = "aes-256-gcm";
const IV_LENGTH = 16;
function getEncryptionKey(): Buffer {
const key = process.env.ENCRYPTION_KEY;
if (!key) {
throw new Error("ENCRYPTION_KEY environment variable is required");
}
// Ensure key is exactly 32 bytes for AES-256
return Buffer.from(key.padEnd(32, "0").slice(0, 32));
}
export function encrypt(plaintext: string): string {
const key = getEncryptionKey();
const iv = randomBytes(IV_LENGTH);
const cipher = createCipheriv(ALGORITHM, key, iv);
let encrypted = cipher.update(plaintext, "utf8", "hex");
encrypted += cipher.final("hex");
const authTag = cipher.getAuthTag();
// Format: iv:authTag:encrypted
return `${iv.toString("hex")}:${authTag.toString("hex")}:${encrypted}`;
}
export function decrypt(ciphertext: string): string {
const key = getEncryptionKey();
const [ivHex, authTagHex, encrypted] = ciphertext.split(":");
if (!ivHex || !authTagHex || !encrypted) {
throw new Error("Invalid ciphertext format");
}
const iv = Buffer.from(ivHex, "hex");
const authTag = Buffer.from(authTagHex, "hex");
const decipher = createDecipheriv(ALGORITHM, key, iv);
decipher.setAuthTag(authTag);
let decrypted = decipher.update(encrypted, "hex", "utf8");
decrypted += decipher.final("utf8");
return decrypted;
}

39
src/lib/garmin.ts Normal file
View File

@@ -0,0 +1,39 @@
// ABOUTME: Garmin Connect API client using stored OAuth tokens.
// ABOUTME: Fetches body battery, HRV, and intensity minutes from Garmin.
import type { GarminTokens } from "@/types";
const GARMIN_BASE_URL = "https://connect.garmin.com/modern/proxy";
interface GarminApiOptions {
oauth2Token: string;
}
export async function fetchGarminData(
endpoint: string,
options: GarminApiOptions,
): Promise<unknown> {
const response = await fetch(`${GARMIN_BASE_URL}${endpoint}`, {
headers: {
Authorization: `Bearer ${options.oauth2Token}`,
NK: "NT",
},
});
if (!response.ok) {
throw new Error(`Garmin API error: ${response.status}`);
}
return response.json();
}
export function isTokenExpired(tokens: GarminTokens): boolean {
const expiresAt = new Date(tokens.expires_at);
return expiresAt <= new Date();
}
export function daysUntilExpiry(tokens: GarminTokens): number {
const expiresAt = new Date(tokens.expires_at);
const now = new Date();
const diffMs = expiresAt.getTime() - now.getTime();
return Math.floor(diffMs / (1000 * 60 * 60 * 24));
}

97
src/lib/ics.ts Normal file
View File

@@ -0,0 +1,97 @@
// ABOUTME: ICS calendar feed generation for cycle phase events.
// ABOUTME: Creates subscribable calendar with phase blocks and warnings.
import { createEvents, type EventAttributes } from "ics";
import { getCycleDay, getPhase, PHASE_CONFIGS } from "./cycle";
const PHASE_EMOJIS: Record<string, string> = {
MENSTRUAL: "🔵",
FOLLICULAR: "🟢",
OVULATION: "🟣",
EARLY_LUTEAL: "🟡",
LATE_LUTEAL: "🔴",
};
interface IcsGeneratorOptions {
lastPeriodDate: Date;
cycleLength: number;
monthsAhead?: number;
}
export function generateIcsFeed(options: IcsGeneratorOptions): string {
const { lastPeriodDate, cycleLength, monthsAhead = 3 } = options;
const events: EventAttributes[] = [];
const endDate = new Date();
endDate.setMonth(endDate.getMonth() + monthsAhead);
const currentDate = new Date(lastPeriodDate);
let currentPhase = getPhase(
getCycleDay(lastPeriodDate, cycleLength, currentDate),
);
let phaseStartDate = new Date(currentDate);
while (currentDate <= endDate) {
const cycleDay = getCycleDay(lastPeriodDate, cycleLength, currentDate);
const phase = getPhase(cycleDay);
// Add warning events
if (cycleDay === 22) {
events.push({
start: dateToArray(currentDate),
end: dateToArray(currentDate),
title: "⚠️ Late Luteal Phase Starts in 3 Days",
description: "Begin reducing training intensity",
});
}
if (cycleDay === 25) {
events.push({
start: dateToArray(currentDate),
end: dateToArray(currentDate),
title: "🔴 CRITICAL PHASE - Gentle Rebounding Only!",
description: "Late luteal phase - protect your cycle",
});
}
// Track phase changes
if (phase !== currentPhase) {
// Close previous phase event
events.push(createPhaseEvent(currentPhase, phaseStartDate, currentDate));
currentPhase = phase;
phaseStartDate = new Date(currentDate);
}
currentDate.setDate(currentDate.getDate() + 1);
}
// Close final phase
events.push(createPhaseEvent(currentPhase, phaseStartDate, currentDate));
const { value, error } = createEvents(events);
if (error) {
throw new Error(`ICS generation error: ${error}`);
}
return value || "";
}
function createPhaseEvent(
phase: string,
startDate: Date,
endDate: Date,
): EventAttributes {
const config = PHASE_CONFIGS.find((c) => c.name === phase);
const emoji = PHASE_EMOJIS[phase] || "📅";
return {
start: dateToArray(startDate),
end: dateToArray(endDate),
title: `${emoji} ${phase.replace("_", " ")}`,
description: config?.trainingType || "",
};
}
function dateToArray(date: Date): [number, number, number, number, number] {
return [date.getFullYear(), date.getMonth() + 1, date.getDate(), 0, 0];
}

44
src/lib/nutrition.ts Normal file
View File

@@ -0,0 +1,44 @@
// ABOUTME: Nutrition guidance based on cycle day.
// ABOUTME: Provides seed cycling and macro recommendations by phase.
import type { NutritionGuidance } from "@/types";
export function getNutritionGuidance(cycleDay: number): NutritionGuidance {
// Seed cycling
const seeds =
cycleDay <= 14
? "Flax (1-2 tbsp) + Pumpkin (1-2 tbsp)"
: "Sesame (1-2 tbsp) + Sunflower (1-2 tbsp)";
// Macro guidance by day range
let carbRange: string;
let ketoGuidance: string;
if (cycleDay >= 1 && cycleDay <= 3) {
carbRange = "100-150g";
ketoGuidance = "No - body needs carbs during menstruation";
} else if (cycleDay >= 4 && cycleDay <= 6) {
carbRange = "75-100g";
ketoGuidance = "No - transition phase";
} else if (cycleDay >= 7 && cycleDay <= 14) {
carbRange = "20-100g";
ketoGuidance = "OPTIONAL - optimal keto window";
} else if (cycleDay >= 15 && cycleDay <= 16) {
carbRange = "100-150g";
ketoGuidance = "No - exit keto, need carbs for ovulation";
} else if (cycleDay >= 17 && cycleDay <= 24) {
carbRange = "75-125g";
ketoGuidance = "No - progesterone needs carbs";
} else {
carbRange = "100-150g+";
ketoGuidance = "NEVER - mood/hormones need carbs for PMS";
}
return { seeds, carbRange, ketoGuidance };
}
export function getSeedSwitchAlert(cycleDay: number): string | null {
if (cycleDay === 15) {
return "🌱 SWITCH TODAY! Start Sesame + Sunflower";
}
return null;
}

10
src/lib/pocketbase.ts Normal file
View File

@@ -0,0 +1,10 @@
// ABOUTME: PocketBase client initialization and utilities.
// ABOUTME: Provides typed access to the PocketBase backend for auth and data.
import PocketBase from "pocketbase";
const POCKETBASE_URL = process.env.POCKETBASE_URL || "http://localhost:8090";
export const pb = new PocketBase(POCKETBASE_URL);
// Disable auto-cancellation for server-side usage
pb.autoCancellation(false);

8
src/lib/utils.ts Normal file
View File

@@ -0,0 +1,8 @@
// ABOUTME: Utility functions for className merging with Tailwind CSS.
// ABOUTME: Provides cn() helper for combining conditional class names.
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

103
src/types/index.ts Normal file
View File

@@ -0,0 +1,103 @@
// ABOUTME: Central type definitions for the PhaseFlow application.
// ABOUTME: Contains interfaces for users, daily logs, decisions, and cycle data.
export type CyclePhase =
| "MENSTRUAL"
| "FOLLICULAR"
| "OVULATION"
| "EARLY_LUTEAL"
| "LATE_LUTEAL";
export type HrvStatus = "Balanced" | "Unbalanced" | "Unknown";
export type DecisionStatus = "REST" | "GENTLE" | "LIGHT" | "REDUCED" | "TRAIN";
export type OverrideType = "flare" | "stress" | "sleep" | "pms";
export interface User {
id: string;
email: string;
// Garmin
garminConnected: boolean;
garminOauth1Token: string; // encrypted JSON
garminOauth2Token: string; // encrypted JSON
garminTokenExpiresAt: Date;
// Calendar
calendarToken: string; // random secret for ICS URL
// Cycle
lastPeriodDate: Date;
cycleLength: number; // default: 31
// Preferences
notificationTime: string; // "07:00"
timezone: string;
// Overrides
activeOverrides: OverrideType[];
created: Date;
updated: Date;
}
export interface DailyLog {
id: string;
user: string; // relation
date: Date;
cycleDay: number;
phase: CyclePhase;
bodyBatteryCurrent: number | null;
bodyBatteryYesterdayLow: number | null;
hrvStatus: HrvStatus;
weekIntensityMinutes: number;
phaseLimit: number;
remainingMinutes: number;
trainingDecision: string;
decisionReason: string;
notificationSentAt: Date | null;
created: Date;
}
export interface PeriodLog {
id: string;
user: string; // relation
startDate: Date;
created: Date;
}
export interface Decision {
status: DecisionStatus;
reason: string;
icon: string;
}
export interface DailyData {
hrvStatus: HrvStatus;
bbYesterdayLow: number;
phase: CyclePhase;
weekIntensity: number;
phaseLimit: number;
bbCurrent: number;
}
export interface GarminTokens {
oauth1: string;
oauth2: string;
expires_at: string;
}
export interface PhaseConfig {
name: CyclePhase;
days: [number, number]; // start and end day
weeklyLimit: number;
dailyAvg: number;
trainingType: string;
}
export interface NutritionGuidance {
seeds: string;
carbRange: string;
ketoGuidance: string;
}

34
tsconfig.json Normal file
View File

@@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}

12
vitest.config.ts Normal file
View File

@@ -0,0 +1,12 @@
// ABOUTME: Vitest configuration for unit and integration testing.
// ABOUTME: Configures jsdom environment for React component testing.
import react from "@vitejs/plugin-react";
import { defineConfig } from "vitest/config";
export default defineConfig({
plugins: [react()],
test: {
environment: "jsdom",
include: ["src/**/*.test.{ts,tsx}"],
},
});