diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index 0e11393..ad48404 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -4,14 +4,14 @@ This file is maintained by Ralph. Run `./ralph-sandbox.sh plan 3` to generate ta ## Current State Summary -### Overall Status: 977 unit tests passing across 50 test files + 64 E2E tests across 6 files +### Overall Status: 990 unit tests passing across 50 test files + 64 E2E tests across 6 files ### Library Implementation | File | Status | Gap Analysis | |------|--------|--------------| | `cycle.ts` | **COMPLETE** | 22 tests covering all functions including dynamic phase boundaries for variable cycle lengths | | `nutrition.ts` | **COMPLETE** | 17 tests covering getNutritionGuidance, getSeedSwitchAlert, phase-specific carb ranges, keto guidance | -| `email.ts` | **COMPLETE** | 30 tests covering sendDailyEmail, sendPeriodConfirmationEmail, sendTokenExpirationWarning, email formatting, subject lines, structured logging | +| `email.ts` | **COMPLETE** | 32 tests covering sendDailyEmail, sendPeriodConfirmationEmail, sendTokenExpirationWarning, email formatting, subject lines, structured logging | | `ics.ts` | **COMPLETE** | 33 tests covering generateIcsFeed (90 days of phase events), ICS format validation, timezone handling, period prediction feedback, CATEGORIES for calendar colors | | `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 | @@ -100,7 +100,7 @@ This file is maintained by Ralph. Run `./ralph-sandbox.sh plan 3` to generate ta | `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) | | `src/lib/nutrition.test.ts` | **EXISTS** - 17 tests (seed cycling, carb ranges, keto guidance by phase) | -| `src/lib/email.test.ts` | **EXISTS** - 30 tests (email content, subject lines, formatting, token expiration warnings, structured logging) | +| `src/lib/email.test.ts` | **EXISTS** - 32 tests (email content, subject lines, formatting, token expiration warnings, structured logging) | | `src/lib/ics.test.ts` | **EXISTS** - 33 tests (ICS format validation, 90-day event generation, timezone handling, period prediction feedback, CATEGORIES for colors) | | `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) | @@ -123,7 +123,7 @@ This file is maintained by Ralph. Run `./ralph-sandbox.sh plan 3` to generate ta | `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** - 18 tests (biometrics display, null handling, styling) | +| `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/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) | @@ -998,6 +998,16 @@ Analysis of all specs vs implementation revealed these gaps: | Toast notifications | dashboard.md | **COMPLETE** | sonner library + Toaster component + showToast utility (23 tests) | | CI pipeline | testing.md | **COMPLETE** | See P5.3 below | +### Spec Gaps Fixed (2026-01-12) +Additional spec compliance improvements implemented: + +| Gap | Spec | Status | Notes | +|-----|------|--------|-------| +| Email subject line format | notifications.md | **FIXED** | Subject now uses spec format: `PhaseFlow: [STATUS] - Day [cycleDay] ([phase])` | +| 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 | + --- ## P5: Final Items ✅ ALL COMPLETE diff --git a/src/app/page.test.tsx b/src/app/page.test.tsx index a5f705f..39d5636 100644 --- a/src/app/page.test.tsx +++ b/src/app/page.test.tsx @@ -244,7 +244,7 @@ describe("Dashboard", () => { render(); await waitFor(() => { - expect(screen.getByText(/hrv.*balanced/i)).toBeInTheDocument(); + expect(screen.getByTestId("hrv-status")).toHaveTextContent("Balanced"); }); }); diff --git a/src/components/dashboard/data-panel.test.tsx b/src/components/dashboard/data-panel.test.tsx index 747c9c4..3c77e63 100644 --- a/src/components/dashboard/data-panel.test.tsx +++ b/src/components/dashboard/data-panel.test.tsx @@ -36,7 +36,8 @@ describe("DataPanel", () => { it("renders HRV status", () => { render(); - expect(screen.getByText(/HRV: Balanced/)).toBeInTheDocument(); + expect(screen.getByText(/HRV:/)).toBeInTheDocument(); + expect(screen.getByTestId("hrv-status")).toHaveTextContent("Balanced"); }); it("renders week intensity with phase limit", () => { @@ -83,19 +84,42 @@ describe("DataPanel", () => { it("displays Balanced HRV status", () => { render(); - expect(screen.getByText(/HRV: Balanced/)).toBeInTheDocument(); + expect(screen.getByTestId("hrv-status")).toHaveTextContent("Balanced"); }); it("displays Unbalanced HRV status", () => { render(); - expect(screen.getByText(/HRV: Unbalanced/)).toBeInTheDocument(); + expect(screen.getByTestId("hrv-status")).toHaveTextContent("Unbalanced"); }); it("displays Unknown HRV status", () => { render(); - expect(screen.getByText(/HRV: Unknown/)).toBeInTheDocument(); + expect(screen.getByTestId("hrv-status")).toHaveTextContent("Unknown"); + }); + }); + + describe("HRV color-coding", () => { + it("applies green color class for Balanced HRV", () => { + render(); + + const hrvElement = screen.getByTestId("hrv-status"); + expect(hrvElement).toHaveClass("text-green-600"); + }); + + it("applies red color class for Unbalanced HRV", () => { + render(); + + const hrvElement = screen.getByTestId("hrv-status"); + expect(hrvElement).toHaveClass("text-red-600"); + }); + + it("applies gray color class for Unknown HRV", () => { + render(); + + const hrvElement = screen.getByTestId("hrv-status"); + expect(hrvElement).toHaveClass("text-gray-500"); }); }); @@ -140,4 +164,62 @@ describe("DataPanel", () => { expect(heading).toHaveClass("font-semibold"); }); }); + + describe("intensity progress bar", () => { + it("renders a progress bar for week intensity", () => { + render(); + + const progressBar = screen.getByRole("progressbar"); + expect(progressBar).toBeInTheDocument(); + }); + + it("sets progress bar aria-valuenow to weekIntensity", () => { + render(); + + const progressBar = screen.getByRole("progressbar"); + expect(progressBar).toHaveAttribute("aria-valuenow", "120"); + }); + + it("sets progress bar aria-valuemax to phaseLimit", () => { + render(); + + const progressBar = screen.getByRole("progressbar"); + expect(progressBar).toHaveAttribute("aria-valuemax", "200"); + }); + + it("calculates correct width percentage for progress bar", () => { + render(); + + const progressFill = screen.getByTestId("progress-fill"); + expect(progressFill).toHaveStyle({ width: "50%" }); + }); + + it("caps progress bar at 100% when over limit", () => { + render(); + + const progressFill = screen.getByTestId("progress-fill"); + expect(progressFill).toHaveStyle({ width: "100%" }); + }); + + it("shows warning color when approaching limit (>80%)", () => { + render(); + + const progressFill = screen.getByTestId("progress-fill"); + expect(progressFill).toHaveClass("bg-yellow-500"); + }); + + it("shows danger color when over limit", () => { + render(); + + const progressFill = screen.getByTestId("progress-fill"); + expect(progressFill).toHaveClass("bg-red-500"); + }); + + it("shows normal color when well below limit (<80%)", () => { + render(); + + const progressFill = screen.getByTestId("progress-fill"); + expect(progressFill).toHaveClass("bg-green-500"); + }); + }); }); diff --git a/src/components/dashboard/data-panel.tsx b/src/components/dashboard/data-panel.tsx index 214f8c0..04812b9 100644 --- a/src/components/dashboard/data-panel.tsx +++ b/src/components/dashboard/data-panel.tsx @@ -1,5 +1,5 @@ // ABOUTME: Dashboard panel showing biometric data. -// ABOUTME: Displays body battery, HRV, and intensity minutes. +// ABOUTME: Displays body battery, HRV, and intensity minutes with visual indicators. interface DataPanelProps { bodyBatteryCurrent: number | null; bodyBatteryYesterdayLow: number | null; @@ -9,6 +9,27 @@ interface DataPanelProps { remainingMinutes: number; } +function getHrvColorClass(status: string): string { + switch (status) { + case "Balanced": + return "text-green-600"; + case "Unbalanced": + return "text-red-600"; + default: + return "text-gray-500"; + } +} + +function getProgressBarColorClass(percentage: number): string { + if (percentage > 100) { + return "bg-red-500"; + } + if (percentage > 80) { + return "bg-yellow-500"; + } + return "bg-green-500"; +} + export function DataPanel({ bodyBatteryCurrent, bodyBatteryYesterdayLow, @@ -17,16 +38,44 @@ export function DataPanel({ phaseLimit, remainingMinutes, }: DataPanelProps) { + const intensityPercentage = + phaseLimit > 0 ? (weekIntensity / phaseLimit) * 100 : 0; + const displayPercentage = Math.min(intensityPercentage, 100); + return (

YOUR DATA

  • Body Battery: {bodyBatteryCurrent ?? "N/A"}
  • Yesterday Low: {bodyBatteryYesterdayLow ?? "N/A"}
  • -
  • HRV: {hrvStatus}
  • +
  • + HRV:{" "} + + {hrvStatus} + +
  • Week: {weekIntensity}/{phaseLimit} min
  • +
  • +
    +
    +
    +
  • Remaining: {remainingMinutes} min
diff --git a/src/lib/email.test.ts b/src/lib/email.test.ts index 69ec293..d596f59 100644 --- a/src/lib/email.test.ts +++ b/src/lib/email.test.ts @@ -55,11 +55,11 @@ describe("sendDailyEmail", () => { ketoGuidance: "No - exit keto, need carbs for ovulation", }; - it("sends email with correct subject line", async () => { + it("sends email with correct subject line per spec", async () => { await sendDailyEmail(sampleData); expect(mockSend).toHaveBeenCalledWith( expect.objectContaining({ - subject: "Today's Training: 💪 TRAIN", + subject: "PhaseFlow: 💪 TRAIN - Day 15 (OVULATION)", }), ); }); @@ -126,6 +126,23 @@ describe("sendDailyEmail", () => { const call = mockSend.mock.calls[0][0]; expect(call.text).toContain("Auto-generated by PhaseFlow"); }); + + it("includes seed switch alert on day 15", async () => { + // sampleData already has cycleDay: 15 + await sendDailyEmail(sampleData); + const call = mockSend.mock.calls[0][0]; + expect(call.text).toContain("🌱 SWITCH TODAY! Start Sesame + Sunflower"); + }); + + it("does not include seed switch alert on other days", async () => { + const day10Data: DailyEmailData = { + ...sampleData, + cycleDay: 10, + }; + await sendDailyEmail(day10Data); + const call = mockSend.mock.calls[0][0]; + expect(call.text).not.toContain("SWITCH TODAY"); + }); }); describe("sendPeriodConfirmationEmail", () => { diff --git a/src/lib/email.ts b/src/lib/email.ts index d42529d..4a7295f 100644 --- a/src/lib/email.ts +++ b/src/lib/email.ts @@ -4,6 +4,7 @@ import { Resend } from "resend"; import { logger } from "@/lib/logger"; import { emailSentTotal } from "@/lib/metrics"; +import { getSeedSwitchAlert } from "@/lib/nutrition"; const resend = new Resend(process.env.RESEND_API_KEY); @@ -33,7 +34,12 @@ export async function sendDailyEmail( data: DailyEmailData, userId?: string, ): Promise { - const subject = `Today's Training: ${data.decision.icon} ${data.decision.status}`; + // Subject format per spec: PhaseFlow: [STATUS] - Day [cycleDay] ([phase]) + const subject = `PhaseFlow: ${data.decision.icon} ${data.decision.status} - Day ${data.cycleDay} (${data.phase})`; + + // Check for seed switch alert on day 15 + const seedSwitchAlert = getSeedSwitchAlert(data.cycleDay); + const seedSwitchSection = seedSwitchAlert ? `\n\n${seedSwitchAlert}` : ""; const body = `Good morning! @@ -52,7 +58,7 @@ ${data.decision.icon} ${data.decision.reason} 🌱 SEEDS: ${data.seeds} 🍽️ MACROS: ${data.carbRange} -🥑 KETO: ${data.ketoGuidance} +🥑 KETO: ${data.ketoGuidance}${seedSwitchSection} --- Auto-generated by PhaseFlow`;