mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
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:
1 parent
4ae8801c56
commit
8c740fd146
10 files changed
+209
-4
No files matched your search
@@ -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",
|
||||
|
||||
@@ -474,6 +474,7 @@
|
||||
"scheduleDaily": "Daily",
|
||||
"inactive": "Paused",
|
||||
"intakeHistory": "Intake history",
|
||||
"openDetailPage": "Open medication detail page",
|
||||
"deleteConfirm": "Delete medication?",
|
||||
"compliance": "Compliance",
|
||||
"importIntakes": "Import intakes",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user