From a17e7a46af9c15a730d00e7135af88d62c9738a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 17 May 2026 12:21:00 +0200 Subject: [PATCH] feat(medications): restore intake-history table on the detail page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- messages/de.json | 14 + messages/en.json | 14 + messages/es.json | 14 + messages/fr.json | 14 + messages/it.json | 14 + messages/pl.json | 14 + src/app/medications/[id]/history/page.tsx | 9 + .../__tests__/intake-history-list-v2.test.tsx | 248 ++++++++++++++++ .../medications/intake-history-list-v2.tsx | 280 ++++++++++++++++++ 9 files changed, 621 insertions(+) create mode 100644 src/components/medications/__tests__/intake-history-list-v2.test.tsx create mode 100644 src/components/medications/intake-history-list-v2.tsx diff --git a/messages/de.json b/messages/de.json index 8d9fdb73c..2911299e6 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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)", diff --git a/messages/en.json b/messages/en.json index 01c5c986a..428451006 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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)", diff --git a/messages/es.json b/messages/es.json index 283770bdf..ecaa6acd4 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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)", diff --git a/messages/fr.json b/messages/fr.json index 5a2659538..d2388dc47 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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)", diff --git a/messages/it.json b/messages/it.json index fa8ad5eb2..b843fb629 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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)", diff --git a/messages/pl.json b/messages/pl.json index 600de05ce..9b1fcca3c 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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)", diff --git a/src/app/medications/[id]/history/page.tsx b/src/app/medications/[id]/history/page.tsx index c718c07b9..37b14b5f5 100644 --- a/src/app/medications/[id]/history/page.tsx +++ b/src/app/medications/[id]/history/page.tsx @@ -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" && ( )} + + {/* 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 && } ); } diff --git a/src/components/medications/__tests__/intake-history-list-v2.test.tsx b/src/components/medications/__tests__/intake-history-list-v2.test.tsx new file mode 100644 index 000000000..bcbc2bd8f --- /dev/null +++ b/src/components/medications/__tests__/intake-history-list-v2.test.tsx @@ -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( + + {node} + , + ); +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe(" — empty state", () => { + it("renders the empty-state title and CTA in English", () => { + const client = makeClient(); + seedIntake(client, "med-1", []); + const html = render( + , + 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( + , + client, + "de", + ); + expect(html).toContain("Noch keine Einnahmen erfasst"); + expect(html).toContain("Zur Tagesübersicht"); + }); +}); + +describe(" — 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( + , + 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( + , + client, + ); + expect(enHtml).toContain("Intake history"); + + const deClient = makeClient(); + seedIntake(deClient, "med-1", []); + const deHtml = render( + , + deClient, + "de", + ); + expect(deHtml).toContain("Einnahme-Verlauf"); + }); +}); + +describe(" — 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( + , + 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( + , + 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( + , + client, + ); + expect(html).not.toContain(">Previous<"); + expect(html).not.toContain(">Next<"); + }); +}); diff --git a/src/components/medications/intake-history-list-v2.tsx b/src/components/medications/intake-history-list-v2.tsx new file mode 100644 index 000000000..35edda6f4 --- /dev/null +++ b/src/components/medications/intake-history-list-v2.tsx @@ -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("takenAt"); + const [sortDir, setSortDir] = useState<"asc" | "desc">("desc"); + const [page, setPage] = useState(0); + + const offset = page * pageSize; + + const { data, isLoading, isError } = useQuery({ + 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 = 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 ; + } + return sortDir === "desc" ? ( + + ) : ( + + ); + }; + + return ( + + + + {t("medications.intakeHistory")} + + + + {isLoading ? ( +
+ +
+ ) : isError ? ( +

+ {t("medications.loadFailed")} +

+ ) : events.length === 0 ? ( + } + title={t("medications.intakeHistoryEmptyTitle")} + description={t("medications.intakeHistoryEmptyDescription")} + action={ + + } + /> + ) : ( + <> + + + + + + + + + + {t("medications.intakeHistoryColStatus")} + {t("medications.intakeHistoryColSource")} + + + + {events.map((event) => ( + + + {event.takenAt ? formatters.dateTime(event.takenAt) : "—"} + + + {formatters.dateTime(event.scheduledFor)} + + + + {event.skipped + ? t("medications.intakeHistoryStatusSkipped") + : t("medications.intakeHistoryStatusTaken")} + + + + + {sourceLabels[event.source] ?? event.source} + + + + ))} + +
+ + {totalPages > 1 && ( +
+ + {t("medications.intakeHistoryPageInfo", { + page: page + 1, + total: totalPages, + count: total, + })} + +
+ + +
+
+ )} + + )} +
+
+ ); +}