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) =>