From e21dfd040e2ed4c610b744551916bfdd6f67d209 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 12 Jun 2026 18:10:21 +0200 Subject: [PATCH] feat(medications): a sortable table view with an order you choose MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/api/openapi.yaml | 192 ++++- messages/de.json | 23 +- messages/en.json | 23 +- messages/es.json | 23 +- messages/fr.json | 23 +- messages/it.json | 23 +- messages/pl.json | 23 +- .../migration.sql | 11 + prisma/schema.prisma | 8 + .../api/medications/__tests__/route.test.ts | 6 + .../layout/__tests__/route.test.ts | 277 +++++++ src/app/api/medications/layout/route.ts | 177 +++++ src/app/api/medications/route.ts | 55 +- .../__tests__/list-view-toggle.test.tsx | 165 +++++ src/app/medications/page.tsx | 166 +++-- .../__tests__/medication-table.test.tsx | 439 ++++++++++++ .../card-parts/medication-status-pill.tsx | 28 +- .../medications/medication-reorder-dialog.tsx | 294 ++++++++ .../medications/medication-table.tsx | 678 ++++++++++++++++++ .../medications/medication-view-toggle.tsx | 73 ++ .../__tests__/medication-list-layout.test.ts | 92 +++ src/lib/cache/invalidate.ts | 11 + src/lib/cache/server-cache.ts | 12 + src/lib/medication-list-layout.ts | 113 +++ .../__tests__/medication-order.test.ts | 52 ++ src/lib/medications/medication-order.ts | 37 + src/lib/openapi/routes/medications.ts | 116 ++- .../use-medication-list-layout.test.ts | 150 ++++ .../use-medication-compliance-summary.ts | 33 +- src/lib/queries/use-medication-list-layout.ts | 116 +++ src/lib/query-keys/medications.ts | 8 + 31 files changed, 3382 insertions(+), 65 deletions(-) create mode 100644 prisma/migrations/0150_v11610_medication_list_layout/migration.sql create mode 100644 src/app/api/medications/layout/__tests__/route.test.ts create mode 100644 src/app/api/medications/layout/route.ts create mode 100644 src/app/medications/__tests__/list-view-toggle.test.tsx create mode 100644 src/components/medications/__tests__/medication-table.test.tsx create mode 100644 src/components/medications/medication-reorder-dialog.tsx create mode 100644 src/components/medications/medication-table.tsx create mode 100644 src/components/medications/medication-view-toggle.tsx create mode 100644 src/lib/__tests__/medication-list-layout.test.ts create mode 100644 src/lib/medication-list-layout.ts create mode 100644 src/lib/medications/__tests__/medication-order.test.ts create mode 100644 src/lib/medications/medication-order.ts create mode 100644 src/lib/queries/__tests__/use-medication-list-layout.test.ts create mode 100644 src/lib/queries/use-medication-list-layout.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index c01249a6a..d5c80eb3a 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -2153,6 +2153,61 @@ paths: "401": *a1 "422": *a3 "429": *a4 + /api/medications/layout: + get: + tags: + - Medications + summary: Read the calling user's medications list presentation + description: Returns the per-user /medications presentation (card/table view + manual order). Falls back to the defaults + (cards, empty order) when the user has not customised it. Mirrors the insights-layout contract. + responses: + "200": + description: The resolved presentation (custom or default). + content: + application/json: + schema: + $ref: "#/components/schemas/MedicationListLayoutResponse" + "401": *a1 + "422": *a3 + "429": *a4 + put: + tags: + - Medications + summary: Update the calling user's medications list presentation + description: "Field-scoped update: `view` and `order` are each optional, and whichever the body omits is preserved from + the stored blob — a view toggle can never wipe the manual order and vice versa. The normalised presentation is + returned. Invalid bodies return the multi-issue 422 envelope." + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/MedicationListLayout" + responses: + "200": + description: Presentation saved; the normalised blob is echoed back. + content: + application/json: + schema: + $ref: "#/components/schemas/MedicationListLayoutSaved" + "401": *a1 + "422": *a3 + "429": *a4 + delete: + tags: + - Medications + summary: Reset the calling user's medications list presentation + description: Clears the persisted presentation and returns the defaults (cards, empty order). Idempotent. + responses: + "200": + description: Presentation reset; the defaults are returned. + content: + application/json: + schema: + $ref: "#/components/schemas/MedicationListLayoutReset" + "401": *a1 + "422": *a3 + "429": *a4 /api/medications/{id}: get: tags: @@ -4888,6 +4943,34 @@ components: description: One explicit per-dose on-time intake window. `timeOfDay` matches a schedule dose time; `[start, end]` (HH:mm, user local, `start <= end`) is the on-time band. Outside it the cadence-derived late tail applies, then ad-hoc. + MedicationListLayout: + type: object + properties: + version: + type: number + const: 1 + view: + description: Which presentation /medications renders in. Default "cards". Optional on PUT — when omitted the stored + value is preserved (preserve-when-absent, like `heroVisible` on the dashboard layout). Always present on + responses. + type: string + enum: + - cards + - table + order: + description: User-defined manual medication order (medication ids, first = top), shared by both views. Display-only — + unknown / deleted ids are ignored at render time, never 422. Optional on PUT (preserve-when-absent); always + present on responses. + maxItems: 200 + type: array + items: + type: string + minLength: 1 + maxLength: 64 + required: + - version + description: "Per-user /medications presentation: the card/table view choice plus the manual medication order shared by + both views. Mirrors the dashboard-widgets / insights-layout contract." UpdateMedicationRequest: type: object properties: @@ -8596,6 +8679,23 @@ components: description: "Number of ACTIONED intake events for today (user-local day window): rows with a recorded `takenAt` or an explicit skip. Pending projector-minted rows do not count — the card overdue-pill suppression compares this against the passed-dose count, and a pending mint must not read as covered." + stockUnitsRemaining: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + description: v1.16.10 — usable inventory units left across the medication's containers (sum of `unitsRemaining` over + ACTIVE / IN_USE items with units left). NULL = inventory tracking off (no items ever registered); 0 = + tracking on, supply ran out. Read-only — aggregated, not stored. + stockDosesRemaining: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + description: "v1.16.10 — dose-derived stock: `floor(stockUnitsRemaining / max(1, unitsPerDose))`. NULL when inventory + tracking is off. Drives the table view's Bestand column. Read-only — aggregated, not stored." required: - id - name @@ -8622,12 +8722,14 @@ components: - category - lastTakenAt - todayEventCount + - stockUnitsRemaining + - stockDosesRemaining additionalProperties: false - description: List-row variant of the medication resource enriched with the joined `category`, `lastTakenAt`, and - `todayEventCount` fields the dashboard + iOS client consume. The base medication fields (`id`, `name`, `dose`, - `treatmentClass`, `dosesPerUnit`, `active`, `notificationsEnabled`, `pausedAt`, `snoozedUntil`, `startsOn`, - `endsOn`, `oneShot`, `createdAt`, `updatedAt`, `schedules`) are inlined; see the `Medication` component for - their semantics. + description: List-row variant of the medication resource enriched with the joined `category`, `lastTakenAt`, + `todayEventCount`, and the v1.16.10 aggregated stock fields (`stockUnitsRemaining`, `stockDosesRemaining`) the + dashboard + iOS client consume. The base medication fields (`id`, `name`, `dose`, `treatmentClass`, + `dosesPerUnit`, `active`, `notificationsEnabled`, `pausedAt`, `snoozedUntil`, `startsOn`, `endsOn`, `oneShot`, + `createdAt`, `updatedAt`, `schedules`) are inlined; see the `Medication` component for their semantics. MedicationCategory: type: string enum: @@ -8801,6 +8903,57 @@ components: additionalProperties: false description: Detail variant of the medication resource enriched with the joined `category`. The base medication fields are inlined; see the `Medication` component for their semantics. + MedicationListLayoutResponse: + type: object + properties: + data: + $ref: "#/components/schemas/MedicationListLayoutOutput" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + MedicationListLayoutSaved: + type: object + properties: + data: + $ref: "#/components/schemas/MedicationListLayoutOutput" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + MedicationListLayoutReset: + type: object + properties: + data: + $ref: "#/components/schemas/MedicationListLayoutOutput" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false GetMedicationResponse: type: object properties: @@ -13162,6 +13315,35 @@ components: primitives are mutually exclusive — enforced by the Zod refine on writes, the route layer, and a DB CHECK constraint (`medication_schedules_rrule_xor_rolling`). v1.7.0 adds `scheduleType` (SCHEDULED / PRN / CYCLIC) and the cyclic on/off-week fields. + MedicationListLayoutOutput: + type: object + properties: + version: + type: number + const: 1 + view: + description: Which presentation /medications renders in. Default "cards". Optional on PUT — when omitted the stored + value is preserved (preserve-when-absent, like `heroVisible` on the dashboard layout). Always present on + responses. + type: string + enum: + - cards + - table + order: + description: User-defined manual medication order (medication ids, first = top), shared by both views. Display-only — + unknown / deleted ids are ignored at render time, never 422. Optional on PUT (preserve-when-absent); always + present on responses. + maxItems: 200 + type: array + items: + type: string + minLength: 1 + maxLength: 64 + required: + - version + additionalProperties: false + description: "Per-user /medications presentation: the card/table view choice plus the manual medication order shared by + both views. Mirrors the dashboard-widgets / insights-layout contract." DisableCoachFlagOutput: type: object properties: diff --git a/messages/de.json b/messages/de.json index d49bcd2ae..f5debfaca 100644 --- a/messages/de.json +++ b/messages/de.json @@ -1714,7 +1714,28 @@ "trackInjectionSitesToggle": "Injektionsstellen erfassen", "trackInjectionSitesHint": "Nach einer eingenommenen Dosis nach der verwendeten Stelle fragen und die nächste Rotation vorschlagen.", "allowedSitesLabel": "Erlaubte Stellen", - "allowedSitesHint": "Alle ausgewählt lassen für keine Einschränkung. Global ausgeschlossene Stellen werden nie angeboten." + "allowedSitesHint": "Alle ausgewählt lassen für keine Einschränkung. Global ausgeschlossene Stellen werden nie angeboten.", + "viewToggleLabel": "Ansicht", + "viewCards": "Kartenansicht", + "viewTable": "Tabellenansicht", + "viewSaveFailed": "Ansicht konnte nicht gespeichert werden", + "tableCaption": "Medikamente in Tabellenform mit Status, nächster Einnahme, Therapietreue und Bestand", + "tableColName": "Name", + "tableColStatus": "Status", + "tableColNextDose": "Nächste Einnahme", + "tableColCompliance": "Therapietreue", + "tableColStock": "Bestand", + "tableColActions": "Aktionen", + "tableSortBy": "Sortieren nach {column}", + "tableComplianceDays": "{days} T", + "tableStockDoseOne": "1 Dosis", + "tableStockDoses": "{count} Dosen", + "takeActionFor": "Einnehmen – {name}", + "skipActionFor": "Überspringen – {name}", + "reorderTitle": "Reihenfolge anpassen", + "reorderDescription": "Bestimme die Reihenfolge deiner Medikamente – sie gilt für die Karten- und die Tabellenansicht.", + "reorderSaved": "Reihenfolge gespeichert", + "reorderSaveFailed": "Reihenfolge konnte nicht gespeichert werden" }, "charts": { "loadingLabel": "Diagramm wird geladen…", diff --git a/messages/en.json b/messages/en.json index e0c9d9a60..5616d3971 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1714,7 +1714,28 @@ "trackInjectionSitesToggle": "Track injection sites", "trackInjectionSitesHint": "After a dose is taken, ask which site was used and suggest the next rotation.", "allowedSitesLabel": "Allowed sites", - "allowedSitesHint": "Leave all selected for no restriction. Sites you excluded globally are never offered." + "allowedSitesHint": "Leave all selected for no restriction. Sites you excluded globally are never offered.", + "viewToggleLabel": "View", + "viewCards": "Card view", + "viewTable": "Table view", + "viewSaveFailed": "View could not be saved", + "tableCaption": "Medications as a table with status, next dose, adherence and stock", + "tableColName": "Name", + "tableColStatus": "Status", + "tableColNextDose": "Next dose", + "tableColCompliance": "Adherence", + "tableColStock": "Stock", + "tableColActions": "Actions", + "tableSortBy": "Sort by {column}", + "tableComplianceDays": "{days} d", + "tableStockDoseOne": "1 dose", + "tableStockDoses": "{count} doses", + "takeActionFor": "Take – {name}", + "skipActionFor": "Skip – {name}", + "reorderTitle": "Adjust order", + "reorderDescription": "Set the order of your medications – it applies to both the card and the table view.", + "reorderSaved": "Order saved", + "reorderSaveFailed": "Order could not be saved" }, "charts": { "loadingLabel": "Loading chart…", diff --git a/messages/es.json b/messages/es.json index 6413beec0..fbb57fbb3 100644 --- a/messages/es.json +++ b/messages/es.json @@ -1714,7 +1714,28 @@ "trackInjectionSitesToggle": "Registrar sitios de inyección", "trackInjectionSitesHint": "Tras administrar una dosis, pregunta qué sitio se usó y sugiere la siguiente rotación.", "allowedSitesLabel": "Sitios permitidos", - "allowedSitesHint": "Deja todos seleccionados para no aplicar restricciones. Los sitios que excluiste globalmente nunca se ofrecen." + "allowedSitesHint": "Deja todos seleccionados para no aplicar restricciones. Los sitios que excluiste globalmente nunca se ofrecen.", + "viewToggleLabel": "Vista", + "viewCards": "Vista de tarjetas", + "viewTable": "Vista de tabla", + "viewSaveFailed": "No se pudo guardar la vista", + "tableCaption": "Medicamentos en tabla con estado, próxima toma, adherencia y existencias", + "tableColName": "Nombre", + "tableColStatus": "Estado", + "tableColNextDose": "Próxima toma", + "tableColCompliance": "Adherencia", + "tableColStock": "Existencias", + "tableColActions": "Acciones", + "tableSortBy": "Ordenar por {column}", + "tableComplianceDays": "{days} d", + "tableStockDoseOne": "1 dosis", + "tableStockDoses": "{count} dosis", + "takeActionFor": "Tomar – {name}", + "skipActionFor": "Omitir – {name}", + "reorderTitle": "Ajustar orden", + "reorderDescription": "Define el orden de tus medicamentos: se aplica a la vista de tarjetas y a la de tabla.", + "reorderSaved": "Orden guardado", + "reorderSaveFailed": "No se pudo guardar el orden" }, "charts": { "loadingLabel": "Cargando la gráfica…", diff --git a/messages/fr.json b/messages/fr.json index 19487aaa5..a4dc6c116 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -1714,7 +1714,28 @@ "trackInjectionSitesToggle": "Suivre les sites d'injection", "trackInjectionSitesHint": "Après la prise d'une dose, demander quel site a été utilisé et suggérer la prochaine rotation.", "allowedSitesLabel": "Sites autorisés", - "allowedSitesHint": "Laissez tout sélectionné pour aucune restriction. Les sites exclus globalement ne sont jamais proposés." + "allowedSitesHint": "Laissez tout sélectionné pour aucune restriction. Les sites exclus globalement ne sont jamais proposés.", + "viewToggleLabel": "Affichage", + "viewCards": "Vue cartes", + "viewTable": "Vue tableau", + "viewSaveFailed": "L'affichage n'a pas pu être enregistré", + "tableCaption": "Médicaments sous forme de tableau avec statut, prochaine prise, observance et stock", + "tableColName": "Nom", + "tableColStatus": "Statut", + "tableColNextDose": "Prochaine prise", + "tableColCompliance": "Observance", + "tableColStock": "Stock", + "tableColActions": "Actions", + "tableSortBy": "Trier par {column}", + "tableComplianceDays": "{days} j", + "tableStockDoseOne": "1 dose", + "tableStockDoses": "{count} doses", + "takeActionFor": "Prendre – {name}", + "skipActionFor": "Passer – {name}", + "reorderTitle": "Modifier l'ordre", + "reorderDescription": "Définis l'ordre de tes médicaments – il s'applique à la vue cartes et à la vue tableau.", + "reorderSaved": "Ordre enregistré", + "reorderSaveFailed": "L'ordre n'a pas pu être enregistré" }, "charts": { "loadingLabel": "Chargement du graphique…", diff --git a/messages/it.json b/messages/it.json index 097725139..45fadd671 100644 --- a/messages/it.json +++ b/messages/it.json @@ -1714,7 +1714,28 @@ "trackInjectionSitesToggle": "Registra i siti di iniezione", "trackInjectionSitesHint": "Dopo l'assunzione di una dose, chiedi quale sito è stato usato e suggerisci la rotazione successiva.", "allowedSitesLabel": "Siti consentiti", - "allowedSitesHint": "Lascia tutti selezionati per nessuna restrizione. I siti esclusi globalmente non vengono mai proposti." + "allowedSitesHint": "Lascia tutti selezionati per nessuna restrizione. I siti esclusi globalmente non vengono mai proposti.", + "viewToggleLabel": "Vista", + "viewCards": "Vista a schede", + "viewTable": "Vista tabella", + "viewSaveFailed": "Impossibile salvare la vista", + "tableCaption": "Farmaci in tabella con stato, prossima assunzione, aderenza e scorte", + "tableColName": "Nome", + "tableColStatus": "Stato", + "tableColNextDose": "Prossima assunzione", + "tableColCompliance": "Aderenza", + "tableColStock": "Scorte", + "tableColActions": "Azioni", + "tableSortBy": "Ordina per {column}", + "tableComplianceDays": "{days} g", + "tableStockDoseOne": "1 dose", + "tableStockDoses": "{count} dosi", + "takeActionFor": "Assumi – {name}", + "skipActionFor": "Salta – {name}", + "reorderTitle": "Modifica ordine", + "reorderDescription": "Imposta l'ordine dei tuoi farmaci: vale per la vista a schede e per la tabella.", + "reorderSaved": "Ordine salvato", + "reorderSaveFailed": "Impossibile salvare l’ordine" }, "charts": { "loadingLabel": "Caricamento del grafico…", diff --git a/messages/pl.json b/messages/pl.json index 61fb2f695..9004b7804 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -1714,7 +1714,28 @@ "trackInjectionSitesToggle": "Rejestruj miejsca iniekcji", "trackInjectionSitesHint": "Po przyjęciu dawki zapytaj, którego miejsca użyto, i zaproponuj kolejną rotację.", "allowedSitesLabel": "Dozwolone miejsca", - "allowedSitesHint": "Pozostaw wszystkie zaznaczone, aby nie stosować ograniczeń. Miejsca wykluczone globalnie nigdy nie są proponowane." + "allowedSitesHint": "Pozostaw wszystkie zaznaczone, aby nie stosować ograniczeń. Miejsca wykluczone globalnie nigdy nie są proponowane.", + "viewToggleLabel": "Widok", + "viewCards": "Widok kart", + "viewTable": "Widok tabeli", + "viewSaveFailed": "Nie udało się zapisać widoku", + "tableCaption": "Leki w formie tabeli ze statusem, następną dawką, przestrzeganiem zaleceń i zapasem", + "tableColName": "Nazwa", + "tableColStatus": "Status", + "tableColNextDose": "Następna dawka", + "tableColCompliance": "Przestrzeganie", + "tableColStock": "Zapas", + "tableColActions": "Akcje", + "tableSortBy": "Sortuj według {column}", + "tableComplianceDays": "{days} dni", + "tableStockDoseOne": "1 dawka", + "tableStockDoses": "{count} dawek", + "takeActionFor": "Przyjmij – {name}", + "skipActionFor": "Pomiń – {name}", + "reorderTitle": "Zmień kolejność", + "reorderDescription": "Ustal kolejność swoich leków – obowiązuje w widoku kart i w tabeli.", + "reorderSaved": "Kolejność zapisana", + "reorderSaveFailed": "Nie udało się zapisać kolejności" }, "charts": { "loadingLabel": "Ładowanie wykresu…", diff --git a/prisma/migrations/0150_v11610_medication_list_layout/migration.sql b/prisma/migrations/0150_v11610_medication_list_layout/migration.sql new file mode 100644 index 000000000..13785ab4e --- /dev/null +++ b/prisma/migrations/0150_v11610_medication_list_layout/migration.sql @@ -0,0 +1,11 @@ +-- v1.16.10 — medications list presentation preference. +-- +-- `medication_list_layout_json` (users): the per-user card/table view +-- choice for /medications plus the user-defined manual medication order +-- shared by both views. Follows the per-surface-column convention of +-- `dashboard_widgets_json` / `insights_layout_json`. NULL = defaults +-- (cards, alphabetical order); the GET endpoint never lazy-writes. +-- +-- Shape: { "version": 1, "view": "table", "order": ["", ...] } + +ALTER TABLE "users" ADD COLUMN "medication_list_layout_json" JSONB; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 099674630..79c5acbfd 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -137,6 +137,14 @@ model User { // the user has explicitly saved a layout. // Shape: { "tiles": [{ "id": "blutdruck", "visible": true, "order": 0 }, ...], "version": 1 } insightsLayoutJson Json? @map("insights_layout_json") + // Per-user medications list presentation — the card/table view choice + // plus the user-defined manual medication order shared by BOTH views. + // Follows the per-surface-column convention of `dashboardWidgetsJson` / + // `insightsLayoutJson`. Null = defaults (cards, alphabetical order). + // The GET endpoint never lazy-writes — the column only carries data + // once the user has explicitly toggled the view or saved an order. + // Shape: { "version": 1, "view": "table", "order": ["", ...] } + medicationListLayoutJson Json? @map("medication_list_layout_json") // Blood-glucose display unit preference. Canonical storage is mg/dL. glucoseUnit String? @map("glucose_unit") // "mg/dL" | "mmol/L" | null = default "mg/dL" // v1.7.0 unit pref — global metric/imperial display preference. diff --git a/src/app/api/medications/__tests__/route.test.ts b/src/app/api/medications/__tests__/route.test.ts index c00b3f3b0..5f54b423e 100644 --- a/src/app/api/medications/__tests__/route.test.ts +++ b/src/app/api/medications/__tests__/route.test.ts @@ -17,6 +17,9 @@ vi.mock("@/lib/db", () => ({ medicationScheduleRevision: { groupBy: vi.fn().mockResolvedValue([]), }, + medicationInventoryItem: { + groupBy: vi.fn().mockResolvedValue([]), + }, medicationCategoryAssignment: { findMany: vi.fn().mockResolvedValue([]), }, @@ -390,6 +393,9 @@ describe("GET /api/medications — todayEventCount counts only actioned rows (v1 vi.mocked(prisma.medicationScheduleRevision.groupBy).mockResolvedValue( [] as never, ); + vi.mocked(prisma.medicationInventoryItem.groupBy).mockResolvedValue( + [] as never, + ); // The handler body takes no parameters; `apiHandler` still receives // the request through the wrapper's rest args. const res = await ( diff --git a/src/app/api/medications/layout/__tests__/route.test.ts b/src/app/api/medications/layout/__tests__/route.test.ts new file mode 100644 index 000000000..e6c075a20 --- /dev/null +++ b/src/app/api/medications/layout/__tests__/route.test.ts @@ -0,0 +1,277 @@ +/** + * v1.16.10 — `/api/medications/layout` route. + * + * Mirrors the insights-layout test deck: GET returns the defaults for + * users who have not saved a blob, PUT round-trips through the + * serializer with preserve-when-absent semantics for `view` / `order`, + * 422s carry the multi-issue envelope, DELETE returns the canonical + * defaults. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/db", () => ({ + prisma: { + user: { + findUnique: vi.fn(), + update: vi.fn(), + }, + auditLog: { + create: vi.fn(), + }, + }, + toJson: (v: unknown) => v, +})); + +vi.mock("@/lib/auth/session", () => ({ + getSession: vi.fn(), +})); + +vi.mock("@/lib/logging/transports", () => ({ + emitIfSampled: vi.fn(), +})); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { GET, PUT, DELETE } from "../route"; +import { prisma } from "@/lib/db"; +import { getSession } from "@/lib/auth/session"; +import { __resetAllCachesForTests } from "@/lib/cache/server-cache"; +import { __resetAuditDedupMemoForTests } from "@/lib/audit-dedup"; +import { DEFAULT_MEDICATION_LIST_LAYOUT } from "@/lib/medication-list-layout"; + +const SESSION_OK = { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { + id: "user-1", + username: "tester", + role: "USER" as const, + displayName: null, + }, +}; + +const callGet = GET as unknown as () => Promise; +const callPut = PUT as unknown as (req: NextRequest) => Promise; +const callDelete = DELETE as unknown as () => Promise; + +function makeReq(body: unknown): NextRequest { + return new NextRequest("http://localhost/api/medications/layout", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +beforeEach(() => { + vi.resetAllMocks(); + __resetAllCachesForTests(); + __resetAuditDedupMemoForTests(); + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never); +}); + +describe("GET /api/medications/layout", () => { + it("returns the defaults when the user has no saved blob", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + medicationListLayoutJson: null, + } as never); + + const res = await callGet(); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: typeof DEFAULT_MEDICATION_LIST_LAYOUT; + }; + expect(body.data).toEqual(DEFAULT_MEDICATION_LIST_LAYOUT); + }); + + it("does not lazy-write a row on GET", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + medicationListLayoutJson: null, + } as never); + + await callGet(); + expect(prisma.user.update).not.toHaveBeenCalled(); + }); + + it("returns the saved blob through the resolver", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + medicationListLayoutJson: { + version: 1, + view: "table", + order: ["med-b", "med-a"], + }, + } as never); + + const res = await callGet(); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { view: string; order: string[] }; + }; + expect(body.data.view).toBe("table"); + expect(body.data.order).toEqual(["med-b", "med-a"]); + }); + + it("collapses a malformed stored blob onto the defaults instead of failing", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + medicationListLayoutJson: { view: "kanban", order: "nope" }, + } as never); + + const res = await callGet(); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { view: string; order: string[] }; + }; + expect(body.data.view).toBe("cards"); + expect(body.data.order).toEqual([]); + }); +}); + +describe("PUT /api/medications/layout — preserve-when-absent", () => { + it("persists a full blob and echoes the normalised result", async () => { + vi.mocked(prisma.user.update).mockResolvedValue({} as never); + + const res = await callPut( + makeReq({ version: 1, view: "table", order: ["med-a", "med-b"] }), + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { version: number; view: string; order: string[] }; + }; + expect(body.data).toEqual({ + version: 1, + view: "table", + order: ["med-a", "med-b"], + }); + + expect(prisma.user.update).toHaveBeenCalledTimes(1); + const call = vi.mocked(prisma.user.update).mock.calls[0]?.[0] as { + where: { id: string }; + data: { medicationListLayoutJson: unknown }; + }; + expect(call.where.id).toBe("user-1"); + expect(call.data.medicationListLayoutJson).toEqual({ + version: 1, + view: "table", + order: ["med-a", "med-b"], + }); + // No stored-blob read needed — both fields were on the body. + expect(prisma.user.findUnique).not.toHaveBeenCalled(); + }); + + it("a view-only PUT preserves the stored manual order", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + medicationListLayoutJson: { + version: 1, + view: "cards", + order: ["med-z", "med-a"], + }, + } as never); + vi.mocked(prisma.user.update).mockResolvedValue({} as never); + + const res = await callPut(makeReq({ version: 1, view: "table" })); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { view: string; order: string[] }; + }; + expect(body.data.view).toBe("table"); + expect(body.data.order).toEqual(["med-z", "med-a"]); + }); + + it("an order-only PUT preserves the stored view", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + medicationListLayoutJson: { version: 1, view: "table", order: [] }, + } as never); + vi.mocked(prisma.user.update).mockResolvedValue({} as never); + + const res = await callPut( + makeReq({ version: 1, order: ["med-1", "med-2"] }), + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { view: string; order: string[] }; + }; + expect(body.data.view).toBe("table"); + expect(body.data.order).toEqual(["med-1", "med-2"]); + }); + + it("dedupes a repeated id in the submitted order", async () => { + vi.mocked(prisma.user.update).mockResolvedValue({} as never); + + const res = await callPut( + makeReq({ version: 1, view: "cards", order: ["a", "b", "a"] }), + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { data: { order: string[] } }; + expect(body.data.order).toEqual(["a", "b"]); + }); +}); + +describe("PUT /api/medications/layout — 422 multi-issue envelope", () => { + it("rejects an unknown view and a malformed order in one envelope", async () => { + const res = await callPut( + makeReq({ version: 1, view: "kanban", order: [""] }), + ); + expect(res.status).toBe(422); + const body = (await res.json()) as { + data: null; + error: string; + details: { issues: Array<{ path: string }> }; + }; + expect(body.data).toBeNull(); + expect(body.error).toBe("Validation failed"); + const paths = body.details.issues.map((i) => i.path); + expect(paths.some((p) => p.startsWith("view"))).toBe(true); + expect(paths.some((p) => p.startsWith("order"))).toBe(true); + }); + + it("rejects a wrong version", async () => { + const res = await callPut(makeReq({ version: 2, view: "table" })); + expect(res.status).toBe(422); + }); + + it("writes one deduped audit breadcrumb for repeated 422s", async () => { + const res1 = await callPut(makeReq({ version: 9 })); + const res2 = await callPut(makeReq({ version: 9 })); + expect(res1.status).toBe(422); + expect(res2.status).toBe(422); + + await new Promise((r) => setTimeout(r, 5)); + + expect(prisma.auditLog.create).toHaveBeenCalledTimes(1); + const call = vi.mocked(prisma.auditLog.create).mock.calls[0]?.[0] as { + data: { userId: string; action: string }; + }; + expect(call.data.userId).toBe("user-1"); + expect(call.data.action).toBe("medication.layout.validation-failed"); + }); +}); + +describe("DELETE /api/medications/layout", () => { + it("clears the saved blob and returns the defaults", async () => { + vi.mocked(prisma.user.update).mockResolvedValue({} as never); + + const res = await callDelete(); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: typeof DEFAULT_MEDICATION_LIST_LAYOUT; + }; + expect(body.data).toEqual(DEFAULT_MEDICATION_LIST_LAYOUT); + + expect(prisma.user.update).toHaveBeenCalledTimes(1); + const call = vi.mocked(prisma.user.update).mock.calls[0]?.[0] as { + data: { medicationListLayoutJson: unknown }; + }; + expect(call.data.medicationListLayoutJson).toBeDefined(); + }); +}); diff --git a/src/app/api/medications/layout/route.ts b/src/app/api/medications/layout/route.ts new file mode 100644 index 000000000..1fb39de3d --- /dev/null +++ b/src/app/api/medications/layout/route.ts @@ -0,0 +1,177 @@ +/** + * GET / PUT / DELETE medications list presentation. + * + * GET returns the resolved presentation (view + manual order, defaults + * merged in if the user hasn't customised yet). PUT updates it with + * preserve-when-absent semantics — a view-only PUT keeps the stored + * order and vice versa, matching the `heroVisible` contract on + * `/api/dashboard/widgets`. DELETE resets to default. + * + * Mirrors the shape and semantics of `/api/insights/layout`; the blob + * lives on its own `User` column (`medication_list_layout_json`) per + * the per-surface-column convention. + */ +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { + apiSuccess, + buildPayloadDiagnostic, + safeJson, + returnAllZodIssues, + sanitiseZodIssues, +} from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { prisma, toJson } from "@/lib/db"; +import { + resolveMedicationListLayout, + serializeMedicationListLayout, + DEFAULT_MEDICATION_LIST_LAYOUT, + MEDICATION_LIST_VIEWS, + MEDICATION_ORDER_ID_MAX_LENGTH, + MEDICATION_ORDER_MAX_ENTRIES, + type MedicationListLayout, +} from "@/lib/medication-list-layout"; +import { Prisma } from "@/generated/prisma/client"; +import { z } from "zod/v4"; +import { invalidateUserMedicationListLayout } from "@/lib/cache/invalidate"; +import { cached, caches, type ServerCache } from "@/lib/cache/server-cache"; +import { redactSensitiveFields } from "@/lib/observability/redact-payload"; +import { shouldEmitAuditRow } from "@/lib/audit-dedup"; +import type { NextRequest } from "next/server"; + +const layoutSchema = z.object({ + version: z.literal(1), + // Both fields are optional so a client can PUT exactly the field it + // changed; the handler merges the absent one from the stored blob + // (preserve-when-absent, like `heroVisible` on the dashboard layout). + view: z.enum(MEDICATION_LIST_VIEWS).optional(), + // Medication ids are opaque here — display-only ordering, unknown / + // deleted ids are ignored at apply time, so the schema bounds size + // and shape but does not assert ownership. + order: z + .array(z.string().min(1).max(MEDICATION_ORDER_ID_MAX_LENGTH)) + .max(MEDICATION_ORDER_MAX_ENTRIES) + .optional(), +}); + +async function buildMedicationListLayout( + userId: string, +): Promise { + const row = await prisma.user.findUnique({ + where: { id: userId }, + select: { medicationListLayoutJson: true }, + }); + return resolveMedicationListLayout(row?.medicationListLayoutJson); +} + +export const GET = apiHandler(async () => { + const { user } = await requireAuth(); + + // 5-minute TTL matches the dashboard-widgets / insights-layout + // caches; the blob changes only on a view toggle or an order save, + // which invalidates via `invalidateUserMedicationListLayout()`. + const layout = await cached( + caches.medicationListLayout as ServerCache, + user.id, + () => buildMedicationListLayout(user.id), + annotate, + ); + return apiSuccess(layout); +}); + +export const PUT = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 64 * 1024, + }); + if (jsonError) return jsonError; + + const parsed = layoutSchema.safeParse(body); + if (!parsed.success) { + // Multi-issue 422 envelope matches the dashboard-widgets / insights + // layout routes; the wide-event line carries the redacted payload + // shape for operator debugging without leaking the raw body. + const issues = sanitiseZodIssues(parsed.error.issues); + const payloadDiagnostic = buildPayloadDiagnostic( + redactSensitiveFields(body), + ); + annotate({ + action: { name: "medication.layout.validation-failed" }, + meta: { + issue_count: issues.length, + ...payloadDiagnostic, + zod_issues: issues, + }, + }); + if ( + shouldEmitAuditRow( + user.id, + "medication.layout.validation-failed", + Date.now(), + ) + ) { + // Best-effort breadcrumb — never block the 422 on a write miss. + // `message` strips from the audit row so a Zod code that embeds + // the offending value cannot leak through the audit surface. + const auditIssues = sanitiseZodIssues(parsed.error.issues, { + stripValuesFromMessage: true, + }); + prisma.auditLog + .create({ + data: { + userId: user.id, + action: "medication.layout.validation-failed", + details: JSON.stringify({ issues: auditIssues }), + }, + }) + .catch(() => { + /* swallow — validation response is the contract, audit row is best-effort */ + }); + } + return returnAllZodIssues(parsed.error, 422); + } + + // Preserve-when-absent: a PUT carrying only `view` must not wipe the + // stored manual order, and an order-only PUT must not reset the view. + // One stored-blob read covers both fallbacks. + let mergedView = parsed.data.view; + let mergedOrder = parsed.data.order; + if (mergedView === undefined || mergedOrder === undefined) { + const existing = await buildMedicationListLayout(user.id); + if (mergedView === undefined) mergedView = existing.view; + if (mergedOrder === undefined) mergedOrder = existing.order; + } + const normalized = serializeMedicationListLayout({ + view: mergedView, + order: mergedOrder, + }); + + await prisma.user.update({ + where: { id: user.id }, + data: { medicationListLayoutJson: toJson(normalized) }, + }); + + annotate({ + action: { name: "medication.layout.update" }, + meta: { view: normalized.view, order_count: normalized.order.length }, + }); + + invalidateUserMedicationListLayout(user.id); + + return apiSuccess(normalized); +}); + +export const DELETE = apiHandler(async () => { + const { user } = await requireAuth(); + + await prisma.user.update({ + where: { id: user.id }, + data: { medicationListLayoutJson: Prisma.JsonNull }, + }); + + annotate({ action: { name: "medication.layout.reset" } }); + + invalidateUserMedicationListLayout(user.id); + + return apiSuccess(DEFAULT_MEDICATION_LIST_LAYOUT); +}); diff --git a/src/app/api/medications/route.ts b/src/app/api/medications/route.ts index f8da7dde9..219c633f4 100644 --- a/src/app/api/medications/route.ts +++ b/src/app/api/medications/route.ts @@ -54,8 +54,15 @@ async function buildMedicationsList( todayStartUtc.getTime() - OVERDUE_LOOKBACK_MS, ); - const [medications, latestIntakes, todayEvents, resolvedEvents, eraFloors] = - await Promise.all([ + const [ + medications, + latestIntakes, + todayEvents, + resolvedEvents, + eraFloors, + usableStock, + inventoryCounts, + ] = await Promise.all([ prisma.medication.findMany({ where: { userId }, include: { schedules: true }, @@ -117,6 +124,28 @@ async function buildMedicationsList( where: { medication: { userId }, supersededByRevisionId: null }, _max: { validUntil: true }, }), + // v1.16.10 — usable stock per medication (one batched aggregate, + // not per-row): the sum of `unitsRemaining` over ACTIVE / IN_USE + // containers with units left — the same usable-container filter + // the GLP-1 details endpoint applies. Feeds the list payload's + // `stockUnitsRemaining` / `stockDosesRemaining` for the table view. + prisma.medicationInventoryItem.groupBy({ + by: ["medicationId"], + where: { + userId, + state: { in: ["ACTIVE", "IN_USE"] }, + unitsRemaining: { gt: 0 }, + }, + _sum: { unitsRemaining: true }, + }), + // …and the any-state item count, so a medication whose containers + // are all used up / expired reads as stock 0 (tracking is ON, the + // supply ran out) instead of null (tracking off). + prisma.medicationInventoryItem.groupBy({ + by: ["medicationId"], + where: { userId }, + _count: { id: true }, + }), ]); const resolvedSlotsByMedId = new Map(); @@ -152,6 +181,15 @@ async function buildMedicationsList( ), ); + const usableUnitsByMedId = new Map(); + for (const entry of usableStock) { + usableUnitsByMedId.set( + entry.medicationId, + entry._sum.unitsRemaining ?? 0, + ); + } + const trackedMedIds = new Set(inventoryCounts.map((e) => e.medicationId)); + let categoryMap: Record = {}; try { categoryMap = await getMedicationCategories(medications.map((m) => m.id)); @@ -185,6 +223,17 @@ async function buildMedicationsList( resolvedSlots: resolvedSlotsByMedId.get(m.id) ?? [], eraStart: eraStartByMedId.get(m.id) ?? null, }); + // v1.16.10 — dose-derived stock for the table view. NULL when the + // medication has no inventory items at all (tracking off); 0 when + // tracking is on but every container is used up / expired. + const tracksInventory = trackedMedIds.has(m.id); + const stockUnitsRemaining = tracksInventory + ? (usableUnitsByMedId.get(m.id) ?? 0) + : null; + const stockDosesRemaining = + stockUnitsRemaining === null + ? null + : Math.floor(stockUnitsRemaining / Math.max(1, m.unitsPerDose)); return { ...m, category: categoryMap[m.id] ?? "OTHER", @@ -192,6 +241,8 @@ async function buildMedicationsList( todayEventCount: todayEventCountByMedId[m.id] ?? 0, nextDueAt: display ? display.at.toISOString() : null, nextDueOverdue: display?.overdue ?? false, + stockUnitsRemaining, + stockDosesRemaining, }; }); } diff --git a/src/app/medications/__tests__/list-view-toggle.test.tsx b/src/app/medications/__tests__/list-view-toggle.test.tsx new file mode 100644 index 000000000..409997fb9 --- /dev/null +++ b/src/app/medications/__tests__/list-view-toggle.test.tsx @@ -0,0 +1,165 @@ +/** + * v1.16.10 — /medications view-toggle wiring. + * + * Pins that the page renders the view the PERSISTED preference names + * (cards vs table from `GET /api/medications/layout`, seeded into the + * query cache), that the header toggle announces the active segment, + * and that the manual medication order applies to BOTH views. The PUT + * side of the persistence contract is pinned in + * `src/lib/queries/__tests__/use-medication-list-layout.test.ts`. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ replace: vi.fn(), push: vi.fn() }), + useSearchParams: () => new URLSearchParams(""), +})); + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ + isAuthenticated: true, + isLoading: false, + user: { timezone: "Europe/Berlin" }, + }), +})); + +// The create wizard and the dialogs are closed in every assertion here; +// mock them away so the page render stays scoped to the list surface. +vi.mock("@/components/medications/wizard/MedicationWizardDialog", () => ({ + MedicationWizardDialog: () => null, +})); +vi.mock("@/components/medications/log-intake-dialog", () => ({ + LogIntakeDialog: () => null, +})); +vi.mock("@/components/medications/medication-reorder-dialog", () => ({ + MedicationReorderDialog: () => null, +})); + +import MedicationsPage from "@/app/medications/page"; +import { I18nProvider } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; +import type { MedicationListLayout } from "@/lib/medication-list-layout"; + +const pastWindow = { + windowStart: "01:00", + windowEnd: "02:00", + label: null, + daysOfWeek: null, + dose: null, +}; + +const MEDS = [ + { + id: "m1", + name: "Ramipril", + dose: "5 mg", + category: "OTHER", + active: true, + notificationsEnabled: true, + pausedAt: null, + lastTakenAt: null, + todayEventCount: 0, + nextDueAt: null, + nextDueOverdue: false, + stockDosesRemaining: null, + schedules: [{ id: "s1", ...pastWindow }], + }, + { + id: "m2", + name: "Aspirin", + dose: "100 mg", + category: "OTHER", + active: true, + notificationsEnabled: true, + pausedAt: null, + lastTakenAt: null, + todayEventCount: 0, + nextDueAt: null, + nextDueOverdue: false, + stockDosesRemaining: null, + schedules: [{ id: "s2", ...pastWindow }], + }, +]; + +function renderPage(layout: MedicationListLayout): string { + const client = new QueryClient({ + defaultOptions: { + queries: { retry: false, gcTime: 0, staleTime: Infinity }, + }, + }); + client.setQueryData(queryKeys.medications(), MEDS); + client.setQueryData(queryKeys.medicationListLayout(), layout); + return renderToStaticMarkup( + + + + + , + ); +} + +beforeEach(() => { + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(new Date("2026-06-02T12:00:00Z")); +}); +afterEach(() => { + vi.useRealTimers(); +}); + +describe("/medications — view renders from the persisted preference", () => { + it("renders the card grid for view=cards and announces the active segment", () => { + const html = renderPage({ version: 1, view: "cards", order: [] }); + + expect(html).not.toContain("]*aria-pressed="true"|aria-pressed="true"[^>]*data-slot="medications-view-cards"/, + ); + expect(html).toMatch( + /data-slot="medications-view-table"[^>]*aria-pressed="false"|aria-pressed="false"[^>]*data-slot="medications-view-table"/, + ); + }); + + it("renders the table for view=table", () => { + const html = renderPage({ version: 1, view: "table", order: [] }); + + expect(html).toContain("]*aria-pressed="true"|aria-pressed="true"[^>]*data-slot="medications-view-table"/, + ); + }); + + it("offers the manual-order editor from the header", () => { + const html = renderPage({ version: 1, view: "cards", order: [] }); + expect(html).toContain('aria-label="Adjust order"'); + }); +}); + +describe("/medications — the manual order applies to BOTH views", () => { + // Alphabetical default would put Aspirin first; the saved order pins + // Ramipril to the top. Both views must follow it. + const layout: MedicationListLayout = { + version: 1, + view: "cards", + order: ["m1", "m2"], + }; + + it("orders the cards by the saved order", () => { + const html = renderPage(layout); + expect(html.indexOf("Ramipril")).toBeLessThan(html.indexOf("Aspirin")); + }); + + it("orders the table rows by the saved order", () => { + const html = renderPage({ ...layout, view: "table" }); + expect(html.indexOf("Ramipril")).toBeLessThan(html.indexOf("Aspirin")); + }); + + it("falls back to the alphabetical default without a saved order", () => { + const html = renderPage({ version: 1, view: "cards", order: [] }); + expect(html.indexOf("Aspirin")).toBeLessThan(html.indexOf("Ramipril")); + }); +}); diff --git a/src/app/medications/page.tsx b/src/app/medications/page.tsx index f061a6599..f948b5e80 100644 --- a/src/app/medications/page.tsx +++ b/src/app/medications/page.tsx @@ -10,6 +10,12 @@ import { MedicationWizardDialog } from "@/components/medications/wizard/Medicati import { MedicationCard } from "@/components/medications/medication-card"; import { Glp1MedicationCard } from "@/components/medications/glp1-medication-card"; import { LogIntakeDialog } from "@/components/medications/log-intake-dialog"; +import { + MedicationTable, + MedicationTableSkeleton, +} from "@/components/medications/medication-table"; +import { MedicationViewToggle } from "@/components/medications/medication-view-toggle"; +import { MedicationReorderDialog } from "@/components/medications/medication-reorder-dialog"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; import { @@ -20,8 +26,10 @@ import { } from "@/components/ui/dropdown-menu"; import { EmptyState } from "@/components/ui/empty-state"; import { Skeleton } from "@/components/ui/skeleton"; -import { CheckCircle2, Loader2, Pill, Plus } from "lucide-react"; +import { ArrowDownUp, CheckCircle2, Loader2, Pill, Plus } from "lucide-react"; import { apiGet } from "@/lib/api/api-fetch"; +import { useMedicationListLayout } from "@/lib/queries/use-medication-list-layout"; +import { applyMedicationOrder } from "@/lib/medications/medication-order"; interface Schedule { id: string; @@ -75,6 +83,14 @@ interface Medication { atcCode?: string | null; /** v1.9.0 — optional RxNorm RxCUI (secondary FHIR coding). */ rxNormCode?: string | null; + /** + * v1.16.10 — usable inventory units left across the medication's + * containers (ACTIVE / IN_USE with units remaining). Null = inventory + * tracking off (no items ever registered). + */ + stockUnitsRemaining?: number | null; + /** v1.16.10 — dose-derived stock (`stockUnitsRemaining / unitsPerDose`). */ + stockDosesRemaining?: number | null; schedules: Schedule[]; } @@ -133,6 +149,16 @@ export default function MedicationsPage() { // two paths: log an intake (incl. a backdated one) against an existing // medication, or create a new medication (the existing wizard). const [logIntakeOpen, setLogIntakeOpen] = useState(false); + // v1.16.10 — manual-order editor (shared by both views). + const [reorderOpen, setReorderOpen] = useState(false); + + // v1.16.10 — the persisted list presentation: cards vs table plus the + // manual medication order, server-side per user + // (`GET`/`PUT /api/medications/layout`). The toggle writes + // optimistically; the reorder dialog saves explicitly. + const { layout, isLayoutLoading, setView } = useMedicationListLayout( + isAuthenticated, + ); useEffect(() => { if (shouldOpenFromUrl) { @@ -211,18 +237,25 @@ export default function MedicationsPage() { ); } - const byName = (a: Medication, b: Medication) => - a.name.localeCompare(b.name, "de", { sensitivity: "base" }); - // Defensive against stale service-worker responses or any future API // shape change: only filter when we actually have an array. const medsArray = Array.isArray(medications) ? medications : []; - const activeMeds = medsArray.filter((m) => m.active).sort(byName); - const inactiveMeds = medsArray.filter((m) => !m.active).sort(byName); + // v1.16.10 — the user-defined manual order applies to BOTH views + // (cards and table). Medications not in the saved order keep the + // alphabetical default, appended after the ordered block. + const activeMeds = applyMedicationOrder( + medsArray.filter((m) => m.active), + layout.order, + ); + const inactiveMeds = applyMedicationOrder( + medsArray.filter((m) => !m.active), + layout.order, + ); + const tableView = layout.view === "table"; return (
-
+

{t("medications.title")} @@ -233,48 +266,75 @@ export default function MedicationsPage() { {t("medications.subtitle")}

- {/* v1.14.0 — the "Add" button is now a choice: log an intake - (incl. a backdated one) against an existing medication, or - create a new medication. v1.12.2 — match the dashboard Add - button's responsive tap-target floor (`min-h-11 sm:min-h-9`) so - both primary "add" entry points clear the WCAG 2.5.5 44px mobile - minimum identically. */} - - - - - - setLogIntakeOpen(true)} - disabled={activeMeds.length === 0} +
+ {/* v1.16.10 — card ⇄ table view toggle, persisted server-side + per user; plus the manual-order editor shared by both + views. The reorder affordance only earns its slot once + there is something to reorder. */} + + {medsArray.length > 1 && ( + + )} + {/* v1.14.0 — the "Add" button is now a choice: log an intake + (incl. a backdated one) against an existing medication, or + create a new medication. v1.12.2 — match the dashboard Add + button's responsive tap-target floor (`min-h-11 sm:min-h-9`) so + both primary "add" entry points clear the WCAG 2.5.5 44px mobile + minimum identically. */} + + + + + + setLogIntakeOpen(true)} + disabled={activeMeds.length === 0} + > + + {t("medications.addChoice.logIntake")} + + + + {t("medications.addChoice.newMedication")} + + + +
- {isLoading ? ( - // v1.11.3 C4 — card skeletons instead of a bare centred spinner so - // the page reserves the loaded layout and does not jump when the - // medications resolve. + {isLoading || isLayoutLoading ? ( + // v1.11.3 C4 — skeletons instead of a bare centred spinner so the + // page reserves the loaded layout and does not jump when the + // medications resolve. v1.16.10 — the skeleton follows the + // persisted view (table rows vs card grid) so resolving the data + // never swaps the footprint; while the view preference itself is + // still loading the default cards skeleton stands in.
- {Array.from({ length: 4 }).map((_, i) => ( - - ))} + {tableView && !isLayoutLoading ? ( + + ) : ( +
+ {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
+ )}
) : isError ? (
@@ -307,6 +367,14 @@ export default function MedicationsPage() { } /> + ) : tableView ? ( + // v1.16.10 — the compact table view. Same payloads, same status + // gating, same mutation hooks as the cards; one row per + // medication, inactive rows pinned after the active block. + ) : (
{/* Active medications */} @@ -391,6 +459,22 @@ export default function MedicationsPage() { date+time that supports backdating. Submits to the existing per-medication intake route (same slot upsert as a live "Taken" tap) so the one-row-per-dose-slot invariant holds. */} + {/* v1.16.10 — manual-order editor. The list is the page's current + effective order (active block first, inactive after), so the + dialog opens showing exactly what both views render. */} + {reorderOpen && ( + ({ + id: m.id, + name: m.name, + dose: m.dose, + active: m.active, + }))} + /> + )} + {logIntakeOpen && ( ` 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( + + {node} + , + ); +} + +// 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 & { 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(" — structure + shared payloads", () => { + it("renders real table semantics: scope=col headers, sticky first column, sr caption", () => { + const client = makeClient(); + const html = render( + , + client, + ); + + expect(html).toContain(" { + const client = makeClient(); + seedCompliance(client, "m1", 90, 88); + const html = render( + , + 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( + , + 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( + , + 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( + , + 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( + , + 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( + , + client, + ); + + expect(html).toContain("Mounjaro"); + expect(html).toContain('aria-label="Take – Mounjaro"'); + expect(html).toContain("4 doses"); + }); +}); + +describe(" — 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( + , + 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( + , + 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( + , + 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( + , + 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(" — reserved footprint", () => { + it("mirrors the loaded table shell (container, table, action slots) and hides from AT", () => { + const html = renderToStaticMarkup(); + expect(html).toContain('aria-hidden="true"'); + expect(html).toContain(" { + 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"); + }); +}); diff --git a/src/components/medications/card-parts/medication-status-pill.tsx b/src/components/medications/card-parts/medication-status-pill.tsx index 7f9e41be9..b937e315d 100644 --- a/src/components/medications/card-parts/medication-status-pill.tsx +++ b/src/components/medications/card-parts/medication-status-pill.tsx @@ -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 })} - - {" "} - — {formatTimeWindowRange(windowStart, windowEnd, locale)} - + {!compact && ( + + {" "} + — {formatTimeWindowRange(windowStart, windowEnd, locale)} + + )}

); } @@ -85,10 +95,12 @@ export function MedicationStatusPill({ ? t("medications.overdue") : t("medications.veryOverdue")} - - {" "} - — {formatTimeWindowRange(windowStart, windowEnd, locale)} - + {!compact && ( + + {" "} + — {formatTimeWindowRange(windowStart, windowEnd, locale)} + + )}

); } diff --git a/src/components/medications/medication-reorder-dialog.tsx b/src/components/medications/medication-reorder-dialog.tsx new file mode 100644 index 000000000..5dbb98baa --- /dev/null +++ b/src/components/medications/medication-reorder-dialog.tsx @@ -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(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 ( + + + + {t("medications.reorderTitle")} + + {t("medications.reorderDescription")} + + + +
+ + + {rows.map((med, index) => ( + + ))} + + + {rows.length > 0 && ( +

+ {t("dashboard.dragHandleHint")} +

+ )} +
+ + + + + +
+
+ ); +} + +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 ( +
+ + + + {medication.name} + + + {medication.dose} + {!medication.active && <> · {labels.inactive}} + + + + +
+ ); +} diff --git a/src/components/medications/medication-table.tsx b/src/components/medications/medication-table.tsx new file mode 100644 index 000000000..70d55b341 --- /dev/null +++ b/src/components/medications/medication-table.tsx @@ -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 `` 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( + rows: readonly T[], + sort: MedicationTableSort | null, + shortRateById?: ReadonlyMap, +): 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(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( + (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 ( +
+
+ + + + {columns.map((col) => + col.sortable ? ( + + + + ) : ( + + {col.label} + + ), + )} + + + + {sortedActive.map((med) => ( + + ))} + {sortedInactive.map((med) => ( + + ))} + +
{t("medications.tableCaption")}
+
+ ); +} + +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 ? ( + + ) : overdueLabel ? ( + + {overdueLabel} + + ) : currentWindowStatus.status ? ( + + ) : ( + – + ); + + // 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 = ( + – + ); + if (medication.active) { + if (medication.nextDueOverdue && nextAt) { + nextCell = ( + + {t("medications.nextIntakeOverdue", { + time: formatTime(new Date(nextAt).toISOString()), + })} + + ); + } else if (currentWindowStatus.status === "in_window") { + nextCell = ( + + {formatTimeWindowRange( + currentWindowStatus.window!.start, + currentWindowStatus.window!.end, + locale, + )} + + ); + } 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 = ( + + {dayLabel}, {formatTime(new Date(nextAt).toISOString())} + + ); + } else if (nextSchedule) { + nextCell = ( + + {formatTimeWindowRange( + nextSchedule.windowStart, + nextSchedule.windowEnd, + locale, + )} + + ); + } + } + + // Therapietreue cell — the same two cadence-scaled windows the card + // bars show, compacted to label + mini bar + percentage per row. + const complianceCell = compliance ? ( +
+ {( + [ + [shortDays, rateShort], + [longDays, rateLong], + ] as const + ).map(([days, rate]) => ( +
+ + {t("medications.tableComplianceDays", { days })} + + + + {fmt.number(Math.round(rate))}% + +
+ ))} +
+ ) : ( + + ); + + const stock = medication.stockDosesRemaining; + const stockCell = + stock === null || stock === undefined ? ( + – + ) : ( + + {stock === 1 + ? t("medications.tableStockDoseOne") + : t("medications.tableStockDoses", { count: stock })} + + ); + + return ( + + {/* Sticky name column — opaque background so scrolled content + passes underneath, not through. */} + + + + {medication.name} + + + {medication.dose} + {!medication.active && <> · {t("common.inactive")}} + + + + {statusCell} + {nextCell} + {medication.active ? complianceCell : –} + {stockCell} + + {medication.active ? ( +
+ + +
+ ) : ( + – + )} +
+
+ ); +} + +/** + * 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 ( + + ); +} diff --git a/src/components/medications/medication-view-toggle.tsx b/src/components/medications/medication-view-toggle.tsx new file mode 100644 index 000000000..e9e08d109 --- /dev/null +++ b/src/components/medications/medication-view-toggle.tsx @@ -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 ( +
+ {segments.map(({ value, label, icon: Icon }) => { + const active = view === value; + return ( + + ); + })} +
+ ); +} diff --git a/src/lib/__tests__/medication-list-layout.test.ts b/src/lib/__tests__/medication-list-layout.test.ts new file mode 100644 index 000000000..ac9e0313d --- /dev/null +++ b/src/lib/__tests__/medication-list-layout.test.ts @@ -0,0 +1,92 @@ +/** + * v1.16.10 — medications list presentation blob (resolver + serializer). + * + * The resolver must never throw on a stored row (malformed / legacy / + * partial blobs collapse onto the defaults field-by-field); the + * serializer dedupes and caps the manual order so the persisted blob + * stays bounded and canonical. + */ +import { describe, expect, it } from "vitest"; + +import { + DEFAULT_MEDICATION_LIST_LAYOUT, + MEDICATION_ORDER_MAX_ENTRIES, + resolveMedicationListLayout, + serializeMedicationListLayout, +} from "@/lib/medication-list-layout"; + +describe("resolveMedicationListLayout", () => { + it("returns the defaults for a null / missing column", () => { + expect(resolveMedicationListLayout(null)).toEqual( + DEFAULT_MEDICATION_LIST_LAYOUT, + ); + expect(resolveMedicationListLayout(undefined)).toEqual( + DEFAULT_MEDICATION_LIST_LAYOUT, + ); + }); + + it("returns the defaults for a non-object blob", () => { + expect(resolveMedicationListLayout("cards")).toEqual( + DEFAULT_MEDICATION_LIST_LAYOUT, + ); + expect(resolveMedicationListLayout(42)).toEqual( + DEFAULT_MEDICATION_LIST_LAYOUT, + ); + }); + + it("resolves a complete stored blob verbatim", () => { + expect( + resolveMedicationListLayout({ + version: 1, + view: "table", + order: ["med-b", "med-a"], + }), + ).toEqual({ version: 1, view: "table", order: ["med-b", "med-a"] }); + }); + + it("falls back per field: unknown view → cards, malformed order → empty", () => { + expect( + resolveMedicationListLayout({ view: "kanban", order: "med-a" }), + ).toEqual({ version: 1, view: "cards", order: [] }); + }); + + it("drops non-string and oversized order entries, keeping the rest", () => { + const resolved = resolveMedicationListLayout({ + view: "table", + order: ["med-a", 7, null, "x".repeat(65), "med-b"], + }); + expect(resolved.order).toEqual(["med-a", "med-b"]); + }); + + it("dedupes a stored order (first occurrence wins)", () => { + const resolved = resolveMedicationListLayout({ + view: "cards", + order: ["med-a", "med-b", "med-a"], + }); + expect(resolved.order).toEqual(["med-a", "med-b"]); + }); +}); + +describe("serializeMedicationListLayout", () => { + it("emits the canonical v1 blob", () => { + expect( + serializeMedicationListLayout({ view: "table", order: ["med-a"] }), + ).toEqual({ version: 1, view: "table", order: ["med-a"] }); + }); + + it("dedupes the order before persisting", () => { + expect( + serializeMedicationListLayout({ + view: "cards", + order: ["a", "b", "a", "c", "b"], + }).order, + ).toEqual(["a", "b", "c"]); + }); + + it("caps the order at the documented maximum", () => { + const order = Array.from({ length: 500 }, (_, i) => `med-${i}`); + expect( + serializeMedicationListLayout({ view: "cards", order }).order.length, + ).toBe(MEDICATION_ORDER_MAX_ENTRIES); + }); +}); diff --git a/src/lib/cache/invalidate.ts b/src/lib/cache/invalidate.ts index 140769403..80be52ea2 100644 --- a/src/lib/cache/invalidate.ts +++ b/src/lib/cache/invalidate.ts @@ -246,6 +246,16 @@ export function invalidateUserInsightsLayout(userId: string): void { caches.insightsLayout.deleteByPrefix(userId); } +/** + * v1.16.10 — invalidate the medications list-presentation cache for a + * single user. Called from the medications-layout PUT / DELETE + * endpoints when the user toggles the view or saves a manual order so + * the next /medications mount paints the new presentation. + */ +export function invalidateUserMedicationListLayout(userId: string): void { + caches.medicationListLayout.deleteByPrefix(userId); +} + /** * v1.16.9 — full per-user cache eviction for the wipe / restore paths. * A data wipe or a backup restore replaces (or removes) every row the @@ -265,6 +275,7 @@ export function invalidateUserData(userId: string): void { caches.workouts.deleteByPrefix(`${userId}|`); caches.dashboardWidgets.deleteByPrefix(userId); caches.insightsLayout.deleteByPrefix(userId); + caches.medicationListLayout.deleteByPrefix(userId); } /** diff --git a/src/lib/cache/server-cache.ts b/src/lib/cache/server-cache.ts index c19601b2a..580fd462b 100644 --- a/src/lib/cache/server-cache.ts +++ b/src/lib/cache/server-cache.ts @@ -610,6 +610,18 @@ export const caches = { maxEntries: 500, ttlMs: 300_000, }), + /** + * v1.16.10 — per-user medications list presentation (view choice + + * manual order). Mirrors `insightsLayout` (same 5-minute TTL, same + * per-user bucket size). Read on every /medications mount; changes + * only on a view toggle / order save, which invalidates via + * `invalidateUserMedicationListLayout()`. + */ + medicationListLayout: new ServerCache({ + name: "medicationListLayout", + maxEntries: 500, + ttlMs: 300_000, + }), } as const; /** diff --git a/src/lib/medication-list-layout.ts b/src/lib/medication-list-layout.ts new file mode 100644 index 000000000..7aa5ae60e --- /dev/null +++ b/src/lib/medication-list-layout.ts @@ -0,0 +1,113 @@ +/** + * Medications list presentation — persisted in + * `User.medicationListLayoutJson`. + * + * Single source of truth for the /medications view choice (cards vs the + * compact table) and the user-defined manual medication order shared by + * BOTH views. Null / missing column = the defaults below; the GET + * endpoint never lazy-writes a row, so the column only carries data once + * the user has explicitly toggled the view or saved an order. + * + * Mirrors `insights-layout.ts` / `dashboard-layout.ts` — same separate + * per-surface column on `User`, same resolver / serializer semantics, + * same preserve-when-absent PUT contract (see the route). + */ + +/** The two list presentations /medications can render. */ +export const MEDICATION_LIST_VIEWS = ["cards", "table"] as const; + +export type MedicationListView = (typeof MEDICATION_LIST_VIEWS)[number]; + +export interface MedicationListLayout { + version: 1; + /** Which presentation the list renders in. Default: cards. */ + view: MedicationListView; + /** + * User-defined manual medication order (medication ids, first = top). + * Applied to BOTH views; medications not in the list append after the + * ordered block in the alphabetical default order. Ids that no longer + * resolve to a medication are ignored at apply time (a deleted + * medication must not 422 the stored layout), so the list is bounded + * but not ownership-validated — it is display-only. + */ + order: string[]; +} + +/** + * Upper bound on the persisted order list. Generous — the medications + * list itself has no hard cap, but the blob must stay small; beyond + * this many entries the tail falls back to the alphabetical default. + */ +export const MEDICATION_ORDER_MAX_ENTRIES = 200; + +/** Per-entry id length bound (cuids are 25 chars; leave headroom). */ +export const MEDICATION_ORDER_ID_MAX_LENGTH = 64; + +export const DEFAULT_MEDICATION_LIST_LAYOUT: MedicationListLayout = { + version: 1, + view: "cards", + order: [], +}; + +function isMedicationListView(value: unknown): value is MedicationListView { + return ( + typeof value === "string" && + (MEDICATION_LIST_VIEWS as readonly string[]).includes(value) + ); +} + +/** + * Tolerant read-side resolver: any malformed / legacy / partial blob + * collapses onto the defaults field-by-field, so a GET can never fail on + * a stored row and a new field added later defaults cleanly for blobs + * persisted before it existed. + */ +export function resolveMedicationListLayout( + raw: unknown, +): MedicationListLayout { + if (raw === null || raw === undefined || typeof raw !== "object") { + return { ...DEFAULT_MEDICATION_LIST_LAYOUT, order: [] }; + } + const blob = raw as { view?: unknown; order?: unknown }; + const view = isMedicationListView(blob.view) + ? blob.view + : DEFAULT_MEDICATION_LIST_LAYOUT.view; + const order = Array.isArray(blob.order) + ? dedupeOrder( + blob.order.filter( + (id): id is string => + typeof id === "string" && + id.length > 0 && + id.length <= MEDICATION_ORDER_ID_MAX_LENGTH, + ), + ) + : []; + return { version: 1, view, order }; +} + +/** + * Write-side normaliser: dedupes the order list (first occurrence wins) + * and caps it. The route merges absent fields from the stored blob + * BEFORE calling this, so the result is always a complete layout. + */ +export function serializeMedicationListLayout(input: { + view: MedicationListView; + order: string[]; +}): MedicationListLayout { + return { + version: 1, + view: input.view, + order: dedupeOrder(input.order).slice(0, MEDICATION_ORDER_MAX_ENTRIES), + }; +} + +function dedupeOrder(order: readonly string[]): string[] { + const seen = new Set(); + const out: string[] = []; + for (const id of order) { + if (seen.has(id)) continue; + seen.add(id); + out.push(id); + } + return out; +} diff --git a/src/lib/medications/__tests__/medication-order.test.ts b/src/lib/medications/__tests__/medication-order.test.ts new file mode 100644 index 000000000..27bcb0af5 --- /dev/null +++ b/src/lib/medications/__tests__/medication-order.test.ts @@ -0,0 +1,52 @@ +/** + * v1.16.10 — the manual medication order shared by BOTH /medications + * views. Ordered ids first (in saved order), everything else appends in + * the alphabetical default; unknown ids are ignored. + */ +import { describe, expect, it } from "vitest"; + +import { applyMedicationOrder } from "@/lib/medications/medication-order"; + +const meds = [ + { id: "m1", name: "Ramipril" }, + { id: "m2", name: "Aspirin" }, + { id: "m3", name: "Mounjaro" }, +]; + +describe("applyMedicationOrder", () => { + it("returns the alphabetical default for an empty order", () => { + expect(applyMedicationOrder(meds, []).map((m) => m.name)).toEqual([ + "Aspirin", + "Mounjaro", + "Ramipril", + ]); + }); + + it("applies the saved order verbatim when every id is named", () => { + expect( + applyMedicationOrder(meds, ["m3", "m1", "m2"]).map((m) => m.id), + ).toEqual(["m3", "m1", "m2"]); + }); + + it("appends unnamed medications alphabetically after the ordered block", () => { + // A medication created after the last order save must surface, not + // vanish — it lands after the ordered block in the default order. + expect(applyMedicationOrder(meds, ["m3"]).map((m) => m.id)).toEqual([ + "m3", + "m2", // Aspirin + "m1", // Ramipril + ]); + }); + + it("ignores ids that no longer resolve to a medication", () => { + expect( + applyMedicationOrder(meds, ["deleted-med", "m2"]).map((m) => m.id), + ).toEqual(["m2", "m3", "m1"]); + }); + + it("does not mutate the input array", () => { + const input = [...meds]; + applyMedicationOrder(input, ["m3", "m1"]); + expect(input).toEqual(meds); + }); +}); diff --git a/src/lib/medications/medication-order.ts b/src/lib/medications/medication-order.ts new file mode 100644 index 000000000..0419c1f08 --- /dev/null +++ b/src/lib/medications/medication-order.ts @@ -0,0 +1,37 @@ +/** + * v1.16.10 — the user-defined manual medication order, applied to BOTH + * /medications views (cards and table). + * + * The persisted layout carries an ordered list of medication ids + * (`MedicationListLayout.order`). Medications named there render first, + * in that order; every other medication (newly created since the last + * save, or never ordered) appends after the ordered block in the + * alphabetical default the list always used. Ids that no longer resolve + * to a medication are simply ignored — a deleted medication must not + * corrupt the order. + */ + +/** The default tiebreak the list used before manual ordering existed. */ +export function compareMedicationNames( + a: { name: string }, + b: { name: string }, +): number { + return a.name.localeCompare(b.name, "de", { sensitivity: "base" }); +} + +export function applyMedicationOrder( + medications: readonly T[], + order: readonly string[], +): T[] { + const position = new Map(); + order.forEach((id, index) => { + if (!position.has(id)) position.set(id, index); + }); + const ordered = medications + .filter((m) => position.has(m.id)) + .sort((a, b) => position.get(a.id)! - position.get(b.id)!); + const rest = medications + .filter((m) => !position.has(m.id)) + .sort(compareMedicationNames); + return [...ordered, ...rest]; +} diff --git a/src/lib/openapi/routes/medications.ts b/src/lib/openapi/routes/medications.ts index 52791dff5..d3ffcfc0a 100644 --- a/src/lib/openapi/routes/medications.ts +++ b/src/lib/openapi/routes/medications.ts @@ -18,6 +18,11 @@ import { MEDICATION_TREATMENT_CLASS_VALUES, } from "@/lib/validations/medication"; import { medicationExtractionSchema } from "@/lib/ai/coach/medication-extract-prompt"; +import { + MEDICATION_LIST_VIEWS, + MEDICATION_ORDER_ID_MAX_LENGTH, + MEDICATION_ORDER_MAX_ENTRIES, +} from "@/lib/medication-list-layout"; import { scheduleRevisionCreateSchema, scheduleRevisionUpdateSchema, @@ -228,11 +233,25 @@ const medicationListEntry = medicationResource .describe( "Number of ACTIONED intake events for today (user-local day window): rows with a recorded `takenAt` or an explicit skip. Pending projector-minted rows do not count — the card overdue-pill suppression compares this against the passed-dose count, and a pending mint must not read as covered.", ), + stockUnitsRemaining: z + .number() + .int() + .nullable() + .describe( + "v1.16.10 — usable inventory units left across the medication's containers (sum of `unitsRemaining` over ACTIVE / IN_USE items with units left). NULL = inventory tracking off (no items ever registered); 0 = tracking on, supply ran out. Read-only — aggregated, not stored.", + ), + stockDosesRemaining: z + .number() + .int() + .nullable() + .describe( + "v1.16.10 — dose-derived stock: `floor(stockUnitsRemaining / max(1, unitsPerDose))`. NULL when inventory tracking is off. Drives the table view's Bestand column. Read-only — aggregated, not stored.", + ), }) .meta({ id: "MedicationListEntry", description: - "List-row variant of the medication resource enriched with the joined `category`, `lastTakenAt`, and `todayEventCount` fields the dashboard + iOS client consume. The base medication fields (`id`, `name`, `dose`, `treatmentClass`, `dosesPerUnit`, `active`, `notificationsEnabled`, `pausedAt`, `snoozedUntil`, `startsOn`, `endsOn`, `oneShot`, `createdAt`, `updatedAt`, `schedules`) are inlined; see the `Medication` component for their semantics.", + "List-row variant of the medication resource enriched with the joined `category`, `lastTakenAt`, `todayEventCount`, and the v1.16.10 aggregated stock fields (`stockUnitsRemaining`, `stockDosesRemaining`) the dashboard + iOS client consume. The base medication fields (`id`, `name`, `dose`, `treatmentClass`, `dosesPerUnit`, `active`, `notificationsEnabled`, `pausedAt`, `snoozedUntil`, `startsOn`, `endsOn`, `oneShot`, `createdAt`, `updatedAt`, `schedules`) are inlined; see the `Medication` component for their semantics.", }); const medicationDetailEntry = medicationResource @@ -677,6 +696,32 @@ medicationExtractionSchema.meta({ "Citation-guarded partial extraction of medication scheduling fields. Every field is optional; the wizard merges what is present onto the form state and leaves the rest blank. `name` and `dose` are post-validated against the original free-text and dropped when not substring-matched, so the wizard cannot silently land a hallucinated brand or dose. `cadenceKind` / `doseUnit` / `weekdays` are closed enums; numeric fields are clamped to the wizard's wire bounds.", }); +// v1.16.10 — medications list presentation (cards/table view + manual +// order), persisted per user in its own `User` column following the +// dashboard-widgets / insights-layout per-surface convention. +const medicationListLayoutSchema = z + .object({ + version: z.literal(1), + view: z + .enum(MEDICATION_LIST_VIEWS) + .optional() + .describe( + 'Which presentation /medications renders in. Default "cards". Optional on PUT — when omitted the stored value is preserved (preserve-when-absent, like `heroVisible` on the dashboard layout). Always present on responses.', + ), + order: z + .array(z.string().min(1).max(MEDICATION_ORDER_ID_MAX_LENGTH)) + .max(MEDICATION_ORDER_MAX_ENTRIES) + .optional() + .describe( + "User-defined manual medication order (medication ids, first = top), shared by both views. Display-only — unknown / deleted ids are ignored at render time, never 422. Optional on PUT (preserve-when-absent); always present on responses.", + ), + }) + .meta({ + id: "MedicationListLayout", + description: + "Per-user /medications presentation: the card/table view choice plus the manual medication order shared by both views. Mirrors the dashboard-widgets / insights-layout contract.", + }); + export const medicationPaths: NonNullable = { "/api/medications": { get: { @@ -724,6 +769,75 @@ export const medicationPaths: NonNullable = { }, }, }, + "/api/medications/layout": { + get: { + tags: ["Medications"], + summary: "Read the calling user's medications list presentation", + description: + "Returns the per-user /medications presentation (card/table view + manual order). Falls back to the defaults (cards, empty order) when the user has not customised it. Mirrors the insights-layout contract.", + responses: { + "200": { + description: "The resolved presentation (custom or default).", + content: { + "application/json": { + schema: dataEnvelope( + medicationListLayoutSchema, + "MedicationListLayoutResponse", + ), + }, + }, + }, + ...stdResponses, + }, + }, + put: { + tags: ["Medications"], + summary: "Update the calling user's medications list presentation", + description: + "Field-scoped update: `view` and `order` are each optional, and whichever the body omits is preserved from the stored blob — a view toggle can never wipe the manual order and vice versa. The normalised presentation is returned. Invalid bodies return the multi-issue 422 envelope.", + requestBody: { + required: true, + content: { + "application/json": { schema: medicationListLayoutSchema }, + }, + }, + responses: { + "200": { + description: + "Presentation saved; the normalised blob is echoed back.", + content: { + "application/json": { + schema: dataEnvelope( + medicationListLayoutSchema, + "MedicationListLayoutSaved", + ), + }, + }, + }, + ...stdResponses, + }, + }, + delete: { + tags: ["Medications"], + summary: "Reset the calling user's medications list presentation", + description: + "Clears the persisted presentation and returns the defaults (cards, empty order). Idempotent.", + responses: { + "200": { + description: "Presentation reset; the defaults are returned.", + content: { + "application/json": { + schema: dataEnvelope( + medicationListLayoutSchema, + "MedicationListLayoutReset", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, "/api/medications/{id}": { get: { tags: ["Medications"], diff --git a/src/lib/queries/__tests__/use-medication-list-layout.test.ts b/src/lib/queries/__tests__/use-medication-list-layout.test.ts new file mode 100644 index 000000000..58ba26c33 --- /dev/null +++ b/src/lib/queries/__tests__/use-medication-list-layout.test.ts @@ -0,0 +1,150 @@ +/** + * v1.16.10 — toggle / reorder persistence wiring for the medications + * list presentation. Pins the PUT payload shape (field-scoped, version + * 1), the optimistic cache flip + rollback-on-failure for the view + * toggle, and the cache update on a saved order — without a React + * render, via the dependency-injected `run*` orchestration functions + * (the `use-medication-intake` testing convention). + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { QueryClient } from "@tanstack/react-query"; + +vi.mock("@/lib/api/api-fetch", () => ({ + apiGet: vi.fn(), + apiPut: vi.fn(), +})); +vi.mock("sonner", () => ({ + toast: { success: vi.fn(), error: vi.fn() }, +})); + +import { apiPut } from "@/lib/api/api-fetch"; +import { toast } from "sonner"; +import { + runSetMedicationListView, + runSaveMedicationListOrder, +} from "@/lib/queries/use-medication-list-layout"; +import { queryKeys } from "@/lib/query-keys"; +import type { MedicationListLayout } from "@/lib/medication-list-layout"; + +const t = (key: string) => key; + +function makeClient(): QueryClient { + return new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: Infinity } }, + }); +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("runSetMedicationListView", () => { + it("PUTs the field-scoped body and lands the server echo in the cache", async () => { + const queryClient = makeClient(); + queryClient.setQueryData( + queryKeys.medicationListLayout(), + { version: 1, view: "cards", order: ["med-a"] }, + ); + const saved: MedicationListLayout = { + version: 1, + view: "table", + order: ["med-a"], + }; + vi.mocked(apiPut).mockResolvedValue(saved as never); + + await runSetMedicationListView({ view: "table", queryClient, t }); + + // Field-scoped: only `view` rides the body — the server preserves + // the stored order (preserve-when-absent), so the client must not + // resend (and thereby race) it. + expect(apiPut).toHaveBeenCalledWith("/api/medications/layout", { + version: 1, + view: "table", + }); + expect( + queryClient.getQueryData(queryKeys.medicationListLayout()), + ).toEqual(saved); + expect(toast.error).not.toHaveBeenCalled(); + }); + + it("flips the cache optimistically before the PUT resolves", async () => { + const queryClient = makeClient(); + queryClient.setQueryData( + queryKeys.medicationListLayout(), + { version: 1, view: "cards", order: [] }, + ); + let observedDuringFlight: MedicationListLayout | undefined; + vi.mocked(apiPut).mockImplementation((async () => { + observedDuringFlight = queryClient.getQueryData( + queryKeys.medicationListLayout(), + ); + return { version: 1, view: "table", order: [] }; + }) as never); + + await runSetMedicationListView({ view: "table", queryClient, t }); + + expect(observedDuringFlight?.view).toBe("table"); + }); + + it("rolls the cache back and surfaces a toast when the PUT fails", async () => { + const queryClient = makeClient(); + const previous: MedicationListLayout = { + version: 1, + view: "cards", + order: ["med-a"], + }; + queryClient.setQueryData(queryKeys.medicationListLayout(), previous); + vi.mocked(apiPut).mockRejectedValue(new Error("boom")); + + await runSetMedicationListView({ view: "table", queryClient, t }); + + expect( + queryClient.getQueryData(queryKeys.medicationListLayout()), + ).toEqual(previous); + expect(toast.error).toHaveBeenCalledWith("medications.viewSaveFailed"); + }); +}); + +describe("runSaveMedicationListOrder", () => { + it("PUTs the order-only body, caches the echo, and reports success", async () => { + const queryClient = makeClient(); + const saved: MedicationListLayout = { + version: 1, + view: "table", + order: ["med-b", "med-a"], + }; + vi.mocked(apiPut).mockResolvedValue(saved as never); + + const ok = await runSaveMedicationListOrder({ + order: ["med-b", "med-a"], + queryClient, + t, + }); + + expect(ok).toBe(true); + expect(apiPut).toHaveBeenCalledWith("/api/medications/layout", { + version: 1, + order: ["med-b", "med-a"], + }); + expect( + queryClient.getQueryData(queryKeys.medicationListLayout()), + ).toEqual(saved); + expect(toast.success).toHaveBeenCalledWith("medications.reorderSaved"); + }); + + it("returns false and surfaces a toast when the PUT fails", async () => { + const queryClient = makeClient(); + vi.mocked(apiPut).mockRejectedValue(new Error("boom")); + + const ok = await runSaveMedicationListOrder({ + order: ["med-a"], + queryClient, + t, + }); + + expect(ok).toBe(false); + expect(toast.error).toHaveBeenCalledWith( + "medications.reorderSaveFailed", + ); + }); +}); diff --git a/src/lib/queries/use-medication-compliance-summary.ts b/src/lib/queries/use-medication-compliance-summary.ts index ed9fb1d1d..16990a9dc 100644 --- a/src/lib/queries/use-medication-compliance-summary.ts +++ b/src/lib/queries/use-medication-compliance-summary.ts @@ -43,6 +43,19 @@ export interface MedicationComplianceSummaryEntry { * lets the cards swap the skeleton for a quiet same-footprint fallback * with a manual retry. */ +/** + * The ONE fetcher behind the shared summary key. Both hooks below (and + * any future consumer) must ride this exact function so the key can + * never be registered with diverging fetch shapes. + */ +function fetchComplianceSummary(): Promise< + MedicationComplianceSummaryEntry[] +> { + return apiGet( + "/api/medications/compliance", + ); +} + export function useMedicationComplianceSummary(medicationId: string): { data: MedicationComplianceSummaryEntry | null | undefined; isError: boolean; @@ -50,8 +63,7 @@ export function useMedicationComplianceSummary(medicationId: string): { } { const { data, isError, refetch } = useQuery({ queryKey: queryKeys.medicationComplianceSummary(), - queryFn: () => - apiGet("/api/medications/compliance"), + queryFn: fetchComplianceSummary, // Dose actions invalidate the key explicitly through // `medicationDependentKeys`; a shorter window would only re-fire the // batch on every list visit. Matches the reminder-thresholds query @@ -62,3 +74,20 @@ export function useMedicationComplianceSummary(medicationId: string): { }); return { data, isError, refetch }; } + +/** + * v1.16.10 — the whole batched array, for consumers that need every + * row at once (the table view's Therapietreue column sort). Same key, + * same fetcher, no `select` — the per-row hook and this one share one + * cache entry by construction. + */ +export function useMedicationComplianceSummaryAll(): { + data: MedicationComplianceSummaryEntry[] | undefined; +} { + const { data } = useQuery({ + queryKey: queryKeys.medicationComplianceSummary(), + queryFn: fetchComplianceSummary, + staleTime: 5 * 60 * 1000, + }); + return { data }; +} diff --git a/src/lib/queries/use-medication-list-layout.ts b/src/lib/queries/use-medication-list-layout.ts new file mode 100644 index 000000000..b21bf6eee --- /dev/null +++ b/src/lib/queries/use-medication-list-layout.ts @@ -0,0 +1,116 @@ +"use client"; + +import { useQuery, useQueryClient, type QueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; + +import { apiGet, apiPut } from "@/lib/api/api-fetch"; +import { queryKeys } from "@/lib/query-keys"; +import { useTranslations } from "@/lib/i18n/context"; +import { + DEFAULT_MEDICATION_LIST_LAYOUT, + type MedicationListLayout, + type MedicationListView, +} from "@/lib/medication-list-layout"; + +type Translator = ( + key: string, + params?: Record, +) => string; + +/** + * v1.16.10 — the /medications presentation preference (cards/table view + * + manual order), persisted per user through + * `GET`/`PUT /api/medications/layout`. + * + * The PUT is field-scoped: the server preserves whichever of `view` / + * `order` the body omits, so the two writers below each send only the + * field they own and can never wipe the other one. + * + * The orchestration lives in dependency-injected `run*` functions (the + * repo convention from `use-medication-intake.ts`) so the optimistic + * write + rollback contract is unit-testable without a React render. + */ + +/** + * Flip the view optimistically (the toggle must feel instant), then + * persist. On failure the cache rolls back to the previous layout and + * a calm toast surfaces — the user is back on the view they came from. + */ +export async function runSetMedicationListView(deps: { + view: MedicationListView; + queryClient: QueryClient; + t: Translator; +}): Promise { + const { view, queryClient, t } = deps; + const key = queryKeys.medicationListLayout(); + const previous = queryClient.getQueryData(key); + queryClient.setQueryData(key, { + ...(previous ?? DEFAULT_MEDICATION_LIST_LAYOUT), + view, + }); + try { + const saved = await apiPut( + "/api/medications/layout", + { version: 1, view }, + ); + if (saved) queryClient.setQueryData(key, saved); + } catch { + queryClient.setQueryData(key, previous); + toast.error(t("medications.viewSaveFailed")); + } +} + +/** + * Persist a manual medication order (the reorder dialog's Save). Not + * optimistic — the dialog stays open until the PUT resolves, mirroring + * the dashboard layout section's explicit Save. Returns whether the + * save landed so the dialog knows to close. + */ +export async function runSaveMedicationListOrder(deps: { + order: string[]; + queryClient: QueryClient; + t: Translator; +}): Promise { + const { order, queryClient, t } = deps; + try { + const saved = await apiPut( + "/api/medications/layout", + { version: 1, order }, + ); + if (saved) { + queryClient.setQueryData(queryKeys.medicationListLayout(), saved); + } + toast.success(t("medications.reorderSaved")); + return true; + } catch { + toast.error(t("medications.reorderSaveFailed")); + return false; + } +} + +export function useMedicationListLayout(enabled: boolean = true): { + /** Resolved layout — defaults until the GET lands. */ + layout: MedicationListLayout; + /** True while the preference GET is in flight (first load only). */ + isLayoutLoading: boolean; + setView: (view: MedicationListView) => Promise; + saveOrder: (order: string[]) => Promise; +} { + const queryClient = useQueryClient(); + const { t } = useTranslations(); + + const { data, isLoading } = useQuery({ + queryKey: queryKeys.medicationListLayout(), + queryFn: () => apiGet("/api/medications/layout"), + staleTime: 5 * 60 * 1000, + enabled, + }); + + return { + layout: data ?? DEFAULT_MEDICATION_LIST_LAYOUT, + isLayoutLoading: enabled && isLoading, + setView: (view) => runSetMedicationListView({ view, queryClient, t }), + saveOrder: (order) => + runSaveMedicationListOrder({ order, queryClient, t }), + }; +} diff --git a/src/lib/query-keys/medications.ts b/src/lib/query-keys/medications.ts index 99ecee37f..8a5ae44ca 100644 --- a/src/lib/query-keys/medications.ts +++ b/src/lib/query-keys/medications.ts @@ -28,6 +28,14 @@ export const medicationKeys = { */ medicationComplianceSummary: () => ["medications", "compliance-summary"] as const, + /** + * v1.16.10 — the medications list presentation (cards/table view + + * manual order, `GET /api/medications/layout`). Deliberately OUTSIDE + * the `["medications"]` prefix: an intake mutation must not refetch a + * pure UI preference. Writes update the slot directly through the + * layout PUT's response. + */ + medicationListLayout: () => ["medication-list-layout"] as const, medicationCadence: (medicationId: string) => ["medications", medicationId, "cadence"] as const, medicationGlp1Details: (medicationId: string) =>