diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md new file mode 100644 index 0000000..a1b7d07 --- /dev/null +++ b/IMPLEMENTATION_PLAN.md @@ -0,0 +1,496 @@ +# PhaseFlow Implementation Plan + +This file is maintained by Ralph. Run `./ralph-sandbox.sh plan 3` to generate tasks. + +## Current State Summary + +### Library Implementation +| File | Status | Gap Analysis | +|------|--------|--------------| +| `cycle.ts` | **COMPLETE** | 9 tests covering all functions, production-ready | +| `nutrition.ts` | **Complete** | getNutritionGuidance, getSeedSwitchAlert implemented. **MISSING: tests** | +| `email.ts` | **Complete** | sendDailyEmail, sendPeriodConfirmationEmail implemented. **MISSING: tests** | +| `ics.ts` | **Complete** | generateIcsFeed implemented (90 days of phase events). **MISSING: tests** | +| `encryption.ts` | **Complete** | AES-256-GCM encrypt/decrypt implemented. **MISSING: tests** | +| `decision-engine.ts` | **COMPLETE** | 8 priority rules + override handling with `getDecisionWithOverrides()`, 24 tests | +| `garmin.ts` | **Minimal (~30%)** | Has fetchGarminData, isTokenExpired, daysUntilExpiry. **MISSING: fetchHrvStatus, fetchBodyBattery, fetchIntensityMinutes** | +| `pocketbase.ts` | **Basic** | Has pb client only. **MISSING: getCurrentUser(), isAuthenticated(), loadAuthFromCookies()** | + +### Missing Infrastructure Files (CONFIRMED NOT EXIST) +- `src/lib/auth-middleware.ts` - Does NOT exist, needs creation +- `src/app/middleware.ts` - Does NOT exist, needs creation + +### API Routes (12 total) +| Route | Status | Notes | +|-------|--------|-------| +| GET /api/user | 501 | Returns Not Implemented | +| PATCH /api/user | 501 | Returns Not Implemented | +| POST /api/cycle/period | 501 | Returns Not Implemented | +| GET /api/cycle/current | 501 | Returns Not Implemented | +| GET /api/today | 501 | Returns Not Implemented | +| POST /api/overrides | 501 | Returns Not Implemented | +| DELETE /api/overrides | 501 | Returns Not Implemented | +| POST /api/garmin/tokens | 501 | Returns Not Implemented | +| DELETE /api/garmin/tokens | 501 | Returns Not Implemented | +| GET /api/garmin/status | 501 | Returns Not Implemented | +| GET /api/calendar/[userId]/[token].ics | 501 | Has param extraction, core logic TODO | +| POST /api/calendar/regenerate-token | 501 | Returns Not Implemented | +| POST /api/cron/garmin-sync | 501 | Has CRON_SECRET auth check, core logic TODO | +| POST /api/cron/notifications | 501 | Has CRON_SECRET auth check, core logic TODO | + +### Pages (7 total, ALL placeholders) +| Page | Status | Notes | +|------|--------|-------| +| Dashboard (`/`) | Placeholder | Needs real data integration | +| Login (`/login`) | Placeholder | Needs PocketBase auth integration | +| Settings (`/settings`) | Placeholder | Needs form implementation | +| Settings/Garmin (`/settings/garmin`) | Placeholder | Needs token management UI | +| Calendar (`/calendar`) | Placeholder | Needs MonthView integration | +| History (`/history`) | Placeholder | Needs list/pagination implementation | +| Plan (`/plan`) | Placeholder | Needs phase details display | + +### Components +| Component | Status | Notes | +|-----------|--------|-------| +| `DecisionCard` | **COMPLETE** | Displays status, icon, reason | +| `DataPanel` | **COMPLETE** | Shows BB, HRV, intensity data | +| `NutritionPanel` | **COMPLETE** | Shows seeds, carbs, keto guidance | +| `OverrideToggles` | **COMPLETE** | Toggle buttons with callbacks | +| `DayCell` | **COMPLETE** | Phase-colored day with click handler | +| `MiniCalendar` | **Partial (~30%)** | Has header only, **MISSING: calendar grid** | +| `MonthView` | **Partial (~30%)** | Has header only, **MISSING: calendar grid + DayCell integration** | + +### Test Coverage +| Test File | Status | +|-----------|--------| +| `src/lib/cycle.test.ts` | **EXISTS** - 9 tests | +| `src/lib/decision-engine.test.ts` | **EXISTS** - 24 tests (8 algorithmic rules + 16 override scenarios) | +| `src/lib/nutrition.test.ts` | **MISSING** | +| `src/lib/email.test.ts` | **MISSING** | +| `src/lib/ics.test.ts` | **MISSING** | +| `src/lib/encryption.test.ts` | **MISSING** | +| `src/lib/garmin.test.ts` | **MISSING** | +| `src/lib/pocketbase.test.ts` | **MISSING** | +| API route tests | **NONE** | +| E2E tests | **NONE** | + +### Critical Business Rules (from Spec) +1. **Override Priority:** flare > stress > sleep > pms (must be enforced in order) +2. **HRV Unbalanced:** ALWAYS forces REST (highest algorithmic priority, non-overridable) +3. **Phase Limits:** Strictly enforced per phase configuration +4. **Token Expiration Warnings:** Must send email at 14 days and 7 days before expiry +5. **ICS Feed:** Generates 90 days of phase events for calendar subscription + +--- + +## P0: Critical Blockers + +These must be completed first - nothing else works without them. + +### P0.1: PocketBase Auth Helpers +- [ ] Add authentication utilities to pocketbase.ts +- **Files:** + - `src/lib/pocketbase.ts` - Add `getCurrentUser()`, `isAuthenticated()`, `loadAuthFromCookies()` +- **Tests:** + - `src/lib/pocketbase.test.ts` - Test auth state management, cookie loading +- **Why:** Every protected route and page depends on these helpers +- **Blocking:** P0.2, P0.4, P1.1-P1.7, P2.2-P2.13 + +### P0.2: Auth Middleware for API Routes +- [ ] Create reusable auth middleware for protected API endpoints +- **Files:** + - `src/lib/auth-middleware.ts` - **CREATE** `withAuth()` wrapper for route handlers + - `src/app/middleware.ts` - **CREATE** Next.js middleware for page protection +- **Tests:** + - `src/lib/auth-middleware.test.ts` - Test unauthorized rejection, user context passing +- **Why:** All API routes except `/api/calendar/[userId]/[token].ics` and `/api/cron/*` require auth +- **Depends On:** P0.1 +- **Blocking:** P0.4, P1.1-P1.5 + +### P0.3: Decision Engine Override Handling ✅ COMPLETE +- [x] Add override priority logic before algorithmic decision +- **Files:** + - `src/lib/decision-engine.ts` - Added `getDecisionWithOverrides(data, overrides)` function +- **Tests:** + - `src/lib/decision-engine.test.ts` - 24 tests covering all 8 priority rules + override scenarios +- **Override Priority (enforced in this order):** + 1. `flare` - Always forces REST + 2. `stress` - Forces REST + 3. `sleep` - Forces REST + 4. `pms` - Forces REST +- **Why:** Overrides are core to the user experience per spec +- **Blocking:** P1.4, P1.5 + +### P0.4: GET /api/user Implementation +- [ ] Return authenticated user profile +- **Files:** + - `src/app/api/user/route.ts` - Implement GET handler with auth middleware +- **Tests:** + - `src/app/api/user/route.test.ts` - Test auth required, correct response shape +- **Why:** Dashboard and all pages need user context +- **Depends On:** P0.1, P0.2 +- **Blocking:** P1.7, P2.9, P2.10 + +--- + +## P1: Core Functionality + +Minimum viable product - app can be used for daily decisions. + +### P1.1: PATCH /api/user Implementation +- [ ] Allow profile updates (cycleLength, notificationTime, timezone) +- **Files:** + - `src/app/api/user/route.ts` - Implement PATCH handler with validation +- **Tests:** + - `src/app/api/user/route.test.ts` - Test field validation, persistence +- **Why:** Users need to configure their cycle and preferences +- **Depends On:** P0.1, P0.2 + +### P1.2: POST /api/cycle/period Implementation +- [ ] Log period start date, update user record, create PeriodLog +- **Files:** + - `src/app/api/cycle/period/route.ts` - Implement POST handler +- **Tests:** + - `src/app/api/cycle/period/route.test.ts` - Test date validation, user update, log creation +- **Why:** Cycle tracking is the foundation of all recommendations +- **Depends On:** P0.1, P0.2 + +### P1.3: GET /api/cycle/current Implementation +- [ ] Return current cycle day, phase, and phase config +- **Files:** + - `src/app/api/cycle/current/route.ts` - Implement GET using cycle.ts utilities +- **Tests:** + - `src/app/api/cycle/current/route.test.ts` - Test phase calculation, config response +- **Why:** Dashboard needs this for display +- **Depends On:** P0.1, P0.2, P1.2 + +### P1.4: GET /api/today Implementation +- [ ] Return complete daily snapshot with decision, biometrics, nutrition +- **Files:** + - `src/app/api/today/route.ts` - Implement GET aggregating all data sources +- **Tests:** + - `src/app/api/today/route.test.ts` - Test decision computation, data assembly +- **Why:** This is THE core API for the dashboard +- **Depends On:** P0.1, P0.2, P0.3, P1.3 + +### P1.5: POST/DELETE /api/overrides Implementation +- [ ] Toggle override flags on user record +- **Files:** + - `src/app/api/overrides/route.ts` - Implement POST (add) and DELETE (remove) handlers +- **Tests:** + - `src/app/api/overrides/route.test.ts` - Test override types, persistence, validation +- **Override Types:** flare, stress, sleep, pms +- **Why:** Emergency overrides are critical for flare days +- **Depends On:** P0.1, P0.2, P0.3 + +### P1.6: Login Page Implementation +- [ ] Functional login form with PocketBase auth +- **Files:** + - `src/app/login/page.tsx` - Form with email/password, error handling, redirect +- **Tests:** + - E2E test: valid login redirects to dashboard, invalid shows error +- **Why:** Users need to authenticate to use the app +- **Depends On:** P0.1 + +### P1.7: Dashboard Page Implementation +- [ ] Wire up dashboard with real data from /api/today +- **Files:** + - `src/app/page.tsx` - Fetch data, render DecisionCard, DataPanel, NutritionPanel, OverrideToggles +- **Tests:** + - E2E test: dashboard loads data, override toggles work +- **Why:** This is the main user interface +- **Depends On:** P0.4, P1.3, P1.4, P1.5 +- **Note:** Components (DecisionCard, DataPanel, NutritionPanel, OverrideToggles) are already **COMPLETE** + +--- + +## P2: Important Features + +Full feature set for production use. + +### P2.1: Garmin Data Fetching Functions +- [ ] Add specific fetchers for HRV, Body Battery, Intensity Minutes +- **Files:** + - `src/lib/garmin.ts` - Add `fetchHrvStatus()`, `fetchBodyBattery()`, `fetchIntensityMinutes()` +- **Tests:** + - `src/lib/garmin.test.ts` - Test API calls, response parsing, error handling +- **Why:** Real biometric data is required for accurate decisions +- **Note:** Currently only has generic fetchGarminData, isTokenExpired, daysUntilExpiry + +### P2.2: POST/DELETE /api/garmin/tokens Implementation +- [ ] Store encrypted Garmin OAuth tokens +- **Files:** + - `src/app/api/garmin/tokens/route.ts` - Implement with encryption.ts +- **Tests:** + - `src/app/api/garmin/tokens/route.test.ts` - Test encryption, validation, storage +- **Why:** Users need to connect their Garmin accounts +- **Depends On:** P0.1, P0.2 + +### P2.3: GET /api/garmin/status Implementation +- [ ] Return Garmin connection status and days until expiry +- **Files:** + - `src/app/api/garmin/status/route.ts` - Implement status check +- **Tests:** + - `src/app/api/garmin/status/route.test.ts` - Test connected/disconnected states, expiry calc +- **Why:** Users need visibility into their Garmin connection +- **Depends On:** P0.1, P0.2, P2.1 + +### P2.4: POST /api/cron/garmin-sync Implementation +- [ ] Daily sync of all Garmin data for all users +- **Files:** + - `src/app/api/cron/garmin-sync/route.ts` - Iterate users, fetch data, store DailyLog +- **Tests:** + - `src/app/api/cron/garmin-sync/route.test.ts` - Test auth, user iteration, data persistence +- **Why:** Automated data sync is required for morning notifications +- **Depends On:** P2.1, P2.2 +- **Note:** Route exists with CRON_SECRET auth check, needs core logic + +### P2.5: POST /api/cron/notifications Implementation +- [ ] Send daily email notifications at user's preferred time +- **Files:** + - `src/app/api/cron/notifications/route.ts` - Find users by hour, compute decision, send email +- **Tests:** + - `src/app/api/cron/notifications/route.test.ts` - Test timezone handling, duplicate prevention +- **Why:** Email notifications are a key feature per spec +- **Depends On:** P2.4 +- **Note:** Route exists with CRON_SECRET auth check, needs core logic + +### P2.6: GET /api/calendar/[userId]/[token].ics Implementation +- [ ] Return ICS feed for calendar subscription +- **Files:** + - `src/app/api/calendar/[userId]/[token].ics/route.ts` - Validate token, generate ICS +- **Tests:** + - Integration test: valid token returns ICS, invalid returns 401 +- **Why:** Calendar integration for external apps +- **Note:** Route has param extraction, needs ICS generation (90 days of events per spec) + +### P2.7: POST /api/calendar/regenerate-token Implementation +- [ ] Generate new calendar token +- **Files:** + - `src/app/api/calendar/regenerate-token/route.ts` - Create random token, update user +- **Tests:** + - `src/app/api/calendar/regenerate-token/route.test.ts` - Test token uniqueness, old URL invalidation +- **Why:** Security feature for calendar URLs +- **Depends On:** P0.1, P0.2 + +### P2.8: GET /api/history Implementation +- [ ] Return paginated historical daily logs +- **Files:** + - `src/app/api/history/route.ts` - Query DailyLog with pagination +- **Tests:** + - `src/app/api/history/route.test.ts` - Test pagination, date filtering +- **Why:** Users want to see their training history +- **Depends On:** P0.1, P0.2 + +### P2.9: Settings Page Implementation +- [ ] User profile management UI +- **Files:** + - `src/app/settings/page.tsx` - Form for cycleLength, notificationTime, timezone +- **Tests:** + - E2E test: settings update and persist +- **Why:** Users need to configure their preferences +- **Depends On:** P0.4, P1.1 + +### P2.10: Settings/Garmin Page Implementation +- [ ] Garmin connection management UI +- **Files:** + - `src/app/settings/garmin/page.tsx` - Token input form, connection status, disconnect button +- **Tests:** + - E2E test: connect flow, disconnect flow +- **Why:** Users need to manage their Garmin connection +- **Depends On:** P0.4, P2.2, P2.3 + +### P2.11: Calendar Page Implementation +- [ ] In-app calendar with phase visualization +- **Files:** + - `src/app/calendar/page.tsx` - Month view with navigation + - `src/components/calendar/month-view.tsx` - **Complete calendar grid using DayCell** +- **Tests:** + - E2E test: navigation works, phases displayed correctly +- **Why:** Planning ahead is a key user need +- **Depends On:** P2.6 +- **Note:** DayCell is **COMPLETE**, MonthView needs grid implementation (~70% remaining) + +### P2.12: History Page Implementation +- [ ] View past training decisions and data +- **Files:** + - `src/app/history/page.tsx` - List view of DailyLogs with pagination +- **Tests:** + - E2E test: history loads, pagination works +- **Why:** Users want to review their training history +- **Depends On:** P2.8 + +### P2.13: Plan Page Implementation +- [ ] Phase-specific training plan view +- **Files:** + - `src/app/plan/page.tsx` - Current phase details, upcoming phases, limits +- **Tests:** + - E2E test: correct phase info displayed +- **Why:** Users want detailed training guidance +- **Depends On:** P0.4, P1.3 + +### P2.14: Mini Calendar Component +- [ ] Dashboard overview calendar +- **Files:** + - `src/components/dashboard/mini-calendar.tsx` - **Complete calendar grid with phase colors** +- **Tests:** + - Component test: renders current month, highlights today +- **Why:** Quick visual reference on dashboard +- **Note:** Component exists with header only, needs calendar grid (~70% remaining) + +--- + +## P3: Polish and Quality + +Testing, error handling, and refinements. + +### P3.1: Decision Engine Tests ✅ COMPLETE +- [x] Comprehensive unit tests for all decision paths +- **Files:** + - `src/lib/decision-engine.test.ts` - All 8 priority rules, override combinations (24 tests) +- **Test Cases Covered:** + - HRV Unbalanced always forces REST (highest algorithmic priority) + - Override priority: flare > stress > sleep > pms + - Phase limits strictly enforced + - All override bypass and fallthrough scenarios +- **Why:** Critical logic is now fully tested + +### P3.2: Nutrition Tests +- [ ] Unit tests for nutrition guidance +- **Files:** + - `src/lib/nutrition.test.ts` - Seed cycling, carb ranges, keto guidance by day +- **Why:** Nutrition advice must be accurate + +### P3.3: Email Tests +- [ ] Unit tests for email composition +- **Files:** + - `src/lib/email.test.ts` - Email content, subject lines +- **Why:** Email formatting must be correct + +### P3.4: ICS Tests +- [ ] Unit tests for calendar generation +- **Files:** + - `src/lib/ics.test.ts` - ICS format validation, 90-day event generation +- **Why:** Calendar integration must work with external apps + +### P3.5: Encryption Tests +- [ ] Unit tests for encrypt/decrypt round-trip +- **Files:** + - `src/lib/encryption.test.ts` - Round-trip, error handling +- **Why:** Token security is critical + +### P3.6: Garmin Tests +- [ ] Unit tests for Garmin API interactions +- **Files:** + - `src/lib/garmin.test.ts` - API calls, error handling, token expiry +- **Why:** External API integration must be robust + +### P3.7: Error Handling Improvements +- [ ] Add consistent error responses across all API routes +- **Files:** + - All route files - Standardize error format, add logging +- **Why:** Better debugging and user experience + +### P3.8: Loading States +- [ ] Add loading indicators to all pages +- **Files:** + - All page files - Add loading.tsx or Suspense boundaries +- **Why:** Better perceived performance + +### P3.9: Token Expiration Warnings +- [ ] Email warnings at 14 and 7 days before Garmin token expiry +- **Files:** + - `src/lib/email.ts` - Add `sendTokenExpirationWarning()` + - `src/app/api/cron/garmin-sync/route.ts` - Check expiry, trigger warnings +- **Tests:** + - Test warning triggers at exactly 14 days and 7 days +- **Why:** Users need time to refresh tokens (per spec requirement) + +### P3.10: E2E Test Suite +- [ ] Comprehensive end-to-end tests +- **Files:** + - `tests/e2e/*.spec.ts` - Full user flows +- **Test Scenarios:** + - Login flow + - Period logging and phase calculation + - Override toggle functionality + - Settings update flow + - Garmin connection flow + - Calendar subscription +- **Why:** Confidence in production deployment + +--- + +## Implementation Order + +``` +P0.1 PocketBase Auth ──┬──> P0.2 Auth Middleware ──> P0.4 GET /api/user + │ +P0.3 Override Logic ───┴──> P1.4 GET /api/today ──> P1.7 Dashboard + +P1.1 PATCH /api/user ────> P2.9 Settings Page +P1.2 POST period ────────> P1.3 GET current ────> P1.7 Dashboard +P1.5 Overrides API ──────> P1.7 Dashboard +P1.6 Login Page + +P2.1 Garmin fetchers ──> P2.2 Garmin tokens ──> P2.4 Cron sync ──> P2.5 Notifications + │ + └──> P3.9 Token Warnings +P2.3 Garmin status ────> P2.10 Garmin settings + +P2.6 ICS endpoint ─────> P2.11 Calendar page +P2.7 Regen token + +P2.8 History API ──────> P2.12 History page +P2.13 Plan page +P2.14 Mini calendar +``` + +### Dependency Summary + +| Task | Blocked By | Blocks | +|------|------------|--------| +| P0.1 | - | P0.2, P0.4, P1.1-P1.6, P2.2-P2.3, P2.7-P2.8 | +| P0.2 | P0.1 | P0.4, P1.1-P1.5, P2.2-P2.3, P2.7-P2.8 | +| P0.3 | - | P1.4, P1.5 | +| P0.4 | P0.1, P0.2 | P1.7, P2.9, P2.10, P2.13 | + +--- + +## Completed + +### Library +- [x] **cycle.ts** - Complete with 9 tests (`getCycleDay`, `getPhase`, `getPhaseConfig`, `getPhaseLimit`) +- [x] **decision-engine.ts** - Complete with 24 tests (`getTrainingDecision` + `getDecisionWithOverrides`) + +### Components +- [x] **DecisionCard** - Displays decision status, icon, and reason +- [x] **DataPanel** - Shows body battery, HRV, intensity data +- [x] **NutritionPanel** - Shows seeds, carbs, keto guidance +- [x] **OverrideToggles** - Toggle buttons for flare/stress/sleep/pms +- [x] **DayCell** - Phase-colored calendar day cell with click handler + +--- + +## Discovered Issues + +*Bugs and inconsistencies found during implementation* + +- [ ] `src/lib/auth-middleware.ts` does not exist - must be created in P0.2 +- [ ] `src/app/middleware.ts` does not exist - must be created in P0.2 +- [ ] `garmin.ts` is only ~30% complete - missing specific biometric fetchers +- [ ] `pocketbase.ts` missing all auth helper functions + +--- + +## Notes + +1. **TDD Approach:** Each implementation task should follow TDD - write failing tests first, then implement +2. **Auth First:** P0 items unlock all other work; prioritize ruthlessly +3. **Incremental Delivery:** P1 completion = usable app without Garmin (manual data entry fallback) +4. **P2 Completion:** Full feature set with automation +5. **P3:** Quality and polish for production confidence +6. **Component Reuse:** Dashboard components are complete and can be used directly in P1.7 +7. **HRV Rule:** HRV Unbalanced status ALWAYS forces REST - this is the highest algorithmic priority and cannot be overridden by manual toggles +8. **Override Order:** When multiple overrides are active, apply in order: flare > stress > sleep > pms +9. **Token Warnings:** Per spec, warnings must be sent at exactly 14 days and 7 days before expiry diff --git a/src/lib/decision-engine.test.ts b/src/lib/decision-engine.test.ts new file mode 100644 index 0000000..fc8ee9b --- /dev/null +++ b/src/lib/decision-engine.test.ts @@ -0,0 +1,242 @@ +// ABOUTME: Unit tests for the training decision engine. +// ABOUTME: Tests all 8 priority rules and 4 override scenarios. +import { describe, expect, it } from "vitest"; + +import type { DailyData, OverrideType } from "@/types"; +import { + getDecisionWithOverrides, + getTrainingDecision, +} from "./decision-engine"; + +// Helper to create baseline "healthy" data where TRAIN would be the decision +function createHealthyData(): DailyData { + return { + hrvStatus: "Balanced", + bbYesterdayLow: 50, // above 30 + phase: "FOLLICULAR", // not LATE_LUTEAL or MENSTRUAL + weekIntensity: 0, // well below limit + phaseLimit: 120, + bbCurrent: 90, // above 85 + }; +} + +describe("getTrainingDecision (algorithmic rules)", () => { + describe("Priority 1: HRV Unbalanced", () => { + it("forces REST when HRV is Unbalanced", () => { + const data = createHealthyData(); + data.hrvStatus = "Unbalanced"; + const result = getTrainingDecision(data); + expect(result.status).toBe("REST"); + expect(result.reason).toContain("HRV"); + }); + }); + + describe("Priority 2: Body Battery Yesterday Low", () => { + it("forces REST when BB yesterday low is below 30", () => { + const data = createHealthyData(); + data.bbYesterdayLow = 25; + const result = getTrainingDecision(data); + expect(result.status).toBe("REST"); + expect(result.reason).toContain("BB"); + }); + }); + + describe("Priority 3: Late Luteal Phase", () => { + it("forces GENTLE during late luteal phase", () => { + const data = createHealthyData(); + data.phase = "LATE_LUTEAL"; + const result = getTrainingDecision(data); + expect(result.status).toBe("GENTLE"); + expect(result.reason).toContain("rebounding"); + }); + }); + + describe("Priority 4: Menstrual Phase", () => { + it("forces GENTLE during menstrual phase", () => { + const data = createHealthyData(); + data.phase = "MENSTRUAL"; + const result = getTrainingDecision(data); + expect(result.status).toBe("GENTLE"); + expect(result.reason).toContain("rebounding"); + }); + }); + + describe("Priority 5: Weekly Intensity Limit", () => { + it("forces REST when weekly intensity meets limit", () => { + const data = createHealthyData(); + data.weekIntensity = 120; // equals phaseLimit + const result = getTrainingDecision(data); + expect(result.status).toBe("REST"); + expect(result.reason).toContain("LIMIT"); + }); + + it("forces REST when weekly intensity exceeds limit", () => { + const data = createHealthyData(); + data.weekIntensity = 150; // exceeds phaseLimit + const result = getTrainingDecision(data); + expect(result.status).toBe("REST"); + }); + }); + + describe("Priority 6: Body Battery Current Low", () => { + it("forces LIGHT when current BB is below 75", () => { + const data = createHealthyData(); + data.bbCurrent = 70; + const result = getTrainingDecision(data); + expect(result.status).toBe("LIGHT"); + expect(result.reason).toContain("BB"); + }); + }); + + describe("Priority 7: Body Battery Current Medium", () => { + it("forces REDUCED when current BB is below 85 but above 74", () => { + const data = createHealthyData(); + data.bbCurrent = 80; + const result = getTrainingDecision(data); + expect(result.status).toBe("REDUCED"); + expect(result.reason).toContain("25%"); + }); + }); + + describe("Priority 8: Default", () => { + it("returns TRAIN when all conditions are favorable", () => { + const data = createHealthyData(); + const result = getTrainingDecision(data); + expect(result.status).toBe("TRAIN"); + expect(result.reason).toContain("OK to train"); + }); + }); + + describe("Priority enforcement", () => { + it("HRV Unbalanced takes precedence over low BB yesterday", () => { + const data = createHealthyData(); + data.hrvStatus = "Unbalanced"; + data.bbYesterdayLow = 25; + const result = getTrainingDecision(data); + expect(result.reason).toContain("HRV"); + }); + + it("low BB yesterday takes precedence over phase-based rules", () => { + const data = createHealthyData(); + data.bbYesterdayLow = 25; + data.phase = "LATE_LUTEAL"; + const result = getTrainingDecision(data); + expect(result.status).toBe("REST"); + expect(result.reason).toContain("BB"); + }); + }); +}); + +describe("getDecisionWithOverrides", () => { + describe("override types force REST", () => { + it("flare override forces REST", () => { + const data = createHealthyData(); + const overrides: OverrideType[] = ["flare"]; + const result = getDecisionWithOverrides(data, overrides); + expect(result.status).toBe("REST"); + expect(result.reason).toContain("flare"); + }); + + it("stress override forces REST", () => { + const data = createHealthyData(); + const overrides: OverrideType[] = ["stress"]; + const result = getDecisionWithOverrides(data, overrides); + expect(result.status).toBe("REST"); + expect(result.reason).toContain("stress"); + }); + + it("sleep override forces REST", () => { + const data = createHealthyData(); + const overrides: OverrideType[] = ["sleep"]; + const result = getDecisionWithOverrides(data, overrides); + expect(result.status).toBe("REST"); + expect(result.reason).toContain("sleep"); + }); + + it("pms override forces REST", () => { + const data = createHealthyData(); + const overrides: OverrideType[] = ["pms"]; + const result = getDecisionWithOverrides(data, overrides); + expect(result.status).toBe("REST"); + expect(result.reason).toContain("pms"); + }); + }); + + describe("override priority (flare > stress > sleep > pms)", () => { + it("flare takes precedence over stress", () => { + const data = createHealthyData(); + const overrides: OverrideType[] = ["stress", "flare"]; + const result = getDecisionWithOverrides(data, overrides); + expect(result.reason).toContain("flare"); + expect(result.reason).not.toContain("stress"); + }); + + it("stress takes precedence over sleep", () => { + const data = createHealthyData(); + const overrides: OverrideType[] = ["sleep", "stress"]; + const result = getDecisionWithOverrides(data, overrides); + expect(result.reason).toContain("stress"); + expect(result.reason).not.toContain("sleep"); + }); + + it("sleep takes precedence over pms", () => { + const data = createHealthyData(); + const overrides: OverrideType[] = ["pms", "sleep"]; + const result = getDecisionWithOverrides(data, overrides); + expect(result.reason).toContain("sleep"); + expect(result.reason).not.toContain("pms"); + }); + + it("respects full priority chain with all overrides", () => { + const data = createHealthyData(); + const overrides: OverrideType[] = ["pms", "sleep", "stress", "flare"]; + const result = getDecisionWithOverrides(data, overrides); + expect(result.reason).toContain("flare"); + }); + }); + + describe("overrides bypass algorithmic rules", () => { + it("flare override bypasses favorable conditions", () => { + const data = createHealthyData(); + // Normally this would return TRAIN + const overrides: OverrideType[] = ["flare"]; + const result = getDecisionWithOverrides(data, overrides); + expect(result.status).toBe("REST"); + }); + + it("stress override works even with perfect biometrics", () => { + const data = createHealthyData(); + data.bbCurrent = 100; + data.bbYesterdayLow = 80; + const overrides: OverrideType[] = ["stress"]; + const result = getDecisionWithOverrides(data, overrides); + expect(result.status).toBe("REST"); + }); + }); + + describe("empty overrides fall through to algorithmic rules", () => { + it("returns TRAIN with no overrides and healthy data", () => { + const data = createHealthyData(); + const overrides: OverrideType[] = []; + const result = getDecisionWithOverrides(data, overrides); + expect(result.status).toBe("TRAIN"); + }); + + it("returns REST for HRV Unbalanced with no overrides", () => { + const data = createHealthyData(); + data.hrvStatus = "Unbalanced"; + const overrides: OverrideType[] = []; + const result = getDecisionWithOverrides(data, overrides); + expect(result.status).toBe("REST"); + expect(result.reason).toContain("HRV"); + }); + + it("algorithmic rules apply when overrides array is empty", () => { + const data = createHealthyData(); + data.bbCurrent = 70; + const overrides: OverrideType[] = []; + const result = getDecisionWithOverrides(data, overrides); + expect(result.status).toBe("LIGHT"); + }); + }); +}); diff --git a/src/lib/decision-engine.ts b/src/lib/decision-engine.ts index ac681e5..78b22ee 100644 --- a/src/lib/decision-engine.ts +++ b/src/lib/decision-engine.ts @@ -1,6 +1,16 @@ // 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"; +import type { DailyData, Decision, OverrideType } from "@/types"; + +// Override priority order - checked before algorithmic rules +const OVERRIDE_PRIORITY: OverrideType[] = ["flare", "stress", "sleep", "pms"]; + +const OVERRIDE_REASONS: Record = { + flare: "Hashimoto's flare - rest required", + stress: "High stress override - rest required", + sleep: "Poor sleep override - rest required", + pms: "pms override - rest required", +}; export function getTrainingDecision(data: DailyData): Decision { const { @@ -62,3 +72,22 @@ export function getTrainingDecision(data: DailyData): Decision { icon: "✅", }; } + +export function getDecisionWithOverrides( + data: DailyData, + overrides: OverrideType[], +): Decision { + // Check overrides first, in priority order: flare > stress > sleep > pms + for (const override of OVERRIDE_PRIORITY) { + if (overrides.includes(override)) { + return { + status: "REST", + reason: OVERRIDE_REASONS[override], + icon: "🛑", + }; + } + } + + // No active overrides - fall through to algorithmic rules + return getTrainingDecision(data); +}