Draw the canonical four-phase cycle ring for low-data trackers

When only a few cycle days are logged, the labelled forward run was short
enough that normalising it filled the whole ring with a single phase. For
a tracker below seven labelled days, derive the four-phase proportions
from the profile's typical lengths instead (period / luteal / cycle
length, textbook 28/5/14 defaults), so the dial shows the familiar
MENSTRUAL/FOLLICULAR/OVULATORY/LUTEAL arcs. The current phase and the
day-of-cycle marker stay driven by the observed labels.

The ring tile reads the shared, 5-min-cached cycle profile to source the
typical lengths (no extra request). Well-populated cycles keep the
observed-share spans unchanged.
This commit is contained in:
Marc-André Bombeck committed 2026-06-07 14:15:39 +02:00
1 parent 6abc178c88
commit cd9a0b6152
4 files changed
+176 -11

No files matched your search

@@ -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("<CycleRingTile>", () => {
});
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(<CycleRingTile />)).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(<CycleRingTile />)).toBe("");
});
@@ -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);
});
});
+12 -3
View File
@@ -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
+84
View File
@@ -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<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
@@ -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,