From 75b0e618b47066df5f66e7d2e3381918b04f2a7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 5 Jul 2026 14:53:35 +0200 Subject: [PATCH] fix(cycle): stop the wheel counting past a plausible cycle day MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- messages/de.json | 4 +- messages/en.json | 4 +- messages/es.json | 4 +- messages/fr.json | 4 +- messages/it.json | 4 +- messages/pl.json | 4 +- .../cycle/__tests__/wheel-state.test.ts | 46 ++++++++++++++ src/components/cycle/cycle-view.tsx | 16 +++-- src/components/cycle/wheel-state.ts | 60 ++++++++++++++++++- 9 files changed, 134 insertions(+), 12 deletions(-) diff --git a/messages/de.json b/messages/de.json index f4a562b0b..61fab8f20 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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", diff --git a/messages/en.json b/messages/en.json index ff2929c9b..d8750c994 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/es.json b/messages/es.json index 277dc4bb5..769788bfe 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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", diff --git a/messages/fr.json b/messages/fr.json index e01c96195..197e1512f 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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", diff --git a/messages/it.json b/messages/it.json index ad61b14ae..2c8e32cf5 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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", diff --git a/messages/pl.json b/messages/pl.json index 79dc93b9b..46f54e8d5 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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", diff --git a/src/components/cycle/__tests__/wheel-state.test.ts b/src/components/cycle/__tests__/wheel-state.test.ts index 5d8ce6253..242aa3545 100644 --- a/src/components/cycle/__tests__/wheel-state.test.ts +++ b/src/components/cycle/__tests__/wheel-state.test.ts @@ -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); + }); }); }); diff --git a/src/components/cycle/cycle-view.tsx b/src/components/cycle/cycle-view.tsx index 7016b61cd..0f9bbfe2b 100644 --- a/src/components/cycle/cycle-view.tsx +++ b/src/components/cycle/cycle-view.tsx @@ -198,18 +198,26 @@ export function CycleView() { /> )} {!calendarError ? ( -

- {t("cycle.ring.caption")} +

+ {/* 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")}

) : 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 ? ( ) : null} diff --git a/src/components/cycle/wheel-state.ts b/src/components/cycle/wheel-state.ts index 4494fc447..958b794e4 100644 --- a/src/components/cycle/wheel-state.ts +++ b/src/components/cycle/wheel-state.ts @@ -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 = { @@ -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, }; }