diff --git a/src/components/charts/__tests__/health-chart-a11y-label.test.tsx b/src/components/charts/__tests__/health-chart-a11y-label.test.tsx new file mode 100644 index 000000000..8a02ddda0 --- /dev/null +++ b/src/components/charts/__tests__/health-chart-a11y-label.test.tsx @@ -0,0 +1,69 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import deMessages from "../../../../messages/de.json"; + +/** + * Chart accessibility — the rendered chart must expose an image role with + * a non-empty data-summary label so a screen reader announces the series + * instead of skipping a silent SVG. Mirrors the empty-window render test: + * mock auth + react-query, render to static markup, assert the DOM. + */ + +function buildData(): unknown[] { + const out: Array<{ date: string; timestamp: number; PULSE: number }> = []; + const base = Date.UTC(2026, 0, 1, 12, 0, 0); + for (let i = 0; i < 10; i++) { + out.push({ + date: `2026-01-${String(i + 1).padStart(2, "0")}`, + timestamp: base + i * 86_400_000, + PULSE: 60 + i, + }); + } + return out; +} + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ + isAuthenticated: true, + user: null, + isLoading: false, + }), +})); + +describe(" — accessibility label", () => { + beforeEach(() => { + vi.resetModules(); + }); + + it("wraps the chart in role=img with a non-empty aria-label when data is present", async () => { + const data = buildData(); + + vi.doMock("@tanstack/react-query", () => ({ + useQuery: () => ({ data, isLoading: false }), + useQueryClient: () => ({ + cancelQueries: () => Promise.resolve(), + getQueryData: () => undefined, + setQueryData: () => undefined, + invalidateQueries: () => Promise.resolve(), + }), + useMutation: () => ({ mutate: () => undefined, isPending: false }), + })); + + const { I18nProvider } = await import("@/lib/i18n/context"); + const { HealthChart } = await import("../health-chart"); + + const html = renderToStaticMarkup( + + + , + ); + + expect(html).toContain('role="img"'); + // A role=img wrapper with an empty/absent label is worse than none — + // assert the aria-label attribute carries actual text. + const match = html.match(/role="img"[^>]*aria-label="([^"]*)"/); + expect(match?.[1] ?? "").not.toBe(""); + + vi.doUnmock("@tanstack/react-query"); + }); +}); diff --git a/src/components/charts/compliance-heatmap.tsx b/src/components/charts/compliance-heatmap.tsx index e92a03f48..06071fb69 100644 --- a/src/components/charts/compliance-heatmap.tsx +++ b/src/components/charts/compliance-heatmap.tsx @@ -1,7 +1,7 @@ "use client"; import { useState, useMemo, useEffect, useRef } from "react"; -import { useTranslations } from "@/lib/i18n/context"; +import { useTranslations, useFormatters } from "@/lib/i18n/context"; interface DailyData { expected: number; @@ -72,22 +72,17 @@ function getColor(data: DailyData): string { return "var(--dracula-red)"; } -function formatDateDE(dateStr: string): string { - // v1.4.27 B7 / BL-P4-2 — parse the day-key against UTC so the - // formatted tooltip label matches the dateKey computation below - // (which uses `toISOString().slice(0, 10)`). Without the UTC - // anchor an SSR server in a non-Berlin timezone could format the - // tick a day off the dateKey it sits under. - const d = new Date(dateStr + "T00:00:00Z"); - return `${d.getUTCDate()}.${d.getUTCMonth() + 1}.${d.getUTCFullYear()}`; -} - export function ComplianceHeatmap({ dailyCompliance, days = 90, stretch = false, }: ComplianceHeatmapProps) { const { t } = useTranslations(); + const fmt = useFormatters(); + // Day keys are UTC-anchored "YYYY-MM-DD"; format at noon UTC so the + // locale-aware renderer never lands a day off in either direction. + const formatDay = (dateKey: string) => + fmt.date(new Date(`${dateKey}T12:00:00Z`)); const containerRef = useRef(null); const [containerWidth, setContainerWidth] = useState(0); const [tooltip, setTooltip] = useState<{ @@ -230,6 +225,16 @@ export function ComplianceHeatmap({ return { cells: cellList, weeks: col + 1, monthMarkers: markers }; }, [dailyCompliance, days, t]); + const activeDays = cells.filter((cell) => cell.data.expected > 0).length; + const summaryLabel = + cells.length === 0 || activeDays === 0 + ? t("charts.a11y.complianceHeatmapEmpty") + : t("charts.a11y.complianceHeatmap", { + start: formatDay(cells[0].dateKey), + end: formatDay(cells[cells.length - 1].dateKey), + days: activeDays, + }); + const labelWidth = stretch ? 0 : 76; const headerHeight = 18; // v1.4.27 MB7 / CF-10 — clamp the stretch-branch adaptive cell to the @@ -268,6 +273,8 @@ export function ComplianceHeatmap({ {tooltip.text} diff --git a/src/components/charts/health-chart.tsx b/src/components/charts/health-chart.tsx index 6ea6cff27..60f97d96a 100644 --- a/src/components/charts/health-chart.tsx +++ b/src/components/charts/health-chart.tsx @@ -1386,6 +1386,51 @@ export function HealthChart({ const formatTooltipValue = (value: number) => fmt.number(value, 1); + // Accessibility — a concise spoken summary of the primary series so a screen + // reader announces the shape of the chart (range, latest value, direction) + // instead of an unlabelled graphic. Mirrors the visible-range stats the + // chart already holds; falls back to the raw min/max when the stat strip + // is suppressed (mini mode). + const primaryType = types[0]; + const chartAriaLabel = useMemo(() => { + const metric = getTypeLabel(primaryType, valueMode, t); + const source = chartDataWithCompare ?? chartData; + const values = (source ?? []) + .map((point) => point[primaryType]) + .filter((v): v is number => typeof v === "number" && Number.isFinite(v)); + if (values.length === 0) { + return t("charts.a11y.noData", { metric }); + } + const stats = visibleStatsByType?.[primaryType]; + const minVal = stats?.min ?? Math.min(...values); + const maxVal = stats?.max ?? Math.max(...values); + const latestVal = values[values.length - 1]; + const firstVal = values[0]; + const epsilon = Math.max(Math.abs(maxVal - minVal) * 0.02, 0.01); + const delta = latestVal - firstVal; + const trend = + delta > epsilon + ? t("charts.a11y.trendUp") + : delta < -epsilon + ? t("charts.a11y.trendDown") + : t("charts.a11y.trendFlat"); + return t("charts.a11y.summary", { + metric, + min: fmt.number(minVal, 1), + max: fmt.number(maxVal, 1), + latest: fmt.number(latestVal, 1), + trend, + }); + }, [ + primaryType, + valueMode, + t, + chartDataWithCompare, + chartData, + visibleStatsByType, + fmt, + ]); + const trendInfo = useMemo(() => { if (!showTrend || !chartData?.length) return []; @@ -1734,7 +1779,11 @@ export function HealthChart({ ) : null} -
+
`${fmt.integer(value)} %`; const animationsEnabled = !prefersReducedMotion(); + // Accessibility — a concise spoken summary of the compliance series so a + // screen reader announces the adherence range, the latest day and the + // direction rather than an unlabelled graphic. Reuses the same 7-day trend + // the chip surfaces; falls back to a first-vs-last comparison when the + // window is too short for the trend helper. + const complianceAriaLabel = ((): string => { + const metric = displayTitle; + const rates = chartData + .map((p) => p.rate) + .filter((v): v is number => typeof v === "number" && Number.isFinite(v)); + if (rates.length === 0) { + return t("charts.a11y.noData", { metric }); + } + const pct = (v: number) => `${fmt.integer(v)} %`; + const minVal = Math.min(...rates); + const maxVal = Math.max(...rates); + const latestVal = rates[rates.length - 1]; + let trendWord: string; + if (trend && trend.direction !== "stable") { + trendWord = + trend.direction === "up" + ? t("charts.a11y.trendUp") + : t("charts.a11y.trendDown"); + } else { + const delta = latestVal - rates[0]; + trendWord = + delta > 1 + ? t("charts.a11y.trendUp") + : delta < -1 + ? t("charts.a11y.trendDown") + : t("charts.a11y.trendFlat"); + } + return t("charts.a11y.summary", { + metric, + min: pct(minVal), + max: pct(maxVal), + latest: pct(latestVal), + trend: trendWord, + }); + })(); + // Empty-state guard: if the user has zero scheduled doses across the // whole window we render the title + a "no data" hint, mirroring how // the other charts handle the empty case (HealthChart returns null @@ -475,7 +516,11 @@ export function MedicationComplianceChart({ /> ) ) : ( -
+
{ + const metric = displayTitle; + const scores = (chartData ?? []) + .map((d) => d.score) + .filter((v): v is number => typeof v === "number" && Number.isFinite(v)); + if (scores.length === 0) { + return t("charts.a11y.noData", { metric }); + } + const fmtScore = (v: number) => String(Math.round(v * 10) / 10); + const minVal = Math.min(...scores); + const maxVal = Math.max(...scores); + const latestVal = scores[scores.length - 1]; + const firstVal = scores[0]; + const epsilon = 0.1; + const delta = latestVal - firstVal; + const trend = + delta > epsilon + ? t("charts.a11y.trendUp") + : delta < -epsilon + ? t("charts.a11y.trendDown") + : t("charts.a11y.trendFlat"); + return t("charts.a11y.summary", { + metric, + min: fmtScore(minVal), + max: fmtScore(maxVal), + latest: fmtScore(latestVal), + trend, + }); + })(); + const chartBody = isLoading ? ( // v1.16.0 — height-matched skeleton band instead of the former // `h-48` spinner box: the loading state occupies the same box the @@ -680,6 +713,8 @@ export function MoodChart({ ? "h-[var(--chart-height,140px)]" : "h-[var(--chart-height,200px)] md:h-[var(--chart-height-md,220px)]" } touch-pan-y`} + role="img" + aria-label={moodAriaLabel} > + fmt.date(new Date(`${dateKey}T12:00:00Z`)); const containerRef = useRef(null); const [containerWidth, setContainerWidth] = useState(0); const [tooltip, setTooltip] = useState<{ @@ -185,6 +185,16 @@ export function MoodHeatmap({ return { cells: cellList, weeks: col + 1, monthMarkers: markers }; }, [cellData, days, t]); + const loggedDays = cells.filter((cell) => cell.cell !== null).length; + const summaryLabel = + cells.length === 0 || loggedDays === 0 + ? t("charts.a11y.moodHeatmapEmpty") + : t("charts.a11y.moodHeatmap", { + start: formatDay(cells[0].dateKey), + end: formatDay(cells[cells.length - 1].dateKey), + days: loggedDays, + }); + const labelWidth = stretch ? 0 : 76; const headerHeight = 18; const cellSize = @@ -214,6 +224,8 @@ export function MoodHeatmap({ { const buildText = (): string => { if (!cell.cell) { - return `${formatDateDE(cell.dateKey)}: ${t("insights.mood.heatmapNoEntry")}`; + return `${formatDay(cell.dateKey)}: ${t("insights.mood.heatmapNoEntry")}`; } const labelKey = moodLabelKeyForScore( Math.round(cell.cell.score), ); const moodLabel = labelKey ? t(labelKey) : ""; - return `${formatDateDE(cell.dateKey)}: ${cell.cell.score.toFixed(1)}${moodLabel ? ` · ${moodLabel}` : ""}`; + return `${formatDay(cell.dateKey)}: ${cell.cell.score.toFixed(1)}${moodLabel ? ` · ${moodLabel}` : ""}`; }; return ( {tooltip.text}
diff --git a/src/components/charts/scatter-correlation-chart.tsx b/src/components/charts/scatter-correlation-chart.tsx index 1f6f9f3ed..a75d3d7ac 100644 --- a/src/components/charts/scatter-correlation-chart.tsx +++ b/src/components/charts/scatter-correlation-chart.tsx @@ -10,6 +10,7 @@ import { XAxis, YAxis, } from "recharts"; +import { useTranslations } from "@/lib/i18n/context"; /** * Self-contained correlation scatter chart used by /insights. @@ -82,6 +83,22 @@ export function ScatterCorrelationChart>({ tooltipFormatter, height = 250, }: ScatterCorrelationChartProps) { + const { t } = useTranslations(); + // Accessibility — a concise spoken summary so a screen reader announces + // what the scatter relates and how many points it plots rather than an + // unlabelled graphic. + const count = data?.length ?? 0; + const summary = + count === 0 + ? t("charts.a11y.scatterNoData", { + xLabel: xAxis.name, + yLabel: yAxis.name, + }) + : t("charts.a11y.scatterSummary", { + count, + xLabel: xAxis.name, + yLabel: yAxis.name, + }); // v1.4.27 MB7 / CF-37 — drop the fixed pixel height in favour of a // responsive aspect-ratio on mobile so the chart never compresses // below readable axes. The `aspect-square` ratio at `>({ className="aspect-square min-h-[180px] touch-pan-y sm:aspect-[3/2] sm:h-auto" style={{ height: undefined }} data-explicit-height={height} + role="img" + aria-label={summary} >