"use client"; import { useMemo } from "react"; import { cn } from "@/lib/utils"; import { useTranslations } from "@/lib/i18n/context"; import { useCountUp } from "@/hooks/use-count-up"; import { prefersReducedMotion } from "@/lib/charts/reduced-motion"; import type { CyclePhase } from "./types"; import { PHASE_HUE } from "./phase-tokens"; /** * v1.15.0 — the cycle wheel: a hand-rolled SVG phase ring (0 KB runtime, no * chart lib) that carries the same premium "signature reveal" as the wellness * rings while keeping its own identity as a FULL-circle four-phase dial. The * four cycle phases render as proportional arc segments around the ring; a * marker dot sits at the current day-of-cycle; the day number is real centred * DOM text. * * Motion (all CSS-first, all gated on `prefers-reduced-motion` AND on the * host's `data-revealed`, which the view sets ONCE per session so a background * calendar refetch never re-triggers the moment): * • a clockwise CLOCK-WIPE (`.cycle-ring-reveal`) draws the arcs on from the * 12-o'clock day-0 origin — the already carries `-rotate-90`, so the * conic mask origin lines up with both the arcs and the marker; * • the active phase arc carries the shared `.wellness-ring-arc` bloom, * `--ring-glow` set inline to the active phase hue; * • the shared `.wellness-ring-sheen` sweeps a single specular pass; * • the day number eases up with `useCountUp`; * • the marker gets a soft `.cycle-ring-marker-halo` pulse. * * a11y: `role="img"` + a phase-aware aria-label so the ring is never * colour-only. The arcs/marker are real SVG JSX (`` would not * be `dangerouslySetInnerHTML`); the centred number is real DOM text. */ const VIEW = 100; const STROKE = 8; const R = (VIEW - STROKE) / 2; const C = 2 * Math.PI * R; const CX = VIEW / 2; const GAP = 0.5; // unit gap between segments so phases read as distinct arcs. const PHASE_ORDER: CyclePhase[] = [ "MENSTRUAL", "FOLLICULAR", "OVULATORY", "LUTEAL", ]; export interface PhaseSpan { phase: CyclePhase; /** Proportion of the cycle this phase covers (0..1). Spans sum to ~1. */ fraction: number; } export interface CycleRingProps { /** 1-based day of the current cycle, or null when no cycle is active. */ dayOfCycle: number | null; /** Total length the ring represents (estimated cycle length, days). */ cycleLength: number | null; /** The current phase (drives the centre label + aria). */ phase: CyclePhase | null; /** Proportional phase spans around the ring. Omit for an even quarter split. */ spans?: PhaseSpan[]; /** Whether the once-per-session reveal plays (false = paint final). */ animate?: boolean; size?: number; className?: string; } function evenSpans(): PhaseSpan[] { return PHASE_ORDER.map((phase) => ({ phase, fraction: 0.25 })); } export function CycleRing({ dayOfCycle, cycleLength, phase, spans, animate = false, size = 220, className, }: CycleRingProps) { const { t } = useTranslations(); const reduced = prefersReducedMotion(); const shouldAnimate = animate && !reduced; const resolvedSpans = useMemo(() => { const raw = spans && spans.length > 0 ? spans : evenSpans(); const total = raw.reduce((s, x) => s + x.fraction, 0) || 1; return raw.map((x) => ({ ...x, fraction: x.fraction / total })); }, [spans]); // Build each phase arc as a dash segment around the ring. The running // start-offset is carried through `reduce` rather than mutating a closure // variable inside `map` (the latter trips the render-immutability rule). const segments = useMemo( () => resolvedSpans.reduce<{ offset: number; segs: { phase: CyclePhase; dashArray: string; dashOffset: number; active: boolean; }[]; }>( (acc, span) => { const len = Math.max(span.fraction * C - GAP, 0); acc.segs.push({ phase: span.phase, dashArray: `${len} ${C - len}`, dashOffset: -acc.offset, active: span.phase === phase, }); return { offset: acc.offset + span.fraction * C, segs: acc.segs, }; }, { offset: 0, segs: [] }, ).segs, [resolvedSpans, phase], ); // The marker sits at the cycle-day proportion around the circle. The // surrounding already carries `-rotate-90`, so 0 rad here maps to // 12 o'clock — day 0 must NOT subtract another π/2 (the prior double-rotate // bug parked the marker at 9 o'clock). Day 1 is treated as the 12-o'clock // origin (1-based dayOfCycle → 0-based progress). const markerProgress = dayOfCycle != null && cycleLength != null && cycleLength > 0 ? Math.min((dayOfCycle - 1) / cycleLength, 1) : null; const markerAngle = markerProgress != null ? markerProgress * 2 * Math.PI : null; const markerX = markerAngle != null ? CX + R * Math.cos(markerAngle) : 0; const markerY = markerAngle != null ? CX + R * Math.sin(markerAngle) : 0; // The day number eases up on the reveal, mirroring the score-ring count-up. const displayedDay = useCountUp(dayOfCycle ?? 0, { enabled: shouldAnimate && dayOfCycle != null, durationMs: 900, startDelayMs: 220, }); const phaseLabel = phase ? t(`cycle.phase.${phase}`) : t("cycle.phase.none"); const ariaLabel = dayOfCycle != null ? t("cycle.ring.ariaPhase", { day: dayOfCycle, phase: phaseLabel }) : t("cycle.ring.ariaUnknown"); const markerHue = phase ? PHASE_HUE[phase] : "var(--foreground)"; return (
{/* Conic specular sheen — sweeps the dial once on the reveal. */} ); }