Add period prediction accuracy feedback (P4.5 complete)
All checks were successful
Deploy / deploy (push) Successful in 1m36s
All checks were successful
Deploy / deploy (push) Successful in 1m36s
Implements visual feedback for cycle prediction accuracy in ICS calendar feeds: - Add predictedDate field to PeriodLog type for tracking predicted vs actual dates - POST /api/cycle/period now calculates and stores predictedDate based on previous lastPeriodDate + cycleLength, returns daysEarly/daysLate in response - ICS feed generates "(Predicted)" events when actual period start differs from predicted, with descriptions like "period arrived 2 days early" - Calendar route fetches period logs and passes them to ICS generator This creates an accuracy feedback loop helping users understand their cycle variability over time per calendar.md spec. 807 tests passing across 43 test files. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -4,32 +4,51 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { User } from "@/types";
|
||||
import type { PeriodLog, User } from "@/types";
|
||||
|
||||
// Module-level variable to control mock user lookup
|
||||
let mockUsers: Map<string, User> = new Map();
|
||||
let mockPeriodLogs: PeriodLog[] = [];
|
||||
|
||||
// Mock PocketBase
|
||||
vi.mock("@/lib/pocketbase", () => ({
|
||||
createPocketBaseClient: vi.fn(() => ({
|
||||
collection: vi.fn(() => ({
|
||||
getOne: vi.fn((userId: string) => {
|
||||
const user = mockUsers.get(userId);
|
||||
if (!user) {
|
||||
const error = new Error("Not found");
|
||||
(error as unknown as { status: number }).status = 404;
|
||||
throw error;
|
||||
}
|
||||
collection: vi.fn((name: string) => {
|
||||
if (name === "users") {
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
calendarToken: user.calendarToken,
|
||||
lastPeriodDate: user.lastPeriodDate.toISOString(),
|
||||
cycleLength: user.cycleLength,
|
||||
garminConnected: user.garminConnected,
|
||||
getOne: vi.fn((userId: string) => {
|
||||
const user = mockUsers.get(userId);
|
||||
if (!user) {
|
||||
const error = new Error("Not found");
|
||||
(error as unknown as { status: number }).status = 404;
|
||||
throw error;
|
||||
}
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
calendarToken: user.calendarToken,
|
||||
lastPeriodDate: user.lastPeriodDate.toISOString(),
|
||||
cycleLength: user.cycleLength,
|
||||
garminConnected: user.garminConnected,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}),
|
||||
})),
|
||||
}
|
||||
if (name === "period_logs") {
|
||||
return {
|
||||
getFullList: vi.fn(() =>
|
||||
mockPeriodLogs.map((log) => ({
|
||||
id: log.id,
|
||||
user: log.user,
|
||||
startDate: log.startDate.toISOString(),
|
||||
predictedDate: log.predictedDate?.toISOString() ?? null,
|
||||
created: log.created.toISOString(),
|
||||
})),
|
||||
),
|
||||
};
|
||||
}
|
||||
return {};
|
||||
}),
|
||||
})),
|
||||
}));
|
||||
|
||||
@@ -73,6 +92,7 @@ describe("GET /api/calendar/[userId]/[token].ics", () => {
|
||||
vi.clearAllMocks();
|
||||
mockUsers = new Map();
|
||||
mockUsers.set("user123", mockUser);
|
||||
mockPeriodLogs = [];
|
||||
});
|
||||
|
||||
// Helper to create route context with params
|
||||
@@ -228,4 +248,47 @@ describe("GET /api/calendar/[userId]/[token].ics", () => {
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
it("passes period logs to ICS generator for prediction accuracy", async () => {
|
||||
mockPeriodLogs = [
|
||||
{
|
||||
id: "log1",
|
||||
user: "user123",
|
||||
startDate: new Date("2025-01-10"),
|
||||
predictedDate: new Date("2025-01-12"), // 2 days early
|
||||
created: new Date("2025-01-10"),
|
||||
},
|
||||
{
|
||||
id: "log2",
|
||||
user: "user123",
|
||||
startDate: new Date("2024-12-15"),
|
||||
predictedDate: null, // First log, no prediction
|
||||
created: new Date("2024-12-15"),
|
||||
},
|
||||
];
|
||||
|
||||
const mockRequest = {} as NextRequest;
|
||||
const context = createRouteContext(
|
||||
"user123",
|
||||
"valid-calendar-token-abc123def",
|
||||
);
|
||||
|
||||
await GET(mockRequest, context);
|
||||
|
||||
expect(mockGenerateIcsFeed).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
periodLogs: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: "log1",
|
||||
startDate: expect.any(Date),
|
||||
predictedDate: expect.any(Date),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: "log2",
|
||||
predictedDate: null,
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -37,11 +37,26 @@ export async function GET(_request: NextRequest, { params }: RouteParams) {
|
||||
);
|
||||
}
|
||||
|
||||
// Fetch period logs for prediction accuracy display
|
||||
const periodLogs = await pb.collection("period_logs").getFullList({
|
||||
filter: `user = "${userId}"`,
|
||||
sort: "-startDate",
|
||||
});
|
||||
|
||||
// Generate ICS feed with 90 days of events (3 months)
|
||||
const icsContent = generateIcsFeed({
|
||||
lastPeriodDate: new Date(user.lastPeriodDate as string),
|
||||
cycleLength: user.cycleLength as number,
|
||||
monthsAhead: 3,
|
||||
periodLogs: periodLogs.map((log) => ({
|
||||
id: log.id,
|
||||
user: log.user as string,
|
||||
startDate: new Date(log.startDate as string),
|
||||
predictedDate: log.predictedDate
|
||||
? new Date(log.predictedDate as string)
|
||||
: null,
|
||||
created: new Date(log.created as string),
|
||||
})),
|
||||
});
|
||||
|
||||
// Return ICS content with appropriate headers
|
||||
|
||||
@@ -205,4 +205,105 @@ describe("POST /api/cycle/period", () => {
|
||||
const body = await response.json();
|
||||
expect(body.error).toBe("Failed to update period date");
|
||||
});
|
||||
|
||||
describe("prediction accuracy tracking", () => {
|
||||
it("calculates and stores predictedDate based on previous cycle", async () => {
|
||||
// User's last period was 2024-12-15 with 28-day cycle
|
||||
// Predicted next period: 2024-12-15 + 28 days = 2025-01-12
|
||||
currentMockUser = mockUser;
|
||||
|
||||
const mockRequest = {
|
||||
json: vi.fn().mockResolvedValue({ startDate: "2025-01-10" }),
|
||||
} as unknown as NextRequest;
|
||||
|
||||
const response = await POST(mockRequest);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
// Verify PeriodLog was created with predictedDate
|
||||
expect(mockPbCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
user: "user123",
|
||||
startDate: "2025-01-10",
|
||||
predictedDate: "2025-01-12", // lastPeriodDate (Dec 15) + cycleLength (28)
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns prediction accuracy information in response", async () => {
|
||||
currentMockUser = mockUser;
|
||||
|
||||
const mockRequest = {
|
||||
json: vi.fn().mockResolvedValue({ startDate: "2025-01-10" }),
|
||||
} as unknown as NextRequest;
|
||||
|
||||
const response = await POST(mockRequest);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const body = await response.json();
|
||||
|
||||
expect(body.predictedDate).toBe("2025-01-12");
|
||||
expect(body.daysEarly).toBe(2); // Arrived 2 days early
|
||||
});
|
||||
|
||||
it("handles period arriving late (positive daysLate)", async () => {
|
||||
currentMockUser = mockUser;
|
||||
|
||||
// Period arrives 3 days after predicted (2025-01-15 instead of 2025-01-12)
|
||||
const mockRequest = {
|
||||
json: vi.fn().mockResolvedValue({ startDate: "2025-01-15" }),
|
||||
} as unknown as NextRequest;
|
||||
|
||||
const response = await POST(mockRequest);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const body = await response.json();
|
||||
|
||||
expect(body.predictedDate).toBe("2025-01-12");
|
||||
expect(body.daysLate).toBe(3);
|
||||
});
|
||||
|
||||
it("sets predictedDate to null when user has no previous lastPeriodDate", async () => {
|
||||
// First period log - no previous cycle data
|
||||
currentMockUser = {
|
||||
...mockUser,
|
||||
lastPeriodDate: null as unknown as Date,
|
||||
};
|
||||
|
||||
const mockRequest = {
|
||||
json: vi.fn().mockResolvedValue({ startDate: "2025-01-10" }),
|
||||
} as unknown as NextRequest;
|
||||
|
||||
const response = await POST(mockRequest);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
// Should not include predictedDate for first log
|
||||
expect(mockPbCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
user: "user123",
|
||||
startDate: "2025-01-10",
|
||||
predictedDate: null,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("handles period arriving on predicted date exactly", async () => {
|
||||
currentMockUser = mockUser;
|
||||
|
||||
// Period arrives exactly on predicted date (2025-01-12)
|
||||
const mockRequest = {
|
||||
json: vi.fn().mockResolvedValue({ startDate: "2025-01-12" }),
|
||||
} as unknown as NextRequest;
|
||||
|
||||
const response = await POST(mockRequest);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const body = await response.json();
|
||||
|
||||
expect(body.predictedDate).toBe("2025-01-12");
|
||||
expect(body.daysEarly).toBeUndefined();
|
||||
expect(body.daysLate).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -65,15 +65,25 @@ export const POST = withAuth(async (request: NextRequest, user) => {
|
||||
|
||||
const pb = createPocketBaseClient();
|
||||
|
||||
// Calculate predicted date based on previous cycle (if exists)
|
||||
let predictedDateStr: string | null = null;
|
||||
if (user.lastPeriodDate) {
|
||||
const previousPeriod = new Date(user.lastPeriodDate);
|
||||
const predictedDate = new Date(previousPeriod);
|
||||
predictedDate.setDate(previousPeriod.getDate() + user.cycleLength);
|
||||
predictedDateStr = predictedDate.toISOString().split("T")[0];
|
||||
}
|
||||
|
||||
// Update user's lastPeriodDate
|
||||
await pb.collection("users").update(user.id, {
|
||||
lastPeriodDate: body.startDate,
|
||||
});
|
||||
|
||||
// Create PeriodLog record
|
||||
// Create PeriodLog record with prediction data
|
||||
await pb.collection("period_logs").create({
|
||||
user: user.id,
|
||||
startDate: body.startDate,
|
||||
predictedDate: predictedDateStr,
|
||||
});
|
||||
|
||||
// Calculate updated cycle information
|
||||
@@ -81,6 +91,22 @@ export const POST = withAuth(async (request: NextRequest, user) => {
|
||||
const cycleDay = getCycleDay(lastPeriodDate, user.cycleLength, new Date());
|
||||
const phase = getPhase(cycleDay);
|
||||
|
||||
// Calculate prediction accuracy
|
||||
let daysEarly: number | undefined;
|
||||
let daysLate: number | undefined;
|
||||
if (predictedDateStr) {
|
||||
const actual = new Date(body.startDate);
|
||||
const predicted = new Date(predictedDateStr);
|
||||
const diffDays = Math.floor(
|
||||
(predicted.getTime() - actual.getTime()) / (1000 * 60 * 60 * 24),
|
||||
);
|
||||
if (diffDays > 0) {
|
||||
daysEarly = diffDays;
|
||||
} else if (diffDays < 0) {
|
||||
daysLate = Math.abs(diffDays);
|
||||
}
|
||||
}
|
||||
|
||||
// Log successful period logging per observability spec
|
||||
logger.info({ userId: user.id, date: body.startDate }, "Period logged");
|
||||
|
||||
@@ -89,6 +115,9 @@ export const POST = withAuth(async (request: NextRequest, user) => {
|
||||
lastPeriodDate: body.startDate,
|
||||
cycleDay,
|
||||
phase,
|
||||
...(predictedDateStr && { predictedDate: predictedDateStr }),
|
||||
...(daysEarly !== undefined && { daysEarly }),
|
||||
...(daysLate !== undefined && { daysLate }),
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error({ err: error, userId: user.id }, "Period logging error");
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// ABOUTME: Tests phase events, warning events, and ICS format validity.
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { PeriodLog } from "@/types";
|
||||
import { generateIcsFeed } from "./ics";
|
||||
|
||||
describe("generateIcsFeed", () => {
|
||||
@@ -196,4 +197,129 @@ describe("generateIcsFeed", () => {
|
||||
expect(ics).toContain("SUMMARY:");
|
||||
});
|
||||
});
|
||||
|
||||
describe("prediction accuracy feedback", () => {
|
||||
it("generates predicted event when period arrived early", () => {
|
||||
const periodLogs: PeriodLog[] = [
|
||||
{
|
||||
id: "log1",
|
||||
user: "user1",
|
||||
startDate: new Date("2025-01-10"), // Actual period start
|
||||
predictedDate: new Date("2025-01-12"), // Was predicted for Jan 12
|
||||
created: new Date("2025-01-10"),
|
||||
},
|
||||
];
|
||||
|
||||
const ics = generateIcsFeed({
|
||||
lastPeriodDate: new Date("2025-01-10"),
|
||||
cycleLength: 31,
|
||||
monthsAhead: 1,
|
||||
periodLogs,
|
||||
});
|
||||
|
||||
// Should contain both actual and predicted menstrual events
|
||||
expect(ics).toContain("🔵 MENSTRUAL (Predicted)");
|
||||
expect(ics).toContain("Original prediction");
|
||||
expect(ics).toContain("period arrived 2 days early");
|
||||
});
|
||||
|
||||
it("generates predicted event when period arrived late", () => {
|
||||
const periodLogs: PeriodLog[] = [
|
||||
{
|
||||
id: "log1",
|
||||
user: "user1",
|
||||
startDate: new Date("2025-01-15"), // Actual period start
|
||||
predictedDate: new Date("2025-01-12"), // Was predicted for Jan 12
|
||||
created: new Date("2025-01-15"),
|
||||
},
|
||||
];
|
||||
|
||||
const ics = generateIcsFeed({
|
||||
lastPeriodDate: new Date("2025-01-15"),
|
||||
cycleLength: 31,
|
||||
monthsAhead: 1,
|
||||
periodLogs,
|
||||
});
|
||||
|
||||
// Should contain both actual and predicted menstrual events
|
||||
expect(ics).toContain("🔵 MENSTRUAL (Predicted)");
|
||||
expect(ics).toContain("Original prediction");
|
||||
expect(ics).toContain("period arrived 3 days late");
|
||||
});
|
||||
|
||||
it("does not generate predicted event when period arrived on time", () => {
|
||||
const periodLogs: PeriodLog[] = [
|
||||
{
|
||||
id: "log1",
|
||||
user: "user1",
|
||||
startDate: new Date("2025-01-12"),
|
||||
predictedDate: new Date("2025-01-12"), // Same as actual
|
||||
created: new Date("2025-01-12"),
|
||||
},
|
||||
];
|
||||
|
||||
const ics = generateIcsFeed({
|
||||
lastPeriodDate: new Date("2025-01-12"),
|
||||
cycleLength: 31,
|
||||
monthsAhead: 1,
|
||||
periodLogs,
|
||||
});
|
||||
|
||||
// Should NOT contain predicted event since it was accurate
|
||||
expect(ics).not.toContain("(Predicted)");
|
||||
});
|
||||
|
||||
it("handles period logs without predictedDate (first log)", () => {
|
||||
const periodLogs: PeriodLog[] = [
|
||||
{
|
||||
id: "log1",
|
||||
user: "user1",
|
||||
startDate: new Date("2025-01-01"),
|
||||
predictedDate: null, // First log, no prediction
|
||||
created: new Date("2025-01-01"),
|
||||
},
|
||||
];
|
||||
|
||||
const ics = generateIcsFeed({
|
||||
lastPeriodDate: new Date("2025-01-01"),
|
||||
cycleLength: 31,
|
||||
monthsAhead: 1,
|
||||
periodLogs,
|
||||
});
|
||||
|
||||
// Should work without predicted events
|
||||
expect(ics).toContain("BEGIN:VCALENDAR");
|
||||
expect(ics).not.toContain("(Predicted)");
|
||||
});
|
||||
|
||||
it("generates multiple predicted events for multiple period logs", () => {
|
||||
const periodLogs: PeriodLog[] = [
|
||||
{
|
||||
id: "log1",
|
||||
user: "user1",
|
||||
startDate: new Date("2024-12-01"),
|
||||
predictedDate: new Date("2024-12-03"), // 2 days early
|
||||
created: new Date("2024-12-01"),
|
||||
},
|
||||
{
|
||||
id: "log2",
|
||||
user: "user1",
|
||||
startDate: new Date("2025-01-01"),
|
||||
predictedDate: new Date("2024-12-30"), // 2 days late
|
||||
created: new Date("2025-01-01"),
|
||||
},
|
||||
];
|
||||
|
||||
const ics = generateIcsFeed({
|
||||
lastPeriodDate: new Date("2025-01-01"),
|
||||
cycleLength: 31,
|
||||
monthsAhead: 1,
|
||||
periodLogs,
|
||||
});
|
||||
|
||||
// Should contain predicted events for both periods
|
||||
const predictedMatches = ics.match(/MENSTRUAL \(Predicted\)/g) || [];
|
||||
expect(predictedMatches.length).toBe(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// ABOUTME: Creates subscribable calendar with phase blocks and warnings.
|
||||
import { createEvents, type EventAttributes } from "ics";
|
||||
|
||||
import type { PeriodLog } from "@/types";
|
||||
import { getCycleDay, getPhase, PHASE_CONFIGS } from "./cycle";
|
||||
|
||||
const PHASE_EMOJIS: Record<string, string> = {
|
||||
@@ -16,10 +17,16 @@ interface IcsGeneratorOptions {
|
||||
lastPeriodDate: Date;
|
||||
cycleLength: number;
|
||||
monthsAhead?: number;
|
||||
periodLogs?: PeriodLog[];
|
||||
}
|
||||
|
||||
export function generateIcsFeed(options: IcsGeneratorOptions): string {
|
||||
const { lastPeriodDate, cycleLength, monthsAhead = 3 } = options;
|
||||
const {
|
||||
lastPeriodDate,
|
||||
cycleLength,
|
||||
monthsAhead = 3,
|
||||
periodLogs = [],
|
||||
} = options;
|
||||
const events: EventAttributes[] = [];
|
||||
|
||||
const endDate = new Date();
|
||||
@@ -68,6 +75,43 @@ export function generateIcsFeed(options: IcsGeneratorOptions): string {
|
||||
// Close final phase
|
||||
events.push(createPhaseEvent(currentPhase, phaseStartDate, currentDate));
|
||||
|
||||
// Add predicted vs actual events from period logs
|
||||
for (const log of periodLogs) {
|
||||
if (!log.predictedDate) {
|
||||
continue; // Skip logs without prediction (first log)
|
||||
}
|
||||
|
||||
const actual = new Date(log.startDate);
|
||||
const predicted = new Date(log.predictedDate);
|
||||
|
||||
// Calculate difference in days
|
||||
const diffMs = predicted.getTime() - actual.getTime();
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
|
||||
// Only show predicted event if dates differ
|
||||
if (diffDays === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Generate predicted menstrual event
|
||||
const predictedEnd = new Date(predicted);
|
||||
predictedEnd.setDate(predicted.getDate() + 3); // Menstrual phase is 3 days
|
||||
|
||||
let description: string;
|
||||
if (diffDays > 0) {
|
||||
description = `Original prediction - period arrived ${diffDays} days early`;
|
||||
} else {
|
||||
description = `Original prediction - period arrived ${Math.abs(diffDays)} days late`;
|
||||
}
|
||||
|
||||
events.push({
|
||||
start: dateToArray(predicted),
|
||||
end: dateToArray(predictedEnd),
|
||||
title: "🔵 MENSTRUAL (Predicted)",
|
||||
description,
|
||||
});
|
||||
}
|
||||
|
||||
const { value, error } = createEvents(events);
|
||||
if (error) {
|
||||
throw new Error(`ICS generation error: ${error}`);
|
||||
|
||||
@@ -64,6 +64,7 @@ export interface PeriodLog {
|
||||
id: string;
|
||||
user: string; // relation
|
||||
startDate: Date;
|
||||
predictedDate: Date | null; // date that was predicted for this period (null for first log)
|
||||
created: Date;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user