feat(cycle): premium cycle ring, BBT chart, and full read-state coverage

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) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.8 committed 2026-06-06 13:09:00 +02:00
1 parent 4b10bdc5da
commit 31a14ce7bf
18 files changed
+906 -117

No files matched your search

+26
View File
@@ -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
+9
View File
@@ -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": {
+9
View File
@@ -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": {
+9
View File
@@ -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": {
+9
View File
@@ -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": {
+9
View File
@@ -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": {
+9
View File
@@ -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": {
+101 -6
View File
@@ -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 <svg> 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: "<angle>";
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;
}
}
@@ -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(
<I18nProvider initialLocale="en">{node}</I18nProvider>,
);
}
function day(
date: string,
overrides: Partial<CalendarDay> = {},
): 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("<BbtChart>", () => {
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(
<BbtChart
days={days}
today={today}
predictedOvulation={null}
rawChartMode={false}
/>,
);
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(
<BbtChart
days={days}
today={today}
predictedOvulation="2026-06-08"
rawChartMode={false}
/>,
);
expect(html).toContain('data-slot="cycle-bbt-chart"');
expect(html).toContain('data-slot="cycle-bbt-caption"');
});
});
@@ -22,6 +22,9 @@ function dayBase(date: string): CalendarDay {
flow: null,
hasSymptoms: false,
confidence: 1,
basalBodyTempC: null,
ovulationTest: null,
cervicalMucus: null,
};
}
@@ -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();
});
});
+298
View File
@@ -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<BbtPoint[]>(() => {
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 (
<Card data-slot="cycle-bbt-chart">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Thermometer className="text-primary h-4 w-4" aria-hidden="true" />
{t("cycle.bbt.title")}
</CardTitle>
</CardHeader>
<CardContent>
{!hasCurve ? (
<div
className="text-muted-foreground bg-muted/40 flex flex-col items-start gap-2 rounded-md p-4 text-sm"
data-slot="cycle-bbt-empty"
>
<p className="font-medium">{t("cycle.bbt.empty")}</p>
<p className="text-xs">{t("cycle.bbt.emptyHint")}</p>
</div>
) : (
<>
<p
className="text-muted-foreground mb-2 text-xs"
data-slot="cycle-bbt-caption"
>
{t("cycle.bbt.caption")}
</p>
<div
className="touch-pan-y"
style={{ height: "210px" }}
data-slot="cycle-bbt-area"
>
<ResponsiveContainer width="100%" height="100%">
<LineChart
data={points}
margin={{ top: 10, right: 14, bottom: 16, left: 4 }}
>
<CartesianGrid
strokeDasharray="3 3"
stroke="var(--border)"
opacity={0.5}
/>
<XAxis
dataKey="t"
type="number"
scale="time"
domain={["dataMin", "dataMax"]}
ticks={ticks}
tickFormatter={(v) => fmt.dateShort(new Date(v as number))}
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
tickLine={false}
axisLine={false}
/>
<YAxis
domain={yDomain}
width={40}
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
tickLine={false}
axisLine={false}
tickFormatter={(v) => `${fmt.number(v as number)}°`}
/>
<Tooltip
cursor={{
stroke: "var(--muted-foreground)",
strokeOpacity: 0.3,
strokeDasharray: "3 3",
}}
content={<BbtTooltip />}
/>
{ovulationMs != null ? (
<ReferenceLine
x={ovulationMs}
stroke={OVULATION_HUE}
strokeDasharray="4 3"
strokeWidth={1.5}
label={{
value: t("cycle.bbt.ovulationEstimate"),
position: "insideTopRight",
fontSize: 10,
fill: OVULATION_HUE,
}}
/>
) : null}
<Line
type="monotone"
dataKey="temp"
stroke={LINE_COLOR}
strokeWidth={2}
dot={<PhaseDot />}
activeDot={{ r: 5 }}
isAnimationActive={animationsEnabled}
animationDuration={animationsEnabled ? 600 : 0}
animationEasing="ease-out"
/>
</LineChart>
</ResponsiveContainer>
</div>
</>
)}
</CardContent>
</Card>
);
}
/** 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 (
<circle
cx={cx}
cy={cy}
r={3.5}
fill={payload.phaseHue}
stroke="var(--background)"
strokeWidth={1}
/>
);
}
/** 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 (
<div
className={cn(
"bg-popover border-border rounded-md border px-2.5 py-2 text-xs shadow-md",
)}
>
<p className="text-muted-foreground">{fmt.dateShort(new Date(p.t))}</p>
<p className="text-foreground font-medium tabular-nums">
{t("cycle.bbt.tooltipTemp", { temp: fmt.number(p.temp) })}
</p>
{p.mucus ? (
<p className="text-muted-foreground mt-0.5">
{t("cycle.mucus.label")}: {t(`cycle.mucus.${p.mucus}`)}
</p>
) : null}
{p.ovulationTest && p.ovulationTest !== "NEGATIVE" ? (
<p className="text-muted-foreground mt-0.5">
{t("cycle.ovulationTest.label")}:{" "}
{t(`cycle.ovulationTest.${p.ovulationTest}`)}
</p>
) : null}
</div>
);
}
+83 -35
View File
@@ -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 <svg> 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 (`<linearGradient>` is NOT
* `dangerouslySetInnerHTML`).
* colour-only. The arcs/marker are real SVG JSX (`<linearGradient>` 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 (
<div
data-slot="cycle-ring"
@@ -138,11 +161,18 @@ export function CycleRing({
role="img"
aria-label={ariaLabel}
className={cn("relative shrink-0", className)}
style={{ width: size, height: size }}
style={
{
width: size,
height: size,
// The bloom on the active arc is tinted to the live phase hue.
"--ring-glow": markerHue,
} as React.CSSProperties
}
>
<svg
viewBox={`0 0 ${VIEW} ${VIEW}`}
className="h-full w-full -rotate-90"
className="cycle-ring-reveal h-full w-full -rotate-90"
aria-hidden="true"
>
{/* Faint segmented tick track — reads as an instrument. */}
@@ -156,10 +186,12 @@ export function CycleRing({
strokeWidth={STROKE}
strokeDasharray="0.5 4.2"
/>
{/* Phase arcs — one calm hue per phase, the active phase fully opaque. */}
{/* Phase arcs — one calm hue per phase, the active phase fully opaque
and carrying the shared wellness-ring bloom. */}
{segments.map((seg) => (
<circle
key={seg.phase}
className={cn(seg.active && "wellness-ring-arc")}
cx={CX}
cy={CX}
r={R}
@@ -175,22 +207,38 @@ export function CycleRing({
}}
/>
))}
{/* Current-day marker dot. */}
{/* Current-day marker: a soft halo pulse behind a solid dot. */}
{markerAngle != null ? (
<circle
cx={markerX}
cy={markerY}
r={STROKE / 1.7}
fill="var(--background)"
stroke={phase ? PHASE_HUE[phase] : "var(--foreground)"}
strokeWidth={2}
/>
<>
<circle
className="cycle-ring-marker-halo"
cx={markerX}
cy={markerY}
r={STROKE / 1.4}
fill="none"
stroke={markerHue}
strokeWidth={1.5}
strokeOpacity={0.6}
/>
<circle
cx={markerX}
cy={markerY}
r={STROKE / 1.7}
fill="var(--background)"
stroke={markerHue}
strokeWidth={2}
/>
</>
) : null}
</svg>
{/* Conic specular sheen — sweeps the dial once on the reveal. */}
<div className="wellness-ring-sheen" aria-hidden="true" />
{/* Centred day number + phase caption as real DOM text. */}
<div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center text-center">
<span className="text-foreground text-4xl font-semibold tracking-tight tabular-nums">
{dayOfCycle != null ? dayOfCycle : t("cycle.ring.dayUnknown")}
{dayOfCycle != null
? Math.round(displayedDay)
: t("cycle.ring.dayUnknown")}
</span>
<span className="text-muted-foreground mt-0.5 text-xs">
{phaseLabel}
+188 -74
View File
@@ -1,25 +1,23 @@
"use client";
import { useMemo, useState } from "react";
import { Loader2, Plus } from "lucide-react";
import { Loader2, Plus, RefreshCw } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
} from "@/components/ui/card";
import { Card, CardContent } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import { CycleRing } from "./cycle-ring";
import { BbtChart } from "./bbt-chart";
import { CycleCalendar } from "./cycle-calendar";
import { CycleDisclaimer } from "./cycle-disclaimer";
import { LogDaySheet } from "./log-day-sheet";
import { PredictionsPanel } from "./predictions-panel";
import {
CyclePhaseHeadline,
CyclePhaseCrosstab,
} from "./cycle-phase-crosstab";
import { CyclePhaseHeadline, CyclePhaseCrosstab } from "./cycle-phase-crosstab";
import { CycleSettings } from "./cycle-settings";
import { deriveWheelState } from "./wheel-state";
import { PHASE_HUE } from "./phase-tokens";
import {
localYmd,
useCycleCalendar,
@@ -32,10 +30,15 @@ import {
* v1.15.0 — the cycle vertical's client orchestrator.
*
* Holds the four tabs (Calendar · Predictions · Insights · Settings), the
* wheel + month calendar above the tab strip, and the log-day sheet. The
* calendar read drives the ring (day-of-cycle + phase) and the grid; the
* wheel above the tab strip, and the log-day sheet. The calendar read drives
* the ring (day-of-cycle + phase), the grid, and the BBT chart; the
* predictions read powers the panel; history powers the stats. The page-level
* RSC has already gated on `cycleTrackingEnabled`.
*
* The wheel earns the once-per-session "signature reveal" (sweep + glow +
* sheen + count-up): a `data-revealed` flag set on first visit drives the CSS
* keyframes, gated by sessionStorage so a background calendar refetch never
* re-triggers the moment.
*/
/** YYYY-MM-DD for `n` days from now in the local tz (shares `localYmd`). */
@@ -60,6 +63,20 @@ export function CycleView() {
const [sheetOpen, setSheetOpen] = useState(false);
const [selectedDate, setSelectedDate] = useState(today);
// The signature reveal plays ONCE per browser session (mirrors the wellness
// strip): a background calendar refetch after a quick-log would otherwise
// replay the sweep/sheen and read as janky.
const [play] = useState(() => {
if (typeof window === "undefined") return false;
try {
if (sessionStorage.getItem("cycle-wheel-revealed")) return false;
sessionStorage.setItem("cycle-wheel-revealed", "1");
return true;
} catch {
return true;
}
});
const wheel = useMemo(
() => deriveWheelState(calendar.data?.days ?? [], today),
[calendar.data, today],
@@ -71,6 +88,9 @@ export function CycleView() {
}
const loading = calendar.isLoading && !calendar.data;
const calendarError = calendar.isError && !calendar.data;
const tileHue = wheel.phase ? PHASE_HUE[wheel.phase] : undefined;
const disclaimerText = t("cycle.prediction.disclaimer");
return (
<div className="space-y-6">
@@ -91,26 +111,55 @@ export function CycleView() {
{/* Desktop: ring (left) + tabs (right). Single column below lg. */}
<div className="grid gap-6 lg:grid-cols-[minmax(0,22rem)_minmax(0,1fr)] lg:items-start">
{/* Wheel — the signature ring. */}
<Card className="lg:sticky lg:top-6">
<CardContent className="flex flex-col items-center gap-3 py-6">
{/* Wheel — the signature ring on the premium wellness-tile surface. */}
<div
data-slot="cycle-wheel-tile"
data-revealed={play ? "true" : undefined}
style={
tileHue
? ({ "--tile-hue": tileHue } as React.CSSProperties)
: undefined
}
className={cn(
"wellness-tile flex flex-col items-center gap-3 rounded-xl px-6 py-6 lg:sticky lg:top-6",
play && "wellness-tile-rise",
)}
>
{loading ? (
<div className="flex h-[220px] items-center justify-center">
<Loader2 className="text-primary h-8 w-8 animate-spin motion-reduce:animate-none" />
</div>
) : calendarError ? (
<div className="flex h-[220px] flex-col items-center justify-center gap-3 text-center">
<p className="text-muted-foreground text-sm">
{t("cycle.loadError")}
</p>
<Button
variant="outline"
size="sm"
className="gap-1.5"
onClick={() => void calendar.refetch()}
>
<RefreshCw className="h-3.5 w-3.5" aria-hidden="true" />
{t("common.retry")}
</Button>
</div>
) : (
<CycleRing
dayOfCycle={wheel.dayOfCycle}
cycleLength={wheel.cycleLength}
phase={wheel.phase}
spans={wheel.spans}
animate={play}
/>
)}
<p className="text-muted-foreground text-xs">
{t("cycle.ring.caption")}
</p>
{!calendarError ? (
<p className="text-muted-foreground text-xs">
{t("cycle.ring.caption")}
</p>
) : null}
{/* First-period CTA — only when no cycle is active yet. */}
{!loading && wheel.dayOfCycle == null ? (
{!loading && !calendarError && wheel.dayOfCycle == null ? (
<Button
variant="outline"
className="mt-1 w-full"
@@ -119,69 +168,110 @@ export function CycleView() {
{t("cycle.ring.firstPeriodCta")}
</Button>
) : null}
</CardContent>
</Card>
</div>
<Tabs defaultValue="calendar">
<TabsList className="w-full">
<TabsTrigger value="calendar" className="flex-1">
{t("cycle.tabs.calendar")}
</TabsTrigger>
<TabsTrigger value="predictions" className="flex-1">
{t("cycle.tabs.predictions")}
</TabsTrigger>
<TabsTrigger value="insights" className="flex-1">
{t("cycle.tabs.insights")}
</TabsTrigger>
<TabsTrigger value="settings" className="flex-1">
{t("cycle.tabs.settings")}
</TabsTrigger>
</TabsList>
<Tabs defaultValue="calendar">
<TabsList className="w-full">
<TabsTrigger value="calendar" className="flex-1">
{t("cycle.tabs.calendar")}
</TabsTrigger>
<TabsTrigger value="predictions" className="flex-1">
{t("cycle.tabs.predictions")}
</TabsTrigger>
<TabsTrigger value="insights" className="flex-1">
{t("cycle.tabs.insights")}
</TabsTrigger>
<TabsTrigger value="settings" className="flex-1">
{t("cycle.tabs.settings")}
</TabsTrigger>
</TabsList>
<TabsContent value="calendar" className="mt-4">
<Card>
<CardContent className="py-4">
{loading ? (
<p className="text-muted-foreground text-sm">
{t("cycle.calendar.loading")}
</p>
) : (
<CycleCalendar
<TabsContent value="calendar" className="mt-4 space-y-4">
<Card>
<CardContent className="py-4">
{loading ? (
<p className="text-muted-foreground text-sm">
{t("cycle.calendar.loading")}
</p>
) : calendarError ? (
<TabError onRetry={() => void calendar.refetch()} />
) : (
<CycleCalendar
days={calendar.data?.days ?? []}
today={today}
onSelectDay={openSheet}
/>
)}
</CardContent>
</Card>
{!loading && !calendarError ? (
<CycleDisclaimer text={disclaimerText} />
) : null}
</TabsContent>
<TabsContent value="predictions" className="mt-4 space-y-4">
{calendarError ? (
<Card>
<CardContent className="py-4">
<TabError onRetry={() => void calendar.refetch()} />
</CardContent>
</Card>
) : (
<>
<PredictionsPanel
prediction={calendar.data?.prediction ?? null}
rawChartMode={calendar.data?.profile.rawChartMode ?? false}
history={history.data}
fallbackDisclaimer={disclaimerText}
/>
<BbtChart
days={calendar.data?.days ?? []}
today={today}
onSelectDay={openSheet}
predictedOvulation={
calendar.data?.prediction?.predictedOvulation ?? null
}
rawChartMode={calendar.data?.profile.rawChartMode ?? false}
/>
)}
</CardContent>
</Card>
</TabsContent>
</>
)}
</TabsContent>
<TabsContent value="predictions" className="mt-4">
<PredictionsPanel
prediction={calendar.data?.prediction ?? null}
rawChartMode={calendar.data?.profile.rawChartMode ?? false}
history={history.data}
/>
</TabsContent>
<TabsContent value="insights" className="mt-4 space-y-4">
{insights.isError ? (
<Card>
<CardContent className="py-4">
<TabError onRetry={() => void insights.refetch()} />
</CardContent>
</Card>
) : (
<>
<CyclePhaseHeadline
headline={insights.data?.headline ?? null}
/>
<CyclePhaseCrosstab rows={insights.data?.rows ?? []} />
</>
)}
</TabsContent>
<TabsContent value="insights" className="mt-4 space-y-4">
<CyclePhaseHeadline headline={insights.data?.headline ?? null} />
<CyclePhaseCrosstab rows={insights.data?.rows ?? []} />
</TabsContent>
<TabsContent value="settings" className="mt-4">
{profileQuery.data ? (
<CycleSettings
key={profileQuery.data.updatedAt}
profile={profileQuery.data}
/>
) : (
<div className="flex h-32 items-center justify-center">
<Loader2 className="text-primary h-6 w-6 animate-spin motion-reduce:animate-none" />
</div>
)}
</TabsContent>
</Tabs>
<TabsContent value="settings" className="mt-4">
{profileQuery.isError ? (
<Card>
<CardContent className="py-4">
<TabError onRetry={() => void profileQuery.refetch()} />
</CardContent>
</Card>
) : profileQuery.isLoading ? (
<div className="flex h-32 items-center justify-center">
<Loader2 className="text-primary h-6 w-6 animate-spin motion-reduce:animate-none" />
</div>
) : profileQuery.data ? (
<CycleSettings
key={profileQuery.data.updatedAt}
profile={profileQuery.data}
/>
) : null}
</TabsContent>
</Tabs>
</div>
<LogDaySheet
@@ -193,3 +283,27 @@ export function CycleView() {
</div>
);
}
/** A compact in-place error + Retry for a failed tab read. */
function TabError({ onRetry }: { onRetry: () => void }) {
const { t } = useTranslations();
return (
<div
role="alert"
data-slot="cycle-tab-error"
className="text-muted-foreground flex flex-col items-start gap-3 text-sm sm:flex-row sm:items-center sm:justify-between"
>
<span>{t("cycle.loadError")}</span>
<Button
type="button"
size="sm"
variant="outline"
onClick={onRetry}
className="gap-1.5"
>
<RefreshCw className="h-3.5 w-3.5" aria-hidden="true" />
<span>{t("common.retry")}</span>
</Button>
</div>
);
}
+4
View File
@@ -43,6 +43,10 @@ export interface CalendarDay {
flow: string | null;
hasSymptoms: boolean;
confidence: number;
/** Logged basal body temperature (°C), or null. Feeds the BBT chart. */
basalBodyTempC: number | null;
ovulationTest: OvulationTest | null;
cervicalMucus: CervicalMucus | null;
}
export interface CyclePrediction {
+34 -1
View File
@@ -24,6 +24,36 @@ export interface WheelState {
spans: { phase: CyclePhase; fraction: number }[];
}
/**
* The first day (`YYYY-MM-DD`) of the current cycle: the start of the most
* recent MENSTRUAL-anchored run at/before `today` with no phase gap. Shares
* the exact backward-walk `deriveWheelState` uses so the BBT chart scopes to
* the same cycle the wheel shows. Returns null when today has no phase (no
* active cycle).
*/
export function currentCycleStartDate(
days: CalendarDay[],
today: string,
): string | null {
const sorted = [...days].sort((a, b) => a.date.localeCompare(b.date));
const todayIdx = sorted.findIndex((d) => d.date === today);
if (todayIdx < 0 || sorted[todayIdx].phase == null) return null;
let startIdx = todayIdx;
for (let i = todayIdx; i >= 0; i--) {
const d = sorted[i];
if (d.phase == null) break;
startIdx = i;
if (
d.phase === "MENSTRUAL" &&
(i === 0 || sorted[i - 1].phase !== "MENSTRUAL")
) {
break;
}
}
return sorted[startIdx].date;
}
export function deriveWheelState(
days: CalendarDay[],
today: string,
@@ -48,7 +78,10 @@ export function deriveWheelState(
const d = sorted[i];
if (d.phase == null) break;
startIdx = i;
if (d.phase === "MENSTRUAL" && (i === 0 || sorted[i - 1].phase !== "MENSTRUAL")) {
if (
d.phase === "MENSTRUAL" &&
(i === 0 || sorted[i - 1].phase !== "MENSTRUAL")
) {
break;
}
}
+9
View File
@@ -35,6 +35,12 @@ export interface CalendarDayDTO {
flow: string | null;
hasSymptoms: boolean;
confidence: number;
/** Logged basal body temperature (°C), or null. Feeds the web BBT chart. */
basalBodyTempC: number | null;
/** Logged ovulation-test result, or null. */
ovulationTest: string | null;
/** Logged cervical-mucus quality, or null. */
cervicalMucus: string | null;
}
/** Rows the calendar needs from each day-log. */
@@ -288,6 +294,9 @@ export function buildCalendar(
flow: log?.flow ?? null,
hasSymptoms: log?.hasSymptoms ?? false,
confidence: prediction?.confidence ?? 0,
basalBodyTempC: log?.basalBodyTempC ?? null,
ovulationTest: log?.ovulationTest ?? null,
cervicalMucus: log?.cervicalMucus ?? null,
});
}
+6
View File
@@ -2303,6 +2303,12 @@ const cycleCalendarDayDto = z.object({
flow: flowLevelEnum.nullable(),
hasSymptoms: z.boolean(),
confidence: z.number(),
// v1.15.0 — logged basal-body-temperature + fertility-sign markers, surfaced
// so the web BBT chart renders from the calendar read (the values are already
// loaded server-side for the symptothermal layer; no extra query).
basalBodyTempC: z.number().nullable(),
ovulationTest: ovulationTestEnum.nullable(),
cervicalMucus: cervicalMucusEnum.nullable(),
});
const cycleProfileDto = z