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.
This commit is contained in:
Marc-André Bombeck committed 2026-08-01 10:37:18 +02:00
1 parent 6c87c96fa6
commit dbecbbcbdb
21 files changed
+702 -554

No files matched your search

+30 -3
View File
@@ -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