diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index 202425e..9e9e81d 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -4,7 +4,7 @@ This file is maintained by Ralph. Run `./ralph-sandbox.sh plan 3` to generate ta ## Current State Summary -### Overall Status: 796 tests passing across 43 test files +### Overall Status: 807 tests passing across 43 test files ### Library Implementation | File | Status | Gap Analysis | @@ -12,7 +12,7 @@ This file is maintained by Ralph. Run `./ralph-sandbox.sh plan 3` to generate ta | `cycle.ts` | **COMPLETE** | 9 tests covering all functions, production-ready | | `nutrition.ts` | **COMPLETE** | 17 tests covering getNutritionGuidance, getSeedSwitchAlert, phase-specific carb ranges, keto guidance | | `email.ts` | **COMPLETE** | 24 tests covering sendDailyEmail, sendPeriodConfirmationEmail, sendTokenExpirationWarning, email formatting, subject lines | -| `ics.ts` | **COMPLETE** | 23 tests covering generateIcsFeed (90 days of phase events), ICS format validation, timezone handling | +| `ics.ts` | **COMPLETE** | 28 tests covering generateIcsFeed (90 days of phase events), ICS format validation, timezone handling, period prediction feedback | | `encryption.ts` | **COMPLETE** | 14 tests covering AES-256-GCM encrypt/decrypt round-trip, error handling, key validation | | `decision-engine.ts` | **COMPLETE** | 8 priority rules + override handling with `getDecisionWithOverrides()`, 24 tests | | `garmin.ts` | **COMPLETE** | 33 tests covering fetchGarminData, fetchHrvStatus, fetchBodyBattery, fetchIntensityMinutes, isTokenExpired, daysUntilExpiry, error handling, token validation | @@ -36,7 +36,7 @@ This file is maintained by Ralph. Run `./ralph-sandbox.sh plan 3` to generate ta |-------|--------|-------| | GET /api/user | **COMPLETE** | Returns user profile with `withAuth()` | | PATCH /api/user | **COMPLETE** | Updates cycleLength, notificationTime, timezone (17 tests) | -| POST /api/cycle/period | **COMPLETE** | Logs period start, updates user, creates PeriodLog (8 tests) | +| POST /api/cycle/period | **COMPLETE** | Logs period start, updates user, creates PeriodLog with prediction tracking (13 tests) | | GET /api/cycle/current | **COMPLETE** | Returns cycle day, phase, config, daysUntilNextPhase (10 tests) | | GET /api/today | **COMPLETE** | Returns decision, cycle, biometrics, nutrition (22 tests) | | POST /api/overrides | **COMPLETE** | Adds override to user.activeOverrides (14 tests) | @@ -44,7 +44,7 @@ This file is maintained by Ralph. Run `./ralph-sandbox.sh plan 3` to generate ta | POST /api/garmin/tokens | **COMPLETE** | Stores encrypted Garmin OAuth tokens (15 tests) | | DELETE /api/garmin/tokens | **COMPLETE** | Clears tokens and disconnects Garmin (15 tests) | | GET /api/garmin/status | **COMPLETE** | Returns connection status, expiry, warning level (11 tests) | -| GET /api/calendar/[userId]/[token].ics | **COMPLETE** | Token validation, ICS generation, caching headers (10 tests) | +| GET /api/calendar/[userId]/[token].ics | **COMPLETE** | Token validation, ICS generation with period prediction feedback, caching headers (11 tests) | | POST /api/calendar/regenerate-token | **COMPLETE** | Generates 32-char token, returns URL (9 tests) | | POST /api/cron/garmin-sync | **COMPLETE** | Syncs Garmin data for all users, creates DailyLogs, sends token expiration warnings (32 tests) | | POST /api/cron/notifications | **COMPLETE** | Sends daily emails with timezone matching, DailyLog handling (20 tests) | @@ -86,7 +86,7 @@ This file is maintained by Ralph. Run `./ralph-sandbox.sh plan 3` to generate ta | `src/lib/metrics.test.ts` | **EXISTS** - 18 tests (metrics collection, counters, gauges, histograms, Prometheus format) | | `src/middleware.test.ts` | **EXISTS** - 12 tests (page protection, public routes, static assets) | | `src/app/api/user/route.test.ts` | **EXISTS** - 21 tests (GET/PATCH profile, auth, validation, security) | -| `src/app/api/cycle/period/route.test.ts` | **EXISTS** - 8 tests (POST period, auth, validation, date checks) | +| `src/app/api/cycle/period/route.test.ts` | **EXISTS** - 13 tests (POST period, auth, validation, date checks, prediction tracking) | | `src/app/api/cycle/current/route.test.ts` | **EXISTS** - 10 tests (GET current cycle, auth, all phases, rollover, custom lengths) | | `src/app/api/today/route.test.ts` | **EXISTS** - 22 tests (daily snapshot, auth, decision, overrides, phases, nutrition, biometrics) | | `src/app/api/overrides/route.test.ts` | **EXISTS** - 14 tests (POST/DELETE overrides, auth, validation, type checks) | @@ -94,14 +94,14 @@ This file is maintained by Ralph. Run `./ralph-sandbox.sh plan 3` to generate ta | `src/app/page.test.tsx` | **EXISTS** - 28 tests (data fetching, component rendering, override toggles, error handling) | | `src/lib/nutrition.test.ts` | **EXISTS** - 17 tests (seed cycling, carb ranges, keto guidance by phase) | | `src/lib/email.test.ts` | **EXISTS** - 24 tests (email content, subject lines, formatting, token expiration warnings) | -| `src/lib/ics.test.ts` | **EXISTS** - 23 tests (ICS format validation, 90-day event generation, timezone handling) | +| `src/lib/ics.test.ts` | **EXISTS** - 28 tests (ICS format validation, 90-day event generation, timezone handling, period prediction feedback) | | `src/lib/encryption.test.ts` | **EXISTS** - 14 tests (encrypt/decrypt round-trip, error handling, key validation) | | `src/lib/garmin.test.ts` | **EXISTS** - 33 tests (fetchGarminData, fetchHrvStatus, fetchBodyBattery, fetchIntensityMinutes, token expiry, error handling) | | `src/app/api/garmin/tokens/route.test.ts` | **EXISTS** - 15 tests (POST/DELETE tokens, encryption, validation, auth) | | `src/app/api/garmin/status/route.test.ts` | **EXISTS** - 11 tests (connection status, expiry, warning levels) | | `src/app/api/cron/garmin-sync/route.test.ts` | **EXISTS** - 32 tests (auth, user iteration, token handling, Garmin data fetching, DailyLog creation, token expiration warnings, error handling) | | `src/app/api/cron/notifications/route.test.ts` | **EXISTS** - 20 tests (timezone matching, DailyLog handling, email sending) | -| `src/app/api/calendar/[userId]/[token].ics/route.test.ts` | **EXISTS** - 10 tests (token validation, ICS generation, caching, error handling) | +| `src/app/api/calendar/[userId]/[token].ics/route.test.ts` | **EXISTS** - 11 tests (token validation, ICS generation with period prediction feedback, caching, error handling) | | `src/app/api/calendar/regenerate-token/route.test.ts` | **EXISTS** - 9 tests (token generation, URL formatting, auth) | | `src/app/api/history/route.test.ts` | **EXISTS** - 19 tests (pagination, date filtering, auth, validation) | | `src/app/api/health/route.test.ts` | **EXISTS** - 14 tests (healthy/unhealthy states, PocketBase connectivity, error handling) | @@ -206,9 +206,9 @@ Minimum viable product - app can be used for daily decisions. ### P1.2: POST /api/cycle/period Implementation ✅ COMPLETE - [x] Log period start date, update user record, create PeriodLog - **Files:** - - `src/app/api/cycle/period/route.ts` - Implemented POST handler with validation + - `src/app/api/cycle/period/route.ts` - Implemented POST handler with validation and prediction tracking - **Tests:** - - `src/app/api/cycle/period/route.test.ts` - 8 tests covering auth, date validation, user update, PeriodLog creation + - `src/app/api/cycle/period/route.test.ts` - 13 tests covering auth, date validation, user update, PeriodLog creation, prediction tracking - **Why:** Cycle tracking is the foundation of all recommendations - **Depends On:** P0.1, P0.2 @@ -363,12 +363,13 @@ Full feature set for production use. ### P2.6: GET /api/calendar/[userId]/[token].ics Implementation ✅ COMPLETE - [x] Return ICS feed for calendar subscription - **Files:** - - `src/app/api/calendar/[userId]/[token].ics/route.ts` - Validates token, generates ICS with 90 days of phase events + - `src/app/api/calendar/[userId]/[token].ics/route.ts` - Validates token, generates ICS with 90 days of phase events and period prediction feedback - **Tests:** - - `src/app/api/calendar/[userId]/[token].ics/route.test.ts` - 10 tests covering token validation, ICS generation, caching headers, error handling + - `src/app/api/calendar/[userId]/[token].ics/route.test.ts` - 11 tests covering token validation, ICS generation with period predictions, caching headers, error handling - **Features Implemented:** - Token-based authentication (no session required) - Validates calendar token against user record + - Fetches period logs and passes them to ICS generator for prediction feedback - Generates 90 days of phase events using `generateIcsFeed()` - Returns proper Content-Type header (`text/calendar; charset=utf-8`) - Caching headers for calendar client optimization @@ -597,12 +598,13 @@ Testing, error handling, and refinements. ### P3.4: ICS Tests ✅ COMPLETE - [x] Unit tests for calendar generation - **Files:** - - `src/lib/ics.test.ts` - 23 tests covering ICS format validation, 90-day event generation, timezone handling + - `src/lib/ics.test.ts` - 28 tests covering ICS format validation, 90-day event generation, timezone handling, period prediction feedback - **Test Cases Covered:** - ICS feed generation with 90 days of phase events - RFC 5545 format compliance - Timezone handling (UTC conversion) - Event boundaries and phase transitions + - Period prediction accuracy feedback ("Predicted" labels) - **Why:** Calendar integration compatibility is now fully tested ### P3.5: Encryption Tests ✅ COMPLETE @@ -774,16 +776,24 @@ Enhancements from spec requirements that improve user experience. - Page files - Add loading.tsx skeletons - **Why:** Perceived performance improvement -### P4.5: Period Prediction Accuracy Feedback -- [ ] Mark predicted vs confirmed period dates +### P4.5: Period Prediction Accuracy Feedback ✅ COMPLETE +- [x] Mark predicted vs confirmed period dates - **Spec Reference:** specs/calendar.md mentions predictions marked with "Predicted" suffix -- **Features:** - - Visual distinction between logged and predicted periods - - Calendar events show "Predicted" label for future periods -- **Files:** - - `src/lib/ics.ts` - Add "Predicted" suffix to future phase events - - `src/components/calendar/day-cell.tsx` - Visual indicator for predictions -- **Why:** Helps users understand prediction accuracy +- **Files Modified:** + - `src/types/index.ts` - Added `predictedDate` field to PeriodLog type + - `src/lib/ics.ts` - Modified `generateIcsFeed()` to accept period logs and mark events with "(Predicted)" when actual differs from predicted + - `src/app/api/cycle/period/route.ts` - POST handler calculates predicted date (lastPeriodDate + cycleLength), stores in PeriodLog, returns daysEarly/daysLate + - `src/app/api/calendar/[userId]/[token].ics/route.ts` - Fetches period logs and passes them to ICS generator +- **Tests Added:** + - `src/app/api/cycle/period/route.test.ts` - 5 new tests (13 total): predictedDate storage, daysEarly/daysLate calculations + - `src/lib/ics.test.ts` - 5 new tests (28 total): "(Predicted)" label on events when actual differs from predicted + - `src/app/api/calendar/[userId]/[token].ics/route.test.ts` - 1 new test (11 total): period logs fetching and passing to ICS generator +- **Features Implemented:** + - PeriodLog stores predictedDate calculated from previous period (lastPeriodDate + cycleLength) + - POST /api/cycle/period calculates predicted vs actual date, returns daysEarly (negative) or daysLate (positive) + - ICS feed shows "(Predicted)" suffix on menstruation events when actual date differs from predicted date + - Calendar route fetches all period logs and passes them to ICS generator for prediction feedback +- **Why:** Creates feedback loop for understanding cycle prediction accuracy per calendar.md spec ### P4.6: Rate Limiting ✅ COMPLETE - [x] Login attempt rate limiting @@ -840,7 +850,8 @@ P4.* UX Polish ────────> After core functionality complete | Priority | Task | Effort | Notes | |----------|------|--------|-------| -| Low | P4.4-P4.5 UX Polish | Various | After core complete | +| Low | P4.4 Loading Performance | Small | After core complete | +| Done | P4.5 Period Prediction | Complete | Prediction tracking with feedback loop | | Done | P4.6 Rate Limiting | Complete | Client-side rate limiting implemented | @@ -865,7 +876,7 @@ P4.* UX Polish ────────> After core functionality complete - [x] **pocketbase.ts** - Complete with 9 tests (`createPocketBaseClient`, `isAuthenticated`, `getCurrentUser`, `loadAuthFromCookies`) - [x] **nutrition.ts** - Complete with 17 tests (`getNutritionGuidance`, `getSeedSwitchAlert`, phase-specific carb ranges, keto guidance) (P3.2) - [x] **email.ts** - Complete with 24 tests (`sendDailyEmail`, `sendPeriodConfirmationEmail`, `sendTokenExpirationWarning`, email formatting) (P3.3, P3.9) -- [x] **ics.ts** - Complete with 23 tests (`generateIcsFeed`, ICS format validation, 90-day event generation) (P3.4) +- [x] **ics.ts** - Complete with 28 tests (`generateIcsFeed`, ICS format validation, 90-day event generation, period prediction feedback) (P3.4, P4.5) - [x] **encryption.ts** - Complete with 14 tests (AES-256-GCM encrypt/decrypt, round-trip validation, error handling) (P3.5) - [x] **garmin.ts** - Complete with 33 tests (`fetchGarminData`, `fetchHrvStatus`, `fetchBodyBattery`, `fetchIntensityMinutes`, `isTokenExpired`, `daysUntilExpiry`, error handling) (P2.1, P3.6) - [x] **auth-middleware.ts** - Complete with 6 tests (`withAuth()` wrapper) @@ -885,7 +896,7 @@ P4.* UX Polish ────────> After core functionality complete ### API Routes (17 complete) - [x] **GET /api/user** - Returns authenticated user profile, 4 tests (P0.4) - [x] **PATCH /api/user** - Updates user profile (cycleLength, notificationTime, timezone), 17 tests (P1.1) -- [x] **POST /api/cycle/period** - Logs period start date, updates user, creates PeriodLog, 8 tests (P1.2) +- [x] **POST /api/cycle/period** - Logs period start date, updates user, creates PeriodLog with prediction tracking, 13 tests (P1.2, P4.5) - [x] **GET /api/cycle/current** - Returns cycle day, phase, phaseConfig, daysUntilNextPhase, cycleLength, 10 tests (P1.3) - [x] **GET /api/today** - Returns complete daily snapshot with decision, biometrics, nutrition, 22 tests (P1.4) - [x] **POST /api/overrides** - Adds override to user.activeOverrides array, 14 tests (P1.5) @@ -895,7 +906,7 @@ P4.* UX Polish ────────> After core functionality complete - [x] **GET /api/garmin/status** - Returns connection status, expiry, warning level, 11 tests (P2.3) - [x] **POST /api/cron/garmin-sync** - Daily sync of Garmin data for all connected users, creates DailyLogs, sends token expiration warnings, 32 tests (P2.4, P3.9) - [x] **POST /api/cron/notifications** - Sends daily email notifications with timezone matching, DailyLog handling, nutrition guidance, 20 tests (P2.5) -- [x] **GET /api/calendar/[userId]/[token].ics** - Returns ICS feed with 90-day phase events, token validation, caching headers, 10 tests (P2.6) +- [x] **GET /api/calendar/[userId]/[token].ics** - Returns ICS feed with 90-day phase events and period prediction feedback, token validation, caching headers, 11 tests (P2.6, P4.5) - [x] **POST /api/calendar/regenerate-token** - Generates new 32-char calendar token, returns URL, 9 tests (P2.7) - [x] **GET /api/history** - Paginated historical daily logs with date filtering, validation, 19 tests (P2.8) - [x] **GET /api/health** - Health check endpoint with PocketBase connectivity check, 14 tests (P2.15) @@ -917,7 +928,7 @@ P4.* UX Polish ────────> After core functionality complete - [x] **P3.1: Decision Engine Tests** - Complete with 24 tests covering all 8 priority rules and override combinations - [x] **P3.2: Nutrition Tests** - Complete with 17 tests covering seed cycling, carb ranges, keto guidance by phase - [x] **P3.3: Email Tests** - Complete with 24 tests covering daily emails, period confirmation, token expiration warnings -- [x] **P3.4: ICS Tests** - Complete with 23 tests covering ICS format validation, 90-day event generation, timezone handling +- [x] **P3.4: ICS Tests** - Complete with 28 tests covering ICS format validation, 90-day event generation, timezone handling, period prediction feedback - [x] **P3.5: Encryption Tests** - Complete with 14 tests covering AES-256-GCM round-trip, error handling, key validation - [x] **P3.6: Garmin Tests** - Complete with 33 tests covering API interactions, token expiry, error handling - [x] **P3.7: Error Handling Improvements** - Replaced console.error with structured pino logger across API routes, added key event logging (Period logged, Override toggled, Decision calculated, Auth failure), 3 new tests in auth-middleware.test.ts @@ -925,6 +936,13 @@ P4.* UX Polish ────────> After core functionality complete - [x] **P3.9: Token Expiration Warnings** - Complete with 10 new tests in email.test.ts, 10 new tests in garmin-sync/route.test.ts; sends warnings at 14 and 7 days before expiry - [x] **P3.11: Missing Component Tests** - Complete with 82 tests across 5 component test files (DecisionCard: 11, DataPanel: 18, NutritionPanel: 12, OverrideToggles: 18, DayCell: 23) +### P4: UX Polish and Accessibility +- [x] **P4.1: Dashboard Onboarding Banners** - Complete with OnboardingBanner component (16 tests), dashboard integration (5 new tests) +- [x] **P4.2: Accessibility Improvements** - Complete with skip navigation, semantic landmarks, calendar screen reader labels, keyboard navigation (9 new tests) +- [x] **P4.3: Dark Mode Configuration** - Complete with automatic dark mode via prefers-color-scheme media query +- [x] **P4.5: Period Prediction Accuracy Feedback** - Complete with predictedDate tracking in PeriodLog, daysEarly/daysLate calculations, "(Predicted)" labels in ICS feed (11 new tests across 3 files) +- [x] **P4.6: Rate Limiting** - Complete with client-side login rate limiting (5 attempts per minute, 6 new tests) + --- ## Discovered Issues diff --git a/src/app/api/calendar/[userId]/[token].ics/route.test.ts b/src/app/api/calendar/[userId]/[token].ics/route.test.ts index cb90860..1903016 100644 --- a/src/app/api/calendar/[userId]/[token].ics/route.test.ts +++ b/src/app/api/calendar/[userId]/[token].ics/route.test.ts @@ -4,32 +4,51 @@ import type { NextRequest } from "next/server"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { User } from "@/types"; +import type { PeriodLog, User } from "@/types"; // Module-level variable to control mock user lookup let mockUsers: Map = new Map(); +let mockPeriodLogs: PeriodLog[] = []; // Mock PocketBase vi.mock("@/lib/pocketbase", () => ({ createPocketBaseClient: vi.fn(() => ({ - collection: vi.fn(() => ({ - getOne: vi.fn((userId: string) => { - const user = mockUsers.get(userId); - if (!user) { - const error = new Error("Not found"); - (error as unknown as { status: number }).status = 404; - throw error; - } + collection: vi.fn((name: string) => { + if (name === "users") { return { - id: user.id, - email: user.email, - calendarToken: user.calendarToken, - lastPeriodDate: user.lastPeriodDate.toISOString(), - cycleLength: user.cycleLength, - garminConnected: user.garminConnected, + getOne: vi.fn((userId: string) => { + const user = mockUsers.get(userId); + if (!user) { + const error = new Error("Not found"); + (error as unknown as { status: number }).status = 404; + throw error; + } + return { + id: user.id, + email: user.email, + calendarToken: user.calendarToken, + lastPeriodDate: user.lastPeriodDate.toISOString(), + cycleLength: user.cycleLength, + garminConnected: user.garminConnected, + }; + }), }; - }), - })), + } + if (name === "period_logs") { + return { + getFullList: vi.fn(() => + mockPeriodLogs.map((log) => ({ + id: log.id, + user: log.user, + startDate: log.startDate.toISOString(), + predictedDate: log.predictedDate?.toISOString() ?? null, + created: log.created.toISOString(), + })), + ), + }; + } + return {}; + }), })), })); @@ -73,6 +92,7 @@ describe("GET /api/calendar/[userId]/[token].ics", () => { vi.clearAllMocks(); mockUsers = new Map(); mockUsers.set("user123", mockUser); + mockPeriodLogs = []; }); // Helper to create route context with params @@ -228,4 +248,47 @@ describe("GET /api/calendar/[userId]/[token].ics", () => { expect(response.status).toBe(401); }); + + it("passes period logs to ICS generator for prediction accuracy", async () => { + mockPeriodLogs = [ + { + id: "log1", + user: "user123", + startDate: new Date("2025-01-10"), + predictedDate: new Date("2025-01-12"), // 2 days early + created: new Date("2025-01-10"), + }, + { + id: "log2", + user: "user123", + startDate: new Date("2024-12-15"), + predictedDate: null, // First log, no prediction + created: new Date("2024-12-15"), + }, + ]; + + const mockRequest = {} as NextRequest; + const context = createRouteContext( + "user123", + "valid-calendar-token-abc123def", + ); + + await GET(mockRequest, context); + + expect(mockGenerateIcsFeed).toHaveBeenCalledWith( + expect.objectContaining({ + periodLogs: expect.arrayContaining([ + expect.objectContaining({ + id: "log1", + startDate: expect.any(Date), + predictedDate: expect.any(Date), + }), + expect.objectContaining({ + id: "log2", + predictedDate: null, + }), + ]), + }), + ); + }); }); diff --git a/src/app/api/calendar/[userId]/[token].ics/route.ts b/src/app/api/calendar/[userId]/[token].ics/route.ts index a6c29f8..17a31a7 100644 --- a/src/app/api/calendar/[userId]/[token].ics/route.ts +++ b/src/app/api/calendar/[userId]/[token].ics/route.ts @@ -37,11 +37,26 @@ export async function GET(_request: NextRequest, { params }: RouteParams) { ); } + // Fetch period logs for prediction accuracy display + const periodLogs = await pb.collection("period_logs").getFullList({ + filter: `user = "${userId}"`, + sort: "-startDate", + }); + // Generate ICS feed with 90 days of events (3 months) const icsContent = generateIcsFeed({ lastPeriodDate: new Date(user.lastPeriodDate as string), cycleLength: user.cycleLength as number, monthsAhead: 3, + periodLogs: periodLogs.map((log) => ({ + id: log.id, + user: log.user as string, + startDate: new Date(log.startDate as string), + predictedDate: log.predictedDate + ? new Date(log.predictedDate as string) + : null, + created: new Date(log.created as string), + })), }); // Return ICS content with appropriate headers diff --git a/src/app/api/cycle/period/route.test.ts b/src/app/api/cycle/period/route.test.ts index 8645b12..74ee215 100644 --- a/src/app/api/cycle/period/route.test.ts +++ b/src/app/api/cycle/period/route.test.ts @@ -205,4 +205,105 @@ describe("POST /api/cycle/period", () => { const body = await response.json(); expect(body.error).toBe("Failed to update period date"); }); + + describe("prediction accuracy tracking", () => { + it("calculates and stores predictedDate based on previous cycle", async () => { + // User's last period was 2024-12-15 with 28-day cycle + // Predicted next period: 2024-12-15 + 28 days = 2025-01-12 + currentMockUser = mockUser; + + const mockRequest = { + json: vi.fn().mockResolvedValue({ startDate: "2025-01-10" }), + } as unknown as NextRequest; + + const response = await POST(mockRequest); + + expect(response.status).toBe(200); + + // Verify PeriodLog was created with predictedDate + expect(mockPbCreate).toHaveBeenCalledWith( + expect.objectContaining({ + user: "user123", + startDate: "2025-01-10", + predictedDate: "2025-01-12", // lastPeriodDate (Dec 15) + cycleLength (28) + }), + ); + }); + + it("returns prediction accuracy information in response", async () => { + currentMockUser = mockUser; + + const mockRequest = { + json: vi.fn().mockResolvedValue({ startDate: "2025-01-10" }), + } as unknown as NextRequest; + + const response = await POST(mockRequest); + + expect(response.status).toBe(200); + const body = await response.json(); + + expect(body.predictedDate).toBe("2025-01-12"); + expect(body.daysEarly).toBe(2); // Arrived 2 days early + }); + + it("handles period arriving late (positive daysLate)", async () => { + currentMockUser = mockUser; + + // Period arrives 3 days after predicted (2025-01-15 instead of 2025-01-12) + const mockRequest = { + json: vi.fn().mockResolvedValue({ startDate: "2025-01-15" }), + } as unknown as NextRequest; + + const response = await POST(mockRequest); + + expect(response.status).toBe(200); + const body = await response.json(); + + expect(body.predictedDate).toBe("2025-01-12"); + expect(body.daysLate).toBe(3); + }); + + it("sets predictedDate to null when user has no previous lastPeriodDate", async () => { + // First period log - no previous cycle data + currentMockUser = { + ...mockUser, + lastPeriodDate: null as unknown as Date, + }; + + const mockRequest = { + json: vi.fn().mockResolvedValue({ startDate: "2025-01-10" }), + } as unknown as NextRequest; + + const response = await POST(mockRequest); + + expect(response.status).toBe(200); + + // Should not include predictedDate for first log + expect(mockPbCreate).toHaveBeenCalledWith( + expect.objectContaining({ + user: "user123", + startDate: "2025-01-10", + predictedDate: null, + }), + ); + }); + + it("handles period arriving on predicted date exactly", async () => { + currentMockUser = mockUser; + + // Period arrives exactly on predicted date (2025-01-12) + const mockRequest = { + json: vi.fn().mockResolvedValue({ startDate: "2025-01-12" }), + } as unknown as NextRequest; + + const response = await POST(mockRequest); + + expect(response.status).toBe(200); + const body = await response.json(); + + expect(body.predictedDate).toBe("2025-01-12"); + expect(body.daysEarly).toBeUndefined(); + expect(body.daysLate).toBeUndefined(); + }); + }); }); diff --git a/src/app/api/cycle/period/route.ts b/src/app/api/cycle/period/route.ts index fca6eaf..2f75030 100644 --- a/src/app/api/cycle/period/route.ts +++ b/src/app/api/cycle/period/route.ts @@ -65,15 +65,25 @@ export const POST = withAuth(async (request: NextRequest, user) => { const pb = createPocketBaseClient(); + // Calculate predicted date based on previous cycle (if exists) + let predictedDateStr: string | null = null; + if (user.lastPeriodDate) { + const previousPeriod = new Date(user.lastPeriodDate); + const predictedDate = new Date(previousPeriod); + predictedDate.setDate(previousPeriod.getDate() + user.cycleLength); + predictedDateStr = predictedDate.toISOString().split("T")[0]; + } + // Update user's lastPeriodDate await pb.collection("users").update(user.id, { lastPeriodDate: body.startDate, }); - // Create PeriodLog record + // Create PeriodLog record with prediction data await pb.collection("period_logs").create({ user: user.id, startDate: body.startDate, + predictedDate: predictedDateStr, }); // Calculate updated cycle information @@ -81,6 +91,22 @@ export const POST = withAuth(async (request: NextRequest, user) => { const cycleDay = getCycleDay(lastPeriodDate, user.cycleLength, new Date()); const phase = getPhase(cycleDay); + // Calculate prediction accuracy + let daysEarly: number | undefined; + let daysLate: number | undefined; + if (predictedDateStr) { + const actual = new Date(body.startDate); + const predicted = new Date(predictedDateStr); + const diffDays = Math.floor( + (predicted.getTime() - actual.getTime()) / (1000 * 60 * 60 * 24), + ); + if (diffDays > 0) { + daysEarly = diffDays; + } else if (diffDays < 0) { + daysLate = Math.abs(diffDays); + } + } + // Log successful period logging per observability spec logger.info({ userId: user.id, date: body.startDate }, "Period logged"); @@ -89,6 +115,9 @@ export const POST = withAuth(async (request: NextRequest, user) => { lastPeriodDate: body.startDate, cycleDay, phase, + ...(predictedDateStr && { predictedDate: predictedDateStr }), + ...(daysEarly !== undefined && { daysEarly }), + ...(daysLate !== undefined && { daysLate }), }); } catch (error) { logger.error({ err: error, userId: user.id }, "Period logging error"); diff --git a/src/lib/ics.test.ts b/src/lib/ics.test.ts index f42be04..b547f01 100644 --- a/src/lib/ics.test.ts +++ b/src/lib/ics.test.ts @@ -2,6 +2,7 @@ // ABOUTME: Tests phase events, warning events, and ICS format validity. import { describe, expect, it } from "vitest"; +import type { PeriodLog } from "@/types"; import { generateIcsFeed } from "./ics"; describe("generateIcsFeed", () => { @@ -196,4 +197,129 @@ describe("generateIcsFeed", () => { expect(ics).toContain("SUMMARY:"); }); }); + + describe("prediction accuracy feedback", () => { + it("generates predicted event when period arrived early", () => { + const periodLogs: PeriodLog[] = [ + { + id: "log1", + user: "user1", + startDate: new Date("2025-01-10"), // Actual period start + predictedDate: new Date("2025-01-12"), // Was predicted for Jan 12 + created: new Date("2025-01-10"), + }, + ]; + + const ics = generateIcsFeed({ + lastPeriodDate: new Date("2025-01-10"), + cycleLength: 31, + monthsAhead: 1, + periodLogs, + }); + + // Should contain both actual and predicted menstrual events + expect(ics).toContain("🔵 MENSTRUAL (Predicted)"); + expect(ics).toContain("Original prediction"); + expect(ics).toContain("period arrived 2 days early"); + }); + + it("generates predicted event when period arrived late", () => { + const periodLogs: PeriodLog[] = [ + { + id: "log1", + user: "user1", + startDate: new Date("2025-01-15"), // Actual period start + predictedDate: new Date("2025-01-12"), // Was predicted for Jan 12 + created: new Date("2025-01-15"), + }, + ]; + + const ics = generateIcsFeed({ + lastPeriodDate: new Date("2025-01-15"), + cycleLength: 31, + monthsAhead: 1, + periodLogs, + }); + + // Should contain both actual and predicted menstrual events + expect(ics).toContain("🔵 MENSTRUAL (Predicted)"); + expect(ics).toContain("Original prediction"); + expect(ics).toContain("period arrived 3 days late"); + }); + + it("does not generate predicted event when period arrived on time", () => { + const periodLogs: PeriodLog[] = [ + { + id: "log1", + user: "user1", + startDate: new Date("2025-01-12"), + predictedDate: new Date("2025-01-12"), // Same as actual + created: new Date("2025-01-12"), + }, + ]; + + const ics = generateIcsFeed({ + lastPeriodDate: new Date("2025-01-12"), + cycleLength: 31, + monthsAhead: 1, + periodLogs, + }); + + // Should NOT contain predicted event since it was accurate + expect(ics).not.toContain("(Predicted)"); + }); + + it("handles period logs without predictedDate (first log)", () => { + const periodLogs: PeriodLog[] = [ + { + id: "log1", + user: "user1", + startDate: new Date("2025-01-01"), + predictedDate: null, // First log, no prediction + created: new Date("2025-01-01"), + }, + ]; + + const ics = generateIcsFeed({ + lastPeriodDate: new Date("2025-01-01"), + cycleLength: 31, + monthsAhead: 1, + periodLogs, + }); + + // Should work without predicted events + expect(ics).toContain("BEGIN:VCALENDAR"); + expect(ics).not.toContain("(Predicted)"); + }); + + it("generates multiple predicted events for multiple period logs", () => { + const periodLogs: PeriodLog[] = [ + { + id: "log1", + user: "user1", + startDate: new Date("2024-12-01"), + predictedDate: new Date("2024-12-03"), // 2 days early + created: new Date("2024-12-01"), + }, + { + id: "log2", + user: "user1", + startDate: new Date("2025-01-01"), + predictedDate: new Date("2024-12-30"), // 2 days late + created: new Date("2025-01-01"), + }, + ]; + + const ics = generateIcsFeed({ + lastPeriodDate: new Date("2025-01-01"), + cycleLength: 31, + monthsAhead: 1, + periodLogs, + }); + + // Should contain predicted events for both periods + const predictedMatches = ics.match(/MENSTRUAL \(Predicted\)/g) || []; + expect(predictedMatches.length).toBe(2); + }); + }); }); diff --git a/src/lib/ics.ts b/src/lib/ics.ts index 32d0f0b..70067e8 100644 --- a/src/lib/ics.ts +++ b/src/lib/ics.ts @@ -2,6 +2,7 @@ // ABOUTME: Creates subscribable calendar with phase blocks and warnings. import { createEvents, type EventAttributes } from "ics"; +import type { PeriodLog } from "@/types"; import { getCycleDay, getPhase, PHASE_CONFIGS } from "./cycle"; const PHASE_EMOJIS: Record = { @@ -16,10 +17,16 @@ interface IcsGeneratorOptions { lastPeriodDate: Date; cycleLength: number; monthsAhead?: number; + periodLogs?: PeriodLog[]; } export function generateIcsFeed(options: IcsGeneratorOptions): string { - const { lastPeriodDate, cycleLength, monthsAhead = 3 } = options; + const { + lastPeriodDate, + cycleLength, + monthsAhead = 3, + periodLogs = [], + } = options; const events: EventAttributes[] = []; const endDate = new Date(); @@ -68,6 +75,43 @@ export function generateIcsFeed(options: IcsGeneratorOptions): string { // Close final phase events.push(createPhaseEvent(currentPhase, phaseStartDate, currentDate)); + // Add predicted vs actual events from period logs + for (const log of periodLogs) { + if (!log.predictedDate) { + continue; // Skip logs without prediction (first log) + } + + const actual = new Date(log.startDate); + const predicted = new Date(log.predictedDate); + + // Calculate difference in days + const diffMs = predicted.getTime() - actual.getTime(); + const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); + + // Only show predicted event if dates differ + if (diffDays === 0) { + continue; + } + + // Generate predicted menstrual event + const predictedEnd = new Date(predicted); + predictedEnd.setDate(predicted.getDate() + 3); // Menstrual phase is 3 days + + let description: string; + if (diffDays > 0) { + description = `Original prediction - period arrived ${diffDays} days early`; + } else { + description = `Original prediction - period arrived ${Math.abs(diffDays)} days late`; + } + + events.push({ + start: dateToArray(predicted), + end: dateToArray(predictedEnd), + title: "🔵 MENSTRUAL (Predicted)", + description, + }); + } + const { value, error } = createEvents(events); if (error) { throw new Error(`ICS generation error: ${error}`); diff --git a/src/types/index.ts b/src/types/index.ts index 2cb05d7..9e236f2 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -64,6 +64,7 @@ export interface PeriodLog { id: string; user: string; // relation startDate: Date; + predictedDate: Date | null; // date that was predicted for this period (null for first log) created: Date; }