Fix spec compliance gaps in email and dashboard
- 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:
@@ -244,7 +244,7 @@ describe("Dashboard", () => {
|
||||
render(<Dashboard />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/hrv.*balanced/i)).toBeInTheDocument();
|
||||
expect(screen.getByTestId("hrv-status")).toHaveTextContent("Balanced");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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`;
|
||||
|
||||
Reference in New Issue
Block a user