mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
feat(cycle): flow-shaded calendar, confirmed-ovulation marker, and cycle-length history chart
This commit is contained in:
1 parent
985e197db4
commit
32552f02ec
13 files changed
+782
-39
No files matched your search
+12
-2
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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ń",
|
||||
|
||||
@@ -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"');
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user