feat(medications): a sortable table view with an order you choose

The medications page gains a compact table beside the card view — one
row per medication with the same served-due status pill, next-intake
line, cadence-scaled adherence bars, dose-derived stock and the take
and skip actions the cards use, all from the payloads the cards
already read; the list response aggregates stock in the same round
trip. Columns sort tri-state with nulls last, the default order is
yours to arrange — the same drag dialog the dashboard and insights
use, applied to both views — and the chosen view and order persist
per user on their own settings column with field-scoped writes, so
toggling the view never touches the order. Inactive medications pin
to the end, the first column stays put under horizontal scroll, and
the loading skeleton follows the view you left.
This commit is contained in:
Marc-André Bombeck committed 2026-06-12 18:10:21 +02:00
1 parent d142a6ed31
commit e21dfd040e
31 files changed
+3382 -65

No files matched your search

@@ -0,0 +1,439 @@
/**
* v1.16.10 — the compact /medications table view.
*
* Pins:
* - real `<table>` semantics with `scope="col"` headers and a sticky
* first column (the responsive horizontal-scroll pattern);
* - every column renders from the SAME payloads the cards consume
* (the list row's `nextDueAt` / `stockDosesRemaining`, the batched
* compliance-summary cache entry the cards share);
* - the action buttons are per-row labelled ("Take – {name}") and the
* row rides the cards' shared intake hook (source-level guard);
* - tri-state sorting semantics incl. `aria-sort` and null-last
* ordering, with `null` sort = the manual order the page passed in;
* - inactive rows pin after the active block, muted, without actions.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
import {
MedicationTable,
MedicationTableSkeleton,
nextSortState,
sortMedicationRows,
type TableMedication,
} from "@/components/medications/medication-table";
function makeClient(): QueryClient {
return new QueryClient({
defaultOptions: {
queries: { retry: false, gcTime: 0, staleTime: Infinity },
},
});
}
function render(node: React.ReactNode, client: QueryClient) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<QueryClientProvider client={client}>{node}</QueryClientProvider>
</I18nProvider>,
);
}
// Seed the SAME batched summary key the cards read — the table's
// compliance column must come from this shared cache entry.
function seedCompliance(
client: QueryClient,
medId: string,
rate7: number,
rate30: number,
) {
const key = ["medications", "compliance-summary"];
const existing =
(client.getQueryData(key) as Array<{ medicationId: string }>) ?? [];
client.setQueryData(key, [
...existing.filter((row) => row.medicationId !== medId),
{
medicationId: medId,
compliance7: { rate: rate7, streak: 0 },
compliance30: { rate: rate30 },
},
]);
}
// A schedule whose window has already passed for today (01:00–02:00) so
// the row shows the next-intake value rather than the take-now pill,
// regardless of host clock.
const pastWindow = {
windowStart: "01:00",
windowEnd: "02:00",
label: null,
daysOfWeek: null,
dose: null,
};
function med(partial: Partial<TableMedication> & { id: string; name: string }): TableMedication {
return {
dose: "5 mg",
active: true,
pausedAt: null,
lastTakenAt: null,
todayEventCount: 0,
nextDueAt: null,
nextDueOverdue: false,
stockDosesRemaining: null,
schedules: [{ id: `s-${partial.id}`, ...pastWindow }],
...partial,
};
}
beforeEach(() => {
vi.useFakeTimers({ toFake: ["Date"] });
vi.setSystemTime(new Date("2026-06-02T12:00:00Z"));
});
afterEach(() => {
vi.useRealTimers();
});
function tomorrowNoonIso(): string {
const due = new Date();
due.setDate(due.getDate() + 1);
due.setHours(12, 0, 0, 0);
return due.toISOString();
}
describe("<MedicationTable> — structure + shared payloads", () => {
it("renders real table semantics: scope=col headers, sticky first column, sr caption", () => {
const client = makeClient();
const html = render(
<MedicationTable
activeMedications={[med({ id: "m1", name: "Ramipril" })]}
inactiveMedications={[]}
/>,
client,
);
expect(html).toContain("<table");
expect((html.match(/scope="col"/g) ?? []).length).toBe(6);
// All six column headers, localised.
for (const label of [
"Name",
"Status",
"Next dose",
"Adherence",
"Stock",
"Actions",
]) {
expect(html).toContain(label);
}
// Sticky first column for the horizontal-scroll mobile pattern.
expect(html).toContain("sticky left-0");
// Screen-reader caption names the table.
expect(html).toContain("Medications as a table");
});
it("renders one row per medication from the list payload: name, dose, next due, stock", () => {
const client = makeClient();
seedCompliance(client, "m1", 90, 88);
const html = render(
<MedicationTable
activeMedications={[
med({
id: "m1",
name: "Ramipril",
dose: "5 mg",
nextDueAt: tomorrowNoonIso(),
stockDosesRemaining: 12,
}),
]}
inactiveMedications={[]}
/>,
client,
);
expect(html).toContain("Ramipril");
expect(html).toContain("5 mg");
// Server-computed next due — the same `nextDueAt` the cards render.
expect(html).toContain("Tomorrow,");
// Dose-derived stock from the list payload.
expect(html).toContain("12 doses");
});
it("renders the compliance column from the cards' shared batched summary cache", () => {
const client = makeClient();
seedCompliance(client, "m1", 92, 87);
const html = render(
<MedicationTable
activeMedications={[med({ id: "m1", name: "Ramipril" })]}
inactiveMedications={[]}
/>,
client,
);
expect(html).toContain("92%");
expect(html).toContain("87%");
// The cadence-window labels (7-/30-day defaults).
expect(html).toContain("7 d");
expect(html).toContain("30 d");
});
it("labels the action buttons per row and keeps the 44px tap floor", () => {
const client = makeClient();
seedCompliance(client, "m1", 90, 88);
const html = render(
<MedicationTable
activeMedications={[med({ id: "m1", name: "Ramipril" })]}
inactiveMedications={[]}
/>,
client,
);
expect(html).toContain('aria-label="Take – Ramipril"');
expect(html).toContain('aria-label="Skip – Ramipril"');
// 44-px mobile floor on the action buttons (shrinks at sm).
expect(html).toContain("size-11 sm:size-9");
});
it("hides stock for an untracked medication and tints a low stock", () => {
const client = makeClient();
seedCompliance(client, "m1", 90, 88);
seedCompliance(client, "m2", 90, 88);
const html = render(
<MedicationTable
activeMedications={[
med({ id: "m1", name: "Aspirin", stockDosesRemaining: null }),
med({ id: "m2", name: "Mounjaro", stockDosesRemaining: 2 }),
]}
inactiveMedications={[]}
/>,
client,
);
expect(html).toContain("2 doses");
expect(html).toContain("text-warning");
});
it("pins inactive rows after the active block, muted and without actions", () => {
const client = makeClient();
seedCompliance(client, "m1", 90, 88);
const html = render(
<MedicationTable
activeMedications={[med({ id: "m1", name: "Zyrtec" })]}
inactiveMedications={[
med({ id: "m2", name: "Amoxicillin", active: false }),
]}
/>,
client,
);
// Active row first despite alphabetical order saying otherwise.
expect(html.indexOf("Zyrtec")).toBeLessThan(html.indexOf("Amoxicillin"));
expect(html).toContain("opacity-60");
expect(html).toContain("Inactive");
// No take/skip affordance on the inactive row.
expect(html).not.toContain('aria-label="Take – Amoxicillin"');
});
it("renders a GLP-1 medication like any other row", () => {
const client = makeClient();
seedCompliance(client, "g1", 100, 100);
const html = render(
<MedicationTable
activeMedications={[
med({
id: "g1",
name: "Mounjaro",
treatmentClass: "GLP1",
nextDueAt: tomorrowNoonIso(),
stockDosesRemaining: 4,
}),
]}
inactiveMedications={[]}
/>,
client,
);
expect(html).toContain("Mounjaro");
expect(html).toContain('aria-label="Take – Mounjaro"');
expect(html).toContain("4 doses");
});
});
describe("<MedicationTable> — manual order + aria-sort", () => {
const rows = [
med({ id: "m1", name: "Ramipril", nextDueAt: tomorrowNoonIso() }),
med({ id: "m2", name: "Aspirin", nextDueAt: null }),
];
it("renders rows in the order the page passed in when no sort is active (manual order)", () => {
const client = makeClient();
const html = render(
<MedicationTable activeMedications={rows} inactiveMedications={[]} />,
client,
);
// The page already applied the manual order; with sort=null the
// table must not re-sort (Ramipril stays before Aspirin).
expect(html.indexOf("Ramipril")).toBeLessThan(html.indexOf("Aspirin"));
// Every sortable header is unsorted.
expect((html.match(/aria-sort="none"/g) ?? []).length).toBe(4);
});
it("sorts by name ascending with the matching aria-sort", () => {
const client = makeClient();
const html = render(
<MedicationTable
activeMedications={rows}
inactiveMedications={[]}
initialSort={{ column: "name", direction: "asc" }}
/>,
client,
);
expect(html.indexOf("Aspirin")).toBeLessThan(html.indexOf("Ramipril"));
expect(html).toContain('aria-sort="ascending"');
});
it("sorts by name descending with the matching aria-sort", () => {
const client = makeClient();
const html = render(
<MedicationTable
activeMedications={rows}
inactiveMedications={[]}
initialSort={{ column: "name", direction: "desc" }}
/>,
client,
);
expect(html.indexOf("Ramipril")).toBeLessThan(html.indexOf("Aspirin"));
expect(html).toContain('aria-sort="descending"');
});
it("names the sort affordance on every sortable header", () => {
const client = makeClient();
const html = render(
<MedicationTable activeMedications={rows} inactiveMedications={[]} />,
client,
);
expect(html).toContain('aria-label="Sort by Name"');
expect(html).toContain('aria-label="Sort by Next dose"');
expect(html).toContain('aria-label="Sort by Adherence"');
expect(html).toContain('aria-label="Sort by Stock"');
});
});
describe("nextSortState — tri-state cycle", () => {
it("cycles none → asc → desc → none per column", () => {
const asc = nextSortState(null, "name");
expect(asc).toEqual({ column: "name", direction: "asc" });
const desc = nextSortState(asc, "name");
expect(desc).toEqual({ column: "name", direction: "desc" });
expect(nextSortState(desc, "name")).toBeNull();
});
it("switching column resets to ascending", () => {
expect(
nextSortState({ column: "name", direction: "desc" }, "nextDue"),
).toEqual({ column: "nextDue", direction: "asc" });
});
});
describe("sortMedicationRows — null-last semantics", () => {
const a = med({ id: "a", name: "Alpha", nextDueAt: null, stockDosesRemaining: null });
const b = med({
id: "b",
name: "Beta",
nextDueAt: "2026-06-03T10:00:00Z",
stockDosesRemaining: 2,
});
const c = med({
id: "c",
name: "Gamma",
nextDueAt: "2026-06-02T18:00:00Z",
stockDosesRemaining: 9,
});
it("returns the input order untouched for a null sort", () => {
expect(
sortMedicationRows([b, a, c], null).map((m) => m.id),
).toEqual(["b", "a", "c"]);
});
it("sorts by next due ascending, rows without a due date last", () => {
expect(
sortMedicationRows([a, b, c], {
column: "nextDue",
direction: "asc",
}).map((m) => m.id),
).toEqual(["c", "b", "a"]);
});
it("keeps rows without a due date last in descending order too", () => {
expect(
sortMedicationRows([a, b, c], {
column: "nextDue",
direction: "desc",
}).map((m) => m.id),
).toEqual(["b", "c", "a"]);
});
it("sorts by compliance via the shared summary rates, unknown rows last", () => {
const rates = new Map([
["b", 50],
["c", 95],
]);
expect(
sortMedicationRows(
[a, b, c],
{ column: "compliance", direction: "desc" },
rates,
).map((m) => m.id),
).toEqual(["c", "b", "a"]);
});
it("sorts by stock, untracked medications last", () => {
expect(
sortMedicationRows([a, b, c], {
column: "stock",
direction: "asc",
}).map((m) => m.id),
).toEqual(["b", "c", "a"]);
});
});
describe("<MedicationTableSkeleton> — reserved footprint", () => {
it("mirrors the loaded table shell (container, table, action slots) and hides from AT", () => {
const html = renderToStaticMarkup(<MedicationTableSkeleton />);
expect(html).toContain('aria-hidden="true"');
expect(html).toContain("<table");
expect(html).toContain("size-11");
});
});
describe("medication-table — shared mutation/status sources (source guard)", () => {
const source = readFileSync(
join(process.cwd(), "src/components/medications/medication-table.tsx"),
"utf8",
);
it("rides the cards' shared intake hook instead of its own POST", () => {
expect(source).toContain(
'from "@/components/medications/use-medication-intake"',
);
expect(source).not.toContain("/intake");
});
it("reads compliance through the cards' batched summary hook", () => {
expect(source).toContain("useMedicationComplianceSummary");
});
it("gates the status pill on the server next-due exactly like the cards", () => {
expect(source).toContain("reduceCurrentWindowStatus");
expect(source).toContain("nextDue:");
expect(source).toContain("resolveDisplayedSlotInstant");
});
});
@@ -19,6 +19,13 @@ interface MedicationStatusPillProps {
* local days since that take.
*/
takenEarlyDaysAgo?: number | null;
/**
* v1.16.10 — compact variant for the table view's Status column:
* suppresses the trailing window-range span so the cell stays one
* short phrase. Tier logic, colours and glyphs are byte-identical
* with the card pill — only the suffix is dropped.
*/
compact?: boolean;
}
/**
@@ -34,6 +41,7 @@ export function MedicationStatusPill({
windowStart,
windowEnd,
takenEarlyDaysAgo = null,
compact = false,
}: MedicationStatusPillProps) {
const { t, locale } = useTranslations();
@@ -46,10 +54,12 @@ export function MedicationStatusPill({
? t("medications.lastDoseYesterday")
: t("medications.lastDoseDaysAgo", { count: takenEarlyDaysAgo })}
</span>
<span className="text-muted-foreground hidden sm:inline">
{" "}
— {formatTimeWindowRange(windowStart, windowEnd, locale)}
</span>
{!compact && (
<span className="text-muted-foreground hidden sm:inline">
{" "}
— {formatTimeWindowRange(windowStart, windowEnd, locale)}
</span>
)}
</p>
);
}
@@ -85,10 +95,12 @@ export function MedicationStatusPill({
? t("medications.overdue")
: t("medications.veryOverdue")}
</span>
<span className="text-muted-foreground hidden sm:inline">
{" "}
— {formatTimeWindowRange(windowStart, windowEnd, locale)}
</span>
{!compact && (
<span className="text-muted-foreground hidden sm:inline">
{" "}
— {formatTimeWindowRange(windowStart, windowEnd, locale)}
</span>
)}
</p>
);
}
@@ -0,0 +1,294 @@
"use client";
import { useId, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { ArrowDown, ArrowUp, GripVertical, Loader2 } from "lucide-react";
import {
DndContext,
KeyboardSensor,
PointerSensor,
closestCenter,
useSensor,
useSensors,
type DragEndEvent,
} from "@dnd-kit/core";
import {
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { reorderById } from "@/lib/insights-layout-reorder";
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
import { useTranslations } from "@/lib/i18n/context";
import { runSaveMedicationListOrder } from "@/lib/queries/use-medication-list-layout";
/**
* v1.16.10 — manual medication ordering, shared by BOTH /medications
* views (cards + table). Reuses the dashboard / insights reorder
* vocabulary: a vertical row list with a drag handle (@dnd-kit, pointer
* + keyboard sensors) AND per-row up/down arrow buttons as the primary
* keyboard surface, flushed by an explicit Save. The swap-and-renumber
* logic is the shared `reorderById` helper the inline Insights edit
* mode rides (the standalone extraction of the dashboard's
* `reorderWidgets` contract), so the surfaces cannot drift.
*
* The dialog edits a draft of the full medication list (active first,
* inactive after — matching the page's render grouping); Save persists
* the id order through `PUT /api/medications/layout` (order-only, the
* stored view is preserved server-side).
*/
export interface ReorderMedication {
id: string;
name: string;
dose: string;
active: boolean;
}
interface MedicationReorderDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** The full list in its current effective order (active block first). */
medications: ReorderMedication[];
}
export function MedicationReorderDialog({
open,
onOpenChange,
medications,
}: MedicationReorderDialogProps) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const dragHintId = useId();
const [saving, setSaving] = useState(false);
// Draft id order — null means "the order the page passed in". Created
// on the first move so an untouched dialog can cancel without churn.
const [draft, setDraft] = useState<string[] | null>(null);
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: { distance: 6 },
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
);
const byId = new Map(medications.map((m) => [m.id, m]));
const ids =
draft ?? medications.map((m) => m.id);
const rows = ids
.map((id) => byId.get(id))
.filter((m): m is ReorderMedication => m !== undefined);
function applyReorder(fromId: string, toId: string) {
const reordered = reorderById(
ids.map((id, order) => ({ id, order })),
fromId,
toId,
);
setDraft(reordered.map((r) => r.id));
}
function move(id: string, delta: -1 | 1) {
const idx = ids.indexOf(id);
const targetIdx = idx + delta;
if (idx < 0 || targetIdx < 0 || targetIdx >= ids.length) return;
applyReorder(id, ids[targetIdx]);
}
function handleDragEnd(event: DragEndEvent) {
const { active, over } = event;
if (!over || active.id === over.id) return;
applyReorder(String(active.id), String(over.id));
}
async function save() {
setSaving(true);
const ok = await runSaveMedicationListOrder({
order: ids,
queryClient,
t,
});
setSaving(false);
if (ok) {
setDraft(null);
onOpenChange(false);
}
}
function close(next: boolean) {
if (!next) setDraft(null);
onOpenChange(next);
}
return (
<Dialog open={open} onOpenChange={close}>
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-md">
<DialogHeader>
<DialogTitle>{t("medications.reorderTitle")}</DialogTitle>
<DialogDescription>
{t("medications.reorderDescription")}
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext items={ids} strategy={verticalListSortingStrategy}>
{rows.map((med, index) => (
<SortableMedicationRow
key={med.id}
medication={med}
index={index}
total={rows.length}
dragHintId={dragHintId}
disabled={saving}
labels={{
moveUp: t("dashboard.moveUp"),
moveDown: t("dashboard.moveDown"),
dragHandle: t("dashboard.dragHandle"),
inactive: t("common.inactive"),
}}
onMove={move}
/>
))}
</SortableContext>
</DndContext>
{rows.length > 0 && (
<p id={dragHintId} className="text-muted-foreground sr-only">
{t("dashboard.dragHandleHint")}
</p>
)}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => close(false)}
disabled={saving}
>
{t("common.cancel")}
</Button>
<Button onClick={() => void save()} disabled={saving || !draft}>
{saving && (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("common.save")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
interface SortableMedicationRowProps {
medication: ReorderMedication;
index: number;
total: number;
dragHintId: string;
disabled: boolean;
labels: {
moveUp: string;
moveDown: string;
dragHandle: string;
inactive: string;
};
onMove: (id: string, delta: -1 | 1) => void;
}
function SortableMedicationRow({
medication,
index,
total,
dragHintId,
disabled,
labels,
onMove,
}: SortableMedicationRowProps) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: medication.id });
const style: React.CSSProperties = {
transform: CSS.Transform.toString(transform),
transition: prefersReducedMotion() ? "none" : transition,
};
return (
<div
ref={setNodeRef}
style={style}
data-slot="medication-reorder-row"
data-dragging={isDragging ? "true" : undefined}
className={`border-border bg-background/30 flex min-h-12 items-center gap-2 rounded-md border px-3 py-2 ${
isDragging ? "ring-primary z-10 opacity-90 shadow-lg ring-2" : ""
}`}
>
<button
type="button"
{...attributes}
{...listeners}
aria-label={`${labels.dragHandle} — ${medication.name}`}
aria-describedby={dragHintId}
title={labels.dragHandle}
disabled={disabled}
data-slot="medication-drag-handle"
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring focus-visible:ring-offset-background relative -m-1 inline-flex h-7 w-7 cursor-grab touch-none items-center justify-center rounded transition-colors before:absolute before:inset-[-8px] before:content-[''] focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none active:cursor-grabbing disabled:cursor-not-allowed disabled:opacity-50 motion-reduce:transition-none"
>
<GripVertical className="h-4 w-4" />
</button>
<span className="min-w-0 flex-1 text-sm">
<span className="block truncate font-medium" title={medication.name}>
{medication.name}
</span>
<span className="text-muted-foreground block truncate text-xs">
{medication.dose}
{!medication.active && <> · {labels.inactive}</>}
</span>
</span>
<Button
type="button"
variant="ghost"
size="icon"
className="size-11 sm:size-9"
onClick={() => onMove(medication.id, -1)}
disabled={index === 0 || disabled}
aria-label={`${labels.moveUp} — ${medication.name}`}
>
<ArrowUp className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="size-11 sm:size-9"
onClick={() => onMove(medication.id, 1)}
disabled={index === total - 1 || disabled}
aria-label={`${labels.moveDown} — ${medication.name}`}
>
<ArrowDown className="h-4 w-4" />
</Button>
</div>
);
}
@@ -0,0 +1,678 @@
"use client";
import { useEffect, useReducer, useState } from "react";
import Link from "next/link";
import { useQuery } from "@tanstack/react-query";
import {
ArrowDown,
ArrowUp,
Check,
ChevronsUpDown,
Loader2,
SkipForward,
} from "lucide-react";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
import { Skeleton } from "@/components/ui/skeleton";
import { MedicationStatusPill } from "@/components/medications/card-parts/medication-status-pill";
import { useWeekdayLabel } from "@/components/medications/card-parts/medication-next-last-slot";
import { resolveDisplayedSlotInstant } from "@/components/medications/card-parts/displayed-slot-instant";
import { useMedicationIntake } from "@/components/medications/use-medication-intake";
import {
reduceCurrentWindowStatus,
toZonedDate,
} from "@/lib/medications/window-status";
import { formatTime } from "@/lib/format";
import { formatTimeWindowRange } from "@/lib/time-window-format";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { apiGet } from "@/lib/api/api-fetch";
import {
useMedicationComplianceSummary,
useMedicationComplianceSummaryAll,
} from "@/lib/queries/use-medication-compliance-summary";
import { useAuth } from "@/hooks/use-auth";
import { cn } from "@/lib/utils";
/**
* v1.16.10 — the compact table view for /medications (issue #316 item 3).
*
* One row per medication, the same data sources as the cards by
* construction: the rows render straight off the `GET /api/medications`
* list payload (next-due, stock), the Therapietreue column reads the
* SAME batched compliance summary the cards share
* (`useMedicationComplianceSummary`), the status cell mirrors the card
* body's precedence over the SAME `reduceCurrentWindowStatus` /
* server-`nextDueAt` gate, and the action buttons fire the SAME
* `useMedicationIntake` mutation hook with the same displayed-slot
* instant. Nothing here recomputes dueness beyond what the cards do.
*
* Responsive: horizontal scroll (the shared `<Table>` wrapper) with a
* sticky first column so the medication name stays anchored while the
* row scrolls. Action buttons keep the 44-px tap-target floor.
*/
interface TableSchedule {
id: string;
windowStart: string;
windowEnd: string;
label: string | null;
dose: string | null;
daysOfWeek: string | null;
timesOfDay?: string[];
doseWindows?: { timeOfDay: string; start: string; end: string }[] | null;
}
export interface TableMedication {
id: string;
name: string;
dose: string;
treatmentClass?: string;
active: boolean;
pausedAt: string | null;
lastTakenAt: string | null;
todayEventCount?: number;
nextDueAt?: string | null;
nextDueOverdue?: boolean;
/** v1.16.10 — dose-derived stock from the list payload; null = inventory tracking off. */
stockDosesRemaining?: number | null;
schedules: TableSchedule[];
}
export type MedicationTableSortColumn =
| "name"
| "nextDue"
| "compliance"
| "stock";
export interface MedicationTableSort {
column: MedicationTableSortColumn;
direction: "asc" | "desc";
}
/**
* Tri-state header click cycle: none → ascending → descending → none.
* `null` = the manual (user-defined) order the page passed in.
*/
export function nextSortState(
current: MedicationTableSort | null,
column: MedicationTableSortColumn,
): MedicationTableSort | null {
if (!current || current.column !== column) {
return { column, direction: "asc" };
}
if (current.direction === "asc") return { column, direction: "desc" };
return null;
}
/**
* Client-side sort over the loaded list. Rows without a value for the
* chosen column (no next due, no compliance row yet, inventory tracking
* off) sort last in BOTH directions — an unknown must never outrank a
* known value. `null` sort returns the input order untouched (the
* manual order).
*/
export function sortMedicationRows<T extends TableMedication>(
rows: readonly T[],
sort: MedicationTableSort | null,
shortRateById?: ReadonlyMap<string, number>,
): T[] {
if (!sort) return [...rows];
const dir = sort.direction === "asc" ? 1 : -1;
const value = (m: T): number | string | null => {
switch (sort.column) {
case "name":
return m.name;
case "nextDue": {
if (!m.nextDueAt) return null;
const ms = new Date(m.nextDueAt).getTime();
return Number.isFinite(ms) ? ms : null;
}
case "compliance":
return shortRateById?.get(m.id) ?? null;
case "stock":
return m.stockDosesRemaining ?? null;
}
};
return [...rows].sort((a, b) => {
const va = value(a);
const vb = value(b);
if (va === null && vb === null) return 0;
if (va === null) return 1;
if (vb === null) return -1;
if (typeof va === "string" && typeof vb === "string") {
return va.localeCompare(vb, "de", { sensitivity: "base" }) * dir;
}
return ((va as number) - (vb as number)) * dir;
});
}
const LOW_STOCK_DOSES = 4;
interface MedicationTableProps {
/** Active medications, already in the page's manual order. */
activeMedications: TableMedication[];
/** Inactive medications, already in the page's manual order; pinned after the active block. */
inactiveMedications: TableMedication[];
/** SSR-pinnable initial sort (tests); the header buttons own it after mount. */
initialSort?: MedicationTableSort | null;
}
export function MedicationTable({
activeMedications,
inactiveMedications,
initialSort = null,
}: MedicationTableProps) {
const { t } = useTranslations();
const { user } = useAuth();
const userTz = user?.timezone || "Europe/Berlin";
const [sort, setSort] = useState<MedicationTableSort | null>(initialSort);
const [, forceUpdate] = useReducer((x: number) => x + 1, 0);
// Same reminder-thresholds source as the cards so the status pill
// tiers identically on both presentations.
const { data: thresholds } = useQuery({
queryKey: queryKeys.settingsReminderThresholds(),
queryFn: async () => {
try {
return await apiGet<{ lateMinutes: number; missedMinutes: number }>(
"/api/settings/reminder-thresholds",
);
} catch {
return null;
}
},
staleTime: 5 * 60 * 1000,
});
// The whole batched compliance array — the Therapietreue sort needs
// every row at once. Same key + fetcher as the per-row hook.
const { data: complianceRows } = useMedicationComplianceSummaryAll();
const shortRateById = new Map<string, number>(
(complianceRows ?? []).map((row) => [
row.medicationId,
row.complianceDisplay?.short.rate ?? row.compliance7?.rate ?? 0,
]),
);
// Re-render once a minute so the status pill tracks wall-clock
// progress — the cards tick on the same cadence.
useEffect(() => {
const interval = setInterval(forceUpdate, 60_000);
return () => clearInterval(interval);
}, []);
const sortedActive = sortMedicationRows(activeMedications, sort, shortRateById);
const sortedInactive = sortMedicationRows(
inactiveMedications,
sort,
shortRateById,
);
const lateMinutes = thresholds?.lateMinutes ?? 120;
const missedMinutes = thresholds?.missedMinutes ?? 240;
const columns: Array<{
key: MedicationTableSortColumn | "status" | "actions";
label: string;
sortable: boolean;
className?: string;
}> = [
{
key: "name",
label: t("medications.tableColName"),
sortable: true,
// Sticky first column: the name stays anchored while the row
// scrolls horizontally on narrow viewports.
className: "bg-card sticky left-0 z-10",
},
{ key: "status", label: t("medications.tableColStatus"), sortable: false },
{ key: "nextDue", label: t("medications.tableColNextDose"), sortable: true },
{
key: "compliance",
label: t("medications.tableColCompliance"),
sortable: true,
},
{ key: "stock", label: t("medications.tableColStock"), sortable: true },
{ key: "actions", label: t("medications.tableColActions"), sortable: false },
];
function ariaSort(
column: MedicationTableSortColumn,
): "ascending" | "descending" | "none" {
if (!sort || sort.column !== column) return "none";
return sort.direction === "asc" ? "ascending" : "descending";
}
return (
<div className="bg-card border-border rounded-xl border">
<Table>
<caption className="sr-only">{t("medications.tableCaption")}</caption>
<TableHeader>
<TableRow>
{columns.map((col) =>
col.sortable ? (
<TableHead
key={col.key}
scope="col"
aria-sort={ariaSort(col.key as MedicationTableSortColumn)}
className={col.className}
>
<button
type="button"
onClick={() =>
setSort((current) =>
nextSortState(
current,
col.key as MedicationTableSortColumn,
),
)
}
aria-label={t("medications.tableSortBy", {
column: col.label,
})}
className="hover:text-foreground focus-visible:ring-ring inline-flex min-h-8 items-center gap-1 rounded font-medium focus-visible:ring-2 focus-visible:outline-none"
>
{col.label}
{sort?.column === col.key ? (
sort.direction === "asc" ? (
<ArrowUp className="size-3.5" aria-hidden="true" />
) : (
<ArrowDown className="size-3.5" aria-hidden="true" />
)
) : (
<ChevronsUpDown
className="text-muted-foreground/60 size-3.5"
aria-hidden="true"
/>
)}
</button>
</TableHead>
) : (
<TableHead key={col.key} scope="col" className={col.className}>
{col.label}
</TableHead>
),
)}
</TableRow>
</TableHeader>
<TableBody>
{sortedActive.map((med) => (
<MedicationTableRowItem
key={med.id}
medication={med}
userTz={userTz}
lateMinutes={lateMinutes}
missedMinutes={missedMinutes}
/>
))}
{sortedInactive.map((med) => (
<MedicationTableRowItem
key={med.id}
medication={med}
userTz={userTz}
lateMinutes={lateMinutes}
missedMinutes={missedMinutes}
/>
))}
</TableBody>
</Table>
</div>
);
}
interface MedicationTableRowItemProps {
medication: TableMedication;
userTz: string;
lateMinutes: number;
missedMinutes: number;
}
function MedicationTableRowItem({
medication,
userTz,
lateMinutes,
missedMinutes,
}: MedicationTableRowItemProps) {
const { t, locale } = useTranslations();
const fmt = useFormatters();
const weekdayLabel = useWeekdayLabel();
// SAME mutation path as the cards — the shared take/skip hook with
// failure toast + Undo. No card-specific follow-up here; the
// injection-site prompt stays a card affordance.
const { intakeLoading, recordIntake } = useMedicationIntake({ medication });
// SAME batched compliance source as the cards.
const { data: compliance } = useMedicationComplianceSummary(medication.id);
const display = compliance?.complianceDisplay;
const shortDays = display?.shortDays ?? 7;
const longDays = display?.longDays ?? 30;
const rateShort = display?.short.rate ?? compliance?.compliance7?.rate ?? 0;
const rateLong = display?.long.rate ?? compliance?.compliance30?.rate ?? 0;
const doseStatus = display?.currentDose.status ?? "upcoming";
const sortedSchedules = [...medication.schedules].sort(
(a, b) =>
a.windowStart.localeCompare(b.windowStart) ||
a.windowEnd.localeCompare(b.windowEnd),
);
const now = new Date();
const nowZoned = toZonedDate(now, userTz);
const nextDueMs = medication.nextDueAt
? new Date(medication.nextDueAt).getTime()
: NaN;
const nextAt = Number.isFinite(nextDueMs) ? nextDueMs : undefined;
// The SAME served-next-due-gated pill reduction the cards run —
// including the v1.16.6 `nextDue` gate so a rolling cadence whose
// next dose is tomorrow can never paint an overdue pill today.
const currentWindowStatus = reduceCurrentWindowStatus({
schedules: sortedSchedules,
nowBerlin: nowZoned,
lateMinutes,
missedMinutes,
active: medication.active,
lastTakenAt: medication.lastTakenAt,
todayEventCount: medication.todayEventCount ?? 0,
tz: userTz,
nextDue:
medication.nextDueAt === undefined
? undefined
: nextAt !== undefined
? {
at: new Date(nextAt),
overdue: medication.nextDueOverdue === true,
}
: null,
});
// Same displayed-slot threading as the cards so the server records
// THIS dose rather than snapping "now" to the nearest slot.
const displayedSlot = resolveDisplayedSlotInstant({
currentWindowStatus,
nextDueAt: medication.nextDueAt,
now,
timeZone: userTz,
});
// Status cell — the card body's exact precedence: last-dose context
// outranks the overdue escalation, which outranks the window pill.
const overdueLabel =
medication.active && doseStatus === "missed"
? t("medications.veryOverdue")
: medication.active && doseStatus === "overdue"
? t("medications.overdue")
: null;
const statusCell =
currentWindowStatus.status &&
currentWindowStatus.takenEarlyDaysAgo != null ? (
<MedicationStatusPill
compact
status={currentWindowStatus.status}
windowStart={currentWindowStatus.window!.start}
windowEnd={currentWindowStatus.window!.end}
takenEarlyDaysAgo={currentWindowStatus.takenEarlyDaysAgo}
/>
) : overdueLabel ? (
<span className="text-destructive text-sm font-medium">
{overdueLabel}
</span>
) : currentWindowStatus.status ? (
<MedicationStatusPill
compact
status={currentWindowStatus.status}
windowStart={currentWindowStatus.window!.start}
windowEnd={currentWindowStatus.window!.end}
/>
) : (
<span className="text-muted-foreground text-sm">–</span>
);
// Next-intake cell — the card's value logic, compacted: overdue slot
// in calm amber, in-window shows the matched take-window range, a
// future slot shows the relative day + canonical next-due time.
const nextSchedule = sortedSchedules[0] ?? null;
let nextCell: React.ReactNode = (
<span className="text-muted-foreground">–</span>
);
if (medication.active) {
if (medication.nextDueOverdue && nextAt) {
nextCell = (
<span className="font-medium text-amber-600 dark:text-amber-400">
{t("medications.nextIntakeOverdue", {
time: formatTime(new Date(nextAt).toISOString()),
})}
</span>
);
} else if (currentWindowStatus.status === "in_window") {
nextCell = (
<span>
{formatTimeWindowRange(
currentWindowStatus.window!.start,
currentWindowStatus.window!.end,
locale,
)}
</span>
);
} else if (nextAt) {
const nextDate = toZonedDate(new Date(nextAt), userTz);
const todayStr = `${nowZoned.getFullYear()}-${nowZoned.getMonth()}-${nowZoned.getDate()}`;
const nextStr = `${nextDate.getFullYear()}-${nextDate.getMonth()}-${nextDate.getDate()}`;
const tomorrow = new Date(nowZoned);
tomorrow.setDate(tomorrow.getDate() + 1);
const tomorrowStr = `${tomorrow.getFullYear()}-${tomorrow.getMonth()}-${tomorrow.getDate()}`;
const diffDays = Math.round(
(nextDate.getTime() - nowZoned.getTime()) / (24 * 60 * 60 * 1000),
);
const dayLabel =
nextStr === todayStr
? t("medications.today")
: nextStr === tomorrowStr
? t("medications.tomorrow")
: diffDays <= 5
? weekdayLabel(nextDate.getDay())
: fmt.dateWithWeekday(nextDate);
nextCell = (
<span>
{dayLabel}, {formatTime(new Date(nextAt).toISOString())}
</span>
);
} else if (nextSchedule) {
nextCell = (
<span>
{formatTimeWindowRange(
nextSchedule.windowStart,
nextSchedule.windowEnd,
locale,
)}
</span>
);
}
}
// Therapietreue cell — the same two cadence-scaled windows the card
// bars show, compacted to label + mini bar + percentage per row.
const complianceCell = compliance ? (
<div className="flex flex-col gap-1">
{(
[
[shortDays, rateShort],
[longDays, rateLong],
] as const
).map(([days, rate]) => (
<div key={days} className="flex items-center gap-2 text-xs">
<span className="text-muted-foreground w-8 shrink-0 tabular-nums">
{t("medications.tableComplianceDays", { days })}
</span>
<Progress
value={rate}
className="h-1.5 w-14"
aria-label={t("medications.complianceWindow", { days })}
/>
<span className="w-9 text-right font-medium tabular-nums">
{fmt.number(Math.round(rate))}%
</span>
</div>
))}
</div>
) : (
<div className="flex flex-col gap-1" aria-hidden="true">
<Skeleton className="h-3.5 w-28" />
<Skeleton className="h-3.5 w-28" />
</div>
);
const stock = medication.stockDosesRemaining;
const stockCell =
stock === null || stock === undefined ? (
<span className="text-muted-foreground">–</span>
) : (
<span
className={cn(
"tabular-nums",
stock === 0
? "text-destructive font-medium"
: stock < LOW_STOCK_DOSES
? "text-warning font-medium"
: undefined,
)}
>
{stock === 1
? t("medications.tableStockDoseOne")
: t("medications.tableStockDoses", { count: stock })}
</span>
);
return (
<TableRow className={cn(!medication.active && "opacity-60")}>
{/* Sticky name column — opaque background so scrolled content
passes underneath, not through. */}
<TableCell className="bg-card sticky left-0 z-10">
<Link
href={`/medications/${medication.id}`}
aria-label={t("medications.openDetailPage")}
className="focus-visible:ring-ring block min-w-0 rounded focus-visible:ring-2 focus-visible:outline-none"
>
<span className="text-foreground block max-w-40 truncate text-sm font-medium sm:max-w-56">
{medication.name}
</span>
<span className="text-muted-foreground block text-xs">
{medication.dose}
{!medication.active && <> · {t("common.inactive")}</>}
</span>
</Link>
</TableCell>
<TableCell>{statusCell}</TableCell>
<TableCell className="text-sm">{nextCell}</TableCell>
<TableCell>{medication.active ? complianceCell : <span className="text-muted-foreground text-sm">–</span>}</TableCell>
<TableCell className="text-sm">{stockCell}</TableCell>
<TableCell>
{medication.active ? (
<div className="flex gap-1.5">
<Button
size="icon"
className="size-11 sm:size-9"
onClick={() => recordIntake(false, displayedSlot)}
disabled={!!intakeLoading}
aria-label={t("medications.takeActionFor", {
name: medication.name,
})}
>
{intakeLoading === "take" ? (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<Check className="h-4 w-4" />
)}
</Button>
<Button
size="icon"
variant="outline"
className="size-11 sm:size-9"
onClick={() => recordIntake(true, displayedSlot)}
disabled={!!intakeLoading}
aria-label={t("medications.skipActionFor", {
name: medication.name,
})}
>
{intakeLoading === "skip" ? (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<SkipForward className="h-4 w-4" />
)}
</Button>
</div>
) : (
<span className="text-muted-foreground text-sm">–</span>
)}
</TableCell>
</TableRow>
);
}
/**
* Loading placeholder for the table view. Mirrors the loaded table's
* shell (same container, same column count, same row height) so the
* page reserves the footprint and does not jump when the rows resolve —
* the table-view counterpart of `MedicationCardSkeleton`.
*/
export function MedicationTableSkeleton({ rows = 4 }: { rows?: number }) {
return (
<div className="bg-card border-border rounded-xl border" aria-hidden="true">
<Table>
<TableHeader>
<TableRow>
{Array.from({ length: 6 }).map((_, i) => (
<TableHead key={i}>
<Skeleton className="h-4 w-16" />
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{Array.from({ length: rows }).map((_, i) => (
<TableRow key={i}>
<TableCell>
<div className="space-y-1.5">
<Skeleton className="h-4 w-28" />
<Skeleton className="h-3 w-16" />
</div>
</TableCell>
<TableCell>
<Skeleton className="h-4 w-24" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-24" />
</TableCell>
<TableCell>
<div className="space-y-1.5">
<Skeleton className="h-3.5 w-28" />
<Skeleton className="h-3.5 w-28" />
</div>
</TableCell>
<TableCell>
<Skeleton className="h-4 w-14" />
</TableCell>
<TableCell>
<div className="flex gap-1.5">
<Skeleton className="size-11 rounded-md sm:size-9" />
<Skeleton className="size-11 rounded-md sm:size-9" />
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
}
@@ -0,0 +1,73 @@
"use client";
import { LayoutGrid, Table as TableIcon } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import type { MedicationListView } from "@/lib/medication-list-layout";
/**
* v1.16.10 — icon-only card/table view toggle for the /medications page
* header. A small two-segment control: the active segment carries the
* filled surface, the inactive one stays ghost. `aria-pressed` announces
* the state per segment; the group is named for screen readers. Segments
* keep the 44-px mobile tap floor (`size-11`), shrinking to the standard
* 36-px control height on desktop like the neighbouring Add button.
*/
interface MedicationViewToggleProps {
view: MedicationListView;
onChange: (view: MedicationListView) => void;
}
export function MedicationViewToggle({
view,
onChange,
}: MedicationViewToggleProps) {
const { t } = useTranslations();
const segments = [
{
value: "cards" as const,
label: t("medications.viewCards"),
icon: LayoutGrid,
},
{
value: "table" as const,
label: t("medications.viewTable"),
icon: TableIcon,
},
];
return (
<div
role="group"
aria-label={t("medications.viewToggleLabel")}
className="border-border bg-background/30 inline-flex shrink-0 items-center rounded-md border p-0.5"
>
{segments.map(({ value, label, icon: Icon }) => {
const active = view === value;
return (
<button
key={value}
type="button"
onClick={() => {
if (!active) onChange(value);
}}
aria-pressed={active}
aria-label={label}
title={label}
data-slot={`medications-view-${value}`}
className={cn(
"focus-visible:ring-ring inline-flex size-10 items-center justify-center rounded-[5px] transition-colors focus-visible:ring-2 focus-visible:outline-none motion-reduce:transition-none sm:size-8",
active
? "bg-secondary text-secondary-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
>
<Icon className="size-4" aria-hidden="true" />
</button>
);
})}
</div>
);
}