merge(reminders): measurement/Vorsorge reminders with auto-resolve and neutral cards

# Conflicts:
#	docs/api/openapi.yaml
#	src/lib/openapi/routes/index.ts
This commit is contained in:
Marc-André Bombeck committed 2026-06-14 23:36:57 +02:00
commit 4dcaee5ec5
31 files changed
+3183

No files matched your search

+433
View File
@@ -39,6 +39,7 @@ tags:
- name: Export
- name: Sync
- name: Cycle
- name: MeasurementReminders
- name: Admin
- name: Meta
paths:
@@ -5250,6 +5251,148 @@ paths:
$ref: "#/components/schemas/CsvImportTooLargeEnvelope"
"422": *a3
"429": *a4
/api/measurement-reminders:
get:
tags:
- MeasurementReminders
summary: List Vorsorge reminders (v1.17.1)
description: Returns the owner's live (non-tombstoned) Vorsorge reminders, sorted by server-computed nextDueAt ascending
(nulls last). Each row carries the canonical nextDueAt the client renders without recomputing.
responses:
"200":
description: The owner's measurement reminders.
content:
application/json:
schema:
$ref: "#/components/schemas/MeasurementReminderListEnvelope"
"401": *a1
"422": *a3
"429": *a4
post:
tags:
- MeasurementReminders
summary: Create a Vorsorge reminder (v1.17.1)
description: Creates a reminder and computes its server-authoritative nextDueAt. Exactly one of intervalDays (rolling)
or rrule (RFC-5545) is required. Wrapped in withIdempotency. 201 on insert.
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/MeasurementReminderCreate"
responses:
"201":
description: Reminder created.
content:
application/json:
schema:
$ref: "#/components/schemas/MeasurementReminderCreatedEnvelope"
"401": *a1
"422": *a3
"429": *a4
/api/measurement-reminders/{id}:
get:
tags:
- MeasurementReminders
summary: Read a single Vorsorge reminder (v1.17.1)
description: Owner-scoped; a cross-user or tombstoned id 404s.
parameters:
- in: path
name: id
schema:
type: string
required: true
responses:
"200":
description: The reminder.
content:
application/json:
schema:
$ref: "#/components/schemas/MeasurementReminderEnvelope"
"401": *a1
"404": &a6
description: Measurement reminder not found / not owned.
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"422": *a3
"429": *a4
patch:
tags:
- MeasurementReminders
summary: Edit a Vorsorge reminder (v1.17.1)
description: Partial edit; omitted fields are left untouched. nextDueAt is recomputed server-side after the cadence
merge. Owner-scoped.
parameters:
- in: path
name: id
schema:
type: string
required: true
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/MeasurementReminderUpdate"
responses:
"200":
description: Reminder updated.
content:
application/json:
schema:
$ref: "#/components/schemas/MeasurementReminderPatchEnvelope"
"401": *a1
"404": *a6
"422": *a3
"429": *a4
delete:
tags:
- MeasurementReminders
summary: Soft-delete a Vorsorge reminder (v1.17.1)
description: Sets deletedAt (tombstone). Idempotent. Owner-scoped.
parameters:
- in: path
name: id
schema:
type: string
required: true
responses:
"200":
description: Soft-deleted.
content:
application/json:
schema:
$ref: "#/components/schemas/MeasurementReminderDeleteEnvelope"
"401": *a1
"404": *a6
"422": *a3
"429": *a4
/api/measurement-reminders/{id}/satisfy:
post:
tags:
- MeasurementReminders
summary: Mark a Vorsorge reminder done (v1.17.1)
description: "Manual 'Erledigt': stamps lastSatisfiedAt = now and recomputes nextDueAt past now. Free-text reminders
resolve only through this path; typed reminders also auto-resolve in the cron when a matching reading lands."
parameters:
- in: path
name: id
schema:
type: string
required: true
responses:
"200":
description: Reminder satisfied; next-due re-anchored.
content:
application/json:
schema:
$ref: "#/components/schemas/MeasurementReminderSatisfyEnvelope"
"401": *a1
"404": *a6
"422": *a3
"429": *a4
components:
schemas:
PasskeyLoginVerifyRequest:
@@ -6261,6 +6404,11 @@ components:
enum:
- weekly
- biweekly
measurementReminder:
type: object
properties:
clientManaged:
type: boolean
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 —
@@ -6413,6 +6561,107 @@ components:
properties: {}
description: No body fields. The user is taken from the session / Bearer and the locale from the session; the warm
covers every assessment for that user.
MeasurementReminderCreate:
type: object
properties:
label:
type: string
minLength: 1
maxLength: 120
measurementType:
anyOf:
- $ref: "#/components/schemas/MeasurementReminderType"
- type: "null"
intervalDays:
anyOf:
- type: integer
minimum: 1
maximum: 3650
- type: "null"
rrule:
anyOf:
- type: string
minLength: 1
maxLength: 512
- type: "null"
anchorDate:
anyOf:
- type: string
format: date-time
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
- type: "null"
notifyHour:
default: 9
type: integer
minimum: 0
maximum: 23
location:
anyOf:
- type: string
maxLength: 200
- type: "null"
enabled:
default: true
type: boolean
required:
- label
description: Create a Vorsorge reminder. Exactly one of intervalDays (rolling, every N days) or rrule (RFC-5545, e.g.
FREQ=YEARLY) is required. measurementType enables auto-resolve from an incoming reading; omit it for a free-text
checklist reminder.
MeasurementReminderType:
type: string
enum:
- WEIGHT
- BLOOD_PRESSURE_SYS
- PULSE
- BLOOD_GLUCOSE
- OXYGEN_SATURATION
- BODY_FAT
- BODY_TEMPERATURE
description: Auto-resolve target metric. BP resolves on BLOOD_PRESSURE_SYS (the SYS row is the 'a BP was measured'
sentinel). Omit for a free-text Vorsorge that resolves only on a manual satisfy.
MeasurementReminderUpdate:
type: object
properties:
label:
type: string
minLength: 1
maxLength: 120
measurementType:
anyOf:
- $ref: "#/components/schemas/MeasurementReminderType"
- type: "null"
intervalDays:
anyOf:
- type: integer
minimum: 1
maximum: 3650
- type: "null"
rrule:
anyOf:
- type: string
minLength: 1
maxLength: 512
- type: "null"
anchorDate:
anyOf:
- type: string
format: date-time
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
- type: "null"
notifyHour:
type: integer
minimum: 0
maximum: 23
location:
anyOf:
- type: string
maxLength: 200
- type: "null"
enabled:
type: boolean
description: Partial edit of a Vorsorge reminder. Omitted fields are left untouched; nextDueAt is recomputed server-side
after the patch applies.
MedicationTreatmentClass:
type: string
enum:
@@ -15722,6 +15971,190 @@ components:
- data
- error
additionalProperties: false
MeasurementReminderListEnvelope:
type: object
properties:
data:
type: array
items:
$ref: "#/components/schemas/MeasurementReminderDTO"
error:
type: "null"
meta:
type: object
properties:
requestId:
type: string
additionalProperties: false
required:
- data
- error
additionalProperties: false
MeasurementReminderDTO:
type: object
properties:
id:
type: string
label:
type: string
measurementType:
anyOf:
- $ref: "#/components/schemas/MeasurementReminderType"
- type: "null"
intervalDays:
anyOf:
- type: integer
minimum: -9007199254740991
maximum: 9007199254740991
- type: "null"
rrule:
anyOf:
- type: string
- type: "null"
anchorDate:
anyOf:
- type: string
format: date-time
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
- type: "null"
notifyHour:
type: integer
minimum: -9007199254740991
maximum: 9007199254740991
location:
anyOf:
- type: string
- type: "null"
nextDueAt:
anyOf:
- type: string
format: date-time
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
- type: "null"
lastSatisfiedAt:
anyOf:
- type: string
format: date-time
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
- type: "null"
enabled:
type: boolean
createdAt:
type: string
format: date-time
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
updatedAt:
type: string
format: date-time
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
required:
- id
- label
- measurementType
- intervalDays
- rrule
- anchorDate
- notifyHour
- location
- nextDueAt
- lastSatisfiedAt
- enabled
- createdAt
- updatedAt
additionalProperties: false
description: A Vorsorge reminder. nextDueAt is server-computed (server-authoritative). A free-text reminder carries
measurementType=null and resolves only on a manual satisfy.
MeasurementReminderCreatedEnvelope:
type: object
properties:
data:
$ref: "#/components/schemas/MeasurementReminderDTO"
error:
type: "null"
meta:
type: object
properties:
requestId:
type: string
additionalProperties: false
required:
- data
- error
additionalProperties: false
MeasurementReminderEnvelope:
type: object
properties:
data:
$ref: "#/components/schemas/MeasurementReminderDTO"
error:
type: "null"
meta:
type: object
properties:
requestId:
type: string
additionalProperties: false
required:
- data
- error
additionalProperties: false
MeasurementReminderPatchEnvelope:
type: object
properties:
data:
$ref: "#/components/schemas/MeasurementReminderDTO"
error:
type: "null"
meta:
type: object
properties:
requestId:
type: string
additionalProperties: false
required:
- data
- error
additionalProperties: false
MeasurementReminderDeleteEnvelope:
type: object
properties:
data:
type: object
properties:
deleted:
type: boolean
required:
- deleted
additionalProperties: false
error:
type: "null"
meta:
type: object
properties:
requestId:
type: string
additionalProperties: false
required:
- data
- error
additionalProperties: false
MeasurementReminderSatisfyEnvelope:
type: object
properties:
data:
$ref: "#/components/schemas/MeasurementReminderDTO"
error:
type: "null"
meta:
type: object
properties:
requestId:
type: string
additionalProperties: false
required:
- data
- error
additionalProperties: false
MedicationScheduleOutput:
type: object
properties:
+51
View File
@@ -6255,5 +6255,56 @@
"deleteN": "Löschen ({count})",
"clearSelection": "Auswahl aufheben",
"selectionBarLabel": "Auswahlaktionen"
},
"measurementReminders": {
"sectionTitle": "Vorsorge",
"sectionDescription": "Erinnerungen, was wann und wo zu messen oder zu prüfen ist.",
"addButton": "Erinnerung hinzufügen",
"markDone": "Erledigt",
"delete": "Löschen",
"disabledBadge": "Aus",
"nextDue": {
"none": "Nicht geplant",
"today": "Heute fällig",
"tomorrow": "Morgen fällig",
"inDays": "In {days} Tagen"
},
"overdueByDays": "Überfällig seit {days} Tagen",
"cadence": {
"everyNDays": "Alle {days} Tage",
"custom": "Eigener Zeitplan"
},
"location": {
"prefix": "Bei: {location}"
},
"types": {
"WEIGHT": "Gewicht",
"BLOOD_PRESSURE_SYS": "Blutdruck",
"PULSE": "Puls",
"BLOOD_GLUCOSE": "Blutzucker",
"OXYGEN_SATURATION": "Sauerstoffsättigung",
"BODY_FAT": "Körperfett",
"BODY_TEMPERATURE": "Körpertemperatur"
},
"empty": {
"title": "Noch keine Erinnerungen",
"description": "Füge eine Erinnerung hinzu, um z. B. regelmäßig den Blutdruck zu messen oder ein jährliches Blutbild zu planen."
},
"form": {
"label": "Bezeichnung",
"labelPlaceholder": "z. B. Blutdruck messen",
"kind": "Art",
"kindType": "Mit einer Messung verknüpft",
"kindFreeText": "Freitext-Prüfung",
"measurementType": "Messwert",
"cadence": "Wiederholung",
"notifyHour": "Erinnern um",
"location": "Wo (optional)",
"locationPlaceholder": "z. B. Hausarzt, Labor",
"save": "Speichern"
},
"pushTitle": "Vorsorge-Erinnerung",
"pushBody": "Fällig: {label}",
"pushLocation": "(bei {location})"
}
}
+51
View File
@@ -6255,5 +6255,56 @@
"deleteN": "Delete ({count})",
"clearSelection": "Clear selection",
"selectionBarLabel": "Selection actions"
},
"measurementReminders": {
"sectionTitle": "Preventive care",
"sectionDescription": "Reminders for what to measure or check, when, and where.",
"addButton": "Add reminder",
"markDone": "Done",
"delete": "Delete",
"disabledBadge": "Off",
"nextDue": {
"none": "Not scheduled",
"today": "Due today",
"tomorrow": "Due tomorrow",
"inDays": "In {days} days"
},
"overdueByDays": "Overdue by {days} days",
"cadence": {
"everyNDays": "Every {days} days",
"custom": "Custom schedule"
},
"location": {
"prefix": "At: {location}"
},
"types": {
"WEIGHT": "Weight",
"BLOOD_PRESSURE_SYS": "Blood pressure",
"PULSE": "Pulse",
"BLOOD_GLUCOSE": "Blood glucose",
"OXYGEN_SATURATION": "Oxygen saturation",
"BODY_FAT": "Body fat",
"BODY_TEMPERATURE": "Body temperature"
},
"empty": {
"title": "No reminders yet",
"description": "Add a reminder to measure your blood pressure on a cadence, or to schedule an annual blood panel."
},
"form": {
"label": "Label",
"labelPlaceholder": "e.g. Measure blood pressure",
"kind": "Type",
"kindType": "Linked to a measurement",
"kindFreeText": "Free-text check",
"measurementType": "Measurement",
"cadence": "Repeat",
"notifyHour": "Remind at",
"location": "Where (optional)",
"locationPlaceholder": "e.g. GP, Lab",
"save": "Save"
},
"pushTitle": "Preventive-care reminder",
"pushBody": "Time for: {label}",
"pushLocation": "(at {location})"
}
}
+51
View File
@@ -6255,5 +6255,56 @@
"deleteN": "Eliminar ({count})",
"clearSelection": "Borrar selección",
"selectionBarLabel": "Acciones de selección"
},
"measurementReminders": {
"sectionTitle": "Prevención",
"sectionDescription": "Recordatorios de qué medir o revisar, cuándo y dónde.",
"addButton": "Añadir recordatorio",
"markDone": "Hecho",
"delete": "Eliminar",
"disabledBadge": "Apagado",
"nextDue": {
"none": "Sin programar",
"today": "Vence hoy",
"tomorrow": "Vence mañana",
"inDays": "En {days} días"
},
"overdueByDays": "Vencido hace {days} días",
"cadence": {
"everyNDays": "Cada {days} días",
"custom": "Programación personalizada"
},
"location": {
"prefix": "En: {location}"
},
"types": {
"WEIGHT": "Peso",
"BLOOD_PRESSURE_SYS": "Presión arterial",
"PULSE": "Pulso",
"BLOOD_GLUCOSE": "Glucosa en sangre",
"OXYGEN_SATURATION": "Saturación de oxígeno",
"BODY_FAT": "Grasa corporal",
"BODY_TEMPERATURE": "Temperatura corporal"
},
"empty": {
"title": "Aún no hay recordatorios",
"description": "Añade un recordatorio para medir la presión arterial con regularidad o programar un análisis de sangre anual."
},
"form": {
"label": "Etiqueta",
"labelPlaceholder": "p. ej. Medir la presión arterial",
"kind": "Tipo",
"kindType": "Vinculado a una medición",
"kindFreeText": "Comprobación de texto libre",
"measurementType": "Medición",
"cadence": "Repetir",
"notifyHour": "Recordar a las",
"location": "Dónde (opcional)",
"locationPlaceholder": "p. ej. Médico, Laboratorio",
"save": "Guardar"
},
"pushTitle": "Recordatorio de prevención",
"pushBody": "Toca: {label}",
"pushLocation": "(en {location})"
}
}
+51
View File
@@ -6255,5 +6255,56 @@
"deleteN": "Supprimer ({count})",
"clearSelection": "Effacer la sélection",
"selectionBarLabel": "Actions de sélection"
},
"measurementReminders": {
"sectionTitle": "Prévention",
"sectionDescription": "Rappels de ce qu'il faut mesurer ou vérifier, quand et où.",
"addButton": "Ajouter un rappel",
"markDone": "Terminé",
"delete": "Supprimer",
"disabledBadge": "Désactivé",
"nextDue": {
"none": "Non planifié",
"today": "À faire aujourd'hui",
"tomorrow": "À faire demain",
"inDays": "Dans {days} jours"
},
"overdueByDays": "En retard de {days} jours",
"cadence": {
"everyNDays": "Tous les {days} jours",
"custom": "Planning personnalisé"
},
"location": {
"prefix": "Chez : {location}"
},
"types": {
"WEIGHT": "Poids",
"BLOOD_PRESSURE_SYS": "Tension artérielle",
"PULSE": "Pouls",
"BLOOD_GLUCOSE": "Glycémie",
"OXYGEN_SATURATION": "Saturation en oxygène",
"BODY_FAT": "Masse grasse",
"BODY_TEMPERATURE": "Température corporelle"
},
"empty": {
"title": "Aucun rappel pour l'instant",
"description": "Ajoutez un rappel pour mesurer régulièrement votre tension ou planifier un bilan sanguin annuel."
},
"form": {
"label": "Libellé",
"labelPlaceholder": "ex. Mesurer la tension",
"kind": "Type",
"kindType": "Lié à une mesure",
"kindFreeText": "Vérification libre",
"measurementType": "Mesure",
"cadence": "Répétition",
"notifyHour": "Rappeler à",
"location": "Où (facultatif)",
"locationPlaceholder": "ex. Médecin, Laboratoire",
"save": "Enregistrer"
},
"pushTitle": "Rappel de prévention",
"pushBody": "C'est l'heure : {label}",
"pushLocation": "(chez {location})"
}
}
+51
View File
@@ -6255,5 +6255,56 @@
"deleteN": "Elimina ({count})",
"clearSelection": "Cancella selezione",
"selectionBarLabel": "Azioni di selezione"
},
"measurementReminders": {
"sectionTitle": "Prevenzione",
"sectionDescription": "Promemoria su cosa misurare o controllare, quando e dove.",
"addButton": "Aggiungi promemoria",
"markDone": "Fatto",
"delete": "Elimina",
"disabledBadge": "Disattivato",
"nextDue": {
"none": "Non pianificato",
"today": "In scadenza oggi",
"tomorrow": "In scadenza domani",
"inDays": "Tra {days} giorni"
},
"overdueByDays": "In ritardo di {days} giorni",
"cadence": {
"everyNDays": "Ogni {days} giorni",
"custom": "Pianificazione personalizzata"
},
"location": {
"prefix": "Presso: {location}"
},
"types": {
"WEIGHT": "Peso",
"BLOOD_PRESSURE_SYS": "Pressione sanguigna",
"PULSE": "Battito",
"BLOOD_GLUCOSE": "Glicemia",
"OXYGEN_SATURATION": "Saturazione di ossigeno",
"BODY_FAT": "Grasso corporeo",
"BODY_TEMPERATURE": "Temperatura corporea"
},
"empty": {
"title": "Ancora nessun promemoria",
"description": "Aggiungi un promemoria per misurare regolarmente la pressione o pianificare un esame del sangue annuale."
},
"form": {
"label": "Etichetta",
"labelPlaceholder": "es. Misurare la pressione",
"kind": "Tipo",
"kindType": "Collegato a una misurazione",
"kindFreeText": "Controllo a testo libero",
"measurementType": "Misurazione",
"cadence": "Ripeti",
"notifyHour": "Ricorda alle",
"location": "Dove (facoltativo)",
"locationPlaceholder": "es. Medico, Laboratorio",
"save": "Salva"
},
"pushTitle": "Promemoria di prevenzione",
"pushBody": "È ora di: {label}",
"pushLocation": "(presso {location})"
}
}
+51
View File
@@ -6255,5 +6255,56 @@
"deleteN": "Usuń ({count})",
"clearSelection": "Wyczyść zaznaczenie",
"selectionBarLabel": "Akcje zaznaczenia"
},
"measurementReminders": {
"sectionTitle": "Profilaktyka",
"sectionDescription": "Przypomnienia, co, kiedy i gdzie zmierzyć lub sprawdzić.",
"addButton": "Dodaj przypomnienie",
"markDone": "Gotowe",
"delete": "Usuń",
"disabledBadge": "Wył.",
"nextDue": {
"none": "Niezaplanowane",
"today": "Termin dzisiaj",
"tomorrow": "Termin jutro",
"inDays": "Za {days} dni"
},
"overdueByDays": "Zaległe od {days} dni",
"cadence": {
"everyNDays": "Co {days} dni",
"custom": "Własny harmonogram"
},
"location": {
"prefix": "U: {location}"
},
"types": {
"WEIGHT": "Waga",
"BLOOD_PRESSURE_SYS": "Ciśnienie krwi",
"PULSE": "Tętno",
"BLOOD_GLUCOSE": "Glukoza we krwi",
"OXYGEN_SATURATION": "Saturacja tlenem",
"BODY_FAT": "Tkanka tłuszczowa",
"BODY_TEMPERATURE": "Temperatura ciała"
},
"empty": {
"title": "Brak przypomnień",
"description": "Dodaj przypomnienie, aby regularnie mierzyć ciśnienie krwi lub zaplanować coroczne badanie krwi."
},
"form": {
"label": "Etykieta",
"labelPlaceholder": "np. Zmierz ciśnienie krwi",
"kind": "Typ",
"kindType": "Powiązane z pomiarem",
"kindFreeText": "Sprawdzenie tekstowe",
"measurementType": "Pomiar",
"cadence": "Powtarzaj",
"notifyHour": "Przypomnij o",
"location": "Gdzie (opcjonalnie)",
"locationPlaceholder": "np. Lekarz, Laboratorium",
"save": "Zapisz"
},
"pushTitle": "Przypomnienie profilaktyczne",
"pushBody": "Czas na: {label}",
"pushLocation": "(u {location})"
}
}
@@ -103,6 +103,9 @@ describe("GET /api/auth/me/notification-prefs", () => {
nudgeRoutine: true,
nudgeFrequency: "weekly",
},
measurementReminder: {
clientManaged: false,
},
});
});
@@ -135,6 +138,9 @@ describe("GET /api/auth/me/notification-prefs", () => {
nudgeRoutine: true,
nudgeFrequency: "weekly",
},
measurementReminder: {
clientManaged: false,
},
});
});
@@ -169,6 +175,9 @@ describe("GET /api/auth/me/notification-prefs", () => {
nudgeRoutine: true,
nudgeFrequency: "weekly",
},
measurementReminder: {
clientManaged: false,
},
});
});
});
@@ -214,6 +223,9 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
nudgeRoutine: true,
nudgeFrequency: "weekly",
},
measurementReminder: {
clientManaged: false,
},
});
expect(prisma.user.update).toHaveBeenCalledWith({
@@ -235,6 +247,9 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
nudgeRoutine: true,
nudgeFrequency: "weekly",
},
measurementReminder: {
clientManaged: false,
},
},
},
});
@@ -260,6 +275,9 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
nudgeRoutine: true,
nudgeFrequency: "weekly",
},
measurementReminder: {
clientManaged: false,
},
},
next: {
medication: {
@@ -277,6 +295,9 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
nudgeRoutine: true,
nudgeFrequency: "weekly",
},
measurementReminder: {
clientManaged: false,
},
},
changed: ["medication"],
}),
@@ -347,6 +368,9 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
nudgeRoutine: true,
nudgeFrequency: "weekly",
},
measurementReminder: {
clientManaged: false,
},
},
},
});
@@ -382,6 +406,9 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
nudgeRoutine: true,
nudgeFrequency: "weekly",
},
measurementReminder: {
clientManaged: false,
},
});
expect(prisma.user.update).toHaveBeenCalledWith({
@@ -403,6 +430,9 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
nudgeRoutine: true,
nudgeFrequency: "weekly",
},
measurementReminder: {
clientManaged: false,
},
},
},
});
@@ -443,6 +473,9 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
nudgeRoutine: true,
nudgeFrequency: "weekly",
},
measurementReminder: {
clientManaged: false,
},
},
},
});
@@ -0,0 +1,209 @@
/**
* v1.17.1 — Vorsorge (measurement) reminder by id: get + patch + delete.
*
* PATCH re-derives the server-authoritative `nextDueAt` after applying
* the (mutually-exclusive cadence) edit. DELETE soft-deletes (tombstone)
* for parity with the rest of the tree.
*/
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiSuccess,
apiError,
getClientIp,
returnAllZodIssues,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { updateMeasurementReminderSchema } from "@/lib/validations/measurement-reminders";
import {
computeReminderNextDueAt,
type ReminderScheduleInput,
} from "@/lib/measurement-reminders/scheduling";
import { toMeasurementReminderDto } from "@/lib/measurement-reminders/dto";
type RouteParams = { params: Promise<{ id: string }> };
const DEFAULT_TIMEZONE = "Europe/Berlin";
async function resolveTimezone(userId: string): Promise<string> {
const row = await prisma.user.findUnique({
where: { id: userId },
select: { timezone: true },
});
return row?.timezone || DEFAULT_TIMEZONE;
}
export const GET = apiHandler(
async (_request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
const { id } = await params;
const reminder = await prisma.measurementReminder.findFirst({
where: { id, deletedAt: null },
});
if (!reminder || reminder.userId !== user.id) {
return apiError("Measurement reminder not found", 404);
}
annotate({
action: { name: "measurement-reminders.get" },
meta: { reminderId: id },
});
return apiSuccess(toMeasurementReminderDto(reminder));
},
);
export const PATCH = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
const { id } = await params;
const existing = await prisma.measurementReminder.findFirst({
where: { id, deletedAt: null },
});
if (!existing || existing.userId !== user.id) {
return apiError("Measurement reminder not found", 404);
}
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 16 * 1024,
});
if (jsonError) return jsonError;
const parsed = updateMeasurementReminderSchema.safeParse(body);
if (!parsed.success) {
const issues = sanitiseZodIssues(parsed.error.issues);
annotate({
action: { name: "measurement-reminders.update.validation-failed" },
meta: { issue_count: issues.length, reminderId: id },
});
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "measurement-reminders.update.validation-failed",
details: JSON.stringify({ issues: auditIssues, reminderId: id }),
},
})
.catch(() => {
/* swallow — 422 response is the contract */
});
return returnAllZodIssues(parsed.error, 422);
}
const data = parsed.data;
// Field-by-field — no mass assignment.
const updateData: Record<string, unknown> = {};
if (data.label !== undefined) updateData.label = data.label;
if (data.measurementType !== undefined) {
updateData.measurementType = data.measurementType;
}
if (data.intervalDays !== undefined) {
updateData.intervalDays = data.intervalDays;
// Setting one cadence clears the other so the engine reads exactly
// one dispatch family.
if (data.intervalDays !== null && data.rrule === undefined) {
updateData.rrule = null;
}
}
if (data.rrule !== undefined) {
updateData.rrule = data.rrule;
if (data.rrule !== null && data.intervalDays === undefined) {
updateData.intervalDays = null;
}
}
if (data.anchorDate !== undefined) {
updateData.anchorDate =
data.anchorDate != null ? new Date(data.anchorDate) : null;
}
if (data.notifyHour !== undefined) updateData.notifyHour = data.notifyHour;
if (data.location !== undefined) updateData.location = data.location;
if (data.enabled !== undefined) updateData.enabled = data.enabled;
// Recompute next-due against the merged cadence. Floor the search at
// the last-satisfied instant (or now) so a cadence edit re-anchors
// cleanly off the user's last fulfilment.
const timezone = await resolveTimezone(user.id);
const now = new Date();
const merged: ReminderScheduleInput = {
intervalDays:
(updateData.intervalDays as number | null | undefined) ??
existing.intervalDays,
rrule: (updateData.rrule as string | null | undefined) ?? existing.rrule,
anchorDate:
(updateData.anchorDate as Date | null | undefined) ??
existing.anchorDate,
notifyHour:
(updateData.notifyHour as number | undefined) ?? existing.notifyHour,
lastSatisfiedAt: existing.lastSatisfiedAt,
createdAt: existing.createdAt,
};
const after =
existing.lastSatisfiedAt && existing.lastSatisfiedAt > now
? existing.lastSatisfiedAt
: now;
updateData.nextDueAt = computeReminderNextDueAt(merged, timezone, after);
const updated = await prisma.measurementReminder.update({
where: { id },
data: updateData,
});
await auditLog("measurementReminder.update", {
userId: user.id,
ipAddress: getClientIp(request),
details: { reminderId: id },
});
annotate({
action: { name: "measurement-reminders.update" },
meta: { reminderId: id },
});
return apiSuccess(toMeasurementReminderDto(updated));
},
);
export const DELETE = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
const { id } = await params;
const existing = await prisma.measurementReminder.findUnique({
where: { id },
});
if (!existing || existing.userId !== user.id) {
return apiError("Measurement reminder not found", 404);
}
// Soft-delete (tombstone) parity with the rest of the tree. A
// re-delete of an already-tombstoned row is idempotent.
await prisma.measurementReminder.update({
where: { id },
data: { deletedAt: new Date() },
});
await auditLog("measurementReminder.delete", {
userId: user.id,
ipAddress: getClientIp(request),
details: { reminderId: id },
});
annotate({
action: { name: "measurement-reminders.delete" },
meta: { reminderId: id },
});
return apiSuccess({ deleted: true });
},
);
@@ -0,0 +1,73 @@
/**
* v1.17.1 — manual "Erledigt" for a Vorsorge reminder.
*
* Stamps `lastSatisfiedAt = now` and recomputes the server-authoritative
* `nextDueAt` past now. Free-text (no measurementType) reminders resolve
* ONLY through this path; typed reminders auto-resolve in the cron when a
* matching reading lands, but a manual satisfy still works for them.
*/
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess, apiError, getClientIp } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import {
computeReminderNextDueAt,
type ReminderScheduleInput,
} from "@/lib/measurement-reminders/scheduling";
import { toMeasurementReminderDto } from "@/lib/measurement-reminders/dto";
type RouteParams = { params: Promise<{ id: string }> };
const DEFAULT_TIMEZONE = "Europe/Berlin";
export const POST = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
const { id } = await params;
const existing = await prisma.measurementReminder.findFirst({
where: { id, deletedAt: null },
});
if (!existing || existing.userId !== user.id) {
return apiError("Measurement reminder not found", 404);
}
const userRow = await prisma.user.findUnique({
where: { id: user.id },
select: { timezone: true },
});
const timezone = userRow?.timezone || DEFAULT_TIMEZONE;
const now = new Date();
const scheduleInput: ReminderScheduleInput = {
intervalDays: existing.intervalDays,
rrule: existing.rrule,
anchorDate: existing.anchorDate,
notifyHour: existing.notifyHour,
lastSatisfiedAt: now,
createdAt: existing.createdAt,
};
const nextDueAt = computeReminderNextDueAt(scheduleInput, timezone, now);
const updated = await prisma.measurementReminder.update({
where: { id },
data: { lastSatisfiedAt: now, nextDueAt },
});
await auditLog("measurementReminder.satisfy", {
userId: user.id,
ipAddress: getClientIp(request),
details: { reminderId: id },
});
annotate({
action: { name: "measurement-reminders.satisfy" },
meta: { reminderId: id },
});
return apiSuccess(toMeasurementReminderDto(updated));
},
);
+137
View File
@@ -0,0 +1,137 @@
/**
* v1.17.1 — Vorsorge (measurement) reminders CRUD: list + create.
*
* Server-authoritative `nextDueAt` is computed via the canonical
* medication recurrence engine so web ↔ iOS read identical numbers.
*/
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiSuccess,
getClientIp,
returnAllZodIssues,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { withIdempotency } from "@/lib/idempotency";
import { createMeasurementReminderSchema } from "@/lib/validations/measurement-reminders";
import {
computeReminderNextDueAt,
type ReminderScheduleInput,
} from "@/lib/measurement-reminders/scheduling";
import { toMeasurementReminderDto } from "@/lib/measurement-reminders/dto";
const DEFAULT_TIMEZONE = "Europe/Berlin";
async function resolveTimezone(userId: string): Promise<string> {
const row = await prisma.user.findUnique({
where: { id: userId },
select: { timezone: true },
});
return row?.timezone || DEFAULT_TIMEZONE;
}
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
const reminders = await prisma.measurementReminder.findMany({
where: { userId: user.id, deletedAt: null },
// Most-urgent first; a null next-due (uncomputable / disabled) sinks
// to the end so the actionable items float to the top.
orderBy: [{ nextDueAt: { sort: "asc", nulls: "last" } }, { createdAt: "asc" }],
});
annotate({
action: { name: "measurement-reminders.list" },
meta: { total: reminders.length },
});
return apiSuccess(reminders.map(toMeasurementReminderDto));
});
async function postReminder(request: NextRequest): Promise<Response> {
const { user } = await requireAuth();
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 16 * 1024,
});
if (jsonError) return jsonError;
const parsed = createMeasurementReminderSchema.safeParse(body);
if (!parsed.success) {
const issues = sanitiseZodIssues(parsed.error.issues);
annotate({
action: { name: "measurement-reminders.create.validation-failed" },
meta: { issue_count: issues.length },
});
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "measurement-reminders.create.validation-failed",
details: JSON.stringify({ issues: auditIssues }),
},
})
.catch(() => {
/* swallow — 422 response is the contract */
});
return returnAllZodIssues(parsed.error, 422);
}
const data = parsed.data;
const timezone = await resolveTimezone(user.id);
const now = new Date();
const anchorDate =
data.anchorDate != null ? new Date(data.anchorDate) : null;
// Server-authoritative next-due. No satisfy yet → anchors on
// anchorDate ?? createdAt (createdAt ≈ now for a fresh row).
const scheduleInput: ReminderScheduleInput = {
intervalDays: data.intervalDays ?? null,
rrule: data.rrule ?? null,
anchorDate,
notifyHour: data.notifyHour,
lastSatisfiedAt: null,
createdAt: now,
};
const nextDueAt = computeReminderNextDueAt(scheduleInput, timezone, now);
// Field-by-field — no mass assignment.
const created = await prisma.measurementReminder.create({
data: {
userId: user.id,
label: data.label,
measurementType: data.measurementType ?? null,
intervalDays: data.intervalDays ?? null,
rrule: data.rrule ?? null,
anchorDate,
notifyHour: data.notifyHour,
location: data.location ?? null,
enabled: data.enabled,
nextDueAt,
},
});
await auditLog("measurementReminder.create", {
userId: user.id,
ipAddress: getClientIp(request),
details: { reminderId: created.id },
});
annotate({
action: { name: "measurement-reminders.create" },
meta: { reminderId: created.id, hasType: data.measurementType != null },
});
return apiSuccess(toMeasurementReminderDto(created), 201);
}
export const POST = apiHandler(withIdempotency<[NextRequest]>(postReminder));
+31
View File
@@ -0,0 +1,31 @@
"use client";
import { Loader2 } from "lucide-react";
import { useAuth } from "@/hooks/use-auth";
import { useMounted } from "@/hooks/use-mounted";
import { VorsorgeSection } from "@/components/measurement-reminders/vorsorge-section";
/**
* v1.17.1 — Vorsorge (preventive-care) reminders page. The dedicated
* feature surface for "wann muss ich was wo machen". Auth-gated; the
* section component owns the list + create flow.
*/
export default function VorsorgePage() {
const { isAuthenticated, isLoading } = useAuth();
const mounted = useMounted();
if (!mounted || isLoading) {
return (
<div className="flex min-h-[40vh] items-center justify-center">
<Loader2 className="text-muted-foreground h-6 w-6 animate-spin" />
</div>
);
}
return (
<div className="mx-auto w-full max-w-2xl px-4 py-6">
<VorsorgeSection enabled={isAuthenticated} />
</div>
);
}
@@ -0,0 +1,369 @@
"use client";
/**
* v1.17.1 — Vorsorge (preventive-care / measurement) reminder surface.
*
* Answers "wann muss ich was wo machen": a list of upcoming reminders
* sorted by server-computed next-due, each card showing the label, the
* cadence, the location, and a relative next-due badge. Per the project
* rule the card stays NEUTRAL regardless of due state — no alarming
* red/green tint; status reads through a discreet badge only.
*
* The server is authoritative for `nextDueAt`; this component renders it
* relative to "now" but never recomputes the cadence.
*/
import { useState } from "react";
import { CheckCircle2, Plus, Trash2 } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { EmptyState } from "@/components/ui/empty-state";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { NativeSelect } from "@/components/ui/native-select";
import {
useMeasurementReminders,
useMeasurementReminderMutations,
type MeasurementReminder,
} from "@/hooks/use-measurement-reminders";
const TYPE_OPTIONS = [
"WEIGHT",
"BLOOD_PRESSURE_SYS",
"PULSE",
"BLOOD_GLUCOSE",
"OXYGEN_SATURATION",
"BODY_FAT",
"BODY_TEMPERATURE",
] as const;
const INTERVAL_PRESETS = [7, 14, 30, 90, 180, 365] as const;
const DAY_MS = 24 * 60 * 60 * 1000;
function relativeDueKey(
nextDueAt: string | null,
now: number,
): { key: string; days: number } {
if (!nextDueAt) return { key: "nextDue.none", days: 0 };
const due = new Date(nextDueAt).getTime();
// Compare calendar-day deltas so "today" / "in 1 day" read cleanly.
const deltaDays = Math.round((due - now) / DAY_MS);
if (deltaDays < 0) return { key: "overdueByDays", days: Math.abs(deltaDays) };
if (deltaDays === 0) return { key: "nextDue.today", days: 0 };
if (deltaDays === 1) return { key: "nextDue.tomorrow", days: 1 };
return { key: "nextDue.inDays", days: deltaDays };
}
export function VorsorgeSection({ enabled = true }: { enabled?: boolean }) {
const { t } = useTranslations();
const { data: reminders, isLoading } = useMeasurementReminders(enabled);
const { create, remove, satisfy } = useMeasurementReminderMutations();
const [showForm, setShowForm] = useState(false);
// Form state.
const [label, setLabel] = useState("");
const [kind, setKind] = useState<"type" | "freeText">("type");
const [measurementType, setMeasurementType] =
useState<string>("BLOOD_PRESSURE_SYS");
const [intervalDays, setIntervalDays] = useState<number>(7);
const [notifyHour, setNotifyHour] = useState<number>(9);
const [location, setLocation] = useState("");
// Wall-clock anchor for the relative-due labels, captured once at mount
// via a lazy state initializer so the impure Date.now() stays out of
// render (the repo's purity + set-state-in-effect rules both reject the
// alternatives). The relative labels are coarse (day granularity), so a
// mount-time snapshot is plenty fresh.
const [now] = useState(() => Date.now());
function resetForm() {
setLabel("");
setKind("type");
setMeasurementType("BLOOD_PRESSURE_SYS");
setIntervalDays(7);
setNotifyHour(9);
setLocation("");
}
function submit() {
const trimmed = label.trim();
if (!trimmed) return;
create.mutate(
{
label: trimmed,
measurementType: kind === "type" ? measurementType : null,
intervalDays,
notifyHour,
location: location.trim() || null,
},
{
onSuccess: () => {
resetForm();
setShowForm(false);
},
},
);
}
return (
<section
aria-labelledby="vorsorge-section-title"
className="space-y-4"
>
<header className="flex items-center justify-between gap-2">
<div className="space-y-1">
<h2
id="vorsorge-section-title"
className="text-lg font-semibold"
>
{t("measurementReminders.sectionTitle")}
</h2>
<p className="text-muted-foreground text-sm">
{t("measurementReminders.sectionDescription")}
</p>
</div>
<Button
type="button"
size="sm"
variant={showForm ? "outline" : "default"}
onClick={() => setShowForm((v) => !v)}
>
<Plus className="mr-1 h-4 w-4" />
{t("measurementReminders.addButton")}
</Button>
</header>
{showForm && (
<Card>
<CardContent className="space-y-4 pt-6">
<div className="space-y-2">
<Label htmlFor="vorsorge-label">
{t("measurementReminders.form.label")}
</Label>
<Input
id="vorsorge-label"
value={label}
maxLength={120}
placeholder={t("measurementReminders.form.labelPlaceholder")}
onChange={(e) => setLabel(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="vorsorge-kind">
{t("measurementReminders.form.kind")}
</Label>
<NativeSelect
id="vorsorge-kind"
value={kind}
onChange={(e) =>
setKind(e.target.value as "type" | "freeText")
}
>
<option value="type">
{t("measurementReminders.form.kindType")}
</option>
<option value="freeText">
{t("measurementReminders.form.kindFreeText")}
</option>
</NativeSelect>
</div>
{kind === "type" && (
<div className="space-y-2">
<Label htmlFor="vorsorge-type">
{t("measurementReminders.form.measurementType")}
</Label>
<NativeSelect
id="vorsorge-type"
value={measurementType}
onChange={(e) => setMeasurementType(e.target.value)}
>
{TYPE_OPTIONS.map((type) => (
<option key={type} value={type}>
{t(`measurementReminders.types.${type}`)}
</option>
))}
</NativeSelect>
</div>
)}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="vorsorge-interval">
{t("measurementReminders.form.cadence")}
</Label>
<NativeSelect
id="vorsorge-interval"
value={String(intervalDays)}
onChange={(e) => setIntervalDays(Number(e.target.value))}
>
{INTERVAL_PRESETS.map((days) => (
<option key={days} value={days}>
{t("measurementReminders.cadence.everyNDays", { days })}
</option>
))}
</NativeSelect>
</div>
<div className="space-y-2">
<Label htmlFor="vorsorge-hour">
{t("measurementReminders.form.notifyHour")}
</Label>
<NativeSelect
id="vorsorge-hour"
value={String(notifyHour)}
onChange={(e) => setNotifyHour(Number(e.target.value))}
>
{Array.from({ length: 24 }, (_, h) => (
<option key={h} value={h}>
{`${h.toString().padStart(2, "0")}:00`}
</option>
))}
</NativeSelect>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="vorsorge-location">
{t("measurementReminders.form.location")}
</Label>
<Input
id="vorsorge-location"
value={location}
maxLength={200}
placeholder={t(
"measurementReminders.form.locationPlaceholder",
)}
onChange={(e) => setLocation(e.target.value)}
/>
</div>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="ghost"
onClick={() => {
resetForm();
setShowForm(false);
}}
>
{t("common.cancel")}
</Button>
<Button
type="button"
onClick={submit}
disabled={!label.trim() || create.isPending}
>
{t("measurementReminders.form.save")}
</Button>
</div>
</CardContent>
</Card>
)}
{!isLoading && (reminders?.length ?? 0) === 0 && !showForm && (
<EmptyState
title={t("measurementReminders.empty.title")}
description={t("measurementReminders.empty.description")}
/>
)}
<ul className="space-y-3">
{reminders?.map((reminder) => (
<VorsorgeCard
key={reminder.id}
reminder={reminder}
now={now}
onSatisfy={() => satisfy.mutate(reminder.id)}
onRemove={() => remove.mutate(reminder.id)}
busy={satisfy.isPending || remove.isPending}
/>
))}
</ul>
</section>
);
}
function VorsorgeCard({
reminder,
now,
onSatisfy,
onRemove,
busy,
}: {
reminder: MeasurementReminder;
now: number;
onSatisfy: () => void;
onRemove: () => void;
busy: boolean;
}) {
const { t } = useTranslations();
const due = relativeDueKey(reminder.nextDueAt, now);
const cadence =
reminder.intervalDays != null
? t("measurementReminders.cadence.everyNDays", {
days: reminder.intervalDays,
})
: reminder.rrule
? t("measurementReminders.cadence.custom")
: "";
return (
<li>
{/* NEUTRAL card — no status-driven colour. The due state reads only
through the discreet badge below. */}
<Card>
<CardContent className="flex items-start justify-between gap-3 pt-6">
<div className="min-w-0 space-y-1">
<div className="flex items-center gap-2">
<p className="truncate font-medium">{reminder.label}</p>
{!reminder.enabled && (
<Badge variant="outline">
{t("measurementReminders.disabledBadge")}
</Badge>
)}
</div>
<p className="text-muted-foreground text-sm">
<Badge variant="secondary" className="mr-2">
{t(`measurementReminders.${due.key}`, { days: due.days })}
</Badge>
{cadence}
</p>
{reminder.location && (
<p className="text-muted-foreground text-sm">
{t("measurementReminders.location.prefix", {
location: reminder.location,
})}
</p>
)}
</div>
<div className="flex shrink-0 gap-1">
<Button
type="button"
size="sm"
variant="outline"
onClick={onSatisfy}
disabled={busy}
aria-label={t("measurementReminders.markDone")}
>
<CheckCircle2 className="mr-1 h-4 w-4" />
{t("measurementReminders.markDone")}
</Button>
<Button
type="button"
size="sm"
variant="ghost"
onClick={onRemove}
disabled={busy}
aria-label={t("measurementReminders.delete")}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
</li>
);
}
+97
View File
@@ -0,0 +1,97 @@
"use client";
/**
* v1.17.1 — data hook for Vorsorge (measurement) reminders.
*
* Wraps the `/api/measurement-reminders` CRUD + satisfy surface behind
* TanStack Query. Every mutation invalidates the one
* `measurementReminders()` root so the section list and the dashboard
* tile repaint in lockstep. The DTO is the server-authoritative shape —
* the client renders `nextDueAt` as-is, never recomputing.
*/
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { apiDelete, apiGet, apiPatch, apiPost } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
export interface MeasurementReminder {
id: string;
label: string;
measurementType: string | null;
intervalDays: number | null;
rrule: string | null;
anchorDate: string | null;
notifyHour: number;
location: string | null;
nextDueAt: string | null;
lastSatisfiedAt: string | null;
enabled: boolean;
createdAt: string;
updatedAt: string;
}
export interface CreateMeasurementReminderBody {
label: string;
measurementType?: string | null;
intervalDays?: number | null;
rrule?: string | null;
anchorDate?: string | null;
notifyHour?: number;
location?: string | null;
enabled?: boolean;
}
export type UpdateMeasurementReminderBody = Partial<
CreateMeasurementReminderBody
>;
const BASE = "/api/measurement-reminders";
export function useMeasurementReminders(enabled = true) {
return useQuery({
queryKey: queryKeys.measurementReminders(),
queryFn: () => apiGet<MeasurementReminder[]>(BASE),
enabled,
});
}
export function useMeasurementReminderMutations() {
const qc = useQueryClient();
const invalidate = () =>
qc.invalidateQueries({ queryKey: queryKeys.measurementReminders() });
const create = useMutation({
mutationKey: queryKeys.measurementReminderCreate(),
mutationFn: (body: CreateMeasurementReminderBody) =>
apiPost<MeasurementReminder>(BASE, body),
onSuccess: invalidate,
});
const update = useMutation({
mutationKey: queryKeys.measurementReminderUpdate(),
mutationFn: ({
id,
body,
}: {
id: string;
body: UpdateMeasurementReminderBody;
}) => apiPatch<MeasurementReminder>(`${BASE}/${id}`, body),
onSuccess: invalidate,
});
const remove = useMutation({
mutationKey: queryKeys.measurementReminderDelete(),
mutationFn: (id: string) =>
apiDelete<{ deleted: boolean }>(`${BASE}/${id}`),
onSuccess: invalidate,
});
const satisfy = useMutation({
mutationKey: queryKeys.measurementReminderSatisfy(),
mutationFn: (id: string) =>
apiPost<MeasurementReminder>(`${BASE}/${id}/satisfy`),
onSuccess: invalidate,
});
return { create, update, remove, satisfy };
}
@@ -0,0 +1,60 @@
/**
* v1.17.1 — measurement-reminder queue registration guard.
*
* Same source-text-grep approach as the cycle / mood / drain registration
* guards: assert the Vorsorge-reminder queue is declared, listed in
* `allQueues` (the v1.4.37 dead-queue lesson — an unregistered queue is
* never provisioned and the schedule silently no-ops), scheduled on its
* cron, and wired to a `boss.work` handler that delegates to
* `runMeasurementReminderTick`. No pg-boss / Prisma boot.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
const source =
readFileSync(join(__dirname, "..", "reminder-worker.ts"), "utf8") +
readFileSync(
join(__dirname, "..", "reminder", "mood-cycle-checks.ts"),
"utf8",
);
describe("reminder-worker — measurement-reminder wiring", () => {
it("imports the measurement-reminder tick runner", () => {
expect(source).toMatch(
/from\s*["']@\/lib\/jobs\/measurement-reminder["']/,
);
expect(source).toMatch(/\brunMeasurementReminderTick\b/);
});
it("declares the queue + cron constants", () => {
expect(source).toMatch(
/MEASUREMENT_REMINDER_QUEUE\s*=\s*["']measurement-reminder-check["']/,
);
expect(source).toMatch(
/MEASUREMENT_REMINDER_CRON\s*=\s*["']\*\/15 \* \* \* \*["']/,
);
});
it("registers the queue in the allQueues loop", () => {
const allQueuesMatch = source.match(/const allQueues\s*=\s*\[([\s\S]*?)\];/);
expect(allQueuesMatch).not.toBeNull();
expect(allQueuesMatch![1]).toMatch(/\bMEASUREMENT_REMINDER_QUEUE\b/);
});
it("schedules the measurement-reminder cron", () => {
expect(source).toMatch(
/\[MEASUREMENT_REMINDER_QUEUE,\s*MEASUREMENT_REMINDER_CRON\]/,
);
});
it("registers a boss.work handler that runs the tick", () => {
expect(source).toMatch(
/boss\.work[\s\S]{0,200}MEASUREMENT_REMINDER_QUEUE[\s\S]{0,200}handleMeasurementReminderCheck/,
);
expect(source).toMatch(
/handleMeasurementReminderCheck[\s\S]{0,400}runMeasurementReminderTick/,
);
});
});
@@ -0,0 +1,287 @@
/**
* v1.17.1 — Vorsorge (measurement) reminder dispatcher unit tests.
*
* Pins the contract:
* - Due-predicate window: fires only when past-due AND inside the
* reminder's local notify-hour (08:59 → no, 09:00 → yes, 09:59 →
* yes, 10:00 → no). A disabled reminder / null nextDueAt never fires.
* - Auto-resolve: a matching reading of the reminder's measurementType
* logged since the last satisfy advances lastSatisfiedAt + recomputes
* nextDueAt and suppresses the nudge. Free-text reminders never
* auto-resolve.
* - Ledger-free dedup: a successful dispatch advances nextDueAt past
* now so the same due cycle never re-fires.
* - clientManaged suppression skips the APNs send but still advances.
*
* The Prisma surface is stubbed manually to avoid a testcontainer boot.
*/
import { describe, it, expect, vi } from "vitest";
import {
evaluateMeasurementReminderDue,
runMeasurementReminderTick,
} from "../measurement-reminder";
import type { NotificationPayload } from "@/lib/notifications/types";
import type { DispatchOutcome } from "@/lib/notifications/dispatcher";
type DispatchFn = (payload: NotificationPayload) => Promise<DispatchOutcome>;
const OK: DispatchOutcome = {
dispatched: true,
channelsAttempted: 1,
channelsSucceeded: 1,
};
const TZ = "Europe/Berlin";
// 09:00 Berlin in June = 07:00Z.
const NINE_LOCAL = new Date("2026-06-15T07:00:00Z");
describe("evaluateMeasurementReminderDue — window boundary", () => {
const reminder = { enabled: true, notifyHour: 9, nextDueAt: new Date(0) };
it("08:59 local → not in hour window", () => {
const d = evaluateMeasurementReminderDue(
reminder,
TZ,
new Date("2026-06-15T06:59:00Z"),
);
expect(d.isDue).toBe(true);
expect(d.inHourWindow).toBe(false);
expect(d.fire).toBe(false);
});
it("09:00 local → fires", () => {
const d = evaluateMeasurementReminderDue(reminder, TZ, NINE_LOCAL);
expect(d.fire).toBe(true);
});
it("09:59 local → still in window", () => {
const d = evaluateMeasurementReminderDue(
reminder,
TZ,
new Date("2026-06-15T07:59:00Z"),
);
expect(d.fire).toBe(true);
});
it("10:00 local → outside window", () => {
const d = evaluateMeasurementReminderDue(
reminder,
TZ,
new Date("2026-06-15T08:00:00Z"),
);
expect(d.inHourWindow).toBe(false);
expect(d.fire).toBe(false);
});
it("not yet due → never fires", () => {
const d = evaluateMeasurementReminderDue(
{ enabled: true, notifyHour: 9, nextDueAt: new Date("2099-01-01") },
TZ,
NINE_LOCAL,
);
expect(d.isDue).toBe(false);
expect(d.fire).toBe(false);
});
it("disabled / null nextDueAt → never fires", () => {
expect(
evaluateMeasurementReminderDue(
{ enabled: false, notifyHour: 9, nextDueAt: new Date(0) },
TZ,
NINE_LOCAL,
).fire,
).toBe(false);
expect(
evaluateMeasurementReminderDue(
{ enabled: true, notifyHour: 9, nextDueAt: null },
TZ,
NINE_LOCAL,
).fire,
).toBe(false);
});
});
interface FakeReminder {
id: string;
measurementType: string | null;
intervalDays: number | null;
rrule: string | null;
anchorDate: Date | null;
notifyHour: number;
location: string | null;
nextDueAt: Date | null;
lastSatisfiedAt: Date | null;
enabled: boolean;
createdAt: Date;
user: {
id: string;
timezone: string;
locale: string | null;
notificationPrefs: unknown;
};
}
function makePrisma(opts: {
reminders: FakeReminder[];
measurementMatch?: { measuredAt: Date } | null;
}) {
const updates: Array<{ id: string; data: Record<string, unknown> }> = [];
const prisma = {
measurementReminder: {
findMany: vi.fn(async () => opts.reminders),
update: vi.fn(
async ({
where,
data,
}: {
where: { id: string };
data: Record<string, unknown>;
}) => {
updates.push({ id: where.id, data });
return { id: where.id, ...data };
},
),
},
measurement: {
findFirst: vi.fn(async () => opts.measurementMatch ?? null),
},
};
return { prisma, updates };
}
function reminder(overrides: Partial<FakeReminder>): FakeReminder {
return {
id: "r1",
measurementType: "BLOOD_PRESSURE_SYS",
intervalDays: 7,
rrule: null,
anchorDate: null,
notifyHour: 9,
location: null,
nextDueAt: new Date("2026-06-14T07:00:00Z"), // past at NINE_LOCAL
lastSatisfiedAt: null,
enabled: true,
createdAt: new Date("2026-06-01T00:00:00Z"),
user: {
id: "u1",
timezone: TZ,
locale: "de",
notificationPrefs: null,
},
...overrides,
};
}
describe("runMeasurementReminderTick", () => {
it("auto-resolves a typed reminder when a matching reading landed", async () => {
const matchAt = new Date("2026-06-14T18:00:00Z");
const { prisma, updates } = makePrisma({
reminders: [reminder({})],
measurementMatch: { measuredAt: matchAt },
});
const dispatch = vi.fn<DispatchFn>(async () => OK);
const summary = await runMeasurementReminderTick(
prisma as never,
NINE_LOCAL,
{ dispatch },
);
expect(summary.autoResolved).toBe(1);
expect(summary.dispatched).toBe(0);
expect(dispatch).not.toHaveBeenCalled();
// Advanced lastSatisfiedAt to the reading instant + recomputed nextDueAt.
expect(updates).toHaveLength(1);
expect(updates[0].data.lastSatisfiedAt).toEqual(matchAt);
expect(updates[0].data.nextDueAt).toBeInstanceOf(Date);
});
it("dispatches a due reminder and advances nextDueAt past now (ledger-free dedup)", async () => {
const { prisma, updates } = makePrisma({
reminders: [reminder({})],
measurementMatch: null,
});
const dispatch = vi.fn<DispatchFn>(async () => OK);
const summary = await runMeasurementReminderTick(
prisma as never,
NINE_LOCAL,
{ dispatch },
);
expect(summary.dispatched).toBe(1);
expect(dispatch).toHaveBeenCalledTimes(1);
expect(dispatch.mock.calls[0][0].eventType).toBe("MEASUREMENT_REMINDER");
// nextDueAt advanced strictly past now.
expect(updates).toHaveLength(1);
const advanced = updates[0].data.nextDueAt as Date;
expect(advanced.getTime()).toBeGreaterThan(NINE_LOCAL.getTime());
});
it("free-text reminder never auto-resolves (no measurement query match path)", async () => {
const { prisma } = makePrisma({
reminders: [reminder({ measurementType: null })],
// Even if a reading existed, a free-text reminder must not query it.
measurementMatch: { measuredAt: new Date("2026-06-14T18:00:00Z") },
});
const dispatch = vi.fn<DispatchFn>(async () => OK);
const summary = await runMeasurementReminderTick(
prisma as never,
NINE_LOCAL,
{ dispatch },
);
expect(summary.autoResolved).toBe(0);
expect(summary.dispatched).toBe(1);
expect(prisma.measurement.findFirst).not.toHaveBeenCalled();
});
it("suppresses the push under clientManaged but still advances nextDueAt", async () => {
const { prisma, updates } = makePrisma({
reminders: [
reminder({
measurementType: null,
user: {
id: "u1",
timezone: TZ,
locale: "de",
notificationPrefs: { measurementReminder: { clientManaged: true } },
},
}),
],
measurementMatch: null,
});
const dispatch = vi.fn<DispatchFn>(async () => OK);
const summary = await runMeasurementReminderTick(
prisma as never,
NINE_LOCAL,
{ dispatch },
);
expect(summary.skippedClientManaged).toBe(1);
expect(dispatch).not.toHaveBeenCalled();
expect(updates).toHaveLength(1);
expect(updates[0].data.nextDueAt).toBeInstanceOf(Date);
});
it("skips a reminder outside its notify-hour window", async () => {
const { prisma } = makePrisma({
reminders: [reminder({ measurementType: null })],
measurementMatch: null,
});
const dispatch = vi.fn<DispatchFn>(async () => OK);
const summary = await runMeasurementReminderTick(
prisma as never,
new Date("2026-06-15T08:00:00Z"), // 10:00 Berlin — outside the 09 window
{ dispatch },
);
expect(summary.skippedOutsideWindow).toBe(1);
expect(dispatch).not.toHaveBeenCalled();
});
});
+330
View File
@@ -0,0 +1,330 @@
/**
* v1.17.1 — Vorsorge (measurement) reminder dispatcher.
*
* Mirrors the mood-reminder cron (`mood-reminder.ts`): a separate module,
* a pure due-predicate, a `runMeasurementReminderTick(prisma, now)`
* runner. The cron fires every 15 minutes so it picks up every IANA
* timezone crossing the reminder's `notifyHour` without one cron entry
* per zone.
*
* Differences from the mood reminder:
*
* 1. No dedicated dispatch-ledger table. The reminder's own
* `nextDueAt` IS the dedup guard: after a successful dispatch the
* runner advances `nextDueAt` to the next occurrence strictly past
* now, so the same due cycle never re-fires. This collapses one
* table + one cleanup queue versus the mood pattern.
* 2. Auto-resolve from an incoming measurement. Before deciding to
* fire, the runner checks whether a matching reading of the
* reminder's `measurementType` has landed since the last satisfy
* (BP matched on `BLOOD_PRESSURE_SYS`). If so it advances
* `lastSatisfiedAt` + recomputes `nextDueAt` and skips the nudge —
* the user who already measured today never gets nagged. This is a
* query in the cron, NOT a hook on the hot iOS batch-ingest path.
* Free-text reminders (no `measurementType`) never auto-resolve;
* they advance only on a manual satisfy.
* 3. Per-reminder `clientManaged` suppression of the server-side APNs
* (the medication `clientManaged` precedent) is applied for the
* whole user via `notificationPrefs.measurementReminder.clientManaged`.
*/
import type { PrismaClient } from "@/generated/prisma/client";
import type { MeasurementType } from "@/generated/prisma/client";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import type { Locale } from "@/lib/i18n/config";
import { defaultLocale, locales } from "@/lib/i18n/config";
import { wallClockInTz } from "@/lib/tz/wall-clock";
import { dispatchNotification } from "@/lib/notifications/dispatcher";
import { getEvent } from "@/lib/logging/context";
import { isMeasurementReminderClientManaged } from "@/lib/validations/notification-prefs";
import {
computeReminderNextDueAt,
type ReminderScheduleInput,
} from "@/lib/measurement-reminders/scheduling";
export interface MeasurementReminderSummary {
candidatesScanned: number;
inWindow: number;
dispatched: number;
autoResolved: number;
skippedNotDue: number;
skippedOutsideWindow: number;
skippedClientManaged: number;
skippedNoChannel: number;
failed: number;
}
function resolveLocale(locale: string | null | undefined): Locale {
return locales.includes(locale as Locale)
? (locale as Locale)
: defaultLocale;
}
/**
* Pure due-predicate: at this instant, is the reminder due AND inside its
* local notify-hour window?
*
* "Due" = `nextDueAt != null` and `now >= nextDueAt`. The hour gate keeps
* a reminder that became overdue overnight from firing at 03:00 — it
* waits for the user's chosen `notifyHour` to come round in their local
* timezone. Pulled out so the unit tests can pin the window boundary
* (08:59 → no, 09:00 → yes, 09:59 → yes, 10:00 → no) without the DB.
*/
export function evaluateMeasurementReminderDue(
reminder: {
enabled: boolean;
nextDueAt: Date | null;
notifyHour: number;
},
timezone: string,
now: Date,
): { fire: boolean; inHourWindow: boolean; isDue: boolean } {
if (!reminder.enabled || reminder.nextDueAt === null) {
return { fire: false, inHourWindow: false, isDue: false };
}
const isDue = now.getTime() >= reminder.nextDueAt.getTime();
const parts = wallClockInTz(now, timezone || "Europe/Berlin");
const inHourWindow = parts.hour === reminder.notifyHour;
return { fire: isDue && inHourWindow, inHourWindow, isDue };
}
/**
* Build the localised title + body for the Vorsorge push.
*/
export function buildMeasurementReminderPayload(
locale: string | null | undefined,
label: string,
location: string | null,
): { title: string; body: string } {
const t = getServerTranslator(resolveLocale(locale)).t;
const base = t("measurementReminders.pushBody", { label });
const body = location
? `${base} ${t("measurementReminders.pushLocation", { location })}`
: base;
return { title: t("measurementReminders.pushTitle"), body };
}
/**
* The canonical sentinel measurement type the cron queries for
* auto-resolve. BP is two rows (SYS + DIA); SYS is the "a BP was measured"
* anchor (matching both would double-count).
*/
function autoResolveQueryType(
reminderType: MeasurementType | null,
): MeasurementType | null {
return reminderType;
}
/**
* Run one Vorsorge-reminder cron tick. Iterates every live, enabled
* reminder, auto-resolves the typed ones against incoming readings,
* and dispatches a `MEASUREMENT_REMINDER` push for any that are due
* inside the user's local notify-hour window.
*/
export async function runMeasurementReminderTick(
prisma: PrismaClient,
now: Date,
options: {
dispatch?: typeof dispatchNotification;
} = {},
): Promise<MeasurementReminderSummary> {
const dispatchImpl = options.dispatch ?? dispatchNotification;
const summary: MeasurementReminderSummary = {
candidatesScanned: 0,
inWindow: 0,
dispatched: 0,
autoResolved: 0,
skippedNotDue: 0,
skippedOutsideWindow: 0,
skippedClientManaged: 0,
skippedNoChannel: 0,
failed: 0,
};
const reminders = await prisma.measurementReminder.findMany({
where: { deletedAt: null, enabled: true },
include: {
user: {
select: {
id: true,
timezone: true,
locale: true,
notificationPrefs: true,
},
},
},
});
for (const reminder of reminders) {
summary.candidatesScanned += 1;
try {
const timezone = reminder.user.timezone || "Europe/Berlin";
// ── Auto-resolve from an incoming measurement ──────────────────
// Cheap guard, in the cron (not on the ingest path). Only typed
// reminders auto-resolve; free-text ones wait for a manual satisfy.
const queryType = autoResolveQueryType(reminder.measurementType);
if (queryType !== null) {
// Match readings logged AFTER the last satisfy (or, never
// satisfied, after the anchor). A reading inside the current due
// cycle means the user already measured — re-anchor + skip the
// nudge. `deletedAt: null` so a tombstoned reading never counts.
const sinceFloor =
reminder.lastSatisfiedAt ??
reminder.anchorDate ??
reminder.createdAt;
const match = await prisma.measurement.findFirst({
where: {
userId: reminder.user.id,
type: queryType,
deletedAt: null,
measuredAt: { gt: sinceFloor },
},
orderBy: { measuredAt: "desc" },
select: { measuredAt: true },
});
if (match) {
const scheduleInput: ReminderScheduleInput = {
intervalDays: reminder.intervalDays,
rrule: reminder.rrule,
anchorDate: reminder.anchorDate,
notifyHour: reminder.notifyHour,
lastSatisfiedAt: match.measuredAt,
createdAt: reminder.createdAt,
};
const nextDueAt = computeReminderNextDueAt(
scheduleInput,
timezone,
match.measuredAt,
);
await prisma.measurementReminder.update({
where: { id: reminder.id },
data: { lastSatisfiedAt: match.measuredAt, nextDueAt },
});
summary.autoResolved += 1;
continue;
}
}
const decision = evaluateMeasurementReminderDue(
{
enabled: reminder.enabled,
nextDueAt: reminder.nextDueAt,
notifyHour: reminder.notifyHour,
},
timezone,
now,
);
if (!decision.isDue) {
summary.skippedNotDue += 1;
continue;
}
if (!decision.inHourWindow) {
summary.skippedOutsideWindow += 1;
continue;
}
summary.inWindow += 1;
// Per-user client-managed suppression (the medication precedent).
if (
isMeasurementReminderClientManaged(reminder.user.notificationPrefs)
) {
getEvent()?.addMeta(
"measurement_reminder.suppressed_client_managed",
reminder.id,
);
summary.skippedClientManaged += 1;
// Advance past this cycle anyway so a client-managed reminder does
// not pin the server tick re-evaluating the same overdue slot
// every 15 minutes for the rest of the day.
await advanceNextDue(prisma, reminder, timezone, now);
continue;
}
const { title, body } = buildMeasurementReminderPayload(
reminder.user.locale,
reminder.label,
reminder.location,
);
const outcome = await dispatchImpl({
eventType: "MEASUREMENT_REMINDER",
userId: reminder.user.id,
title,
message: body,
metadata: {
scheduledAt: now.toISOString(),
reminderId: reminder.id,
},
});
// No channel succeeded — leave `nextDueAt` where it is so the next
// tick (or the user's next channel) retries. The reminder simply
// stays overdue, which the surface already shows as "überfällig".
if (!outcome.dispatched) {
summary.skippedNoChannel += 1;
continue;
}
// Dispatch succeeded — advance `nextDueAt` past now so this due
// cycle never re-fires (the ledger-free dedup guard).
await advanceNextDue(prisma, reminder, timezone, now);
summary.dispatched += 1;
} catch (err: unknown) {
summary.failed += 1;
const message = err instanceof Error ? err.message : String(err);
getEvent()?.addWarning(
`measurement-reminder per-reminder dispatch failed for ${reminder.id}: ${message}`,
);
}
}
return summary;
}
/**
* Advance `nextDueAt` to the next occurrence strictly after `now`. Used
* after a successful dispatch (and after a client-managed skip) as the
* ledger-free dedup guard. Does NOT touch `lastSatisfiedAt` — a fired
* reminder is not "satisfied", it just rolls to its next slot.
*/
async function advanceNextDue(
prisma: PrismaClient,
reminder: {
id: string;
intervalDays: number | null;
rrule: string | null;
anchorDate: Date | null;
notifyHour: number;
lastSatisfiedAt: Date | null;
createdAt: Date;
},
timezone: string,
now: Date,
): Promise<void> {
// The dedup guard must move the slot strictly forward. For an `rrule`
// the engine already walks to the next strictly-after-now occurrence,
// so passing the row as-is is correct. For a ROLLING reminder the
// engine anchors the first-due slot AT `anchorDate ?? createdAt` when
// never satisfied, which stays ≤ now and would re-fire every tick — so
// re-anchor the rolling cadence on `now` (a fire is the rhythm
// restarting from this dispatch) to roll it forward by exactly one
// interval. This does NOT advance `lastSatisfiedAt`: a fired reminder
// is not satisfied, just rescheduled past the slot it nagged on.
const rolling = reminder.intervalDays !== null;
const scheduleInput: ReminderScheduleInput = {
intervalDays: reminder.intervalDays,
rrule: reminder.rrule,
anchorDate: reminder.anchorDate,
notifyHour: reminder.notifyHour,
lastSatisfiedAt: rolling ? now : reminder.lastSatisfiedAt,
createdAt: reminder.createdAt,
};
const nextDueAt = computeReminderNextDueAt(scheduleInput, timezone, now);
await prisma.measurementReminder.update({
where: { id: reminder.id },
data: { nextDueAt },
});
}
+32
View File
@@ -246,8 +246,10 @@ import {
import {
MoodReminderPayload,
CycleReminderPayload,
MeasurementReminderPayload,
handleMoodReminderCheck,
handleCycleReminderCheck,
handleMeasurementReminderCheck,
} from "./reminder/mood-cycle-checks";
import {
HostMetricSamplePayload,
@@ -507,6 +509,17 @@ const MEASUREMENT_TOMBSTONE_CLEANUP_CRON = "40 3 * * *";
const CYCLE_REMINDER_QUEUE = "cycle-reminder-check";
const CYCLE_REMINDER_CRON = "*/15 * * * *";
// v1.17.1 — every-15-min tick for Vorsorge (measurement) reminders.
// Same cadence + short-circuit shape as the mood / cycle reminders: the
// handler only fires a reminder whose `nextDueAt` is past AND whose local
// time is the reminder's `notifyHour`, so the 15-min cadence picks up
// every IANA timezone crossing that hour without one cron entry per zone.
// Dedup is the reminder's own `nextDueAt` advance — no ledger table.
const MEASUREMENT_REMINDER_QUEUE = "measurement-reminder-check";
const MEASUREMENT_REMINDER_CRON = "*/15 * * * *";
// v1.4.38 — the per-sample cutoff hours constant now lives on the
// helper module so the worker, the admin route, and the CLI all read
// the same source of truth. Re-export pulled in alongside
@@ -730,6 +743,11 @@ export async function startReminderWorker() {
// without this entry the every-15-min schedule silently no-ops and the
// cycle dispatcher never fires (the v1.4.37 dead-queue class).
CYCLE_REMINDER_QUEUE,
// v1.17.1 — Vorsorge (measurement) reminder cron tick. Same pg-boss
// v12 createQueue contract; without this entry pg-boss never
// provisions the queue and the every-15-min schedule silently no-ops
// (the v1.4.37 dead-queue class).
MEASUREMENT_REMINDER_QUEUE,
// v1.4.49 — push-attempt ledger cleanup. Same createQueue contract
// as the other cleanup jobs; the daily schedule below would
// silently no-op without this entry.
@@ -922,6 +940,11 @@ export async function startReminderWorker() {
// hour, so the cron costs ~one prediction-row scan per tick for the
// opted-in cohort.
[CYCLE_REMINDER_QUEUE, CYCLE_REMINDER_CRON],
// v1.17.1 — every-15-min tick for the Vorsorge (measurement) reminder.
// The handler short-circuits unless a reminder is past-due AND the
// user's local time matches the reminder's notifyHour, so the cron
// costs ~one reminder-row scan per tick for the active cohort.
[MEASUREMENT_REMINDER_QUEUE, MEASUREMENT_REMINDER_CRON],
// v1.4.49 — daily 03:35 Europe/Berlin prune for push_attempts.
[PUSH_ATTEMPT_CLEANUP_QUEUE, PUSH_ATTEMPT_CLEANUP_CRON],
// v1.7.0 — nightly 04:30 Europe/Berlin comprehensive-insight
@@ -1228,6 +1251,15 @@ export async function startReminderWorker() {
{ localConcurrency: 1 },
handleCycleReminderCheck,
);
// v1.17.1 — single-flight Vorsorge (measurement) reminder worker.
// localConcurrency=1 keeps two ticks from racing the `nextDueAt`
// advance that anchors the per-cycle idempotency, exactly like the
// mood / cycle reminder workers.
await boss.work<MeasurementReminderPayload>(
MEASUREMENT_REMINDER_QUEUE,
{ localConcurrency: 1 },
handleMeasurementReminderCheck,
);
// v1.4.49 — daily prune of the push-attempt ledger. Single-flight
// matches every other cleanup queue; two ticks racing on the same
// DELETE statement is wasted work and the second tick's payload
@@ -9,6 +9,7 @@ import { recordError } from "@/lib/jobs/worker-status";
import { withBackgroundEvent } from "@/lib/logging/background";
import { runMoodReminderTick } from "@/lib/jobs/mood-reminder";
import { runCycleReminderTick } from "@/lib/jobs/cycle-reminder";
import { runMeasurementReminderTick } from "@/lib/jobs/measurement-reminder";
import { getWorkerPrisma } from "./shared";
export interface MoodReminderPayload {
@@ -19,6 +20,10 @@ export interface CycleReminderPayload {
triggeredAt: string;
}
export interface MeasurementReminderPayload {
triggeredAt: string;
}
export async function handleMoodReminderCheck(
jobs: Job<MoodReminderPayload>[],
) {
@@ -80,3 +85,38 @@ export async function handleCycleReminderCheck(
}
});
}
/**
* v1.17.1 — every-15-min Vorsorge (measurement) reminder tick. Thin shim
* around `runMeasurementReminderTick` so the unit tests exercise the
* due-predicate + auto-resolve + advance logic without pg-boss.
*/
export async function handleMeasurementReminderCheck(
jobs: Job<MeasurementReminderPayload>[],
) {
void jobs;
await withBackgroundEvent("job.measurement_reminder", async (evt) => {
const prisma = getWorkerPrisma();
try {
const summary = await runMeasurementReminderTick(prisma, new Date());
evt.setBackground({
task_name: "job.measurement_reminder",
result: {
candidates_scanned: summary.candidatesScanned,
in_window: summary.inWindow,
dispatched: summary.dispatched,
auto_resolved: summary.autoResolved,
skipped_not_due: summary.skippedNotDue,
skipped_outside_window: summary.skippedOutsideWindow,
skipped_client_managed: summary.skippedClientManaged,
skipped_no_channel: summary.skippedNoChannel,
failed: summary.failed,
},
});
} catch (err) {
evt.setError(err);
recordError();
throw err;
}
});
}
@@ -0,0 +1,100 @@
/**
* v1.17.1 — server-authoritative next-due computation for Vorsorge
* reminders. Pins the rolling + rrule cadence contract that web ↔ iOS
* both read off `nextDueAt`.
*/
import { describe, it, expect } from "vitest";
import {
computeReminderNextDueAt,
type ReminderScheduleInput,
} from "../scheduling";
const TZ = "Europe/Berlin";
function base(overrides: Partial<ReminderScheduleInput>): ReminderScheduleInput {
return {
intervalDays: null,
rrule: null,
anchorDate: null,
notifyHour: 9,
lastSatisfiedAt: null,
createdAt: new Date("2026-06-01T08:00:00Z"),
...overrides,
};
}
describe("computeReminderNextDueAt", () => {
it("returns null when neither intervalDays nor rrule is set", () => {
const now = new Date("2026-06-10T07:00:00Z");
expect(computeReminderNextDueAt(base({}), TZ, now)).toBeNull();
});
it("rolling, never satisfied: first due AT the anchor day notify-hour", () => {
// Anchor 2026-06-10; notifyHour 9 local (Berlin = UTC+2 in June → 07:00Z).
const reminder = base({
intervalDays: 7,
anchorDate: new Date("2026-06-10T00:00:00Z"),
notifyHour: 9,
createdAt: new Date("2026-06-09T00:00:00Z"),
});
// "after" before the anchor → the first due is the anchor's slot.
const due = computeReminderNextDueAt(
reminder,
TZ,
new Date("2026-06-09T12:00:00Z"),
);
expect(due).not.toBeNull();
// 09:00 Berlin on 2026-06-10 == 07:00Z (DST).
expect(due!.toISOString()).toBe("2026-06-10T07:00:00.000Z");
});
it("rolling, satisfied: next due is lastSatisfiedAt + N days at notify-hour", () => {
const reminder = base({
intervalDays: 7,
lastSatisfiedAt: new Date("2026-06-10T15:00:00Z"),
notifyHour: 9,
});
const due = computeReminderNextDueAt(
reminder,
TZ,
new Date("2026-06-10T16:00:00Z"),
);
expect(due).not.toBeNull();
// 7 days after the satisfy → 2026-06-17, slot at 09:00 Berlin = 07:00Z.
expect(due!.toISOString()).toBe("2026-06-17T07:00:00.000Z");
});
it("rolling honours the interval length (30 days)", () => {
const reminder = base({
intervalDays: 30,
lastSatisfiedAt: new Date("2026-06-01T15:00:00Z"),
notifyHour: 9,
});
const due = computeReminderNextDueAt(
reminder,
TZ,
new Date("2026-06-02T00:00:00Z"),
);
expect(due!.toISOString()).toBe("2026-07-01T07:00:00.000Z");
});
it("rrule annual: walks to the next yearly occurrence after now", () => {
// FREQ=YEARLY anchored on the createdAt day (2026-06-01).
const reminder = base({
rrule: "FREQ=YEARLY",
anchorDate: new Date("2026-06-01T00:00:00Z"),
createdAt: new Date("2026-06-01T00:00:00Z"),
notifyHour: 9,
});
// After the 2026 occurrence → expect the 2027 one.
const due = computeReminderNextDueAt(
reminder,
TZ,
new Date("2026-06-02T00:00:00Z"),
);
expect(due).not.toBeNull();
expect(due!.getUTCFullYear()).toBe(2027);
expect(due!.getUTCMonth()).toBe(5); // June (0-indexed)
});
});
+47
View File
@@ -0,0 +1,47 @@
/**
* v1.17.1 — DTO serializer for Vorsorge (measurement) reminders.
*
* Maps the Prisma `MeasurementReminder` row onto the canonical
* `MeasurementReminderDTO` the web list, dashboard tile, and iOS client
* all mirror. Dates serialise to ISO-8601 with offset.
*/
import type { MeasurementReminder } from "@/generated/prisma/client";
import type { MeasurementReminderType } from "@/lib/validations/measurement-reminders";
export interface MeasurementReminderDtoShape {
id: string;
label: string;
measurementType: MeasurementReminderType | null;
intervalDays: number | null;
rrule: string | null;
anchorDate: string | null;
notifyHour: number;
location: string | null;
nextDueAt: string | null;
lastSatisfiedAt: string | null;
enabled: boolean;
createdAt: string;
updatedAt: string;
}
export function toMeasurementReminderDto(
row: MeasurementReminder,
): MeasurementReminderDtoShape {
return {
id: row.id,
label: row.label,
measurementType: row.measurementType as MeasurementReminderType | null,
intervalDays: row.intervalDays,
rrule: row.rrule,
anchorDate: row.anchorDate ? row.anchorDate.toISOString() : null,
notifyHour: row.notifyHour,
location: row.location,
nextDueAt: row.nextDueAt ? row.nextDueAt.toISOString() : null,
lastSatisfiedAt: row.lastSatisfiedAt
? row.lastSatisfiedAt.toISOString()
: null,
enabled: row.enabled,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
+120
View File
@@ -0,0 +1,120 @@
/**
* v1.17.1 — server-authoritative next-due computation for Vorsorge
* (measurement) reminders.
*
* Reuses the canonical medication recurrence engine
* (`src/lib/medications/scheduling/recurrence.ts`) so a Vorsorge cadence
* is driven by exactly the same code that powers the medication
* "nextDueAt" line — web ↔ iOS read identical numbers (server-authoritative
* per project memory: iOS consumes the resolved DTO, never recomputes).
*
* A `MeasurementReminder` maps onto the engine's `CanonicalSchedule` +
* `RecurrenceContext` as follows:
*
* - rolling `intervalDays` → `rollingIntervalDays`, anchored on
* `lastSatisfiedAt ?? anchorDate ?? createdAt`. With no satisfy yet
* the first due is AT the anchor (not anchor + N); once satisfied the
* `+ N` cadence begins, exactly like a rolling medication's
* last-intake anchor.
* - `rrule` → passed straight through (RFC-5545).
* - the single `notifyHour` → the schedule's one `timesOfDay` entry, so
* the slot fires at the user's chosen local hour (DST-safe — the
* engine applies the time in the user's IANA timezone).
*
* Pure: no DB access. The caller fetches the reminder row + the user's
* timezone and threads them in.
*/
import {
type CanonicalSchedule,
type RecurrenceContext,
nextOccurrenceAfter,
} from "@/lib/medications/scheduling/recurrence";
/**
* The reminder fields this module reads. A subset of the Prisma row so
* tests can construct it without the full model.
*/
export interface ReminderScheduleInput {
intervalDays: number | null;
rrule: string | null;
anchorDate: Date | null;
notifyHour: number;
lastSatisfiedAt: Date | null;
createdAt: Date;
}
function hourToHhmm(hour: number): string {
const safe = Number.isInteger(hour) && hour >= 0 && hour <= 23 ? hour : 9;
return `${safe.toString().padStart(2, "0")}:00`;
}
/**
* Build the `(CanonicalSchedule, RecurrenceContext)` pair the engine
* consumes for one Vorsorge reminder.
*
* The rolling anchor is threaded through the engine's `lastIntakeAt`
* (after a satisfy) and `medication.startsOn` (the first-due anchor when
* never satisfied) so the rolling path's "first dose AT the anchor, then
* + N after the first satisfy" semantics apply verbatim.
*/
export function buildReminderRecurrence(
reminder: ReminderScheduleInput,
timeZone: string,
): { schedule: CanonicalSchedule; ctx: RecurrenceContext } {
const hhmm = hourToHhmm(reminder.notifyHour);
const schedule: CanonicalSchedule = {
id: "measurement-reminder",
rrule: reminder.rrule,
rollingIntervalDays: reminder.intervalDays,
timesOfDay: [hhmm],
daysOfWeek: null,
windowStart: hhmm,
windowEnd: hhmm,
reminderGraceMinutes: null,
scheduleType: "SCHEDULED",
cyclicOnWeeks: null,
cyclicOffWeeks: null,
};
// First-due anchor when never satisfied: anchorDate ?? createdAt. After
// a satisfy the rolling path re-anchors on `lastIntakeAt + N`, so we
// feed `lastSatisfiedAt` through `lastIntakeAt`.
const startsOn = reminder.anchorDate ?? reminder.createdAt;
const ctx: RecurrenceContext = {
medication: {
id: "measurement-reminder",
startsOn,
endsOn: null,
oneShot: false,
createdAt: reminder.createdAt,
},
timeZone: timeZone || "Europe/Berlin",
lastIntakeAt: reminder.lastSatisfiedAt,
};
return { schedule, ctx };
}
/**
* Compute the canonical next-due instant for a reminder, strictly after
* `after`. Returns `null` when the cadence is uncomputable (no interval +
* no rrule) or the engine finds no future occurrence.
*
* `after` defaults to `now` but the caller can floor it (e.g. to the last
* satisfy instant) so a freshly-satisfied reminder advances past the
* current due cycle.
*/
export function computeReminderNextDueAt(
reminder: ReminderScheduleInput,
timeZone: string,
after: Date,
): Date | null {
if (reminder.intervalDays === null && reminder.rrule === null) {
return null;
}
const { schedule, ctx } = buildReminderRecurrence(reminder, timeZone);
const occurrence = nextOccurrenceAfter(schedule, after, ctx);
return occurrence?.at ?? null;
}
+15
View File
@@ -56,6 +56,16 @@ export const EVENT_TYPES = [
// refill or threshold change. ON at the channel layer — the real
// gate is the per-user threshold in `notificationPrefs.medication`.
"MEDICATION_LOW_STOCK",
// v1.17.1 — Vorsorge (measurement / preventive-care) reminder. Fired by
// the every-15-min measurement-reminder cron when a user-created
// reminder ("measure BP every 7 days", "annual blood panel") is due
// inside the user's local notify-hour window. ON by default at the
// channel layer (see EVENT_DEFAULT_ENABLED): these are reminders the
// user explicitly created, so silence-by-default would be surprising.
// The real gate is the per-reminder `enabled` flag plus the per-user
// `notificationPrefs.measurementReminder.clientManaged` suppression.
// NOT time-sensitive — a Vorsorge nudge is not a Focus-bypass case.
"MEASUREMENT_REMINDER",
] as const;
export type EventType = (typeof EVENT_TYPES)[number];
@@ -102,6 +112,11 @@ export const EVENT_DEFAULT_ENABLED: Record<EventType, boolean> = {
// push per medication per crossing. An explicit per-channel
// `NotificationPreference` row still wins.
MEDICATION_LOW_STOCK: true,
// v1.17.1 — ON at the channel layer; the real gates are the per-reminder
// `enabled` flag and the per-user `measurementReminder.clientManaged`
// suppression the cron reads. An explicit per-channel
// `NotificationPreference` row still wins.
MEASUREMENT_REMINDER: true,
};
export const CHANNEL_TYPE_LABELS: Record<ChannelType, string> = {
+1
View File
@@ -72,6 +72,7 @@ const openApiBase: Pick<
{ name: "Export" },
{ name: "Sync" },
{ name: "Cycle" },
{ name: "MeasurementReminders" },
{ name: "Admin" },
{ name: "Meta" },
],
+2
View File
@@ -31,6 +31,7 @@ import { healthRecordPaths } from "./health-record";
import { importPaths } from "./import";
import { insightsPaths } from "./insights";
import { measurementPaths } from "./measurements";
import { measurementReminderPaths } from "./measurement-reminders";
import { medicationPaths, medicationResource } from "./medications";
import { metaPaths } from "./meta";
import { moodPaths } from "./mood";
@@ -59,6 +60,7 @@ export const openApiPaths: NonNullable<ZodOpenApiObject["paths"]> = {
...settingsPaths,
...consentPaths,
...importPaths,
...measurementReminderPaths,
};
export const openApiComponents: NonNullable<ZodOpenApiObject["components"]> = {
@@ -0,0 +1,172 @@
/**
* OpenAPI route table — Vorsorge (measurement) reminders (v1.17.1).
*
* Part of the OpenAPI route table; aggregated in `./index.ts`. Request
* bodies come from the Zod validation module so the wire contract stays
* single-source; the response DTO mirrors `src/lib/measurement-reminders/dto.ts`.
*/
import { z } from "zod/v4";
import type { ZodOpenApiObject } from "zod-openapi";
import {
createMeasurementReminderSchema,
updateMeasurementReminderSchema,
measurementReminderDto,
} from "@/lib/validations/measurement-reminders";
import { dataEnvelope, errorEnvelope, stdResponses } from "./shared";
const reminderNotFound = {
"404": {
description: "Measurement reminder not found / not owned.",
content: { "application/json": { schema: errorEnvelope } },
},
} as const;
export const measurementReminderPaths: NonNullable<
ZodOpenApiObject["paths"]
> = {
"/api/measurement-reminders": {
get: {
tags: ["MeasurementReminders"],
summary: "List Vorsorge reminders (v1.17.1)",
description:
"Returns the owner's live (non-tombstoned) Vorsorge reminders, sorted by server-computed nextDueAt ascending (nulls last). Each row carries the canonical nextDueAt the client renders without recomputing.",
responses: {
"200": {
description: "The owner's measurement reminders.",
content: {
"application/json": {
schema: dataEnvelope(
z.array(measurementReminderDto),
"MeasurementReminderListEnvelope",
),
},
},
},
...stdResponses,
},
},
post: {
tags: ["MeasurementReminders"],
summary: "Create a Vorsorge reminder (v1.17.1)",
description:
"Creates a reminder and computes its server-authoritative nextDueAt. Exactly one of intervalDays (rolling) or rrule (RFC-5545) is required. Wrapped in withIdempotency. 201 on insert.",
requestBody: {
required: true,
content: {
"application/json": { schema: createMeasurementReminderSchema },
},
},
responses: {
"201": {
description: "Reminder created.",
content: {
"application/json": {
schema: dataEnvelope(
measurementReminderDto,
"MeasurementReminderCreatedEnvelope",
),
},
},
},
...stdResponses,
},
},
},
"/api/measurement-reminders/{id}": {
get: {
tags: ["MeasurementReminders"],
summary: "Read a single Vorsorge reminder (v1.17.1)",
description: "Owner-scoped; a cross-user or tombstoned id 404s.",
requestParams: { path: z.object({ id: z.string() }) },
responses: {
"200": {
description: "The reminder.",
content: {
"application/json": {
schema: dataEnvelope(
measurementReminderDto,
"MeasurementReminderEnvelope",
),
},
},
},
...reminderNotFound,
...stdResponses,
},
},
patch: {
tags: ["MeasurementReminders"],
summary: "Edit a Vorsorge reminder (v1.17.1)",
description:
"Partial edit; omitted fields are left untouched. nextDueAt is recomputed server-side after the cadence merge. Owner-scoped.",
requestParams: { path: z.object({ id: z.string() }) },
requestBody: {
required: true,
content: {
"application/json": { schema: updateMeasurementReminderSchema },
},
},
responses: {
"200": {
description: "Reminder updated.",
content: {
"application/json": {
schema: dataEnvelope(
measurementReminderDto,
"MeasurementReminderPatchEnvelope",
),
},
},
},
...reminderNotFound,
...stdResponses,
},
},
delete: {
tags: ["MeasurementReminders"],
summary: "Soft-delete a Vorsorge reminder (v1.17.1)",
description: "Sets deletedAt (tombstone). Idempotent. Owner-scoped.",
requestParams: { path: z.object({ id: z.string() }) },
responses: {
"200": {
description: "Soft-deleted.",
content: {
"application/json": {
schema: dataEnvelope(
z.object({ deleted: z.boolean() }),
"MeasurementReminderDeleteEnvelope",
),
},
},
},
...reminderNotFound,
...stdResponses,
},
},
},
"/api/measurement-reminders/{id}/satisfy": {
post: {
tags: ["MeasurementReminders"],
summary: "Mark a Vorsorge reminder done (v1.17.1)",
description:
"Manual 'Erledigt': stamps lastSatisfiedAt = now and recomputes nextDueAt past now. Free-text reminders resolve only through this path; typed reminders also auto-resolve in the cron when a matching reading lands.",
requestParams: { path: z.object({ id: z.string() }) },
responses: {
"200": {
description: "Reminder satisfied; next-due re-anchored.",
content: {
"application/json": {
schema: dataEnvelope(
measurementReminderDto,
"MeasurementReminderSatisfyEnvelope",
),
},
},
},
...reminderNotFound,
...stdResponses,
},
},
},
};
+2
View File
@@ -22,6 +22,7 @@ import { dashboardKeys } from "./dashboard";
import { insightsKeys } from "./insights";
import { integrationKeys } from "./integrations";
import { measurementKeys } from "./measurements";
import { measurementReminderKeys } from "./measurement-reminders";
import { medicationKeys } from "./medications";
import { moodKeys } from "./mood";
import { settingsKeys } from "./settings";
@@ -40,6 +41,7 @@ export const queryKeys = {
...workoutKeys,
...settingsKeys,
...cycleKeys,
...measurementReminderKeys,
};
/**
@@ -0,0 +1,21 @@
/**
* Query keys — Vorsorge (measurement) reminders (v1.17.1).
* Part of the centralized factory; aggregated in `./index.ts`.
*/
export const measurementReminderKeys = {
/**
* The owner's Vorsorge reminder list. A create / edit / delete / satisfy
* mutation invalidates this root so the section + the dashboard tile
* repaint in lockstep.
*/
measurementReminders: () => ["measurement-reminders"] as const,
/** Mutation keys — kept in the factory so no bare array reaches a call site. */
measurementReminderCreate: () =>
["measurement-reminders", "create"] as const,
measurementReminderUpdate: () =>
["measurement-reminders", "update"] as const,
measurementReminderDelete: () =>
["measurement-reminders", "delete"] as const,
measurementReminderSatisfy: () =>
["measurement-reminders", "satisfy"] as const,
};
@@ -6,6 +6,7 @@ import {
DEFAULT_NOTIFICATION_PREFS,
DEFAULT_REORDER_LEAD_DAYS,
isCycleReminderClientManaged,
isMeasurementReminderClientManaged,
isMedicationReminderClientManaged,
notificationPrefsSchema,
parseNotificationPrefs,
@@ -109,6 +110,9 @@ describe("parseNotificationPrefs", () => {
nudgeRoutine: true,
nudgeFrequency: "weekly",
},
measurementReminder: {
clientManaged: false,
},
});
});
@@ -129,6 +133,9 @@ describe("parseNotificationPrefs", () => {
nudgeRoutine: true,
nudgeFrequency: "weekly",
},
measurementReminder: {
clientManaged: false,
},
});
});
@@ -151,6 +158,9 @@ describe("parseNotificationPrefs", () => {
nudgeRoutine: true,
nudgeFrequency: "weekly",
},
measurementReminder: {
clientManaged: false,
},
});
});
@@ -189,6 +199,9 @@ describe("resolveNotificationPrefs (deep-merge)", () => {
nudgeRoutine: true,
nudgeFrequency: "weekly",
},
measurementReminder: {
clientManaged: false,
},
});
});
@@ -215,6 +228,9 @@ describe("resolveNotificationPrefs (deep-merge)", () => {
nudgeRoutine: true,
nudgeFrequency: "weekly",
},
measurementReminder: {
clientManaged: false,
},
});
});
@@ -238,6 +254,9 @@ describe("resolveNotificationPrefs (deep-merge)", () => {
nudgeRoutine: true,
nudgeFrequency: "weekly",
},
measurementReminder: {
clientManaged: false,
},
});
});
@@ -261,6 +280,9 @@ describe("resolveNotificationPrefs (deep-merge)", () => {
nudgeRoutine: true,
nudgeFrequency: "weekly",
},
measurementReminder: {
clientManaged: false,
},
});
});
@@ -285,6 +307,9 @@ describe("resolveNotificationPrefs (deep-merge)", () => {
nudgeRoutine: true,
nudgeFrequency: "weekly",
},
measurementReminder: {
clientManaged: false,
},
});
});
@@ -356,6 +381,38 @@ describe("isCycleReminderClientManaged — cron-skip gate", () => {
});
});
describe("isMeasurementReminderClientManaged — cron-skip gate", () => {
it("returns false for a null / undefined row (server-managed default)", () => {
expect(isMeasurementReminderClientManaged(null)).toBe(false);
expect(isMeasurementReminderClientManaged(undefined)).toBe(false);
});
it("returns false when the user has not opted in", () => {
expect(
isMeasurementReminderClientManaged({
measurementReminder: { clientManaged: false },
}),
).toBe(false);
});
it("returns true when the iOS app owns local Vorsorge reminders", () => {
expect(
isMeasurementReminderClientManaged({
measurementReminder: { clientManaged: true },
}),
).toBe(true);
});
it("is independent of the cycle / medication client-managed flags", () => {
expect(
isMeasurementReminderClientManaged({
cycle: { clientManaged: true },
medication: { deliveryDefault: "client" },
}),
).toBe(false);
});
});
describe("resolveMoodReminderHour — cron hour gate", () => {
it("returns the default hour for a null row", () => {
expect(resolveMoodReminderHour(null)).toBe(DEFAULT_MOOD_REMINDER_HOUR);
@@ -0,0 +1,162 @@
/**
* v1.17.1 — Measurement / Vorsorge (preventive-care) reminders.
*
* A reminder class distinct from the medication loop: "measure BP every
* 7 days", "annual blood panel", "log weight weekly". Tied to a
* measurement TYPE (auto-resolving when a matching reading lands) or to a
* free-text checklist label (resolving only on a manual "Erledigt").
*
* The cadence vocabulary reuses the medication recurrence engine: a
* `intervalDays` rolling cadence OR an RFC-5545 `rrule`. The server
* computes the canonical `nextDueAt` so web ↔ iOS show identical numbers
* (server-authoritative; the client consumes the resolved DTO, never
* recomputes).
*/
import { z } from "zod/v4";
/**
* The auto-resolve target types. Kept as an explicit allow-list rather
* than the full `MeasurementType` enum: a Vorsorge reminder only makes
* sense for the metrics a user actively measures on a cadence (BP,
* weight, glucose, pulse, SpO2, body composition, body temperature).
* Free-text reminders pass `null` and resolve only on a manual satisfy.
*
* BP is matched on `BLOOD_PRESSURE_SYS` as the canonical sentinel — a BP
* reading is two rows (SYS + DIA) and matching either would double-count;
* SYS is the agreed "a BP was measured" anchor.
*/
export const measurementReminderTypeEnum = z
.enum([
"WEIGHT",
"BLOOD_PRESSURE_SYS",
"PULSE",
"BLOOD_GLUCOSE",
"OXYGEN_SATURATION",
"BODY_FAT",
"BODY_TEMPERATURE",
])
.meta({
id: "MeasurementReminderType",
description:
"Auto-resolve target metric. BP resolves on BLOOD_PRESSURE_SYS (the SYS row is the 'a BP was measured' sentinel). Omit for a free-text Vorsorge that resolves only on a manual satisfy.",
});
export type MeasurementReminderType = z.infer<
typeof measurementReminderTypeEnum
>;
/**
* RFC-5545 RRULE guard. The recurrence engine parses it; here we only
* bound the length and require the `FREQ=` token so a typo doesn't reach
* the engine. Full validation is the engine's job (it tolerates a
* malformed rule by emitting no slots rather than throwing).
*/
const rruleField = z
.string()
.min(1)
.max(512)
.refine((v) => /FREQ=/i.test(v), {
message: "rrule must contain a FREQ= component",
});
/**
* Shared cadence refinement: exactly one of `intervalDays` / `rrule` is
* required on create. Both NULL would leave `nextDueAt` uncomputable.
*/
const cadenceRefinement = (
data: { intervalDays?: number | null; rrule?: string | null },
ctx: z.RefinementCtx,
): void => {
const hasInterval =
data.intervalDays !== undefined && data.intervalDays !== null;
const hasRrule = data.rrule !== undefined && data.rrule !== null;
if (!hasInterval && !hasRrule) {
ctx.addIssue({
code: "custom",
message: "Provide either intervalDays or rrule",
path: ["intervalDays"],
});
}
if (hasInterval && hasRrule) {
ctx.addIssue({
code: "custom",
message: "Provide only one of intervalDays or rrule",
path: ["rrule"],
});
}
};
export const createMeasurementReminderSchema = z
.object({
label: z.string().trim().min(1).max(120),
measurementType: measurementReminderTypeEnum.nullish(),
intervalDays: z.number().int().min(1).max(3650).nullish(),
rrule: rruleField.nullish(),
anchorDate: z.iso.datetime({ offset: true }).nullish(),
notifyHour: z.number().int().min(0).max(23).default(9),
location: z.string().trim().max(200).nullish(),
enabled: z.boolean().default(true),
})
.superRefine(cadenceRefinement)
.meta({
id: "MeasurementReminderCreate",
description:
"Create a Vorsorge reminder. Exactly one of intervalDays (rolling, every N days) or rrule (RFC-5545, e.g. FREQ=YEARLY) is required. measurementType enables auto-resolve from an incoming reading; omit it for a free-text checklist reminder.",
});
export type CreateMeasurementReminderInput = z.infer<
typeof createMeasurementReminderSchema
>;
/**
* Update is fully partial. Cadence fields stay mutually-exclusive but
* are not required (an edit that only changes the label leaves the
* cadence alone). The route re-derives `nextDueAt` after applying the
* patch.
*/
export const updateMeasurementReminderSchema = z
.object({
label: z.string().trim().min(1).max(120).optional(),
measurementType: measurementReminderTypeEnum.nullish(),
intervalDays: z.number().int().min(1).max(3650).nullish(),
rrule: rruleField.nullish(),
anchorDate: z.iso.datetime({ offset: true }).nullish(),
notifyHour: z.number().int().min(0).max(23).optional(),
location: z.string().trim().max(200).nullish(),
enabled: z.boolean().optional(),
})
.meta({
id: "MeasurementReminderUpdate",
description:
"Partial edit of a Vorsorge reminder. Omitted fields are left untouched; nextDueAt is recomputed server-side after the patch applies.",
});
export type UpdateMeasurementReminderInput = z.infer<
typeof updateMeasurementReminderSchema
>;
/**
* The canonical DTO every surface (web list, dashboard tile, iOS) mirrors.
* `nextDueAt` is the server-computed next-due instant.
*/
export const measurementReminderDto = z
.object({
id: z.string(),
label: z.string(),
measurementType: measurementReminderTypeEnum.nullable(),
intervalDays: z.number().int().nullable(),
rrule: z.string().nullable(),
anchorDate: z.iso.datetime({ offset: true }).nullable(),
notifyHour: z.number().int(),
location: z.string().nullable(),
nextDueAt: z.iso.datetime({ offset: true }).nullable(),
lastSatisfiedAt: z.iso.datetime({ offset: true }).nullable(),
enabled: z.boolean(),
createdAt: z.iso.datetime({ offset: true }),
updatedAt: z.iso.datetime({ offset: true }),
})
.meta({
id: "MeasurementReminderDTO",
description:
"A Vorsorge reminder. nextDueAt is server-computed (server-authoritative). A free-text reminder carries measurementType=null and resolves only on a manual satisfy.",
});
+47
View File
@@ -89,6 +89,19 @@ const cyclePrefsSchema = z
})
.partial();
/**
* v1.17.1 — measurement / Vorsorge reminder category schema.
* `clientManaged` mirrors the medication + cycle gate: when the iOS app
* owns local Vorsorge reminders it flips this to `true` and the
* server-side measurement-reminder cron suppresses its APNs send for that
* user. Sub-object form keeps the layout open for future Vorsorge knobs.
*/
const measurementReminderPrefsSchema = z
.object({
clientManaged: z.boolean(),
})
.partial();
/**
* v1.15.20 — coach category schema. `nudgesEnabled` gates the proactive
* Coach nudge cron (05:15 Europe/Berlin): default ON (the nudge is an
@@ -134,6 +147,7 @@ export const notificationPrefsSchema = z
mood: moodPrefsSchema,
cycle: cyclePrefsSchema,
coach: coachPrefsSchema,
measurementReminder: measurementReminderPrefsSchema,
})
.partial();
@@ -187,6 +201,14 @@ export interface NotificationPrefs {
/** v1.16.5 — cap interval: "weekly" (7 d) or "biweekly" (14 d). */
nudgeFrequency: "weekly" | "biweekly";
};
measurementReminder: {
/**
* v1.17.1 — when true the iOS app owns local Vorsorge reminders and
* the server-side measurement-reminder cron suppresses its APNs send
* (the medication / cycle `clientManaged` precedent).
*/
clientManaged: boolean;
};
}
/**
@@ -235,6 +257,9 @@ export const DEFAULT_NOTIFICATION_PREFS: NotificationPrefs = {
nudgeRoutine: true,
nudgeFrequency: "weekly",
},
measurementReminder: {
clientManaged: false,
},
};
/**
@@ -279,6 +304,10 @@ export function resolveNotificationPrefs(
...base.coach,
...(incoming.coach ?? {}),
},
measurementReminder: {
...base.measurementReminder,
...(incoming.measurementReminder ?? {}),
},
});
}
@@ -307,6 +336,7 @@ function applyDeliveryDefaultMapping(
mood: { ...prefs.mood },
cycle: { ...prefs.cycle },
coach: { ...prefs.coach },
measurementReminder: { ...prefs.measurementReminder },
};
}
@@ -335,6 +365,16 @@ export function isCycleReminderClientManaged(raw: unknown): boolean {
return parseNotificationPrefs(raw).cycle.clientManaged === true;
}
/**
* v1.17.1 — cron-side helper. Returns `true` when the user has opted in
* to client-managed Vorsorge (measurement) reminders so the server-side
* push should be suppressed. Tolerates a null / missing prefs row.
* Mirrors `isMedicationReminderClientManaged` / `isCycleReminderClientManaged`.
*/
export function isMeasurementReminderClientManaged(raw: unknown): boolean {
return parseNotificationPrefs(raw).measurementReminder.clientManaged === true;
}
/**
* v1.7.0 — resolve the effective delivery channel for a device. The
* per-device override wins; otherwise the user-level roaming default;
@@ -426,6 +466,9 @@ function cloneDefaults(): NotificationPrefs {
mood: { ...DEFAULT_NOTIFICATION_PREFS.mood },
cycle: { ...DEFAULT_NOTIFICATION_PREFS.cycle },
coach: { ...DEFAULT_NOTIFICATION_PREFS.coach },
measurementReminder: {
...DEFAULT_NOTIFICATION_PREFS.measurementReminder,
},
};
}
@@ -447,5 +490,9 @@ function mergeOverDefaults(input: NotificationPrefsInput): NotificationPrefs {
...DEFAULT_NOTIFICATION_PREFS.coach,
...(input.coach ?? {}),
},
measurementReminder: {
...DEFAULT_NOTIFICATION_PREFS.measurementReminder,
...(input.measurementReminder ?? {}),
},
});
}