mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
fix(medications): the audit findings on the new supply and dose surfaces
- The escalated card row keeps its time anchor: the red badge carries the matched window or the overdue-slot value beside it, and the inline row's range stays visible on mobile, where it was the only clock on the card. - Take-all-due collects what the cards offer: a dose escalated past its catch-up band joins the set with exactly the slot the card's own button would record; as-needed medications never join. - The table's stock warning follows the user's runway threshold instead of a leftover four-dose constant; threshold off means no amber, empty stays red. - The card itself shows the remaining runway in the warning tone once it crosses the threshold — only then, the card stays calm above. - The low-stock push deep-links straight into the supply tab and its APNs payload keeps url, runwayDays and thresholdDays; supply tab and threshold setting link to each other; the reminder-thresholds and notification-prefs endpoints enter the OpenAPI contract.
This commit is contained in:
1 parent
3943dcfb72
commit
6fef2081d0
24 files changed
+970
-40
No files matched your search
@@ -3564,6 +3564,49 @@ paths:
|
||||
"401": *a1
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/api/auth/me/notification-prefs:
|
||||
get:
|
||||
tags:
|
||||
- Notifications
|
||||
summary: Read per-user notification preferences
|
||||
description: Returns the fully-resolved preferences with the documented defaults filled in. A null persisted row
|
||||
resolves to the defaults (server-managed reminders, low-stock threshold 7 days, mood reminder 22:00, Coach
|
||||
nudges on).
|
||||
responses:
|
||||
"200":
|
||||
description: Resolved preferences.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/GetNotificationPrefsResponse"
|
||||
"401": *a1
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
patch:
|
||||
tags:
|
||||
- Notifications
|
||||
summary: Update per-user notification preferences
|
||||
description: 'Deep-merges the supplied partial shape over the persisted row — a PATCH touching only one category leaves
|
||||
the siblings intact. Always returns the fully-resolved next state so clients can hard-set their optimistic
|
||||
update. `medication.clientManaged: true` (or `deliveryDefault: "client"`) suppresses server-side
|
||||
MEDICATION_REMINDER APNs; `medication.lowStockRunwayDays` (1–60, nullable, default 7) tunes the low-stock alert
|
||||
— null switches it off. Rate-limit 60/min per user.'
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/NotificationPrefsPatchRequest"
|
||||
responses:
|
||||
"200":
|
||||
description: Resolved next-state preferences echoed back.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/PatchNotificationPrefsResponse"
|
||||
"401": *a1
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/api/insights/layout:
|
||||
get:
|
||||
tags:
|
||||
@@ -4805,6 +4848,24 @@ paths:
|
||||
"401": *a1
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/api/settings/reminder-thresholds:
|
||||
get:
|
||||
tags:
|
||||
- Notifications
|
||||
summary: Read the medication reminder thresholds
|
||||
description: Returns the operator-level late/missed minute thresholds that tier an open dose's status, plus the calling
|
||||
user's low-stock runway threshold (days; null = alert off). One endpoint so every threshold consumer reads one
|
||||
shape. Auth via cookie or Bearer.
|
||||
responses:
|
||||
"200":
|
||||
description: Resolved thresholds.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/GetReminderThresholdsResponse"
|
||||
"401": *a1
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
components:
|
||||
schemas:
|
||||
PasskeyLoginVerifyRequest:
|
||||
@@ -5753,6 +5814,57 @@ components:
|
||||
- type: "null"
|
||||
description: Partial profile update. Every field is optional; an omitted field is left untouched, an explicit null (or
|
||||
empty string) clears it. `userId` is never accepted — it is narrowed from the session/token.
|
||||
NotificationPrefsPatchRequest:
|
||||
type: object
|
||||
properties:
|
||||
medication:
|
||||
type: object
|
||||
properties:
|
||||
clientManaged:
|
||||
type: boolean
|
||||
deliveryDefault:
|
||||
type: string
|
||||
enum:
|
||||
- server
|
||||
- client
|
||||
lowStockRunwayDays:
|
||||
anyOf:
|
||||
- type: integer
|
||||
minimum: 1
|
||||
maximum: 60
|
||||
- type: "null"
|
||||
mood:
|
||||
type: object
|
||||
properties:
|
||||
reminderHour:
|
||||
type: integer
|
||||
minimum: 0
|
||||
maximum: 23
|
||||
cycle:
|
||||
type: object
|
||||
properties:
|
||||
clientManaged:
|
||||
type: boolean
|
||||
coach:
|
||||
type: object
|
||||
properties:
|
||||
nudgesEnabled:
|
||||
type: boolean
|
||||
nudgeMedication:
|
||||
type: boolean
|
||||
nudgeVitals:
|
||||
type: boolean
|
||||
nudgeRoutine:
|
||||
type: boolean
|
||||
nudgeFrequency:
|
||||
type: string
|
||||
enum:
|
||||
- weekly
|
||||
- biweekly
|
||||
description: Partial per-user notification preferences. Every category and field is optional; the server deep-merges the
|
||||
supplied keys over the persisted row, so a PATCH touching only `medication` leaves the siblings intact.
|
||||
`medication.lowStockRunwayDays` (1–60, nullable) is the low-stock alert threshold in remaining runway days —
|
||||
`null` switches the alert off.
|
||||
InsightsLayoutBody:
|
||||
type: object
|
||||
properties:
|
||||
@@ -11804,6 +11916,124 @@ components:
|
||||
additionalProperties: false
|
||||
description: Profile echo returned by the PATCH/PUT update path. Reports KVNR presence as a boolean rather than
|
||||
re-emitting the decrypted value.
|
||||
GetNotificationPrefsResponse:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: "#/components/schemas/NotificationPrefs"
|
||||
error:
|
||||
type: "null"
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
requestId:
|
||||
type: string
|
||||
additionalProperties: false
|
||||
required:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
NotificationPrefs:
|
||||
type: object
|
||||
properties:
|
||||
medication:
|
||||
type: object
|
||||
properties:
|
||||
clientManaged:
|
||||
type: boolean
|
||||
description: True when the iOS app owns local medication reminders; the server-side MEDICATION_REMINDER APNs cron is
|
||||
suppressed.
|
||||
deliveryDefault:
|
||||
type: string
|
||||
enum:
|
||||
- server
|
||||
- client
|
||||
description: 'Roaming user-level delivery default. "client" implies clientManaged: true.'
|
||||
lowStockRunwayDays:
|
||||
anyOf:
|
||||
- type: integer
|
||||
minimum: 1
|
||||
maximum: 60
|
||||
- type: "null"
|
||||
description: Low-stock alert threshold as remaining runway days (1–60). null = alert off. Default 7.
|
||||
required:
|
||||
- clientManaged
|
||||
- deliveryDefault
|
||||
- lowStockRunwayDays
|
||||
additionalProperties: false
|
||||
mood:
|
||||
type: object
|
||||
properties:
|
||||
reminderHour:
|
||||
type: integer
|
||||
minimum: 0
|
||||
maximum: 23
|
||||
description: Local-time hour for the daily mood reminder. Default 22.
|
||||
required:
|
||||
- reminderHour
|
||||
additionalProperties: false
|
||||
cycle:
|
||||
type: object
|
||||
properties:
|
||||
clientManaged:
|
||||
type: boolean
|
||||
description: True when the iOS app owns local cycle reminders; the server-side cycle APNs cron is suppressed.
|
||||
required:
|
||||
- clientManaged
|
||||
additionalProperties: false
|
||||
coach:
|
||||
type: object
|
||||
properties:
|
||||
nudgesEnabled:
|
||||
type: boolean
|
||||
description: Master switch for the proactive Coach nudge cron.
|
||||
nudgeMedication:
|
||||
type: boolean
|
||||
description: Medication-group nudge triggers (compliance).
|
||||
nudgeVitals:
|
||||
type: boolean
|
||||
description: Vitals-group nudge triggers (bp / score / weight / sleep).
|
||||
nudgeRoutine:
|
||||
type: boolean
|
||||
description: Routine-group nudge triggers (measurement gap / self-context).
|
||||
nudgeFrequency:
|
||||
type: string
|
||||
enum:
|
||||
- weekly
|
||||
- biweekly
|
||||
description: "Nudge frequency cap: one per 7 or per 14 days."
|
||||
required:
|
||||
- nudgesEnabled
|
||||
- nudgeMedication
|
||||
- nudgeVitals
|
||||
- nudgeRoutine
|
||||
- nudgeFrequency
|
||||
additionalProperties: false
|
||||
required:
|
||||
- medication
|
||||
- mood
|
||||
- cycle
|
||||
- coach
|
||||
additionalProperties: false
|
||||
description: Fully-resolved per-user notification preferences. Every key is present — a null / drifted persisted row
|
||||
resolves to the documented defaults.
|
||||
PatchNotificationPrefsResponse:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: "#/components/schemas/NotificationPrefs"
|
||||
error:
|
||||
type: "null"
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
requestId:
|
||||
type: string
|
||||
additionalProperties: false
|
||||
required:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
InsightsLayout:
|
||||
type: object
|
||||
properties:
|
||||
@@ -14050,6 +14280,51 @@ components:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
GetReminderThresholdsResponse:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: "#/components/schemas/ReminderThresholdsResponse"
|
||||
error:
|
||||
type: "null"
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
requestId:
|
||||
type: string
|
||||
additionalProperties: false
|
||||
required:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
ReminderThresholdsResponse:
|
||||
type: object
|
||||
properties:
|
||||
lateMinutes:
|
||||
type: integer
|
||||
minimum: -9007199254740991
|
||||
maximum: 9007199254740991
|
||||
description: Minutes after the dose anchor at which an open dose tiers 'late' (operator-level singleton; default 120).
|
||||
missedMinutes:
|
||||
type: integer
|
||||
minimum: -9007199254740991
|
||||
maximum: 9007199254740991
|
||||
description: Minutes after the dose anchor at which an open dose tiers 'missed' (operator-level singleton; default 240).
|
||||
lowStockRunwayDays:
|
||||
anyOf:
|
||||
- type: integer
|
||||
minimum: 1
|
||||
maximum: 60
|
||||
- type: "null"
|
||||
description: Per-user low-stock alert threshold as remaining runway days (1–60). null = the alert is off. Default 7.
|
||||
Written via PATCH /api/auth/me/notification-prefs.
|
||||
required:
|
||||
- lateMinutes
|
||||
- missedMinutes
|
||||
- lowStockRunwayDays
|
||||
additionalProperties: false
|
||||
description: "Medication reminder thresholds: the operator-level late/missed minute marks plus the calling user's
|
||||
low-stock runway threshold (v1.16.11)."
|
||||
MedicationScheduleOutput:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
+5
-1
@@ -1669,6 +1669,8 @@
|
||||
"emptyHelper": "Erfasse eine Packung oder einen Behälter, um den verbleibenden Vorrat und die Reichweite zu verfolgen.",
|
||||
"emptyCta": "Packung erfassen",
|
||||
"addButton": "Erfassen",
|
||||
"lowStockLinkOn": "Benachrichtigung bei niedrigem Bestand: unter {days} Tagen",
|
||||
"lowStockLinkOff": "Benachrichtigung bei niedrigem Bestand: aus",
|
||||
"addTitle": "Packung oder Behälter erfassen",
|
||||
"addDescription": "Lege einen neuen Vorrat mit seiner Einheitenanzahl an; das Ablaufdatum ist optional.",
|
||||
"addQuantityLabel": "Menge (Einheiten)",
|
||||
@@ -1819,6 +1821,7 @@
|
||||
"tableStockDoseOne": "1 Dosis",
|
||||
"tableStockDoses": "{count} Dosen",
|
||||
"tableStockRunway": "≈ {days} Tage übrig",
|
||||
"cardLowStockRunway": "Vorrat: ≈ {days} Tage",
|
||||
"takeActionFor": "Einnehmen – {name}",
|
||||
"skipActionFor": "Überspringen – {name}",
|
||||
"reorderTitle": "Reihenfolge anpassen",
|
||||
@@ -5398,7 +5401,8 @@
|
||||
"savedToast": "Vorratsschwelle gespeichert.",
|
||||
"enabledToast": "Vorratswarnungen aktiviert.",
|
||||
"disabledToast": "Vorratswarnungen deaktiviert.",
|
||||
"saveError": "Speichern fehlgeschlagen. Bitte später erneut versuchen."
|
||||
"saveError": "Speichern fehlgeschlagen. Bitte später erneut versuchen.",
|
||||
"medicationsLink": "Zu deinen Medikamenten"
|
||||
},
|
||||
"eventMedicationReminder": "Medikamenten-Erinnerung",
|
||||
"eventMedicationReminderDesc": "Wird zu jeder geplanten Einnahmezeit gesendet.",
|
||||
|
||||
+5
-1
@@ -1669,6 +1669,8 @@
|
||||
"emptyHelper": "Register a pack or container to track remaining supply and runway.",
|
||||
"emptyCta": "Register a pack",
|
||||
"addButton": "Register",
|
||||
"lowStockLinkOn": "Low-stock notification: below {days} days",
|
||||
"lowStockLinkOff": "Low-stock notification: off",
|
||||
"addTitle": "Register a pack or container",
|
||||
"addDescription": "Record a new supply with its unit count; the expiry date is optional.",
|
||||
"addQuantityLabel": "Quantity (units)",
|
||||
@@ -1819,6 +1821,7 @@
|
||||
"tableStockDoseOne": "1 dose",
|
||||
"tableStockDoses": "{count} doses",
|
||||
"tableStockRunway": "≈ {days} days left",
|
||||
"cardLowStockRunway": "Supply: ≈ {days} days",
|
||||
"takeActionFor": "Take – {name}",
|
||||
"skipActionFor": "Skip – {name}",
|
||||
"reorderTitle": "Adjust order",
|
||||
@@ -5398,7 +5401,8 @@
|
||||
"savedToast": "Low-stock threshold saved.",
|
||||
"enabledToast": "Low-stock alerts enabled.",
|
||||
"disabledToast": "Low-stock alerts disabled.",
|
||||
"saveError": "Could not save. Please try again."
|
||||
"saveError": "Could not save. Please try again.",
|
||||
"medicationsLink": "Go to your medications"
|
||||
},
|
||||
"eventMedicationReminder": "Medication reminder",
|
||||
"eventMedicationReminderDesc": "Sent at each scheduled dose time.",
|
||||
|
||||
+5
-1
@@ -1669,6 +1669,8 @@
|
||||
"emptyHelper": "Registra un envase o recipiente para hacer seguimiento de las existencias restantes y la autonomía.",
|
||||
"emptyCta": "Registrar un envase",
|
||||
"addButton": "Registrar",
|
||||
"lowStockLinkOn": "Aviso de reserva baja: por debajo de {days} días",
|
||||
"lowStockLinkOff": "Aviso de reserva baja: desactivado",
|
||||
"addTitle": "Registrar un envase o recipiente",
|
||||
"addDescription": "Registra una nueva existencia con su número de unidades; la fecha de caducidad es opcional.",
|
||||
"addQuantityLabel": "Cantidad (unidades)",
|
||||
@@ -1819,6 +1821,7 @@
|
||||
"tableStockDoseOne": "1 dosis",
|
||||
"tableStockDoses": "{count} dosis",
|
||||
"tableStockRunway": "≈ {days} días restantes",
|
||||
"cardLowStockRunway": "Reserva: ≈ {days} días",
|
||||
"takeActionFor": "Tomar – {name}",
|
||||
"skipActionFor": "Omitir – {name}",
|
||||
"reorderTitle": "Ajustar orden",
|
||||
@@ -5398,7 +5401,8 @@
|
||||
"savedToast": "Umbral de existencias guardado.",
|
||||
"enabledToast": "Avisos de existencias bajas activados.",
|
||||
"disabledToast": "Avisos de existencias bajas desactivados.",
|
||||
"saveError": "No se pudo guardar. Inténtalo de nuevo."
|
||||
"saveError": "No se pudo guardar. Inténtalo de nuevo.",
|
||||
"medicationsLink": "Ir a tus medicamentos"
|
||||
},
|
||||
"eventMedicationReminder": "Recordatorio de medicación",
|
||||
"eventMedicationReminderDesc": "Se envía a cada hora de dosis programada.",
|
||||
|
||||
+5
-1
@@ -1669,6 +1669,8 @@
|
||||
"emptyHelper": "Enregistrez une boîte ou un contenant pour suivre le stock restant et l'autonomie.",
|
||||
"emptyCta": "Enregistrer une boîte",
|
||||
"addButton": "Enregistrer",
|
||||
"lowStockLinkOn": "Alerte de stock bas : en dessous de {days} jours",
|
||||
"lowStockLinkOff": "Alerte de stock bas : désactivée",
|
||||
"addTitle": "Enregistrer une boîte ou un contenant",
|
||||
"addDescription": "Enregistrez un nouveau stock avec son nombre d'unités ; la date de péremption est facultative.",
|
||||
"addQuantityLabel": "Quantité (unités)",
|
||||
@@ -1819,6 +1821,7 @@
|
||||
"tableStockDoseOne": "1 dose",
|
||||
"tableStockDoses": "{count} doses",
|
||||
"tableStockRunway": "≈ {days} jours restants",
|
||||
"cardLowStockRunway": "Réserve : ≈ {days} jours",
|
||||
"takeActionFor": "Prendre – {name}",
|
||||
"skipActionFor": "Passer – {name}",
|
||||
"reorderTitle": "Modifier l'ordre",
|
||||
@@ -5398,7 +5401,8 @@
|
||||
"savedToast": "Seuil de stock enregistré.",
|
||||
"enabledToast": "Alertes de stock faible activées.",
|
||||
"disabledToast": "Alertes de stock faible désactivées.",
|
||||
"saveError": "Échec de l’enregistrement. Réessayez."
|
||||
"saveError": "Échec de l’enregistrement. Réessayez.",
|
||||
"medicationsLink": "Voir tes médicaments"
|
||||
},
|
||||
"eventMedicationReminder": "Rappel de médicament",
|
||||
"eventMedicationReminderDesc": "Envoyé à chaque heure de prise prévue.",
|
||||
|
||||
+5
-1
@@ -1669,6 +1669,8 @@
|
||||
"emptyHelper": "Registra una confezione o un contenitore per monitorare la scorta rimanente e l'autonomia.",
|
||||
"emptyCta": "Registra una confezione",
|
||||
"addButton": "Registra",
|
||||
"lowStockLinkOn": "Avviso scorta bassa: sotto i {days} giorni",
|
||||
"lowStockLinkOff": "Avviso scorta bassa: disattivato",
|
||||
"addTitle": "Registra una confezione o un contenitore",
|
||||
"addDescription": "Registra una nuova scorta con il numero di unità; la data di scadenza è facoltativa.",
|
||||
"addQuantityLabel": "Quantità (unità)",
|
||||
@@ -1819,6 +1821,7 @@
|
||||
"tableStockDoseOne": "1 dose",
|
||||
"tableStockDoses": "{count} dosi",
|
||||
"tableStockRunway": "≈ {days} giorni rimanenti",
|
||||
"cardLowStockRunway": "Scorta: ≈ {days} giorni",
|
||||
"takeActionFor": "Assumi – {name}",
|
||||
"skipActionFor": "Salta – {name}",
|
||||
"reorderTitle": "Modifica ordine",
|
||||
@@ -5398,7 +5401,8 @@
|
||||
"savedToast": "Soglia delle scorte salvata.",
|
||||
"enabledToast": "Avvisi di scorte basse attivati.",
|
||||
"disabledToast": "Avvisi di scorte basse disattivati.",
|
||||
"saveError": "Salvataggio non riuscito. Riprova."
|
||||
"saveError": "Salvataggio non riuscito. Riprova.",
|
||||
"medicationsLink": "Vai ai tuoi farmaci"
|
||||
},
|
||||
"eventMedicationReminder": "Promemoria farmaco",
|
||||
"eventMedicationReminderDesc": "Inviato a ogni orario di dose programmato.",
|
||||
|
||||
+5
-1
@@ -1669,6 +1669,8 @@
|
||||
"emptyHelper": "Zarejestruj opakowanie lub pojemnik, aby śledzić pozostały zapas i jego wystarczalność.",
|
||||
"emptyCta": "Zarejestruj opakowanie",
|
||||
"addButton": "Zarejestruj",
|
||||
"lowStockLinkOn": "Powiadomienie o niskim zapasie: poniżej {days} dni",
|
||||
"lowStockLinkOff": "Powiadomienie o niskim zapasie: wyłączone",
|
||||
"addTitle": "Zarejestruj opakowanie lub pojemnik",
|
||||
"addDescription": "Zapisz nowy zapas z liczbą jednostek; data ważności jest opcjonalna.",
|
||||
"addQuantityLabel": "Ilość (jednostki)",
|
||||
@@ -1819,6 +1821,7 @@
|
||||
"tableStockDoseOne": "1 dawka",
|
||||
"tableStockDoses": "Dawki: {count}",
|
||||
"tableStockRunway": "≈ {days} dni zapasu",
|
||||
"cardLowStockRunway": "Zapas: ≈ {days} dni",
|
||||
"takeActionFor": "Przyjmij – {name}",
|
||||
"skipActionFor": "Pomiń – {name}",
|
||||
"reorderTitle": "Zmień kolejność",
|
||||
@@ -5398,7 +5401,8 @@
|
||||
"savedToast": "Próg zapasu zapisany.",
|
||||
"enabledToast": "Alerty niskiego zapasu włączone.",
|
||||
"disabledToast": "Alerty niskiego zapasu wyłączone.",
|
||||
"saveError": "Zapis nieudany. Spróbuj ponownie."
|
||||
"saveError": "Zapis nieudany. Spróbuj ponownie.",
|
||||
"medicationsLink": "Przejdź do swoich leków"
|
||||
},
|
||||
"eventMedicationReminder": "Przypomnienie o leku",
|
||||
"eventMedicationReminderDesc": "Wysyłane o każdej zaplanowanej godzinie dawki.",
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
Wrench,
|
||||
} from "lucide-react";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { useMedicationComplianceSummaryAll } from "@/lib/queries/use-medication-compliance-summary";
|
||||
import { useMedicationListLayout } from "@/lib/queries/use-medication-list-layout";
|
||||
import { applyMedicationOrder } from "@/lib/medications/medication-order";
|
||||
|
||||
@@ -204,9 +205,11 @@ export default function MedicationsPage() {
|
||||
queryKey: queryKeys.settingsReminderThresholds(),
|
||||
queryFn: async () => {
|
||||
try {
|
||||
return await apiGet<{ lateMinutes: number; missedMinutes: number }>(
|
||||
"/api/settings/reminder-thresholds",
|
||||
);
|
||||
return await apiGet<{
|
||||
lateMinutes: number;
|
||||
missedMinutes: number;
|
||||
lowStockRunwayDays: number | null;
|
||||
}>("/api/settings/reminder-thresholds");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -215,6 +218,14 @@ export default function MedicationsPage() {
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
// v1.16.11 — the SAME batched compliance rows the cards / table read
|
||||
// (shared key + cache, no extra round trip). The due-set derivation
|
||||
// needs each medication's compliance dose status so an escalated
|
||||
// (overdue / missed past its band tail) medication — which still
|
||||
// renders an active take button on its red card — joins the
|
||||
// take-all-due set.
|
||||
const { data: complianceRows } = useMedicationComplianceSummaryAll();
|
||||
|
||||
function openCreate() {
|
||||
setDialogOpen(true);
|
||||
}
|
||||
@@ -289,11 +300,19 @@ export default function MedicationsPage() {
|
||||
|
||||
// v1.16.11 (#316) — the currently-due set, derived from the list payload
|
||||
// the page already holds via the SAME pipeline a card's pill runs (band
|
||||
// model + server display-due gate + taken-early downgrade). Page order is
|
||||
// preserved so the confirm dialog lists medications as rendered.
|
||||
// model + server display-due gate + taken-early downgrade), plus the
|
||||
// batched compliance dose status so escalated (overdue / missed)
|
||||
// medications past their band tail stay takeable from the batch. Page
|
||||
// order is preserved so the confirm dialog lists medications as rendered.
|
||||
const dueMeds = deriveDueMedications(activeMeds, {
|
||||
tz: user?.timezone || "Europe/Berlin",
|
||||
thresholds: thresholds ?? undefined,
|
||||
doseStatusById: new Map(
|
||||
(complianceRows ?? []).map((row) => [
|
||||
row.medicationId,
|
||||
row.complianceDisplay?.currentDose.status ?? "upcoming",
|
||||
]),
|
||||
),
|
||||
});
|
||||
|
||||
return (
|
||||
|
||||
@@ -65,6 +65,20 @@ function seedCompliance(
|
||||
]);
|
||||
}
|
||||
|
||||
// Seed the SAME reminder-thresholds key the cards read — the stock
|
||||
// cell's amber tier rides the per-user low-stock runway threshold
|
||||
// (v1.16.11); `null` = the alert (and the tier) is off.
|
||||
function seedThresholds(
|
||||
client: QueryClient,
|
||||
lowStockRunwayDays: number | null,
|
||||
) {
|
||||
client.setQueryData(["settings", "reminder-thresholds"], {
|
||||
lateMinutes: 120,
|
||||
missedMinutes: 240,
|
||||
lowStockRunwayDays,
|
||||
});
|
||||
}
|
||||
|
||||
// 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.
|
||||
@@ -217,6 +231,54 @@ describe("<MedicationTable> — structure + shared payloads", () => {
|
||||
expect(html).toContain("text-warning");
|
||||
});
|
||||
|
||||
it("warns on runway DAYS below the user's threshold, not a legacy dose count", () => {
|
||||
const client = makeClient();
|
||||
seedCompliance(client, "m1", 90, 88);
|
||||
seedCompliance(client, "m2", 90, 88);
|
||||
// Per-user threshold: 3 runway days (the same value the low-stock
|
||||
// notification fires on).
|
||||
seedThresholds(client, 3);
|
||||
const html = render(
|
||||
<MedicationTable
|
||||
activeMedications={[
|
||||
// Daily schedule → 5 doses = 5 runway days ≥ 3 → calm, even
|
||||
// though the retired 4-dose constant would have tinted it.
|
||||
med({ id: "m1", name: "Aspirin", stockDosesRemaining: 5 }),
|
||||
// 2 doses = 2 runway days < 3 → amber.
|
||||
med({ id: "m2", name: "Mounjaro", stockDosesRemaining: 2 }),
|
||||
]}
|
||||
inactiveMedications={[]}
|
||||
/>,
|
||||
client,
|
||||
);
|
||||
|
||||
expect((html.match(/text-warning/g) ?? []).length).toBe(1);
|
||||
expect(html.indexOf("text-warning")).toBeGreaterThan(
|
||||
html.indexOf("Mounjaro"),
|
||||
);
|
||||
});
|
||||
|
||||
it("threshold OFF (null) disables the amber tier; an exhausted stock stays destructive", () => {
|
||||
const client = makeClient();
|
||||
seedCompliance(client, "m1", 90, 88);
|
||||
seedCompliance(client, "m2", 90, 88);
|
||||
seedThresholds(client, null);
|
||||
const html = render(
|
||||
<MedicationTable
|
||||
activeMedications={[
|
||||
med({ id: "m1", name: "Aspirin", stockDosesRemaining: 1 }),
|
||||
med({ id: "m2", name: "Mounjaro", stockDosesRemaining: 0 }),
|
||||
]}
|
||||
inactiveMedications={[]}
|
||||
/>,
|
||||
client,
|
||||
);
|
||||
|
||||
expect(html).not.toContain("text-warning");
|
||||
// Stock 0 is destructive red regardless of the threshold switch.
|
||||
expect(html).toContain("text-destructive");
|
||||
});
|
||||
|
||||
it("pins inactive rows after the active block, muted and without actions", () => {
|
||||
const client = makeClient();
|
||||
seedCompliance(client, "m1", 90, 88);
|
||||
|
||||
@@ -280,6 +280,108 @@ describe("deriveDueMedications — only currently-due, active, unpaused", () =>
|
||||
});
|
||||
});
|
||||
|
||||
describe("deriveDueMedications — compliance-escalated medications (band tail closed)", () => {
|
||||
// 14:00 UTC: the 08:00 dose band's catch-up tail closed at 13:00
|
||||
// (missedMinutes 240), so the window-derived status is null — but the
|
||||
// card still renders the red escalation value with an ACTIVE take
|
||||
// button when the batched compliance dose status says overdue/missed.
|
||||
const now = todayAtUtc(14, 0);
|
||||
|
||||
const morning = () =>
|
||||
makeMed({
|
||||
id: "morning",
|
||||
schedules: [
|
||||
{
|
||||
windowStart: "08:00",
|
||||
windowEnd: "08:00",
|
||||
daysOfWeek: null,
|
||||
timesOfDay: ["08:00"],
|
||||
dose: null,
|
||||
},
|
||||
],
|
||||
// The server still serves the open overdue slot as next-due.
|
||||
nextDueAt: todayAtUtc(8, 0).toISOString(),
|
||||
nextDueOverdue: true,
|
||||
});
|
||||
|
||||
it("includes a missed dose past its catch-up tail, recording the card's own displayed slot", () => {
|
||||
const due = deriveDueMedications([morning()], {
|
||||
now,
|
||||
tz: "UTC",
|
||||
doseStatusById: new Map([["morning", "missed"]]),
|
||||
});
|
||||
expect(due).toHaveLength(1);
|
||||
expect(due[0].id).toBe("morning");
|
||||
// Band closed → no matched window for the dialog line.
|
||||
expect(due[0].window).toBeNull();
|
||||
// EXACTLY what the card's take button records: the same
|
||||
// `resolveDisplayedSlotInstant` call resolves (status null) to the
|
||||
// server's next-due instant.
|
||||
expect(due[0].scheduledFor?.toISOString()).toBe(
|
||||
todayAtUtc(8, 0).toISOString(),
|
||||
);
|
||||
});
|
||||
|
||||
it("matches the count of visible red/due cards: dedupes window-due + escalated, excludes calm doses", () => {
|
||||
const inWindow = makeMed({
|
||||
id: "in-window",
|
||||
schedules: [
|
||||
{
|
||||
windowStart: "14:00",
|
||||
windowEnd: "14:00",
|
||||
daysOfWeek: null,
|
||||
timesOfDay: ["14:00"],
|
||||
dose: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
const evening = makeMed({
|
||||
id: "evening",
|
||||
schedules: [
|
||||
{
|
||||
windowStart: "20:00",
|
||||
windowEnd: "20:00",
|
||||
daysOfWeek: null,
|
||||
timesOfDay: ["20:00"],
|
||||
dose: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
const due = deriveDueMedications([inWindow, morning(), evening], {
|
||||
now,
|
||||
tz: "UTC",
|
||||
doseStatusById: new Map([
|
||||
// The open-window med is ALSO compliance-escalated — it must
|
||||
// appear exactly once (the two branches are exclusive).
|
||||
["in-window", "overdue"],
|
||||
["morning", "missed"],
|
||||
["evening", "upcoming"],
|
||||
]),
|
||||
});
|
||||
expect(due.map((d) => d.id)).toEqual(["in-window", "morning"]);
|
||||
});
|
||||
|
||||
it("never includes an as-needed medication, even with a (bogus) escalated compliance row", () => {
|
||||
const prn = makeMed({
|
||||
id: "prn",
|
||||
asNeeded: true,
|
||||
schedules: [],
|
||||
nextDueAt: null,
|
||||
});
|
||||
const due = deriveDueMedications([prn], {
|
||||
now,
|
||||
tz: "UTC",
|
||||
doseStatusById: new Map([["prn", "missed"]]),
|
||||
});
|
||||
expect(due).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not escalate without a compliance verdict (window-only derivation stays the floor)", () => {
|
||||
const due = deriveDueMedications([morning()], { now, tz: "UTC" });
|
||||
expect(due).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("runTakeAllDue — per-medication loop with failure isolation", () => {
|
||||
const slotA = new Date(Date.now() - 60 * 60 * 1000);
|
||||
const slotB = new Date(Date.now() - 30 * 60 * 1000);
|
||||
|
||||
@@ -5,6 +5,7 @@ import { AlertTriangle } from "lucide-react";
|
||||
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { formatTimeWindowRange } from "@/lib/time-window-format";
|
||||
import { MedicationCardHeader } from "@/components/medications/MedicationCardHeader";
|
||||
import { MedicationStatusPill } from "@/components/medications/card-parts/medication-status-pill";
|
||||
import {
|
||||
@@ -120,6 +121,16 @@ export interface MedicationCardBodyProps {
|
||||
*/
|
||||
asNeeded?: boolean;
|
||||
|
||||
/**
|
||||
* v1.16.11 — projected supply runway in whole days, set ONLY while it
|
||||
* sits below the user's low-stock threshold (the variants gate it).
|
||||
* Non-null renders one muted warning-toned "Vorrat: ≈ N Tage" line
|
||||
* under the next/last slot; null keeps the card free of stock noise.
|
||||
* Never set for as-needed medications (no consuming schedule, no
|
||||
* runway).
|
||||
*/
|
||||
lowStockRunwayDays?: number | null;
|
||||
|
||||
/** "take" | "skip" while the matching request is in flight, else null. */
|
||||
intakeLoading: string | null;
|
||||
/** Record the displayed dose (the card binds the slot instant). */
|
||||
@@ -147,11 +158,12 @@ export function MedicationCardBody({
|
||||
onRetryCompliance,
|
||||
currentCycle,
|
||||
asNeeded = false,
|
||||
lowStockRunwayDays = null,
|
||||
intakeLoading,
|
||||
onRecordIntake,
|
||||
children,
|
||||
}: MedicationCardBodyProps) {
|
||||
const { t } = useTranslations();
|
||||
const { t, locale } = useTranslations();
|
||||
|
||||
// Overdue escalation line: a dose past its on-time window. `missed` (at /
|
||||
// past the clinical miss cutoff) reads "Stark überfällig"; the still-takeable
|
||||
@@ -176,11 +188,31 @@ export function MedicationCardBody({
|
||||
// tier: the row falls through to the plain schedule value and the
|
||||
// "last intake" line directly below already names the recent dose —
|
||||
// the guard's job is that no take prompt renders, not a louder line.
|
||||
//
|
||||
// The escalation value keeps the due time: the red badge alone evicted
|
||||
// the window / time the amber tier used to carry, leaving the user
|
||||
// with urgency but no anchor. The matched window rides along when one
|
||||
// is still open; otherwise the card's own next-intake value (the
|
||||
// overdue slot time) fills the gap.
|
||||
const nextValueOverride =
|
||||
windowStatus?.takenEarlyDaysAgo != null ? null : overdueLabel ? (
|
||||
<span className="text-destructive inline-flex items-center gap-1 text-sm font-medium">
|
||||
<AlertTriangle className="size-3.5 shrink-0" aria-hidden="true" />
|
||||
{overdueLabel}
|
||||
<span className="inline-flex flex-wrap items-center gap-x-1 gap-y-0.5 text-sm">
|
||||
<span className="text-destructive inline-flex items-center gap-1 font-medium">
|
||||
<AlertTriangle className="size-3.5 shrink-0" aria-hidden="true" />
|
||||
{overdueLabel}
|
||||
</span>
|
||||
{windowStatus !== null ? (
|
||||
<span className="text-muted-foreground">
|
||||
—{" "}
|
||||
{formatTimeWindowRange(
|
||||
windowStatus.windowStart,
|
||||
windowStatus.windowEnd,
|
||||
locale,
|
||||
)}
|
||||
</span>
|
||||
) : nextLine !== null ? (
|
||||
<span className="text-muted-foreground">— {nextLine}</span>
|
||||
) : null}
|
||||
</span>
|
||||
) : windowStatus !== null ? (
|
||||
<MedicationStatusPill
|
||||
@@ -218,6 +250,21 @@ export function MedicationCardBody({
|
||||
last={lastLine}
|
||||
/>
|
||||
|
||||
{/* v1.16.11 — low-stock context, ONLY below the user's runway
|
||||
threshold (the prop is pre-gated). One calm warning-toned
|
||||
line, not a badge: the card stays quiet until the supply
|
||||
actually needs attention. */}
|
||||
{active && !asNeeded && lowStockRunwayDays != null && (
|
||||
<p
|
||||
className="text-warning text-xs"
|
||||
data-slot="medication-card-low-stock"
|
||||
>
|
||||
{t("medications.cardLowStockRunway", {
|
||||
days: lowStockRunwayDays,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Compliance bars — always two rows; constant-height skeleton holds
|
||||
the slot while the query is in flight so the grid row stays even.
|
||||
A FAILED query swaps the skeleton for the same-footprint quiet
|
||||
|
||||
@@ -55,6 +55,14 @@ export function MedicationStatusPill({
|
||||
const { t, locale } = useTranslations();
|
||||
const Root = inline ? "span" : "p";
|
||||
|
||||
// v1.16.11 — the inline (next-row) variant keeps its window-range
|
||||
// suffix visible on mobile: the suffix is the row's only time anchor
|
||||
// there, and hiding it left every prompt tier time-less below `sm`.
|
||||
// The standalone `<p>` contexts keep the legacy truncation.
|
||||
const suffixClass = inline
|
||||
? "text-muted-foreground"
|
||||
: "text-muted-foreground hidden sm:inline";
|
||||
|
||||
if (takenEarlyDaysAgo != null) {
|
||||
return (
|
||||
<Root className="text-sm">
|
||||
@@ -65,7 +73,7 @@ export function MedicationStatusPill({
|
||||
: t("medications.lastDoseDaysAgo", { count: takenEarlyDaysAgo })}
|
||||
</span>
|
||||
{!compact && (
|
||||
<span className="text-muted-foreground hidden sm:inline">
|
||||
<span className={suffixClass}>
|
||||
{" "}
|
||||
— {formatTimeWindowRange(windowStart, windowEnd, locale)}
|
||||
</span>
|
||||
@@ -106,7 +114,7 @@ export function MedicationStatusPill({
|
||||
: t("medications.veryOverdue")}
|
||||
</span>
|
||||
{!compact && (
|
||||
<span className="text-muted-foreground hidden sm:inline">
|
||||
<span className={suffixClass}>
|
||||
{" "}
|
||||
— {formatTimeWindowRange(windowStart, windowEnd, locale)}
|
||||
</span>
|
||||
|
||||
@@ -26,6 +26,10 @@ import {
|
||||
toZonedDate,
|
||||
} from "@/lib/medications/window-status";
|
||||
import { resolveDisplayedSlotInstant } from "@/components/medications/card-parts/displayed-slot-instant";
|
||||
import {
|
||||
estimateDailyDoseCount,
|
||||
estimateRunwayDays,
|
||||
} from "@/components/medications/detail/supply-runway";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useMedicationComplianceSummary } from "@/lib/queries/use-medication-compliance-summary";
|
||||
|
||||
@@ -61,6 +65,9 @@ interface ScheduleLite {
|
||||
*/
|
||||
timesOfDay?: string[];
|
||||
doseWindows?: { timeOfDay: string; start: string; end: string }[] | null;
|
||||
/** Cadence fields the supply-runway estimate reads (v1.16.11). */
|
||||
rrule?: string | null;
|
||||
rollingIntervalDays?: number | null;
|
||||
}
|
||||
|
||||
interface DoseChangeLite {
|
||||
@@ -108,6 +115,8 @@ export interface Glp1Medication {
|
||||
* renders it as "overdue — still takeable" instead of a future date.
|
||||
*/
|
||||
nextDueOverdue?: boolean;
|
||||
/** v1.16.10 — dose-derived stock from the list payload; null = inventory tracking off. */
|
||||
stockDosesRemaining?: number | null;
|
||||
schedules: ScheduleLite[];
|
||||
}
|
||||
|
||||
@@ -212,9 +221,11 @@ export function Glp1MedicationCard({
|
||||
queryKey: queryKeys.settingsReminderThresholds(),
|
||||
queryFn: async () => {
|
||||
try {
|
||||
return await apiGet<{ lateMinutes: number; missedMinutes: number }>(
|
||||
"/api/settings/reminder-thresholds",
|
||||
);
|
||||
return await apiGet<{
|
||||
lateMinutes: number;
|
||||
missedMinutes: number;
|
||||
lowStockRunwayDays: number | null;
|
||||
}>("/api/settings/reminder-thresholds");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -328,6 +339,31 @@ export function Glp1MedicationCard({
|
||||
timeZone: userTz,
|
||||
});
|
||||
|
||||
// v1.16.11 — low-stock card context, identical to the generic card:
|
||||
// runway days from the list payload's dose-derived stock, surfaced
|
||||
// ONLY below the user's runway threshold (`lowStockRunwayDays`,
|
||||
// default 7, null = alert off → no card line). Stock 0 with a
|
||||
// consuming schedule is runway 0 (mirrors `evaluateMedicationRunway`).
|
||||
const lowStockThreshold =
|
||||
thresholds == null ? 7 : thresholds.lowStockRunwayDays;
|
||||
const stockRunwayDays =
|
||||
medication.stockDosesRemaining == null
|
||||
? null
|
||||
: medication.stockDosesRemaining > 0
|
||||
? estimateRunwayDays(
|
||||
medication.stockDosesRemaining,
|
||||
medication.schedules,
|
||||
)
|
||||
: estimateDailyDoseCount(medication.schedules) > 0
|
||||
? 0
|
||||
: null;
|
||||
const lowStockRunwayDays =
|
||||
stockRunwayDays !== null &&
|
||||
lowStockThreshold !== null &&
|
||||
stockRunwayDays < lowStockThreshold
|
||||
? stockRunwayDays
|
||||
: null;
|
||||
|
||||
// v1.15.9 — the recent-injection list now feeds ONLY the post-dose
|
||||
// injection-site dialog's rotation history; the card surface no longer
|
||||
// shows where the last shot landed. The operator: "where I injected
|
||||
@@ -459,6 +495,7 @@ export function Glp1MedicationCard({
|
||||
complianceError={complianceError}
|
||||
onRetryCompliance={refetchCompliance}
|
||||
currentCycle={display?.currentCycle ?? null}
|
||||
lowStockRunwayDays={lowStockRunwayDays}
|
||||
intakeLoading={intakeLoading}
|
||||
onRecordIntake={(skipped) => recordIntake(skipped, displayedSlot)}
|
||||
>
|
||||
|
||||
@@ -16,6 +16,10 @@ import {
|
||||
toZonedDate,
|
||||
} from "@/lib/medications/window-status";
|
||||
import { resolveDisplayedSlotInstant } from "@/components/medications/card-parts/displayed-slot-instant";
|
||||
import {
|
||||
estimateDailyDoseCount,
|
||||
estimateRunwayDays,
|
||||
} from "@/components/medications/detail/supply-runway";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import {
|
||||
invalidateKeys,
|
||||
@@ -44,6 +48,9 @@ interface Schedule {
|
||||
*/
|
||||
timesOfDay?: string[];
|
||||
doseWindows?: { timeOfDay: string; start: string; end: string }[] | null;
|
||||
/** Cadence fields the supply-runway estimate reads (v1.16.11). */
|
||||
rrule?: string | null;
|
||||
rollingIntervalDays?: number | null;
|
||||
}
|
||||
|
||||
interface Medication {
|
||||
@@ -92,6 +99,8 @@ interface Medication {
|
||||
* compliance bars with the last-taken oriented presentation.
|
||||
*/
|
||||
asNeeded?: boolean;
|
||||
/** v1.16.10 — dose-derived stock from the list payload; null = inventory tracking off. */
|
||||
stockDosesRemaining?: number | null;
|
||||
schedules: Schedule[];
|
||||
}
|
||||
|
||||
@@ -158,9 +167,11 @@ export function MedicationCard({
|
||||
queryKey: queryKeys.settingsReminderThresholds(),
|
||||
queryFn: async () => {
|
||||
try {
|
||||
return await apiGet<{ lateMinutes: number; missedMinutes: number }>(
|
||||
"/api/settings/reminder-thresholds",
|
||||
);
|
||||
return await apiGet<{
|
||||
lateMinutes: number;
|
||||
missedMinutes: number;
|
||||
lowStockRunwayDays: number | null;
|
||||
}>("/api/settings/reminder-thresholds");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -264,6 +275,33 @@ export function MedicationCard({
|
||||
timeZone: userTz,
|
||||
});
|
||||
|
||||
// v1.16.11 — low-stock card context. Runway days from the list
|
||||
// payload's dose-derived stock over the same estimate the table /
|
||||
// detail Übersicht / notification engine use; surfaced ONLY below the
|
||||
// user's runway threshold (`lowStockRunwayDays`, default 7, null =
|
||||
// alert off → no card line either). Stock 0 with a consuming schedule
|
||||
// is runway 0 (mirrors `evaluateMedicationRunway`); as-needed has no
|
||||
// runway, ever.
|
||||
const lowStockThreshold =
|
||||
thresholds == null ? 7 : thresholds.lowStockRunwayDays;
|
||||
const stockRunwayDays =
|
||||
medication.asNeeded || medication.stockDosesRemaining == null
|
||||
? null
|
||||
: medication.stockDosesRemaining > 0
|
||||
? estimateRunwayDays(
|
||||
medication.stockDosesRemaining,
|
||||
medication.schedules,
|
||||
)
|
||||
: estimateDailyDoseCount(medication.schedules) > 0
|
||||
? 0
|
||||
: null;
|
||||
const lowStockRunwayDays =
|
||||
stockRunwayDays !== null &&
|
||||
lowStockThreshold !== null &&
|
||||
stockRunwayDays < lowStockThreshold
|
||||
? stockRunwayDays
|
||||
: null;
|
||||
|
||||
function formatLastTakenAt(value: string): string {
|
||||
// Intentionally en-CA: gives YYYY-MM-DD which is locale-independent and
|
||||
// string-comparable for the today / yesterday / older bucketing below.
|
||||
@@ -426,6 +464,7 @@ export function MedicationCard({
|
||||
complianceError={complianceError}
|
||||
onRetryCompliance={refetchCompliance}
|
||||
currentCycle={display?.currentCycle ?? null}
|
||||
lowStockRunwayDays={lowStockRunwayDays}
|
||||
intakeLoading={intakeLoading}
|
||||
onRecordIntake={(skipped) => recordIntake(skipped, displayedSlot)}
|
||||
>
|
||||
|
||||
@@ -167,8 +167,6 @@ export function sortMedicationRows<T extends TableMedication>(
|
||||
});
|
||||
}
|
||||
|
||||
const LOW_STOCK_DOSES = 4;
|
||||
|
||||
interface MedicationTableProps {
|
||||
/** Active medications, already in the page's manual order. */
|
||||
activeMedications: TableMedication[];
|
||||
@@ -190,14 +188,19 @@ export function MedicationTable({
|
||||
const [, forceUpdate] = useReducer((x: number) => x + 1, 0);
|
||||
|
||||
// Same reminder-thresholds source as the cards so the status pill
|
||||
// tiers identically on both presentations.
|
||||
// tiers identically on both presentations. The read also carries the
|
||||
// per-user low-stock runway threshold (v1.16.11) so the stock cell's
|
||||
// warn tier fires on the SAME days-of-runway predicate as the
|
||||
// low-stock notification, not a legacy dose count.
|
||||
const { data: thresholds } = useQuery({
|
||||
queryKey: queryKeys.settingsReminderThresholds(),
|
||||
queryFn: async () => {
|
||||
try {
|
||||
return await apiGet<{ lateMinutes: number; missedMinutes: number }>(
|
||||
"/api/settings/reminder-thresholds",
|
||||
);
|
||||
return await apiGet<{
|
||||
lateMinutes: number;
|
||||
missedMinutes: number;
|
||||
lowStockRunwayDays: number | null;
|
||||
}>("/api/settings/reminder-thresholds");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -237,6 +240,11 @@ export function MedicationTable({
|
||||
|
||||
const lateMinutes = thresholds?.lateMinutes ?? 120;
|
||||
const missedMinutes = thresholds?.missedMinutes ?? 240;
|
||||
// Per-user low-stock runway threshold: explicit `null` = the alert is
|
||||
// OFF (no amber tier); a missing / failed read falls back to the
|
||||
// server default (7 days), mirroring `resolveLowStockRunwayDays`.
|
||||
const lowStockRunwayDays =
|
||||
thresholds == null ? 7 : thresholds.lowStockRunwayDays;
|
||||
|
||||
const columns: Array<{
|
||||
key: MedicationTableSortColumn | "status" | "actions";
|
||||
@@ -335,6 +343,7 @@ export function MedicationTable({
|
||||
userTz={userTz}
|
||||
lateMinutes={lateMinutes}
|
||||
missedMinutes={missedMinutes}
|
||||
lowStockRunwayDays={lowStockRunwayDays}
|
||||
/>
|
||||
))}
|
||||
{sortedInactive.map((med) => (
|
||||
@@ -344,6 +353,7 @@ export function MedicationTable({
|
||||
userTz={userTz}
|
||||
lateMinutes={lateMinutes}
|
||||
missedMinutes={missedMinutes}
|
||||
lowStockRunwayDays={lowStockRunwayDays}
|
||||
/>
|
||||
))}
|
||||
</TableBody>
|
||||
@@ -357,6 +367,8 @@ interface MedicationTableRowItemProps {
|
||||
userTz: string;
|
||||
lateMinutes: number;
|
||||
missedMinutes: number;
|
||||
/** Per-user low-stock runway threshold in days; null = alert off. */
|
||||
lowStockRunwayDays: number | null;
|
||||
}
|
||||
|
||||
function MedicationTableRowItem({
|
||||
@@ -364,6 +376,7 @@ function MedicationTableRowItem({
|
||||
userTz,
|
||||
lateMinutes,
|
||||
missedMinutes,
|
||||
lowStockRunwayDays,
|
||||
}: MedicationTableRowItemProps) {
|
||||
const { t, locale } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
@@ -427,8 +440,13 @@ function MedicationTableRowItem({
|
||||
timeZone: userTz,
|
||||
});
|
||||
|
||||
// Status cell — the card body's exact precedence: last-dose context
|
||||
// outranks the overdue escalation, which outranks the window pill.
|
||||
// Status cell — the card body's precedence with one DELIBERATE
|
||||
// divergence: the card suppresses the taken-early pill on its
|
||||
// next-intake row (the "last intake" line below already names the
|
||||
// dose), while the table keeps it — the table has a dedicated Status
|
||||
// column with no neighbouring last-intake context. Order here:
|
||||
// last-dose context outranks the overdue escalation, which outranks
|
||||
// the window pill.
|
||||
const overdueLabel =
|
||||
medication.active && doseStatus === "missed"
|
||||
? t("medications.veryOverdue")
|
||||
@@ -574,6 +592,14 @@ function MedicationTableRowItem({
|
||||
stock === null || stock === undefined
|
||||
? null
|
||||
: estimateRunwayDays(stock, medication.schedules);
|
||||
// Warn tier (amber) on the SAME predicate as the low-stock
|
||||
// notification: projected runway days strictly below the user's
|
||||
// threshold. Threshold OFF (null) → no amber tier; an exhausted stock
|
||||
// (0) stays destructive red regardless.
|
||||
const lowStock =
|
||||
runwayDays !== null &&
|
||||
lowStockRunwayDays !== null &&
|
||||
runwayDays < lowStockRunwayDays;
|
||||
const stockCell =
|
||||
stock === null || stock === undefined ? (
|
||||
<span className="text-muted-foreground">–</span>
|
||||
@@ -584,7 +610,7 @@ function MedicationTableRowItem({
|
||||
"tabular-nums",
|
||||
stock === 0
|
||||
? "text-destructive font-medium"
|
||||
: stock < LOW_STOCK_DOSES
|
||||
: lowStock
|
||||
? "text-warning font-medium"
|
||||
: undefined,
|
||||
)}
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
*/
|
||||
|
||||
import { useState, type FormEvent } from "react";
|
||||
import Link from "next/link";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { Loader2, PackageOpen, Plus } from "lucide-react";
|
||||
@@ -153,6 +154,26 @@ export function InventorySection({
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
// v1.16.11 — the low-stock alert threshold, for the cross-link row
|
||||
// below the supply summary (same shared key + cache the cards read).
|
||||
// Null on failure falls back to the server default (7 days).
|
||||
const { data: thresholds } = useQuery({
|
||||
queryKey: queryKeys.settingsReminderThresholds(),
|
||||
queryFn: async () => {
|
||||
try {
|
||||
return await apiGet<{
|
||||
lateMinutes: number;
|
||||
missedMinutes: number;
|
||||
lowStockRunwayDays: number | null;
|
||||
}>("/api/settings/reminder-thresholds");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
const lowStockDays = thresholds == null ? 7 : thresholds.lowStockRunwayDays;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div
|
||||
@@ -271,6 +292,22 @@ export function InventorySection({
|
||||
{t("medications.detail.bestand.addButton")}
|
||||
</Button>
|
||||
</div>
|
||||
{/* v1.16.11 — cross-link to the low-stock alert setting: the
|
||||
threshold lives in Settings → Notifications, but the question
|
||||
"when will it warn me?" comes up here, where the stock lives. */}
|
||||
<div className="py-2">
|
||||
<Link
|
||||
href="/settings/notifications#low-stock"
|
||||
className="text-muted-foreground hover:text-foreground text-xs underline-offset-2 hover:underline"
|
||||
data-slot="inventory-low-stock-link"
|
||||
>
|
||||
{lowStockDays !== null
|
||||
? t("medications.detail.bestand.lowStockLinkOn", {
|
||||
days: lowStockDays,
|
||||
})
|
||||
: t("medications.detail.bestand.lowStockLinkOff")}
|
||||
</Link>
|
||||
</div>
|
||||
</SettingsGroup>
|
||||
|
||||
{/* v1.16.11 — packaging economics surfaced where the stock lives:
|
||||
|
||||
@@ -46,6 +46,7 @@ import {
|
||||
type ScheduleWindowInput,
|
||||
} from "@/lib/medications/window-status";
|
||||
import { resolveDisplayedSlotInstant } from "@/components/medications/card-parts/displayed-slot-instant";
|
||||
import type { DoseStatus } from "@/lib/analytics/compliance";
|
||||
|
||||
type Translator = (
|
||||
key: string,
|
||||
@@ -69,6 +70,8 @@ export interface DueDerivationMedication {
|
||||
/** Server display-due verdict from `GET /api/medications`. */
|
||||
nextDueAt?: string | null;
|
||||
nextDueOverdue?: boolean;
|
||||
/** v1.16.11 (#316) — as-needed (PRN): never due, never in the set. */
|
||||
asNeeded?: boolean;
|
||||
schedules: DueDerivationSchedule[];
|
||||
}
|
||||
|
||||
@@ -95,6 +98,21 @@ const DEFAULT_THRESHOLDS = { lateMinutes: 120, missedMinutes: 240 };
|
||||
* a non-null current-window status (in_window / late / very_late) that is
|
||||
* not the day-scale taken-early downgrade. Input order is preserved so
|
||||
* the dialog lists medications in the same order the page renders them.
|
||||
*
|
||||
* The due set ADDITIONALLY includes escalated medications: an active,
|
||||
* unpaused, non-as-needed medication whose batched compliance
|
||||
* `currentDose.status` (`doseStatusById`, the same
|
||||
* `useMedicationComplianceSummaryAll` rows the page and the table hold)
|
||||
* is `overdue` / `missed`. A dose whose band + catch-up tail have closed
|
||||
* renders the red escalation value with an ACTIVE take button on its
|
||||
* card, so it must be takeable from the batch too — the window-only
|
||||
* derivation silently dropped it. The recorded slot for such an entry is
|
||||
* exactly what the card's own take button records: the SAME
|
||||
* `resolveDisplayedSlotInstant` call over the same (closed) window
|
||||
* status, which resolves to the server's `nextDueAt` instant (or null,
|
||||
* preserving the unscheduled now-snap path). Entries dedupe by
|
||||
* construction — the window branch and the escalation branch are
|
||||
* mutually exclusive per medication.
|
||||
*/
|
||||
export function deriveDueMedications(
|
||||
medications: DueDerivationMedication[],
|
||||
@@ -103,6 +121,12 @@ export function deriveDueMedications(
|
||||
/** IANA timezone (profile timezone; Berlin is the legacy fallback). */
|
||||
tz?: string;
|
||||
thresholds?: { lateMinutes: number; missedMinutes: number };
|
||||
/**
|
||||
* Batched compliance dose status per medication id (the page's
|
||||
* `useMedicationComplianceSummaryAll` rows). Absent / unknown ids
|
||||
* keep the window-only derivation — the legacy behaviour.
|
||||
*/
|
||||
doseStatusById?: ReadonlyMap<string, DoseStatus>;
|
||||
} = {},
|
||||
): DueMedication[] {
|
||||
const now = options.now ?? new Date();
|
||||
@@ -114,8 +138,10 @@ export function deriveDueMedications(
|
||||
const due: DueMedication[] = [];
|
||||
for (const m of medications) {
|
||||
// Paused courses keep their schedules but must never surface here —
|
||||
// the cards suppress their pills the same way.
|
||||
if (!m.active || m.pausedAt) continue;
|
||||
// the cards suppress their pills the same way. As-needed (PRN)
|
||||
// medications are never due, structurally — even a (bogus)
|
||||
// compliance row for one must not pull it into the set.
|
||||
if (!m.active || m.pausedAt || m.asNeeded) continue;
|
||||
|
||||
const nextDueMs = m.nextDueAt ? new Date(m.nextDueAt).getTime() : NaN;
|
||||
const status = reduceCurrentWindowStatus({
|
||||
@@ -139,8 +165,17 @@ export function deriveDueMedications(
|
||||
});
|
||||
// `takenEarlyDaysAgo` non-null = a day-scale dose already on board
|
||||
// earlier in its period; including it would build a double-dose
|
||||
// confirm list.
|
||||
if (!status.status || status.takenEarlyDaysAgo !== null) continue;
|
||||
// confirm list. The card suppresses every prompt tier (incl. the red
|
||||
// escalation value) the same way.
|
||||
if (status.takenEarlyDaysAgo !== null) continue;
|
||||
|
||||
// Escalation alignment with the card: a compliance-derived
|
||||
// overdue / missed dose keeps an active take button on its card even
|
||||
// after the band's catch-up tail closed (window status null).
|
||||
const complianceStatus = options.doseStatusById?.get(m.id);
|
||||
const escalated =
|
||||
complianceStatus === "overdue" || complianceStatus === "missed";
|
||||
if (!status.status && !escalated) continue;
|
||||
|
||||
due.push({
|
||||
id: m.id,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { PackageOpen } from "lucide-react";
|
||||
|
||||
@@ -144,8 +145,11 @@ export function LowStockCard({
|
||||
|
||||
return (
|
||||
<section
|
||||
// `id` is the anchor target of the supply tab's cross-link
|
||||
// (`/settings/notifications#low-stock`) — keep it stable.
|
||||
id="low-stock"
|
||||
aria-labelledby="settings-low-stock-title"
|
||||
className="bg-card border-border rounded-xl border p-4 sm:p-6"
|
||||
className="bg-card border-border scroll-mt-20 rounded-xl border p-4 sm:p-6"
|
||||
>
|
||||
<SettingsCardHeader
|
||||
icon={PackageOpen}
|
||||
@@ -207,6 +211,17 @@ export function LowStockCard({
|
||||
{msg}
|
||||
</p>
|
||||
)}
|
||||
{/* v1.16.11 — back-link to the medications list, the surface the
|
||||
alert is about (the supply tab links here the same way). */}
|
||||
<p className="mt-3 pl-7">
|
||||
<Link
|
||||
href="/medications"
|
||||
className="text-muted-foreground hover:text-foreground text-xs underline-offset-2 hover:underline"
|
||||
data-slot="low-stock-medications-link"
|
||||
>
|
||||
{t("notifications.lowStock.medicationsLink")}
|
||||
</Link>
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -239,7 +239,8 @@ describe("runMedicationLowStockTick", () => {
|
||||
runwayDays: 5,
|
||||
thresholdDays: 7,
|
||||
unitsRemaining: 5,
|
||||
url: "/medications/med-1",
|
||||
// Deep link lands on the detail page's Bestand (supply) tab.
|
||||
url: "/medications/med-1?tab=bestand",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -352,8 +352,10 @@ export async function runMedicationLowStockTick(
|
||||
unitsRemaining: evaluation.unitsRemaining,
|
||||
dosesRemaining: evaluation.dosesRemaining,
|
||||
// Deep link — the web-push sender threads this into
|
||||
// the service-worker payload's click target.
|
||||
url: `/medications/${med.id}`,
|
||||
// the service-worker payload's click target. Lands on
|
||||
// the detail page's Bestand (supply) tab, the surface
|
||||
// the alert is about.
|
||||
url: `/medications/${med.id}?tab=bestand`,
|
||||
},
|
||||
});
|
||||
if (!outcome.dispatched) {
|
||||
|
||||
@@ -766,6 +766,12 @@ const IOS_METADATA_ALLOWLIST = new Set([
|
||||
"scheduleId",
|
||||
"phase",
|
||||
"date",
|
||||
// v1.16.11 — MEDICATION_LOW_STOCK deep link + context: the in-app
|
||||
// path the push should open (`/medications/{id}?tab=bestand`) plus
|
||||
// the runway / threshold figures the iOS handler can surface.
|
||||
"url",
|
||||
"runwayDays",
|
||||
"thresholdDays",
|
||||
]);
|
||||
|
||||
function pickIosMetadata(
|
||||
|
||||
@@ -33,6 +33,7 @@ import { medicationPaths, medicationResource } from "./medications";
|
||||
import { metaPaths } from "./meta";
|
||||
import { moodPaths } from "./mood";
|
||||
import { profilePaths } from "./profile";
|
||||
import { settingsPaths } from "./settings";
|
||||
import { syncPaths } from "./sync";
|
||||
import { workoutPaths } from "./workouts";
|
||||
|
||||
@@ -53,6 +54,7 @@ export const openApiPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
...adminDiagnosticPaths,
|
||||
...insightsPaths,
|
||||
...moodPaths,
|
||||
...settingsPaths,
|
||||
};
|
||||
|
||||
export const openApiComponents: NonNullable<ZodOpenApiObject["components"]> = {
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import { z } from "zod/v4";
|
||||
import type { ZodOpenApiObject } from "zod-openapi";
|
||||
import { coachPrefsSchema } from "@/lib/validations/coach-prefs";
|
||||
import { notificationPrefsSchema } from "@/lib/validations/notification-prefs";
|
||||
import { sourcePrioritySchema } from "@/lib/validations/source-priority";
|
||||
import { dataEnvelope, errorEnvelope, stdResponses } from "./shared";
|
||||
|
||||
@@ -29,6 +30,80 @@ const disableCoachFlag = z
|
||||
"Per-account Coach opt-out toggle (v1.4.47 W3). `true` hides the Coach FAB and short-circuits its API gates.",
|
||||
});
|
||||
|
||||
// v1.16.11 — per-user notification preferences. The PATCH request body
|
||||
// is the REAL runtime validator (`notificationPrefsSchema` — every
|
||||
// category and field optional, deep-merged server-side); the response
|
||||
// is the fully-resolved shape with the documented defaults filled in.
|
||||
const notificationPrefsPatchRequest = notificationPrefsSchema.meta({
|
||||
id: "NotificationPrefsPatchRequest",
|
||||
description:
|
||||
"Partial per-user notification preferences. Every category and field is optional; the server deep-merges the supplied keys over the persisted row, so a PATCH touching only `medication` leaves the siblings intact. `medication.lowStockRunwayDays` (1–60, nullable) is the low-stock alert threshold in remaining runway days — `null` switches the alert off.",
|
||||
});
|
||||
|
||||
const notificationPrefsResolved = z
|
||||
.object({
|
||||
medication: z.object({
|
||||
clientManaged: z
|
||||
.boolean()
|
||||
.describe(
|
||||
"True when the iOS app owns local medication reminders; the server-side MEDICATION_REMINDER APNs cron is suppressed.",
|
||||
),
|
||||
deliveryDefault: z
|
||||
.enum(["server", "client"])
|
||||
.describe(
|
||||
"Roaming user-level delivery default. \"client\" implies clientManaged: true.",
|
||||
),
|
||||
lowStockRunwayDays: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(60)
|
||||
.nullable()
|
||||
.describe(
|
||||
"Low-stock alert threshold as remaining runway days (1–60). null = alert off. Default 7.",
|
||||
),
|
||||
}),
|
||||
mood: z.object({
|
||||
reminderHour: z
|
||||
.number()
|
||||
.int()
|
||||
.min(0)
|
||||
.max(23)
|
||||
.describe("Local-time hour for the daily mood reminder. Default 22."),
|
||||
}),
|
||||
cycle: z.object({
|
||||
clientManaged: z
|
||||
.boolean()
|
||||
.describe(
|
||||
"True when the iOS app owns local cycle reminders; the server-side cycle APNs cron is suppressed.",
|
||||
),
|
||||
}),
|
||||
coach: z.object({
|
||||
nudgesEnabled: z
|
||||
.boolean()
|
||||
.describe("Master switch for the proactive Coach nudge cron."),
|
||||
nudgeMedication: z
|
||||
.boolean()
|
||||
.describe("Medication-group nudge triggers (compliance)."),
|
||||
nudgeVitals: z
|
||||
.boolean()
|
||||
.describe("Vitals-group nudge triggers (bp / score / weight / sleep)."),
|
||||
nudgeRoutine: z
|
||||
.boolean()
|
||||
.describe(
|
||||
"Routine-group nudge triggers (measurement gap / self-context).",
|
||||
),
|
||||
nudgeFrequency: z
|
||||
.enum(["weekly", "biweekly"])
|
||||
.describe("Nudge frequency cap: one per 7 or per 14 days."),
|
||||
}),
|
||||
})
|
||||
.meta({
|
||||
id: "NotificationPrefs",
|
||||
description:
|
||||
"Fully-resolved per-user notification preferences. Every key is present — a null / drifted persisted row resolves to the documented defaults.",
|
||||
});
|
||||
|
||||
// v1.8.6 — profile read/write surface for the native client. The
|
||||
// runtime validation lives in `src/lib/validations/auth.ts`
|
||||
// (`profileSchema`, whose transforms normalise empty → null and run the
|
||||
@@ -413,4 +488,52 @@ export const profilePaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/auth/me/notification-prefs": {
|
||||
get: {
|
||||
tags: ["Notifications"],
|
||||
summary: "Read per-user notification preferences",
|
||||
description:
|
||||
"Returns the fully-resolved preferences with the documented defaults filled in. A null persisted row resolves to the defaults (server-managed reminders, low-stock threshold 7 days, mood reminder 22:00, Coach nudges on).",
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Resolved preferences.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
notificationPrefsResolved,
|
||||
"GetNotificationPrefsResponse",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
patch: {
|
||||
tags: ["Notifications"],
|
||||
summary: "Update per-user notification preferences",
|
||||
description:
|
||||
"Deep-merges the supplied partial shape over the persisted row — a PATCH touching only one category leaves the siblings intact. Always returns the fully-resolved next state so clients can hard-set their optimistic update. `medication.clientManaged: true` (or `deliveryDefault: \"client\"`) suppresses server-side MEDICATION_REMINDER APNs; `medication.lowStockRunwayDays` (1–60, nullable, default 7) tunes the low-stock alert — null switches it off. Rate-limit 60/min per user.",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": { schema: notificationPrefsPatchRequest },
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Resolved next-state preferences echoed back.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
notificationPrefsResolved,
|
||||
"PatchNotificationPrefsResponse",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* OpenAPI route table — settings read surfaces.
|
||||
*
|
||||
* Part of the OpenAPI route table; aggregated in `./index.ts`.
|
||||
* Schemas come from `src/lib/validations/*` where shared with the
|
||||
* runtime request parsing, so the wire contract stays single-source.
|
||||
*/
|
||||
import { z } from "zod/v4";
|
||||
import type { ZodOpenApiObject } from "zod-openapi";
|
||||
import { dataEnvelope, stdResponses } from "./shared";
|
||||
|
||||
// v1.16.11 — the one threshold read every dose-status consumer makes
|
||||
// (cards, table, take-all-due derivation). `lateMinutes` /
|
||||
// `missedMinutes` are the operator-level singleton; the low-stock
|
||||
// runway threshold rides along but is PER-USER (written through
|
||||
// `PATCH /api/auth/me/notification-prefs`).
|
||||
const reminderThresholdsResponse = z
|
||||
.object({
|
||||
lateMinutes: z
|
||||
.number()
|
||||
.int()
|
||||
.describe(
|
||||
"Minutes after the dose anchor at which an open dose tiers 'late' (operator-level singleton; default 120).",
|
||||
),
|
||||
missedMinutes: z
|
||||
.number()
|
||||
.int()
|
||||
.describe(
|
||||
"Minutes after the dose anchor at which an open dose tiers 'missed' (operator-level singleton; default 240).",
|
||||
),
|
||||
lowStockRunwayDays: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(60)
|
||||
.nullable()
|
||||
.describe(
|
||||
"Per-user low-stock alert threshold as remaining runway days (1–60). null = the alert is off. Default 7. Written via PATCH /api/auth/me/notification-prefs.",
|
||||
),
|
||||
})
|
||||
.meta({
|
||||
id: "ReminderThresholdsResponse",
|
||||
description:
|
||||
"Medication reminder thresholds: the operator-level late/missed minute marks plus the calling user's low-stock runway threshold (v1.16.11).",
|
||||
});
|
||||
|
||||
export const settingsPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
"/api/settings/reminder-thresholds": {
|
||||
get: {
|
||||
tags: ["Notifications"],
|
||||
summary: "Read the medication reminder thresholds",
|
||||
description:
|
||||
"Returns the operator-level late/missed minute thresholds that tier an open dose's status, plus the calling user's low-stock runway threshold (days; null = alert off). One endpoint so every threshold consumer reads one shape. Auth via cookie or Bearer.",
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Resolved thresholds.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
reminderThresholdsResponse,
|
||||
"GetReminderThresholdsResponse",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
Reference in new issue
Block a user