diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 7a36fc111..3d1079f21 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/messages/de.json b/messages/de.json index 7a6964ab4..072bcbe74 100644 --- a/messages/de.json +++ b/messages/de.json @@ -7524,6 +7524,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 85efbc250..27d518481 100644 --- a/messages/en.json +++ b/messages/en.json @@ -7524,6 +7524,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 b008bd088..3e75df9ca 100644 --- a/messages/es.json +++ b/messages/es.json @@ -7524,6 +7524,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 8872899d3..f89538f60 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -7524,6 +7524,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 2d3abdcf0..52d20fcae 100644 --- a/messages/it.json +++ b/messages/it.json @@ -7524,6 +7524,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 df0a5c772..322477b9d 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -7524,6 +7524,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/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..509433639 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,39 @@ 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 }, +}; + +/** 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. + * 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 +135,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/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