Add spec compliance improvements: seed switch alert, calendar emojis, period indicator, IP logging
- 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:
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -101,4 +101,5 @@ export interface NutritionGuidance {
|
||||
seeds: string;
|
||||
carbRange: string;
|
||||
ketoGuidance: string;
|
||||
seedSwitchAlert?: string | null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user