Fix spec compliance gaps in email and dashboard
Some checks failed
CI / quality (push) Failing after 28s
Deploy / deploy (push) Successful in 2m39s

- Email subject now follows spec format: PhaseFlow: [STATUS] - Day [cycleDay] ([phase])
- Daily email includes seed switch alert on day 15 (using getSeedSwitchAlert)
- Data panel HRV status now color-coded: green=Balanced, red=Unbalanced, gray=Unknown
- Data panel shows progress bar for week intensity vs phase limit with color thresholds

Adds 13 new tests (990 total).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-12 23:20:18 +00:00
parent 0ea8e2f2b5
commit d613417e47
6 changed files with 179 additions and 15 deletions

View File

@@ -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

View File

@@ -244,7 +244,7 @@ describe("Dashboard", () => {
render(<Dashboard />);
await waitFor(() => {
expect(screen.getByText(/hrv.*balanced/i)).toBeInTheDocument();
expect(screen.getByTestId("hrv-status")).toHaveTextContent("Balanced");
});
});

View File

@@ -36,7 +36,8 @@ describe("DataPanel", () => {
it("renders HRV status", () => {
render(<DataPanel {...baseProps} hrvStatus="Balanced" />);
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(<DataPanel {...baseProps} hrvStatus="Balanced" />);
expect(screen.getByText(/HRV: Balanced/)).toBeInTheDocument();
expect(screen.getByTestId("hrv-status")).toHaveTextContent("Balanced");
});
it("displays Unbalanced HRV status", () => {
render(<DataPanel {...baseProps} hrvStatus="Unbalanced" />);
expect(screen.getByText(/HRV: Unbalanced/)).toBeInTheDocument();
expect(screen.getByTestId("hrv-status")).toHaveTextContent("Unbalanced");
});
it("displays Unknown HRV status", () => {
render(<DataPanel {...baseProps} hrvStatus="Unknown" />);
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(<DataPanel {...baseProps} hrvStatus="Balanced" />);
const hrvElement = screen.getByTestId("hrv-status");
expect(hrvElement).toHaveClass("text-green-600");
});
it("applies red color class for Unbalanced HRV", () => {
render(<DataPanel {...baseProps} hrvStatus="Unbalanced" />);
const hrvElement = screen.getByTestId("hrv-status");
expect(hrvElement).toHaveClass("text-red-600");
});
it("applies gray color class for Unknown HRV", () => {
render(<DataPanel {...baseProps} hrvStatus="Unknown" />);
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(<DataPanel {...baseProps} />);
const progressBar = screen.getByRole("progressbar");
expect(progressBar).toBeInTheDocument();
});
it("sets progress bar aria-valuenow to weekIntensity", () => {
render(<DataPanel {...baseProps} weekIntensity={120} />);
const progressBar = screen.getByRole("progressbar");
expect(progressBar).toHaveAttribute("aria-valuenow", "120");
});
it("sets progress bar aria-valuemax to phaseLimit", () => {
render(<DataPanel {...baseProps} phaseLimit={200} />);
const progressBar = screen.getByRole("progressbar");
expect(progressBar).toHaveAttribute("aria-valuemax", "200");
});
it("calculates correct width percentage for progress bar", () => {
render(<DataPanel {...baseProps} weekIntensity={100} phaseLimit={200} />);
const progressFill = screen.getByTestId("progress-fill");
expect(progressFill).toHaveStyle({ width: "50%" });
});
it("caps progress bar at 100% when over limit", () => {
render(<DataPanel {...baseProps} weekIntensity={250} phaseLimit={200} />);
const progressFill = screen.getByTestId("progress-fill");
expect(progressFill).toHaveStyle({ width: "100%" });
});
it("shows warning color when approaching limit (>80%)", () => {
render(<DataPanel {...baseProps} weekIntensity={170} phaseLimit={200} />);
const progressFill = screen.getByTestId("progress-fill");
expect(progressFill).toHaveClass("bg-yellow-500");
});
it("shows danger color when over limit", () => {
render(<DataPanel {...baseProps} weekIntensity={210} phaseLimit={200} />);
const progressFill = screen.getByTestId("progress-fill");
expect(progressFill).toHaveClass("bg-red-500");
});
it("shows normal color when well below limit (<80%)", () => {
render(<DataPanel {...baseProps} weekIntensity={100} phaseLimit={200} />);
const progressFill = screen.getByTestId("progress-fill");
expect(progressFill).toHaveClass("bg-green-500");
});
});
});

View File

@@ -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 (
<div className="rounded-lg border p-4">
<h3 className="font-semibold mb-4">YOUR DATA</h3>
<ul className="space-y-2 text-sm">
<li>Body Battery: {bodyBatteryCurrent ?? "N/A"}</li>
<li>Yesterday Low: {bodyBatteryYesterdayLow ?? "N/A"}</li>
<li>HRV: {hrvStatus}</li>
<li>
HRV:{" "}
<span
data-testid="hrv-status"
className={getHrvColorClass(hrvStatus)}
>
{hrvStatus}
</span>
</li>
<li>
Week: {weekIntensity}/{phaseLimit} min
</li>
<li className="pt-1">
<div
role="progressbar"
aria-valuenow={weekIntensity}
aria-valuemin={0}
aria-valuemax={phaseLimit}
aria-label="Week intensity progress"
className="h-2 w-full rounded-full bg-gray-200 dark:bg-gray-700"
>
<div
data-testid="progress-fill"
className={`h-full rounded-full transition-all ${getProgressBarColorClass(intensityPercentage)}`}
style={{ width: `${displayPercentage}%` }}
/>
</div>
</li>
<li>Remaining: {remainingMinutes} min</li>
</ul>
</div>

View File

@@ -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", () => {

View File

@@ -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<void> {
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`;