diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index e31579f6f..045e4c31f 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -15585,6 +15585,8 @@ components: anyOf: - $ref: "#/components/schemas/CyclePredictionDTO" - type: "null" + verdict: + $ref: "#/components/schemas/CycleVerdictDTO" stillLearning: type: boolean days: @@ -15697,6 +15699,7 @@ components: required: - profile - prediction + - verdict - stillLearning - days - meta @@ -15771,6 +15774,100 @@ components: - SYMPTOTHERMAL - TEMPERATURE_TREND - BLENDED + CycleVerdictDTO: + type: object + properties: + state: + type: string + enum: + - IN_CYCLE + - OVERDUE + - INSUFFICIENT_DATA + dayOfCycle: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + description: 1-based day of the current cycle. Null in OVERDUE and INSUFFICIENT_DATA — beyond the typical length plus + the server's grace window the count is no longer an observed fact. + cycleLength: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + description: "Days the ring represents: the observed labelled run, or the profile-derived idealized cycle for a low-data + tracker." + phase: + anyOf: + - $ref: "#/components/schemas/CyclePhase" + - type: "null" + spans: + type: array + items: + type: object + properties: + phase: + $ref: "#/components/schemas/CyclePhase" + fraction: + type: number + required: + - phase + - fraction + additionalProperties: false + cycleStartDate: + anyOf: + - type: string + - type: "null" + description: First day of the current cycle (YYYY-MM-DD). Still set while OVERDUE — the last logged period start remains + a fact once the count stops. + overdueDays: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + description: How many days past the profile's typical cycle length the open cycle has run. Set only in OVERDUE. + daysUntilNext: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + description: Days from today to the predicted next period start. Null when no prediction ran and null once that start is + in the past. + fertileWindow: + type: object + properties: + start: + anyOf: + - type: string + - type: "null" + end: + anyOf: + - type: string + - type: "null" + active: + type: boolean + required: + - start + - end + - active + additionalProperties: false + required: + - state + - dayOfCycle + - cycleLength + - phase + - spans + - cycleStartDate + - overdueDays + - daysUntilNext + - fertileWindow + additionalProperties: false + description: The resolved cycle verdict for the user's own timezone day. Render these values; do not recompute them. The + grace window that decides OVERDUE is deliberately not published — read `state` and `overdueDays`. CyclePhase: type: string enum: diff --git a/e2e/cycle-insights-mobile.spec.ts b/e2e/cycle-insights-mobile.spec.ts index a33ea0ad4..030481e19 100644 --- a/e2e/cycle-insights-mobile.spec.ts +++ b/e2e/cycle-insights-mobile.spec.ts @@ -37,20 +37,29 @@ function ymd(d: Date): string { return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; } +/** The labelled run the fixture paints: 5 menstrual, 6 follicular, 1 ovulatory, + * then luteal through today and one day past it. */ +const RUN_START_OFFSET = -12; +const PHASE_PLAN: string[] = [ + ...Array(5).fill("MENSTRUAL"), + ...Array(6).fill("FOLLICULAR"), + "OVULATORY", + "LUTEAL", + "LUTEAL", +]; + +function runStart(): Date { + const start = new Date(); + start.setDate(start.getDate() + RUN_START_OFFSET); + return start; +} + /** A 90-day-back → 180-day-forward calendar with a recent labelled cycle run - * so the wheel resolves a current phase + day and the calendar paints cells. */ + * so the wheel has a phase + day to show and the calendar paints cells. */ function buildCalendarDays(): Array> { const days: Array> = []; - const start = new Date(); - start.setDate(start.getDate() - 12); - // 5 menstrual, 6 follicular, 1 ovulatory, then luteal through today. - const plan: string[] = [ - ...Array(5).fill("MENSTRUAL"), - ...Array(6).fill("FOLLICULAR"), - "OVULATORY", - "LUTEAL", - "LUTEAL", - ]; + const start = runStart(); + const plan = PHASE_PLAN; for (let i = 0; i < plan.length; i++) { const d = new Date(start); d.setDate(start.getDate() + i); @@ -72,6 +81,34 @@ function buildCalendarDays(): Array> { return days; } +/** + * The verdict the server resolves for that grid. The real route publishes this + * and the wheel renders it, so a fixture that omitted it would paint an empty + * ring — deriving it here from the SAME plan keeps the two from drifting. + */ +function buildVerdict(): Record { + const plan = PHASE_PLAN; + const counts = plan.reduce>((acc, phase) => { + acc[phase] = (acc[phase] ?? 0) + 1; + return acc; + }, {}); + const order = ["MENSTRUAL", "FOLLICULAR", "OVULATORY", "LUTEAL"]; + return { + state: "IN_CYCLE", + // The run starts 12 days back, so today is its 13th day. + dayOfCycle: 1 - RUN_START_OFFSET, + cycleLength: plan.length, + phase: plan[-RUN_START_OFFSET], + spans: order + .filter((phase) => (counts[phase] ?? 0) > 0) + .map((phase) => ({ phase, fraction: counts[phase] / plan.length })), + cycleStartDate: ymd(runStart()), + overdueDays: null, + daysUntilNext: null, + fertileWindow: { start: null, end: null, active: false }, + }; +} + const CALENDAR_BODY = { data: { profile: { @@ -81,6 +118,8 @@ const CALENDAR_BODY = { cyclesObserved: 1, }, prediction: null, + verdict: buildVerdict(), + stillLearning: false, days: buildCalendarDays(), meta: { generatedAt: new Date().toISOString() }, }, diff --git a/messages/de.json b/messages/de.json index 301b630f3..a5a24f4da 100644 --- a/messages/de.json +++ b/messages/de.json @@ -7513,6 +7513,7 @@ "caption": "deines Zyklus", "firstPeriodCta": "Erste Periode erfassen", "periodOverdue": "Deine Periode ist später als üblich – die Tageszählung pausiert, bis die nächste Periode erfasst ist.", + "periodOverdueDays": "Deine Periode ist {days} Tage später als üblich – die Tageszählung pausiert, bis die nächste Periode erfasst ist.", "logPeriodCta": "Periode erfassen" }, "calendar": { diff --git a/messages/en.json b/messages/en.json index 50500e27f..a63fa34f2 100644 --- a/messages/en.json +++ b/messages/en.json @@ -7513,6 +7513,7 @@ "caption": "of your cycle", "firstPeriodCta": "Log my first period", "periodOverdue": "Your period is later than usual — the day count pauses until the next period is logged.", + "periodOverdueDays": "Your period is {days} days later than usual — the day count pauses until the next period is logged.", "logPeriodCta": "Log period" }, "calendar": { diff --git a/messages/es.json b/messages/es.json index 602833c88..30e8f2fdf 100644 --- a/messages/es.json +++ b/messages/es.json @@ -7513,6 +7513,7 @@ "caption": "de tu ciclo", "firstPeriodCta": "Registrar mi primer periodo", "periodOverdue": "Tu período llega más tarde de lo habitual — el conteo de días se pausa hasta que registres el próximo período.", + "periodOverdueDays": "Tu período llega {days} días más tarde de lo habitual — el conteo de días se pausa hasta que registres el próximo período.", "logPeriodCta": "Registrar período" }, "calendar": { diff --git a/messages/fr.json b/messages/fr.json index f850b79f5..fde24ba37 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -7513,6 +7513,7 @@ "caption": "de ton cycle", "firstPeriodCta": "Enregistrer mes premières règles", "periodOverdue": "Tes règles arrivent plus tard que d’habitude — le compteur de jours est en pause jusqu’à ce que les prochaines règles soient enregistrées.", + "periodOverdueDays": "Tes règles arrivent {days} jours plus tard que d’habitude — le compteur de jours est en pause jusqu’à ce que les prochaines règles soient enregistrées.", "logPeriodCta": "Enregistrer les règles" }, "calendar": { diff --git a/messages/it.json b/messages/it.json index 5362ee653..fc5f686ab 100644 --- a/messages/it.json +++ b/messages/it.json @@ -7513,6 +7513,7 @@ "caption": "del tuo ciclo", "firstPeriodCta": "Registra il mio primo ciclo", "periodOverdue": "Il ciclo è in ritardo rispetto al solito — il conteggio dei giorni resta in pausa finché non registri la prossima mestruazione.", + "periodOverdueDays": "Il ciclo è in ritardo di {days} giorni rispetto al solito — il conteggio dei giorni resta in pausa finché non registri la prossima mestruazione.", "logPeriodCta": "Registra mestruazione" }, "calendar": { diff --git a/messages/pl.json b/messages/pl.json index 83d8db5ae..f34d27c7c 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -7513,6 +7513,7 @@ "caption": "twojego cyklu", "firstPeriodCta": "Zapisz pierwszą miesiączkę", "periodOverdue": "Okres jest późniejszy niż zwykle — licznik dni jest wstrzymany, dopóki nie zapiszesz następnego okresu.", + "periodOverdueDays": "Okres jest {days} dni późniejszy niż zwykle — licznik dni jest wstrzymany, dopóki nie zapiszesz następnego okresu.", "logPeriodCta": "Zapisz okres" }, "calendar": { diff --git a/src/__tests__/cycle-verdict-guard-defeat.test.ts b/src/__tests__/cycle-verdict-guard-defeat.test.ts new file mode 100644 index 000000000..b3b510196 --- /dev/null +++ b/src/__tests__/cycle-verdict-guard-defeat.test.ts @@ -0,0 +1,193 @@ +/** + * The cycle-verdict guard, checked against itself. + * + * A structural check that cannot fail reads as proof and is not. So each case + * below is a disguise someone would plausibly reach for while putting the + * client-side cycle derivation back — renaming the constant, inlining the + * number, lifting it into a variable one line above the comparison (the move + * that defeated a guard in this repo before), hiding it in an object, in a + * default parameter, behind a helper — and each is asserted to be caught by + * the SAME detector the real guard runs. + * + * The second half matters just as much: a detector that flags everything is + * a detector nobody keeps. So the innocent shapes are pinned too, including + * the one the client is SUPPOSED to write. + * + * These sources are strings on purpose. They are the shapes that must not + * ship, so none of them may live in a file the app can import. + */ +import { describe, expect, it } from "vitest"; + +import { + derivationFindings, + parse, + type FindingKind, +} from "./helpers/cycle-verdict-derivation"; + +const FILE = "disguise.tsx"; + +/** Every cycle-context source needs the vocabulary a real derivation would use. */ +const PREAMBLE = `import type { CalendarDay, CyclePhase } from "./types";\n`; + +function kinds(source: string): FindingKind[] { + return derivationFindings(parse(FILE, PREAMBLE + source)).map((f) => f.kind); +} + +describe("the guard catches the derivation it was written for", () => { + it("catches the exact constant that was copied", () => { + expect( + kinds(`const OVERDUE_GRACE_DAYS = 14; + export function wheel(days: CalendarDay[]) { return days; }`), + ).toContain("grace-constant"); + }); + + it("catches it renamed — it reads the shape, not the old name", () => { + expect(kinds(`const graceWindow = 14;`)).toContain("grace-constant"); + expect(kinds(`const overdueAfterDays = 21;`)).toContain("grace-constant"); + }); + + it("catches it hidden in an object literal", () => { + expect( + kinds(`const tuning = { overdueGraceDays: 14, cycleLength: 28 };`), + ).toContain("grace-constant"); + }); + + it("catches it hidden in a default parameter", () => { + expect( + kinds( + `export function wheel(days: CalendarDay[], graceDays = 14) { return days.length > graceDays; }`, + ), + ).toContain("grace-constant"); + }); + + it("catches the verdict being computed rather than read", () => { + expect( + kinds( + `const dayOfCycle = 43; + const typical = 28; + const periodOverdue = dayOfCycle > typical + 14;`, + ), + ).toContain("client-derived-verdict"); + }); + + it("catches the threshold LIFTED INTO A VARIABLE one line up", () => { + // The move that defeated a guard here before: no literal and no sum sit + // next to the comparison any more, and nothing is named suspiciously. + // The detector follows the local binding instead of reading the line. + expect( + kinds( + `const limit = 28; + const slack = 14; + const ceiling = limit + slack; + const dayOfCycle = 43; + const stale = dayOfCycle > ceiling;`, + ), + ).toContain("day-count-threshold"); + }); + + it("catches it lifted twice", () => { + expect( + kinds( + `const limit = 28; + const slack = 14; + const ceiling = limit + slack; + const bound = ceiling; + const dayOfCycle = 43; + const stale = dayOfCycle > bound;`, + ), + ).toContain("day-count-threshold"); + }); + + it("catches the sum written inline", () => { + expect( + kinds( + `const limit = 28; + const slack = 14; + const dayOfCycle = 43; + const stale = dayOfCycle > limit + slack;`, + ), + ).toContain("day-count-threshold"); + }); + + it("catches the shape with no suspicious name anywhere", () => { + // Nothing here is called grace, overdue, or late. The count compared + // against a sum is the whole tell. + expect( + kinds( + `function ring(dayOfCycle: number, a: number, b: number) { + return dayOfCycle >= a + b ? null : dayOfCycle; + }`, + ), + ).toContain("day-count-threshold"); + }); + + it("catches it with the operands swapped", () => { + expect( + kinds( + `function ring(dayOfCycle: number, a: number, b: number) { + return a + b < dayOfCycle; + }`, + ), + ).toContain("day-count-threshold"); + }); + + it("catches a class field carrying the window", () => { + expect(kinds(`class Wheel { private overdueGrace = 14; }`)).toContain( + "grace-constant", + ); + }); +}); + +describe("the guard stays quiet on innocent code", () => { + it("says nothing about reading the published state — the correct shape", () => { + expect( + kinds( + `const verdict = calendar.data?.verdict; + const periodOverdue = verdict?.state === "OVERDUE"; + const dayOfCycle = verdict?.dayOfCycle ?? null; + const overdueDays = verdict?.overdueDays ?? null;`, + ), + ).toEqual([]); + }); + + it("says nothing about drawing the ring from the published numbers", () => { + expect( + kinds( + `function ring(dayOfCycle: number | null, cycleLength: number | null) { + if (dayOfCycle == null || cycleLength == null || cycleLength <= 0) return 0; + return Math.min((dayOfCycle - 1) / cycleLength, 1); + }`, + ), + ).toEqual([]); + }); + + it("says nothing about a cycle-day count printed in copy", () => { + expect( + kinds( + `const label = dayOfCycle != null ? t("cycle.ring.dayOfCycle", { day: dayOfCycle }) : null;`, + ), + ).toEqual([]); + }); + + it("says nothing about an overdue medication dose", () => { + // The same English word, a different domain, no cycle vocabulary — the + // medication card decides its own overdue state and always has. + expect( + derivationFindings( + parse( + "medication-card.tsx", + `const overdue = dueAt != null && dueAt + graceMinutes < now; + const OVERDUE_GRACE_MINUTES = 30;`, + ), + ), + ).toEqual([]); + }); + + it("says nothing about an unrelated grace constant outside the cycle", () => { + expect( + derivationFindings( + parse("tour-launcher.tsx", `const POST_WIZARD_GRACE_MS = 4000;`), + ), + ).toEqual([]); + }); +}); diff --git a/src/__tests__/cycle-verdict-single-decider-guard.test.ts b/src/__tests__/cycle-verdict-single-decider-guard.test.ts new file mode 100644 index 000000000..4d39d23bc --- /dev/null +++ b/src/__tests__/cycle-verdict-single-decider-guard.test.ts @@ -0,0 +1,123 @@ +/** + * One decider for the cycle verdict. + * + * The server resolves the cycle day, the ring's arcs, the fertile-window state + * and the overdue judgement with its day count, and publishes them on + * `GET /api/cycle/calendar`. Client code renders that. It does not recompute + * any part of it, and it does not keep its own grace-period constant — while + * such a constant exists in client code it can be copied into another client, + * which is exactly how a cycle ring came to show a person a day their record + * did not hold. + * + * Two halves, because either one alone is satisfiable by broken code: + * nothing in the client tree may derive the verdict, AND the surfaces that + * show it must still be reading the published field. + */ +import { readFileSync, readdirSync, statSync } from "node:fs"; +import { join, relative as relativeTo } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { + accessPaths, + derivationFindings, + parse, +} from "./helpers/cycle-verdict-derivation"; + +const ROOT = process.cwd(); + +/** Client-side code: the component tree, the hooks, and the app's pages. */ +const CLIENT_ROOTS = ["src/components", "src/hooks", "src/app"]; +const SKIP_DIRS = new Set(["__tests__", "node_modules", ".next", "generated"]); + +function walk(dir: string, out: string[] = []): string[] { + for (const name of readdirSync(dir)) { + if (SKIP_DIRS.has(name)) continue; + const p = join(dir, name); + if (statSync(p).isDirectory()) { + walk(p, out); + } else if ( + (name.endsWith(".ts") || name.endsWith(".tsx")) && + !name.endsWith(".test.ts") && + !name.endsWith(".test.tsx") && + // Route handlers under `src/app/api` are the server; the verdict is + // resolved there on purpose. + !p.includes(`${join("src", "app", "api")}`) + ) { + out.push(relativeTo(ROOT, p)); + } + } + return out; +} + +function clientFiles(): string[] { + return CLIENT_ROOTS.flatMap((root) => walk(join(ROOT, root))); +} + +function parseFile(relative: string) { + return parse(relative, readFileSync(join(ROOT, relative), "utf8")); +} + +describe("the cycle verdict is decided in one place", () => { + it("finds no client file that re-derives it", () => { + const files = clientFiles(); + // A guard over an empty file list proves nothing. + expect(files.length).toBeGreaterThan(100); + + const findings = files.flatMap((relative) => + derivationFindings(parseFile(relative)), + ); + + expect( + findings.map((f) => `${f.file}:${f.line} ${f.kind} — ${f.where}`), + ).toEqual([]); + }); + + it("keeps the grace window out of client code entirely", () => { + // The specific constant that was copied. Named here so its return is loud + // rather than merely structural. + const offenders = clientFiles().filter((relative) => + readFileSync(join(ROOT, relative), "utf8").includes("OVERDUE_GRACE_DAYS"), + ); + expect(offenders).toEqual([]); + }); +}); + +describe("the surfaces that show the verdict actually read it", () => { + const CONSUMERS = [ + "src/components/cycle/cycle-view.tsx", + "src/components/cycle/cycle-ring-tile.tsx", + "src/components/cycle/cycle-insight-summary-card.tsx", + ]; + + it.each(CONSUMERS)("%s reads the published verdict", (relative) => { + const paths = accessPaths(parseFile(relative)); + const readsVerdict = [...paths].some((p) => p.endsWith(".verdict")); + expect(readsVerdict).toBe(true); + }); + + it("the temperature chart takes the cycle start from the verdict", () => { + // The BBT chart used to walk the calendar itself to find the cycle start, + // which was a second copy of the same walk. It now takes the resolved + // value as a prop, and the cycle page feeds it from the verdict. + const chart = readFileSync( + join(ROOT, "src/components/cycle/bbt-chart.tsx"), + "utf8", + ); + expect(chart).toContain("cycleStartDate"); + + const paths = accessPaths(parseFile("src/components/cycle/cycle-view.tsx")); + expect([...paths]).toContain("verdict.cycleStartDate"); + }); + + it("the calendar response type declares the verdict as always present", () => { + // An optional verdict is a plausible default that lets a client fall back + // to deriving its own. + const types = readFileSync( + join(ROOT, "src/components/cycle/types.ts"), + "utf8", + ); + expect(types).toContain("verdict: CycleVerdict;"); + expect(types).not.toContain("verdict?:"); + }); +}); diff --git a/src/__tests__/helpers/cycle-verdict-derivation.ts b/src/__tests__/helpers/cycle-verdict-derivation.ts new file mode 100644 index 000000000..c15f1af59 --- /dev/null +++ b/src/__tests__/helpers/cycle-verdict-derivation.ts @@ -0,0 +1,367 @@ +/** + * The detector behind the cycle-verdict single-decider guard. + * + * The cycle ring's day count, its phase arcs and the judgement that a period is + * late are resolved once, on the server, and published on + * `GET /api/cycle/calendar`. A second copy of that reasoning in client code is + * not a duplicate detail; it is a second answer about a person's body, and the + * last time it existed it froze someone's ring on a day their record did not + * hold. + * + * A text search would not hold this line. The grace window can be renamed, + * inlined as a bare `14`, or lifted into a variable one statement above the + * comparison — a defeat this repo has already watched happen to a different + * guard. So this reads the syntax tree and looks for three things: + * + * 1. a numeric constant named like a grace / overdue window; + * 2. a binding named like an overdue verdict whose value is COMPUTED from a + * comparison rather than read from the published `state`; + * 3. the threshold shape itself — a cycle-day count compared against a sum — + * whatever the pieces happen to be called. + * + * Rule 3 is what survives the rename: the constant can go anywhere, but the + * comparison has to exist somewhere for the client to reach a verdict, and it + * has the same shape wherever it hides. + */ +import ts from "typescript"; + +export type FindingKind = + "grace-constant" | "client-derived-verdict" | "day-count-threshold"; + +export interface Finding { + kind: FindingKind; + /** The binding or expression text that tripped the rule. */ + where: string; + file: string; + line: number; +} + +/** Names that read as a grace / overdue window. */ +const GRACE_NAME = /grace|overdue/i; +/** Names that read as "is the period late?". */ +const VERDICT_NAME = /overdue|periodlate|islate|isoverdue/i; +/** Names that read as a cycle-day count. */ +const DAY_COUNT_NAME = /day.?of.?cycle|cycleday|cyclerun|runlength/i; + +/** + * Markers that put a file in cycle territory. "Overdue" is a word other parts + * of the product use honestly — a medication dose is overdue, a screening is + * overdue — so the rules below only apply where the cycle's own vocabulary is + * present. A file cannot re-derive the cycle verdict without touching the + * calendar's types, its phase names, or the verdict's own fields, so nothing + * that could actually commit the defect escapes by renaming one identifier. + */ +const CYCLE_MARKERS = new Set([ + "CalendarDay", + "CalendarResponse", + "CyclePhase", + "CycleVerdict", + "CycleVerdictState", + "useCycleCalendar", + "dayOfCycle", + "cycleLength", + "cycleStartDate", + "typicalCycleLength", + "lutealPhaseLength", + "periodOverdue", + "MENSTRUAL", + "FOLLICULAR", + "OVULATORY", + "LUTEAL", +]); + +/** The published states. A comparison against one of these is a READ. */ +const PUBLISHED_STATES = new Set(["IN_CYCLE", "OVERDUE", "INSUFFICIENT_DATA"]); + +const COMPARISON_OPERATORS = new Set([ + ts.SyntaxKind.GreaterThanToken, + ts.SyntaxKind.GreaterThanEqualsToken, + ts.SyntaxKind.LessThanToken, + ts.SyntaxKind.LessThanEqualsToken, + ts.SyntaxKind.EqualsEqualsToken, + ts.SyntaxKind.EqualsEqualsEqualsToken, + ts.SyntaxKind.ExclamationEqualsToken, + ts.SyntaxKind.ExclamationEqualsEqualsToken, +]); + +const ARITHMETIC_OPERATORS = new Set([ + ts.SyntaxKind.PlusToken, + ts.SyntaxKind.MinusToken, +]); + +export function parse(fileName: string, source: string): ts.SourceFile { + return ts.createSourceFile( + fileName, + source, + ts.ScriptTarget.Latest, + true, + fileName.endsWith("x") ? ts.ScriptKind.TSX : ts.ScriptKind.TS, + ); +} + +/** Strip parentheses, `as`, `satisfies`, `!` so the shape underneath shows. */ +function unwrap(node: ts.Expression): ts.Expression { + let cur = node; + for (;;) { + if ( + ts.isParenthesizedExpression(cur) || + ts.isAsExpression(cur) || + ts.isSatisfiesExpression(cur) || + ts.isNonNullExpression(cur) + ) { + cur = cur.expression; + continue; + } + return cur; + } +} + +/** A plain number literal, with or without a leading minus. */ +function isNumericConstant(node: ts.Expression | undefined): boolean { + if (!node) return false; + const inner = unwrap(node); + if (ts.isNumericLiteral(inner)) return true; + return ( + ts.isPrefixUnaryExpression(inner) && + inner.operator === ts.SyntaxKind.MinusToken && + ts.isNumericLiteral(inner.operand) + ); +} + +/** Every identifier-ish name appearing anywhere under `node`. */ +function namesUnder(node: ts.Node): string[] { + const out: string[] = []; + function visit(n: ts.Node): void { + if (ts.isIdentifier(n)) out.push(n.text); + ts.forEachChild(n, visit); + } + visit(node); + return out; +} + +/** Whether the expression compares against the PUBLISHED verdict state. */ +function readsPublishedState(node: ts.Node): boolean { + let found = false; + function visit(n: ts.Node): void { + if (found) return; + if (ts.isStringLiteral(n) && PUBLISHED_STATES.has(n.text)) { + found = true; + return; + } + if ( + (ts.isPropertyAccessExpression(n) || ts.isElementAccessExpression(n)) && + ts.isPropertyAccessExpression(n) && + n.name.text === "state" + ) { + found = true; + return; + } + ts.forEachChild(n, visit); + } + visit(node); + return found; +} + +/** Whether a comparison lives anywhere under `node`. */ +function containsComparison(node: ts.Node): boolean { + let found = false; + function visit(n: ts.Node): void { + if (found) return; + if ( + ts.isBinaryExpression(n) && + COMPARISON_OPERATORS.has(n.operatorToken.kind) + ) { + found = true; + return; + } + ts.forEachChild(n, visit); + } + visit(node); + return found; +} + +/** The name of a declaration, when it has a plain one. */ +function declaredName( + name: ts.Node | undefined, + file: ts.SourceFile, +): string | null { + if (!name) return null; + if (ts.isIdentifier(name)) return name.text; + if (ts.isStringLiteral(name)) return name.text; + if (ts.isComputedPropertyName(name)) return null; + if (ts.isPrivateIdentifier(name)) return name.text; + return name.getText(file); +} + +/** Every `const NAME = ` in the file, so one line up is not a hiding place. */ +function localBindings(file: ts.SourceFile): Map { + const bindings = new Map(); + function visit(node: ts.Node): void { + if ( + ts.isVariableDeclaration(node) && + ts.isIdentifier(node.name) && + node.initializer + ) { + bindings.set(node.name.text, node.initializer); + } + ts.forEachChild(node, visit); + } + visit(file); + return bindings; +} + +/** + * Whether the expression is a sum, following local `const` bindings. + * + * `dayOfCycle > typical + grace` and + * `const ceiling = typical + grace; dayOfCycle > ceiling` are the same + * derivation written two ways, and lifting the sum into a variable one line + * above the comparison is precisely how a structural guard was talked around + * here before. Two hops is enough for any readable version of the move. + */ +function isSumLike( + node: ts.Expression, + bindings: Map, + seen: Set = new Set(), + depth = 0, +): boolean { + const inner = unwrap(node); + if ( + ts.isBinaryExpression(inner) && + ARITHMETIC_OPERATORS.has(inner.operatorToken.kind) + ) { + return true; + } + if (depth >= 3) return false; + if (ts.isIdentifier(inner) && !seen.has(inner.text)) { + const bound = bindings.get(inner.text); + if (bound) { + return isSumLike( + bound, + bindings, + new Set(seen).add(inner.text), + depth + 1, + ); + } + } + return false; +} + +/** Whether the file speaks the cycle's vocabulary at all. */ +export function isCycleContext(file: ts.SourceFile): boolean { + let found = false; + function visit(n: ts.Node): void { + if (found) return; + if (ts.isIdentifier(n) && CYCLE_MARKERS.has(n.text)) { + found = true; + return; + } + if (ts.isStringLiteral(n) && CYCLE_MARKERS.has(n.text)) { + found = true; + return; + } + ts.forEachChild(n, visit); + } + visit(file); + return found; +} + +/** + * Every way the cycle verdict could be decided again in the file. + * An empty array is the only acceptable result for client code. + */ +export function derivationFindings(file: ts.SourceFile): Finding[] { + const out: Finding[] = []; + if (!isCycleContext(file)) return out; + const bindings = localBindings(file); + + function record(kind: FindingKind, node: ts.Node, where: string): void { + const { line } = file.getLineAndCharacterOfPosition(node.getStart(file)); + out.push({ kind, where, file: file.fileName, line: line + 1 }); + } + + function checkBinding( + node: ts.Node, + nameNode: ts.Node | undefined, + initializer: ts.Expression | undefined, + ): void { + const name = declaredName(nameNode, file); + if (name === null || initializer === undefined) return; + + // 1. A grace / overdue window pinned to a number. + if (GRACE_NAME.test(name) && isNumericConstant(initializer)) { + record("grace-constant", node, name); + return; + } + + // 2. An overdue verdict COMPUTED here. Reading the published `state` is + // exactly what the client is supposed to do, so that shape is allowed. + if ( + VERDICT_NAME.test(name) && + containsComparison(initializer) && + !readsPublishedState(initializer) + ) { + record("client-derived-verdict", node, name); + } + } + + function visit(node: ts.Node): void { + if (ts.isVariableDeclaration(node)) { + checkBinding(node, node.name, node.initializer); + } else if (ts.isPropertyAssignment(node)) { + checkBinding(node, node.name, node.initializer); + } else if (ts.isPropertyDeclaration(node)) { + checkBinding(node, node.name, node.initializer); + } else if (ts.isParameter(node)) { + checkBinding(node, node.name, node.initializer); + } else if (ts.isEnumMember(node)) { + checkBinding(node, node.name, node.initializer); + } + + // 3. The threshold shape: a cycle-day count compared against a sum. The + // constant can be renamed, inlined, or lifted into a variable one line + // up; this comparison still has to exist for a client to decide. + if ( + ts.isBinaryExpression(node) && + COMPARISON_OPERATORS.has(node.operatorToken.kind) + ) { + const hasSum = [node.left, node.right].some((side) => + isSumLike(side, bindings), + ); + const mentionsDayCount = namesUnder(node).some((n) => + DAY_COUNT_NAME.test(n), + ); + if (hasSum && mentionsDayCount) { + record("day-count-threshold", node, node.getText(file).slice(0, 120)); + } + } + + ts.forEachChild(node, visit); + } + + visit(file); + return out; +} + +/** Every property-access path in the file, e.g. `calendar.data.verdict`. */ +export function accessPaths(file: ts.SourceFile): Set { + const paths = new Set(); + function pathOf(expression: ts.Expression): string | null { + const inner = unwrap(expression); + if (ts.isIdentifier(inner)) return inner.text; + if (ts.isPropertyAccessExpression(inner)) { + const base = pathOf(inner.expression); + return base === null ? null : `${base}.${inner.name.text}`; + } + return null; + } + function visit(node: ts.Node): void { + if (ts.isPropertyAccessExpression(node)) { + const p = pathOf(node); + if (p !== null) paths.add(p); + } + ts.forEachChild(node, visit); + } + visit(file); + return paths; +} diff --git a/src/app/api/cycle/calendar/__tests__/verdict-round-trip.test.ts b/src/app/api/cycle/calendar/__tests__/verdict-round-trip.test.ts new file mode 100644 index 000000000..83ca9fb97 --- /dev/null +++ b/src/app/api/cycle/calendar/__tests__/verdict-round-trip.test.ts @@ -0,0 +1,278 @@ +/** + * `GET /api/cycle/calendar` — the resolved verdict actually reaches a client. + * + * Both ends of a payload field can be written correctly and the wire can still + * carry nothing; this repo has shipped exactly that. So nothing here is + * hand-built downstream of the route: the test seeds rows, calls the REAL + * exported `GET`, reads the response body as text, parses it, and asserts on + * what came back. The engine, the calendar builder, the verdict resolver, the + * DTO mapping and the envelope all run for real. + * + * The scenario is the one that froze a cycle ring: several long observed + * cycles push the predicted next start weeks into the future, so every day of + * the gap carries a phase label and the raw count walks past the profile's + * typical length. The verdict must stop counting and say how late the period + * is — resolved against the USER's timezone day, not the process's. + */ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/db", () => ({ + prisma: { + menstrualCycle: { findMany: vi.fn() }, + cycleDayLog: { findMany: vi.fn() }, + measurement: { findMany: vi.fn() }, + auditLog: { create: vi.fn() }, + }, +})); +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: vi.fn().mockResolvedValue({ allowed: true }), + rateLimitHeaders: () => ({}), +})); +vi.mock("@/lib/cycle/gate", () => ({ requireCycleEnabled: vi.fn() })); +// The cache persist is fire-and-forget and would reach the database. +vi.mock("@/lib/cycle/prediction-cache", () => ({ + persistPredictionCache: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { GET } from "../route"; +import { prisma } from "@/lib/db"; +import { getSession } from "@/lib/auth/session"; +import { requireCycleEnabled } from "@/lib/cycle/gate"; +import { phaseForDate } from "@/lib/cycle/engine-adapter"; +import { resolveLuteal } from "@/lib/cycle/prediction"; +import { addDays } from "@/lib/cycle/day-math"; + +/** A user in UTC+14, so their calendar day runs ahead of the process's. */ +const AHEAD_TZ = "Pacific/Kiritimati"; + +function session(timezone: string) { + return { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { + id: "user-1", + username: "tester", + role: "USER" as const, + gender: "FEMALE", + timezone, + locale: "en", + }, + }; +} + +function profile(overrides: Record = {}) { + return { + userId: "user-1", + goal: "GENERAL_HEALTH", + // What the person says their cycle usually is. The observed cycles below + // are much longer, which is what drives the prediction past today. + typicalCycleLength: 28, + typicalPeriodLength: 5, + lutealPhaseLength: 14, + predictionEnabled: true, + rawChartMode: false, + secondarySymptom: "MUCUS", + cycleTrackingEnabled: true, + ...overrides, + }; +} + +/** Four period starts 60 days apart; the last one is still open. */ +const LONG_CYCLE_STARTS = [ + "2025-11-01", + "2025-12-31", + "2026-03-01", + "2026-04-30", +]; + +function cycleRows(starts: readonly string[]) { + return starts.map((startDate, i) => ({ + id: `cyc-${i}`, + userId: "user-1", + startDate, + endDate: null, + periodEndDate: null, + ovulationDate: null, + ovulationConfirmed: false, + isPredicted: false, + })); +} + +async function callCalendar(): Promise<{ + status: number; + body: Record; +}> { + const res = await GET(new NextRequest("http://localhost/api/cycle/calendar")); + // Read the WIRE, not the handler's return value. + const text = await res.text(); + return { status: res.status, body: JSON.parse(text) }; +} + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(getSession).mockResolvedValue(session(AHEAD_TZ) as never); + vi.mocked(requireCycleEnabled).mockResolvedValue({ + enabled: true, + profile: profile(), + } as never); + vi.mocked(prisma.menstrualCycle.findMany).mockResolvedValue( + cycleRows(LONG_CYCLE_STARTS) as never, + ); + vi.mocked(prisma.cycleDayLog.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("GET /api/cycle/calendar carries the resolved verdict", () => { + it("puts the whole verdict on the wire", async () => { + // 2026-06-10 12:00 UTC is already 2026-06-11 for a user at UTC+14. + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-06-10T12:00:00Z")); + + const { status, body } = await callCalendar(); + expect(status).toBe(200); + + const data = body.data as Record; + expect(data).toHaveProperty("verdict"); + const verdict = data.verdict as Record; + + // Every field the contract promises, present on the response itself. + expect(Object.keys(verdict).sort()).toEqual([ + "cycleLength", + "cycleStartDate", + "dayOfCycle", + "daysUntilNext", + "fertileWindow", + "overdueDays", + "phase", + "spans", + "state", + ]); + }); + + it("resolves the overdue judgement in the USER's timezone, with the day count", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-06-10T12:00:00Z")); + + const { body } = await callCalendar(); + const verdict = (body.data as Record).verdict as Record< + string, + unknown + >; + + // The user's day is 2026-06-11: 43 days into a cycle that opened on + // 2026-04-30, past their stated 28-day typical plus the grace window. The + // count stops and the delay is stated: 43 − 28 = 15. + expect(verdict.state).toBe("OVERDUE"); + expect(verdict.dayOfCycle).toBeNull(); + expect(verdict.phase).toBeNull(); + expect(verdict.overdueDays).toBe(15); + // The last logged period start survives the ceiling — the BBT chart scopes + // to it and the ring still draws four arcs. + expect(verdict.cycleStartDate).toBe("2026-04-30"); + expect(verdict.spans).toHaveLength(4); + }); + + it("reads one day earlier for the same instant in UTC — the zone decides", async () => { + // Same wall-clock instant, a user whose day has not rolled over yet. If + // the route resolved `today` from the process instead of the account, this + // case and the one above could not differ. + vi.mocked(getSession).mockResolvedValue(session("UTC") as never); + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-06-10T12:00:00Z")); + + const { body } = await callCalendar(); + const verdict = (body.data as Record).verdict as Record< + string, + unknown + >; + + expect(verdict.state).toBe("IN_CYCLE"); + expect(verdict.dayOfCycle).toBe(42); + expect(verdict.overdueDays).toBeNull(); + }); + + it("agrees with the phase engine the coach read uses", async () => { + // Two independent server derivations of one number: the ring counts the + // labelled run, the coach/MCP block asks the phase engine directly. They + // must not be able to tell a person two different cycle days. + vi.mocked(getSession).mockResolvedValue(session("UTC") as never); + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-05-20T12:00:00Z")); + + const { body } = await callCalendar(); + const data = body.data as Record; + const verdict = data.verdict as Record; + const prediction = data.prediction as { nextPeriodStart: string } | null; + + expect(verdict.state).toBe("IN_CYCLE"); + + const fromEngine = phaseForDate( + "2026-05-20", + cycleRows(LONG_CYCLE_STARTS) as never, + prediction?.nextPeriodStart ?? null, + resolveLuteal(profile() as never), + ); + expect(verdict.dayOfCycle).toBe(fromEngine.dayOfCycle); + expect(verdict.phase).toBe(fromEngine.phase); + }); + + it("counts the days to the next predicted period", async () => { + vi.mocked(getSession).mockResolvedValue(session("UTC") as never); + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-05-20T12:00:00Z")); + + const { body } = await callCalendar(); + const data = body.data as Record; + const verdict = data.verdict as Record; + const prediction = data.prediction as { nextPeriodStart: string } | null; + + expect(prediction).not.toBeNull(); + const expected = Math.round( + (Date.parse(`${prediction!.nextPeriodStart}T12:00:00Z`) - + Date.parse("2026-05-20T12:00:00Z")) / + 86_400_000, + ); + expect(verdict.daysUntilNext).toBe(expected); + expect(verdict.daysUntilNext).toBeGreaterThan(0); + // Sanity: the predicted start really is ahead of today. + expect(prediction!.nextPeriodStart).toBe(addDays("2026-05-20", expected)); + }); + + it("says INSUFFICIENT_DATA when nothing has been logged", async () => { + vi.mocked(prisma.menstrualCycle.findMany).mockResolvedValue([] as never); + vi.mocked(getSession).mockResolvedValue(session("UTC") as never); + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-05-20T12:00:00Z")); + + const { body } = await callCalendar(); + const verdict = (body.data as Record).verdict as Record< + string, + unknown + >; + + expect(verdict.state).toBe("INSUFFICIENT_DATA"); + expect(verdict.dayOfCycle).toBeNull(); + expect(verdict.cycleStartDate).toBeNull(); + expect(verdict.spans).toEqual([]); + }); +}); diff --git a/src/app/api/cycle/calendar/route.ts b/src/app/api/cycle/calendar/route.ts index 0f4c4f38b..830999cfd 100644 --- a/src/app/api/cycle/calendar/route.ts +++ b/src/app/api/cycle/calendar/route.ts @@ -30,6 +30,7 @@ import { goalAllowsFertileWindow, cycleDisclaimerKey, } from "@/lib/cycle/dto"; +import { resolveCycleVerdict } from "@/lib/cycle/verdict"; import { persistPredictionCache } from "@/lib/cycle/prediction-cache"; import { addDays, dayDiff } from "@/lib/cycle/day-math"; import { BBT_WINDOW } from "@/lib/cycle/types"; @@ -173,12 +174,36 @@ export const GET = apiHandler(async (request: NextRequest) => { // "not a contraceptive method" caveat (QA H-1). const disclaimer = t.t(cycleDisclaimerKey(profile.goal)); + const predictionDto = prediction + ? toCyclePredictionDTO(prediction, goalAllowsFertile, disclaimer) + : null; + + // The resolved verdict. Built from the SAME grid the response carries and + // from the ALREADY-GATED prediction DTO, never the raw engine result — a + // fertile window suppressed for the profile's goal or by the cold-start gate + // cannot reappear here. `today` is the user's timezone day, which is the + // other half of why this cannot be a client-side derivation: a browser in a + // different zone resolves a different "today" and reads a different cycle day. + const verdict = resolveCycleVerdict({ + days, + today, + profile: { + typicalCycleLength: profile.typicalCycleLength, + typicalPeriodLength: profile.typicalPeriodLength, + lutealPhaseLength: profile.lutealPhaseLength, + }, + nextPeriodStart: predictionDto?.nextPeriodStart ?? null, + fertileWindowStart: predictionDto?.fertileWindowStart ?? null, + fertileWindowEnd: predictionDto?.fertileWindowEnd ?? null, + }); + annotate({ action: { name: "cycle.calendar.read" }, meta: { days: days.length, cycles_observed: prediction?.cyclesObserved ?? 0, has_prediction: prediction !== null, + verdict_state: verdict.state, }, }); @@ -194,9 +219,11 @@ export const GET = apiHandler(async (request: NextRequest) => { // so a consumer never sees two divergent "cycles observed" values. cyclesObserved: prediction?.cyclesObserved ?? cycles.length, }, - prediction: prediction - ? toCyclePredictionDTO(prediction, goalAllowsFertile, disclaimer) - : null, + prediction: predictionDto, + // The one resolved answer about today: cycle day, the ring's arcs, how + // many days until the next period, and whether the period is late and by + // how much. Every client renders this; none of them recomputes it. + verdict, // Top-level cold-start flag (mirrors `prediction.stillLearning`): the // calendar grid has suppressed fertile/ovulation/phase output, so the // client renders a calm "learning your cycle" banner over the grid. Additive diff --git a/src/components/cycle/__tests__/bbt-chart.test.tsx b/src/components/cycle/__tests__/bbt-chart.test.tsx index 9b65dcd69..296831157 100644 --- a/src/components/cycle/__tests__/bbt-chart.test.tsx +++ b/src/components/cycle/__tests__/bbt-chart.test.tsx @@ -41,6 +41,7 @@ describe("", () => { , @@ -60,6 +61,7 @@ describe("", () => { , diff --git a/src/components/cycle/__tests__/cycle-insight-summary-card.test.tsx b/src/components/cycle/__tests__/cycle-insight-summary-card.test.tsx index 385df6d05..1dc11f247 100644 --- a/src/components/cycle/__tests__/cycle-insight-summary-card.test.tsx +++ b/src/components/cycle/__tests__/cycle-insight-summary-card.test.tsx @@ -25,7 +25,7 @@ vi.mock("@/hooks/use-unit-display", async () => { }); import { I18nProvider } from "@/lib/i18n/context"; -import type { CalendarResponse } from "../types"; +import type { CalendarResponse, CycleVerdict } from "../types"; import type { CycleInsightsResponse } from "../use-cycle"; import type { CyclePhaseCrosstabRow } from "../cycle-phase-crosstab"; @@ -80,8 +80,6 @@ vi.mock("../use-cycle", async () => { ...actual, useCycleCalendar: () => calendarState.current, useCycleInsights: () => insightsState.current, - // The card now reads the profile lengths for the low-data idealized ring. - useCycleProfile: () => ({ data: undefined }), }; }); @@ -93,9 +91,26 @@ function render(node: React.ReactNode) { ); } -/** Build a calendar read where `today` sits on a LUTEAL day at day-of-cycle 3 - * (a short MENSTRUAL run → LUTEAL today, so `deriveWheelState` resolves a - * phase + day). */ +/** The verdict the server would resolve for that grid: LUTEAL, cycle day 3. */ +const LUTEAL_VERDICT: CycleVerdict = { + state: "IN_CYCLE", + dayOfCycle: 3, + cycleLength: 28, + phase: "LUTEAL", + spans: [ + { phase: "MENSTRUAL", fraction: 5 / 28 }, + { phase: "FOLLICULAR", fraction: 7 / 28 }, + { phase: "OVULATORY", fraction: 2 / 28 }, + { phase: "LUTEAL", fraction: 14 / 28 }, + ], + cycleStartDate: null, + overdueDays: null, + daysUntilNext: null, + fertileWindow: { start: null, end: null, active: false }, +}; + +/** A calendar read whose SERVER-RESOLVED verdict puts today on LUTEAL, day 3. + * The card renders the verdict; it no longer derives anything from `days`. */ function lutealCalendar(today: string): CalendarResponse { // Three consecutive days ending today: MENSTRUAL, FOLLICULAR, LUTEAL. const days = [ @@ -107,6 +122,7 @@ function lutealCalendar(today: string): CalendarResponse { days: days as unknown as CalendarResponse["days"], profile: {} as CalendarResponse["profile"], prediction: null, + verdict: LUTEAL_VERDICT, } as CalendarResponse; } diff --git a/src/components/cycle/__tests__/cycle-ring-tile.test.tsx b/src/components/cycle/__tests__/cycle-ring-tile.test.tsx index 59a3b8ee5..192383f05 100644 --- a/src/components/cycle/__tests__/cycle-ring-tile.test.tsx +++ b/src/components/cycle/__tests__/cycle-ring-tile.test.tsx @@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { renderToStaticMarkup } from "react-dom/server"; import { I18nProvider } from "@/lib/i18n/context"; -import type { CalendarResponse } from "../types"; +import type { CalendarResponse, CycleVerdict } from "../types"; /** * v1.15.3 — the compact cycle ring as a wellness-strip tile. @@ -10,7 +10,7 @@ import type { CalendarResponse } from "../types"; * The tile is mounted ONLY when `user.cycleTrackingEnabled` is true (a * page-level gate the Insights page owns, mirroring the summary card); these * tests cover the tile's own behaviour given that gate: it sources the phase + - * cycle day from the SAME calendar read + `deriveWheelState` the wheel uses, + * cycle day from the SAME server-resolved `verdict` the wheel reads, * renders the cycle ring labelled, deep-links to the cycle page, and stays * silent while resolving / on a hard error / when there is no active cycle so * the wellness strip never shows a half-painted dial (no gap, no placeholder). @@ -49,9 +49,6 @@ vi.mock("../use-cycle", async () => { return { ...actual, useCycleCalendar: () => calendarState.current, - // The tile now reads the profile lengths for the low-data idealized ring. - // Mock it at the hook boundary too so the SSR snapshot needs no QueryClient. - useCycleProfile: () => ({ data: undefined }), }; }); @@ -63,7 +60,38 @@ function render(node: React.ReactNode) { ); } -/** A calendar read where `today` sits on a LUTEAL day at day-of-cycle 3. */ +/** The verdict the server would resolve for that grid: LUTEAL, cycle day 3. */ +const LUTEAL_VERDICT: CycleVerdict = { + state: "IN_CYCLE", + dayOfCycle: 3, + cycleLength: 28, + phase: "LUTEAL", + spans: [ + { phase: "MENSTRUAL", fraction: 5 / 28 }, + { phase: "FOLLICULAR", fraction: 7 / 28 }, + { phase: "OVULATORY", fraction: 2 / 28 }, + { phase: "LUTEAL", fraction: 14 / 28 }, + ], + cycleStartDate: null, + overdueDays: null, + daysUntilNext: null, + fertileWindow: { start: null, end: null, active: false }, +}; + +/** The verdict for an account with nothing to say about today. */ +const NO_CYCLE_VERDICT: CycleVerdict = { + state: "INSUFFICIENT_DATA", + dayOfCycle: null, + cycleLength: null, + phase: null, + spans: [], + cycleStartDate: null, + overdueDays: null, + daysUntilNext: null, + fertileWindow: { start: null, end: null, active: false }, +}; + +/** A calendar read whose SERVER-RESOLVED verdict puts today on LUTEAL, day 3. */ function lutealCalendar(today: string): CalendarResponse { const days = [ { date: shift(today, -2), phase: "MENSTRUAL" as const }, @@ -74,6 +102,7 @@ function lutealCalendar(today: string): CalendarResponse { days: days as unknown as CalendarResponse["days"], profile: {} as CalendarResponse["profile"], prediction: null, + verdict: LUTEAL_VERDICT, } as CalendarResponse; } @@ -138,6 +167,7 @@ describe("", () => { days: [] as unknown as CalendarResponse["days"], profile: {} as CalendarResponse["profile"], prediction: null, + verdict: NO_CYCLE_VERDICT, } as CalendarResponse; calendarState.current = { data: empty, isLoading: false, isError: false }; expect(render()).toBe(""); diff --git a/src/components/cycle/__tests__/wheel-state.test.ts b/src/components/cycle/__tests__/wheel-state.test.ts deleted file mode 100644 index 242aa3545..000000000 --- a/src/components/cycle/__tests__/wheel-state.test.ts +++ /dev/null @@ -1,191 +0,0 @@ -import { describe, it, expect } from "vitest"; - -import { deriveWheelState, currentCycleStartDate } from "../wheel-state"; -import type { CalendarDay } from "../types"; - -function day(date: string, phase: CalendarDay["phase"]): CalendarDay { - return { - date, - phase, - isPredictedPeriod: false, - isFertileWindow: false, - isPredictedOvulation: false, - isPeriodLogged: phase === "MENSTRUAL", - flow: null, - hasSymptoms: false, - confidence: 1, - basalBodyTempC: null, - ovulationTest: null, - cervicalMucus: null, - }; -} - -describe("deriveWheelState", () => { - it("returns a null state when today carries no phase", () => { - const days = [day("2026-06-01", null), day("2026-06-02", null)]; - const w = deriveWheelState(days, "2026-06-02"); - expect(w.dayOfCycle).toBeNull(); - expect(w.phase).toBeNull(); - expect(w.spans).toEqual([]); - }); - - it("counts the day-of-cycle from the most recent menstrual start", () => { - // A run starting 2026-06-01 (menstrual) → today 2026-06-05 is cycle day 5. - const days = [ - day("2026-05-30", "LUTEAL"), - day("2026-06-01", "MENSTRUAL"), - day("2026-06-02", "MENSTRUAL"), - day("2026-06-03", "FOLLICULAR"), - day("2026-06-04", "FOLLICULAR"), - day("2026-06-05", "FOLLICULAR"), - ]; - const w = deriveWheelState(days, "2026-06-05"); - expect(w.dayOfCycle).toBe(5); - expect(w.phase).toBe("FOLLICULAR"); - }); - - it("builds proportional phase spans summing to ~1", () => { - // A well-populated run (>= 7 labelled days spanning all four phases) keeps - // the OBSERVED-share spans. Eight forward-labelled days exercise the - // observed path (above the sparse threshold). - const days = [ - day("2026-06-01", "MENSTRUAL"), - day("2026-06-02", "MENSTRUAL"), - day("2026-06-03", "MENSTRUAL"), - day("2026-06-04", "FOLLICULAR"), - day("2026-06-05", "FOLLICULAR"), - day("2026-06-06", "OVULATORY"), - day("2026-06-07", "LUTEAL"), - day("2026-06-08", "LUTEAL"), - ]; - const w = deriveWheelState(days, "2026-06-05"); - const total = w.spans.reduce((s, x) => s + x.fraction, 0); - expect(total).toBeCloseTo(1, 5); - // All four observed phases are emitted, and the observed cycle length - // matches the labelled run (8 days) — NOT the idealized fallback length. - expect(w.spans.map((s) => s.phase).sort()).toEqual([ - "FOLLICULAR", - "LUTEAL", - "MENSTRUAL", - "OVULATORY", - ]); - expect(w.cycleLength).toBe(8); - }); - - it("falls back to canonical four-phase spans for a low-data tracker", () => { - // Only the first few days of a first-ever cycle are logged — a 2-day - // menstrual run. The observed-share path would normalise this so MENSTRUAL - // filled (nearly) the whole ring. The low-data fallback instead draws the - // canonical four phases from the profile lengths. - const days = [ - day("2026-06-01", "MENSTRUAL"), - day("2026-06-02", "MENSTRUAL"), - ]; - const w = deriveWheelState(days, "2026-06-02", { - typicalCycleLength: 28, - typicalPeriodLength: 5, - lutealPhaseLength: 14, - }); - - // The marker read stays driven by the observed labels. - expect(w.dayOfCycle).toBe(2); - expect(w.phase).toBe("MENSTRUAL"); - - // FOUR sensible arcs, not one dominant sliver. The idealized cycle length - // (5 + follicular + 2 + 14 = 28) is represented, and no single phase - // swallows the ring. - expect(w.spans).toHaveLength(4); - expect(w.spans.map((s) => s.phase)).toEqual([ - "MENSTRUAL", - "FOLLICULAR", - "OVULATORY", - "LUTEAL", - ]); - const total = w.spans.reduce((s, x) => s + x.fraction, 0); - expect(total).toBeCloseTo(1, 5); - for (const span of w.spans) { - expect(span.fraction).toBeGreaterThan(0); - expect(span.fraction).toBeLessThan(0.95); - } - expect(w.cycleLength).toBe(28); - }); - - it("uses textbook defaults for the low-data ring when no profile is given", () => { - const days = [day("2026-06-01", "MENSTRUAL")]; - const w = deriveWheelState(days, "2026-06-01"); - expect(w.spans).toHaveLength(4); - // Defaults: 28-day cycle. - expect(w.cycleLength).toBe(28); - expect(w.phase).toBe("MENSTRUAL"); - expect(w.dayOfCycle).toBe(1); - expect(w.periodOverdue).toBe(false); - }); - - // v1.27.5 — a months-old open cycle whose predicted next start moved into - // the future labels every gap day LUTEAL; the wheel must not read that back - // as "day 90 of your cycle". - describe("overdue ceiling", () => { - /** A run of 5 MENSTRUAL days + (n−5) LUTEAL days ending at `today`. */ - function longRun(n: number): { days: CalendarDay[]; today: string } { - const start = Date.UTC(2026, 0, 1); - const days: CalendarDay[] = []; - for (let i = 0; i < n; i++) { - const date = new Date(start + i * 86_400_000) - .toISOString() - .slice(0, 10); - days.push(day(date, i < 5 ? "MENSTRUAL" : "LUTEAL")); - } - return { days, today: days[n - 1].date }; - } - - it("stops asserting a day count beyond typical length + grace", () => { - const { days, today } = longRun(90); - const w = deriveWheelState(days, today, { typicalCycleLength: 28 }); - expect(w.periodOverdue).toBe(true); - expect(w.dayOfCycle).toBeNull(); - expect(w.phase).toBeNull(); - // The ring still draws the canonical four-phase dial. - expect(w.spans).toHaveLength(4); - expect(w.cycleLength).toBe(28); - }); - - it("keeps counting inside the grace window", () => { - // Typical 28 + 14 grace = 42; day 42 is still an honest count. - const { days, today } = longRun(42); - const w = deriveWheelState(days, today, { typicalCycleLength: 28 }); - expect(w.periodOverdue).toBe(false); - expect(w.dayOfCycle).toBe(42); - }); - - it("respects a long typical cycle length from the profile", () => { - // A 60-day typical cycle keeps day 49 honest. - const { days, today } = longRun(49); - const w = deriveWheelState(days, today, { typicalCycleLength: 60 }); - expect(w.periodOverdue).toBe(false); - expect(w.dayOfCycle).toBe(49); - }); - }); -}); - -describe("currentCycleStartDate", () => { - it("returns the most recent menstrual run-start at/before today", () => { - const days = [ - day("2026-05-30", "LUTEAL"), - day("2026-06-01", "MENSTRUAL"), - day("2026-06-02", "MENSTRUAL"), - day("2026-06-03", "FOLLICULAR"), - day("2026-06-04", "FOLLICULAR"), - ]; - expect(currentCycleStartDate(days, "2026-06-04")).toBe("2026-06-01"); - }); - - it("returns null when today carries no phase", () => { - const days = [day("2026-06-01", null), day("2026-06-02", null)]; - expect(currentCycleStartDate(days, "2026-06-02")).toBeNull(); - }); - - it("returns null when today is absent from the calendar", () => { - const days = [day("2026-06-01", "MENSTRUAL")]; - expect(currentCycleStartDate(days, "2026-06-09")).toBeNull(); - }); -}); diff --git a/src/components/cycle/bbt-chart.tsx b/src/components/cycle/bbt-chart.tsx index d3378192c..88528dc5f 100644 --- a/src/components/cycle/bbt-chart.tsx +++ b/src/components/cycle/bbt-chart.tsx @@ -3,8 +3,10 @@ /** * v1.15.0 — the basal-body-temperature (BBT) chart. * - * Plots the user's logged BBT across the CURRENT cycle (the same window the - * wheel shows, via `currentCycleStartDate`) as a line with phase-tinted dots, + * Plots the user's logged BBT across the CURRENT cycle — the same window the + * wheel shows, because both take the cycle start from the server's resolved + * verdict rather than each walking the calendar themselves — as a line with + * phase-tinted dots, * the symptothermal signal a fertility-aware reader expects. Fertility signs * already logged for a day — egg-white/watery mucus, a positive LH test — * surface in the tooltip so the temperature shift reads in context. When the @@ -42,7 +44,6 @@ import { prefersReducedMotion } from "@/lib/charts/reduced-motion"; import { cn } from "@/lib/utils"; import type { CalendarDay, CervicalMucus, OvulationTest } from "./types"; import { PHASE_HUE, OVULATION_HUE } from "./phase-tokens"; -import { currentCycleStartDate } from "./wheel-state"; /** Trailing fallback window (days) when no MENSTRUAL-anchored cycle is active. */ const FALLBACK_WINDOW_DAYS = 35; @@ -60,6 +61,12 @@ interface BbtPoint { export interface BbtChartProps { days: CalendarDay[]; today: string; + /** + * First day of the current cycle, from the calendar read's resolved + * `verdict.cycleStartDate`. Null when no cycle is active, and the chart + * falls back to a trailing window so recent temperatures still show. + */ + cycleStartDate: string | null; /** Estimated ovulation day (`YYYY-MM-DD`), or null when goal-gated off. */ predictedOvulation: string | null; /** Whether the ovulation day was confirmed by a signal layer (solid marker). */ @@ -76,6 +83,7 @@ function ymdToMs(d: string): number { export function BbtChart({ days, today, + cycleStartDate, predictedOvulation, ovulationConfirmed = false, rawChartMode, @@ -84,15 +92,15 @@ export function BbtChart({ const fmt = useFormatters(); const animationsEnabled = !prefersReducedMotion(); - // Scope to the current cycle (wheel window); fall back to a trailing window - // when no cycle is active so a user still sees their recent temperatures. + // Scope to the current cycle using the server's resolved start; fall back to + // a trailing window when no cycle is active so a user still sees their + // recent temperatures. const fromDate = useMemo(() => { - const start = currentCycleStartDate(days, today); - if (start) return start; + if (cycleStartDate) return cycleStartDate; const fallback = new Date(ymdToMs(today)); fallback.setUTCDate(fallback.getUTCDate() - FALLBACK_WINDOW_DAYS); return fallback.toISOString().slice(0, 10); - }, [days, today]); + }, [cycleStartDate, today]); const points = useMemo(() => { const fromMs = ymdToMs(fromDate); diff --git a/src/components/cycle/cycle-insight-summary-card.tsx b/src/components/cycle/cycle-insight-summary-card.tsx index 1fd2c4f48..e0b6457a8 100644 --- a/src/components/cycle/cycle-insight-summary-card.tsx +++ b/src/components/cycle/cycle-insight-summary-card.tsx @@ -8,22 +8,16 @@ import { useTranslations } from "@/lib/i18n/context"; import { SectionHeading } from "@/components/insights/section-heading"; import { CyclePhaseHeadline } from "./cycle-phase-crosstab"; import { PHASE_HUE } from "./phase-tokens"; -import { deriveWheelState } from "./wheel-state"; -import { - localYmd, - useCycleCalendar, - useCycleInsights, - useCycleProfile, -} from "./use-cycle"; +import { localYmd, useCycleCalendar, useCycleInsights } from "./use-cycle"; /** * v1.15.2 — the gated cycle-insights summary card for the main Insights page. * * A compact TEASER + entry point, never a duplicate of the full cycle insights * tab. It surfaces three things and links into the source of truth: - * 1. the current phase (name + hue dot) and cycle day, derived from the - * SAME calendar read + `deriveWheelState` the cycle wheel uses, so the - * two surfaces can never disagree on the day-of-cycle or phase; + * 1. the current phase (name + hue dot) and cycle day, read from the SAME + * server-resolved `verdict` on the calendar read the cycle wheel uses, so + * the two surfaces can never disagree on the day-of-cycle or phase; * 2. the one headline phase finding, rendered through the SHARED * `` (which already owns the FDR-gated copy + the * honest "not enough cycles yet" empty line) — no re-implementation; @@ -54,25 +48,13 @@ export function CycleInsightSummaryCard() { // Mirror the cycle-view window so the cache key is shared (no extra request // when the user later opens the cycle page in the same session). - const today = useMemo(() => shiftToday(0), []); const from = useMemo(() => shiftToday(-90), []); const to = useMemo(() => shiftToday(180), []); const calendar = useCycleCalendar(from, to); const insights = useCycleInsights(); - // Shared 5-min-cached profile read so a low-data tracker still gets the - // canonical four-phase ring (no extra request — shared cache key). - const profile = useCycleProfile(); - - const wheel = useMemo( - () => - deriveWheelState(calendar.data?.days ?? [], today, { - typicalCycleLength: profile.data?.typicalCycleLength, - typicalPeriodLength: profile.data?.typicalPeriodLength, - lutealPhaseLength: profile.data?.lutealPhaseLength, - }), - [calendar.data, today, profile.data], - ); + // The server's resolved verdict — the teaser reports it, never recomputes it. + const verdict = calendar.data?.verdict; // Stay silent until the calendar read resolves, and on a hard error: the // overview must never show a half-painted or error-framed cycle teaser. The @@ -80,10 +62,10 @@ export function CycleInsightSummaryCard() { if (calendar.isLoading && !calendar.data) return null; if (calendar.isError && !calendar.data) return null; - const phase = wheel.phase; + const phase = verdict?.phase ?? null; const hue = phase ? PHASE_HUE[phase] : PHASE_HUE.LUTEAL; const phaseName = phase ? t(`cycle.phase.${phase}`) : t("cycle.phase.none"); - const dayOfCycle = wheel.dayOfCycle; + const dayOfCycle = verdict?.dayOfCycle ?? null; const ariaLabel = phase && dayOfCycle != null diff --git a/src/components/cycle/cycle-ring-tile.tsx b/src/components/cycle/cycle-ring-tile.tsx index c857f1779..9b0342ab2 100644 --- a/src/components/cycle/cycle-ring-tile.tsx +++ b/src/components/cycle/cycle-ring-tile.tsx @@ -8,8 +8,7 @@ import { cn } from "@/lib/utils"; import { useTranslations } from "@/lib/i18n/context"; import { CycleRing } from "./cycle-ring"; import { PHASE_HUE } from "./phase-tokens"; -import { deriveWheelState } from "./wheel-state"; -import { localYmd, useCycleCalendar, useCycleProfile } from "./use-cycle"; +import { localYmd, useCycleCalendar } from "./use-cycle"; /** * v1.15.3 — the compact cycle ring as a WELLNESS-STRIP element. @@ -24,8 +23,9 @@ import { localYmd, useCycleCalendar, useCycleProfile } from "./use-cycle"; * * It is NOT a second `` — that teaser (phase finding + * deep-link) already lives further down the overview. This is the RING only, - * as a wellness dial. Both surfaces derive day-of-cycle + phase from the SAME - * `useCycleCalendar` read + `deriveWheelState`, so they can never disagree. + * as a wellness dial. Both surfaces read day-of-cycle + phase from the SAME + * server-resolved `verdict` on the calendar read, so they can never disagree — + * and neither of them computes it. * * GATING IS THE CALLER'S JOB. The page mounts this only when * `user.cycleTrackingEnabled` is true (the same `/api/auth/me` signal the @@ -48,25 +48,14 @@ export function CycleRingTile({ className }: { className?: string }) { // Mirror the cycle-view + summary-card window so the cache key is shared — // no extra request when the user opens the cycle page in the same session. - const today = useMemo(() => shiftToday(0), []); const from = useMemo(() => shiftToday(-90), []); const to = useMemo(() => shiftToday(180), []); const calendar = useCycleCalendar(from, to); - // Shared 5-min-cached profile read (no extra request when the cycle page or - // summary card already loaded it): its typical lengths let a low-data tracker - // draw the canonical four-phase ring instead of one dominant arc. - const profile = useCycleProfile(); - - const wheel = useMemo( - () => - deriveWheelState(calendar.data?.days ?? [], today, { - typicalCycleLength: profile.data?.typicalCycleLength, - typicalPeriodLength: profile.data?.typicalPeriodLength, - lutealPhaseLength: profile.data?.lutealPhaseLength, - }), - [calendar.data, today, profile.data], - ); + // The server already resolved the ring: cycle day, phase, and the arc + // proportions (including the profile-derived idealized ring a low-data + // tracker gets). The tile renders it. + const verdict = calendar.data?.verdict; // The signature reveal plays ONCE per browser session, gated on its own key // so a background calendar refetch never re-triggers the moment. The @@ -87,9 +76,9 @@ export function CycleRingTile({ className }: { className?: string }) { // empty-framed cycle dial. The full cycle page owns the empty-state CTA. if (calendar.isLoading && !calendar.data) return null; if (calendar.isError && !calendar.data) return null; - if (wheel.phase == null || wheel.dayOfCycle == null) return null; + if (verdict?.phase == null || verdict.dayOfCycle == null) return null; - const hue = PHASE_HUE[wheel.phase]; + const hue = PHASE_HUE[verdict.phase]; return ( // `data-revealed` must sit on an ANCESTOR of `.wellness-tile-rise` — the @@ -102,7 +91,7 @@ export function CycleRingTile({ className }: { className?: string }) { href="/cycle" data-slot="wellness-cycle-tile" data-metric="CYCLE" - data-phase={wheel.phase} + data-phase={verdict.phase} // Match the score `RingTile` chrome: the phase's `--tile-hue` // low-opacity mix over `--card` + the faint film grain (the // `.wellness-tile` family), with a calm once-per-session rise. The @@ -122,10 +111,10 @@ export function CycleRingTile({ className }: { className?: string }) {
@@ -133,7 +122,7 @@ export function CycleRingTile({ className }: { className?: string }) { data-slot="wellness-cycle-band-word" className="text-muted-foreground text-center text-xs" > - {t(`cycle.phase.${wheel.phase}`)} + {t(`cycle.phase.${verdict.phase}`)}
diff --git a/src/components/cycle/cycle-view.tsx b/src/components/cycle/cycle-view.tsx index 0d7b7d450..650093273 100644 --- a/src/components/cycle/cycle-view.tsx +++ b/src/components/cycle/cycle-view.tsx @@ -27,7 +27,6 @@ import { PredictionsPanel } from "./predictions-panel"; import { CyclePhaseHeadline, CyclePhaseCrosstab } from "./cycle-phase-crosstab"; import { CycleSymptomPatterns } from "./cycle-symptom-patterns"; import { CycleSettings } from "./cycle-settings"; -import { deriveWheelState } from "./wheel-state"; import { PHASE_HUE } from "./phase-tokens"; import { localYmd, @@ -139,17 +138,9 @@ export function CycleView() { } }); - // Pass the profile's typical lengths so a low-data tracker (few logged - // cycles) draws the canonical four-phase ring instead of one dominant arc. - const wheel = useMemo( - () => - deriveWheelState(calendar.data?.days ?? [], today, { - typicalCycleLength: profileQuery.data?.typicalCycleLength, - typicalPeriodLength: profileQuery.data?.typicalPeriodLength, - lutealPhaseLength: profileQuery.data?.lutealPhaseLength, - }), - [calendar.data, today, profileQuery.data], - ); + // The server's resolved verdict — cycle day, the ring's arcs, the overdue + // judgement and its day count. Nothing on this page recomputes any of it. + const verdict = calendar.data?.verdict; function openSheet(date: string) { setSelectedDate(date); @@ -158,7 +149,7 @@ export function CycleView() { const loading = calendar.isLoading && !calendar.data; const calendarError = calendar.isError && !calendar.data; - const tileHue = wheel.phase ? PHASE_HUE[wheel.phase] : undefined; + const tileHue = verdict?.phase ? PHASE_HUE[verdict.phase] : undefined; const goal = calendar.data?.profile.goal; // Honesty-gate inputs for the phase-education card (Clue precedent): the // predictive phase framing only shows when prediction is on, not in @@ -244,32 +235,38 @@ export function CycleView() { ) : ( )} {!calendarError ? (

- {/* v1.27.5 — beyond typical length + grace the wheel stops - asserting a day count; this caption says why instead of - the "of your cycle" tagline under an empty ring. */} - {wheel.periodOverdue - ? t("cycle.ring.periodOverdue") + {/* Beyond the typical length plus the server's grace window + the count stops being an observed fact, so the server + resolves OVERDUE and says how late; this caption reports + that instead of the "of your cycle" tagline under an + empty ring. */} + {verdict?.state === "OVERDUE" + ? verdict.overdueDays != null + ? t("cycle.ring.periodOverdueDays", { + days: verdict.overdueDays, + }) + : t("cycle.ring.periodOverdue") : t("cycle.ring.caption")}

) : null} {/* Log CTA — first period when no cycle exists yet, next period when the count paused on an overdue cycle. */} - {!loading && !calendarError && wheel.dayOfCycle == null ? ( + {!loading && !calendarError && verdict?.dayOfCycle == null ? ( @@ -283,7 +280,7 @@ export function CycleView() { {!loading && !calendarError ? ( = { - MENSTRUAL: period, - FOLLICULAR: follicular, - OVULATORY: ovulatory, - LUTEAL: luteal, - }; - const total = days.MENSTRUAL + days.FOLLICULAR + days.OVULATORY + days.LUTEAL; - const spans = PHASE_ORDER.map((phase) => ({ - phase, - fraction: days[phase] / total, - })); - return { spans, cycleLength: total }; -} - -/** - * Walk back from `todayIdx` to the start of the current MENSTRUAL-anchored run: - * the first day of the most recent MENSTRUAL block at/before today with no phase - * gap. The ONE source of truth both `currentCycleStartDate` and - * `deriveWheelState` use, so the BBT chart, the day-of-cycle count, and the ring - * spans can never disagree on where the current cycle begins. Assumes - * `sorted[todayIdx].phase != null` (the callers check first). - */ -function findCycleStartIndex(sorted: CalendarDay[], todayIdx: number): number { - let startIdx = todayIdx; - for (let i = todayIdx; i >= 0; i--) { - const d = sorted[i]; - if (d.phase == null) break; - startIdx = i; - if ( - d.phase === "MENSTRUAL" && - (i === 0 || sorted[i - 1].phase !== "MENSTRUAL") - ) { - break; - } - } - return startIdx; -} - -/** - * The first day (`YYYY-MM-DD`) of the current cycle, or null when today has no - * phase (no active cycle). Scopes the BBT chart to the same cycle the wheel - * shows. - */ -export function currentCycleStartDate( - days: CalendarDay[], - today: string, -): string | null { - const sorted = [...days].sort((a, b) => a.date.localeCompare(b.date)); - const todayIdx = sorted.findIndex((d) => d.date === today); - if (todayIdx < 0 || sorted[todayIdx].phase == null) return null; - return sorted[findCycleStartIndex(sorted, todayIdx)].date; -} - -export function deriveWheelState( - days: CalendarDay[], - today: string, - profile?: CycleProfileLengths, -): WheelState { - const byDate = new Map(days.map((d) => [d.date, d])); - const todayDay = byDate.get(today); - if (!todayDay || todayDay.phase == null) { - return { - dayOfCycle: null, - cycleLength: null, - phase: null, - spans: [], - periodOverdue: false, - }; - } - - // Sort dates ascending and find today's index. - const sorted = [...days].sort((a, b) => a.date.localeCompare(b.date)); - const todayIdx = sorted.findIndex((d) => d.date === today); - if (todayIdx < 0) { - return { - dayOfCycle: null, - cycleLength: null, - phase: null, - spans: [], - periodOverdue: false, - }; - } - - const startIdx = findCycleStartIndex(sorted, todayIdx); - const run = sorted.slice(startIdx, todayIdx + 1); - const dayOfCycle = run.length; - - // Honest ceiling: once the run overshoots the typical length + grace, the - // count reflects the engine's open-ended cycle window, not an observed - // cycle day. Degrade to the idealized ring with no day / phase claim; the - // view pairs it with the "period overdue" caption + log CTA. - const typicalLength = Math.max( - Math.round(profile?.typicalCycleLength ?? 28) || 28, - 7, - ); - if (dayOfCycle > typicalLength + OVERDUE_GRACE_DAYS) { - const ideal = idealizedSpans(profile); - return { - dayOfCycle: null, - cycleLength: ideal.cycleLength, - phase: null, - spans: ideal.spans, - periodOverdue: true, - }; - } - - // Tally each phase's day-count across the run + the forward predicted run - // (so the ring shows the whole cycle, not just elapsed days). - const counts: Record = { - MENSTRUAL: 0, - FOLLICULAR: 0, - OVULATORY: 0, - LUTEAL: 0, - }; - // Include forward days until the next MENSTRUAL block to bound a full cycle. - let end = todayIdx; - for (let i = todayIdx + 1; i < sorted.length; i++) { - const d = sorted[i]; - if (d.phase == null) break; - if (d.phase === "MENSTRUAL") break; - end = i; - } - const fullRun = sorted.slice(startIdx, end + 1); - for (const d of fullRun) { - if (d.phase) counts[d.phase] += 1; - } - - // Low-data tracker: too few labelled days to span a real cycle. The - // observed-share math would normalise a 1-3 day partial run so a single - // phase filled the whole ring (the "one dominant arc" bug). Fall back to the - // canonical four-phase proportions derived from the profile's typical - // lengths so the dial reads as a real cycle wheel. The current phase + the - // day-of-cycle marker stay driven by the observed labels; only the arc - // proportions and the ring's represented length come from the ideal. - if (fullRun.length < SPARSE_RUN_THRESHOLD) { - const ideal = idealizedSpans(profile); - return { - dayOfCycle, - cycleLength: ideal.cycleLength, - phase: todayDay.phase, - spans: ideal.spans, - periodOverdue: false, - }; - } - - const total = fullRun.length || 1; - const spans = PHASE_ORDER.filter((p) => counts[p] > 0).map((phase) => ({ - phase, - fraction: counts[phase] / total, - })); - - return { - dayOfCycle, - cycleLength: fullRun.length, - phase: todayDay.phase, - spans, - periodOverdue: false, - }; -} diff --git a/src/lib/cycle/__tests__/verdict.test.ts b/src/lib/cycle/__tests__/verdict.test.ts new file mode 100644 index 000000000..a7be69428 --- /dev/null +++ b/src/lib/cycle/__tests__/verdict.test.ts @@ -0,0 +1,258 @@ +/** + * The resolved cycle verdict. + * + * The load-bearing case is the overdue boundary. Beyond the profile's typical + * length plus the server's grace window the day count stops being an observed + * fact and the verdict must say so, with the number of days. One day either + * side of that line is the difference between a person reading "day 43" and + * reading "15 days later than usual", so both sides are pinned here, together + * with the day counts each side produces. + */ +import { describe, it, expect } from "vitest"; + +import { resolveCycleVerdict, type VerdictCalendarDay } from "../verdict"; +import type { CyclePhase } from "../types"; + +function day( + date: string, + phase: CyclePhase | null, + isFertileWindow = false, +): VerdictCalendarDay { + return { date, phase, isFertileWindow }; +} + +/** A run of 5 MENSTRUAL days then LUTEAL days, `n` long, ending at `today`. */ +function longRun( + n: number, + from = "2026-01-01", +): { + days: VerdictCalendarDay[]; + today: string; +} { + const start = Date.parse(`${from}T00:00:00Z`); + const days: VerdictCalendarDay[] = []; + for (let i = 0; i < n; i++) { + const date = new Date(start + i * 86_400_000).toISOString().slice(0, 10); + days.push(day(date, i < 5 ? "MENSTRUAL" : "LUTEAL")); + } + return { days, today: days[n - 1].date }; +} + +describe("resolveCycleVerdict — the overdue boundary", () => { + it("still counts on the last honest day", () => { + // Typical 28 + a 14-day grace window = 42. Day 42 is an observed fact. + const { days, today } = longRun(42); + const v = resolveCycleVerdict({ + days, + today, + profile: { typicalCycleLength: 28 }, + }); + expect(v.state).toBe("IN_CYCLE"); + expect(v.dayOfCycle).toBe(42); + expect(v.overdueDays).toBeNull(); + }); + + it("stops counting one day later, and says how late", () => { + const { days, today } = longRun(43); + const v = resolveCycleVerdict({ + days, + today, + profile: { typicalCycleLength: 28 }, + }); + expect(v.state).toBe("OVERDUE"); + expect(v.dayOfCycle).toBeNull(); + expect(v.phase).toBeNull(); + // Measured against the typical length the person actually has, not against + // typical + grace: 43 days into a 28-day cycle is 15 days later than usual. + expect(v.overdueDays).toBe(15); + }); + + it("keeps growing the overdue count as the delay grows", () => { + const { days, today } = longRun(90); + const v = resolveCycleVerdict({ + days, + today, + profile: { typicalCycleLength: 28 }, + }); + expect(v.state).toBe("OVERDUE"); + expect(v.overdueDays).toBe(62); + // The ring still draws the canonical four-phase dial rather than nothing. + expect(v.spans).toHaveLength(4); + expect(v.cycleLength).toBe(28); + }); + + it("moves the boundary with the profile's own typical length", () => { + // A 60-day typical cycle keeps day 49 honest where a 28-day one would not. + const { days, today } = longRun(49); + const v = resolveCycleVerdict({ + days, + today, + profile: { typicalCycleLength: 60 }, + }); + expect(v.state).toBe("IN_CYCLE"); + expect(v.dayOfCycle).toBe(49); + expect(v.overdueDays).toBeNull(); + }); + + it("keeps the cycle start even once the count stops", () => { + // The last logged period start is still a fact; the BBT chart scopes to it. + const { days, today } = longRun(60); + const v = resolveCycleVerdict({ + days, + today, + profile: { typicalCycleLength: 28 }, + }); + expect(v.state).toBe("OVERDUE"); + expect(v.cycleStartDate).toBe("2026-01-01"); + }); +}); + +describe("resolveCycleVerdict — what the record supports", () => { + it("says INSUFFICIENT_DATA when today carries no phase", () => { + const days = [day("2026-06-01", null), day("2026-06-02", null)]; + const v = resolveCycleVerdict({ days, today: "2026-06-02" }); + expect(v.state).toBe("INSUFFICIENT_DATA"); + expect(v.dayOfCycle).toBeNull(); + expect(v.phase).toBeNull(); + expect(v.cycleStartDate).toBeNull(); + expect(v.spans).toEqual([]); + }); + + it("says INSUFFICIENT_DATA when today is absent from the grid", () => { + const days = [day("2026-06-01", "MENSTRUAL")]; + const v = resolveCycleVerdict({ days, today: "2026-06-09" }); + expect(v.state).toBe("INSUFFICIENT_DATA"); + expect(v.dayOfCycle).toBeNull(); + }); + + it("counts the cycle day from the most recent menstrual start", () => { + const days = [ + day("2026-05-30", "LUTEAL"), + day("2026-06-01", "MENSTRUAL"), + day("2026-06-02", "MENSTRUAL"), + day("2026-06-03", "FOLLICULAR"), + day("2026-06-04", "FOLLICULAR"), + day("2026-06-05", "FOLLICULAR"), + ]; + const v = resolveCycleVerdict({ days, today: "2026-06-05" }); + expect(v.state).toBe("IN_CYCLE"); + expect(v.dayOfCycle).toBe(5); + expect(v.phase).toBe("FOLLICULAR"); + expect(v.cycleStartDate).toBe("2026-06-01"); + }); + + it("builds observed phase arcs that sum to the whole ring", () => { + const days = [ + day("2026-06-01", "MENSTRUAL"), + day("2026-06-02", "MENSTRUAL"), + day("2026-06-03", "MENSTRUAL"), + day("2026-06-04", "FOLLICULAR"), + day("2026-06-05", "FOLLICULAR"), + day("2026-06-06", "OVULATORY"), + day("2026-06-07", "LUTEAL"), + day("2026-06-08", "LUTEAL"), + ]; + const v = resolveCycleVerdict({ days, today: "2026-06-05" }); + expect(v.spans.reduce((s, x) => s + x.fraction, 0)).toBeCloseTo(1, 5); + expect(v.spans.map((s) => s.phase).sort()).toEqual([ + "FOLLICULAR", + "LUTEAL", + "MENSTRUAL", + "OVULATORY", + ]); + expect(v.cycleLength).toBe(8); + }); + + it("draws the canonical four arcs for a low-data tracker", () => { + // A two-day first-ever run: the observed-share math would let MENSTRUAL + // fill nearly the whole circle, so the ring falls back to the profile's + // canonical proportions while the day count stays observed. + const days = [ + day("2026-06-01", "MENSTRUAL"), + day("2026-06-02", "MENSTRUAL"), + ]; + const v = resolveCycleVerdict({ + days, + today: "2026-06-02", + profile: { + typicalCycleLength: 28, + typicalPeriodLength: 5, + lutealPhaseLength: 14, + }, + }); + expect(v.state).toBe("IN_CYCLE"); + expect(v.dayOfCycle).toBe(2); + expect(v.spans).toHaveLength(4); + expect(v.cycleLength).toBe(28); + for (const span of v.spans) { + expect(span.fraction).toBeGreaterThan(0); + expect(span.fraction).toBeLessThan(0.95); + } + }); +}); + +describe("resolveCycleVerdict — days until the next period", () => { + const days = [ + day("2026-06-01", "MENSTRUAL"), + day("2026-06-02", "MENSTRUAL"), + day("2026-06-03", "FOLLICULAR"), + ]; + + it("counts forward to the predicted start", () => { + const v = resolveCycleVerdict({ + days, + today: "2026-06-03", + nextPeriodStart: "2026-06-29", + }); + expect(v.daysUntilNext).toBe(26); + }); + + it("is null when no prediction ran", () => { + expect( + resolveCycleVerdict({ days, today: "2026-06-03" }).daysUntilNext, + ).toBeNull(); + }); + + it("is null once the predicted start is already past", () => { + // There is no "until" any more, and inventing a negative number would only + // invite a client to render it. + const v = resolveCycleVerdict({ + days, + today: "2026-06-03", + nextPeriodStart: "2026-06-01", + }); + expect(v.daysUntilNext).toBeNull(); + }); +}); + +describe("resolveCycleVerdict — the fertile window", () => { + it("reports the window it is given and whether today falls in it", () => { + const days = [ + day("2026-06-10", "FOLLICULAR"), + day("2026-06-11", "OVULATORY", true), + ]; + const v = resolveCycleVerdict({ + days, + today: "2026-06-11", + fertileWindowStart: "2026-06-08", + fertileWindowEnd: "2026-06-13", + }); + expect(v.fertileWindow).toEqual({ + start: "2026-06-08", + end: "2026-06-13", + active: true, + }); + }); + + it("stays empty when the caller suppressed the window", () => { + // The route passes the ALREADY goal- and cold-start-gated values, so a + // suppressed window arrives here as nulls and cannot reappear. + const days = [day("2026-06-11", "OVULATORY", false)]; + const v = resolveCycleVerdict({ days, today: "2026-06-11" }); + expect(v.fertileWindow).toEqual({ + start: null, + end: null, + active: false, + }); + }); +}); diff --git a/src/lib/cycle/index.ts b/src/lib/cycle/index.ts index d8e2a2297..14fac3fa0 100644 --- a/src/lib/cycle/index.ts +++ b/src/lib/cycle/index.ts @@ -24,3 +24,11 @@ export { clampLuteal, } from "./prediction"; export { phaseForDay, phaseSeries, type PhaseCycle } from "./phase"; +export { + resolveCycleVerdict, + type CycleVerdict, + type CycleVerdictState, + type CyclePhaseSpan, + type CycleProfileLengths, + type VerdictCalendarDay, +} from "./verdict"; diff --git a/src/lib/cycle/verdict.ts b/src/lib/cycle/verdict.ts new file mode 100644 index 000000000..5fb5ecadd --- /dev/null +++ b/src/lib/cycle/verdict.ts @@ -0,0 +1,354 @@ +/** + * The resolved cycle verdict — day-of-cycle, the ring's phase segments, the + * overdue judgement and its day count, and the days until the next predicted + * period. Computed here, on the server, once. + * + * This used to live in web client code (`src/components/cycle/wheel-state.ts`) + * and every client that wanted the same answer had to reproduce it, including + * the fourteen-day grace window that decides when the day count stops being + * an observed fact. Two copies of that rule means two answers about one + * person's body, and the second copy is how a cycle ring ends up frozen. The + * rule now exists once and the answer travels on the wire. + * + * Pure and deterministic: it reads the calendar grid the same request already + * built, plus the profile's typical lengths and the (already goal-gated) + * prediction fields. No database, no clock — `today` is resolved by the caller + * from the user's own timezone, which is also why a client cannot compute this + * correctly on its own. + */ +import { dayDiff } from "./day-math"; +import type { CyclePhase } from "./types"; + +const PHASE_ORDER: CyclePhase[] = [ + "MENSTRUAL", + "FOLLICULAR", + "OVULATORY", + "LUTEAL", +]; + +/** One arc of the ring: a phase and its share of the represented cycle. */ +export interface CyclePhaseSpan { + phase: CyclePhase; + fraction: number; +} + +/** + * What the record supports saying about today. + * + * - `IN_CYCLE` — today sits inside a cycle whose day count is still an + * observed fact. `dayOfCycle`, `phase` and `cycleStartDate` are all set. + * - `OVERDUE` — the open cycle has run past the profile's typical length by + * more than the grace window. `dayOfCycle` and `phase` are null on purpose + * (see the ceiling below); `overdueDays` carries how late it is. + * - `INSUFFICIENT_DATA` — today carries no phase at all: no cycle has been + * logged, or today falls outside every known cycle window. + */ +export type CycleVerdictState = "IN_CYCLE" | "OVERDUE" | "INSUFFICIENT_DATA"; + +export interface CycleVerdict { + state: CycleVerdictState; + /** 1-based day of the current cycle. Null in OVERDUE and INSUFFICIENT_DATA. */ + dayOfCycle: number | null; + /** Days the ring represents (observed run, or the idealized cycle). */ + cycleLength: number | null; + /** Today's phase. Null in OVERDUE and INSUFFICIENT_DATA. */ + phase: CyclePhase | null; + /** The ring's arcs, as fractions summing to ~1. Empty when there is nothing to draw. */ + spans: CyclePhaseSpan[]; + /** + * First day of the current cycle (`YYYY-MM-DD`), or null when no cycle is + * active. Still set while OVERDUE — the last logged period start remains a + * fact even once the count stops. Scopes the BBT chart to the same cycle the + * ring shows. + */ + cycleStartDate: string | null; + /** + * How many days past the profile's typical cycle length the open cycle has + * run. Set only in OVERDUE. Measured against the typical length, not against + * typical + grace: a person asking how late they are means "later than my + * usual cycle", and the grace window is our tolerance, not their calendar. + */ + overdueDays: number | null; + /** + * Days from today to the predicted next period start. Null when there is no + * prediction (raw-chart mode / prediction disabled) and null once that + * predicted start is in the past — at that point there is no "until", and + * `state` + `overdueDays` carry what is true instead. + */ + daysUntilNext: number | null; + /** The fertile window as already goal-gated upstream, plus whether today falls in it. */ + fertileWindow: { + start: string | null; + end: string | null; + active: boolean; + }; +} + +/** + * The profile-derived canonical phase lengths used to draw an idealized ring + * for a low-data tracker (see `idealizedSpans`). All optional — every field + * falls back to the textbook default when the profile has not set it. + */ +export interface CycleProfileLengths { + /** Typical full cycle length in days (default 28). */ + typicalCycleLength?: number | null; + /** Typical period (menstrual) length in days (default 5). */ + typicalPeriodLength?: number | null; + /** Typical luteal-phase length in days (default 14). */ + lutealPhaseLength?: number | null; +} + +/** The calendar-grid fields the verdict reads. `CalendarDayDTO` satisfies it. */ +export interface VerdictCalendarDay { + date: string; + phase: CyclePhase | null; + isFertileWindow: boolean; +} + +export interface ResolveCycleVerdictInput { + /** The calendar grid for the rendered span, in any order. */ + days: readonly VerdictCalendarDay[]; + /** Today's `YYYY-MM-DD` in the USER's timezone, not the process's. */ + today: string; + profile?: CycleProfileLengths; + /** `prediction.nextPeriodStart`, or null when no prediction ran. */ + nextPeriodStart?: string | null; + /** Already goal- and cold-start-gated fertile window bounds. */ + fertileWindowStart?: string | null; + fertileWindowEnd?: string | null; +} + +/** Below this many labelled days in the current cycle run, the ring would + * normalise a 1-3 day partial run so a single phase filled the whole circle. + * For such low-data trackers we draw the canonical four-phase proportions from + * the profile instead, so the dial always reads as a real cycle wheel. A run + * of 7+ labelled days already spans more than the menstrual phase, so the + * observed-share path takes over from there. */ +const SPARSE_RUN_THRESHOLD = 7; + +/** + * Days PAST the profile's typical cycle length before the count stops. + * The engine keeps the open cycle's phase window alive until the predicted + * next start, and a confirmed/anchored ovulation signal (BBT trend, + * symptothermal double-check, LH surge) or a long estimated length can push + * that prediction into the future while the last logged period is months old — + * every day of the gap then carries a (population-framed) LUTEAL label and the + * day-of-cycle walks up without bound ("day 90"). Beyond typical + grace the + * count is no longer an observed fact, so the verdict degrades to the honest + * overdue state instead. Two weeks of grace covers ordinary cycle-to-cycle + * variation without hiding a real delay. + * + * This constant is deliberately NOT published on the wire. A client that knows + * the threshold will eventually apply it itself, and then there are two + * deciders again. Clients read `state` and `overdueDays`. + */ +const OVERDUE_GRACE_DAYS = 14; + +/** The profile's typical cycle length, floored at a week. */ +function typicalCycleLength(profile?: CycleProfileLengths): number { + return Math.max(Math.round(profile?.typicalCycleLength ?? 28) || 28, 7); +} + +/** + * The canonical four-phase span set for a low-data tracker, derived from the + * profile's typical lengths (textbook defaults when unset): a 28-day cycle + * with a 5-day period, a 2-day ovulatory window, a 14-day luteal phase, and + * the follicular phase filling the remainder. Proportions, not day counts, so + * the ring renders the familiar MENSTRUAL/FOLLICULAR/OVULATORY/LUTEAL arcs + * instead of one dominant sliver. + */ +function idealizedSpans(profile?: CycleProfileLengths): { + spans: CyclePhaseSpan[]; + cycleLength: number; +} { + const cycle = typicalCycleLength(profile); + const period = Math.min( + Math.max(Math.round(profile?.typicalPeriodLength ?? 5) || 5, 1), + cycle - 3, + ); + const luteal = Math.min( + Math.max(Math.round(profile?.lutealPhaseLength ?? 14) || 14, 1), + cycle - period - 1, + ); + // A short ovulatory window sits at the fertile peak; the follicular phase + // fills whatever remains between the period and the ovulatory window. + const ovulatory = Math.min(2, Math.max(cycle - period - luteal - 1, 1)); + const follicular = Math.max(cycle - period - ovulatory - luteal, 1); + + const days: Record = { + MENSTRUAL: period, + FOLLICULAR: follicular, + OVULATORY: ovulatory, + LUTEAL: luteal, + }; + const total = days.MENSTRUAL + days.FOLLICULAR + days.OVULATORY + days.LUTEAL; + const spans = PHASE_ORDER.map((phase) => ({ + phase, + fraction: days[phase] / total, + })); + return { spans, cycleLength: total }; +} + +/** + * Walk back from `todayIdx` to the start of the current MENSTRUAL-anchored run: + * the first day of the most recent MENSTRUAL block at/before today with no phase + * gap. Assumes `sorted[todayIdx].phase != null` (the caller checks first). + */ +function findCycleStartIndex( + sorted: readonly VerdictCalendarDay[], + todayIdx: number, +): number { + let startIdx = todayIdx; + for (let i = todayIdx; i >= 0; i--) { + const d = sorted[i]; + if (d.phase == null) break; + startIdx = i; + if ( + d.phase === "MENSTRUAL" && + (i === 0 || sorted[i - 1].phase !== "MENSTRUAL") + ) { + break; + } + } + return startIdx; +} + +/** Days from today to the next predicted start, or null once it is past. */ +function resolveDaysUntilNext( + today: string, + nextPeriodStart: string | null | undefined, +): number | null { + if (!nextPeriodStart) return null; + const delta = dayDiff(nextPeriodStart, today); + return delta >= 0 ? delta : null; +} + +function resolveFertileWindow( + input: ResolveCycleVerdictInput, + todayDay: VerdictCalendarDay | undefined, +): CycleVerdict["fertileWindow"] { + return { + start: input.fertileWindowStart ?? null, + end: input.fertileWindowEnd ?? null, + active: todayDay?.isFertileWindow ?? false, + }; +} + +/** + * Resolve everything a client needs to render the cycle ring and its caption. + * The only place in the product that decides any of it. + */ +export function resolveCycleVerdict( + input: ResolveCycleVerdictInput, +): CycleVerdict { + const { days, today, profile } = input; + const byDate = new Map(days.map((d) => [d.date, d])); + const todayDay = byDate.get(today); + const fertileWindow = resolveFertileWindow(input, todayDay); + const daysUntilNext = resolveDaysUntilNext(today, input.nextPeriodStart); + + const nothingToSay: CycleVerdict = { + state: "INSUFFICIENT_DATA", + dayOfCycle: null, + cycleLength: null, + phase: null, + spans: [], + cycleStartDate: null, + overdueDays: null, + daysUntilNext, + fertileWindow, + }; + + if (!todayDay || todayDay.phase == null) return nothingToSay; + + // Sort dates ascending and find today's index. + const sorted = [...days].sort((a, b) => a.date.localeCompare(b.date)); + const todayIdx = sorted.findIndex((d) => d.date === today); + if (todayIdx < 0) return nothingToSay; + + const startIdx = findCycleStartIndex(sorted, todayIdx); + const cycleStartDate = sorted[startIdx].date; + const run = sorted.slice(startIdx, todayIdx + 1); + const dayOfCycle = run.length; + + // Honest ceiling: once the run overshoots the typical length + grace, the + // count reflects the engine's open-ended cycle window, not an observed + // cycle day. Degrade to the idealized ring with no day / phase claim; the + // caption says the period is late and by how much. + const typicalLength = typicalCycleLength(profile); + if (dayOfCycle > typicalLength + OVERDUE_GRACE_DAYS) { + const ideal = idealizedSpans(profile); + return { + state: "OVERDUE", + dayOfCycle: null, + cycleLength: ideal.cycleLength, + phase: null, + spans: ideal.spans, + cycleStartDate, + overdueDays: dayOfCycle - typicalLength, + daysUntilNext, + fertileWindow, + }; + } + + // Tally each phase's day-count across the run + the forward predicted run + // (so the ring shows the whole cycle, not just elapsed days). + const counts: Record = { + MENSTRUAL: 0, + FOLLICULAR: 0, + OVULATORY: 0, + LUTEAL: 0, + }; + // Include forward days until the next MENSTRUAL block to bound a full cycle. + let end = todayIdx; + for (let i = todayIdx + 1; i < sorted.length; i++) { + const d = sorted[i]; + if (d.phase == null) break; + if (d.phase === "MENSTRUAL") break; + end = i; + } + const fullRun = sorted.slice(startIdx, end + 1); + for (const d of fullRun) { + if (d.phase) counts[d.phase] += 1; + } + + // Low-data tracker: too few labelled days to span a real cycle. The + // observed-share math would normalise a 1-3 day partial run so a single + // phase filled the whole ring (the "one dominant arc" bug). Fall back to the + // canonical four-phase proportions derived from the profile's typical + // lengths so the dial reads as a real cycle wheel. The current phase + the + // day-of-cycle marker stay driven by the observed labels; only the arc + // proportions and the ring's represented length come from the ideal. + if (fullRun.length < SPARSE_RUN_THRESHOLD) { + const ideal = idealizedSpans(profile); + return { + state: "IN_CYCLE", + dayOfCycle, + cycleLength: ideal.cycleLength, + phase: todayDay.phase, + spans: ideal.spans, + cycleStartDate, + overdueDays: null, + daysUntilNext, + fertileWindow, + }; + } + + const total = fullRun.length || 1; + const spans = PHASE_ORDER.filter((p) => counts[p] > 0).map((phase) => ({ + phase, + fraction: counts[phase] / total, + })); + + return { + state: "IN_CYCLE", + dayOfCycle, + cycleLength: fullRun.length, + phase: todayDay.phase, + spans, + cycleStartDate, + overdueDays: null, + daysUntilNext, + fertileWindow, + }; +} diff --git a/src/lib/openapi/routes/cycle.ts b/src/lib/openapi/routes/cycle.ts index b9880a8d6..0a8efbe27 100644 --- a/src/lib/openapi/routes/cycle.ts +++ b/src/lib/openapi/routes/cycle.ts @@ -191,6 +191,45 @@ const cycleCalendarDayDto = z.object({ cervixOpening: cervixOpeningEnum.nullable(), }); +const cycleVerdictDto = z + .object({ + state: z.enum(["IN_CYCLE", "OVERDUE", "INSUFFICIENT_DATA"]), + dayOfCycle: z.number().int().nullable().meta({ + description: + "1-based day of the current cycle. Null in OVERDUE and INSUFFICIENT_DATA — beyond the typical length plus the server's grace window the count is no longer an observed fact.", + }), + cycleLength: z.number().int().nullable().meta({ + description: + "Days the ring represents: the observed labelled run, or the profile-derived idealized cycle for a low-data tracker.", + }), + phase: cyclePhaseEnumOpenapi.nullable(), + spans: z.array( + z.object({ phase: cyclePhaseEnumOpenapi, fraction: z.number() }), + ), + cycleStartDate: z.string().nullable().meta({ + description: + "First day of the current cycle (YYYY-MM-DD). Still set while OVERDUE — the last logged period start remains a fact once the count stops.", + }), + overdueDays: z.number().int().nullable().meta({ + description: + "How many days past the profile's typical cycle length the open cycle has run. Set only in OVERDUE.", + }), + daysUntilNext: z.number().int().nullable().meta({ + description: + "Days from today to the predicted next period start. Null when no prediction ran and null once that start is in the past.", + }), + fertileWindow: z.object({ + start: z.string().nullable(), + end: z.string().nullable(), + active: z.boolean(), + }), + }) + .meta({ + id: "CycleVerdictDTO", + description: + "The resolved cycle verdict for the user's own timezone day. Render these values; do not recompute them. The grace window that decides OVERDUE is deliberately not published — read `state` and `overdueDays`.", + }); + const cycleProfileDto = z .object({ goal: cycleTrackingGoalEnum, @@ -218,6 +257,10 @@ const cycleCalendarResponse = z.object({ cyclesObserved: z.number().int(), }), prediction: cyclePredictionDto.nullable(), + // The resolved verdict — required, never optional. An optional verdict is a + // plausible default that invites a client to fall back to deriving its own, + // which is the defect this field exists to remove. + verdict: cycleVerdictDto, // Cold-start gate (mirrors `prediction.stillLearning`): true while < 3 cycles // are observed. When set, the `days` grid carries no fertile window, no // predicted-ovulation dot, and no phase band (those would rest on a