mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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:
1 parent
4b10bdc5da
commit
31a14ce7bf
18 files changed
+906
-117
No files matched your search
@@ -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
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user