feat(meds): list-card history icon routes to the new detail page + cascade pin tests

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) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-28 23:54:07 +02:00
1 parent 4ae8801c56
commit 8c740fd146
10 files changed
+209 -4

No files matched your search

+1
View File
@@ -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",
+1
View File
@@ -474,6 +474,7 @@
"scheduleDaily": "Daily",
"inactive": "Paused",
"intakeHistory": "Intake history",
"openDetailPage": "Open medication detail page",
"deleteConfirm": "Delete medication?",
"compliance": "Compliance",
"importIntakes": "Import intakes",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
@@ -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 `<ApiTokensRow>` 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);
});
});
@@ -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(
<MedicationDetailHeader
name={props.name ?? "Test Drug"}
dose={props.dose ?? "5 mg"}
active={props.active ?? true}
endsOn={props.endsOn}
onEdit={() => {}}
/>,
);
}
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("<h1");
const buttonIndex = html.indexOf(
"data-slot=\"medication-detail-edit-button\"",
);
expect(headingIndex).toBeGreaterThan(-1);
expect(buttonIndex).toBeGreaterThan(-1);
expect(headingIndex).toBeLessThan(buttonIndex);
});
it("status pill text always renders (no colour-only state)", () => {
// 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/,
);
});
});
@@ -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. */}
<Button
variant="ghost"
size="icon"
className="min-h-11 min-w-11"
asChild
aria-label={t("medications.intakeHistory")}
aria-label={t("medications.openDetailPage")}
>
<Link href={`/medications/${medication.id}/history`}>
<Link href={`/medications/${medication.id}`}>
<History className="h-4 w-4" />
</Link>
</Button>
@@ -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. */}
<Button
variant="ghost"
size="icon"
className="min-h-11 min-w-11"
asChild
aria-label={t("medications.intakeHistory")}
aria-label={t("medications.openDetailPage")}
>
<Link href={`/medications/${medication.id}/history`}>
<Link href={`/medications/${medication.id}`}>
<History className="h-4 w-4" />
</Link>
</Button>