feat(cycle): flow-shaded calendar, confirmed-ovulation marker, and cycle-length history chart

This commit is contained in:
Marc-André Bombeck committed 2026-06-06 17:01:33 +02:00
1 parent 985e197db4
commit 32552f02ec
13 files changed
+782 -39

No files matched your search

+12 -2
View File
@@ -5061,7 +5061,12 @@
"empty": "Erfasse Daten, damit sich dein Kalender füllt.",
"today": "Heute",
"dayAria": "{date}",
"legendSymptom": "Symptome"
"legendSymptom": "Symptome",
"legendOvulationConfirmed": "Bestätigter Eisprung",
"flowSpotting": "Schmierblutung",
"flowLight": "leicht",
"flowMedium": "mittel",
"flowHeavy": "stark"
},
"predictions": {
"title": "Was als Nächstes kommt",
@@ -5096,7 +5101,12 @@
"recentCycles": "Letzte Zyklen",
"ongoing": "laufend",
"periodDays": "Periode {count} T",
"ovulationChip": "Eisprung"
"ovulationChip": "Eisprung",
"chartTitle": "Zykluslänge im Verlauf",
"chartCaption": "Jeder Balken ist ein Zyklus: die rosa Basis ist deine Periode, der Rest die Zykluslänge.",
"chartAria": "Zykluslängen-Diagramm deiner letzten {count} Zyklen, Durchschnitt {avg} Tage, eingestuft als {regularity}.",
"legendPeriodSegment": "Periodentage",
"legendRestSegment": "Rest des Zyklus"
},
"sheet": {
"title": "Tag erfassen",
+12 -2
View File
@@ -5061,7 +5061,12 @@
"empty": "Start logging to see your calendar fill in.",
"today": "Today",
"dayAria": "{date}",
"legendSymptom": "Symptoms"
"legendSymptom": "Symptoms",
"legendOvulationConfirmed": "Confirmed ovulation",
"flowSpotting": "spotting",
"flowLight": "light",
"flowMedium": "medium",
"flowHeavy": "heavy"
},
"predictions": {
"title": "What comes next",
@@ -5096,7 +5101,12 @@
"recentCycles": "Recent cycles",
"ongoing": "ongoing",
"periodDays": "period {count} d",
"ovulationChip": "Ovulation"
"ovulationChip": "Ovulation",
"chartTitle": "Cycle length over time",
"chartCaption": "Each bar is one cycle: the rose base is your period, the rest is the cycle length.",
"chartAria": "Cycle-length chart of your last {count} cycles, averaging {avg} days, classified as {regularity}.",
"legendPeriodSegment": "Period days",
"legendRestSegment": "Rest of cycle"
},
"sheet": {
"title": "Log your day",
+12 -2
View File
@@ -5061,7 +5061,12 @@
"empty": "Empieza a registrar para ver tu calendario.",
"today": "Hoy",
"dayAria": "{date}",
"legendSymptom": "Síntomas"
"legendSymptom": "Síntomas",
"legendOvulationConfirmed": "Ovulación confirmada",
"flowSpotting": "manchado",
"flowLight": "ligero",
"flowMedium": "medio",
"flowHeavy": "abundante"
},
"predictions": {
"title": "Qué viene después",
@@ -5096,7 +5101,12 @@
"recentCycles": "Ciclos recientes",
"ongoing": "en curso",
"periodDays": "período {count} d",
"ovulationChip": "Ovulación"
"ovulationChip": "Ovulación",
"chartTitle": "Duración del ciclo en el tiempo",
"chartCaption": "Cada barra es un ciclo: la base rosa es tu periodo, el resto es la duración del ciclo.",
"chartAria": "Gráfico de duración de tus últimos {count} ciclos, con una media de {avg} días, clasificado como {regularity}.",
"legendPeriodSegment": "Días de periodo",
"legendRestSegment": "Resto del ciclo"
},
"sheet": {
"title": "Registrar tu día",
+12 -2
View File
@@ -5061,7 +5061,12 @@
"empty": "Commence à enregistrer pour remplir ton calendrier.",
"today": "Aujourd'hui",
"dayAria": "{date}",
"legendSymptom": "Symptômes"
"legendSymptom": "Symptômes",
"legendOvulationConfirmed": "Ovulation confirmée",
"flowSpotting": "spotting",
"flowLight": "léger",
"flowMedium": "moyen",
"flowHeavy": "abondant"
},
"predictions": {
"title": "Ce qui arrive ensuite",
@@ -5096,7 +5101,12 @@
"recentCycles": "Cycles récents",
"ongoing": "en cours",
"periodDays": "règles {count} j",
"ovulationChip": "Ovulation"
"ovulationChip": "Ovulation",
"chartTitle": "Durée du cycle au fil du temps",
"chartCaption": "Chaque barre est un cycle : la base rose est vos règles, le reste est la durée du cycle.",
"chartAria": "Graphique de durée de vos {count} derniers cycles, d'une moyenne de {avg} jours, classé comme {regularity}.",
"legendPeriodSegment": "Jours de règles",
"legendRestSegment": "Reste du cycle"
},
"sheet": {
"title": "Enregistrer ta journée",
+12 -2
View File
@@ -5061,7 +5061,12 @@
"empty": "Inizia a registrare per riempire il calendario.",
"today": "Oggi",
"dayAria": "{date}",
"legendSymptom": "Sintomi"
"legendSymptom": "Sintomi",
"legendOvulationConfirmed": "Ovulazione confermata",
"flowSpotting": "spotting",
"flowLight": "leggero",
"flowMedium": "medio",
"flowHeavy": "abbondante"
},
"predictions": {
"title": "Cosa arriva dopo",
@@ -5096,7 +5101,12 @@
"recentCycles": "Cicli recenti",
"ongoing": "in corso",
"periodDays": "ciclo {count} g",
"ovulationChip": "Ovulazione"
"ovulationChip": "Ovulazione",
"chartTitle": "Durata del ciclo nel tempo",
"chartCaption": "Ogni barra è un ciclo: la base rosa è il ciclo mestruale, il resto è la durata del ciclo.",
"chartAria": "Grafico della durata dei tuoi ultimi {count} cicli, con una media di {avg} giorni, classificato come {regularity}.",
"legendPeriodSegment": "Giorni di mestruazioni",
"legendRestSegment": "Resto del ciclo"
},
"sheet": {
"title": "Registra la tua giornata",
+12 -2
View File
@@ -5061,7 +5061,12 @@
"empty": "Zacznij zapisywać, aby wypełnić kalendarz.",
"today": "Dziś",
"dayAria": "{date}",
"legendSymptom": "Objawy"
"legendSymptom": "Objawy",
"legendOvulationConfirmed": "Potwierdzona owulacja",
"flowSpotting": "plamienie",
"flowLight": "lekkie",
"flowMedium": "średnie",
"flowHeavy": "obfite"
},
"predictions": {
"title": "Co dalej",
@@ -5096,7 +5101,12 @@
"recentCycles": "Ostatnie cykle",
"ongoing": "w toku",
"periodDays": "okres {count} d",
"ovulationChip": "Owulacja"
"ovulationChip": "Owulacja",
"chartTitle": "Długość cyklu w czasie",
"chartCaption": "Każdy słupek to jeden cykl: różowa podstawa to miesiączka, reszta to długość cyklu.",
"chartAria": "Wykres długości {count} ostatnich cykli, średnio {avg} dni, sklasyfikowany jako {regularity}.",
"legendPeriodSegment": "Dni miesiączki",
"legendRestSegment": "Pozostała część cyklu"
},
"sheet": {
"title": "Zapisz swój dzień",
+30
View File
@@ -798,3 +798,33 @@
opacity: 0;
}
}
/*
* v1.15.1 — the cycle-length history chart reveal.
*
* The hand-rolled SVG bars grow up from the baseline on first paint, a calm
* staggered rise (per-bar `--bar-delay`) that matches the wheel's 0-KB SVG
* identity. Gated on the chart's own `data-animate="true"` (the view only sets
* it when motion is allowed) so a background history refetch never replays it,
* and zeroed entirely under prefers-reduced-motion (bars paint at full height).
*/
.cycle-history-bar {
transform-box: fill-box;
transform-origin: bottom;
}
[data-animate="true"] .cycle-history-bar {
transform: scaleY(0);
animation: cycleHistoryBarRise 520ms var(--ring-ease, ease-out) forwards;
animation-delay: var(--bar-delay, 0ms);
}
@keyframes cycleHistoryBarRise {
to {
transform: scaleY(1);
}
}
@media (prefers-reduced-motion: reduce) {
[data-animate="true"] .cycle-history-bar {
transform: none;
animation: none;
}
}
@@ -73,4 +73,56 @@ describe("<CycleCalendar>", () => {
);
expect(html).toContain('role="columnheader"');
});
it("exposes the flow level on a logged period day as a stable data-attr", () => {
const days = [
{ ...dayBase("2026-06-10"), isPeriodLogged: true, flow: "HEAVY" },
];
const html = render(
<CycleCalendar days={days} today={today} onSelectDay={() => {}} />,
);
expect(html).toContain('data-flow-level="HEAVY"');
// The flow grade is named in the aria text (never colour-only).
expect(html).toContain("heavy");
});
it("marks a logged period day with no flow grade as UNGRADED", () => {
const days = [{ ...dayBase("2026-06-10"), isPeriodLogged: true }];
const html = render(
<CycleCalendar days={days} today={today} onSelectDay={() => {}} />,
);
expect(html).toContain('data-flow-level="UNGRADED"');
});
it("renders a predicted-ovulation day as a predicted dot", () => {
const days = [{ ...dayBase("2026-06-14"), isPredictedOvulation: true }];
const html = render(
<CycleCalendar days={days} today={today} onSelectDay={() => {}} />,
);
expect(html).toContain('data-ovulation="predicted"');
expect(html).toContain("Ovulation");
});
it("renders a CONFIRMED-ovulation day as the distinct oval, not the dot", () => {
const days = [{ ...dayBase("2026-06-14"), isPredictedOvulation: true }];
const html = render(
<CycleCalendar
days={days}
today={today}
confirmedOvulation="2026-06-14"
onSelectDay={() => {}}
/>,
);
expect(html).toContain('data-ovulation="confirmed"');
expect(html).not.toContain('data-ovulation="predicted"');
expect(html).toContain("Confirmed ovulation");
});
it("renders the fertile window as a data-attr-tagged soft band", () => {
const days = [{ ...dayBase("2026-06-12"), isFertileWindow: true }];
const html = render(
<CycleCalendar days={days} today={today} onSelectDay={() => {}} />,
);
expect(html).toContain('data-fertile="true"');
});
});
@@ -0,0 +1,93 @@
import { describe, it, expect } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { CycleHistoryChart } from "../cycle-history-chart";
import { I18nProvider } from "@/lib/i18n/context";
import type { CycleHistoryResponse, MenstrualCycleDTO } from "../types";
function render(node: React.ReactNode) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">{node}</I18nProvider>,
);
}
function cycle(over: Partial<MenstrualCycleDTO>): MenstrualCycleDTO {
return {
id: over.id ?? "c1",
startDate: over.startDate ?? "2026-01-01",
endDate: over.endDate ?? "2026-01-28",
periodEndDate: over.periodEndDate ?? "2026-01-05",
lengthDays: over.lengthDays ?? 28,
ovulationDate: over.ovulationDate ?? null,
ovulationConfirmed: over.ovulationConfirmed ?? false,
isPredicted: over.isPredicted ?? false,
syncVersion: 1,
updatedAt: "2026-01-28T00:00:00Z",
};
}
const REGULAR_HISTORY: CycleHistoryResponse = {
cycles: [
cycle({ id: "a", startDate: "2026-01-01", lengthDays: 28 }),
cycle({ id: "b", startDate: "2026-01-29", lengthDays: 29 }),
cycle({
id: "c",
startDate: "2026-02-27",
lengthDays: 27,
ovulationConfirmed: true,
ovulationDate: "2026-03-13",
}),
],
stats: {
avgLengthDays: 28,
lengthVariabilityDays: 1,
avgPeriodLengthDays: 5,
regularity: "REGULAR",
},
};
describe("<CycleHistoryChart>", () => {
it("renders one bar per observed cycle with stable data-attrs", () => {
const html = render(<CycleHistoryChart history={REGULAR_HISTORY} />);
expect(html).toContain('data-slot="cycle-history-chart"');
// Three observed cycles → three bars.
const bars = html.match(/data-cycle-bar="true"/g) ?? [];
expect(bars).toHaveLength(3);
// The period segment is drawn for each bar with a logged period end.
expect(html).toContain('data-period-segment="true"');
// The mean baseline rule is present.
expect(html).toContain('data-avg-line="true"');
});
it("surfaces the regularity classification on a stat chip", () => {
const html = render(<CycleHistoryChart history={REGULAR_HISTORY} />);
expect(html).toContain('data-regularity="REGULAR"');
expect(html).toContain("Regular");
});
it("draws a confirmed-ovulation tick only for confirmed cycles", () => {
const html = render(<CycleHistoryChart history={REGULAR_HISTORY} />);
const ticks = html.match(/data-ovulation-tick="true"/g) ?? [];
// Only the third cycle carries a confirmed ovulation date.
expect(ticks).toHaveLength(1);
});
it("excludes predicted cycles from the chart", () => {
const withPredicted: CycleHistoryResponse = {
...REGULAR_HISTORY,
cycles: [
...REGULAR_HISTORY.cycles,
cycle({ id: "future", startDate: "2026-03-26", isPredicted: true }),
],
};
const html = render(<CycleHistoryChart history={withPredicted} />);
const bars = html.match(/data-cycle-bar="true"/g) ?? [];
expect(bars).toHaveLength(3);
});
it("shows the empty state when there is no data", () => {
const html = render(<CycleHistoryChart history={undefined} />);
expect(html).toContain("No cycles recorded yet.");
expect(html).not.toContain('data-cycle-bar="true"');
});
});
+128 -26
View File
@@ -7,7 +7,12 @@ import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { useTranslations } from "@/lib/i18n/context";
import type { CalendarDay } from "./types";
import { FERTILE_HUE, FLOW_HUE, OVULATION_HUE } from "./phase-tokens";
import {
FERTILE_HUE,
FLOW_HUE,
OVULATION_HUE,
flowOpacity,
} from "./phase-tokens";
/**
* v1.15.0 — the month calendar.
@@ -49,6 +54,12 @@ export interface CycleCalendarProps {
days: CalendarDay[];
/** YYYY-MM-DD of today (server tz-anchored) for the "today" ring. */
today: string;
/**
* YYYY-MM-DD of the CONFIRMED (retrospective) ovulation estimate, or null.
* Set only when the prediction carries `ovulationConfirmed` — that day then
* renders as the distinct light oval instead of the predicted dot.
*/
confirmedOvulation?: string | null;
onSelectDay: (date: string) => void;
className?: string;
}
@@ -56,6 +67,7 @@ export interface CycleCalendarProps {
export function CycleCalendar({
days,
today,
confirmedOvulation,
onSelectDay,
className,
}: CycleCalendarProps) {
@@ -143,19 +155,48 @@ export function CycleCalendar({
const date = ymd(cell);
const info = byDate.get(date);
const isToday = date === today;
// A CONFIRMED-ovulation day is the predicted-ovulation day that also
// matches the prediction's retrospective estimate. It supersedes the
// predicted dot with the distinct light oval (Apple's idiom).
const isConfirmedOvulation =
!!info?.isPredictedOvulation &&
confirmedOvulation != null &&
date === confirmedOvulation;
const isPredictedOvulationDot =
!!info?.isPredictedOvulation && !isConfirmedOvulation;
const markers: string[] = [];
if (info?.isPeriodLogged)
markers.push(t("cycle.calendar.legendPeriod"));
if (info?.isPeriodLogged) {
// Restate the flow grade in the aria text so the shading is never
// colour-only (the legend covers it; the cell names it too).
const flowKey =
info.flow && info.flow !== "NONE"
? `cycle.calendar.flow${info.flow.charAt(0)}${info.flow.slice(1).toLowerCase()}`
: null;
markers.push(
flowKey
? `${t("cycle.calendar.legendPeriod")} (${t(flowKey)})`
: t("cycle.calendar.legendPeriod"),
);
}
if (info?.isPredictedPeriod)
markers.push(t("cycle.calendar.legendPredicted"));
if (info?.isFertileWindow)
markers.push(t("cycle.calendar.legendFertile"));
if (info?.isPredictedOvulation)
if (isConfirmedOvulation)
markers.push(t("cycle.calendar.legendOvulationConfirmed"));
else if (isPredictedOvulationDot)
markers.push(t("cycle.calendar.legendOvulation"));
if (info?.hasSymptoms)
markers.push(t("cycle.calendar.legendSymptoms"));
const aria = `${date}${markers.length ? `, ${markers.join(", ")}` : ""}`;
const flowLevel =
info?.isPeriodLogged && info.flow && info.flow !== "NONE"
? info.flow
: info?.isPeriodLogged
? "UNGRADED"
: undefined;
return (
<button
@@ -164,32 +205,71 @@ export function CycleCalendar({
role="gridcell"
aria-label={aria}
aria-current={isToday ? "date" : undefined}
data-flow-level={flowLevel}
data-fertile={info?.isFertileWindow ? "true" : undefined}
data-predicted={
info?.isPredictedPeriod && !info?.isPeriodLogged
? "true"
: undefined
}
data-ovulation={
isConfirmedOvulation
? "confirmed"
: isPredictedOvulationDot
? "predicted"
: undefined
}
onClick={() => onSelectDay(date)}
className={cn(
"relative flex aspect-square min-h-10 flex-col items-center justify-center rounded-lg text-sm transition-colors",
"hover:bg-accent focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:outline-none",
info?.isFertileWindow && "ring-1 ring-inset",
isToday && "font-semibold",
)}
style={
info?.isFertileWindow
? ({ "--tw-ring-color": FERTILE_HUE } as React.CSSProperties)
: undefined
}
>
{/* Logged-period filled pip behind the number. */}
{/* Fertile window: a soft full-cell band (calm fill, not a hard
ring) so the window reads as a continuous range across days. */}
{info?.isFertileWindow ? (
<span
aria-hidden="true"
className="absolute inset-0.5 rounded-md opacity-15"
style={{ backgroundColor: FERTILE_HUE }}
/>
) : null}
{/* Logged-period filled pip behind the number — shaded by flow on
the single-hue opacity ladder (SPOTTING→HEAVY). */}
{info?.isPeriodLogged ? (
<span
aria-hidden="true"
className="absolute inset-1 rounded-md opacity-20"
style={{ backgroundColor: FLOW_HUE }}
className="absolute inset-1 rounded-md"
style={{
backgroundColor: FLOW_HUE,
opacity: flowOpacity(info.flow),
}}
/>
) : null}
{/* Confirmed-ovulation oval: a restrained light oval ringed in the
ovulatory hue, deliberately distinct from the predicted dot. */}
{isConfirmedOvulation ? (
<span
aria-hidden="true"
className="absolute inset-x-1 inset-y-2 rounded-full ring-[1.5px] ring-inset"
style={
{
backgroundColor: OVULATION_HUE,
opacity: 0.16,
"--tw-ring-color": OVULATION_HUE,
} as React.CSSProperties
}
/>
) : null}
<span className={cn("relative z-10", isToday && "text-primary")}>
{cell.getDate()}
</span>
{/* Predicted-period band: a soft underline, never a dot. */}
{/* Predicted-period band: a soft hatched underline, never a dot. */}
{info?.isPredictedPeriod && !info?.isPeriodLogged ? (
<span
aria-hidden="true"
@@ -201,12 +281,13 @@ export function CycleCalendar({
/>
) : null}
{/* Marker row: ovulation + symptom dots. */}
{/* Marker row: predicted-ovulation + symptom dots. The confirmed
oval lives behind the number, so it needs no row dot. */}
<span
aria-hidden="true"
className="relative z-10 mt-0.5 flex h-1.5 items-center gap-0.5"
>
{info?.isPredictedOvulation ? (
{isPredictedOvulationDot ? (
<span
className="h-1.5 w-1.5 rounded-full"
style={{ backgroundColor: OVULATION_HUE }}
@@ -229,12 +310,13 @@ export function CycleCalendar({
function CalendarLegend() {
const { t } = useTranslations();
// Each swatch mirrors its grid affordance: period = filled, predicted =
// dashed, fertile = a ring (not a filled dot), ovulation = filled, symptom =
// the small grey marker dot the grid draws (QA M4).
// dashed, fertile = a soft band fill, predicted-ovulation = a filled dot,
// confirmed-ovulation = a ringed light oval (distinct from the dot), symptom
// = the small grey marker dot the grid draws (QA M4).
const items: {
hue: string;
labelKey: string;
variant: "fill" | "dashed" | "ring" | "dot";
variant: "fill" | "dashed" | "band" | "oval" | "dot";
}[] = [
{ hue: FLOW_HUE, labelKey: "cycle.calendar.legendPeriod", variant: "fill" },
{
@@ -245,12 +327,17 @@ function CalendarLegend() {
{
hue: FERTILE_HUE,
labelKey: "cycle.calendar.legendFertile",
variant: "ring",
variant: "band",
},
{
hue: OVULATION_HUE,
labelKey: "cycle.calendar.legendOvulation",
variant: "fill",
variant: "dot",
},
{
hue: OVULATION_HUE,
labelKey: "cycle.calendar.legendOvulationConfirmed",
variant: "oval",
},
{
hue: "var(--muted-foreground)",
@@ -261,17 +348,32 @@ function CalendarLegend() {
return (
<ul className="text-muted-foreground flex flex-wrap gap-x-4 gap-y-1.5 text-xs">
{items.map((it) => (
<li key={it.labelKey} className="flex items-center gap-1.5">
<li
key={it.labelKey}
data-legend={it.labelKey}
className="flex items-center gap-1.5"
>
<span
aria-hidden="true"
className={cn(
"rounded-full",
it.variant === "dot" ? "h-1 w-1" : "h-2.5 w-2.5",
it.variant === "oval"
? "h-2.5 w-3.5 rounded-full ring-[1.5px] ring-inset"
: "rounded-full",
it.variant === "dot" && it.hue === "var(--muted-foreground)"
? "h-1 w-1"
: it.variant === "oval"
? ""
: "h-2.5 w-2.5",
it.variant === "dashed" && "opacity-60",
it.variant === "band" && "opacity-30",
)}
style={
it.variant === "ring"
? { border: `1.5px solid ${it.hue}` }
it.variant === "oval"
? ({
backgroundColor: it.hue,
opacity: 0.3,
"--tw-ring-color": it.hue,
} as React.CSSProperties)
: { backgroundColor: it.hue }
}
/>
@@ -0,0 +1,370 @@
"use client";
import { useMemo } from "react";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { useTranslations } from "@/lib/i18n/context";
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
import { cn } from "@/lib/utils";
import type { CycleHistoryResponse, MenstrualCycleDTO } from "./types";
import { FLOW_HUE, OVULATION_HUE, PHASE_HUE } from "./phase-tokens";
/**
* v1.15.1 — the cycle-length history chart.
*
* A HAND-ROLLED SVG (0-KB runtime, no chart lib — matching `cycle-ring.tsx`'s
* identity rather than reaching for Recharts) that draws one vertical bar per
* observed cycle, segmented period-vs-rest: the lower span is the menstrual
* (period) portion in the rose `FLOW_HUE`, the upper span is the rest of the
* cycle in the calm luteal hue, and a thin ovulation tick marks the confirmed
* ovulation day when present. A mean baseline rule runs across the field so
* variability reads at a glance. Stat chips above carry the average length,
* the variability spread, and the regularity classification.
*
* Why hand-rolled SVG over Recharts: the geometry is trivial (N rects + a
* baseline line), the visual must match the wheel's bespoke SVG signature, and
* a thin bar chart through Recharts' ResponsiveContainer would pull a chart
* lib, an animation engine, and a tooltip surface we don't want here — the
* 0-KB approach is genuinely better for identity and weight.
*
* a11y: `role="img"` + a summary aria-label restating avg + regularity; each
* bar carries a `<title>` with its own length so the SVG is never colour-only.
* Markers are `data-*`-attr driven (`data-cycle-bar`, `data-ovulation-tick`,
* `data-regularity`) so Playwright asserts stable attrs, not viewport text.
*
* Motion: the shared bar-rise reveal plays ONLY when the host passes
* `animate` AND `prefers-reduced-motion` is not set (it sets `data-animate`
* on the SVG, which the CSS keys the per-bar grow on). Otherwise bars paint
* at full height immediately.
*/
const VIEW_W = 320;
const VIEW_H = 120;
const PAD_TOP = 8;
const PAD_BOTTOM = 4;
const MAX_BARS = 12;
const REST_HUE = "var(--cycle-phase-luteal)";
export interface CycleHistoryChartProps {
history: CycleHistoryResponse | undefined;
/** Play the staggered bar-rise once (gated on reduced-motion internally). */
animate?: boolean;
className?: string;
}
interface Bar {
id: string;
lengthDays: number;
periodDays: number | null;
ovulationDay: number | null;
}
/** Inclusive day span between two `YYYY-MM-DD` dates (noon-UTC anchored). */
function daySpan(from: string, to: string): number {
const a = Date.parse(`${from}T12:00:00Z`);
const b = Date.parse(`${to}T12:00:00Z`);
return Math.round((b - a) / 86_400_000) + 1;
}
function toBar(c: MenstrualCycleDTO): Bar | null {
if (c.lengthDays == null || c.lengthDays <= 0) return null;
const periodDays =
c.periodEndDate != null ? daySpan(c.startDate, c.periodEndDate) : null;
const ovulationDay =
c.ovulationConfirmed && c.ovulationDate != null
? daySpan(c.startDate, c.ovulationDate)
: null;
return {
id: c.id,
lengthDays: c.lengthDays,
periodDays: periodDays != null ? Math.min(periodDays, c.lengthDays) : null,
ovulationDay,
};
}
export function CycleHistoryChart({
history,
animate = false,
className,
}: CycleHistoryChartProps) {
const { t } = useTranslations();
const reduced = prefersReducedMotion();
const shouldAnimate = animate && !reduced;
const stats = history?.stats;
// Oldest-to-newest, capped — read left-to-right like a timeline.
const bars = useMemo<Bar[]>(() => {
const observed = (history?.cycles ?? []).filter((c) => !c.isPredicted);
return observed
.map(toBar)
.filter((b): b is Bar => b != null)
.slice(0, MAX_BARS)
.reverse();
}, [history?.cycles]);
const hasData = bars.length > 0 && stats?.avgLengthDays != null;
const maxLen = useMemo(
() => Math.max(...bars.map((b) => b.lengthDays), stats?.avgLengthDays ?? 1),
[bars, stats?.avgLengthDays],
);
const regularity = stats?.regularity ?? "LEARNING";
return (
<Card data-slot="cycle-history-chart" className={className}>
<CardHeader>
<CardTitle className="text-base">
{t("cycle.history.chartTitle")}
</CardTitle>
{hasData ? (
<CardDescription>
{t("cycle.history.chartCaption")}
</CardDescription>
) : null}
</CardHeader>
<CardContent className="space-y-4">
{!hasData ? (
<p className="text-muted-foreground text-sm">
{t("cycle.history.none")}
</p>
) : (
<>
{/* Stat chips — avg / variability / regularity classification. */}
<ul className="flex flex-wrap gap-2" data-slot="cycle-history-stats">
<StatChip
hue={REST_HUE}
label={t("cycle.history.avgLength")}
value={t("cycle.history.days", {
count: stats!.avgLengthDays!,
})}
/>
{stats!.lengthVariabilityDays != null ? (
<StatChip
hue="var(--muted-foreground)"
label={t("cycle.history.variability")}
value={t("cycle.history.daysPlusMinus", {
count: stats!.lengthVariabilityDays,
})}
/>
) : null}
<StatChip
hue={
regularity === "REGULAR"
? PHASE_HUE.FOLLICULAR
: regularity === "IRREGULAR"
? OVULATION_HUE
: "var(--muted-foreground)"
}
data-regularity={regularity}
label={t("cycle.history.regularity")}
value={t(`cycle.history.regularity${regularity}`)}
/>
</ul>
<BarField
bars={bars}
maxLen={maxLen}
avg={stats!.avgLengthDays!}
shouldAnimate={shouldAnimate}
ariaLabel={t("cycle.history.chartAria", {
count: bars.length,
avg: Math.round(stats!.avgLengthDays!),
regularity: t(`cycle.history.regularity${regularity}`),
})}
periodLabel={t("cycle.history.legendPeriodSegment")}
ovulationLabel={t("cycle.calendar.legendOvulationConfirmed")}
barTitle={(b) =>
t("cycle.history.days", { count: b.lengthDays })
}
/>
{/* Inline legend mirroring the bar segments. */}
<ul className="text-muted-foreground flex flex-wrap gap-x-4 gap-y-1.5 text-xs">
<li className="flex items-center gap-1.5">
<span
aria-hidden="true"
className="h-2.5 w-2.5 rounded-sm"
style={{ backgroundColor: FLOW_HUE }}
/>
{t("cycle.history.legendPeriodSegment")}
</li>
<li className="flex items-center gap-1.5">
<span
aria-hidden="true"
className="h-2.5 w-2.5 rounded-sm opacity-60"
style={{ backgroundColor: REST_HUE }}
/>
{t("cycle.history.legendRestSegment")}
</li>
<li className="flex items-center gap-1.5">
<span
aria-hidden="true"
className="h-2.5 w-0.5 rounded-full"
style={{ backgroundColor: OVULATION_HUE }}
/>
{t("cycle.calendar.legendOvulationConfirmed")}
</li>
</ul>
</>
)}
</CardContent>
</Card>
);
}
function BarField({
bars,
maxLen,
avg,
shouldAnimate,
ariaLabel,
periodLabel,
ovulationLabel,
barTitle,
}: {
bars: Bar[];
maxLen: number;
avg: number;
shouldAnimate: boolean;
ariaLabel: string;
periodLabel: string;
ovulationLabel: string;
barTitle: (b: Bar) => string;
}) {
const usableH = VIEW_H - PAD_TOP - PAD_BOTTOM;
const baseY = VIEW_H - PAD_BOTTOM;
// Column layout: even slots with a calm gap, bars centred in each slot.
const slot = VIEW_W / bars.length;
const barW = Math.min(slot * 0.62, 22);
const scale = (days: number) => (days / maxLen) * usableH;
const avgY = baseY - scale(avg);
return (
<svg
viewBox={`0 0 ${VIEW_W} ${VIEW_H}`}
className="h-32 w-full"
role="img"
aria-label={ariaLabel}
data-animate={shouldAnimate ? "true" : undefined}
preserveAspectRatio="none"
>
{/* Mean baseline rule — variability reads against it. */}
<line
x1={0}
x2={VIEW_W}
y1={avgY}
y2={avgY}
stroke="var(--muted-foreground)"
strokeOpacity={0.4}
strokeWidth={1}
strokeDasharray="3 3"
data-avg-line="true"
/>
{bars.map((b, i) => {
const cx = i * slot + slot / 2;
const x = cx - barW / 2;
const totalH = scale(b.lengthDays);
const topY = baseY - totalH;
const periodH =
b.periodDays != null ? scale(b.periodDays) : 0;
const periodTopY = baseY - periodH;
const restH = totalH - periodH;
const ovuY =
b.ovulationDay != null ? baseY - scale(b.ovulationDay) : null;
return (
<g
key={b.id}
className="cycle-history-bar"
data-cycle-bar="true"
style={{ "--bar-delay": `${i * 55}ms` } as React.CSSProperties}
>
<title>{`${barTitle(b)}`}</title>
{/* Rest-of-cycle segment (upper). */}
{restH > 0 ? (
<rect
x={x}
y={topY}
width={barW}
height={Math.max(restH, 0)}
rx={2}
fill={REST_HUE}
fillOpacity={0.5}
/>
) : null}
{/* Period segment (lower) — rose, the bleeding portion. */}
{periodH > 0 ? (
<rect
x={x}
y={periodTopY}
width={barW}
height={periodH}
rx={2}
fill={FLOW_HUE}
fillOpacity={0.85}
data-period-segment="true"
>
<title>{periodLabel}</title>
</rect>
) : null}
{/* Confirmed-ovulation tick — a short cross-bar at the ovu day. */}
{ovuY != null ? (
<rect
x={x - 1.5}
y={ovuY - 1}
width={barW + 3}
height={2}
rx={1}
fill={OVULATION_HUE}
data-ovulation-tick="true"
>
<title>{ovulationLabel}</title>
</rect>
) : null}
</g>
);
})}
</svg>
);
}
function StatChip({
hue,
label,
value,
...rest
}: {
hue: string;
label: string;
value: string;
} & React.HTMLAttributes<HTMLLIElement>) {
return (
<li
className={cn(
"bg-muted/40 flex items-center gap-2 rounded-md px-3 py-2",
)}
{...rest}
>
<span
aria-hidden="true"
className="h-2.5 w-2.5 shrink-0 rounded-full"
style={{ backgroundColor: hue }}
/>
<span className="flex flex-col">
<span className="text-muted-foreground text-[11px] leading-tight">
{label}
</span>
<span className="text-foreground text-sm font-semibold tabular-nums">
{value}
</span>
</span>
</li>
);
}
+7
View File
@@ -12,6 +12,7 @@ import { CycleRing } from "./cycle-ring";
import { BbtChart } from "./bbt-chart";
import { CycleCalendar } from "./cycle-calendar";
import { CycleDisclaimer } from "./cycle-disclaimer";
import { CycleHistoryChart } from "./cycle-history-chart";
import { LogDaySheet } from "./log-day-sheet";
import { PhaseEducationCard } from "./phase-education-card";
import { PredictionsPanel } from "./predictions-panel";
@@ -244,6 +245,11 @@ export function CycleView() {
<CycleCalendar
days={calendar.data?.days ?? []}
today={today}
confirmedOvulation={
calendar.data?.prediction?.ovulationConfirmed
? (calendar.data.prediction.predictedOvulation ?? null)
: null
}
onSelectDay={openSheet}
/>
)}
@@ -269,6 +275,7 @@ export function CycleView() {
history={history.data}
fallbackDisclaimer={disclaimerText}
/>
<CycleHistoryChart history={history.data} animate={play} />
<BbtChart
days={calendar.data?.days ?? []}
today={today}
+30 -1
View File
@@ -7,7 +7,7 @@
* semantic is never colour-only: every surface that uses a hue also renders
* the phase word + an aria-label.
*/
import type { CyclePhase } from "./types";
import type { CyclePhase, FlowLevel } from "./types";
export const PHASE_HUE: Record<CyclePhase, string> = {
MENSTRUAL: "var(--cycle-phase-menstrual)",
@@ -22,3 +22,32 @@ export const FLOW_HUE = "var(--cycle-phase-menstrual)";
export const FERTILE_HUE = "var(--cycle-phase-follicular)";
/** Ovulation marker tint (amber-gold family). */
export const OVULATION_HUE = "var(--cycle-phase-ovulatory)";
/**
* Flow-intensity shading — a SINGLE-HUE OPACITY LADDER on the menstrual rose
* (`FLOW_HUE`), never a rainbow of hues. Heavier flow = a denser fill of the
* same colour, so the calendar reads as one calm field that deepens with
* intensity. The ladder is the alpha applied to the period cell's filled pip;
* every step keeps the day number (foreground, z-10) above it at WCAG AA. The
* marker is never colour-only — each period day still restates its flow level
* in the cell aria-label + carries a `data-flow-level` attribute for e2e.
*
* SPOTTING (lightest) → HEAVY (densest). NONE keeps no fill (not a period day).
*/
export const FLOW_OPACITY: Record<Exclude<FlowLevel, "NONE">, number> = {
SPOTTING: 0.14,
LIGHT: 0.26,
MEDIUM: 0.42,
HEAVY: 0.62,
};
/** The default period-day fill alpha when a logged day carries no flow grade. */
export const FLOW_OPACITY_DEFAULT = 0.32;
/** Resolve the flow-shading alpha for a logged period day (graceful fallback). */
export function flowOpacity(flow: string | null | undefined): number {
if (flow && flow !== "NONE" && flow in FLOW_OPACITY) {
return FLOW_OPACITY[flow as Exclude<FlowLevel, "NONE">];
}
return FLOW_OPACITY_DEFAULT;
}