mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
1 parent
6c87c96fa6
commit
dbecbbcbdb
21 files changed
+702
-554
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -41,6 +41,7 @@ describe("<BbtChart>", () => {
|
||||
<BbtChart
|
||||
days={days}
|
||||
today={today}
|
||||
cycleStartDate="2026-06-01"
|
||||
predictedOvulation={null}
|
||||
rawChartMode={false}
|
||||
/>,
|
||||
@@ -60,6 +61,7 @@ describe("<BbtChart>", () => {
|
||||
<BbtChart
|
||||
days={days}
|
||||
today={today}
|
||||
cycleStartDate="2026-06-01"
|
||||
predictedOvulation="2026-06-08"
|
||||
rawChartMode={false}
|
||||
/>,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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("<CycleRingTile>", () => {
|
||||
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(<CycleRingTile />)).toBe("");
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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<BbtPoint[]>(() => {
|
||||
const fromMs = ymdToMs(fromDate);
|
||||
|
||||
@@ -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
|
||||
* `<CyclePhaseHeadline>` (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
|
||||
|
||||
@@ -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 `<CycleInsightSummaryCard>` — 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 }) {
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-1.5">
|
||||
<CycleRing
|
||||
dayOfCycle={wheel.dayOfCycle}
|
||||
cycleLength={wheel.cycleLength}
|
||||
phase={wheel.phase}
|
||||
spans={wheel.spans}
|
||||
dayOfCycle={verdict.dayOfCycle}
|
||||
cycleLength={verdict.cycleLength}
|
||||
phase={verdict.phase}
|
||||
spans={verdict.spans}
|
||||
animate={play}
|
||||
size={120}
|
||||
/>
|
||||
@@ -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}`)}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
@@ -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() {
|
||||
</div>
|
||||
) : (
|
||||
<CycleRing
|
||||
dayOfCycle={wheel.dayOfCycle}
|
||||
cycleLength={wheel.cycleLength}
|
||||
phase={wheel.phase}
|
||||
spans={wheel.spans}
|
||||
dayOfCycle={verdict?.dayOfCycle ?? null}
|
||||
cycleLength={verdict?.cycleLength ?? null}
|
||||
phase={verdict?.phase ?? null}
|
||||
spans={verdict?.spans ?? []}
|
||||
animate={play}
|
||||
/>
|
||||
)}
|
||||
{!calendarError ? (
|
||||
<p className="text-muted-foreground max-w-56 text-center text-xs">
|
||||
{/* 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")}
|
||||
</p>
|
||||
) : 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 ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="mt-1 w-full"
|
||||
onClick={() => openSheet(today)}
|
||||
>
|
||||
{wheel.periodOverdue
|
||||
{verdict?.state === "OVERDUE"
|
||||
? t("cycle.ring.logPeriodCta")
|
||||
: t("cycle.ring.firstPeriodCta")}
|
||||
</Button>
|
||||
@@ -283,7 +280,7 @@ export function CycleView() {
|
||||
{!loading && !calendarError ? (
|
||||
<PhaseEducationCard
|
||||
animate={play}
|
||||
phase={wheel.phase}
|
||||
phase={verdict?.phase ?? null}
|
||||
symptomPatterns={insights.data?.symptomPatterns ?? []}
|
||||
predictionEnabled={calProfile?.predictionEnabled ?? false}
|
||||
rawChartMode={calProfile?.rawChartMode ?? false}
|
||||
@@ -388,6 +385,7 @@ export function CycleView() {
|
||||
<BbtChart
|
||||
days={calendar.data?.days ?? []}
|
||||
today={today}
|
||||
cycleStartDate={verdict?.cycleStartDate ?? null}
|
||||
predictedOvulation={
|
||||
calendar.data?.prediction?.predictedOvulation ?? null
|
||||
}
|
||||
@@ -446,9 +444,9 @@ export function CycleView() {
|
||||
onOpenChange={setSheetOpen}
|
||||
date={selectedDate}
|
||||
today={today}
|
||||
activePeriod={wheel.phase === "MENSTRUAL"}
|
||||
phase={wheel.phase}
|
||||
dayOfCycle={wheel.dayOfCycle}
|
||||
activePeriod={verdict?.phase === "MENSTRUAL"}
|
||||
phase={verdict?.phase ?? null}
|
||||
dayOfCycle={verdict?.dayOfCycle ?? null}
|
||||
goal={goal}
|
||||
predictionEnabled={calProfile?.predictionEnabled ?? false}
|
||||
rawChartMode={calProfile?.rawChartMode ?? false}
|
||||
|
||||
@@ -53,6 +53,29 @@ export interface CyclePrediction {
|
||||
disclaimer: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The server's resolved answer about today (`src/lib/cycle/verdict.ts`).
|
||||
*
|
||||
* Render it. Do not recompute any part of it here: the grace window that
|
||||
* decides `OVERDUE`, the day count, the ring's arcs and the fertile-window
|
||||
* state are all resolved server-side against the user's own timezone day, and
|
||||
* a second copy of that logic in client code is how a cycle ring ends up
|
||||
* showing a person a day their record does not hold.
|
||||
*/
|
||||
export type CycleVerdictState = "IN_CYCLE" | "OVERDUE" | "INSUFFICIENT_DATA";
|
||||
|
||||
export interface CycleVerdict {
|
||||
state: CycleVerdictState;
|
||||
dayOfCycle: number | null;
|
||||
cycleLength: number | null;
|
||||
phase: CyclePhase | null;
|
||||
spans: { phase: CyclePhase; fraction: number }[];
|
||||
cycleStartDate: string | null;
|
||||
overdueDays: number | null;
|
||||
daysUntilNext: number | null;
|
||||
fertileWindow: { start: string | null; end: string | null; active: boolean };
|
||||
}
|
||||
|
||||
export interface CalendarResponse {
|
||||
profile: {
|
||||
goal: CycleGoal;
|
||||
@@ -61,6 +84,8 @@ export interface CalendarResponse {
|
||||
cyclesObserved: number;
|
||||
};
|
||||
prediction: CyclePrediction | null;
|
||||
/** The resolved verdict — always present, so nothing here needs to derive one. */
|
||||
verdict: CycleVerdict;
|
||||
/**
|
||||
* Cold-start gate (mirrors `prediction.stillLearning`): true while < 3 cycles
|
||||
* are observed. When set, `days` carries no fertile window, ovulation dot, or
|
||||
|
||||
@@ -1,259 +0,0 @@
|
||||
/**
|
||||
* v1.15.0 — derive the cycle-wheel state (day-of-cycle, current phase,
|
||||
* proportional phase spans) from the calendar read.
|
||||
*
|
||||
* The calendar read already labels each day with its `phase`; we walk
|
||||
* backward from today to the start of the current run of consecutive
|
||||
* non-null phase days to find the cycle's start, count the day-of-cycle, and
|
||||
* tally each phase's share of that run for the proportional ring segments.
|
||||
* Pure + deterministic so it matches the server engine's labels 1:1.
|
||||
*/
|
||||
import type { CalendarDay, CyclePhase } from "./types";
|
||||
|
||||
const PHASE_ORDER: CyclePhase[] = [
|
||||
"MENSTRUAL",
|
||||
"FOLLICULAR",
|
||||
"OVULATORY",
|
||||
"LUTEAL",
|
||||
];
|
||||
|
||||
export interface WheelState {
|
||||
dayOfCycle: number | null;
|
||||
cycleLength: number | null;
|
||||
phase: CyclePhase | null;
|
||||
spans: { phase: CyclePhase; fraction: number }[];
|
||||
/**
|
||||
* v1.27.5 — true when the current run overshoots the profile's typical
|
||||
* cycle length by more than the overdue grace window. The wheel then stops
|
||||
* asserting a day-of-cycle / phase (both null) and the view renders the
|
||||
* honest "period overdue" caption instead: a day count like "90" inside
|
||||
* the ring is a population-frame artefact of the open cycle window, not an
|
||||
* observed fact (see `OVERDUE_GRACE_DAYS`).
|
||||
*/
|
||||
periodOverdue: 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;
|
||||
}
|
||||
|
||||
/** 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 wheel stops
|
||||
* counting up. 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
|
||||
* wheel degrades to the honest overdue state instead. Two weeks of grace
|
||||
* covers ordinary cycle-to-cycle variation without hiding a real delay.
|
||||
*/
|
||||
const OVERDUE_GRACE_DAYS = 14;
|
||||
|
||||
/**
|
||||
* 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: { phase: CyclePhase; fraction: number }[];
|
||||
cycleLength: number;
|
||||
} {
|
||||
const cycle = Math.max(
|
||||
Math.round(profile?.typicalCycleLength ?? 28) || 28,
|
||||
7,
|
||||
);
|
||||
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<CyclePhase, number> = {
|
||||
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<CyclePhase, number> = {
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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<CyclePhase, number> = {
|
||||
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<CyclePhase, number> = {
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user