diff --git a/src/components/charts/__tests__/medication-chart-polish.test.tsx b/src/components/charts/__tests__/medication-chart-polish.test.tsx new file mode 100644 index 000000000..d1c7588cc --- /dev/null +++ b/src/components/charts/__tests__/medication-chart-polish.test.tsx @@ -0,0 +1,45 @@ +import { describe, it, expect, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +/** + * v1.4.16 B1a — Medication compliance chart polish contract. + * + * The medication chart already has 7-day-trend + target lines from A6; + * this commit adds the rest of the v1.4.16 visual leap so the surface + * matches BP/weight/pulse/mood: gradient fill, animated first render, + * rich tooltip primitive. + */ + +const sampleData = vi.hoisted(() => + Array.from({ length: 14 }, (_, i) => ({ + date: `2026-05-${String(i + 1).padStart(2, "0")}`, + scheduled: 4, + taken: 3 + (i % 2), + })), +); + +vi.mock("@tanstack/react-query", () => ({ + useQuery: () => ({ data: sampleData, isLoading: false }), +})); + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ isAuthenticated: true, user: null, isLoading: false }), +})); + +describe(" v1.4.16 B1a polish", () => { + it("emits the gradient defs sibling-SVG block", async () => { + const { I18nProvider } = await import("@/lib/i18n/context"); + const { MedicationComplianceChart } = await import( + "../medication-compliance-chart" + ); + + const html = renderToStaticMarkup( + + + , + ); + + expect(html).toContain('data-slot="chart-linear-gradient"'); + expect(html).toContain('id="chart-gradient-medication"'); + }); +}); diff --git a/src/components/charts/medication-compliance-chart.tsx b/src/components/charts/medication-compliance-chart.tsx index 4203b65b0..4aa48f8f2 100644 --- a/src/components/charts/medication-compliance-chart.tsx +++ b/src/components/charts/medication-compliance-chart.tsx @@ -36,7 +36,8 @@ import { useQuery } from "@tanstack/react-query"; import { useMemo, useState } from "react"; import { ResponsiveContainer, - LineChart, + ComposedChart, + Area, Line, XAxis, YAxis, @@ -50,6 +51,10 @@ import { useAuth } from "@/hooks/use-auth"; import { useTranslations, useFormatters } from "@/lib/i18n/context"; import { formatDateShort } from "@/lib/format"; import { cn } from "@/lib/utils"; +import { ChartLinearGradient, chartGradientFill } from "./chart-gradient"; +import { RichChartTooltip, type RichTooltipRow } from "./chart-tooltip"; +import { ChartEmptyState } from "./chart-empty-state"; +import { prefersReducedMotion } from "@/lib/charts/reduced-motion"; interface DailyCompliancePoint { /** Berlin calendar day, "YYYY-MM-DD". */ @@ -208,6 +213,7 @@ export function MedicationComplianceChart({ const displayTitle = title ?? t("dashboard.medications"); const yAxisFormatter = (value: number) => `${fmt.integer(value)} %`; + const animationsEnabled = !prefersReducedMotion(); // Empty-state guard: if the user has zero scheduled doses across the // whole window we render the title + a "no data" hint, mirroring how @@ -260,6 +266,21 @@ export function MedicationComplianceChart({ + {/* v1.4.16 B1a — sibling SVG block for SSR-discoverable + gradient. */} + + {isLoading ? (
@@ -268,13 +289,32 @@ export function MedicationComplianceChart({
{t("charts.noData")}
+ ) : chartData.length < 3 ? ( + // v1.4.16 B1a — sparse-data placeholder consistent with the + // BP/weight/pulse/mood charts. + ) : (
- + + + + + + { + const { active, payload } = props as unknown as { + active?: boolean; + payload?: Array<{ + value?: number; + color?: string; + payload?: ChartPoint; + }>; + }; + if (!active || !payload?.length) return null; + const ts = payload[0]?.payload?.timestamp; + const dateLabel = ts + ? formatDateShort(new Date(ts), true) + : ""; + const rate = payload[0]?.value; + if (typeof rate !== "number") return null; + // Delta vs. the 100 % goal — a positive delta means + // "100 % target hit" and reads as success; a + // negative delta is the gap to close. + const gap = 100 - rate; + let delta: string | undefined; + if (gap < 0.5) { + delta = t("charts.deltaUnchanged"); + } else { + const formatted = `−${fmt.integer(gap)} pp`; + delta = t("charts.deltaVsTarget").replace( + "{delta}", + formatted, + ); + } + const rows: RichTooltipRow[] = [ + { + name: t("dashboard.compliance7d"), + value: `${fmt.integer(rate)} %`, + color: payload[0]?.color ?? COLOR_LINE, + delta, + }, + ]; + return ( + + ); }} - formatter={(value) => - typeof value === "number" - ? [`${fmt.integer(value)} %`, t("dashboard.compliance7d")] - : value - } - labelFormatter={(_label, payload) => - payload?.[0]?.payload?.timestamp - ? formatDateShort( - new Date(payload[0].payload.timestamp as number), - true, - ) - : "" - } /> {/* v1.4.16 A6 — minimum-acceptable threshold (yellow, paler dash) and 100 % goal line (green, solid-ish). @@ -334,6 +402,21 @@ export function MedicationComplianceChart({ strokeOpacity={0.85} data-slot="medication-goal-line" /> + {/* v1.4.16 B1a — gradient-filled Area painted under the + line. Animated on first render unless the user opts + for reduced motion. */} + - +
)}