diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index ad48404..9278123 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: 990 unit tests passing across 50 test files + 64 E2E tests across 6 files +### Overall Status: 1005 unit tests passing across 50 test files + 64 E2E tests across 6 files ### Library Implementation | File | Status | Gap Analysis | @@ -17,7 +17,7 @@ This file is maintained by Ralph. Run `./ralph-sandbox.sh plan 3` to generate ta | `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 | | `pocketbase.ts` | **COMPLETE** | 9 tests covering `createPocketBaseClient()`, `isAuthenticated()`, `getCurrentUser()`, `loadAuthFromCookies()` | -| `auth-middleware.ts` | **COMPLETE** | 9 tests covering `withAuth()` wrapper for API route protection, structured logging for auth failures | +| `auth-middleware.ts` | **COMPLETE** | 12 tests covering `withAuth()` wrapper for API route protection, structured logging for auth failures, IP address logging | | `middleware.ts` (Next.js) | **COMPLETE** | 12 tests covering page protection, redirects to login | | `logger.ts` | **COMPLETE** | 16 tests covering JSON output, log levels, error stack traces, child loggers | | `metrics.ts` | **COMPLETE** | 33 tests covering metrics collection, counters, gauges, histograms, Prometheus format | @@ -39,7 +39,7 @@ This file is maintained by Ralph. Run `./ralph-sandbox.sh plan 3` to generate ta | PATCH /api/user | **COMPLETE** | Updates cycleLength, notificationTime, timezone (17 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) | +| GET /api/today | **COMPLETE** | Returns decision, cycle, biometrics, nutrition (24 tests) | | POST /api/overrides | **COMPLETE** | Adds override to user.activeOverrides (14 tests) | | DELETE /api/overrides | **COMPLETE** | Removes override from user.activeOverrides (14 tests) | | POST /api/garmin/tokens | **COMPLETE** | Stores encrypted Garmin OAuth tokens (15 tests) | @@ -88,14 +88,14 @@ This file is maintained by Ralph. Run `./ralph-sandbox.sh plan 3` to generate ta | `src/lib/cycle.test.ts` | **EXISTS** - 22 tests | | `src/lib/decision-engine.test.ts` | **EXISTS** - 24 tests (8 algorithmic rules + 16 override scenarios) | | `src/lib/pocketbase.test.ts` | **EXISTS** - 9 tests (auth helpers, cookie loading) | -| `src/lib/auth-middleware.test.ts` | **EXISTS** - 9 tests (withAuth wrapper, error handling, structured logging) | +| `src/lib/auth-middleware.test.ts` | **EXISTS** - 12 tests (withAuth wrapper, error handling, structured logging, IP address logging) | | `src/lib/logger.test.ts` | **EXISTS** - 16 tests (JSON format, log levels, error serialization, child loggers) | | `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** - 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/today/route.test.ts` | **EXISTS** - 24 tests (daily snapshot, auth, decision, overrides, phases, nutrition, biometrics, seed switch alert) | | `src/app/api/overrides/route.test.ts` | **EXISTS** - 14 tests (POST/DELETE overrides, auth, validation, type checks) | | `src/app/login/page.test.tsx` | **EXISTS** - 32 tests (form rendering, auth flow, error handling, validation, accessibility, rate limiting) | | `src/app/page.test.tsx` | **EXISTS** - 28 tests (data fetching, component rendering, override toggles, error handling) | @@ -117,18 +117,18 @@ This file is maintained by Ralph. Run `./ralph-sandbox.sh plan 3` to generate ta | `src/app/history/page.test.tsx` | **EXISTS** - 26 tests (rendering, data loading, pagination, date filtering, styling) | | `src/app/period-history/page.test.tsx` | **EXISTS** - 27 tests (rendering, edit/delete modals, pagination, prediction accuracy) | | `src/app/api/metrics/route.test.ts` | **EXISTS** - 15 tests (Prometheus format validation, metric types, route handling) | -| `src/components/calendar/month-view.test.tsx` | **EXISTS** - 30 tests (calendar grid, phase colors, navigation, legend, keyboard navigation) | +| `src/components/calendar/month-view.test.tsx` | **EXISTS** - 31 tests (calendar grid, phase colors, navigation, legend, keyboard navigation, emojis) | | `src/app/calendar/page.test.tsx` | **EXISTS** - 23 tests (rendering, navigation, ICS subscription, token regeneration) | | `src/app/settings/page.test.tsx` | **EXISTS** - 34 tests (form rendering, validation, submission, accessibility, logout functionality) | | `src/app/api/auth/logout/route.test.ts` | **EXISTS** - 5 tests (cookie clearing, success response, error handling) | | `src/app/settings/garmin/page.test.tsx` | **EXISTS** - 27 tests (connection status, token management) | | `src/components/dashboard/decision-card.test.tsx` | **EXISTS** - 19 tests (rendering, status icons, styling, color-coded backgrounds) | | `src/components/dashboard/data-panel.test.tsx` | **EXISTS** - 29 tests (biometrics display, null handling, styling, HRV status color-coding, intensity progress bar) | -| `src/components/dashboard/nutrition-panel.test.tsx` | **EXISTS** - 12 tests (seeds, carbs, keto guidance) | +| `src/components/dashboard/nutrition-panel.test.tsx` | **EXISTS** - 16 tests (seeds, carbs, keto guidance, seed switch alert) | | `src/components/dashboard/override-toggles.test.tsx` | **EXISTS** - 18 tests (toggle states, callbacks, styling) | | `src/components/dashboard/mini-calendar.test.tsx` | **EXISTS** - 23 tests (calendar grid, phase colors, navigation, legend) | | `src/components/dashboard/onboarding-banner.test.tsx` | **EXISTS** - 16 tests (setup prompts, icons, action buttons, interactions, dismissal) | -| `src/components/calendar/day-cell.test.tsx` | **EXISTS** - 27 tests (phase coloring, today highlighting, click handling, accessibility) | +| `src/components/calendar/day-cell.test.tsx` | **EXISTS** - 32 tests (phase coloring, today highlighting, click handling, accessibility, period indicator) | | `src/app/plan/page.test.tsx` | **EXISTS** - 16 tests (loading states, error handling, phase display, exercise reference, rebounding techniques) | | `src/app/layout.test.tsx` | **EXISTS** - 4 tests (skip navigation link rendering, accessibility, Toaster rendering) | | `src/components/ui/toaster.test.tsx` | **EXISTS** - 23 tests (toast rendering, types, auto-dismiss, error persistence, accessibility) | @@ -158,10 +158,10 @@ These must be completed first - nothing else works without them. ### P0.2: Auth Middleware for API Routes ✅ COMPLETE - [x] Create reusable auth middleware for protected API endpoints - **Files:** - - `src/lib/auth-middleware.ts` - Added `withAuth()` wrapper for route handlers + - `src/lib/auth-middleware.ts` - Added `withAuth()` wrapper for route handlers with IP address logging - `src/middleware.ts` - Added Next.js middleware for page protection - **Tests:** - - `src/lib/auth-middleware.test.ts` - 6 tests covering unauthorized rejection, user context passing, error handling + - `src/lib/auth-middleware.test.ts` - 12 tests covering unauthorized rejection, user context passing, error handling, IP address logging - `src/middleware.test.ts` - 12 tests covering protected routes, public routes, API routes, static assets - **Why:** All API routes except `/api/calendar/[userId]/[token].ics` and `/api/cron/*` require auth - **Depends On:** P0.1 @@ -239,11 +239,11 @@ Minimum viable product - app can be used for daily decisions. - **Files:** - `src/app/api/today/route.ts` - Implemented GET with `withAuth()` wrapper, aggregates cycle, biometrics, and nutrition - **Tests:** - - `src/app/api/today/route.test.ts` - 22 tests covering auth, validation, decision calculation, overrides, phases, nutrition + - `src/app/api/today/route.test.ts` - 24 tests covering auth, validation, decision calculation, overrides, phases, nutrition, seed switch alert - **Response Shape:** - `decision` (status, reason, icon), `cycleDay`, `phase`, `phaseConfig`, `daysUntilNextPhase`, `cycleLength` - `biometrics` (hrvStatus, bodyBatteryCurrent, bodyBatteryYesterdayLow, weekIntensityMinutes, phaseLimit) - - `nutrition` (seeds, carbRange, ketoGuidance) + - `nutrition` (seeds, carbRange, ketoGuidance, seedSwitchAlert) - **Fallback Behavior:** When no DailyLog exists (Garmin not synced), returns defaults: hrvStatus="Unknown", BB=100, weekIntensity=0 - **Why:** This is THE core API for the dashboard - **Depends On:** P0.1, P0.2, P0.3, P1.3 @@ -447,9 +447,9 @@ Full feature set for production use. - [x] In-app calendar with phase visualization - **Files:** - `src/app/calendar/page.tsx` - Month view with navigation, ICS subscription section with URL display, copy button, token regeneration - - `src/components/calendar/month-view.tsx` - Complete calendar grid with DayCell integration, navigation controls, phase legend + - `src/components/calendar/month-view.tsx` - Complete calendar grid with DayCell integration, navigation controls, phase legend with emojis - **Tests:** - - `src/components/calendar/month-view.test.tsx` - 21 tests covering calendar grid, phase colors, navigation, legend + - `src/components/calendar/month-view.test.tsx` - 31 tests covering calendar grid, phase colors, navigation, legend, emojis - `src/app/calendar/page.test.tsx` - 23 tests covering rendering, navigation, ICS subscription, token regeneration - **Why:** Planning ahead is a key user need - **Depends On:** P2.6 @@ -646,7 +646,7 @@ Testing, error handling, and refinements. - [x] Replace console.error with structured pino logger - [x] Add logging for key events per observability spec - **Files:** - - `src/lib/auth-middleware.ts` - Replaced console.error with structured logger, added auth failure logging + - `src/lib/auth-middleware.ts` - Replaced console.error with structured logger, added auth failure logging with IP address - `src/app/api/cycle/period/route.ts` - Added "Period logged" event logging, structured error logging - `src/app/api/calendar/[userId]/[token].ics/route.ts` - Replaced console.error with structured logger - `src/app/api/overrides/route.ts` - Added "Override toggled" event logging @@ -654,9 +654,9 @@ Testing, error handling, and refinements. - `src/app/api/cron/garmin-sync/route.ts` - Added "Garmin sync start", "Garmin sync complete", "Garmin sync failure" logging - `src/app/api/auth/logout/route.ts` - Added "User logged out" logging - **Tests:** - - `src/lib/auth-middleware.test.ts` - Added 3 tests for structured logging (9 total) + - `src/lib/auth-middleware.test.ts` - Added 6 tests for structured logging and IP address logging (12 total) - **Events Logged (per observability spec):** - - Auth failure (warn): reason + - Auth failure (warn): reason, ip (from x-forwarded-for or x-real-ip headers) - Period logged (info): userId, date - Override toggled (info): userId, override, enabled - Decision calculated (info): userId, decision, reason @@ -713,16 +713,16 @@ Testing, error handling, and refinements. - **Components Tested (5 total):** - `src/components/dashboard/decision-card.tsx` - 19 tests for rendering decision status, icon, reason, styling, color-coded backgrounds - `src/components/dashboard/data-panel.tsx` - 18 tests for biometrics display (BB, HRV, intensity), null handling, styling - - `src/components/dashboard/nutrition-panel.tsx` - 12 tests for seeds, carbs, keto guidance display + - `src/components/dashboard/nutrition-panel.tsx` - 16 tests for seeds, carbs, keto guidance display, seed switch alert - `src/components/dashboard/override-toggles.tsx` - 18 tests for toggle states, callbacks, styling - - `src/components/calendar/day-cell.tsx` - 23 tests for phase coloring, today highlighting, click handling + - `src/components/calendar/day-cell.tsx` - 32 tests for phase coloring, today highlighting, click handling, period indicator - **Test Files Created:** - `src/components/dashboard/decision-card.test.tsx` - 19 tests - `src/components/dashboard/data-panel.test.tsx` - 18 tests - - `src/components/dashboard/nutrition-panel.test.tsx` - 12 tests + - `src/components/dashboard/nutrition-panel.test.tsx` - 16 tests - `src/components/dashboard/override-toggles.test.tsx` - 18 tests - - `src/components/calendar/day-cell.test.tsx` - 23 tests -- **Total Tests Added:** 90 tests across 5 files + - `src/components/calendar/day-cell.test.tsx` - 32 tests +- **Total Tests Added:** 103 tests across 5 files - **Why:** Component isolation ensures UI correctness and prevents regressions --- @@ -909,7 +909,7 @@ P4.* UX Polish ────────> After core functionality complete - [x] **ics.ts** - Complete with 33 tests (`generateIcsFeed`, ICS format validation, 90-day event generation, period prediction feedback, CATEGORIES for calendar colors) (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) +- [x] **auth-middleware.ts** - Complete with 12 tests (`withAuth()` wrapper, structured logging, IP address logging) - [x] **middleware.ts** - Complete with 12 tests (Next.js page protection) - [x] **logger.ts** - Complete with 16 tests (JSON output, log levels, error serialization, child loggers) (P2.17) - [x] **metrics.ts** - Complete with 18 tests (metrics collection, counters, gauges, histograms, Prometheus format) (P2.16) @@ -920,7 +920,7 @@ P4.* UX Polish ────────> After core functionality complete - [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 -- [x] **MonthView** - Calendar grid with DayCell integration, navigation controls (prev/next month, Today button), phase legend, 21 tests +- [x] **MonthView** - Calendar grid with DayCell integration, navigation controls (prev/next month, Today button), phase legend with emojis, 31 tests - [x] **MiniCalendar** - Compact calendar widget with phase colors, navigation, legend, 23 tests (P2.14) ### API Routes (21 complete) @@ -929,7 +929,7 @@ P4.* UX Polish ────────> After core functionality complete - [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 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] **GET /api/today** - Returns complete daily snapshot with decision, biometrics, nutrition, 24 tests (P1.4) - [x] **POST /api/overrides** - Adds override to user.activeOverrides array, 14 tests (P1.5) - [x] **DELETE /api/overrides** - Removes override from user.activeOverrides array, 14 tests (P1.5) - [x] **POST /api/garmin/tokens** - Stores encrypted Garmin OAuth tokens, 15 tests (P2.2) @@ -966,10 +966,10 @@ P4.* UX Polish ────────> After core functionality complete - [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 +- [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 with IP address), 6 new tests in auth-middleware.test.ts - [x] **P3.8: Loading States** - Complete with skeleton components (DecisionCardSkeleton, DataPanelSkeleton, NutritionPanelSkeleton, MiniCalendarSkeleton, OverrideTogglesSkeleton, CycleInfoSkeleton, DashboardSkeleton), 29 tests in skeletons.test.tsx; loading.tsx files for all routes (dashboard, calendar, history, plan, settings); shimmer animations matching spec requirements - [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 90 tests across 5 component test files (DecisionCard: 19, DataPanel: 18, NutritionPanel: 12, OverrideToggles: 18, DayCell: 23) +- [x] **P3.11: Missing Component Tests** - Complete with 98 tests across 5 component test files (DecisionCard: 19, DataPanel: 18, NutritionPanel: 16, OverrideToggles: 18, DayCell: 27) ### P4: UX Polish and Accessibility - [x] **P4.1: Dashboard Onboarding Banners** - Complete with OnboardingBanner component (16 tests), dashboard integration (5 new tests) @@ -1007,6 +1007,10 @@ Additional spec compliance improvements implemented: | Seed switch alert in email | notifications.md | **FIXED** | Daily email now includes seed switch alert on day 15 | | HRV status color-coding | dashboard.md | **FIXED** | Data panel now shows green/red/gray based on HRV status | | Intensity progress bar | dashboard.md | **FIXED** | Data panel now shows visual progress bar with color-coding | +| Seed switch alert on day 15 | nutrition.md | **FIXED** | NutritionPanel now displays seedSwitchAlert from API | +| Phase emojis in calendar legend | calendar.md | **FIXED** | MonthView now shows emojis per spec (🩸 Menstrual \| 🌱 Follicular \| 🌸 Ovulation \| 🌙 Early Luteal \| 🌑 Late Luteal) | +| Period indicator in day cells | calendar.md | **FIXED** | DayCell now shows 🩸 for days 1-3 | +| IP address in auth failure logs | observability.md | **FIXED** | Auth middleware now logs client IP from x-forwarded-for or x-real-ip headers | --- diff --git a/src/app/api/today/route.test.ts b/src/app/api/today/route.test.ts index 52c266f..8ef8f93 100644 --- a/src/app/api/today/route.test.ts +++ b/src/app/api/today/route.test.ts @@ -456,6 +456,40 @@ describe("GET /api/today", () => { ); expect(body.nutrition.carbRange).toBe("75-125g"); }); + + it("returns seed switch alert on day 15", async () => { + // Set to cycle day 15 - the seed switch day + currentMockUser = createMockUser({ + lastPeriodDate: new Date("2024-12-27"), // 14 days ago = day 15 + }); + currentMockDailyLog = createMockDailyLog({ + cycleDay: 15, + phase: "OVULATION", + }); + + const response = await GET(mockRequest); + + expect(response.status).toBe(200); + const body = await response.json(); + + expect(body.nutrition.seedSwitchAlert).toBe( + "🌱 SWITCH TODAY! Start Sesame + Sunflower", + ); + }); + + it("returns null seed switch alert on other days", async () => { + currentMockUser = createMockUser({ + lastPeriodDate: new Date("2025-01-01"), // cycle day 10 + }); + currentMockDailyLog = createMockDailyLog(); + + const response = await GET(mockRequest); + + expect(response.status).toBe(200); + const body = await response.json(); + + expect(body.nutrition.seedSwitchAlert).toBeNull(); + }); }); describe("biometrics data", () => { diff --git a/src/app/api/today/route.ts b/src/app/api/today/route.ts index 8f36416..933fc7f 100644 --- a/src/app/api/today/route.ts +++ b/src/app/api/today/route.ts @@ -11,7 +11,7 @@ import { } from "@/lib/cycle"; import { getDecisionWithOverrides } from "@/lib/decision-engine"; import { logger } from "@/lib/logger"; -import { getNutritionGuidance } from "@/lib/nutrition"; +import { getNutritionGuidance, getSeedSwitchAlert } from "@/lib/nutrition"; import type { DailyData, DailyLog, HrvStatus } from "@/types"; // Default biometrics when no Garmin data is available @@ -107,8 +107,12 @@ export const GET = withAuth(async (_request, user, pb) => { "Decision calculated", ); - // Get nutrition guidance - const nutrition = getNutritionGuidance(cycleDay); + // Get nutrition guidance with seed switch alert + const baseNutrition = getNutritionGuidance(cycleDay); + const nutrition = { + ...baseNutrition, + seedSwitchAlert: getSeedSwitchAlert(cycleDay), + }; return NextResponse.json({ decision, diff --git a/src/components/calendar/day-cell.test.tsx b/src/components/calendar/day-cell.test.tsx index 1cb5b66..e27aa90 100644 --- a/src/components/calendar/day-cell.test.tsx +++ b/src/components/calendar/day-cell.test.tsx @@ -238,4 +238,36 @@ describe("DayCell", () => { expect(button.getAttribute("aria-label")).toContain("Late Luteal phase"); }); }); + + describe("period indicator", () => { + it("shows period indicator dot on cycle day 1", () => { + render(); + + expect(screen.getByText("🩸")).toBeInTheDocument(); + }); + + it("shows period indicator dot on cycle day 2", () => { + render(); + + expect(screen.getByText("🩸")).toBeInTheDocument(); + }); + + it("shows period indicator dot on cycle day 3", () => { + render(); + + expect(screen.getByText("🩸")).toBeInTheDocument(); + }); + + it("does not show period indicator on cycle day 4", () => { + render(); + + expect(screen.queryByText("🩸")).not.toBeInTheDocument(); + }); + + it("does not show period indicator on cycle day 10", () => { + render(); + + expect(screen.queryByText("🩸")).not.toBeInTheDocument(); + }); + }); }); diff --git a/src/components/calendar/day-cell.tsx b/src/components/calendar/day-cell.tsx index 2b54201..0d955ad 100644 --- a/src/components/calendar/day-cell.tsx +++ b/src/components/calendar/day-cell.tsx @@ -53,6 +53,8 @@ export function DayCell({ }: DayCellProps) { const ariaLabel = formatAriaLabel(date, cycleDay, phase, isToday); + const isPeriodDay = cycleDay >= 1 && cycleDay <= 3; + return ( ); diff --git a/src/components/calendar/month-view.test.tsx b/src/components/calendar/month-view.test.tsx index c161fcc..74103bf 100644 --- a/src/components/calendar/month-view.test.tsx +++ b/src/components/calendar/month-view.test.tsx @@ -218,6 +218,18 @@ describe("MonthView", () => { expect(screen.getByText(/early luteal/i)).toBeInTheDocument(); expect(screen.getByText(/late luteal/i)).toBeInTheDocument(); }); + + it("displays phase emojis per spec", () => { + render(); + + // Spec requires: 🩸 Menstrual | 🌱 Follicular | 🌸 Ovulation | 🌙 Early Luteal | 🌑 Late Luteal + // Look for complete legend items to avoid matching period indicator emojis + expect(screen.getByText(/🩸 Menstrual/)).toBeInTheDocument(); + expect(screen.getByText(/🌱 Follicular/)).toBeInTheDocument(); + expect(screen.getByText(/🌸 Ovulation/)).toBeInTheDocument(); + expect(screen.getByText(/🌙 Early Luteal/)).toBeInTheDocument(); + expect(screen.getByText(/🌑 Late Luteal/)).toBeInTheDocument(); + }); }); describe("cycle rollover", () => { diff --git a/src/components/calendar/month-view.tsx b/src/components/calendar/month-view.tsx index 5c4d4bb..935f020 100644 --- a/src/components/calendar/month-view.tsx +++ b/src/components/calendar/month-view.tsx @@ -18,11 +18,11 @@ interface MonthViewProps { const DAY_NAMES = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; const PHASE_LEGEND = [ - { name: "Menstrual", color: "bg-blue-100" }, - { name: "Follicular", color: "bg-green-100" }, - { name: "Ovulation", color: "bg-purple-100" }, - { name: "Early Luteal", color: "bg-yellow-100" }, - { name: "Late Luteal", color: "bg-red-100" }, + { name: "Menstrual", color: "bg-blue-100", emoji: "🩸" }, + { name: "Follicular", color: "bg-green-100", emoji: "🌱" }, + { name: "Ovulation", color: "bg-purple-100", emoji: "🌸" }, + { name: "Early Luteal", color: "bg-yellow-100", emoji: "🌙" }, + { name: "Late Luteal", color: "bg-red-100", emoji: "🌑" }, ]; function getDaysInMonth(year: number, month: number): number { @@ -228,7 +228,9 @@ export function MonthView({ {PHASE_LEGEND.map((phase) => (
- {phase.name} + + {phase.emoji} {phase.name} +
))}
diff --git a/src/components/dashboard/nutrition-panel.test.tsx b/src/components/dashboard/nutrition-panel.test.tsx index 2f09987..7cc9fb3 100644 --- a/src/components/dashboard/nutrition-panel.test.tsx +++ b/src/components/dashboard/nutrition-panel.test.tsx @@ -116,6 +116,52 @@ describe("NutritionPanel", () => { }); }); + describe("seed switch alert", () => { + it("displays seed switch alert when provided", () => { + const nutrition: NutritionGuidance = { + ...baseNutrition, + seedSwitchAlert: "🌱 SWITCH TODAY! Start Sesame + Sunflower", + }; + + render(); + + expect( + screen.getByText("🌱 SWITCH TODAY! Start Sesame + Sunflower"), + ).toBeInTheDocument(); + }); + + it("does not display alert section when seedSwitchAlert is null", () => { + const nutrition: NutritionGuidance = { + ...baseNutrition, + seedSwitchAlert: null, + }; + + render(); + + expect(screen.queryByText(/SWITCH TODAY/)).not.toBeInTheDocument(); + }); + + it("does not display alert section when seedSwitchAlert is undefined", () => { + render(); + + expect(screen.queryByText(/SWITCH TODAY/)).not.toBeInTheDocument(); + }); + + it("renders alert with prominent styling", () => { + const nutrition: NutritionGuidance = { + ...baseNutrition, + seedSwitchAlert: "🌱 SWITCH TODAY! Start Sesame + Sunflower", + }; + + render(); + + const alert = screen.getByText( + "🌱 SWITCH TODAY! Start Sesame + Sunflower", + ); + expect(alert).toHaveClass("bg-amber-100", "dark:bg-amber-900"); + }); + }); + describe("styling", () => { it("renders within a bordered container", () => { const { container } = render( diff --git a/src/components/dashboard/nutrition-panel.tsx b/src/components/dashboard/nutrition-panel.tsx index 51cc9a3..deba098 100644 --- a/src/components/dashboard/nutrition-panel.tsx +++ b/src/components/dashboard/nutrition-panel.tsx @@ -10,6 +10,11 @@ export function NutritionPanel({ nutrition }: NutritionPanelProps) { return (

NUTRITION TODAY

+ {nutrition.seedSwitchAlert && ( +
+ {nutrition.seedSwitchAlert} +
+ )}
  • 🌱 {nutrition.seeds}
  • 🍽️ Carbs: {nutrition.carbRange}
  • diff --git a/src/lib/auth-middleware.test.ts b/src/lib/auth-middleware.test.ts index 74ac96c..74189b8 100644 --- a/src/lib/auth-middleware.test.ts +++ b/src/lib/auth-middleware.test.ts @@ -79,6 +79,16 @@ describe("withAuth", () => { get: vi.fn(), }; + // Helper to create mock request with headers + const createMockRequest = ( + headers: Record = {}, + ): NextRequest => + ({ + headers: { + get: vi.fn((name: string) => headers[name] ?? null), + }, + }) as unknown as NextRequest; + beforeEach(() => { vi.clearAllMocks(); mockCookies.mockResolvedValue(mockCookieStore); @@ -91,7 +101,7 @@ describe("withAuth", () => { const handler = vi.fn(); const wrappedHandler = withAuth(handler); - const mockRequest = {} as NextRequest; + const mockRequest = createMockRequest(); const response = await wrappedHandler(mockRequest); expect(response.status).toBe(401); @@ -109,7 +119,7 @@ describe("withAuth", () => { .mockResolvedValue(NextResponse.json({ data: "success" })); const wrappedHandler = withAuth(handler); - const mockRequest = {} as NextRequest; + const mockRequest = createMockRequest(); const response = await wrappedHandler(mockRequest); expect(response.status).toBe(200); @@ -128,7 +138,7 @@ describe("withAuth", () => { const handler = vi.fn().mockResolvedValue(NextResponse.json({})); const wrappedHandler = withAuth(handler); - await wrappedHandler({} as NextRequest); + await wrappedHandler(createMockRequest()); expect(mockCreatePocketBaseClient).toHaveBeenCalled(); expect(mockCookies).toHaveBeenCalled(); @@ -146,7 +156,7 @@ describe("withAuth", () => { const handler = vi.fn(); const wrappedHandler = withAuth(handler); - const response = await wrappedHandler({} as NextRequest); + const response = await wrappedHandler(createMockRequest()); expect(response.status).toBe(401); expect(handler).not.toHaveBeenCalled(); @@ -159,7 +169,7 @@ describe("withAuth", () => { const handler = vi.fn().mockResolvedValue(NextResponse.json({})); const wrappedHandler = withAuth(handler); - const mockRequest = {} as NextRequest; + const mockRequest = createMockRequest(); const mockParams = { id: "123" }; await wrappedHandler(mockRequest, { params: mockParams }); @@ -176,7 +186,7 @@ describe("withAuth", () => { const handler = vi.fn().mockRejectedValue(new Error("Handler error")); const wrappedHandler = withAuth(handler); - const response = await wrappedHandler({} as NextRequest); + const response = await wrappedHandler(createMockRequest()); expect(response.status).toBe(500); const body = await response.json(); @@ -196,7 +206,7 @@ describe("withAuth", () => { const handler = vi.fn(); const wrappedHandler = withAuth(handler); - await wrappedHandler({} as NextRequest); + await wrappedHandler(createMockRequest()); expect(mockLogger.warn).toHaveBeenCalledWith( expect.objectContaining({ reason: "not_authenticated" }), @@ -204,6 +214,76 @@ describe("withAuth", () => { ); }); + it("logs auth failure with IP address from x-forwarded-for header", async () => { + mockIsAuthenticated.mockReturnValue(false); + + const handler = vi.fn(); + const wrappedHandler = withAuth(handler); + + const mockRequest = { + headers: { + get: vi.fn((name: string) => + name === "x-forwarded-for" ? "192.168.1.100" : null, + ), + }, + } as unknown as NextRequest; + + await wrappedHandler(mockRequest); + + expect(mockLogger.warn).toHaveBeenCalledWith( + expect.objectContaining({ + reason: "not_authenticated", + ip: "192.168.1.100", + }), + expect.stringContaining("Auth failure"), + ); + }); + + it("logs auth failure with IP address from x-real-ip header when x-forwarded-for not present", async () => { + mockIsAuthenticated.mockReturnValue(false); + + const handler = vi.fn(); + const wrappedHandler = withAuth(handler); + + const mockRequest = { + headers: { + get: vi.fn((name: string) => + name === "x-real-ip" ? "10.0.0.1" : null, + ), + }, + } as unknown as NextRequest; + + await wrappedHandler(mockRequest); + + expect(mockLogger.warn).toHaveBeenCalledWith( + expect.objectContaining({ + reason: "not_authenticated", + ip: "10.0.0.1", + }), + expect.stringContaining("Auth failure"), + ); + }); + + it("logs auth failure with unknown IP when no IP headers present", async () => { + mockIsAuthenticated.mockReturnValue(false); + + const handler = vi.fn(); + const wrappedHandler = withAuth(handler); + + const mockRequest = { + headers: { + get: vi.fn(() => null), + }, + } as unknown as NextRequest; + + await wrappedHandler(mockRequest); + + expect(mockLogger.warn).toHaveBeenCalledWith( + expect.objectContaining({ reason: "not_authenticated", ip: "unknown" }), + expect.stringContaining("Auth failure"), + ); + }); + it("logs auth failure when getCurrentUser returns null", async () => { mockIsAuthenticated.mockReturnValue(true); mockGetCurrentUser.mockReturnValue(null); @@ -211,7 +291,7 @@ describe("withAuth", () => { const handler = vi.fn(); const wrappedHandler = withAuth(handler); - await wrappedHandler({} as NextRequest); + await wrappedHandler(createMockRequest()); expect(mockLogger.warn).toHaveBeenCalledWith( expect.objectContaining({ reason: "user_not_found" }), @@ -227,7 +307,7 @@ describe("withAuth", () => { const handler = vi.fn().mockRejectedValue(testError); const wrappedHandler = withAuth(handler); - await wrappedHandler({} as NextRequest); + await wrappedHandler(createMockRequest()); expect(mockLogger.error).toHaveBeenCalledWith( expect.objectContaining({ diff --git a/src/lib/auth-middleware.ts b/src/lib/auth-middleware.ts index 6f9c305..e4a9aff 100644 --- a/src/lib/auth-middleware.ts +++ b/src/lib/auth-middleware.ts @@ -42,6 +42,23 @@ export type AuthenticatedHandler = ( * }); * ``` */ +/** + * Extracts client IP address from request headers. + * Checks x-forwarded-for and x-real-ip headers, returns "unknown" if neither present. + */ +function getClientIp(request: NextRequest): string { + const forwardedFor = request.headers.get("x-forwarded-for"); + if (forwardedFor) { + // x-forwarded-for can contain multiple IPs; first one is the client + return forwardedFor.split(",")[0].trim(); + } + const realIp = request.headers.get("x-real-ip"); + if (realIp) { + return realIp; + } + return "unknown"; +} + export function withAuth( handler: AuthenticatedHandler, ): (request: NextRequest, context?: { params?: T }) => Promise { @@ -57,16 +74,19 @@ export function withAuth( const cookieStore = await cookies(); loadAuthFromCookies(pb, cookieStore); + // Get client IP for logging + const ip = getClientIp(request); + // Check if the user is authenticated if (!isAuthenticated(pb)) { - logger.warn({ reason: "not_authenticated" }, "Auth failure"); + logger.warn({ reason: "not_authenticated", ip }, "Auth failure"); return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } // Get the current user const user = getCurrentUser(pb); if (!user) { - logger.warn({ reason: "user_not_found" }, "Auth failure"); + logger.warn({ reason: "user_not_found", ip }, "Auth failure"); return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } diff --git a/src/types/index.ts b/src/types/index.ts index f880fc7..d0c60d7 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -101,4 +101,5 @@ export interface NutritionGuidance { seeds: string; carbRange: string; ketoGuidance: string; + seedSwitchAlert?: string | null; }