feat(medications): restore intake-history table on the detail page

The v1 IntakeHistoryList was retired in v1.4.28 on the assumption
it had no other consumer. The standalone /medications/[id]/history
page lost its dose timeline as a side-effect — non-GLP-1 medications
now render only the heading. This restores a slim, read-only v2.

- Paginated 25/page via the existing limit/offset query params
- Sortable by takenAt (default desc) and scheduledFor
- Status and source badges; no inline edit or delete
- Empty state with CTA back to the daily intake page
- i18n keys across all six locales (DE + EN curated, the AI-initial
  locales mirror the EN copy per the v1.4.25 W9e maintainership policy)
This commit is contained in:
Marc-André Bombeck committed 2026-05-17 12:21:00 +02:00
1 parent 28a829695d
commit a17e7a46af
9 files changed
+621

No files matched your search

+14
View File
@@ -695,6 +695,20 @@
"sourceApi": "Externe API",
"sourceReminder": "Telegram / Erinnerung",
"sourceImport": "CSV-Import",
"intakeHistoryEmptyTitle": "Noch keine Einnahmen erfasst",
"intakeHistoryEmptyDescription": "Trage eine Einnahme auf der Tagesübersicht ein, um den Verlauf aufzubauen.",
"intakeHistoryEmptyAction": "Zur Tagesübersicht",
"intakeHistoryColTaken": "Eingenommen",
"intakeHistoryColScheduled": "Geplant",
"intakeHistoryColStatus": "Status",
"intakeHistoryColSource": "Quelle",
"intakeHistoryStatusTaken": "Eingenommen",
"intakeHistoryStatusSkipped": "Übersprungen",
"intakeHistorySortByTaken": "Nach Einnahme-Zeitpunkt sortieren",
"intakeHistorySortByScheduled": "Nach geplantem Zeitpunkt sortieren",
"intakeHistoryPageInfo": "Seite {page} von {total} · {count} Einträge",
"intakeHistoryPrev": "Zurück",
"intakeHistoryNext": "Weiter",
"unitDrops": "Tropfen",
"unitTablets": "Tablette(n)",
"unitCapsules": "Kapsel(n)",
+14
View File
@@ -695,6 +695,20 @@
"sourceApi": "External API",
"sourceReminder": "Telegram / Reminder",
"sourceImport": "CSV Import",
"intakeHistoryEmptyTitle": "No intakes recorded yet",
"intakeHistoryEmptyDescription": "Log a dose on the daily intake page to start building the history.",
"intakeHistoryEmptyAction": "Open daily intake",
"intakeHistoryColTaken": "Taken",
"intakeHistoryColScheduled": "Scheduled",
"intakeHistoryColStatus": "Status",
"intakeHistoryColSource": "Source",
"intakeHistoryStatusTaken": "Taken",
"intakeHistoryStatusSkipped": "Skipped",
"intakeHistorySortByTaken": "Sort by taken time",
"intakeHistorySortByScheduled": "Sort by scheduled time",
"intakeHistoryPageInfo": "Page {page} of {total} · {count} entries",
"intakeHistoryPrev": "Previous",
"intakeHistoryNext": "Next",
"unitDrops": "Drops",
"unitTablets": "Tablet(s)",
"unitCapsules": "Capsule(s)",
+14
View File
@@ -695,6 +695,20 @@
"sourceApi": "External API",
"sourceReminder": "Telegram / Reminder",
"sourceImport": "CSV Import",
"intakeHistoryEmptyTitle": "No intakes recorded yet",
"intakeHistoryEmptyDescription": "Log a dose on the daily intake page to start building the history.",
"intakeHistoryEmptyAction": "Open daily intake",
"intakeHistoryColTaken": "Taken",
"intakeHistoryColScheduled": "Scheduled",
"intakeHistoryColStatus": "Status",
"intakeHistoryColSource": "Source",
"intakeHistoryStatusTaken": "Taken",
"intakeHistoryStatusSkipped": "Skipped",
"intakeHistorySortByTaken": "Sort by taken time",
"intakeHistorySortByScheduled": "Sort by scheduled time",
"intakeHistoryPageInfo": "Page {page} of {total} · {count} entries",
"intakeHistoryPrev": "Previous",
"intakeHistoryNext": "Next",
"unitDrops": "Drops",
"unitTablets": "Tablet(s)",
"unitCapsules": "Capsule(s)",
+14
View File
@@ -695,6 +695,20 @@
"sourceApi": "External API",
"sourceReminder": "Telegram / Reminder",
"sourceImport": "CSV Import",
"intakeHistoryEmptyTitle": "No intakes recorded yet",
"intakeHistoryEmptyDescription": "Log a dose on the daily intake page to start building the history.",
"intakeHistoryEmptyAction": "Open daily intake",
"intakeHistoryColTaken": "Taken",
"intakeHistoryColScheduled": "Scheduled",
"intakeHistoryColStatus": "Status",
"intakeHistoryColSource": "Source",
"intakeHistoryStatusTaken": "Taken",
"intakeHistoryStatusSkipped": "Skipped",
"intakeHistorySortByTaken": "Sort by taken time",
"intakeHistorySortByScheduled": "Sort by scheduled time",
"intakeHistoryPageInfo": "Page {page} of {total} · {count} entries",
"intakeHistoryPrev": "Previous",
"intakeHistoryNext": "Next",
"unitDrops": "Drops",
"unitTablets": "Tablet(s)",
"unitCapsules": "Capsule(s)",
+14
View File
@@ -695,6 +695,20 @@
"sourceApi": "External API",
"sourceReminder": "Telegram / Reminder",
"sourceImport": "CSV Import",
"intakeHistoryEmptyTitle": "No intakes recorded yet",
"intakeHistoryEmptyDescription": "Log a dose on the daily intake page to start building the history.",
"intakeHistoryEmptyAction": "Open daily intake",
"intakeHistoryColTaken": "Taken",
"intakeHistoryColScheduled": "Scheduled",
"intakeHistoryColStatus": "Status",
"intakeHistoryColSource": "Source",
"intakeHistoryStatusTaken": "Taken",
"intakeHistoryStatusSkipped": "Skipped",
"intakeHistorySortByTaken": "Sort by taken time",
"intakeHistorySortByScheduled": "Sort by scheduled time",
"intakeHistoryPageInfo": "Page {page} of {total} · {count} entries",
"intakeHistoryPrev": "Previous",
"intakeHistoryNext": "Next",
"unitDrops": "Drops",
"unitTablets": "Tablet(s)",
"unitCapsules": "Capsule(s)",
+14
View File
@@ -695,6 +695,20 @@
"sourceApi": "External API",
"sourceReminder": "Telegram / Reminder",
"sourceImport": "CSV Import",
"intakeHistoryEmptyTitle": "No intakes recorded yet",
"intakeHistoryEmptyDescription": "Log a dose on the daily intake page to start building the history.",
"intakeHistoryEmptyAction": "Open daily intake",
"intakeHistoryColTaken": "Taken",
"intakeHistoryColScheduled": "Scheduled",
"intakeHistoryColStatus": "Status",
"intakeHistoryColSource": "Source",
"intakeHistoryStatusTaken": "Taken",
"intakeHistoryStatusSkipped": "Skipped",
"intakeHistorySortByTaken": "Sort by taken time",
"intakeHistorySortByScheduled": "Sort by scheduled time",
"intakeHistoryPageInfo": "Page {page} of {total} · {count} entries",
"intakeHistoryPrev": "Previous",
"intakeHistoryNext": "Next",
"unitDrops": "Drops",
"unitTablets": "Tablet(s)",
"unitCapsules": "Capsule(s)",
@@ -9,6 +9,7 @@ import { DrugLevelChart } from "@/components/medications/DrugLevelChart";
import { SideEffectsSection } from "@/components/medications/SideEffectsSection";
import { SchedulingSection } from "@/components/medications/SchedulingSection";
import { TitrationSection } from "@/components/medications/TitrationSection";
import { IntakeHistoryListV2 } from "@/components/medications/intake-history-list-v2";
import { ArrowLeft, Loader2 } from "lucide-react";
import Link from "next/link";
import { useTranslations } from "@/lib/i18n/context";
@@ -133,6 +134,14 @@ export default function IntakeHistoryPage({
{medication?.treatmentClass === "GLP1" && (
<TitrationSection medicationId={id} />
)}
{/* v1.4.36 W4a — Lite intake-history table mounted for ALL
medication kinds. v1.4.28 dropped the v1 component on the
assumption it had no other consumer; the standalone history
page lost its dose timeline as a side-effect. The v2 is
read-only (no inline CRUD) — edit/delete still flow through
the regular intake routes. */}
{medication && <IntakeHistoryListV2 medicationId={id} />}
</div>
);
}
@@ -0,0 +1,248 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
import { IntakeHistoryListV2 } from "@/components/medications/intake-history-list-v2";
/**
* v1.4.36 W4a — IntakeHistoryListV2 SSR smoke tests.
*
* Mirrors the project convention: `renderToStaticMarkup` +
* react-query data seeded via `QueryClient.setQueryData()`. Sort-toggle
* is a useState interaction and cannot fire through SSR, so the
* toggle test exercises the seeded sortDir/sortBy variants and pins
* the rendered indicator markup.
*/
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: { id: "test-user", username: "tester", role: "USER" },
isAuthenticated: true,
isLoading: false,
}),
}));
function makeClient() {
return new QueryClient({
defaultOptions: {
queries: {
retry: false,
staleTime: Number.POSITIVE_INFINITY,
refetchOnMount: false,
},
},
});
}
type IntakeRow = {
id: string;
medicationId: string;
scheduledFor: string;
takenAt: string | null;
skipped: boolean;
source: "WEB" | "API" | "REMINDER" | "IMPORT";
createdAt: string;
};
function seedIntake(
client: QueryClient,
medId: string,
rows: IntakeRow[],
total: number = rows.length,
opts: {
sortBy?: "takenAt" | "scheduledFor";
sortDir?: "asc" | "desc";
limit?: number;
offset?: number;
} = {},
) {
const sortBy = opts.sortBy ?? "takenAt";
const sortDir = opts.sortDir ?? "desc";
const limit = opts.limit ?? 25;
const offset = opts.offset ?? 0;
client.setQueryData(
[
"medications",
medId,
"intake",
"list",
{ sortBy, sortDir, limit, offset },
],
{ events: rows, meta: { total, limit, offset } },
);
}
function render(
node: React.ReactNode,
client: QueryClient,
locale: "en" | "de" = "en",
) {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>
<QueryClientProvider client={client}>{node}</QueryClientProvider>
</I18nProvider>,
);
}
beforeEach(() => {
vi.clearAllMocks();
});
describe("<IntakeHistoryListV2> — empty state", () => {
it("renders the empty-state title and CTA in English", () => {
const client = makeClient();
seedIntake(client, "med-1", []);
const html = render(
<IntakeHistoryListV2 medicationId="med-1" />,
client,
);
expect(html).toContain("No intakes recorded yet");
expect(html).toContain("Open daily intake");
expect(html).toContain('href="/medications"');
});
it("renders the empty-state title and CTA in German", () => {
const client = makeClient();
seedIntake(client, "med-1", []);
const html = render(
<IntakeHistoryListV2 medicationId="med-1" />,
client,
"de",
);
expect(html).toContain("Noch keine Einnahmen erfasst");
expect(html).toContain("Zur Tagesübersicht");
});
});
describe("<IntakeHistoryListV2> — populated rows", () => {
it("renders one row per intake event with status + source badges", () => {
const client = makeClient();
seedIntake(client, "med-1", [
{
id: "evt-1",
medicationId: "med-1",
scheduledFor: "2026-05-15T08:00:00.000Z",
takenAt: "2026-05-15T08:02:00.000Z",
skipped: false,
source: "WEB",
createdAt: "2026-05-15T08:02:00.000Z",
},
{
id: "evt-2",
medicationId: "med-1",
scheduledFor: "2026-05-14T08:00:00.000Z",
takenAt: null,
skipped: true,
source: "REMINDER",
createdAt: "2026-05-14T20:00:00.000Z",
},
]);
const html = render(
<IntakeHistoryListV2 medicationId="med-1" />,
client,
);
expect(html).toContain("Taken");
expect(html).toContain("Skipped");
expect(html).toContain("Website");
expect(html).toContain("Telegram / Reminder");
});
it("renders the section title across both locales", () => {
const client = makeClient();
seedIntake(client, "med-1", []);
const enHtml = render(
<IntakeHistoryListV2 medicationId="med-1" />,
client,
);
expect(enHtml).toContain("Intake history");
const deClient = makeClient();
seedIntake(deClient, "med-1", []);
const deHtml = render(
<IntakeHistoryListV2 medicationId="med-1" />,
deClient,
"de",
);
expect(deHtml).toContain("Einnahme-Verlauf");
});
});
describe("<IntakeHistoryListV2> — sort indicators", () => {
it("renders a desc arrow on the active sort column", () => {
const client = makeClient();
seedIntake(client, "med-1", [
{
id: "evt-1",
medicationId: "med-1",
scheduledFor: "2026-05-15T08:00:00.000Z",
takenAt: "2026-05-15T08:02:00.000Z",
skipped: false,
source: "WEB",
createdAt: "2026-05-15T08:02:00.000Z",
},
]);
const html = render(
<IntakeHistoryListV2 medicationId="med-1" />,
client,
);
// Default sort = takenAt desc, so the "taken" header carries the
// arrow-down icon and the "scheduled" header carries the neutral
// dual-arrow icon.
expect(html).toContain("arrow-down");
expect(html).toContain("arrow-up-down");
});
it("renders pagination controls only when more than one page", () => {
const client = makeClient();
// 50 total > pageSize 25 → pagination should render
seedIntake(
client,
"med-2",
[
{
id: "evt-1",
medicationId: "med-2",
scheduledFor: "2026-05-15T08:00:00.000Z",
takenAt: "2026-05-15T08:02:00.000Z",
skipped: false,
source: "WEB",
createdAt: "2026-05-15T08:02:00.000Z",
},
],
50,
);
const html = render(
<IntakeHistoryListV2 medicationId="med-2" />,
client,
);
expect(html).toContain("Previous");
expect(html).toContain("Next");
});
it("hides pagination when total ≤ pageSize", () => {
const client = makeClient();
seedIntake(
client,
"med-3",
[
{
id: "evt-1",
medicationId: "med-3",
scheduledFor: "2026-05-15T08:00:00.000Z",
takenAt: "2026-05-15T08:02:00.000Z",
skipped: false,
source: "WEB",
createdAt: "2026-05-15T08:02:00.000Z",
},
],
1,
);
const html = render(
<IntakeHistoryListV2 medicationId="med-3" />,
client,
);
expect(html).not.toContain(">Previous<");
expect(html).not.toContain(">Next<");
});
});
@@ -0,0 +1,280 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { useState, useMemo } from "react";
import Link from "next/link";
import { Loader2, ArrowUp, ArrowDown, ArrowUpDown, History } from "lucide-react";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { EmptyState } from "@/components/ui/empty-state";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
/**
* v1.4.36 W4a — Lite intake-history surface.
*
* The 886-LoC v1 was retired in v1.4.28 (commit 8c81af10) with the
* note "no other consumer". The standalone medication-history page
* lost its dose timeline as a side effect — Marc reported the page
* now shows only the heading for non-GLP-1 meds. This v2 restores a
* read-only timeline for ALL medication kinds without the inline CRUD
* the v1 carried (edit / delete still happen through the regular
* medication-intake routes elsewhere).
*
* Contract:
* - Paginated 25/page via the existing `?limit=&offset=` query on
* `GET /api/medications/[id]/intake`. The route already supports
* `sortBy=takenAt|scheduledFor|createdAt|source` + `sortDir=asc|desc`.
* - Sortable by `takenAt` (default) and `scheduledFor`. Click the
* header to toggle the direction; switching column resets to desc.
* - Source badge surfaces WEB / API / REMINDER / IMPORT using the
* existing `medications.source*` keys.
* - Empty state copy + CTA back to the daily intake page.
* - No edit-in-row, no delete buttons.
*/
type IntakeSource = "WEB" | "API" | "REMINDER" | "IMPORT";
interface IntakeEvent {
id: string;
medicationId: string;
scheduledFor: string;
takenAt: string | null;
skipped: boolean;
source: IntakeSource;
createdAt: string;
}
interface IntakeListResponse {
events: IntakeEvent[];
meta: { total: number; limit: number; offset: number };
}
interface IntakeHistoryListV2Props {
medicationId: string;
/** Override the default page size — primarily for tests. */
pageSize?: number;
}
type SortKey = "takenAt" | "scheduledFor";
const DEFAULT_PAGE_SIZE = 25;
export function IntakeHistoryListV2({
medicationId,
pageSize = DEFAULT_PAGE_SIZE,
}: IntakeHistoryListV2Props) {
const { isAuthenticated } = useAuth();
const { t } = useTranslations();
const formatters = useFormatters();
const [sortBy, setSortBy] = useState<SortKey>("takenAt");
const [sortDir, setSortDir] = useState<"asc" | "desc">("desc");
const [page, setPage] = useState(0);
const offset = page * pageSize;
const { data, isLoading, isError } = useQuery<IntakeListResponse>({
queryKey: [
"medications",
medicationId,
"intake",
"list",
{ sortBy, sortDir, limit: pageSize, offset },
],
queryFn: async () => {
const params = new URLSearchParams({
sortBy,
sortDir,
limit: String(pageSize),
offset: String(offset),
});
const res = await fetch(
`/api/medications/${medicationId}/intake?${params.toString()}`,
);
if (!res.ok) throw new Error("Failed to load intake history");
return (await res.json()).data as IntakeListResponse;
},
enabled: isAuthenticated,
});
const sourceLabels: Record<IntakeSource, string> = useMemo(
() => ({
WEB: t("medications.sourceWeb"),
API: t("medications.sourceApi"),
REMINDER: t("medications.sourceReminder"),
IMPORT: t("medications.sourceImport"),
}),
[t],
);
const total = data?.meta.total ?? 0;
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const events = data?.events ?? [];
const toggleSort = (key: SortKey) => {
if (key === sortBy) {
setSortDir((d) => (d === "desc" ? "asc" : "desc"));
} else {
setSortBy(key);
setSortDir("desc");
}
setPage(0);
};
const sortIndicator = (key: SortKey) => {
if (key !== sortBy) {
return <ArrowUpDown className="ml-1 inline h-3 w-3 opacity-50" />;
}
return sortDir === "desc" ? (
<ArrowDown className="ml-1 inline h-3 w-3" />
) : (
<ArrowUp className="ml-1 inline h-3 w-3" />
);
};
return (
<Card>
<CardHeader>
<CardTitle className="text-base">
{t("medications.intakeHistory")}
</CardTitle>
</CardHeader>
<CardContent className="pb-4 md:pb-6">
{isLoading ? (
<div className="flex h-24 items-center justify-center">
<Loader2 className="text-muted-foreground h-5 w-5 animate-spin motion-reduce:animate-none" />
</div>
) : isError ? (
<p className="text-destructive text-sm">
{t("medications.loadFailed")}
</p>
) : events.length === 0 ? (
<EmptyState
variant="plain"
size="compact"
icon={<History className="size-6" />}
title={t("medications.intakeHistoryEmptyTitle")}
description={t("medications.intakeHistoryEmptyDescription")}
action={
<Button asChild size="sm">
<Link href="/medications">
{t("medications.intakeHistoryEmptyAction")}
</Link>
</Button>
}
/>
) : (
<>
<Table>
<TableHeader>
<TableRow>
<TableHead>
<button
type="button"
onClick={() => toggleSort("takenAt")}
className="inline-flex items-center font-medium hover:underline"
aria-label={t("medications.intakeHistorySortByTaken")}
>
{t("medications.intakeHistoryColTaken")}
{sortIndicator("takenAt")}
</button>
</TableHead>
<TableHead>
<button
type="button"
onClick={() => toggleSort("scheduledFor")}
className="inline-flex items-center font-medium hover:underline"
aria-label={t("medications.intakeHistorySortByScheduled")}
>
{t("medications.intakeHistoryColScheduled")}
{sortIndicator("scheduledFor")}
</button>
</TableHead>
<TableHead>{t("medications.intakeHistoryColStatus")}</TableHead>
<TableHead>{t("medications.intakeHistoryColSource")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{events.map((event) => (
<TableRow key={event.id}>
<TableCell className="text-sm">
{event.takenAt ? formatters.dateTime(event.takenAt) : "—"}
</TableCell>
<TableCell className="text-sm">
{formatters.dateTime(event.scheduledFor)}
</TableCell>
<TableCell>
<Badge
variant={event.skipped ? "outline" : "secondary"}
className="text-xs"
>
{event.skipped
? t("medications.intakeHistoryStatusSkipped")
: t("medications.intakeHistoryStatusTaken")}
</Badge>
</TableCell>
<TableCell>
<Badge variant="outline" className="text-xs">
{sourceLabels[event.source] ?? event.source}
</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{totalPages > 1 && (
<div className="text-muted-foreground mt-3 flex items-center justify-between text-xs">
<span>
{t("medications.intakeHistoryPageInfo", {
page: page + 1,
total: totalPages,
count: total,
})}
</span>
<div className="flex gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setPage((p) => Math.max(0, p - 1))}
disabled={page === 0}
>
{t("medications.intakeHistoryPrev")}
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={() =>
setPage((p) => Math.min(totalPages - 1, p + 1))
}
disabled={page >= totalPages - 1}
>
{t("medications.intakeHistoryNext")}
</Button>
</div>
</div>
)}
</>
)}
</CardContent>
</Card>
);
}