diff --git a/src/components/cycle/__tests__/cycle-ring-tile.test.tsx b/src/components/cycle/__tests__/cycle-ring-tile.test.tsx index 42d7d1d12..59a3b8ee5 100644 --- a/src/components/cycle/__tests__/cycle-ring-tile.test.tsx +++ b/src/components/cycle/__tests__/cycle-ring-tile.test.tsx @@ -49,6 +49,9 @@ 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 }), }; }); @@ -112,12 +115,20 @@ describe("", () => { }); it("renders nothing while the calendar read is still resolving", () => { - calendarState.current = { data: undefined, isLoading: true, isError: false }; + calendarState.current = { + data: undefined, + isLoading: true, + isError: false, + }; expect(render()).toBe(""); }); it("renders nothing on a hard calendar read error", () => { - calendarState.current = { data: undefined, isLoading: false, isError: true }; + calendarState.current = { + data: undefined, + isLoading: false, + isError: true, + }; expect(render()).toBe(""); }); diff --git a/src/components/cycle/__tests__/wheel-state.test.ts b/src/components/cycle/__tests__/wheel-state.test.ts index e700f8f43..5d8ce6253 100644 --- a/src/components/cycle/__tests__/wheel-state.test.ts +++ b/src/components/cycle/__tests__/wheel-state.test.ts @@ -45,18 +45,79 @@ describe("deriveWheelState", () => { }); 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", "FOLLICULAR"), - day("2026-06-04", "OVULATORY"), - day("2026-06-05", "LUTEAL"), + 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-03"); + const w = deriveWheelState(days, "2026-06-05"); const total = w.spans.reduce((s, x) => s + x.fraction, 0); expect(total).toBeCloseTo(1, 5); - // Only phases present in the run are emitted. - expect(w.spans.map((s) => s.phase)).toContain("MENSTRUAL"); + // 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); }); }); diff --git a/src/components/cycle/cycle-ring-tile.tsx b/src/components/cycle/cycle-ring-tile.tsx index 0285f192f..2e20b4761 100644 --- a/src/components/cycle/cycle-ring-tile.tsx +++ b/src/components/cycle/cycle-ring-tile.tsx @@ -9,7 +9,7 @@ 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 } from "./use-cycle"; +import { localYmd, useCycleCalendar, useCycleProfile } from "./use-cycle"; /** * v1.15.3 — the compact cycle ring as a WELLNESS-STRIP element. @@ -53,10 +53,19 @@ export function CycleRingTile({ className }: { className?: string }) { 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), - [calendar.data, today], + () => + deriveWheelState(calendar.data?.days ?? [], today, { + typicalCycleLength: profile.data?.typicalCycleLength, + typicalPeriodLength: profile.data?.typicalPeriodLength, + lutealPhaseLength: profile.data?.lutealPhaseLength, + }), + [calendar.data, today, profile.data], ); // The signature reveal plays ONCE per browser session, gated on its own key diff --git a/src/components/cycle/wheel-state.ts b/src/components/cycle/wheel-state.ts index e86e17e36..4494fc447 100644 --- a/src/components/cycle/wheel-state.ts +++ b/src/components/cycle/wheel-state.ts @@ -24,6 +24,71 @@ export interface WheelState { spans: { phase: CyclePhase; fraction: number }[]; } +/** + * 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; + +/** + * 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 = { + 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 @@ -66,6 +131,7 @@ export function currentCycleStartDate( 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); @@ -104,6 +170,24 @@ export function deriveWheelState( 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, + }; + } + const total = fullRun.length || 1; const spans = PHASE_ORDER.filter((p) => counts[p] > 0).map((phase) => ({ phase,