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:
Marc-André Bombeck committed 2026-06-12 23:17:20 +02:00
1 parent 3943dcfb72
commit 6fef2081d0
24 files changed
+970 -40

No files matched your search

+275
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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.",
+24 -5
View File
@@ -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)}
>
+42 -3
View File
@@ -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:
+39 -4
View File
@@ -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,
+16 -1
View File
@@ -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",
}),
}),
);
+4 -2
View File
@@ -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) {
+6
View File
@@ -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(
+2
View File
@@ -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"]> = {
+123
View File
@@ -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,
},
},
},
};
+70
View File
@@ -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,
},
},
},
};