# 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; } ``` ### Data Retention All logs (DailyLog, PeriodLog) are retained indefinitely. No automatic deletion or archival. SQLite handles large datasets efficiently for single-user scenarios. --- ## API Routes ``` # Auth (PocketBase handles via OIDC) GET /api/auth/login - Initiate OIDC flow GET /api/auth/callback - OIDC callback handler 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 (cursor-based pagination) Query params: ?cursor=&limit=20 # 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 # Observability GET /api/health - Health check for monitoring GET /metrics - Prometheus metrics endpoint # 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 = <