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>
29 KiB
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 |
| 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:
- User runs a provided Python script locally (or via container exec):
# One-time setup script included with PhaseFlow python3 garmin_auth.py - Script uses
garthto authenticate (prompts for MFA code) - Script outputs OAuth1 + OAuth2 tokens as JSON
- User pastes tokens into PhaseFlow settings page
- App stores tokens encrypted in PocketBase
- 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):
// 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):
#!/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):
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 usertoken: random secret token for security (regeneratable)
ICS Content:
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:
- Click "Period Started" button
- Confirm date (defaults to today, can select past date)
- System recalculates all phase dates
- ICS feed automatically reflects new dates
- 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)
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)
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)
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
# 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)
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