mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
fix(charts): give charts and heatmaps a screen-reader text alternative
Wrap the shared HealthChart plus the mood, medication-compliance, and scatter-correlation charts in role="img" with a concise aria-label that states the metric, value range, latest reading, and trend direction, so VoiceOver and TalkBack announce something for every chart cell and the ~50 insight pages behind the shared component. Add the same role="img" summary to the mood and compliance heatmaps, route their tooltip dates through the locale- and timezone-aware formatter instead of a hardcoded German format, and clamp the pinned tooltip to the viewport so it no longer overflows the right edge on a narrow phone.
This commit is contained in:
1 parent
b424c37ab0
commit
51d67af28f
7 files changed
+276
-24
No files matched your search
@@ -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("<HealthChart> — 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(
|
||||
<I18nProvider initialLocale="de" initialMessages={deMessages}>
|
||||
<HealthChart types={["PULSE"]} title="Pulse" unit="bpm" />
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -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<HTMLDivElement | null>(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({
|
||||
<svg
|
||||
width={svgWidth}
|
||||
height={svgHeight}
|
||||
role="img"
|
||||
aria-label={summaryLabel}
|
||||
// v1.15.3 — `max-w-full` (not `w-full`) so a short window keeps its
|
||||
// natural, square, left-aligned grid (the capped cells already size
|
||||
// off `containerWidth`, so a full window still fills the row) rather
|
||||
@@ -330,7 +337,7 @@ export function ComplianceHeatmap({
|
||||
const timingInfo = hasTimingData
|
||||
? ` | ${cell.data.onTime ?? 0} ${t("charts.heatmapOnTime")}, ${cell.data.late ?? 0} ${t("charts.heatmapLate")}, ${cell.data.veryLate ?? 0} ${t("charts.heatmapVeryLate")}`
|
||||
: "";
|
||||
return `${formatDateDE(cell.dateKey)}: ${cell.data.taken}/${cell.data.expected} (${rate}%)${timingInfo}`;
|
||||
return `${formatDay(cell.dateKey)}: ${cell.data.taken}/${cell.data.expected} (${rate}%)${timingInfo}`;
|
||||
};
|
||||
return (
|
||||
<rect
|
||||
@@ -376,7 +383,15 @@ export function ComplianceHeatmap({
|
||||
{tooltip && (
|
||||
<div
|
||||
className="bg-popover text-popover-foreground border-border pointer-events-none fixed z-50 rounded-md border px-2 py-1 text-xs shadow-md"
|
||||
style={{ left: tooltip.x + 10, top: tooltip.y - 30 }}
|
||||
// Clamp the left edge so a tap near the right border doesn't push
|
||||
// the pinned label off-screen on a narrow viewport.
|
||||
style={{
|
||||
left:
|
||||
typeof window !== "undefined"
|
||||
? Math.min(tooltip.x + 10, window.innerWidth - 180 - 8)
|
||||
: tooltip.x + 10,
|
||||
top: tooltip.y - 30,
|
||||
}}
|
||||
>
|
||||
{tooltip.text}
|
||||
</div>
|
||||
|
||||
@@ -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({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="relative z-10 h-full touch-pan-y">
|
||||
<div
|
||||
className="relative z-10 h-full touch-pan-y"
|
||||
role="img"
|
||||
aria-label={chartAriaLabel}
|
||||
>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<ComposedChart
|
||||
data={chartDataWithCompare ?? chartData}
|
||||
|
||||
@@ -338,6 +338,47 @@ export function MedicationComplianceChart({
|
||||
const yAxisFormatter = (value: number) => `${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({
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<div className="h-[var(--chart-height,240px)] touch-pan-y md:h-[var(--chart-height-md,280px)]">
|
||||
<div
|
||||
className="h-[var(--chart-height,240px)] touch-pan-y md:h-[var(--chart-height-md,280px)]"
|
||||
role="img"
|
||||
aria-label={complianceAriaLabel}
|
||||
>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<ComposedChart
|
||||
data={chartData}
|
||||
|
||||
@@ -616,6 +616,39 @@ export function MoodChart({
|
||||
// below the "Stimmung" heading. Aligning the STRUCTURE — not just
|
||||
// the padding values — makes the three trend cards share one title
|
||||
// baseline and one height. Full (non-mini) mode keeps the Card path.
|
||||
// Accessibility — a concise spoken summary of the visible mood line so a
|
||||
// screen reader announces the score range, the latest value and the
|
||||
// direction rather than an unlabelled graphic.
|
||||
const moodAriaLabel = (() => {
|
||||
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}
|
||||
>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<ComposedChart
|
||||
|
||||
@@ -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";
|
||||
import { moodLabelKeyForScore } from "@/lib/mood/labels";
|
||||
|
||||
/**
|
||||
@@ -55,17 +55,17 @@ function getColor(score: number | null): string {
|
||||
return "var(--dracula-green)";
|
||||
}
|
||||
|
||||
function formatDateDE(dateStr: string): string {
|
||||
const d = new Date(dateStr + "T00:00:00Z");
|
||||
return `${d.getUTCDate()}.${d.getUTCMonth() + 1}.${d.getUTCFullYear()}`;
|
||||
}
|
||||
|
||||
export function MoodHeatmap({
|
||||
cells: cellData,
|
||||
days = 90,
|
||||
stretch = false,
|
||||
}: MoodHeatmapProps) {
|
||||
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<HTMLDivElement | null>(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({
|
||||
<svg
|
||||
width={svgWidth}
|
||||
height={svgHeight}
|
||||
role="img"
|
||||
aria-label={summaryLabel}
|
||||
// v1.15.3 — `max-w-full` (not `w-full`) so a short window keeps its
|
||||
// natural, square, left-aligned grid rather than CSS-stretching a few
|
||||
// capped columns into wide rectangles. Mirrors `compliance-heatmap`.
|
||||
@@ -254,13 +266,13 @@ export function MoodHeatmap({
|
||||
{cells.map((cell) => {
|
||||
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 (
|
||||
<rect
|
||||
@@ -306,7 +318,15 @@ export function MoodHeatmap({
|
||||
{tooltip && (
|
||||
<div
|
||||
className="bg-popover text-popover-foreground border-border pointer-events-none fixed z-50 rounded-md border px-2 py-1 text-xs shadow-md"
|
||||
style={{ left: tooltip.x + 10, top: tooltip.y - 30 }}
|
||||
// Clamp the left edge so a tap near the right border doesn't push
|
||||
// the pinned label off-screen on a narrow viewport.
|
||||
style={{
|
||||
left:
|
||||
typeof window !== "undefined"
|
||||
? Math.min(tooltip.x + 10, window.innerWidth - 180 - 8)
|
||||
: tooltip.x + 10,
|
||||
top: tooltip.y - 30,
|
||||
}}
|
||||
>
|
||||
{tooltip.text}
|
||||
</div>
|
||||
|
||||
@@ -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<T extends Record<string, number>>({
|
||||
tooltipFormatter,
|
||||
height = 250,
|
||||
}: ScatterCorrelationChartProps<T>) {
|
||||
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 `<sm` keeps the
|
||||
@@ -100,6 +117,8 @@ export function ScatterCorrelationChart<T extends Record<string, number>>({
|
||||
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}
|
||||
>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<ScatterChart margin={{ top: 10, right: 20, bottom: 36, left: 12 }}>
|
||||
|
||||
Reference in new issue
Block a user