From 31a14ce7bfc2c2ba34f9b1babb18357c5e891085 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 6 Jun 2026 13:09:00 +0200 Subject: [PATCH] feat(cycle): premium cycle ring, BBT chart, and full read-state coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Finish the cycle web vertical's signature surfaces: - Rebuild the cycle wheel to the wellness-ring premium signature — a clockwise clock-wipe that draws the phase arcs on from the 12-o'clock day-0 origin, a phase-tinted bloom on the active arc, the shared specular sheen, a day-number count-up, and a soft current-day marker halo. All on the wellness-tile surface, gated once per session and zeroed under prefers-reduced-motion. - Add the basal-body-temperature chart to the predictions tab: the current-cycle BBT series with phase-tinted points, fertility signs in the tooltip, and an estimated-ovulation reference line (suppressed in raw-chart mode). Surface basalBodyTempC + ovulation-test + mucus on the calendar day DTO so the chart reads from the existing calendar load. - Cover the read states the prediction/disclaimer surfaces were missing: pass the disclaimer to the predictions panel, render it on the calendar tab, add per-tab error + retry, and gate the settings spinner. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/api/openapi.yaml | 26 ++ messages/de.json | 9 + messages/en.json | 9 + messages/es.json | 9 + messages/fr.json | 9 + messages/it.json | 9 + messages/pl.json | 9 + src/app/globals.css | 107 ++++++- .../cycle/__tests__/bbt-chart.test.tsx | 73 +++++ .../cycle/__tests__/cycle-calendar.test.tsx | 3 + .../cycle/__tests__/wheel-state.test.ts | 28 +- src/components/cycle/bbt-chart.tsx | 298 ++++++++++++++++++ src/components/cycle/cycle-ring.tsx | 118 +++++-- src/components/cycle/cycle-view.tsx | 262 ++++++++++----- src/components/cycle/types.ts | 4 + src/components/cycle/wheel-state.ts | 35 +- src/lib/cycle/engine-adapter.ts | 9 + src/lib/openapi/routes.ts | 6 + 18 files changed, 906 insertions(+), 117 deletions(-) create mode 100644 src/components/cycle/__tests__/bbt-chart.test.tsx create mode 100644 src/components/cycle/bbt-chart.tsx diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 325097f05..b040cb1f1 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -5199,6 +5199,29 @@ components: type: boolean confidence: type: number + basalBodyTempC: + anyOf: + - type: number + - type: "null" + ovulationTest: + anyOf: + - type: string + enum: + - NEGATIVE + - POSITIVE_LH_SURGE + - ESTROGEN_SURGE + - INDETERMINATE + - type: "null" + cervicalMucus: + anyOf: + - type: string + enum: + - DRY + - STICKY + - CREAMY + - WATERY + - EGG_WHITE + - type: "null" required: - date - phase @@ -5209,6 +5232,9 @@ components: - flow - hasSymptoms - confidence + - basalBodyTempC + - ovulationTest + - cervicalMucus additionalProperties: false meta: type: object diff --git a/messages/de.json b/messages/de.json index c870b625a..24eaca7b6 100644 --- a/messages/de.json +++ b/messages/de.json @@ -5131,6 +5131,15 @@ "ORAL": "Pille", "PATCH": "Pflaster", "EMERGENCY": "Notfall" + }, + "loadError": "Zyklusdaten konnten nicht geladen werden.", + "bbt": { + "title": "Basaltemperatur", + "caption": "Deine Morgentemperatur über diesen Zyklus. Ein anhaltender Anstieg um etwa 0,2 °C folgt auf den Eisprung.", + "empty": "Noch keine Temperaturwerte", + "emptyHint": "Miss deine Basaltemperatur jeden Morgen, um den Temperaturanstieg deines Zyklus zu sehen.", + "ovulationEstimate": "Gesch. Eisprung", + "tooltipTemp": "{temp} °C" } }, "cycleReminders": { diff --git a/messages/en.json b/messages/en.json index b2cf299bd..703bebb65 100644 --- a/messages/en.json +++ b/messages/en.json @@ -5131,6 +5131,15 @@ "ORAL": "Pill", "PATCH": "Patch", "EMERGENCY": "Emergency" + }, + "loadError": "Couldn't load your cycle data.", + "bbt": { + "title": "Basal body temperature", + "caption": "Your morning temperature across this cycle. A sustained rise of about 0.2 °C follows ovulation.", + "empty": "No temperature readings yet", + "emptyHint": "Log your basal body temperature each morning to see your cycle's temperature shift.", + "ovulationEstimate": "Est. ovulation", + "tooltipTemp": "{temp} °C" } }, "cycleReminders": { diff --git a/messages/es.json b/messages/es.json index 0e583048c..402202490 100644 --- a/messages/es.json +++ b/messages/es.json @@ -5131,6 +5131,15 @@ "ORAL": "Píldora", "PATCH": "Parche", "EMERGENCY": "Emergencia" + }, + "loadError": "No se pudieron cargar tus datos del ciclo.", + "bbt": { + "title": "Temperatura basal", + "caption": "Tu temperatura matutina a lo largo de este ciclo. Una subida sostenida de unos 0,2 °C sigue a la ovulación.", + "empty": "Aún no hay mediciones de temperatura", + "emptyHint": "Registra tu temperatura basal cada mañana para ver el cambio de temperatura de tu ciclo.", + "ovulationEstimate": "Ovulación est.", + "tooltipTemp": "{temp} °C" } }, "cycleReminders": { diff --git a/messages/fr.json b/messages/fr.json index 3d2feb18e..a761e393e 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -5131,6 +5131,15 @@ "ORAL": "Pilule", "PATCH": "Patch", "EMERGENCY": "Urgence" + }, + "loadError": "Impossible de charger vos données de cycle.", + "bbt": { + "title": "Température basale", + "caption": "Votre température matinale sur ce cycle. Une hausse durable d'environ 0,2 °C suit l'ovulation.", + "empty": "Pas encore de relevés de température", + "emptyHint": "Notez votre température basale chaque matin pour voir le décalage de température de votre cycle.", + "ovulationEstimate": "Ovulation est.", + "tooltipTemp": "{temp} °C" } }, "cycleReminders": { diff --git a/messages/it.json b/messages/it.json index 4bc527c12..ef73dcdc9 100644 --- a/messages/it.json +++ b/messages/it.json @@ -5131,6 +5131,15 @@ "ORAL": "Pillola", "PATCH": "Cerotto", "EMERGENCY": "Emergenza" + }, + "loadError": "Impossibile caricare i dati del ciclo.", + "bbt": { + "title": "Temperatura basale", + "caption": "La tua temperatura mattutina in questo ciclo. Un rialzo prolungato di circa 0,2 °C segue l'ovulazione.", + "empty": "Ancora nessuna misurazione della temperatura", + "emptyHint": "Registra la temperatura basale ogni mattina per vedere il rialzo termico del tuo ciclo.", + "ovulationEstimate": "Ovulazione stim.", + "tooltipTemp": "{temp} °C" } }, "cycleReminders": { diff --git a/messages/pl.json b/messages/pl.json index 621243ea6..7143aa461 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -5131,6 +5131,15 @@ "ORAL": "Tabletka", "PATCH": "Plaster", "EMERGENCY": "Awaryjna" + }, + "loadError": "Nie udało się wczytać danych cyklu.", + "bbt": { + "title": "Podstawowa temperatura ciała", + "caption": "Twoja poranna temperatura w tym cyklu. Trwały wzrost o około 0,2 °C następuje po owulacji.", + "empty": "Brak pomiarów temperatury", + "emptyHint": "Zapisuj podstawową temperaturę ciała każdego ranka, aby zobaczyć przesunięcie temperatury w cyklu.", + "ovulationEstimate": "Szac. owulacja", + "tooltipTemp": "{temp} °C" } }, "cycleReminders": { diff --git a/src/app/globals.css b/src/app/globals.css index 9e6d54b2d..07d57cbf4 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -488,7 +488,8 @@ color-mix(in srgb, var(--tile-hue) 7%, var(--card)) 100% ); border: 1px solid color-mix(in srgb, var(--tile-hue) 22%, transparent); - box-shadow: 0 10px 30px -26px color-mix(in srgb, var(--tile-hue) 40%, transparent); + box-shadow: 0 10px 30px -26px + color-mix(in srgb, var(--tile-hue) 40%, transparent); transition: transform 200ms cubic-bezier(0.22, 0.61, 0.36, 1), box-shadow 200ms cubic-bezier(0.22, 0.61, 0.36, 1), @@ -516,7 +517,8 @@ .wellness-tile:hover { transform: translateY(-2px); border-color: color-mix(in srgb, var(--tile-hue) 38%, transparent); - box-shadow: 0 16px 40px -24px color-mix(in srgb, var(--tile-hue) 52%, transparent); + box-shadow: 0 16px 40px -24px + color-mix(in srgb, var(--tile-hue) 52%, transparent); } .wellness-tile:hover [data-slot="score-ring"] { transform: scale(1.015); @@ -552,7 +554,18 @@ — the Oura/WHOOP fill feel. Falls back to the browser default on very old engines that ignore linear() (instant fill, not broken). */ --ring-spring: linear( - 0, 0.06, 0.23, 0.46, 0.7, 0.89, 1.02, 1.06, 1.03, 1, 0.99, 1 + 0, + 0.06, + 0.23, + 0.46, + 0.7, + 0.89, + 1.02, + 1.06, + 1.03, + 1, + 0.99, + 1 ); --ring-ease: cubic-bezier(0.22, 0.61, 0.36, 1); } @@ -632,7 +645,9 @@ on the strongest (green-band) ring so the strip celebrates a good score rather than jittering all five. `--ring-glow` is set inline per ring. */ .wellness-ring-arc { - filter: drop-shadow(0 0 3px color-mix(in srgb, var(--ring-glow) 45%, transparent)); + filter: drop-shadow( + 0 0 3px color-mix(in srgb, var(--ring-glow) 45%, transparent) + ); } .wellness-ring-arc--pulse { /* Breathe a few times to celebrate a strong score, then settle — an @@ -643,10 +658,14 @@ @keyframes ringGlow { 0%, 100% { - filter: drop-shadow(0 0 2px color-mix(in srgb, var(--ring-glow) 40%, transparent)); + filter: drop-shadow( + 0 0 2px color-mix(in srgb, var(--ring-glow) 40%, transparent) + ); } 50% { - filter: drop-shadow(0 0 8px color-mix(in srgb, var(--ring-glow) 65%, transparent)); + filter: drop-shadow( + 0 0 8px color-mix(in srgb, var(--ring-glow) 65%, transparent) + ); } } @@ -703,3 +722,79 @@ animation: none; } } + +/* + * v1.15.0 — the cycle-ring "signature reveal". + * + * The phase dial earns the same premium signature as the wellness rings + * (sweep + glow + sheen + count-up) without losing its own identity as a + * four-phase dial: + * 1. a clockwise CLOCK-WIPE draws the phase arcs on from the 12-o'clock + * day-0 origin (the already carries `-rotate-90`, so the conic + * origin lines up with both the arcs and the current-day marker); + * 2. a phase-tinted bloom on the active arc (shared `.wellness-ring-arc`, + * `--ring-glow` set inline to the active phase hue); + * 3. the shared `.wellness-ring-sheen` specular pass + `.wellness-tile` + * surface the strip already uses; + * 4. a soft halo pulse on the current-day marker so "today" reads as alive. + * All gated on the host's `data-revealed` (set once per session) so a + * background calendar refetch never re-triggers the moment, and all zeroed + * under prefers-reduced-motion. + */ +@property --cycle-wipe { + syntax: ""; + inherits: false; + initial-value: 360deg; +} + +.cycle-ring-reveal { + -webkit-mask: conic-gradient(#000 var(--cycle-wipe), transparent 0); + mask: conic-gradient(#000 var(--cycle-wipe), transparent 0); +} +[data-revealed="true"] .cycle-ring-reveal { + animation: cycleRingWipe 1100ms var(--ring-ease) forwards; +} +@keyframes cycleRingWipe { + from { + --cycle-wipe: 0deg; + } + to { + --cycle-wipe: 360deg; + } +} + +/* The current-day marker's halo — a few gentle pulses on reveal, then calm + (a finite count, never an infinite repaint of the non-compositable ring). */ +.cycle-ring-marker-halo { + transform-box: fill-box; + transform-origin: center; + opacity: 0; +} +[data-revealed="true"] .cycle-ring-marker-halo { + animation: cycleMarkerHalo 2.4s ease-in-out 950ms 3; +} +@keyframes cycleMarkerHalo { + 0%, + 100% { + opacity: 0; + transform: scale(1); + } + 50% { + opacity: 0.45; + transform: scale(1.9); + } +} + +@media (prefers-reduced-motion: reduce) { + .cycle-ring-reveal, + [data-revealed="true"] .cycle-ring-reveal { + -webkit-mask: none; + mask: none; + animation: none; + } + .cycle-ring-marker-halo, + [data-revealed="true"] .cycle-ring-marker-halo { + animation: none; + opacity: 0; + } +} diff --git a/src/components/cycle/__tests__/bbt-chart.test.tsx b/src/components/cycle/__tests__/bbt-chart.test.tsx new file mode 100644 index 000000000..75be6f152 --- /dev/null +++ b/src/components/cycle/__tests__/bbt-chart.test.tsx @@ -0,0 +1,73 @@ +import { describe, it, expect } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { BbtChart } from "../bbt-chart"; +import { I18nProvider } from "@/lib/i18n/context"; +import type { CalendarDay } from "../types"; + +function render(node: React.ReactNode) { + return renderToStaticMarkup( + {node}, + ); +} + +function day( + date: string, + overrides: Partial = {}, +): CalendarDay { + return { + date, + phase: "FOLLICULAR", + isPredictedPeriod: false, + isFertileWindow: false, + isPredictedOvulation: false, + isPeriodLogged: false, + flow: null, + hasSymptoms: false, + confidence: 1, + basalBodyTempC: null, + ovulationTest: null, + cervicalMucus: null, + ...overrides, + }; +} + +describe("", () => { + const today = "2026-06-10"; + + it("shows the empty hint when fewer than two readings exist", () => { + const days = [ + day("2026-06-01", { phase: "MENSTRUAL" }), + day("2026-06-09", { basalBodyTempC: 36.5 }), + ]; + const html = render( + , + ); + expect(html).toContain('data-slot="cycle-bbt-empty"'); + expect(html).not.toContain('data-slot="cycle-bbt-area"'); + }); + + it("draws the curve once two or more readings are present", () => { + const days = [ + day("2026-06-01", { phase: "MENSTRUAL", basalBodyTempC: 36.4 }), + day("2026-06-05", { phase: "FOLLICULAR", basalBodyTempC: 36.5 }), + day("2026-06-08", { phase: "OVULATORY", basalBodyTempC: 36.7 }), + day("2026-06-10", { phase: "LUTEAL", basalBodyTempC: 36.9 }), + ]; + const html = render( + , + ); + expect(html).toContain('data-slot="cycle-bbt-chart"'); + expect(html).toContain('data-slot="cycle-bbt-caption"'); + }); +}); diff --git a/src/components/cycle/__tests__/cycle-calendar.test.tsx b/src/components/cycle/__tests__/cycle-calendar.test.tsx index 7039550ad..dfc93a3d1 100644 --- a/src/components/cycle/__tests__/cycle-calendar.test.tsx +++ b/src/components/cycle/__tests__/cycle-calendar.test.tsx @@ -22,6 +22,9 @@ function dayBase(date: string): CalendarDay { flow: null, hasSymptoms: false, confidence: 1, + basalBodyTempC: null, + ovulationTest: null, + cervicalMucus: null, }; } diff --git a/src/components/cycle/__tests__/wheel-state.test.ts b/src/components/cycle/__tests__/wheel-state.test.ts index c5ee28772..e700f8f43 100644 --- a/src/components/cycle/__tests__/wheel-state.test.ts +++ b/src/components/cycle/__tests__/wheel-state.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from "vitest"; -import { deriveWheelState } from "../wheel-state"; +import { deriveWheelState, currentCycleStartDate } from "../wheel-state"; import type { CalendarDay } from "../types"; function day(date: string, phase: CalendarDay["phase"]): CalendarDay { @@ -14,6 +14,9 @@ function day(date: string, phase: CalendarDay["phase"]): CalendarDay { flow: null, hasSymptoms: false, confidence: 1, + basalBodyTempC: null, + ovulationTest: null, + cervicalMucus: null, }; } @@ -56,3 +59,26 @@ describe("deriveWheelState", () => { expect(w.spans.map((s) => s.phase)).toContain("MENSTRUAL"); }); }); + +describe("currentCycleStartDate", () => { + it("returns the most recent menstrual run-start at/before today", () => { + const days = [ + day("2026-05-30", "LUTEAL"), + day("2026-06-01", "MENSTRUAL"), + day("2026-06-02", "MENSTRUAL"), + day("2026-06-03", "FOLLICULAR"), + day("2026-06-04", "FOLLICULAR"), + ]; + expect(currentCycleStartDate(days, "2026-06-04")).toBe("2026-06-01"); + }); + + it("returns null when today carries no phase", () => { + const days = [day("2026-06-01", null), day("2026-06-02", null)]; + expect(currentCycleStartDate(days, "2026-06-02")).toBeNull(); + }); + + it("returns null when today is absent from the calendar", () => { + const days = [day("2026-06-01", "MENSTRUAL")]; + expect(currentCycleStartDate(days, "2026-06-09")).toBeNull(); + }); +}); diff --git a/src/components/cycle/bbt-chart.tsx b/src/components/cycle/bbt-chart.tsx new file mode 100644 index 000000000..d3406906f --- /dev/null +++ b/src/components/cycle/bbt-chart.tsx @@ -0,0 +1,298 @@ +"use client"; + +/** + * v1.15.0 — the basal-body-temperature (BBT) chart. + * + * Plots the user's logged BBT across the CURRENT cycle (the same window the + * wheel shows, via `currentCycleStartDate`) as a line with phase-tinted dots, + * the symptothermal signal a fertility-aware reader expects. Fertility signs + * already logged for a day — egg-white/watery mucus, a positive LH test — + * surface in the tooltip so the temperature shift reads in context. When the + * goal surfaces a fertile window, the estimated ovulation day is marked with a + * reference line. + * + * Honest data contract: BBT is a LOGGED fact (no estimate), so the curve needs + * no disclaimer of its own; the estimated-ovulation marker is the prediction + * panel's, which already carries the fixed non-medical disclaimer beside it. + * In raw-chart ("Read Your Body") mode the phase tint + ovulation marker are + * suppressed — only the neutral temperatures are drawn, no interpretation. + * + * Recharts (the project's charting lib), Dracula/Alucard tokens, reduced-motion + * gated via `isAnimationActive`. Needs ≥2 logged readings to draw; below that + * it surfaces a "log your morning temperature" hint rather than a flat dot. + */ + +import { useMemo } from "react"; +import { Thermometer } from "lucide-react"; +import { + CartesianGrid, + Line, + LineChart, + ReferenceLine, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; + +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { useTranslations, useFormatters } from "@/lib/i18n/context"; +import { prefersReducedMotion } from "@/lib/charts/reduced-motion"; +import { cn } from "@/lib/utils"; +import type { CalendarDay, CervicalMucus, OvulationTest } from "./types"; +import { PHASE_HUE, OVULATION_HUE } from "./phase-tokens"; +import { currentCycleStartDate } from "./wheel-state"; + +/** Trailing fallback window (days) when no MENSTRUAL-anchored cycle is active. */ +const FALLBACK_WINDOW_DAYS = 35; + +const LINE_COLOR = "var(--cycle-phase-luteal)"; + +interface BbtPoint { + t: number; + temp: number; + phaseHue: string; + mucus: CervicalMucus | null; + ovulationTest: OvulationTest | null; +} + +export interface BbtChartProps { + days: CalendarDay[]; + today: string; + /** Estimated ovulation day (`YYYY-MM-DD`), or null when goal-gated off. */ + predictedOvulation: string | null; + /** Read-Your-Body mode: suppress phase tint + ovulation marker. */ + rawChartMode: boolean; +} + +function ymdToMs(d: string): number { + // Noon UTC so a YYYY-MM-DD never rolls a day across the viewer's tz. + return Date.parse(`${d}T12:00:00Z`); +} + +export function BbtChart({ + days, + today, + predictedOvulation, + rawChartMode, +}: BbtChartProps) { + const { t } = useTranslations(); + const fmt = useFormatters(); + const animationsEnabled = !prefersReducedMotion(); + + // Scope to the current cycle (wheel window); fall back to a trailing window + // when no cycle is active so a user still sees their recent temperatures. + const fromDate = useMemo(() => { + const start = currentCycleStartDate(days, today); + if (start) return start; + const fallback = new Date(ymdToMs(today)); + fallback.setUTCDate(fallback.getUTCDate() - FALLBACK_WINDOW_DAYS); + return fallback.toISOString().slice(0, 10); + }, [days, today]); + + const points = useMemo(() => { + const fromMs = ymdToMs(fromDate); + const todayMs = ymdToMs(today); + return days + .filter((d) => { + if (d.basalBodyTempC == null || !Number.isFinite(d.basalBodyTempC)) { + return false; + } + const ms = ymdToMs(d.date); + return ms >= fromMs && ms <= todayMs; + }) + .map((d) => ({ + t: ymdToMs(d.date), + temp: d.basalBodyTempC as number, + phaseHue: + rawChartMode || d.phase == null ? LINE_COLOR : PHASE_HUE[d.phase], + mucus: d.cervicalMucus, + ovulationTest: d.ovulationTest, + })) + .sort((a, b) => a.t - b.t); + }, [days, fromDate, today, rawChartMode]); + + const ovulationMs = useMemo(() => { + if (rawChartMode || !predictedOvulation) return null; + const ms = ymdToMs(predictedOvulation); + if (points.length < 2) return null; + const first = points[0].t; + const last = points[points.length - 1].t; + return ms >= first && ms <= last ? ms : null; + }, [predictedOvulation, points, rawChartMode]); + + const yDomain = useMemo<[number, number]>(() => { + if (points.length === 0) return [36, 37.5]; + const temps = points.map((p) => p.temp); + const min = Math.min(...temps); + const max = Math.max(...temps); + // Pad ±0.15 °C so the shift is legible and the line never hugs an edge. + return [ + Math.floor((min - 0.15) * 10) / 10, + Math.ceil((max + 0.15) * 10) / 10, + ]; + }, [points]); + + const ticks = useMemo(() => { + if (points.length < 2) return []; + const first = points[0].t; + const last = points[points.length - 1].t; + const span = last - first; + if (span <= 0) return [first]; + return [0, 0.33, 0.66, 1].map((f) => Math.round(first + span * f)); + }, [points]); + + const hasCurve = points.length >= 2; + + return ( + + + + + {t("cycle.bbt.title")} + + + + {!hasCurve ? ( +
+

{t("cycle.bbt.empty")}

+

{t("cycle.bbt.emptyHint")}

+
+ ) : ( + <> +

+ {t("cycle.bbt.caption")} +

+
+ + + + fmt.dateShort(new Date(v as number))} + tick={{ fontSize: 11, fill: "var(--muted-foreground)" }} + tickLine={false} + axisLine={false} + /> + `${fmt.number(v as number)}°`} + /> + } + /> + {ovulationMs != null ? ( + + ) : null} + } + activeDot={{ r: 5 }} + isAnimationActive={animationsEnabled} + animationDuration={animationsEnabled ? 600 : 0} + animationEasing="ease-out" + /> + + +
+ + )} + + + ); +} + +/** A line dot tinted to its day's cycle phase (neutral in raw-chart mode). */ +function PhaseDot(props: { cx?: number; cy?: number; payload?: BbtPoint }) { + const { cx, cy, payload } = props; + if (cx == null || cy == null || !payload) return null; + return ( + + ); +} + +/** Tooltip restating the temperature + any fertility signs logged that day. */ +function BbtTooltip(props: { + active?: boolean; + payload?: { payload: BbtPoint }[]; +}) { + const { t } = useTranslations(); + const fmt = useFormatters(); + if (!props.active || !props.payload || props.payload.length === 0) + return null; + const p = props.payload[0].payload; + return ( +
+

{fmt.dateShort(new Date(p.t))}

+

+ {t("cycle.bbt.tooltipTemp", { temp: fmt.number(p.temp) })} +

+ {p.mucus ? ( +

+ {t("cycle.mucus.label")}: {t(`cycle.mucus.${p.mucus}`)} +

+ ) : null} + {p.ovulationTest && p.ovulationTest !== "NEGATIVE" ? ( +

+ {t("cycle.ovulationTest.label")}:{" "} + {t(`cycle.ovulationTest.${p.ovulationTest}`)} +

+ ) : null} +
+ ); +} diff --git a/src/components/cycle/cycle-ring.tsx b/src/components/cycle/cycle-ring.tsx index 2f8d24824..21e94632d 100644 --- a/src/components/cycle/cycle-ring.tsx +++ b/src/components/cycle/cycle-ring.tsx @@ -4,21 +4,34 @@ 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), matching the premium wellness-ring aesthetic (calm distinct - * hues, thin round-cap arcs, a faint segmented track). Unlike the 0–100 - * score gauge this is a FULL-circle dial: the four cycle phases render as - * proportional arc segments around the ring, and a marker dot sits at the - * current day-of-cycle. The day number is real centred DOM text. + * 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 marker + segments are JSX SVG (`` is NOT - * `dangerouslySetInnerHTML`). + * colour-only. The arcs/marker are real SVG JSX (`` would not + * be `dangerouslySetInnerHTML`); the centred number is real DOM text. */ const VIEW = 100; @@ -50,6 +63,8 @@ export interface CycleRingProps { 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; } @@ -63,11 +78,13 @@ export function CycleRing({ 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(); @@ -80,17 +97,15 @@ export function CycleRing({ // 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; - }[]; - } - >( + 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({ @@ -118,19 +133,27 @@ export function CycleRing({ dayOfCycle != null && cycleLength != null && cycleLength > 0 ? Math.min((dayOfCycle - 1) / cycleLength, 1) : null; - const markerAngle = markerProgress != null ? markerProgress * 2 * Math.PI : 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; - const phaseLabel = phase - ? t(`cycle.phase.${phase}`) - : t("cycle.phase.none"); + // 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. */} +