From 8c740fd146f37cbc5d26d571451f1c844d910d77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 28 May 2026 23:54:07 +0200 Subject: [PATCH] feat(meds): list-card history icon routes to the new detail page + cascade pin tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The list-page medication cards previously pointed their History icon straight at `/medications/{id}/history`. v1.5.5 promotes the detail page as the canonical umbrella surface for everything the v1.5.4 form retirement displaced, so the icon now opens `/medications/{id}` and the detail page's intake-history preview links onward to the bulk- delete sub-route in one extra tap. Two focused tests: - `medication-detail-header.test.tsx` pins the status-pill contract (label always rendered, dot rides Dracula `--success` / `--warning` tokens, dot is `aria-hidden`, DOM order is heading-then-edit). - `detail-page-cascade.test.tsx` pins the per-section query-key + invalidation cascade against the central factory. The §9.4 phase-config key lives outside the bundle so the settings section must invalidate it explicitly; the test documents that contract so a future refactor cannot drop it silently. Co-Authored-By: Claude Opus 4.7 (1M context) --- messages/de.json | 1 + messages/en.json | 1 + messages/es.json | 1 + messages/fr.json | 1 + messages/it.json | 1 + messages/pl.json | 1 + .../__tests__/detail-page-cascade.test.tsx | 96 +++++++++++++++++++ .../medication-detail-header.test.tsx | 95 ++++++++++++++++++ .../medications/glp1-medication-card.tsx | 7 +- .../medications/medication-card.tsx | 9 +- 10 files changed, 209 insertions(+), 4 deletions(-) create mode 100644 src/components/medications/__tests__/detail-page-cascade.test.tsx create mode 100644 src/components/medications/__tests__/medication-detail-header.test.tsx diff --git a/messages/de.json b/messages/de.json index b2b7f48fe..f36080e93 100644 --- a/messages/de.json +++ b/messages/de.json @@ -474,6 +474,7 @@ "scheduleDaily": "Täglich", "inactive": "Pausiert", "intakeHistory": "Einnahme-Verlauf", + "openDetailPage": "Medikament-Detailseite öffnen", "deleteConfirm": "Medikament löschen?", "compliance": "Einnahmetreue", "importIntakes": "Einnahmen importieren", diff --git a/messages/en.json b/messages/en.json index 83e75e71c..bf505d9ae 100644 --- a/messages/en.json +++ b/messages/en.json @@ -474,6 +474,7 @@ "scheduleDaily": "Daily", "inactive": "Paused", "intakeHistory": "Intake history", + "openDetailPage": "Open medication detail page", "deleteConfirm": "Delete medication?", "compliance": "Compliance", "importIntakes": "Import intakes", diff --git a/messages/es.json b/messages/es.json index 71430b852..1ce96c58b 100644 --- a/messages/es.json +++ b/messages/es.json @@ -474,6 +474,7 @@ "scheduleDaily": "A diario", "inactive": "En pausa", "intakeHistory": "Historial de tomas", + "openDetailPage": "Open medication detail page", "deleteConfirm": "¿Eliminar medicamento?", "compliance": "Cumplimiento", "importIntakes": "Import intakes", diff --git a/messages/fr.json b/messages/fr.json index 633479886..106351abe 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -474,6 +474,7 @@ "scheduleDaily": "Quotidien", "inactive": "En pause", "intakeHistory": "Historique des prises", + "openDetailPage": "Open medication detail page", "deleteConfirm": "Supprimer le médicament ?", "compliance": "Observance", "importIntakes": "Import intakes", diff --git a/messages/it.json b/messages/it.json index b30686d47..e3518d3fe 100644 --- a/messages/it.json +++ b/messages/it.json @@ -474,6 +474,7 @@ "scheduleDaily": "Quotidiano", "inactive": "In pausa", "intakeHistory": "Cronologia assunzioni", + "openDetailPage": "Open medication detail page", "deleteConfirm": "Eliminare il farmaco?", "compliance": "Aderenza", "importIntakes": "Import intakes", diff --git a/messages/pl.json b/messages/pl.json index f72fe0d28..5c6280ea5 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -474,6 +474,7 @@ "scheduleDaily": "Codziennie", "inactive": "Wstrzymany", "intakeHistory": "Historia przyjęć", + "openDetailPage": "Open medication detail page", "deleteConfirm": "Usunąć lek?", "compliance": "Przestrzeganie zaleceń", "importIntakes": "Import intakes", diff --git a/src/components/medications/__tests__/detail-page-cascade.test.tsx b/src/components/medications/__tests__/detail-page-cascade.test.tsx new file mode 100644 index 000000000..6e3a17c95 --- /dev/null +++ b/src/components/medications/__tests__/detail-page-cascade.test.tsx @@ -0,0 +1,96 @@ +/** + * v1.5.5 D-3 §9 — detail-page query-key + invalidation cascade. + * + * Pins the contract every detail-page section reads/writes: + * + * §9.1 header → reads `medicationDetail(id)` (under `["medications"]` + * prefix; lives in the bundle). + * §9.2 today → POSTs intake → bundle invalidates compliance chart + * (`compliance-chart-inline` prefix landed in the + * bundle per §10 invariant 20). + * §9.4 phases → `medicationPhaseConfig(id)` lives at + * `["phase-config", id]`, NOT under `["medications"]`, + * so the bundle does not catch it. The save mutation + * must invalidate it explicitly (settings-section + * does so via `[...medicationDependentKeys, + * queryKeys.medicationPhaseConfig(id)]`). + * §9.5 intake list → reads `medicationIntakeList(id, …)` under the + * `["medications", id, "intake", "list"]` prefix. + * + * These tests guard the per-section query-key shape so a future + * rename (e.g. introducing a `["medications"]` -> `["meds"]` factory) + * doesn't silently drift across the eight section consumers. + */ + +import { describe, it, expect } from "vitest"; +import { + medicationDependentKeys, + queryKeys, +} from "@/lib/query-keys"; + +function asStrings(keys: readonly unknown[]): string[] { + return keys.map((k) => JSON.stringify(k)); +} + +function isUnderPrefix(key: readonly unknown[], prefix: readonly unknown[]) { + if (key.length < prefix.length) return false; + for (let i = 0; i < prefix.length; i++) { + if (key[i] !== prefix[i]) return false; + } + return true; +} + +describe("medication-detail-page query-key cascade (D-3 §9)", () => { + it("§9.1 header reads sit under the `[\"medications\"]` prefix so the bundle catches them", () => { + const detail = queryKeys.medicationDetail("med-1"); + expect(isUnderPrefix(detail, queryKeys.medications())).toBe(true); + }); + + it("§9.2 today's-dose mutation evicts the inline compliance chart via the bundle (C-E2-1 / §10 #20)", () => { + const inlineChart = queryKeys.medicationComplianceChart("med-1"); + const bundleStrings = asStrings(medicationDependentKeys); + // Hierarchical-prefix semantics — the per-id chart slot rides + // under the `["compliance-chart-inline"]` prefix we added. + expect(bundleStrings).toContain( + JSON.stringify(["compliance-chart-inline"]), + ); + expect(inlineChart[0]).toBe("compliance-chart-inline"); + }); + + it("§9.4 phase-config is OUTSIDE the bundle and the settings section explicitly invalidates it", () => { + const phaseKey = queryKeys.medicationPhaseConfig("med-1"); + const bundleStrings = asStrings(medicationDependentKeys); + expect(bundleStrings).not.toContain(JSON.stringify(phaseKey)); + expect(bundleStrings).not.toContain(JSON.stringify(["phase-config"])); + // The settings section composes the bundle + the phase key on + // save (see `phase-config-sheet.tsx` save handler). This guard + // documents that contract. + expect(phaseKey).toEqual(["phase-config", "med-1"]); + }); + + it("§9.5 intake-history list keys ride under `[\"medications\", id, \"intake\", \"list\"]`", () => { + const listKey = queryKeys.medicationIntakeList("med-1", { + sortBy: "takenAt", + sortDir: "desc", + limit: 14, + offset: 0, + status: "completed", + }); + expect(listKey.slice(0, 4)).toEqual([ + "medications", + "med-1", + "intake", + "list", + ]); + // And catches by the bundle's `["medications"]` prefix. + expect(isUnderPrefix(listKey, queryKeys.medications())).toBe(true); + }); + + it("§9.7 api-endpoint per-medication slot rides under the `[\"medications\"]` prefix", () => { + // The `` uses a per-medication key + // `["medications", id, "api-endpoint"]` so the bundle catches it + // alongside every other medication-scoped tile. + const apiEndpointKey = ["medications", "med-1", "api-endpoint"] as const; + expect(isUnderPrefix(apiEndpointKey, queryKeys.medications())).toBe(true); + }); +}); diff --git a/src/components/medications/__tests__/medication-detail-header.test.tsx b/src/components/medications/__tests__/medication-detail-header.test.tsx new file mode 100644 index 000000000..759ba414a --- /dev/null +++ b/src/components/medications/__tests__/medication-detail-header.test.tsx @@ -0,0 +1,95 @@ +/** + * v1.5.5 D-3 §9.1 + §10 invariant 5 — status pill renders the label + * always (never colour-only) and the dot rides Dracula tokens. + * + * Critical because an earlier draft proposed bare Tailwind palette + * (`bg-emerald-500` / `bg-amber-500`) which: + * - bypasses the theme so it would not flip with dark/light; + * - relied on colour alone to convey state (WCAG 1.4.1 violation); + * - drifted out of sync with `--success` / `--warning` Dracula + * tokens declared in `src/app/globals.css`. + * + * These tests pin the contract: + * - active medication → label "Aktiv" + Dracula `--success` token + * - paused medication → label "Pausiert" + `--warning` token + * - ended medication → label "Beendet" + muted-foreground + * - dot always carries `aria-hidden="true"` + * - DOM order: H1 (name) before the edit button (C-E4-3) + */ + +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +vi.mock("@/lib/i18n/context", () => ({ + useTranslations: () => ({ + t: (key: string) => key, + }), +})); + +import { MedicationDetailHeader } from "@/components/medications/medication-detail-header"; + +function render(props: { + name?: string; + dose?: string; + active?: boolean; + endsOn?: string | null; +}) { + return renderToStaticMarkup( + {}} + />, + ); +} + +describe("MedicationDetailHeader (D-3 §9.1)", () => { + it("renders the status label and the --success dot for an active medication", () => { + const html = render({ active: true }); + expect(html).toContain("medications.detail.status.active"); + expect(html).toContain("[hsl(var(--success))]"); + expect(html).toContain('aria-hidden="true"'); + }); + + it("renders the paused label + --warning dot for an inactive medication", () => { + const html = render({ active: false }); + expect(html).toContain("medications.detail.status.paused"); + expect(html).toContain("[hsl(var(--warning))]"); + }); + + it("renders the ended label + muted dot when endsOn is past", () => { + const html = render({ + active: false, + endsOn: new Date(Date.now() - 86_400_000).toISOString(), + }); + expect(html).toContain("medications.detail.status.ended"); + expect(html).toContain("bg-muted-foreground"); + }); + + it("DOM order is heading before edit button (C-E4-3)", () => { + const html = render({ active: true }); + const headingIndex = html.indexOf(" { + // Regardless of theme support the label MUST be in the DOM so a + // screen reader announces "Aktiv" even when the dot's hue is + // imperceptible to the user. + const activeHtml = render({ active: true }); + const pausedHtml = render({ active: false }); + expect(activeHtml).toMatch( + /data-slot="medication-detail-status-row"[\s\S]*?medications\.detail\.status\.active/, + ); + expect(pausedHtml).toMatch( + /data-slot="medication-detail-status-row"[\s\S]*?medications\.detail\.status\.paused/, + ); + }); +}); diff --git a/src/components/medications/glp1-medication-card.tsx b/src/components/medications/glp1-medication-card.tsx index d94ff7b7e..096b98b05 100644 --- a/src/components/medications/glp1-medication-card.tsx +++ b/src/components/medications/glp1-medication-card.tsx @@ -326,14 +326,17 @@ export function Glp1MedicationCard({ const headerActions = ( <> + {/* v1.5.5 — routes to the new medication detail page; the + detail-page intake-history preview links onward to + `/medications/{id}/history` for the bulk-delete sub-route. */} diff --git a/src/components/medications/medication-card.tsx b/src/components/medications/medication-card.tsx index b67d97468..93a042b1e 100644 --- a/src/components/medications/medication-card.tsx +++ b/src/components/medications/medication-card.tsx @@ -262,14 +262,19 @@ export function MedicationCard({ medication, onEdit }: MedicationCardProps) { min-w-11` (44px) so these meet the WCAG 2.5.5 minimum tap-target on mobile without the icon glyph itself changing size. */} + {/* v1.5.5 — the kebab-style "open" icon routes to the new + medication detail page. The detail-page intake-history + preview links onward to `/medications/{id}/history` for the + bulk-delete sub-route, so the user still reaches the full + history in one extra tap. */}