diff --git a/messages/de.json b/messages/de.json index ff74825d3..950f0a9e2 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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", diff --git a/messages/en.json b/messages/en.json index 57a864f32..c49172137 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/es.json b/messages/es.json index 834746511..0eb9a918f 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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", diff --git a/messages/fr.json b/messages/fr.json index a40242778..2ca665370 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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", diff --git a/messages/it.json b/messages/it.json index d6dd8ef64..81b1fe0ad 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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", diff --git a/messages/pl.json b/messages/pl.json index 516459dd2..4b62a695e 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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ń", diff --git a/src/app/globals.css b/src/app/globals.css index 07d57cbf4..254e94612 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -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; + } +} diff --git a/src/components/cycle/__tests__/cycle-calendar.test.tsx b/src/components/cycle/__tests__/cycle-calendar.test.tsx index dfc93a3d1..439afa4af 100644 --- a/src/components/cycle/__tests__/cycle-calendar.test.tsx +++ b/src/components/cycle/__tests__/cycle-calendar.test.tsx @@ -73,4 +73,56 @@ describe("", () => { ); 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( + {}} />, + ); + 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( + {}} />, + ); + 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( + {}} />, + ); + 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( + {}} + />, + ); + 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( + {}} />, + ); + expect(html).toContain('data-fertile="true"'); + }); }); diff --git a/src/components/cycle/__tests__/cycle-history-chart.test.tsx b/src/components/cycle/__tests__/cycle-history-chart.test.tsx new file mode 100644 index 000000000..ece2fc0bb --- /dev/null +++ b/src/components/cycle/__tests__/cycle-history-chart.test.tsx @@ -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( + {node}, + ); +} + +function cycle(over: Partial): 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("", () => { + it("renders one bar per observed cycle with stable data-attrs", () => { + const html = render(); + 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(); + expect(html).toContain('data-regularity="REGULAR"'); + expect(html).toContain("Regular"); + }); + + it("draws a confirmed-ovulation tick only for confirmed cycles", () => { + const html = render(); + 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(); + 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(); + expect(html).toContain("No cycles recorded yet."); + expect(html).not.toContain('data-cycle-bar="true"'); + }); +}); diff --git a/src/components/cycle/cycle-calendar.tsx b/src/components/cycle/cycle-calendar.tsx index ff5dcb18c..84b16ad90 100644 --- a/src/components/cycle/cycle-calendar.tsx +++ b/src/components/cycle/cycle-calendar.tsx @@ -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 (