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

@@ -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,
};
}