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>
856 lines
29 KiB
Markdown
856 lines
29 KiB
Markdown
# 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
|
|
```
|