diff --git a/src/app/page.tsx b/src/app/page.tsx
index 668c38de5..01546bf58 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -10,7 +10,6 @@ import {
Heart,
Moon,
Percent,
- Pill,
Plus,
Smile,
Target,
@@ -57,6 +56,13 @@ const MoodChart = dynamic(
})),
{ ssr: false },
);
+const MedicationComplianceChart = dynamic(
+ () =>
+ import("@/components/charts/medication-compliance-chart").then((mod) => ({
+ default: mod.MedicationComplianceChart,
+ })),
+ { ssr: false },
+);
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import type { DataSummary } from "@/lib/analytics/trends";
@@ -863,25 +869,15 @@ export default function DashboardPage() {
});
}
if (showMedicationsCard) {
+ // v1.4.15 Fix 2: the toggle existed since v1.1 but the dashboard
+ // slot only rendered a static placeholder (icon + title), so
+ // flipping the layout switch on did nothing visible. Wire the
+ // real chart that consumes
+ // `/api/medications/intake?scope=compliance&days=N`.
charts.push({
id: "medications",
order: widgetOrder("medications"),
- node: (
-
-
-
-
- {t("dashboard.medications")}
-
-
-
- {t("medications.title") ?? ""}
-
-
- ),
+ node: ,
});
}
diff --git a/src/components/charts/__tests__/medication-compliance-chart.test.tsx b/src/components/charts/__tests__/medication-compliance-chart.test.tsx
new file mode 100644
index 000000000..78e09e206
--- /dev/null
+++ b/src/components/charts/__tests__/medication-compliance-chart.test.tsx
@@ -0,0 +1,95 @@
+import { describe, it, expect, vi } from "vitest";
+import { renderToStaticMarkup } from "react-dom/server";
+
+import {
+ MedicationComplianceChart,
+ aggregateMedicationCompliance,
+} from "../medication-compliance-chart";
+
+/**
+ * Stubs — the dashboard chart wrappers all need TanStack Query +
+ * useAuth shims to render in SSR. We don't care about live data; we
+ * only assert that the wrapper paints its title and "no data" empty
+ * state when the query resolves to an empty array, and that the
+ * aggregation helper produces the correct rates.
+ */
+
+vi.mock("@tanstack/react-query", () => ({
+ useQuery: () => ({ data: [], isLoading: false }),
+}));
+
+vi.mock("@/hooks/use-auth", () => ({
+ useAuth: () => ({ isAuthenticated: true, user: null, isLoading: false }),
+}));
+
+import { I18nProvider } from "@/lib/i18n/context";
+
+function render(node: React.ReactNode) {
+ return renderToStaticMarkup(
+ {node},
+ );
+}
+
+describe("aggregateMedicationCompliance()", () => {
+ it("computes rate as taken/scheduled rounded to nearest integer", () => {
+ const out = aggregateMedicationCompliance([
+ { date: "2026-05-01", scheduled: 4, taken: 4 }, // 100 %
+ { date: "2026-05-02", scheduled: 4, taken: 3 }, // 75 %
+ { date: "2026-05-03", scheduled: 4, taken: 2 }, // 50 %
+ { date: "2026-05-04", scheduled: 4, taken: 0 }, // 0 %
+ ]);
+ expect(out.map((p) => p.rate)).toEqual([100, 75, 50, 0]);
+ });
+
+ it("skips days where no doses were scheduled (rate undefined)", () => {
+ const out = aggregateMedicationCompliance([
+ { date: "2026-05-01", scheduled: 0, taken: 0 }, // skipped
+ { date: "2026-05-02", scheduled: 2, taken: 2 }, // 100 %
+ { date: "2026-05-03", scheduled: 0, taken: 0 }, // skipped
+ ]);
+ expect(out).toHaveLength(1);
+ expect(out[0].rate).toBe(100);
+ });
+
+ it("clamps over-100 % rates (e.g. extra ad-hoc takes) to 100", () => {
+ const out = aggregateMedicationCompliance([
+ { date: "2026-05-01", scheduled: 2, taken: 5 },
+ ]);
+ expect(out[0].rate).toBe(100);
+ });
+
+ it("sorts by date ascending regardless of input order", () => {
+ const out = aggregateMedicationCompliance([
+ { date: "2026-05-03", scheduled: 1, taken: 1 },
+ { date: "2026-05-01", scheduled: 1, taken: 1 },
+ { date: "2026-05-02", scheduled: 1, taken: 1 },
+ ]);
+ expect(out.map((p) => p.timestamp)).toEqual(
+ [...out.map((p) => p.timestamp)].sort((a, b) => a - b),
+ );
+ });
+
+ it("emits an empty array when all input days have no scheduled doses", () => {
+ const out = aggregateMedicationCompliance([
+ { date: "2026-05-01", scheduled: 0, taken: 0 },
+ ]);
+ expect(out).toEqual([]);
+ });
+});
+
+describe("", () => {
+ it("renders the section title and the empty-state message when data is empty", () => {
+ const html = render();
+ // Title — fallback to dashboard.medications when no override prop.
+ expect(html).toContain("Medications");
+ // Empty state surfaces the i18n no-data string.
+ expect(html).toContain("No data");
+ // The wrapper data-slot is present so e2e/visual-verify can target it.
+ expect(html).toContain('data-slot="medication-compliance-chart"');
+ });
+
+ it("respects an explicit title override", () => {
+ const html = render();
+ expect(html).toContain("Adherence");
+ });
+});
diff --git a/src/components/charts/medication-compliance-chart.tsx b/src/components/charts/medication-compliance-chart.tsx
new file mode 100644
index 000000000..facac11a9
--- /dev/null
+++ b/src/components/charts/medication-compliance-chart.tsx
@@ -0,0 +1,235 @@
+"use client";
+
+/**
+ * Dashboard medication-compliance chart.
+ *
+ * Wired to the `medications` toggle in Settings → Dashboard. Up to v1.4.14
+ * the toggle existed but the dashboard slot only rendered a static
+ * placeholder (an icon + the section title), so flipping it on did nothing
+ * visible. This wrapper shows daily compliance % across all of the user's
+ * scheduled medications for the last N days, matching the same visual
+ * pattern the other dashboard charts use (Card surface, Dracula tokens,
+ * range chips in the header, ReferenceLine target at 80 %).
+ *
+ * Data source: `GET /api/medications/intake?scope=compliance&days=N`
+ * already returns `{ date, scheduled, taken }[]`. We aggregate to
+ * `taken / scheduled * 100` per day; days without any scheduled doses
+ * are skipped (compliance is undefined for a day with no expected
+ * intakes — drawing a 0 % point on those would mis-represent the data).
+ *
+ * IMPORTANT: keep the recharts primitives as static imports inside
+ * this wrapper. Phase 4 visual-verify (v1.4.14) proved that wrapping
+ * individual primitives in `next/dynamic` breaks Recharts'
+ * `findAllByType` reconciliation; the dashboard's `next/dynamic` for
+ * the wrapper itself stays intact.
+ */
+import { useQuery } from "@tanstack/react-query";
+import { useMemo, useState } from "react";
+import {
+ ResponsiveContainer,
+ LineChart,
+ Line,
+ XAxis,
+ YAxis,
+ CartesianGrid,
+ Tooltip,
+ ReferenceLine,
+} from "recharts";
+import { Loader2, Pill } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { useAuth } from "@/hooks/use-auth";
+import { useTranslations, useFormatters } from "@/lib/i18n/context";
+import { formatDateShort } from "@/lib/format";
+
+interface DailyCompliancePoint {
+ /** Berlin calendar day, "YYYY-MM-DD". */
+ date: string;
+ scheduled: number;
+ taken: number;
+}
+
+interface ChartPoint {
+ date: string;
+ rate: number;
+ timestamp: number;
+}
+
+const COLOR_LINE = "var(--dracula-purple)";
+const COLOR_TARGET = "var(--dracula-green)";
+
+/** Days for the range buttons. v1.4.15 keeps this in sync with the
+ * health-chart range presets so the dashboard reads consistently. */
+const RANGE_DAYS = [7, 30, 90] as const;
+type RangeDays = (typeof RANGE_DAYS)[number];
+
+/**
+ * Aggregate daily-compliance points from the `/api/medications/intake`
+ * compliance scope into chart-ready rows. Days without any expected
+ * intakes are skipped — compliance is undefined when nothing was due.
+ *
+ * Pure & deterministic so the unit test pins exact rates.
+ */
+export function aggregateMedicationCompliance(
+ points: DailyCompliancePoint[],
+): ChartPoint[] {
+ return points
+ .filter((p) => p.scheduled > 0)
+ .sort((a, b) => a.date.localeCompare(b.date))
+ .map((p) => {
+ const [y, m, d] = p.date.split("-").map(Number);
+ // Anchor the timestamp at noon UTC of the Berlin day so a tooltip
+ // and tick formatter never disagree across DST boundaries.
+ const ts = Date.UTC(y, m - 1, d, 12);
+ const rate = Math.min(100, Math.round((p.taken / p.scheduled) * 100));
+ return {
+ date: formatDateShort(new Date(ts), true),
+ rate,
+ timestamp: ts,
+ };
+ });
+}
+
+interface MedicationComplianceChartProps {
+ /** Override the visible label, primarily for tests. */
+ title?: string;
+}
+
+export function MedicationComplianceChart({
+ title,
+}: MedicationComplianceChartProps) {
+ const { isAuthenticated } = useAuth();
+ const { t } = useTranslations();
+ const fmt = useFormatters();
+ const [days, setDays] = useState(30);
+
+ const { data, isLoading } = useQuery({
+ queryKey: ["medication-compliance-chart", days],
+ queryFn: async (): Promise => {
+ const res = await fetch(
+ `/api/medications/intake?scope=compliance&days=${days}`,
+ );
+ if (!res.ok) throw new Error("failed to fetch medication compliance");
+ const json = await res.json();
+ return json.data as DailyCompliancePoint[];
+ },
+ enabled: isAuthenticated,
+ });
+
+ const chartData = useMemo(
+ () => (data ? aggregateMedicationCompliance(data) : []),
+ [data],
+ );
+
+ const displayTitle = title ?? t("dashboard.medications");
+ const yAxisFormatter = (value: number) => `${fmt.integer(value)} %`;
+
+ // 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
+ // entirely; here we keep the surface visible because the toggle is
+ // explicit user opt-in — silence would feel like a bug).
+ const hasData = chartData.length > 0;
+
+ return (
+
+
+
+
+ {RANGE_DAYS.map((r) => (
+
+ ))}
+
+
+
+ {isLoading ? (
+
+
+
+ ) : !hasData ? (
+
+ {t("charts.noData")}
+
+ ) : (
+
+
+
+
+
+
+
+ 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,
+ )
+ : ""
+ }
+ />
+
+
+
+
+
+ )}
+
+ );
+}