fix(cycle): stop the wheel counting past a plausible cycle day

A user-visible "day 90" inside the cycle ring traced to the open
cycle's phase window: the engine labels every day up to the predicted
next start, and a confirmed or anchored ovulation signal (BBT trend,
symptothermal double-check, LH surge — all clamped to future next
starts) or a long estimated length can push that prediction into the
future while the last logged period start is months old. Every day of
the gap then carries a population-framed LUTEAL label, and the wheel's
back-walk counted the whole gap as one giant cycle.

deriveWheelState now applies an honest ceiling: beyond the profile's
typical cycle length plus a 14-day grace window the wheel drops the
day count and phase claim, draws the canonical four-phase dial, and
the view swaps the caption for a factual "period is later than usual —
the count pauses" line plus a log-period CTA. The dashboard ring tile
and the insights summary card share the same derivation, so neither
can show the inflated count. Calendar labels and the server DTO are
unchanged.
This commit is contained in:
Marc-André Bombeck committed 2026-07-05 14:53:35 +02:00
1 parent 0b2bb42625
commit 75b0e618b4
9 files changed
+134 -12

No files matched your search

+3 -1
View File
@@ -6747,7 +6747,9 @@
"ariaPhase": "Zyklustag {day}, {phase}-Phase",
"ariaUnknown": "Noch kein aktiver Zyklus",
"caption": "deines Zyklus",
"firstPeriodCta": "Erste Periode erfassen"
"firstPeriodCta": "Erste Periode erfassen",
"periodOverdue": "Deine Periode ist später als üblich – die Tageszählung pausiert, bis die nächste Periode erfasst ist.",
"logPeriodCta": "Periode erfassen"
},
"calendar": {
"legendPeriod": "Erfasste Periode",
+3 -1
View File
@@ -6747,7 +6747,9 @@
"ariaPhase": "Cycle day {day}, {phase} phase",
"ariaUnknown": "No active cycle yet",
"caption": "of your cycle",
"firstPeriodCta": "Log my first period"
"firstPeriodCta": "Log my first period",
"periodOverdue": "Your period is later than usual — the day count pauses until the next period is logged.",
"logPeriodCta": "Log period"
},
"calendar": {
"legendPeriod": "Logged period",
+3 -1
View File
@@ -6747,7 +6747,9 @@
"ariaPhase": "Día {day} del ciclo, fase {phase}",
"ariaUnknown": "Aún no hay ciclo activo",
"caption": "de tu ciclo",
"firstPeriodCta": "Registrar mi primer periodo"
"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.",
"logPeriodCta": "Registrar período"
},
"calendar": {
"legendPeriod": "Periodo registrado",
+3 -1
View File
@@ -6747,7 +6747,9 @@
"ariaPhase": "Jour {day} du cycle, phase {phase}",
"ariaUnknown": "Pas encore de cycle actif",
"caption": "de ton cycle",
"firstPeriodCta": "Enregistrer mes premières règles"
"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.",
"logPeriodCta": "Enregistrer les règles"
},
"calendar": {
"legendPeriod": "Règles enregistrées",
+3 -1
View File
@@ -6747,7 +6747,9 @@
"ariaPhase": "Giorno {day} del ciclo, fase {phase}",
"ariaUnknown": "Nessun ciclo attivo ancora",
"caption": "del tuo ciclo",
"firstPeriodCta": "Registra il mio primo 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.",
"logPeriodCta": "Registra mestruazione"
},
"calendar": {
"legendPeriod": "Ciclo registrato",
+3 -1
View File
@@ -6747,7 +6747,9 @@
"ariaPhase": "Dzień {day} cyklu, faza {phase}",
"ariaUnknown": "Brak aktywnego cyklu",
"caption": "twojego cyklu",
"firstPeriodCta": "Zapisz pierwszą miesiączkę"
"firstPeriodCta": "Zapisz pierwszą miesiączkę",
"periodOverdue": "Okres jest późniejszy niż zwykle — licznik dni jest wstrzymany, dopóki nie zapiszesz następnego okresu.",
"logPeriodCta": "Zapisz okres"
},
"calendar": {
"legendPeriod": "Zapisana miesiączka",
@@ -118,6 +118,52 @@ describe("deriveWheelState", () => {
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);
});
});
});
+12 -4
View File
@@ -198,18 +198,26 @@ export function CycleView() {
/>
)}
{!calendarError ? (
<p className="text-muted-foreground text-xs">
{t("cycle.ring.caption")}
<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")
: t("cycle.ring.caption")}
</p>
) : null}
{/* First-period CTA — only when no cycle is active yet. */}
{/* Log CTA — first period when no cycle exists yet, next period
when the count paused on an overdue cycle. */}
{!loading && !calendarError && wheel.dayOfCycle == null ? (
<Button
variant="outline"
className="mt-1 w-full"
onClick={() => openSheet(today)}
>
{t("cycle.ring.firstPeriodCta")}
{wheel.periodOverdue
? t("cycle.ring.logPeriodCta")
: t("cycle.ring.firstPeriodCta")}
</Button>
) : null}
</div>
+58 -2
View File
@@ -22,6 +22,15 @@ export interface WheelState {
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;
}
/**
@@ -46,6 +55,20 @@ export interface CycleProfileLengths {
* 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
@@ -136,20 +159,51 @@ export function deriveWheelState(
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: [] };
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: [] };
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> = {
@@ -185,6 +239,7 @@ export function deriveWheelState(
cycleLength: ideal.cycleLength,
phase: todayDay.phase,
spans: ideal.spans,
periodOverdue: false,
};
}
@@ -199,5 +254,6 @@ export function deriveWheelState(
cycleLength: fullRun.length,
phase: todayDay.phase,
spans,
periodOverdue: false,
};
}