From dbecbbcbdb2cce8eb29b751842ba2289d6f72646 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 1 Aug 2026 10:37:18 +0200 Subject: [PATCH 01/17] Publish the resolved cycle verdict instead of letting each client work it out The cycle ring's day count, its phase arcs, and the judgement that a period is late all used to be worked out in web client code, including the fourteen-day grace window that decides when the count stops being an observed fact. Anything else that wanted the same answer had to reproduce that rule, and a second copy of it is how a ring ends up showing someone a cycle day their record does not hold. GET /api/cycle/calendar now carries a verdict: the state, the cycle day, the ring's arcs, the cycle start, how many days until the next period, and when the period is late, how late. It is resolved against the user's own timezone day, which a browser in another zone cannot do. The web wheel, the wellness-strip tile, the insights teaser and the temperature chart all render it, the grace constant is gone from the components directory, and the spec says the field is required. The overdue caption now names the number of days instead of only saying later than usual. --- docs/api/openapi.yaml | 97 +++++ messages/de.json | 1 + messages/en.json | 1 + messages/es.json | 1 + messages/fr.json | 1 + messages/it.json | 1 + messages/pl.json | 1 + src/app/api/cycle/calendar/route.ts | 33 +- .../cycle/__tests__/bbt-chart.test.tsx | 2 + .../cycle-insight-summary-card.test.tsx | 41 +- .../cycle/__tests__/cycle-ring-tile.test.tsx | 42 ++- .../cycle/__tests__/wheel-state.test.ts | 191 ---------- src/components/cycle/bbt-chart.tsx | 24 +- .../cycle/cycle-insight-summary-card.tsx | 34 +- src/components/cycle/cycle-ring-tile.tsx | 43 +-- src/components/cycle/cycle-view.tsx | 54 ++- src/components/cycle/types.ts | 25 ++ src/components/cycle/wheel-state.ts | 259 ------------- src/lib/cycle/index.ts | 8 + src/lib/cycle/verdict.ts | 354 ++++++++++++++++++ src/lib/openapi/routes/cycle.ts | 43 +++ 21 files changed, 702 insertions(+), 554 deletions(-) delete mode 100644 src/components/cycle/__tests__/wheel-state.test.ts delete mode 100644 src/components/cycle/wheel-state.ts create mode 100644 src/lib/cycle/verdict.ts 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 From c65f122017d3caf98f83fd71aea0ab194723e06f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 1 Aug 2026 10:45:24 +0200 Subject: [PATCH 02/17] Prove the verdict reaches a client and that nothing recomputes it Three checks. The engine tests pin the overdue boundary from both sides: day 42 of a stated 28-day cycle still counts, day 43 stops counting and reports fifteen days later than usual. The round-trip test calls the real GET handler, reads the response body as text and parses it, so a field that both ends handle but the wire never carries fails here. It runs the same instant twice for two accounts in different timezones and gets two different answers, which is what the account timezone is for. It also checks the ring's cycle day against the phase engine the coach read uses, so the two cannot tell someone two different days. The guard reads the syntax tree of every file under components, hooks and app, and fails on a grace-period constant, on an overdue verdict computed rather than read, and on a cycle-day count compared against a sum. It follows local bindings, so lifting the threshold into a variable above the comparison does not hide it. A companion suite tries nine disguises against the same detector and pins five innocent shapes it must ignore, including an overdue medication dose. --- .../cycle-verdict-guard-defeat.test.ts | 193 +++++++++ ...cycle-verdict-single-decider-guard.test.ts | 123 ++++++ .../helpers/cycle-verdict-derivation.ts | 367 ++++++++++++++++++ .../__tests__/verdict-round-trip.test.ts | 278 +++++++++++++ src/lib/cycle/__tests__/verdict.test.ts | 258 ++++++++++++ 5 files changed, 1219 insertions(+) create mode 100644 src/__tests__/cycle-verdict-guard-defeat.test.ts create mode 100644 src/__tests__/cycle-verdict-single-decider-guard.test.ts create mode 100644 src/__tests__/helpers/cycle-verdict-derivation.ts create mode 100644 src/app/api/cycle/calendar/__tests__/verdict-round-trip.test.ts create mode 100644 src/lib/cycle/__tests__/verdict.test.ts 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/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, + }); + }); +}); From 043d2ba1433d42d13c745c46ed85eeca026cc27c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 1 Aug 2026 10:48:51 +0200 Subject: [PATCH 03/17] Give the mobile cycle smoke test a verdict to render The spec hand-builds a calendar response, and the wheel now reads the resolved verdict from it. Without the field the ring paints empty, so the fixture derives the verdict from the same phase plan it derives the day grid from and the two cannot drift apart. --- e2e/cycle-insights-mobile.spec.ts | 61 +++++++++++++++---- .../cycle-insight-summary-card.test.tsx | 13 ---- 2 files changed, 50 insertions(+), 24 deletions(-) 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/src/components/cycle/__tests__/cycle-insight-summary-card.test.tsx b/src/components/cycle/__tests__/cycle-insight-summary-card.test.tsx index 509433639..1dc11f247 100644 --- a/src/components/cycle/__tests__/cycle-insight-summary-card.test.tsx +++ b/src/components/cycle/__tests__/cycle-insight-summary-card.test.tsx @@ -109,19 +109,6 @@ const LUTEAL_VERDICT: CycleVerdict = { 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 { From c60689affd50fbd3c58f1736d1c3db5b5f9982e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 1 Aug 2026 10:51:01 +0200 Subject: [PATCH 04/17] Take the fitness pillar out of the health score It could never score. The schema records VO2max but cannot tell a measured test apart from a device estimate, so every fitness row reached the scorer unproven and was refused - the pillar has never produced a value on any account. Listing it on the score settings page as a thing you can choose, with a line admitting it does nothing, was chrome over a switch that changed no number. So it leaves the catalogue: no pillar id, no domain, no scorer, no row, no wire field. A stored selection that still names it reads back fine - the resolver has always filtered stored ids against the catalogue and drops what it no longer knows, so nobody's saved recipe throws or is discarded over it. That also fixes the page claiming unsaved changes forever. The save stripped the fitness pillar out of the selection on its way to the server, so the answer that came back could never match the form that sent it, and 'All pillars' left the save button lit no matter how often you pressed it. --- docs/api/openapi.yaml | 2 - e2e/settings-score.spec.ts | 49 ++- e2e/utils/mock-populated-insights.ts | 1 - messages/de.json | 15 +- messages/en.json | 15 +- messages/es.json | 15 +- messages/fr.json | 15 +- messages/it.json | 15 +- messages/pl.json | 15 +- src/app/api/analytics/route.ts | 1 - .../__tests__/route.test.ts | 25 +- .../health-score-card-geometry.test.tsx | 1 - .../__tests__/health-score-card.test.tsx | 23 +- .../insights/health-score-pillar-detail.ts | 18 +- .../__tests__/score-pillar-row.test.tsx | 57 +--- src/components/settings/score-pillar-row.tsx | 70 +--- src/components/settings/score-section.tsx | 320 +++++++++--------- .../__tests__/i18n-locale-integrity.test.ts | 1 - .../score/__tests__/composite.test.ts | 1 - .../__tests__/composition-source.test.ts | 67 ++-- ...onfig-change-is-not-a-health-event.test.ts | 4 +- .../__tests__/config-delta-guard.test.ts | 1 - .../score/__tests__/configured-flag.test.ts | 8 +- .../analytics/score/__tests__/pillars.test.ts | 47 --- .../analytics/score/__tests__/record.test.ts | 1 - src/lib/analytics/score/breadth.ts | 1 - src/lib/analytics/score/derived.ts | 2 - src/lib/analytics/score/fitness.ts | 94 ----- src/lib/analytics/score/index.ts | 6 - src/lib/analytics/score/modules.ts | 12 +- src/lib/analytics/score/reader.ts | 32 -- src/lib/analytics/score/types.ts | 42 +-- src/lib/dashboard/snapshot.ts | 1 - .../health-score-coverage-contract.test.ts | 20 +- src/lib/insights/derived/registry.ts | 7 +- src/lib/openapi/routes/insights/schemas.ts | 2 - src/lib/score-config/__tests__/rows.test.ts | 63 +--- src/lib/score-config/labels.ts | 18 +- src/lib/score-config/rows.ts | 44 +-- 39 files changed, 347 insertions(+), 784 deletions(-) delete mode 100644 src/lib/analytics/score/fitness.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 7a36fc111..a7248ea49 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -28110,7 +28110,6 @@ components: - SLEEP - ADIPOSITY - WELLBEING - - FITNESS - LIPIDS configured: description: "True when the account's own recipe narrows the score's composition below what its defaults would resolve @@ -28145,7 +28144,6 @@ components: - SLEEP - ADIPOSITY - WELLBEING - - FITNESS - LIPIDS - type: "null" restMode: diff --git a/e2e/settings-score.spec.ts b/e2e/settings-score.spec.ts index 9d77e0bd3..9909ead1f 100644 --- a/e2e/settings-score.spec.ts +++ b/e2e/settings-score.spec.ts @@ -133,44 +133,35 @@ test.describe("Settings → Health Score", () => { await expect(cardio.locator('[data-slot="score-pillar-row"]')).toHaveCount( 3, ); - await expect( - cardio.locator('[data-slot="score-config-group-note"]'), - ).toBeVisible(); - // Every visible row names recording and showing beside its switch, and - // the card says in words that this page leaves those two alone. + // The rows say nothing about recording or showing — the card says it + // once, above them, and the modules screen owns those two questions. const rows = page.locator('[data-slot="score-pillar-row"]'); - const rowCount = await rows.count(); - expect(rowCount).toBeGreaterThan(0); - await expect( - page.locator('[data-slot="score-pillar-axis-recorded"]'), - ).toHaveCount(rowCount); - await expect( - page.locator('[data-slot="score-pillar-axis-shown"]'), - ).toHaveCount(rowCount); + expect(await rows.count()).toBeGreaterThan(0); + await expect(page.locator('[data-slot="score-pillar-axes"]')).toHaveCount( + 0, + ); await expect( page.locator('[data-slot="score-config-three-axes"]'), ).toContainText("keeps being recorded"); }); - test("shows the pillar it cannot score as present, disabled and honest", async ({ - page, - }) => { + test("offers a switch on every pillar it lists", async ({ page }) => { await page.goto("/settings/score", { waitUntil: "domcontentloaded" }); - const fitness = page.locator( - '[data-slot="score-pillar-row"][data-pillar="FITNESS"]', - ); - await settleBeforeMeasure(page, fitness); + const rows = page.locator('[data-slot="score-pillar-row"]'); + await settleBeforeMeasure(page, rows.first()); - await expect(fitness).toHaveAttribute("data-selectable", "false"); - await expect(fitness).toHaveAttribute("data-eligibility", "unavailable"); - // No switch at all, rather than one that no-ops behind `disabled`. + // Nothing on this page is a choice the person cannot make. The pillar + // that could never score was removed from the catalogue rather than + // listed as an inert row. + const rowCount = await rows.count(); + expect(rowCount).toBeGreaterThan(0); + await expect(page.locator('[data-slot="score-pillar-switch"]')).toHaveCount( + rowCount, + ); await expect( - fitness.locator('[data-slot="score-pillar-switch"]'), + page.locator('[data-slot="score-pillar-row"][data-pillar="FITNESS"]'), ).toHaveCount(0); - await expect( - fitness.locator('[data-slot="score-pillar-unavailable"]'), - ).toHaveAttribute("aria-disabled", "true"); }); test("tells waiting for data apart from safety guidance and a failed read", async ({ @@ -247,9 +238,7 @@ test.describe("Settings → Health Score", () => { // server refuses. await page.locator('[data-slot="score-config-preset-all"]').click(); const keep = new Set(["ACTIVITY", "WELLBEING"]); - const rows = page.locator( - '[data-slot="score-pillar-row"][data-selectable="true"]', - ); + const rows = page.locator('[data-slot="score-pillar-row"]'); for (let index = 0; index < (await rows.count()); index += 1) { const row = rows.nth(index); const pillar = await row.getAttribute("data-pillar"); diff --git a/e2e/utils/mock-populated-insights.ts b/e2e/utils/mock-populated-insights.ts index 51baace4a..266efdc2d 100644 --- a/e2e/utils/mock-populated-insights.ts +++ b/e2e/utils/mock-populated-insights.ts @@ -181,7 +181,6 @@ const HEALTH_SCORE = { scorePillar("ACTIVITY", "activity", 88), scorePillar("SLEEP", "sleep", 41), scorePillar("ADIPOSITY", "adiposity", 72), - gatedPillar("FITNESS", "fitness", "read_failed"), gatedPillar("LIPIDS", "cardiometabolic", "read_failed"), gatedPillar("WELLBEING", "wellbeing", "crisis_signposting"), gatedPillar("GLYCAEMIA", "cardiometabolic", "not_tracked"), diff --git a/messages/de.json b/messages/de.json index 7a6964ab4..160289c44 100644 --- a/messages/de.json +++ b/messages/de.json @@ -2295,7 +2295,6 @@ "sleep": "Schlaf", "adiposity": "Körperfettverteilung", "wellbeing": "Wohlbefinden", - "fitness": "Gemessene Fitness", "lipids": "Blutfette" }, "observed": "Beobachtet: {value}", @@ -2312,11 +2311,9 @@ "observedActivity": "{value} Schritte pro Tag", "observedSleep": "{value} Minuten pro Nacht", "observedAdiposity": "Taille-zu-Körpergröße-Verhältnis {value}", - "observedFitness": "{value} {unit}, gemessen", "referenceActivity": "Der Nutzen flacht bei {high} Schritten pro Tag ab", "referenceSleep": "Mindestens {low} Minuten pro Nacht; längerer Schlaf wird nicht abgewertet", "referenceAdiposity": "Taille-zu-Körpergröße-Verhältnis unter {high}", - "referenceFitness": "{low} bis {high} {unit}, Referenzbereich nach Alter und Geschlecht", "referenceHba1c": "HbA1c {low} bis {high} %", "referenceDetail": "{label} · {source} · {kind} · {low} bis {high}", "openBound": "offen", @@ -4914,8 +4911,6 @@ }, "threeAxes": "Aufzeichnen, Anzeigen und Mitzählen sind drei verschiedene Dinge. Diese Seite ändert nur das Letzte. Ein Bereich, den du hier abschaltest, wird weiter aufgezeichnet und weiter überall in HealthLog angezeigt, er bewegt nur die Zahl nicht mehr.", "axis": { - "recorded": "Wird aufgezeichnet", - "shown": "Wird in der App angezeigt", "countsFor": "{pillar} in den Score einrechnen" }, "domain": { @@ -4923,19 +4918,13 @@ "activity": "Aktivität", "sleep": "Schlaf", "adiposity": "Körperform", - "wellbeing": "Wohlbefinden", - "fitness": "Gemessene Fitness" - }, - "domainNote": { - "cardiometabolic": "Drei Bereiche, ein Feld der Gesundheit. Jeder Bereich zählt im Score gleich viel, dieses Feld wiegt also dreimal so schwer wie eines mit nur einem Bereich." + "wellbeing": "Wohlbefinden" }, "state": { "counting": "Zählt in deinen Score", "waiting": "Ausgewählt, wartet auf Daten", "crisis": "Statt eines Werts wird ein Sicherheitshinweis gezeigt. An deiner Auswahl liegt es nicht.", - "readFailed": "Dieser Bereich konnte beim letzten Durchlauf nicht gelesen werden.", - "unavailable": "Noch nicht verfügbar", - "unavailableDetail": "HealthLog kann einen gemessenen Fitnesstest bisher nicht von einer Geräteschätzung unterscheiden, deshalb hat dieser Bereich noch nie einen Wert ergeben. Er bleibt in der Liste, damit du siehst, dass er nicht mitzählt." + "readFailed": "Dieser Bereich konnte beim letzten Durchlauf nicht gelesen werden." }, "presets": { "label": "Ausgangspunkt", diff --git a/messages/en.json b/messages/en.json index 85efbc250..4a6bbe727 100644 --- a/messages/en.json +++ b/messages/en.json @@ -2295,7 +2295,6 @@ "sleep": "Sleep", "adiposity": "Adiposity", "wellbeing": "Wellbeing", - "fitness": "Measured fitness", "lipids": "Lipids" }, "observed": "Observed: {value}", @@ -2312,11 +2311,9 @@ "observedActivity": "{value} steps per day", "observedSleep": "{value} minutes per night", "observedAdiposity": "Waist-to-height ratio {value}", - "observedFitness": "{value} {unit}, measured", "referenceActivity": "Benefit plateaus by {high} steps per day", "referenceSleep": "At least {low} minutes per night; longer sleep is not penalised", "referenceAdiposity": "Waist-to-height ratio below {high}", - "referenceFitness": "{low} to {high} {unit}, age and sex reference band", "referenceHba1c": "HbA1c {low} to {high}%", "referenceDetail": "{label} · {source} · {kind} · {low} to {high}", "openBound": "open", @@ -4914,8 +4911,6 @@ }, "threeAxes": "Recording, showing and counting are three separate things. This page changes only the last one. A pillar you switch off here keeps being recorded and keeps appearing everywhere else in HealthLog, it just stops moving the number.", "axis": { - "recorded": "Being recorded", - "shown": "Shown in the app", "countsFor": "Count {pillar} toward the score" }, "domain": { @@ -4923,19 +4918,13 @@ "activity": "Activity", "sleep": "Sleep", "adiposity": "Body shape", - "wellbeing": "Wellbeing", - "fitness": "Measured fitness" - }, - "domainNote": { - "cardiometabolic": "Three pillars, one area of health. Each pillar carries the same weight in the score, so this area weighs three times as much as an area with a single pillar." + "wellbeing": "Wellbeing" }, "state": { "counting": "Counting toward your score", "waiting": "Selected, waiting for data", "crisis": "Safety guidance is shown instead of a score. Your selection is fine.", - "readFailed": "This pillar could not be read on the last run.", - "unavailable": "Not available yet", - "unavailableDetail": "HealthLog cannot yet tell a measured fitness test apart from a device estimate, so this pillar has never produced a score. It stays listed so you can see it is not counted." + "readFailed": "This pillar could not be read on the last run." }, "presets": { "label": "Start from", diff --git a/messages/es.json b/messages/es.json index b008bd088..e6d3a489c 100644 --- a/messages/es.json +++ b/messages/es.json @@ -2295,7 +2295,6 @@ "sleep": "Sueño", "adiposity": "Adiposidad", "wellbeing": "Bienestar", - "fitness": "Forma física medida", "lipids": "Lípidos" }, "observed": "Observado: {value}", @@ -2312,11 +2311,9 @@ "observedActivity": "{value} pasos al día", "observedSleep": "{value} minutos por noche", "observedAdiposity": "Relación cintura-altura {value}", - "observedFitness": "{value} {unit}, medido", "referenceActivity": "El beneficio se estabiliza en {high} pasos al día", "referenceSleep": "Al menos {low} minutos por noche; el sueño más largo no se penaliza", "referenceAdiposity": "Relación cintura-altura inferior a {high}", - "referenceFitness": "{low} a {high} {unit}, intervalo de referencia por edad y sexo", "referenceHba1c": "HbA1c de {low} a {high} %", "referenceDetail": "{label} · {source} · {kind} · {low} a {high}", "openBound": "abierto", @@ -4914,8 +4911,6 @@ }, "threeAxes": "Registrar, mostrar y contar son tres cosas distintas. Esta página cambia solo la última. Un pilar que desactives aquí se sigue registrando y se sigue viendo en el resto de HealthLog, simplemente deja de mover el número.", "axis": { - "recorded": "Se registra", - "shown": "Se muestra en la app", "countsFor": "Contar {pillar} para la puntuación" }, "domain": { @@ -4923,19 +4918,13 @@ "activity": "Actividad", "sleep": "Sueño", "adiposity": "Forma corporal", - "wellbeing": "Bienestar", - "fitness": "Condición física medida" - }, - "domainNote": { - "cardiometabolic": "Tres pilares, un mismo ámbito de salud. Cada pilar pesa lo mismo en la puntuación, así que este ámbito pesa el triple que uno con un solo pilar." + "wellbeing": "Bienestar" }, "state": { "counting": "Cuenta para tu puntuación", "waiting": "Seleccionado, esperando datos", "crisis": "En lugar de una puntuación se muestra orientación de seguridad. Tu selección está bien.", - "readFailed": "Este pilar no se pudo leer en el último cálculo.", - "unavailable": "Todavía no disponible", - "unavailableDetail": "HealthLog aún no distingue una prueba de condición física medida de una estimación del dispositivo, así que este pilar nunca ha dado una puntuación. Sigue en la lista para que veas que no cuenta." + "readFailed": "Este pilar no se pudo leer en el último cálculo." }, "presets": { "label": "Punto de partida", diff --git a/messages/fr.json b/messages/fr.json index 8872899d3..e8df25ca5 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -2295,7 +2295,6 @@ "sleep": "Sommeil", "adiposity": "Adiposité", "wellbeing": "Bien-être", - "fitness": "Condition physique mesurée", "lipids": "Lipides" }, "observed": "Observé : {value}", @@ -2312,11 +2311,9 @@ "observedActivity": "{value} pas par jour", "observedSleep": "{value} minutes par nuit", "observedAdiposity": "Rapport taille-hauteur {value}", - "observedFitness": "{value} {unit}, mesuré", "referenceActivity": "Le bénéfice se stabilise à {high} pas par jour", "referenceSleep": "Au moins {low} minutes par nuit ; un sommeil plus long n'est pas pénalisé", "referenceAdiposity": "Rapport taille-hauteur inférieur à {high}", - "referenceFitness": "{low} à {high} {unit}, plage de référence selon l'âge et le sexe", "referenceHba1c": "HbA1c de {low} à {high} %", "referenceDetail": "{label} · {source} · {kind} · {low} à {high}", "openBound": "ouvert", @@ -4914,8 +4911,6 @@ }, "threeAxes": "Enregistrer, afficher et compter sont trois choses distinctes. Cette page ne change que la dernière. Un pilier désactivé ici continue d'être enregistré et reste visible partout ailleurs dans HealthLog, il cesse simplement de faire bouger le chiffre.", "axis": { - "recorded": "Enregistré", - "shown": "Affiché dans l'application", "countsFor": "Compter {pillar} dans le score" }, "domain": { @@ -4923,19 +4918,13 @@ "activity": "Activité", "sleep": "Sommeil", "adiposity": "Silhouette", - "wellbeing": "Bien-être", - "fitness": "Condition physique mesurée" - }, - "domainNote": { - "cardiometabolic": "Trois piliers, un seul domaine de santé. Chaque pilier pèse autant dans le score, ce domaine pèse donc trois fois plus qu'un domaine composé d'un seul pilier." + "wellbeing": "Bien-être" }, "state": { "counting": "Compte dans votre score", "waiting": "Sélectionné, en attente de données", "crisis": "Un message de sécurité s'affiche à la place d'un score. Votre sélection n'est pas en cause.", - "readFailed": "Ce pilier n'a pas pu être lu lors du dernier calcul.", - "unavailable": "Pas encore disponible", - "unavailableDetail": "HealthLog ne sait pas encore distinguer un test de condition physique mesuré d'une estimation d'appareil, ce pilier n'a donc jamais produit de score. Il reste listé pour que vous voyiez qu'il ne compte pas." + "readFailed": "Ce pilier n'a pas pu être lu lors du dernier calcul." }, "presets": { "label": "Point de départ", diff --git a/messages/it.json b/messages/it.json index 2d3abdcf0..296ac67d6 100644 --- a/messages/it.json +++ b/messages/it.json @@ -2295,7 +2295,6 @@ "sleep": "Sonno", "adiposity": "Adiposità", "wellbeing": "Benessere", - "fitness": "Forma fisica misurata", "lipids": "Lipidi" }, "observed": "Osservato: {value}", @@ -2312,11 +2311,9 @@ "observedActivity": "{value} passi al giorno", "observedSleep": "{value} minuti per notte", "observedAdiposity": "Rapporto vita-altezza {value}", - "observedFitness": "{value} {unit}, misurato", "referenceActivity": "Il beneficio si stabilizza a {high} passi al giorno", "referenceSleep": "Almeno {low} minuti per notte; il sonno più lungo non viene penalizzato", "referenceAdiposity": "Rapporto vita-altezza inferiore a {high}", - "referenceFitness": "Da {low} a {high} {unit}, intervallo di riferimento per età e sesso", "referenceHba1c": "HbA1c da {low} a {high}%", "referenceDetail": "{label} · {source} · {kind} · da {low} a {high}", "openBound": "aperto", @@ -4914,8 +4911,6 @@ }, "threeAxes": "Registrare, mostrare e contare sono tre cose diverse. Questa pagina cambia solo l'ultima. Un pilastro che disattivi qui continua a essere registrato e resta visibile ovunque in HealthLog, smette solo di muovere il numero.", "axis": { - "recorded": "Viene registrato", - "shown": "Mostrato nell'app", "countsFor": "Conta {pillar} nel punteggio" }, "domain": { @@ -4923,19 +4918,13 @@ "activity": "Attività", "sleep": "Sonno", "adiposity": "Forma corporea", - "wellbeing": "Benessere", - "fitness": "Forma fisica misurata" - }, - "domainNote": { - "cardiometabolic": "Tre pilastri, un solo ambito di salute. Ogni pilastro pesa allo stesso modo nel punteggio, quindi questo ambito pesa il triplo di uno con un solo pilastro." + "wellbeing": "Benessere" }, "state": { "counting": "Conta nel tuo punteggio", "waiting": "Selezionato, in attesa di dati", "crisis": "Al posto di un punteggio viene mostrata un'indicazione di sicurezza. La tua selezione va bene.", - "readFailed": "Questo pilastro non è stato letto nell'ultimo calcolo.", - "unavailable": "Non ancora disponibile", - "unavailableDetail": "HealthLog non sa ancora distinguere un test di forma fisica misurato da una stima del dispositivo, perciò questo pilastro non ha mai prodotto un punteggio. Resta in elenco perché tu veda che non conta." + "readFailed": "Questo pilastro non è stato letto nell'ultimo calcolo." }, "presets": { "label": "Punto di partenza", diff --git a/messages/pl.json b/messages/pl.json index df0a5c772..e28c1f528 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -2295,7 +2295,6 @@ "sleep": "Sen", "adiposity": "Otłuszczenie", "wellbeing": "Samopoczucie", - "fitness": "Zmierzona wydolność", "lipids": "Lipidy" }, "observed": "Zaobserwowano: {value}", @@ -2312,11 +2311,9 @@ "observedActivity": "{value} kroków dziennie", "observedSleep": "{value} minut na noc", "observedAdiposity": "Stosunek obwodu talii do wzrostu {value}", - "observedFitness": "{value} {unit}, zmierzone", "referenceActivity": "Korzyść stabilizuje się przy {high} krokach dziennie", "referenceSleep": "Co najmniej {low} minut na noc; dłuższy sen nie obniża wyniku", "referenceAdiposity": "Stosunek obwodu talii do wzrostu poniżej {high}", - "referenceFitness": "Od {low} do {high} {unit}, zakres odniesienia według wieku i płci", "referenceHba1c": "HbA1c od {low} do {high}%", "referenceDetail": "{label} · {source} · {kind} · od {low} do {high}", "openBound": "otwarty", @@ -4914,8 +4911,6 @@ }, "threeAxes": "Zapisywanie, pokazywanie i liczenie to trzy różne rzeczy. Ta strona zmienia tylko ostatnią. Filar wyłączony tutaj nadal jest zapisywany i nadal widoczny w całym HealthLogu, przestaje jedynie ruszać liczbą.", "axis": { - "recorded": "Jest zapisywany", - "shown": "Pokazywany w aplikacji", "countsFor": "Licz {pillar} do wyniku" }, "domain": { @@ -4923,19 +4918,13 @@ "activity": "Aktywność", "sleep": "Sen", "adiposity": "Sylwetka", - "wellbeing": "Samopoczucie", - "fitness": "Zmierzona wydolność" - }, - "domainNote": { - "cardiometabolic": "Trzy filary, jeden obszar zdrowia. Każdy filar waży w wyniku tyle samo, więc ten obszar waży trzy razy tyle co obszar z jednym filarem." + "wellbeing": "Samopoczucie" }, "state": { "counting": "Liczy się do Twojego wyniku", "waiting": "Wybrany, czeka na dane", "crisis": "Zamiast wyniku pokazywana jest wskazówka bezpieczeństwa. Z Twoim wyborem wszystko w porządku.", - "readFailed": "Tego filaru nie udało się odczytać przy ostatnim liczeniu.", - "unavailable": "Jeszcze niedostępny", - "unavailableDetail": "HealthLog nie potrafi jeszcze odróżnić zmierzonego testu wydolności od szacunku z urządzenia, więc ten filar nigdy nie dał wyniku. Zostaje na liście, żebyś widział, że się nie liczy." + "readFailed": "Tego filaru nie udało się odczytać przy ostatnim liczeniu." }, "presets": { "label": "Punkt wyjścia", diff --git a/src/app/api/analytics/route.ts b/src/app/api/analytics/route.ts index 6ef274132..08fa120b3 100644 --- a/src/app/api/analytics/route.ts +++ b/src/app/api/analytics/route.ts @@ -559,7 +559,6 @@ async function buildAnalyticsResponse(user: AuthedUser, locale: Locale) { now: scoredAt, profile: { dateOfBirth: user.dateOfBirth, - gender: user.gender, heightCm: user.heightCm ?? null, timezone: userTz, sourcePriorityJson: scoreSourcePriority, diff --git a/src/app/api/auth/me/health-score-config/__tests__/route.test.ts b/src/app/api/auth/me/health-score-config/__tests__/route.test.ts index 7cb6f40f6..592d7c67d 100644 --- a/src/app/api/auth/me/health-score-config/__tests__/route.test.ts +++ b/src/app/api/auth/me/health-score-config/__tests__/route.test.ts @@ -69,14 +69,13 @@ const SESSION_OK = { user: { id: "user-1", username: "testuser", role: "USER" as const }, }; -const ALL_EIGHT: ScorePillarId[] = [ +const ALL_PILLARS: ScorePillarId[] = [ "BLOOD_PRESSURE", "GLYCAEMIA", "ACTIVITY", "SLEEP", "ADIPOSITY", "WELLBEING", - "FITNESS", "LIPIDS", ]; @@ -154,7 +153,7 @@ describe("GET /api/auth/me/health-score-config", () => { ); expect(res.status).toBe(200); const env = (await res.json()) as { data: ResolvedBody }; - expect(env.data.pillars).toEqual(ALL_EIGHT); + expect(env.data.pillars).toEqual(ALL_PILLARS); expect(env.data.hasSelection).toBe(false); expect(env.data.version).toBe(0); expect(env.data.updatedAt).toBeUndefined(); @@ -172,7 +171,7 @@ describe("GET /api/auth/me/health-score-config", () => { ); const env = (await res.json()) as { data: ResolvedBody }; expect(env.data.pillars).toEqual( - ALL_EIGHT.filter((id) => id !== "SLEEP" && id !== "LIPIDS"), + ALL_PILLARS.filter((id) => id !== "SLEEP" && id !== "LIPIDS"), ); expect(env.data.excludedPillars).toEqual(["SLEEP", "LIPIDS"]); expect(env.data.hasSelection).toBe(true); @@ -185,7 +184,7 @@ describe("PATCH /api/auth/me/health-score-config", () => { it("rejects an unauthenticated request with 401", async () => { vi.mocked(getSession).mockResolvedValue(null); const res = await (PATCH as (r: Request) => Promise)( - mkPatch({ pillars: ALL_EIGHT }), + mkPatch({ pillars: ALL_PILLARS }), ); expect(res.status).toBe(401); expect(prisma.user.update).not.toHaveBeenCalled(); @@ -211,7 +210,7 @@ describe("PATCH /api/auth/me/health-score-config", () => { const stored = writtenConfig(); expect(stored.excludedPillars).toEqual( - ALL_EIGHT.filter((id) => !selection.includes(id)), + ALL_PILLARS.filter((id) => !selection.includes(id)), ); expect(stored.version).toBe(1); expect(stored.changedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/); @@ -230,7 +229,7 @@ describe("PATCH /api/auth/me/health-score-config", () => { }); await (PATCH as (r: Request) => Promise)( - mkPatch({ pillars: ALL_EIGHT }), + mkPatch({ pillars: ALL_PILLARS }), ); expect(writtenConfig().version).toBe(8); }); @@ -240,7 +239,7 @@ describe("PATCH /api/auth/me/health-score-config", () => { primeUser(null); const res = await (PATCH as (r: Request) => Promise)( - mkPatch({ pillars: ALL_EIGHT }), + mkPatch({ pillars: ALL_PILLARS }), ); const env = (await res.json()) as { data: ResolvedBody }; expect(writtenConfig().excludedPillars).toEqual([]); @@ -252,7 +251,7 @@ describe("PATCH /api/auth/me/health-score-config", () => { primeUser(null); await (PATCH as (r: Request) => Promise)( - mkPatch({ pillars: ALL_EIGHT }), + mkPatch({ pillars: ALL_PILLARS }), ); expect(invalidateUserHealthScore).toHaveBeenCalledWith("user-1"); }); @@ -334,7 +333,7 @@ describe("PATCH — envelope and transport", () => { vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); primeUser(null); const res = await (PATCH as (r: Request) => Promise)( - mkPatch({ pillars: ALL_EIGHT, weights: { SLEEP: 2 } }), + mkPatch({ pillars: ALL_PILLARS, weights: { SLEEP: 2 } }), ); expect(res.status).toBe(422); expect(prisma.user.update).not.toHaveBeenCalled(); @@ -344,7 +343,7 @@ describe("PATCH — envelope and transport", () => { vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); primeUser(null); const res = await (PATCH as (r: Request) => Promise)( - mkPatch({ pillars: ALL_EIGHT, userId: "user-2" }), + mkPatch({ pillars: ALL_PILLARS, userId: "user-2" }), ); expect(res.status).toBe(422); expect(prisma.user.update).not.toHaveBeenCalled(); @@ -373,7 +372,7 @@ describe("PATCH — envelope and transport", () => { resetAt: Date.now() + 30_000, }); const res = await (PATCH as (r: Request) => Promise)( - mkPatch({ pillars: ALL_EIGHT }), + mkPatch({ pillars: ALL_PILLARS }), ); expect(res.status).toBe(429); expect(prisma.user.update).not.toHaveBeenCalled(); @@ -386,7 +385,7 @@ describe("PATCH — envelope and transport", () => { const res = await (PATCH as (r: Request) => Promise)( mkPatch({ - pillars: ALL_EIGHT, + pillars: ALL_PILLARS, baseUpdatedAt: "2026-07-24T08:00:00.000Z", }), ); diff --git a/src/components/insights/__tests__/health-score-card-geometry.test.tsx b/src/components/insights/__tests__/health-score-card-geometry.test.tsx index 5ef74d250..ff6068a79 100644 --- a/src/components/insights/__tests__/health-score-card-geometry.test.tsx +++ b/src/components/insights/__tests__/health-score-card-geometry.test.tsx @@ -134,7 +134,6 @@ const ALL_PILLARS = [ "SLEEP", "ADIPOSITY", "WELLBEING", - "FITNESS", "LIPIDS", ] as const; diff --git a/src/components/insights/__tests__/health-score-card.test.tsx b/src/components/insights/__tests__/health-score-card.test.tsx index 1301de90d..66ce483f7 100644 --- a/src/components/insights/__tests__/health-score-card.test.tsx +++ b/src/components/insights/__tests__/health-score-card.test.tsx @@ -505,7 +505,7 @@ describe(" pillars", () => { }); describe(" one pillar, one home", () => { - /** Four scored, one failed read, one crisis, two plain absences. */ + /** Four scored, one failed read, one crisis, one plain absence. */ function mixedReport() { return scoredReport({ pillars: [ @@ -513,10 +513,9 @@ describe(" one pillar, one home", () => { scoredAt("GLYCAEMIA", 74), scoredAt("ACTIVITY", 61), scoredAt("SLEEP", 45), - gatedPillar("FITNESS", "read_failed"), + gatedPillar("LIPIDS", "read_failed"), gatedPillar("WELLBEING", "crisis_signposting"), gatedPillar("ADIPOSITY", "missing_height"), - gatedPillar("LIPIDS", "not_tracked"), ], }); } @@ -538,14 +537,14 @@ describe(" one pillar, one home", () => { "ACTIVITY", "SLEEP", ]); - expect(failedIds).toEqual(["FITNESS"]); + expect(failedIds).toEqual(["LIPIDS"]); expect(crisisIds).toEqual(["WELLBEING"]); - expect(counted).toBe(2); + expect(counted).toBe(1); - // The four homes partition the eight pillars: disjoint, and complete. + // The four homes partition the seven pillars: disjoint, and complete. const named = [...rowIds, ...failedIds, ...crisisIds]; expect(new Set(named).size).toBe(named.length); - expect(named.length + counted).toBe(8); + expect(named.length + counted).toBe(7); }); it("never renders one pillar id twice anywhere in the panel", () => { @@ -556,14 +555,7 @@ describe(" one pillar, one home", () => { const ids = pillarAttrs(html); expect(new Set(ids).size).toBe(ids.length); expect(ids.sort()).toEqual( - [ - "ACTIVITY", - "ADIPOSITY", - "BLOOD_PRESSURE", - "GLYCAEMIA", - "LIPIDS", - "SLEEP", - ].sort(), + ["ACTIVITY", "ADIPOSITY", "BLOOD_PRESSURE", "GLYCAEMIA", "SLEEP"].sort(), ); }); @@ -581,7 +573,6 @@ describe(" one pillar, one home", () => { "Sleep", "Adiposity", "Wellbeing", - "Measured fitness", "Lipids", ]) { const hits = listed.split(label).length - 1; diff --git a/src/components/insights/health-score-pillar-detail.ts b/src/components/insights/health-score-pillar-detail.ts index f947bd106..fc43535b2 100644 --- a/src/components/insights/health-score-pillar-detail.ts +++ b/src/components/insights/health-score-pillar-detail.ts @@ -82,12 +82,6 @@ export function pillarObservedText( value: observed.value, }); } - if (pillar.id === "FITNESS") { - return t("insights.healthScore.observedFitness", { - value: observed.value, - unit: observed.unit, - }); - } return observed.label; } @@ -129,18 +123,12 @@ function pillarReferenceText( ? t("insights.healthScore.referenceAdiposity", { high: reference.high ?? "", }) - : pillar.id === "FITNESS" - ? t("insights.healthScore.referenceFitness", { + : pillar.id === "GLYCAEMIA" + ? t("insights.healthScore.referenceHba1c", { low: reference.low ?? "", high: reference.high ?? "", - unit: value.observed.unit, }) - : pillar.id === "GLYCAEMIA" - ? t("insights.healthScore.referenceHba1c", { - low: reference.low ?? "", - high: reference.high ?? "", - }) - : reference.label; + : reference.label; return formatReference( reference, label, diff --git a/src/components/settings/__tests__/score-pillar-row.test.tsx b/src/components/settings/__tests__/score-pillar-row.test.tsx index 85101c7ee..1f73430cc 100644 --- a/src/components/settings/__tests__/score-pillar-row.test.tsx +++ b/src/components/settings/__tests__/score-pillar-row.test.tsx @@ -25,27 +25,28 @@ const base: ScoreConfigRow = { id: "SLEEP", domain: "sleep", counts: true, - selectable: true, eligibility: "counting", }; -describe("the three axes", () => { - it("names recording and showing beside the score switch", () => { - const html = render(base); - - expect(html).toContain('data-slot="score-pillar-axis-recorded"'); - expect(html).toContain('data-slot="score-pillar-axis-shown"'); - expect(html).toContain('data-slot="score-pillar-switch"'); - expect(html).toContain("Being recorded"); - expect(html).toContain("Shown in the app"); - }); - +describe("the row", () => { it("offers exactly one switch, and it is the score one", () => { const html = render(base); + expect(html).toContain('data-slot="score-pillar-switch"'); expect(html.match(/role="switch"/g) ?? []).toHaveLength(1); expect(html).toContain("Count Sleep toward the score"); }); + + it("says nothing about recording or showing", () => { + // Those two belong to the modules screen. Restating them under every + // pillar put the same two words on every row of a page about a third + // question, and the sentence above the rows already says so once. + const html = render(base); + + expect(html).not.toContain('data-slot="score-pillar-axes"'); + expect(html).not.toContain("Being recorded"); + expect(html).not.toContain("Shown in the app"); + }); }); describe("row attributes", () => { @@ -106,38 +107,6 @@ describe("a failed read", () => { }); }); -describe("a pillar this build cannot score", () => { - it("is disabled, honest, and offers no switch at all", () => { - const html = render({ - ...base, - id: "FITNESS", - domain: "fitness", - selectable: false, - eligibility: "unavailable", - }); - - expect(html).toContain('data-selectable="false"'); - expect(html).toContain('data-slot="score-pillar-unavailable"'); - expect(html).toContain('aria-disabled="true"'); - expect(html).toContain("Not available yet"); - // Not a switch that no-ops behind a disabled attribute: no switch. - expect(html).not.toContain('data-slot="score-pillar-switch"'); - expect(html).not.toContain('role="switch"'); - }); - - it("says why, rather than leaving the row a dead end", () => { - const html = render({ - ...base, - id: "FITNESS", - domain: "fitness", - selectable: false, - eligibility: "unavailable", - }); - - expect(html).toContain("has never produced a score"); - }); -}); - describe("localisation", () => { // Asserting the absence of a key path would prove nothing: `t()` falls // back to the English bundle, so a missing locale key renders English diff --git a/src/components/settings/score-pillar-row.tsx b/src/components/settings/score-pillar-row.tsx index 3dd1639b2..99853a15c 100644 --- a/src/components/settings/score-pillar-row.tsx +++ b/src/components/settings/score-pillar-row.tsx @@ -3,15 +3,11 @@ /** * One pillar on the "what counts toward your Health Score" surface. * - * The row's job is to make three things legible that nowhere else in the - * app shows together: whether the pillar is being recorded, whether it is - * shown, and whether it counts toward the score. Only the last is - * writable here, and the card above the rows says so in words. - * - * The first two are not switches on this surface and are not dressed to - * look like ones. They are read-outs, because the page that owns them is - * the modules screen and a second switch for the same fact is how two - * places end up disagreeing about one thing. + * The row carries the pillar's name, one line about where it currently + * stands with the server, and the switch that decides whether it counts. + * Nothing else: recording and showing are the modules screen's business, + * and repeating them on every row was noise on a page about a third + * question. * * Presentation only. The parent owns the draft, the mutation and the * cache, so this stays a leaf a test can drive directly. @@ -35,7 +31,6 @@ const ELIGIBILITY_LINE_KEYS: Partial< counting: "settings.sections.score.state.counting", crisis: "settings.sections.score.state.crisis", read_failed: "settings.sections.score.state.readFailed", - unavailable: "settings.sections.score.state.unavailableDetail", }; export interface ScorePillarRowProps { @@ -65,32 +60,17 @@ export function ScorePillarRow({ data-domain={row.domain} data-counts={row.counts ? "true" : "false"} data-eligibility={row.eligibility} - data-selectable={row.selectable ? "true" : "false"} - className="flex items-start justify-between gap-4 py-3" + className="flex min-h-11 items-center justify-between gap-4" > -
+
- {/* The three axes, side by side. Recording and showing read as - settled facts; the third is the switch to the right. */} -

- - {t("settings.sections.score.axis.recorded")} - - {" · "} - - {t("settings.sections.score.axis.shown")} - -

{stateKey ? (

{t(stateKey)}

@@ -104,30 +84,16 @@ export function ScorePillarRow({ className="text-muted-foreground size-3.5 animate-spin motion-reduce:animate-none" /> ) : null} - {row.selectable ? ( - onToggle(next)} - aria-label={t("settings.sections.score.axis.countsFor", { - pillar: t(SCORE_PILLAR_LABEL_KEYS[row.id]), - })} - /> - ) : ( - // Not a disabled switch pretending to be a choice: a pillar this - // build can never score has no state to show, so it says so. - // `aria-disabled` + the opacity container is the standard - // disabled look (UI-STANDARDS §3). - - {t("settings.sections.score.state.unavailable")} - - )} + onToggle(next)} + aria-label={t("settings.sections.score.axis.countsFor", { + pillar: t(SCORE_PILLAR_LABEL_KEYS[row.id]), + })} + />
); diff --git a/src/components/settings/score-section.tsx b/src/components/settings/score-section.tsx index 99334a8f5..7eb58c8db 100644 --- a/src/components/settings/score-section.tsx +++ b/src/components/settings/score-section.tsx @@ -14,13 +14,18 @@ * - **One concept, one card.** The rows, the starting points and the * save all sit in the same card. A top half and a bottom half of the * same decision is the split the settings IA rules forbid. - * - **Grouped by domain, and the grouping is visible.** Blood pressure, - * glycaemia and lipids sit under one heading with a sentence saying - * they are one area of health carrying three pillars. Equal weight per - * pillar is not equal weight per area, and until this page nothing in - * the app said so. - * - **Only the score toggle is writable.** Recording and showing are - * read-outs with a line saying this page leaves them alone. + * - **The settings card anatomy, unmodified.** Header through + * `SettingsCardHeader`, body at `mt-4 pl-7` so every line starts on + * the title's reading edge, save bottom-right in its own row. The + * v1.35.0 cut hung the save in the header's status slot and let the + * body start at the card edge, which read as a different kind of card + * from the ones above and below it. + * - **Grouped by domain, and the grouping is spacing.** Blood pressure, + * glycaemia and lipids sit under one heading; the gap between groups + * does the separating. Rules between every row made a short list look + * like a table of unrelated things. + * - **Only the score toggle is writable.** Recording and showing belong + * to the modules screen and are not restated per row. * - **A pillar whose module is off is not shown.** Turning a module off * closes the data path deliberately; a pillar behind a closed path is * not waiting for anything, and saying it waits nags a person in the @@ -66,16 +71,13 @@ import { import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import { useAnalyticsQuery } from "@/lib/queries/use-analytics-query"; -import { - SCORE_DOMAIN_LABEL_KEYS, - SCORE_DOMAIN_NOTE_KEYS, -} from "@/lib/score-config/labels"; +import { SCORE_DOMAIN_LABEL_KEYS } from "@/lib/score-config/labels"; import { buildScoreConfigRows, - selectionToSave, type ScorePillarVerdict, } from "@/lib/score-config/rows"; import { + orderedUniquePillars, SCORE_PILLAR_IDS, type ScorePillarId, } from "@/lib/analytics/score/types"; @@ -191,7 +193,13 @@ export function ScoreSection() { apiPatch( "/api/auth/me/health-score-config", withBaseToken( - { pillars: selectionToSave(selection) }, + // Registry order, no duplicates. What the person sees is what + // the server stores: every listed pillar is a real choice, so + // nothing is filtered out of the selection on the way to the + // write. A selection that came back different from the one that + // was sent is what left the page permanently claiming unsaved + // changes in v1.35.0. + { pillars: orderedUniquePillars(selection) }, readUpdatedAtToken(queryClient, queryKeys.healthScoreConfig()), ), ), @@ -295,157 +303,167 @@ export function ScoreSection() { as="section" data-slot="score-config-card" aria-labelledby="score-config-title" - className="space-y-4" > saveMutation.mutate(draft)} - disabled={busy || !dirty || configQuery.isLoading} - data-slot="score-config-save" - className="min-h-11 sm:min-h-9" - > - {busy ? ( -