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