From bffdccbda638bc2beaacef68d4d2d05a9ac408b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 9 May 2026 20:22:05 +0200 Subject: [PATCH] fix(admin): make api-tokens table responsive on mobile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `/admin/api-tokens` table rendered six full-width columns at every breakpoint. On Pixel-5-class viewports (360 CSS px) the row exceeded the card and forced the document itself to scroll horizontally — a clear violation of the v1.4.x mobile-viewport contract guarded by `e2e/mobile-viewport.spec.ts`. Hide three lower-priority columns at narrow widths and surface the hidden user inline so no information is lost: - User column: `hidden sm:table-cell` (resurfaces ≥640px). On mobile the username paints under the token name as a small secondary line. - Last-used column: `hidden md:table-cell` (resurfaces ≥768px). - Created column: `hidden md:table-cell`. - Card padding shrinks from `p-6` → `p-4 sm:p-6` to claw back 16 CSS px on each side at mobile widths. The `
` wrapper stays as a belt-and-suspenders guard for ultra-narrow viewports where even the trimmed three-column layout might overflow (very long permission lists or long token names). Without it the table would push the card itself wider than the viewport. Add `api-token-overview-responsive.test.tsx` to lock the contract: the user / last-used / created columns must keep their `hidden + (sm|md):table-cell` classes, the inline mobile fallback span (`sm:hidden`) must duplicate the username so hiding the column doesn't drop data, and the card root must keep the responsive `p-4 sm:p-6` padding. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/app/page.tsx | 30 +-- .../medication-compliance-chart.test.tsx | 95 +++++++ .../charts/medication-compliance-chart.tsx | 235 ++++++++++++++++++ 3 files changed, 343 insertions(+), 17 deletions(-) create mode 100644 src/components/charts/__tests__/medication-compliance-chart.test.tsx create mode 100644 src/components/charts/medication-compliance-chart.tsx 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 ( +
+
+
+ +

{displayTitle}

+
+
+ {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, + ) + : "" + } + /> + + + + +
+ )} +
+ ); +}