Add spec compliance improvements: seed switch alert, calendar emojis, period indicator, IP logging
Some checks failed
CI / quality (push) Failing after 28s
Deploy / deploy (push) Successful in 2m38s

- NutritionPanel: Display seed switch alert on day 15 per dashboard spec
- MonthView: Add phase emojis to legend (🩸🌱🌸🌙🌑) per calendar spec
- DayCell: Show period indicator (🩸) for days 1-3 per calendar spec
- Auth middleware: Log client IP from x-forwarded-for/x-real-ip per observability spec
- Updated NutritionGuidance type to include seedSwitchAlert field
- /api/today now returns seedSwitchAlert in nutrition response

Test coverage: 1005 tests (15 new tests added)
- nutrition-panel.test.tsx: +4 tests
- month-view.test.tsx: +1 test
- day-cell.test.tsx: +5 tests
- auth-middleware.test.ts: +3 tests
- today/route.test.ts: +2 tests

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-12 23:33:14 +00:00
parent d613417e47
commit eeeece17bf
12 changed files with 293 additions and 48 deletions

View File

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

View File

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

View File

@@ -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(<DayCell {...baseProps} cycleDay={1} phase="MENSTRUAL" />);
expect(screen.getByText("🩸")).toBeInTheDocument();
});
it("shows period indicator dot on cycle day 2", () => {
render(<DayCell {...baseProps} cycleDay={2} phase="MENSTRUAL" />);
expect(screen.getByText("🩸")).toBeInTheDocument();
});
it("shows period indicator dot on cycle day 3", () => {
render(<DayCell {...baseProps} cycleDay={3} phase="MENSTRUAL" />);
expect(screen.getByText("🩸")).toBeInTheDocument();
});
it("does not show period indicator on cycle day 4", () => {
render(<DayCell {...baseProps} cycleDay={4} phase="FOLLICULAR" />);
expect(screen.queryByText("🩸")).not.toBeInTheDocument();
});
it("does not show period indicator on cycle day 10", () => {
render(<DayCell {...baseProps} cycleDay={10} phase="FOLLICULAR" />);
expect(screen.queryByText("🩸")).not.toBeInTheDocument();
});
});
});

View File

@@ -53,6 +53,8 @@ export function DayCell({
}: DayCellProps) {
const ariaLabel = formatAriaLabel(date, cycleDay, phase, isToday);
const isPeriodDay = cycleDay >= 1 && cycleDay <= 3;
return (
<button
type="button"
@@ -61,7 +63,10 @@ export function DayCell({
data-day={dataDay}
className={`p-2 rounded ${PHASE_COLORS[phase]} ${isToday ? "ring-2 ring-black" : ""}`}
>
<span className="text-sm font-medium">{date.getDate()}</span>
<span className="text-sm font-medium">
{date.getDate()}
{isPeriodDay && <span className="ml-0.5">🩸</span>}
</span>
<span className="text-xs text-gray-500 block">Day {cycleDay}</span>
</button>
);

View File

@@ -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(<MonthView {...baseProps} />);
// 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", () => {

View File

@@ -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) => (
<div key={phase.name} className="flex items-center gap-1">
<div className={`w-4 h-4 rounded ${phase.color}`} />
<span className="text-xs text-gray-600">{phase.name}</span>
<span className="text-xs text-gray-600">
{phase.emoji} {phase.name}
</span>
</div>
))}
</div>

View File

@@ -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(<NutritionPanel nutrition={nutrition} />);
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(<NutritionPanel nutrition={nutrition} />);
expect(screen.queryByText(/SWITCH TODAY/)).not.toBeInTheDocument();
});
it("does not display alert section when seedSwitchAlert is undefined", () => {
render(<NutritionPanel nutrition={baseNutrition} />);
expect(screen.queryByText(/SWITCH TODAY/)).not.toBeInTheDocument();
});
it("renders alert with prominent styling", () => {
const nutrition: NutritionGuidance = {
...baseNutrition,
seedSwitchAlert: "🌱 SWITCH TODAY! Start Sesame + Sunflower",
};
render(<NutritionPanel nutrition={nutrition} />);
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(

View File

@@ -10,6 +10,11 @@ export function NutritionPanel({ nutrition }: NutritionPanelProps) {
return (
<div className="rounded-lg border p-4">
<h3 className="font-semibold mb-4">NUTRITION TODAY</h3>
{nutrition.seedSwitchAlert && (
<div className="mb-3 p-2 rounded bg-amber-100 dark:bg-amber-900 text-sm font-medium">
{nutrition.seedSwitchAlert}
</div>
)}
<ul className="space-y-2 text-sm">
<li>🌱 {nutrition.seeds}</li>
<li>🍽 Carbs: {nutrition.carbRange}</li>

View File

@@ -79,6 +79,16 @@ describe("withAuth", () => {
get: vi.fn(),
};
// Helper to create mock request with headers
const createMockRequest = (
headers: Record<string, string | null> = {},
): 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({

View File

@@ -42,6 +42,23 @@ export type AuthenticatedHandler<T = unknown> = (
* });
* ```
*/
/**
* 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<T = unknown>(
handler: AuthenticatedHandler<T>,
): (request: NextRequest, context?: { params?: T }) => Promise<NextResponse> {
@@ -57,16 +74,19 @@ export function withAuth<T = unknown>(
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 });
}

View File

@@ -101,4 +101,5 @@ export interface NutritionGuidance {
seeds: string;
carbRange: string;
ketoGuidance: string;
seedSwitchAlert?: string | null;
}