mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
Let a checkup be skipped or pushed back, and remember every outcome
Refs #223. A due Vorsorge item could only be done, deleted or ignored; Telegram's postpone moved the date with no trace. Both decisions are first-class now, and both are honest: - POST /{id}/skip (no body): the interval restarts from the skip instant through the recurrence engine, lastSkippedAt is stamped, skipCount increments, any snooze clears. lastSatisfiedAt is NEVER touched — done and skipped stay distinguishable forever, enforced in the primitive and pinned by tests. - POST /{id}/snooze ({until: YYYY-MM-DD}): the server resolves the day to the notifyHour in the profile timezone and moves snoozedUntil and nextDueAt to the same instant, so every due-state consumer follows without knowing snooze exists. At least tomorrow, at most five years. - GET /{id}/history: the new append-only completion ledger (MeasurementReminderEvent), one row per satisfy from every engine path and per skip, onTime derived at write time against the due instant that was current when the event landed. Two anchor fixes close the backdated-event gaps: the auto-resolve floor is now the later of last satisfy and last skip (an old reading synced late no longer resolves the cycle the user just skipped), and a backdated real result re-anchors the next due at the later of its own instant and the skip decision. Screenings stay skippable and snoozable — the satisfy-side 409 guards claimed fulfilment; a skip claims the opposite. Appointment reminders 404 on all three routes, in the lookup, like every by-id sibling. /checkups gains the postpone sheet (quick chips, date field, the skip zone with its own confirm) in both view branches, with muted meta lines and no card colour change. The MCP preventive-care tool carries the resolved fields; the OpenAPI contract publishes all three paths and the widened DTO.
This commit is contained in:
1 parent
cd12b3886e
commit
5619783648
35 files changed
+3715
-115
No files matched your search
+1914
-66
File diff suppressed because it is too large.
Load diff
+21
-1
@@ -3168,7 +3168,8 @@
|
||||
"sleep": {
|
||||
"title": "Schlaf",
|
||||
"description": "Phasen-Aufschlüsselung und nächtliche Dauer – gezogen aus Apple Health.",
|
||||
"headlineTitle": "Durchschnitt pro Nacht",
|
||||
"mainSleep": "Hauptschlaf",
|
||||
"totalSleep": "Schlaf gesamt",
|
||||
"compositionTitle": "Phasen-Verteilung",
|
||||
"compositionSubtitle": "Letzte {nights} Nächte",
|
||||
"compositionAriaLabel": "Schlafphasen-Verteilung über {nights} Nächte",
|
||||
@@ -8085,6 +8086,23 @@
|
||||
"delete": "Löschen",
|
||||
"deleteConfirmTitle": "Erinnerung löschen?",
|
||||
"deleteConfirmDescription": "Diese Vorsorge-Erinnerung wird dauerhaft gelöscht.",
|
||||
"postpone": {
|
||||
"menuItem": "Verschieben…",
|
||||
"title": "Fälligkeit verschieben",
|
||||
"description": "Verschiebt nur die aktuelle Fälligkeit — der reguläre Rhythmus bleibt unverändert.",
|
||||
"chip": "+{days} Tage",
|
||||
"dateLabel": "Neues Datum",
|
||||
"confirm": "Verschieben"
|
||||
},
|
||||
"skip": {
|
||||
"heading": "Diese Fälligkeit überspringen",
|
||||
"explainer": "Wird ehrlich als übersprungen festgehalten — nicht als erledigt. Das Intervall startet ab heute neu.",
|
||||
"action": "Überspringen",
|
||||
"confirmTitle": "Fälligkeit überspringen?"
|
||||
},
|
||||
"snoozedUntil": "Verschoben bis {date}",
|
||||
"skippedOn": "Übersprungen am {date}",
|
||||
"skipCount": "{count}× übersprungen",
|
||||
"disabledBadge": "Aus",
|
||||
"nextDue": {
|
||||
"none": "Nicht geplant",
|
||||
@@ -10208,6 +10226,8 @@
|
||||
"measurementReminderDelete": "{name} hat eine Messerinnerung gelöscht",
|
||||
"measurementReminderComplete": "{name} hat eine Messerinnerung erledigt",
|
||||
"measurementReminderSatisfy": "{name} hat eine Messerinnerung abgehakt",
|
||||
"measurementReminderSkip": "{name} hat eine Messerinnerung übersprungen",
|
||||
"measurementReminderSnooze": "{name} hat eine Messerinnerung verschoben",
|
||||
"labResultUpdate": "{name} hat einen Laborwert geändert",
|
||||
"labResultDelete": "{name} hat einen Laborwert gelöscht",
|
||||
"labResultRestore": "{name} hat einen Laborwert wiederhergestellt",
|
||||
|
||||
+21
-1
@@ -3168,7 +3168,8 @@
|
||||
"sleep": {
|
||||
"title": "Sleep",
|
||||
"description": "Stage breakdown and nightly duration, drawn from Apple Health.",
|
||||
"headlineTitle": "Average per night",
|
||||
"mainSleep": "Main sleep",
|
||||
"totalSleep": "Total sleep",
|
||||
"compositionTitle": "Stage composition",
|
||||
"compositionSubtitle": "Last {nights} nights",
|
||||
"compositionAriaLabel": "Sleep stage composition over {nights} nights",
|
||||
@@ -8085,6 +8086,23 @@
|
||||
"delete": "Delete",
|
||||
"deleteConfirmTitle": "Delete reminder?",
|
||||
"deleteConfirmDescription": "This checkup reminder will be permanently deleted.",
|
||||
"postpone": {
|
||||
"menuItem": "Postpone…",
|
||||
"title": "Postpone due date",
|
||||
"description": "Only moves the current due date — the regular rhythm stays unchanged.",
|
||||
"chip": "+{days} days",
|
||||
"dateLabel": "New date",
|
||||
"confirm": "Postpone"
|
||||
},
|
||||
"skip": {
|
||||
"heading": "Skip this due date",
|
||||
"explainer": "Recorded honestly as skipped — not as done. The interval restarts from today.",
|
||||
"action": "Skip",
|
||||
"confirmTitle": "Skip this due date?"
|
||||
},
|
||||
"snoozedUntil": "Postponed until {date}",
|
||||
"skippedOn": "Skipped on {date}",
|
||||
"skipCount": "skipped {count}×",
|
||||
"disabledBadge": "Off",
|
||||
"nextDue": {
|
||||
"none": "Not scheduled",
|
||||
@@ -10208,6 +10226,8 @@
|
||||
"measurementReminderDelete": "{name} removed a measurement reminder",
|
||||
"measurementReminderComplete": "{name} completed a measurement reminder",
|
||||
"measurementReminderSatisfy": "{name} cleared a measurement reminder",
|
||||
"measurementReminderSkip": "{name} skipped a measurement reminder",
|
||||
"measurementReminderSnooze": "{name} postponed a measurement reminder",
|
||||
"labResultUpdate": "{name} changed a lab result",
|
||||
"labResultDelete": "{name} removed a lab result",
|
||||
"labResultRestore": "{name} restored a lab result",
|
||||
|
||||
+21
-1
@@ -3168,7 +3168,8 @@
|
||||
"sleep": {
|
||||
"title": "Sueño",
|
||||
"description": "Desglose por fases y duración nocturna, tomados de Apple Health.",
|
||||
"headlineTitle": "Media por noche",
|
||||
"mainSleep": "Sueño principal",
|
||||
"totalSleep": "Sueño total",
|
||||
"compositionTitle": "Distribución de fases",
|
||||
"compositionSubtitle": "Últimas {nights} noches",
|
||||
"compositionAriaLabel": "Distribución de fases del sueño en {nights} noches",
|
||||
@@ -8085,6 +8086,23 @@
|
||||
"delete": "Eliminar",
|
||||
"deleteConfirmTitle": "¿Eliminar recordatorio?",
|
||||
"deleteConfirmDescription": "Este recordatorio de revisión se eliminará de forma permanente.",
|
||||
"postpone": {
|
||||
"menuItem": "Aplazar…",
|
||||
"title": "Aplazar vencimiento",
|
||||
"description": "Solo mueve el vencimiento actual — el ritmo regular no cambia.",
|
||||
"chip": "+{days} días",
|
||||
"dateLabel": "Nueva fecha",
|
||||
"confirm": "Aplazar"
|
||||
},
|
||||
"skip": {
|
||||
"heading": "Omitir este vencimiento",
|
||||
"explainer": "Se registra honestamente como omitido — no como hecho. El intervalo se reinicia desde hoy.",
|
||||
"action": "Omitir",
|
||||
"confirmTitle": "¿Omitir este vencimiento?"
|
||||
},
|
||||
"snoozedUntil": "Aplazado hasta {date}",
|
||||
"skippedOn": "Omitido el {date}",
|
||||
"skipCount": "omitido {count} veces",
|
||||
"disabledBadge": "Apagado",
|
||||
"nextDue": {
|
||||
"none": "Sin programar",
|
||||
@@ -10208,6 +10226,8 @@
|
||||
"measurementReminderDelete": "{name} eliminó un recordatorio de medición",
|
||||
"measurementReminderComplete": "{name} completó un recordatorio de medición",
|
||||
"measurementReminderSatisfy": "{name} cerró un recordatorio de medición",
|
||||
"measurementReminderSkip": "{name} omitió un recordatorio de medición",
|
||||
"measurementReminderSnooze": "{name} aplazó un recordatorio de medición",
|
||||
"labResultUpdate": "{name} cambió un resultado de laboratorio",
|
||||
"labResultDelete": "{name} eliminó un resultado de laboratorio",
|
||||
"labResultRestore": "{name} restauró un resultado de laboratorio",
|
||||
|
||||
+21
-1
@@ -3168,7 +3168,8 @@
|
||||
"sleep": {
|
||||
"title": "Sommeil",
|
||||
"description": "Répartition des phases et durée nocturne, issues d’Apple Health.",
|
||||
"headlineTitle": "Moyenne par nuit",
|
||||
"mainSleep": "Sommeil principal",
|
||||
"totalSleep": "Sommeil total",
|
||||
"compositionTitle": "Répartition des phases",
|
||||
"compositionSubtitle": "{nights} dernières nuits",
|
||||
"compositionAriaLabel": "Répartition des phases de sommeil sur {nights} nuits",
|
||||
@@ -8085,6 +8086,23 @@
|
||||
"delete": "Supprimer",
|
||||
"deleteConfirmTitle": "Supprimer le rappel ?",
|
||||
"deleteConfirmDescription": "Ce rappel d’examen sera définitivement supprimé.",
|
||||
"postpone": {
|
||||
"menuItem": "Reporter…",
|
||||
"title": "Reporter l'échéance",
|
||||
"description": "Ne déplace que l'échéance actuelle — le rythme régulier reste inchangé.",
|
||||
"chip": "+{days} jours",
|
||||
"dateLabel": "Nouvelle date",
|
||||
"confirm": "Reporter"
|
||||
},
|
||||
"skip": {
|
||||
"heading": "Passer cette échéance",
|
||||
"explainer": "Enregistré honnêtement comme passé — pas comme fait. L'intervalle redémarre à partir d'aujourd'hui.",
|
||||
"action": "Passer",
|
||||
"confirmTitle": "Passer cette échéance ?"
|
||||
},
|
||||
"snoozedUntil": "Reporté au {date}",
|
||||
"skippedOn": "Passé le {date}",
|
||||
"skipCount": "passé {count} fois",
|
||||
"disabledBadge": "Désactivé",
|
||||
"nextDue": {
|
||||
"none": "Non planifié",
|
||||
@@ -10208,6 +10226,8 @@
|
||||
"measurementReminderDelete": "{name} a supprimé un rappel de mesure",
|
||||
"measurementReminderComplete": "{name} a terminé un rappel de mesure",
|
||||
"measurementReminderSatisfy": "{name} a clôturé un rappel de mesure",
|
||||
"measurementReminderSkip": "{name} a passé un rappel de mesure",
|
||||
"measurementReminderSnooze": "{name} a reporté un rappel de mesure",
|
||||
"labResultUpdate": "{name} a modifié un résultat de laboratoire",
|
||||
"labResultDelete": "{name} a supprimé un résultat de laboratoire",
|
||||
"labResultRestore": "{name} a restauré un résultat de laboratoire",
|
||||
|
||||
+21
-1
@@ -3168,7 +3168,8 @@
|
||||
"sleep": {
|
||||
"title": "Sonno",
|
||||
"description": "Suddivisione per fasi e durata notturna, da Apple Health.",
|
||||
"headlineTitle": "Media per notte",
|
||||
"mainSleep": "Sonno principale",
|
||||
"totalSleep": "Sonno totale",
|
||||
"compositionTitle": "Distribuzione delle fasi",
|
||||
"compositionSubtitle": "Ultime {nights} notti",
|
||||
"compositionAriaLabel": "Distribuzione delle fasi del sonno su {nights} notti",
|
||||
@@ -8085,6 +8086,23 @@
|
||||
"delete": "Elimina",
|
||||
"deleteConfirmTitle": "Eliminare il promemoria?",
|
||||
"deleteConfirmDescription": "Questo promemoria di controllo verrà eliminato definitivamente.",
|
||||
"postpone": {
|
||||
"menuItem": "Rimanda…",
|
||||
"title": "Rimanda la scadenza",
|
||||
"description": "Sposta solo la scadenza attuale — il ritmo regolare resta invariato.",
|
||||
"chip": "+{days} giorni",
|
||||
"dateLabel": "Nuova data",
|
||||
"confirm": "Rimanda"
|
||||
},
|
||||
"skip": {
|
||||
"heading": "Salta questa scadenza",
|
||||
"explainer": "Registrato onestamente come saltato — non come fatto. L'intervallo riparte da oggi.",
|
||||
"action": "Salta",
|
||||
"confirmTitle": "Saltare questa scadenza?"
|
||||
},
|
||||
"snoozedUntil": "Rimandato al {date}",
|
||||
"skippedOn": "Saltato il {date}",
|
||||
"skipCount": "saltato {count} volte",
|
||||
"disabledBadge": "Disattivato",
|
||||
"nextDue": {
|
||||
"none": "Non pianificato",
|
||||
@@ -10208,6 +10226,8 @@
|
||||
"measurementReminderDelete": "{name} ha eliminato un promemoria di misurazione",
|
||||
"measurementReminderComplete": "{name} ha completato un promemoria di misurazione",
|
||||
"measurementReminderSatisfy": "{name} ha chiuso un promemoria di misurazione",
|
||||
"measurementReminderSkip": "{name} ha saltato un promemoria di misurazione",
|
||||
"measurementReminderSnooze": "{name} ha rimandato un promemoria di misurazione",
|
||||
"labResultUpdate": "{name} ha modificato un risultato di laboratorio",
|
||||
"labResultDelete": "{name} ha eliminato un risultato di laboratorio",
|
||||
"labResultRestore": "{name} ha ripristinato un risultato di laboratorio",
|
||||
|
||||
+21
-1
@@ -3168,7 +3168,8 @@
|
||||
"sleep": {
|
||||
"title": "Sen",
|
||||
"description": "Podział na fazy i nocna długość snu, dane z Apple Health.",
|
||||
"headlineTitle": "Średnia na noc",
|
||||
"mainSleep": "Sen główny",
|
||||
"totalSleep": "Sen łączny",
|
||||
"compositionTitle": "Rozkład faz",
|
||||
"compositionSubtitle": "Ostatnie {nights} nocy",
|
||||
"compositionAriaLabel": "Rozkład faz snu w {nights} nocach",
|
||||
@@ -8085,6 +8086,23 @@
|
||||
"delete": "Usuń",
|
||||
"deleteConfirmTitle": "Usunąć przypomnienie?",
|
||||
"deleteConfirmDescription": "To przypomnienie o badaniu zostanie trwale usunięte.",
|
||||
"postpone": {
|
||||
"menuItem": "Przełóż…",
|
||||
"title": "Przełóż termin",
|
||||
"description": "Przesuwa tylko bieżący termin — regularny rytm pozostaje bez zmian.",
|
||||
"chip": "+{days} dni",
|
||||
"dateLabel": "Nowa data",
|
||||
"confirm": "Przełóż"
|
||||
},
|
||||
"skip": {
|
||||
"heading": "Pomiń ten termin",
|
||||
"explainer": "Zapisane uczciwie jako pominięte — nie jako wykonane. Odstęp zaczyna się od nowa od dzisiaj.",
|
||||
"action": "Pomiń",
|
||||
"confirmTitle": "Pominąć ten termin?"
|
||||
},
|
||||
"snoozedUntil": "Przełożono do {date}",
|
||||
"skippedOn": "Pominięto {date}",
|
||||
"skipCount": "pominięto {count} razy",
|
||||
"disabledBadge": "Wył.",
|
||||
"nextDue": {
|
||||
"none": "Niezaplanowane",
|
||||
@@ -10208,6 +10226,8 @@
|
||||
"measurementReminderDelete": "{name} usunął przypomnienie o pomiarze",
|
||||
"measurementReminderComplete": "{name} zrealizował przypomnienie o pomiarze",
|
||||
"measurementReminderSatisfy": "{name} zamknął przypomnienie o pomiarze",
|
||||
"measurementReminderSkip": "{name} pominął(-ęła) przypomnienie o pomiarze",
|
||||
"measurementReminderSnooze": "{name} przełożył(-yła) przypomnienie o pomiarze",
|
||||
"labResultUpdate": "{name} zmienił wynik badania",
|
||||
"labResultDelete": "{name} usunął wynik badania",
|
||||
"labResultRestore": "{name} przywrócił wynik badania",
|
||||
|
||||
@@ -97,6 +97,9 @@ function reminderRow(
|
||||
location: null,
|
||||
nextDueAt: at,
|
||||
lastSatisfiedAt: null,
|
||||
snoozedUntil: null,
|
||||
lastSkippedAt: null,
|
||||
skipCount: 0,
|
||||
enabled: true,
|
||||
vaccinationAntigen: null,
|
||||
createdAt: at,
|
||||
@@ -152,6 +155,27 @@ const BY_ID_SITES: ReadonlyArray<{
|
||||
sites: 1,
|
||||
surface: "the manual satisfy action",
|
||||
},
|
||||
{
|
||||
// v1.37.20 (#223) — skipping an appointment would move a date the visit
|
||||
// record still believes it owns; snoozing one likewise. Both refuse in
|
||||
// the lookup, before any write. This entry went in RED (the routes did
|
||||
// not exist yet) and turned green when they landed with the exclusion.
|
||||
file: "src/app/api/measurement-reminders/[id]/skip/route.ts",
|
||||
sites: 1,
|
||||
surface: "the honest skip action",
|
||||
},
|
||||
{
|
||||
file: "src/app/api/measurement-reminders/[id]/snooze/route.ts",
|
||||
sites: 1,
|
||||
surface: "the snooze action",
|
||||
},
|
||||
{
|
||||
// v1.37.20 (iOS #68) — the ledger read answers 404 for an appointment
|
||||
// row like every by-id sibling.
|
||||
file: "src/app/api/measurement-reminders/[id]/history/route.ts",
|
||||
sites: 1,
|
||||
surface: "the completion-ledger history read",
|
||||
},
|
||||
{
|
||||
file: "src/lib/telegram-webhook-handlers.ts",
|
||||
sites: 2,
|
||||
@@ -161,9 +185,9 @@ const BY_ID_SITES: ReadonlyArray<{
|
||||
|
||||
describe("an appointment reminder is not addressable by id", () => {
|
||||
it("names a by-id set that is not empty (no vacuous pass)", () => {
|
||||
expect(BY_ID_SITES.length).toBe(4);
|
||||
expect(BY_ID_SITES.length).toBe(7);
|
||||
expect(BY_ID_SITES.reduce((total, entry) => total + entry.sites, 0)).toBe(
|
||||
7,
|
||||
10,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -1140,6 +1140,18 @@ const DELEGABLE_ROUTES: Record<string, DelegableEntry> = {
|
||||
domain: "measurements",
|
||||
why: "The same primitive from the other caller. Reached only through this module's MANAGE arm; the argument for admitting it is the manage literal's reason line, and the entry here is what leg (a) freezes and leg (f) checks the section against.",
|
||||
},
|
||||
"app/api/measurement-reminders/[id]/skip/route.ts": {
|
||||
domain: "measurements",
|
||||
why: "Honestly skipping the current due cycle (v1.37.20, #223) — the counterpart of complete/satisfy, restarting the interval without claiming fulfilment. Reached only through this module's MANAGE arm; the argument for admitting it is the manage literal's reason line, and the entry here is what leg (a) freezes and leg (f) checks the section against.",
|
||||
},
|
||||
"app/api/measurement-reminders/[id]/snooze/route.ts": {
|
||||
domain: "measurements",
|
||||
why: "Pushing one due date back to a calendar day (v1.37.20, #223), the cadence untouched. Reached only through this module's MANAGE arm; the argument for admitting it is the manage literal's reason line, and the entry here is what leg (a) freezes and leg (f) checks the section against.",
|
||||
},
|
||||
"app/api/measurement-reminders/[id]/history/route.ts": {
|
||||
domain: "measurements",
|
||||
why: "The completion ledger behind one reminder (v1.37.20, iOS #68), read-only and newest-first. The lookup binds the resolved user id and refuses an appointment row like every by-id sibling.",
|
||||
},
|
||||
"app/api/labs/restore/route.ts": {
|
||||
domain: "labs",
|
||||
why: "Restoring tombstoned results, scoped so a foreign or live id is a no-op. Reached only through this module's MANAGE arm; the argument for admitting it is the manage literal's reason line, and the entry here is what leg (a) freezes and leg (f) checks the section against.",
|
||||
@@ -1477,6 +1489,16 @@ const DELEGABLE_MANAGE_ROUTES: Record<string, ManageEntry> = {
|
||||
conditions: [],
|
||||
why: "The same primitive from the other caller.",
|
||||
},
|
||||
"app/api/measurement-reminders/[id]/skip/route.ts": {
|
||||
domain: "measurements",
|
||||
conditions: [],
|
||||
why: "Honestly letting one due cycle go (v1.37.20, #223). Additive management of a schedule the level admits — the interval restarts, lastSatisfiedAt is never touched.",
|
||||
},
|
||||
"app/api/measurement-reminders/[id]/snooze/route.ts": {
|
||||
domain: "measurements",
|
||||
conditions: [],
|
||||
why: "Pushing one due date back (v1.37.20, #223). Additive, self-expiring, the anchor untouched.",
|
||||
},
|
||||
"app/api/labs/[id]/route.ts": {
|
||||
domain: "labs",
|
||||
conditions: ["C4"],
|
||||
@@ -1911,8 +1933,12 @@ const ACTOR_ROUTES: Record<string, string> = {
|
||||
* v1.38.0 -- the immunization log adds six: two reads and one restore on the
|
||||
* record list, one create on the write literal, and the edit/delete pair plus
|
||||
* the restore on the manage literal. 215 -> 221.
|
||||
*
|
||||
* v1.37.20 (#223 / iOS #68) -- Vorsorge skip/snooze adds five: skip, snooze
|
||||
* and the completion-ledger history read on the record list, plus skip and
|
||||
* snooze on the manage literal. 224 -> 229.
|
||||
*/
|
||||
const FROZEN_ENTRY_COUNT = 224;
|
||||
const FROZEN_ENTRY_COUNT = 229;
|
||||
|
||||
/**
|
||||
* The two surfaces that authenticate a Bearer token outside `requireAuth` —
|
||||
@@ -2673,7 +2699,7 @@ describe("(g) the MANAGE route set is frozen", () => {
|
||||
|
||||
it("keeps the admitted mutation inventory complete and discoverable", () => {
|
||||
expect(ADMITTED_MUTATING_HANDLERS.length).toBeGreaterThan(0);
|
||||
expect(ADMITTED_MUTATING_HANDLERS.length).toBe(75);
|
||||
expect(ADMITTED_MUTATING_HANDLERS.length).toBe(77);
|
||||
|
||||
const expected = ADMITTED_MUTATING_HANDLERS.map(
|
||||
({ handlerModule, action, level }) =>
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
/**
|
||||
* v1.37.20 (#223 / iOS #68) — the skip, snooze and history routes.
|
||||
*
|
||||
* What this suite pins, per the feature's design:
|
||||
*
|
||||
* - 404 for a foreign or ENCOUNTER-origin reminder on all three routes
|
||||
* (the refusal lives in the lookup, before any write).
|
||||
* - A SCREENING reminder is skippable and snoozable — deliberately NO 409.
|
||||
* The satisfy-side 409 guards against CLAIMED fulfilment without an
|
||||
* assessment; skip and snooze claim the opposite, so refusing them here
|
||||
* would only push people back to deleting the reminder. This test is the
|
||||
* pinned boundary between the two rules.
|
||||
* - Skip: forward-only no-op behind an existing cursor; the write never
|
||||
* carries `lastSatisfiedAt` (THE invariant); the SKIPPED ledger row.
|
||||
* - Snooze: the named calendar day resolves to the notifyHour in the
|
||||
* profile timezone; both cursors move to the SAME instant; a past date
|
||||
* and a beyond-cap date answer 422; the cadence fields stay untouched.
|
||||
* - History: paginated newest-first with the meta triple.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
|
||||
requireAuth: vi.fn(async () => ({ user: { id: "u1", locale: "en" } })),
|
||||
requireRecordAuth: vi.fn(async () => ({
|
||||
user: { id: "u1", locale: "en" },
|
||||
actor: { id: "u1", locale: "en" },
|
||||
grantId: null,
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
|
||||
|
||||
const findFirstMock = vi.fn();
|
||||
const updateMock = vi.fn();
|
||||
const updateManyMock = vi.fn();
|
||||
const findUniqueOrThrowMock = vi.fn();
|
||||
const findUserMock = vi.fn();
|
||||
const eventCreateMock = vi.fn();
|
||||
const eventFindManyMock = vi.fn();
|
||||
const eventCountMock = vi.fn();
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
measurementReminder: {
|
||||
findFirst: (...a: unknown[]) => findFirstMock(...a),
|
||||
update: (...a: unknown[]) => updateMock(...a),
|
||||
updateMany: (...a: unknown[]) => updateManyMock(...a),
|
||||
findUniqueOrThrow: (...a: unknown[]) => findUniqueOrThrowMock(...a),
|
||||
},
|
||||
measurementReminderEvent: {
|
||||
create: (...a: unknown[]) => eventCreateMock(...a),
|
||||
findMany: (...a: unknown[]) => eventFindManyMock(...a),
|
||||
count: (...a: unknown[]) => eventCountMock(...a),
|
||||
},
|
||||
user: { findUnique: (...a: unknown[]) => findUserMock(...a) },
|
||||
},
|
||||
}));
|
||||
|
||||
import { POST as SKIP } from "../skip/route";
|
||||
import { POST as SNOOZE } from "../snooze/route";
|
||||
import { GET as HISTORY } from "../history/route";
|
||||
|
||||
const NOW = new Date("2026-06-15T08:00:00.000Z");
|
||||
|
||||
const BASE_ROW = {
|
||||
id: "r1",
|
||||
userId: "u1",
|
||||
label: "Blutdruck messen",
|
||||
measurementType: "BLOOD_PRESSURE_SYS",
|
||||
intervalDays: 30,
|
||||
rrule: null as string | null,
|
||||
anchorDate: null as Date | null,
|
||||
endsOn: null,
|
||||
origin: "VORSORGE",
|
||||
notifyHour: 9,
|
||||
location: null,
|
||||
nextDueAt: new Date("2026-06-15T07:00:00Z"),
|
||||
lastSatisfiedAt: null as Date | null,
|
||||
snoozedUntil: null as Date | null,
|
||||
lastSkippedAt: null as Date | null,
|
||||
skipCount: 0,
|
||||
enabled: true,
|
||||
vaccinationAntigen: null,
|
||||
createdAt: new Date("2026-01-01T00:00:00Z"),
|
||||
updatedAt: new Date("2026-01-01T00:00:00Z"),
|
||||
deletedAt: null,
|
||||
};
|
||||
|
||||
function params(id = "r1") {
|
||||
return { params: Promise.resolve({ id }) };
|
||||
}
|
||||
|
||||
function skipRequest(): NextRequest {
|
||||
return new NextRequest("http://localhost/api/measurement-reminders/r1/skip", {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
function snoozeRequest(body: unknown): NextRequest {
|
||||
return new NextRequest(
|
||||
"http://localhost/api/measurement-reminders/r1/snooze",
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
headers: { "content-type": "application/json" },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function historyRequest(query = ""): NextRequest {
|
||||
return new NextRequest(
|
||||
`http://localhost/api/measurement-reminders/r1/history${query}`,
|
||||
{ method: "GET" },
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(NOW);
|
||||
findFirstMock.mockResolvedValue({ ...BASE_ROW });
|
||||
findUserMock.mockResolvedValue({ timezone: "Europe/Berlin" });
|
||||
updateManyMock.mockResolvedValue({ count: 1 });
|
||||
updateMock.mockResolvedValue({ ...BASE_ROW });
|
||||
findUniqueOrThrowMock.mockResolvedValue({ ...BASE_ROW });
|
||||
eventCreateMock.mockResolvedValue({ id: "evt-1" });
|
||||
eventFindManyMock.mockResolvedValue([]);
|
||||
eventCountMock.mockResolvedValue(0);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("POST /api/measurement-reminders/[id]/skip", () => {
|
||||
it("404s a foreign reminder without touching it", async () => {
|
||||
findFirstMock.mockResolvedValue({ ...BASE_ROW, userId: "someone-else" });
|
||||
const res = await SKIP(skipRequest(), params());
|
||||
expect(res.status).toBe(404);
|
||||
expect(updateManyMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("404s an ENCOUNTER reminder via the lookup filter", async () => {
|
||||
// The route's findFirst carries `origin: { not: "ENCOUNTER" }`, so the
|
||||
// DB answers null for an appointment row. Mirror that here.
|
||||
findFirstMock.mockResolvedValue(null);
|
||||
const res = await SKIP(skipRequest(), params());
|
||||
expect(res.status).toBe(404);
|
||||
const where = findFirstMock.mock.calls[0][0].where;
|
||||
expect(where.origin).toEqual({ not: "ENCOUNTER" });
|
||||
expect(updateManyMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips a SCREENING reminder — the satisfy-side 409 does not apply", async () => {
|
||||
findFirstMock.mockResolvedValue({
|
||||
...BASE_ROW,
|
||||
measurementType: "PHQ9_SCORE",
|
||||
});
|
||||
const res = await SKIP(skipRequest(), params());
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.data.skipped).toBe(true);
|
||||
});
|
||||
|
||||
it("stamps the skip without ever touching lastSatisfiedAt", async () => {
|
||||
const res = await SKIP(skipRequest(), params());
|
||||
expect(res.status).toBe(200);
|
||||
const { data } = updateManyMock.mock.calls[0][0];
|
||||
expect(data.lastSkippedAt).toEqual(NOW);
|
||||
expect(data.skipCount).toEqual({ increment: 1 });
|
||||
expect(data.snoozedUntil).toBeNull();
|
||||
expect("lastSatisfiedAt" in data).toBe(false);
|
||||
// Rolling 30d cadence restarts from the skip instant.
|
||||
expect(data.nextDueAt.getTime()).toBeGreaterThan(
|
||||
NOW.getTime() + 29 * 24 * 60 * 60 * 1000,
|
||||
);
|
||||
});
|
||||
|
||||
it("appends the SKIPPED ledger row", async () => {
|
||||
await SKIP(skipRequest(), params());
|
||||
expect(eventCreateMock).toHaveBeenCalledTimes(1);
|
||||
const { data } = eventCreateMock.mock.calls[0][0];
|
||||
expect(data).toMatchObject({
|
||||
userId: "u1",
|
||||
reminderId: "r1",
|
||||
kind: "SKIPPED",
|
||||
source: "skip",
|
||||
});
|
||||
});
|
||||
|
||||
it("answers skipped=false on a forward-only no-op (double skip)", async () => {
|
||||
findFirstMock.mockResolvedValue({
|
||||
...BASE_ROW,
|
||||
// A cursor already ahead of the clock — the primitive refuses in
|
||||
// memory, before any write.
|
||||
lastSkippedAt: new Date("2026-06-16T09:00:00Z"),
|
||||
});
|
||||
const res = await SKIP(skipRequest(), params());
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.data.skipped).toBe(false);
|
||||
expect(updateManyMock).not.toHaveBeenCalled();
|
||||
expect(eventCreateMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/measurement-reminders/[id]/snooze", () => {
|
||||
it("404s an ENCOUNTER reminder via the lookup filter", async () => {
|
||||
findFirstMock.mockResolvedValue(null);
|
||||
const res = await SNOOZE(snoozeRequest({ until: "2026-09-01" }), params());
|
||||
expect(res.status).toBe(404);
|
||||
const where = findFirstMock.mock.calls[0][0].where;
|
||||
expect(where.origin).toEqual({ not: "ENCOUNTER" });
|
||||
});
|
||||
|
||||
it("snoozes a SCREENING reminder without a 409", async () => {
|
||||
findFirstMock.mockResolvedValue({
|
||||
...BASE_ROW,
|
||||
measurementType: "GAD7_SCORE",
|
||||
});
|
||||
const res = await SNOOZE(snoozeRequest({ until: "2026-09-01" }), params());
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("resolves the named day to the notifyHour in the profile timezone and moves both cursors together", async () => {
|
||||
const res = await SNOOZE(snoozeRequest({ until: "2026-09-01" }), params());
|
||||
expect(res.status).toBe(200);
|
||||
const { data } = updateMock.mock.calls[0][0];
|
||||
// 2026-09-01 09:00 Europe/Berlin (CEST, UTC+2) = 07:00Z. This is the
|
||||
// DST-aware wall-clock resolution, not a request-time offset.
|
||||
const expected = new Date("2026-09-01T07:00:00.000Z");
|
||||
expect(data.snoozedUntil).toEqual(expected);
|
||||
expect(data.nextDueAt).toEqual(expected);
|
||||
// The cadence and its cursors stay untouched.
|
||||
expect("lastSatisfiedAt" in data).toBe(false);
|
||||
expect("lastSkippedAt" in data).toBe(false);
|
||||
expect("intervalDays" in data).toBe(false);
|
||||
expect("anchorDate" in data).toBe(false);
|
||||
});
|
||||
|
||||
it("422s a past or same-day date", async () => {
|
||||
const res = await SNOOZE(snoozeRequest({ until: "2026-06-15" }), params());
|
||||
expect(res.status).toBe(422);
|
||||
expect(updateMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("422s a date beyond the five-year cap", async () => {
|
||||
const res = await SNOOZE(snoozeRequest({ until: "2031-07-01" }), params());
|
||||
expect(res.status).toBe(422);
|
||||
expect(updateMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("422s a malformed body through the multi-issue envelope", async () => {
|
||||
const res = await SNOOZE(snoozeRequest({ until: "not-a-date" }), params());
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/measurement-reminders/[id]/history", () => {
|
||||
it("404s an ENCOUNTER reminder via the lookup filter", async () => {
|
||||
findFirstMock.mockResolvedValue(null);
|
||||
const res = await HISTORY(historyRequest(), params());
|
||||
expect(res.status).toBe(404);
|
||||
const where = findFirstMock.mock.calls[0][0].where;
|
||||
expect(where.origin).toEqual({ not: "ENCOUNTER" });
|
||||
});
|
||||
|
||||
it("pages the ledger newest first with the meta triple", async () => {
|
||||
eventFindManyMock.mockResolvedValue([
|
||||
{
|
||||
id: "e2",
|
||||
kind: "SKIPPED",
|
||||
occurredAt: new Date("2026-06-10T09:00:00Z"),
|
||||
onTime: false,
|
||||
source: "skip",
|
||||
createdAt: new Date("2026-06-10T09:00:00Z"),
|
||||
},
|
||||
{
|
||||
id: "e1",
|
||||
kind: "SATISFIED",
|
||||
occurredAt: new Date("2026-05-10T09:00:00Z"),
|
||||
onTime: true,
|
||||
source: "manual",
|
||||
createdAt: new Date("2026-05-10T09:00:00Z"),
|
||||
},
|
||||
]);
|
||||
eventCountMock.mockResolvedValue(2);
|
||||
|
||||
const res = await HISTORY(historyRequest("?limit=10&offset=0"), params());
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.data.events).toHaveLength(2);
|
||||
expect(body.data.events[0]).toMatchObject({
|
||||
id: "e2",
|
||||
kind: "SKIPPED",
|
||||
onTime: false,
|
||||
source: "skip",
|
||||
});
|
||||
expect(body.data.meta).toEqual({ total: 2, limit: 10, offset: 0 });
|
||||
const query = eventFindManyMock.mock.calls[0][0];
|
||||
expect(query.orderBy).toEqual({ occurredAt: "desc" });
|
||||
expect(query.where).toEqual({ reminderId: "r1", userId: "u1" });
|
||||
});
|
||||
|
||||
it("422s an out-of-range limit", async () => {
|
||||
const res = await HISTORY(historyRequest("?limit=1000"), params());
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
});
|
||||
@@ -77,7 +77,13 @@ export const POST = apiHandler(
|
||||
// Forward-only: a completion strictly after any prior satisfy advances
|
||||
// the row; a completion at/behind the last satisfy is a no-op. Either
|
||||
// way no notification is emitted — the primitive only stamps + reschedules.
|
||||
const result = await satisfyReminder(prisma, existing, timezone, now);
|
||||
const result = await satisfyReminder(
|
||||
prisma,
|
||||
existing,
|
||||
timezone,
|
||||
now,
|
||||
"manual",
|
||||
);
|
||||
|
||||
const updated = await prisma.measurementReminder.findUniqueOrThrow({
|
||||
where: { id },
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* v1.37.20 (iOS #68) — the completion ledger for one Vorsorge reminder,
|
||||
* paginated, newest first.
|
||||
*
|
||||
* Every satisfy (from any path — manual, auto-resolve, Telegram,
|
||||
* vaccination booster, visit close) and every skip appends one row via the
|
||||
* shared primitives; this route only reads. History begins at the release
|
||||
* that introduced the ledger: the single-cursor engine holds nothing to
|
||||
* backfill from, which the iOS #68 thread records as agreed.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess, apiError, returnAllZodIssues } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { listReminderHistorySchema } from "@/lib/validations/measurement-reminders";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
export const GET = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
// READ — the same level as the reminder list this history hangs off.
|
||||
const { user } = await requireRecordAuth("read", "measurements");
|
||||
const { id } = await params;
|
||||
|
||||
const searchParams = Object.fromEntries(request.nextUrl.searchParams);
|
||||
const parsed = listReminderHistorySchema.safeParse(searchParams);
|
||||
if (!parsed.success) {
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
const { limit, offset } = parsed.data;
|
||||
|
||||
// An appointment reminder is not addressable by this family — 404, the
|
||||
// same refusal every by-id sibling makes.
|
||||
const existing = await prisma.measurementReminder.findFirst({
|
||||
where: { id, deletedAt: null, origin: { not: "ENCOUNTER" } },
|
||||
select: { id: true, userId: true },
|
||||
});
|
||||
if (!existing || existing.userId !== user.id) {
|
||||
return apiError("Measurement reminder not found", 404);
|
||||
}
|
||||
|
||||
const where = { reminderId: id, userId: user.id };
|
||||
const [rows, total] = await Promise.all([
|
||||
prisma.measurementReminderEvent.findMany({
|
||||
where,
|
||||
orderBy: { occurredAt: "desc" },
|
||||
take: limit,
|
||||
skip: offset,
|
||||
}),
|
||||
prisma.measurementReminderEvent.count({ where }),
|
||||
]);
|
||||
|
||||
annotate({
|
||||
action: { name: "measurement-reminders.history" },
|
||||
meta: { reminderId: id, total },
|
||||
});
|
||||
|
||||
return apiSuccess({
|
||||
events: rows.map((row) => ({
|
||||
id: row.id,
|
||||
kind: row.kind,
|
||||
occurredAt: row.occurredAt.toISOString(),
|
||||
onTime: row.onTime,
|
||||
source: row.source,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
})),
|
||||
meta: { total, limit, offset },
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -189,6 +189,9 @@ export const PATCH = apiHandler(
|
||||
? existing.lastSatisfiedAt
|
||||
: now;
|
||||
updateData.nextDueAt = computeReminderNextDueAt(merged, timezone, after);
|
||||
// v1.37.20 (#223) — a cadence edit recomputes `nextDueAt`, so it clears
|
||||
// the snooze cursor: the cycle the snooze pushed back no longer exists.
|
||||
updateData.snoozedUntil = null;
|
||||
|
||||
const updated = await prisma.measurementReminder.update({
|
||||
where: { id },
|
||||
|
||||
@@ -64,7 +64,7 @@ export const POST = apiHandler(
|
||||
// auto-resolve and the eventful worker use. A manual "Erledigt" is
|
||||
// always strictly after any prior satisfy, so the forward-only guard
|
||||
// advances it.
|
||||
await satisfyReminder(prisma, existing, timezone, now);
|
||||
await satisfyReminder(prisma, existing, timezone, now, "manual");
|
||||
|
||||
const updated = await prisma.measurementReminder.findUniqueOrThrow({
|
||||
where: { id },
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* v1.37.20 (#223) — honestly skip a Vorsorge reminder's current due cycle.
|
||||
*
|
||||
* No body: the skip instant is always the server clock. The interval
|
||||
* restarts from the skip (rolling: skip + N days at the notify hour; rrule:
|
||||
* next occurrence strictly after the skip), `lastSkippedAt` is stamped,
|
||||
* `skipCount` incremented, any snooze cleared, and one SKIPPED row lands in
|
||||
* the completion ledger. `lastSatisfiedAt` is NEVER touched — a skip is the
|
||||
* honest alternative to deleting or ignoring a reminder, not a completion.
|
||||
*
|
||||
* Screening reminders (PHQ-9 / GAD-7 / WHO-5 / SCI) ARE skippable — the
|
||||
* satisfy-side 409 guards against CLAIMED fulfilment without an assessment;
|
||||
* a skip claims the opposite, so refusing it here would only push people
|
||||
* back to deleting the reminder.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess, apiError, getClientIp } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { skipReminder } from "@/lib/measurement-reminders/satisfy";
|
||||
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) => {
|
||||
// MANAGE — the same level as satisfy/complete: deciding to let a cycle
|
||||
// go re-anchors a schedule this level already admits editing.
|
||||
const { user } = await requireRecordAuth("manage", "measurements");
|
||||
const { id } = await params;
|
||||
|
||||
// An appointment reminder is not addressable here. It belongs to a visit,
|
||||
// is managed through the visit routes, and sits in a different sharing
|
||||
// domain — so this family treats one as not found rather than acting on
|
||||
// it. Skipping an appointment would move a date the visit record still
|
||||
// believes it owns.
|
||||
const existing = await prisma.measurementReminder.findFirst({
|
||||
where: { id, deletedAt: null, origin: { not: "ENCOUNTER" } },
|
||||
});
|
||||
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 result = await skipReminder(prisma, existing, timezone, new Date());
|
||||
|
||||
const updated = await prisma.measurementReminder.findUniqueOrThrow({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
await auditLog("measurementReminder.skip", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { reminderId: id, skipped: result.skipped },
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "measurement-reminders.skip" },
|
||||
meta: { reminderId: id, skipped: result.skipped },
|
||||
});
|
||||
|
||||
return apiSuccess({
|
||||
skipped: result.skipped,
|
||||
reminder: toMeasurementReminderDto(updated),
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* v1.37.20 (#223) — push a Vorsorge reminder's current due date back to a
|
||||
* named calendar day.
|
||||
*
|
||||
* Body `{ until: "YYYY-MM-DD" }`, day precision on purpose: the server
|
||||
* resolves the day to the reminder's notifyHour in the profile timezone, so
|
||||
* no client-supplied instant ever anchors the cadence. Both `snoozedUntil`
|
||||
* and `nextDueAt` are set to the SAME resolved instant — every due-state
|
||||
* consumer (digest, dashboard card, cron, AI block) moves with it without
|
||||
* knowing snooze exists, and the cursor self-expires when the clock passes
|
||||
* it. The regular interval is untouched: `lastSatisfiedAt`,
|
||||
* `lastSkippedAt` and the anchor stay where they are, so after the snoozed
|
||||
* cycle the cadence resumes exactly as configured. Repeated snoozes: the
|
||||
* last one wins.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiSuccess,
|
||||
apiError,
|
||||
getClientIp,
|
||||
safeJson,
|
||||
returnAllZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { toMeasurementReminderDto } from "@/lib/measurement-reminders/dto";
|
||||
import { snoozeMeasurementReminderSchema } from "@/lib/validations/measurement-reminders";
|
||||
import { zonedWallClockToUtc } from "@/lib/tz/wall-clock";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
const DEFAULT_TIMEZONE = "Europe/Berlin";
|
||||
|
||||
/** Furthest a snooze may reach: five years from now. */
|
||||
const SNOOZE_CAP_MS = 5 * 365.25 * 24 * 60 * 60 * 1000;
|
||||
|
||||
export const POST = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
// MANAGE — the same level as satisfy/complete: pushing a due date back
|
||||
// re-anchors a schedule this level already admits editing.
|
||||
const { user } = await requireRecordAuth("manage", "measurements");
|
||||
const { id } = await params;
|
||||
|
||||
const body = await safeJson(request);
|
||||
if (body.error) return body.error;
|
||||
const parsed = snoozeMeasurementReminderSchema.safeParse(body.data);
|
||||
if (!parsed.success) {
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
|
||||
// An appointment reminder is not addressable here — see the sibling
|
||||
// routes; snoozing one would move a date the visit record still owns.
|
||||
const existing = await prisma.measurementReminder.findFirst({
|
||||
where: { id, deletedAt: null, origin: { not: "ENCOUNTER" } },
|
||||
});
|
||||
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;
|
||||
|
||||
// Resolve the named local day to its notifyHour instant in the profile
|
||||
// timezone (DST-settled at the target day, not at the request instant).
|
||||
const [year, month, day] = parsed.data.until.split("-").map(Number);
|
||||
const until = zonedWallClockToUtc(
|
||||
{ year, month, day, hour: existing.notifyHour, minute: 0, second: 0 },
|
||||
timezone,
|
||||
);
|
||||
|
||||
// Range gate: at least tomorrow (a snooze to today or the past is a
|
||||
// no-op wearing a confirmation), at most five years out.
|
||||
const now = new Date();
|
||||
const startOfTomorrow = zonedWallClockToUtc(
|
||||
(() => {
|
||||
const probe = new Date(now.getTime() + 24 * 60 * 60 * 1000);
|
||||
const parts = new Intl.DateTimeFormat("sv-SE", {
|
||||
timeZone: timezone,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
})
|
||||
.format(probe)
|
||||
.split("-")
|
||||
.map(Number);
|
||||
return {
|
||||
year: parts[0],
|
||||
month: parts[1],
|
||||
day: parts[2],
|
||||
hour: 0,
|
||||
minute: 0,
|
||||
second: 0,
|
||||
};
|
||||
})(),
|
||||
timezone,
|
||||
);
|
||||
if (until.getTime() < startOfTomorrow.getTime()) {
|
||||
return apiError("Snooze date must be at least tomorrow", 422);
|
||||
}
|
||||
if (until.getTime() > now.getTime() + SNOOZE_CAP_MS) {
|
||||
return apiError("Snooze date must be within five years", 422);
|
||||
}
|
||||
|
||||
// One write, both cursors, same instant. lastSatisfiedAt/lastSkippedAt
|
||||
// and the cadence fields are deliberately untouched.
|
||||
await prisma.measurementReminder.update({
|
||||
where: { id },
|
||||
data: { snoozedUntil: until, nextDueAt: until },
|
||||
});
|
||||
|
||||
const updated = await prisma.measurementReminder.findUniqueOrThrow({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
await auditLog("measurementReminder.snooze", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { reminderId: id, until: until.toISOString() },
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "measurement-reminders.snooze" },
|
||||
meta: { reminderId: id, until: until.toISOString() },
|
||||
});
|
||||
|
||||
return apiSuccess({ reminder: toMeasurementReminderDto(updated) });
|
||||
},
|
||||
);
|
||||
@@ -307,6 +307,9 @@ const REMINDER: MeasurementReminder = {
|
||||
location: null,
|
||||
nextDueAt: "2026-08-10T09:00:00.000Z",
|
||||
lastSatisfiedAt: null,
|
||||
snoozedUntil: null,
|
||||
lastSkippedAt: null,
|
||||
skipCount: 0,
|
||||
enabled: true,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
|
||||
@@ -231,7 +231,8 @@ export function VorsorgeSection({
|
||||
isError,
|
||||
refetch,
|
||||
} = useMeasurementReminders(enabled);
|
||||
const { create, update, remove, satisfy } = useMeasurementReminderMutations();
|
||||
const { create, update, remove, satisfy, skip, snooze } =
|
||||
useMeasurementReminderMutations();
|
||||
// v1.18.6 (MOD-03) — page view (cards/list) + manual order persist
|
||||
// client-side; the settings page writes them, the page reads them.
|
||||
const { prefs } = useModuleListPrefs("vorsorge");
|
||||
@@ -743,8 +744,16 @@ export function VorsorgeSection({
|
||||
onFileVisit={() => openVisitFor(reminder)}
|
||||
onRemove={() => remove.mutate(reminder.id)}
|
||||
onEdit={() => openEdit(reminder)}
|
||||
onSkip={() => skip.mutate(reminder.id)}
|
||||
onSnooze={(until) =>
|
||||
snooze.mutate({ id: reminder.id, until })
|
||||
}
|
||||
busy={
|
||||
satisfy.isPending || remove.isPending || update.isPending
|
||||
satisfy.isPending ||
|
||||
remove.isPending ||
|
||||
update.isPending ||
|
||||
skip.isPending ||
|
||||
snooze.isPending
|
||||
}
|
||||
/>
|
||||
</li>
|
||||
@@ -776,6 +785,18 @@ export function VorsorgeSection({
|
||||
* means due/overdue now, lower means time still remains in the window.
|
||||
* Returns null when there is no interval to measure against (RRULE / unset).
|
||||
*/
|
||||
/** v1.37.20 (#223) — the postpone sheet's quick-chip offsets, in days. */
|
||||
const SNOOZE_CHIP_DAYS = [7, 30, 90] as const;
|
||||
|
||||
/**
|
||||
* The local calendar day (ISO `yyyy-MM-dd`) an instant falls on in `tz`.
|
||||
* Snooze is day-precision by contract: the server resolves the day to the
|
||||
* reminder's notifyHour in the profile timezone.
|
||||
*/
|
||||
function isoDayInTz(atMs: number, tz: string): string {
|
||||
return new Date(atMs).toLocaleDateString("sv-SE", { timeZone: tz });
|
||||
}
|
||||
|
||||
function intervalProgress(
|
||||
reminder: MeasurementReminder,
|
||||
now: number,
|
||||
@@ -797,6 +818,8 @@ function VorsorgeCard({
|
||||
onFileVisit,
|
||||
onRemove,
|
||||
onEdit,
|
||||
onSkip,
|
||||
onSnooze,
|
||||
busy,
|
||||
}: {
|
||||
reminder: MeasurementReminder;
|
||||
@@ -807,6 +830,10 @@ function VorsorgeCard({
|
||||
onFileVisit: () => void;
|
||||
onRemove: () => void;
|
||||
onEdit: () => void;
|
||||
/** v1.37.20 (#223) — honest skip; the server restarts the interval. */
|
||||
onSkip: () => void;
|
||||
/** v1.37.20 (#223) — snooze to a calendar day (ISO YYYY-MM-DD). */
|
||||
onSnooze: (until: string) => void;
|
||||
busy: boolean;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
@@ -819,6 +846,11 @@ function VorsorgeCard({
|
||||
// zone from their profile.
|
||||
const displayTz = useDisplayTimezone();
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
// v1.37.20 (#223) — the postpone sheet (snooze quick-chips + date field,
|
||||
// with the honest-skip zone below) and the skip confirm inside it.
|
||||
const [postponeOpen, setPostponeOpen] = useState(false);
|
||||
const [snoozeDate, setSnoozeDate] = useState("");
|
||||
const [confirmSkip, setConfirmSkip] = useState(false);
|
||||
const due = relativeDueKey(reminder.nextDueAt, now, displayTz);
|
||||
const cadence =
|
||||
reminder.intervalDays != null
|
||||
@@ -897,6 +929,16 @@ function VorsorgeCard({
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
{t("measurementReminders.edit")}
|
||||
</DropdownMenuItem>
|
||||
{/* v1.37.20 (#223) — postpone lives in the kebab, never in the
|
||||
primary-action bar (v1.27.5 rule: the action button keeps one
|
||||
constant look in every state). */}
|
||||
<DropdownMenuItem
|
||||
disabled={busy}
|
||||
onSelect={() => setPostponeOpen(true)}
|
||||
>
|
||||
<CalendarClock className="mr-2 h-4 w-4" />
|
||||
{t("measurementReminders.postpone.menuItem")}
|
||||
</DropdownMenuItem>
|
||||
{/* v1.18.7 (Wave E) — jump to the measurements list pre-filtered to
|
||||
this reminder's type. Only for measurement-linked reminders; a
|
||||
free-text reminder has no readings to show. */}
|
||||
@@ -1007,6 +1049,162 @@ function VorsorgeCard({
|
||||
</AlertDialog>
|
||||
);
|
||||
|
||||
// v1.37.20 (#223) — resolved skip/snooze state, clock comparisons only
|
||||
// (the DTO comment states the contract: the server resolves, the client
|
||||
// compares). Meta lines are muted text — never a card colour change.
|
||||
const isSnoozed =
|
||||
reminder.snoozedUntil != null &&
|
||||
new Date(reminder.snoozedUntil).getTime() > now;
|
||||
const isSkippedCycle =
|
||||
reminder.lastSkippedAt != null &&
|
||||
(reminder.lastSatisfiedAt == null ||
|
||||
new Date(reminder.lastSkippedAt).getTime() >
|
||||
new Date(reminder.lastSatisfiedAt).getTime());
|
||||
|
||||
const closePostpone = () => {
|
||||
setPostponeOpen(false);
|
||||
setSnoozeDate("");
|
||||
setConfirmSkip(false);
|
||||
};
|
||||
const minSnoozeDate = isoDayInTz(now + DAY_MS, displayTz);
|
||||
|
||||
const postponeSheet = (
|
||||
<ResponsiveSheet
|
||||
open={postponeOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) closePostpone();
|
||||
}}
|
||||
title={t("measurementReminders.postpone.title")}
|
||||
description={t("measurementReminders.postpone.description")}
|
||||
footer={
|
||||
<>
|
||||
<Button type="button" variant="outline" onClick={closePostpone}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!snoozeDate || busy}
|
||||
onClick={() => {
|
||||
onSnooze(snoozeDate);
|
||||
closePostpone();
|
||||
}}
|
||||
>
|
||||
{t("measurementReminders.postpone.confirm")}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{/* Quick chips write the date field rather than firing directly, so
|
||||
the person always sees (and can adjust) what will be sent. */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{SNOOZE_CHIP_DAYS.map((days) => {
|
||||
const chipDate = isoDayInTz(now + days * DAY_MS, displayTz);
|
||||
return (
|
||||
<Button
|
||||
key={days}
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={snoozeDate === chipDate ? "secondary" : "outline"}
|
||||
onClick={() => setSnoozeDate(chipDate)}
|
||||
>
|
||||
{t("measurementReminders.postpone.chip", { days })}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`snooze-date-${reminder.id}`}>
|
||||
{t("measurementReminders.postpone.dateLabel")}
|
||||
</Label>
|
||||
<DateField
|
||||
id={`snooze-date-${reminder.id}`}
|
||||
value={snoozeDate}
|
||||
onChange={setSnoozeDate}
|
||||
min={minSnoozeDate}
|
||||
/>
|
||||
</div>
|
||||
{/* The honest-skip zone sits set apart below the snooze controls:
|
||||
a different decision (let this cycle go, restart the rhythm)
|
||||
with its own confirm step. */}
|
||||
<div className="space-y-2 border-t pt-4">
|
||||
<p className="text-sm font-medium">
|
||||
{t("measurementReminders.skip.heading")}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("measurementReminders.skip.explainer")}
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={busy}
|
||||
onClick={() => setConfirmSkip(true)}
|
||||
data-slot="vorsorge-skip"
|
||||
>
|
||||
{t("measurementReminders.skip.action")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ResponsiveSheet>
|
||||
);
|
||||
|
||||
const skipConfirmDialog = (
|
||||
<AlertDialog open={confirmSkip} onOpenChange={setConfirmSkip}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("measurementReminders.skip.confirmTitle")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("measurementReminders.skip.explainer")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => {
|
||||
onSkip();
|
||||
closePostpone();
|
||||
}}
|
||||
>
|
||||
{t("measurementReminders.skip.action")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
|
||||
// Muted meta lines shared by both branches: "pushed back until {date}" /
|
||||
// "skipped on {date}" (+ lifetime count once it says something). "Last
|
||||
// done" stays lastSatisfiedAt — a skip never reads as done.
|
||||
const skipSnoozeMeta =
|
||||
isSnoozed || isSkippedCycle ? (
|
||||
<div
|
||||
className="text-muted-foreground space-y-0.5 text-xs"
|
||||
data-slot="vorsorge-skip-snooze-meta"
|
||||
>
|
||||
{isSnoozed && reminder.snoozedUntil && (
|
||||
<p>
|
||||
{t("measurementReminders.snoozedUntil", {
|
||||
date: fmt.date(new Date(reminder.snoozedUntil)),
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
{isSkippedCycle && reminder.lastSkippedAt && (
|
||||
<p>
|
||||
{t("measurementReminders.skippedOn", {
|
||||
date: fmt.date(new Date(reminder.lastSkippedAt)),
|
||||
})}
|
||||
{reminder.skipCount > 1
|
||||
? ` · ${t("measurementReminders.skipCount", {
|
||||
count: reminder.skipCount,
|
||||
})}`
|
||||
: null}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
// v1.18.6 (MOD-03) — compact list row variant.
|
||||
if (view === "list") {
|
||||
return (
|
||||
@@ -1030,6 +1228,7 @@ function VorsorgeCard({
|
||||
<span>{categoryLabel}</span>
|
||||
{cadenceText ? <span>{cadenceText}</span> : null}
|
||||
</div>
|
||||
{skipSnoozeMeta}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{/* The same gate the cards branch applies through
|
||||
@@ -1078,6 +1277,8 @@ function VorsorgeCard({
|
||||
</CardContent>
|
||||
</Card>
|
||||
{deleteDialog}
|
||||
{postponeSheet}
|
||||
{skipConfirmDialog}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1156,6 +1357,7 @@ function VorsorgeCard({
|
||||
/>
|
||||
<CardContent className="flex h-full flex-col space-y-3.5">
|
||||
{nextLastSlot}
|
||||
{skipSnoozeMeta}
|
||||
|
||||
{/* v1.18.6 (MOD-06) — light gamification: progress through the
|
||||
current interval toward next-due, reusing the medication card's
|
||||
@@ -1208,6 +1410,8 @@ function VorsorgeCard({
|
||||
</Card>
|
||||
|
||||
{deleteDialog}
|
||||
{postponeSheet}
|
||||
{skipConfirmDialog}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -63,6 +63,15 @@ export interface MeasurementReminder {
|
||||
location: string | null;
|
||||
nextDueAt: string | null;
|
||||
lastSatisfiedAt: string | null;
|
||||
/**
|
||||
* v1.37.20 (#223) — resolved skip/snooze state. The client only compares
|
||||
* against the clock: snoozed = `snoozedUntil > now`; a skipped cycle =
|
||||
* `lastSkippedAt > lastSatisfiedAt` (or set with no satisfy yet). Cadence
|
||||
* is never recomputed here; `lastSatisfiedAt` stays the only "done".
|
||||
*/
|
||||
snoozedUntil: string | null;
|
||||
lastSkippedAt: string | null;
|
||||
skipCount: number;
|
||||
enabled: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
@@ -129,5 +138,27 @@ export function useMeasurementReminderMutations() {
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
return { create, update, remove, satisfy };
|
||||
// v1.37.20 (#223) — honest skip: the interval restarts from the skip
|
||||
// instant server-side; lastSatisfiedAt is never touched.
|
||||
const skip = useMutation({
|
||||
mutationKey: queryKeys.measurementReminderSkip(),
|
||||
mutationFn: (id: string) =>
|
||||
apiPost<{ skipped: boolean; reminder: MeasurementReminder }>(
|
||||
`${BASE}/${id}/skip`,
|
||||
),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
// v1.37.20 (#223) — snooze to a calendar day; the server resolves it to
|
||||
// the notifyHour in the profile timezone and moves nextDueAt with it.
|
||||
const snooze = useMutation({
|
||||
mutationKey: queryKeys.measurementReminderSnooze(),
|
||||
mutationFn: ({ id, until }: { id: string; until: string }) =>
|
||||
apiPost<{ reminder: MeasurementReminder }>(`${BASE}/${id}/snooze`, {
|
||||
until,
|
||||
}),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
return { create, update, remove, satisfy, skip, snooze };
|
||||
}
|
||||
@@ -341,7 +341,9 @@ export async function resolveClosableReminder(
|
||||
rrule: true,
|
||||
anchorDate: true,
|
||||
notifyHour: true,
|
||||
nextDueAt: true,
|
||||
lastSatisfiedAt: true,
|
||||
lastSkippedAt: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
@@ -349,11 +351,14 @@ export async function resolveClosableReminder(
|
||||
if (row.origin === "ENCOUNTER") return undefined;
|
||||
return {
|
||||
id: row.id,
|
||||
userId: row.userId,
|
||||
intervalDays: row.intervalDays,
|
||||
rrule: row.rrule,
|
||||
anchorDate: row.anchorDate,
|
||||
notifyHour: row.notifyHour,
|
||||
nextDueAt: row.nextDueAt,
|
||||
lastSatisfiedAt: row.lastSatisfiedAt,
|
||||
lastSkippedAt: row.lastSkippedAt,
|
||||
createdAt: row.createdAt,
|
||||
};
|
||||
}
|
||||
@@ -399,6 +404,7 @@ export async function closeCheckupForVisit(
|
||||
reminder,
|
||||
timezone,
|
||||
satisfiedAt,
|
||||
"encounter",
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -271,6 +271,9 @@ export async function runMeasurementReminderTick(
|
||||
reminder,
|
||||
timezone,
|
||||
satisfiedAt,
|
||||
// Which arm of `findSatisfyingEvent` matched: a typed reminder
|
||||
// resolves from a Measurement, a free-text one from a LabResult.
|
||||
reminder.measurementType !== null ? "auto_measurement" : "auto_lab",
|
||||
);
|
||||
if (result.satisfied) {
|
||||
summary.autoResolved += 1;
|
||||
@@ -491,6 +494,7 @@ export async function runReminderSatisfyForUser(
|
||||
reminder,
|
||||
timezone,
|
||||
satisfiedAt,
|
||||
reminder.measurementType !== null ? "auto_measurement" : "auto_lab",
|
||||
);
|
||||
if (result.satisfied) {
|
||||
summary.satisfied += 1;
|
||||
|
||||
@@ -684,6 +684,9 @@ describe("get_preventive_care", () => {
|
||||
location: null,
|
||||
nextDueAt: "2000-01-01T00:00:00.000Z",
|
||||
lastSatisfiedAt: null,
|
||||
snoozedUntil: null,
|
||||
lastSkippedAt: null,
|
||||
skipCount: 0,
|
||||
enabled: true,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
@@ -735,6 +738,9 @@ describe("get_preventive_care", () => {
|
||||
location: "Praxis <<<USER_TEXT_START>>>Dr. Example",
|
||||
nextDueAt: null,
|
||||
lastSatisfiedAt: null,
|
||||
snoozedUntil: null,
|
||||
lastSkippedAt: null,
|
||||
skipCount: 0,
|
||||
enabled: true,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
@@ -796,6 +802,9 @@ describe("get_preventive_care", () => {
|
||||
location: null,
|
||||
nextDueAt,
|
||||
lastSatisfiedAt: null,
|
||||
snoozedUntil: null,
|
||||
lastSkippedAt: null,
|
||||
skipCount: 0,
|
||||
enabled: true,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
|
||||
@@ -895,6 +895,13 @@ const getPreventiveCareOutput: z.ZodRawShape = {
|
||||
overdue: z.boolean(),
|
||||
location: z.string().nullable(),
|
||||
lastSatisfiedAt: z.string().nullable(),
|
||||
// v1.37.20 (#223) — resolved skip/snooze state. Without these the
|
||||
// model sees a due date jump with no cause and invents one.
|
||||
// `lastSatisfiedAt` stays the ONLY completed signal: a skip is
|
||||
// recorded as a skip, never as done.
|
||||
snoozedUntil: z.string().nullable(),
|
||||
lastSkippedAt: z.string().nullable(),
|
||||
skipCount: z.number(),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
@@ -1714,6 +1721,10 @@ export const MCP_TOOLS: McpToolDefinition[] = [
|
||||
: false,
|
||||
location: dto.location !== null ? fenceUserText(dto.location) : null,
|
||||
lastSatisfiedAt: dto.lastSatisfiedAt,
|
||||
// Timestamps + a counter, no free text — nothing to fence here.
|
||||
snoozedUntil: dto.snoozedUntil,
|
||||
lastSkippedAt: dto.lastSkippedAt,
|
||||
skipCount: dto.skipCount,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ const base = {
|
||||
measurementType: null,
|
||||
anchorDate: null,
|
||||
lastSatisfiedAt: null,
|
||||
lastSkippedAt: null,
|
||||
createdAt: new Date("2026-06-01T00:00:00Z"),
|
||||
} as const;
|
||||
|
||||
@@ -46,6 +47,63 @@ describe("satisfactionFloor", () => {
|
||||
expect(satisfactionFloor({ ...base, anchorDate: anchor })).toEqual(anchor);
|
||||
expect(satisfactionFloor(base)).toEqual(base.createdAt);
|
||||
});
|
||||
|
||||
// v1.37.20 (#223) — the skip arm of the floor. Watched red: with the old
|
||||
// `lastSatisfiedAt ?? anchorDate ?? createdAt` floor both assertions below
|
||||
// failed (the fresh skip was invisible and a backdated reading resolved
|
||||
// the cycle the user had just skipped).
|
||||
it("takes the LATER of lastSatisfiedAt and lastSkippedAt", () => {
|
||||
const satisfied = new Date("2026-06-10T00:00:00Z");
|
||||
const skipped = new Date("2026-06-20T00:00:00Z");
|
||||
expect(
|
||||
satisfactionFloor({
|
||||
...base,
|
||||
lastSatisfiedAt: satisfied,
|
||||
lastSkippedAt: skipped,
|
||||
}),
|
||||
).toEqual(skipped);
|
||||
// And symmetric — a satisfy after the skip wins again.
|
||||
expect(
|
||||
satisfactionFloor({
|
||||
...base,
|
||||
lastSatisfiedAt: skipped,
|
||||
lastSkippedAt: satisfied,
|
||||
}),
|
||||
).toEqual(skipped);
|
||||
});
|
||||
|
||||
it("uses a lone lastSkippedAt as the floor before anchor/created fall backs", () => {
|
||||
const skipped = new Date("2026-06-20T00:00:00Z");
|
||||
const anchor = new Date("2026-06-05T00:00:00Z");
|
||||
expect(
|
||||
satisfactionFloor({
|
||||
...base,
|
||||
lastSkippedAt: skipped,
|
||||
anchorDate: anchor,
|
||||
}),
|
||||
).toEqual(skipped);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findSatisfyingEvent after a skip", () => {
|
||||
it("does not resolve the fresh skip cycle from a reading older than the skip", async () => {
|
||||
// A backdated sync (an old reading arriving late) predates the skip the
|
||||
// user just made. The floor must sit AT the skip, so the query asks for
|
||||
// events strictly after it — the stale reading cannot match.
|
||||
const skipped = new Date("2026-06-20T00:00:00Z");
|
||||
const prisma = makePrisma({ measurement: null });
|
||||
|
||||
await findSatisfyingEvent(prisma as never, "u1", {
|
||||
...base,
|
||||
measurementType: "WEIGHT",
|
||||
lastSkippedAt: skipped,
|
||||
});
|
||||
|
||||
const where = prisma.measurement.findFirst.mock.calls[0]![0].where as {
|
||||
measuredAt: { gt: Date };
|
||||
};
|
||||
expect(where.measuredAt.gt).toEqual(skipped);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findSatisfyingEvent", () => {
|
||||
|
||||
@@ -3,10 +3,17 @@
|
||||
* re-anchored reschedule. The cron, the manual satisfy route, and the
|
||||
* eventful worker all route through it, so these tests pin the invariants
|
||||
* every caller relies on.
|
||||
*
|
||||
* v1.37.20 (#223) — extended for the skip/snooze feature: the ledger append
|
||||
* (iOS #68), the skip-aware satisfy anchor, snooze clearing, and the
|
||||
* `skipReminder` primitive with its hard invariant (`lastSatisfiedAt` is
|
||||
* never touched by a skip). Watched red: the pre-#223 suite went red on the
|
||||
* widened `SatisfiableReminder` shape and the new `source` argument before
|
||||
* this file was updated — proof the type system walks every caller.
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { satisfyReminder } from "../satisfy";
|
||||
import { satisfyReminder, skipReminder } from "../satisfy";
|
||||
|
||||
const TZ = "Europe/Berlin";
|
||||
|
||||
@@ -15,12 +22,13 @@ function makePrisma(updateManyCount = 1) {
|
||||
where: Record<string, unknown>;
|
||||
data: Record<string, unknown>;
|
||||
}> = [];
|
||||
const events: Array<Record<string, unknown>> = [];
|
||||
const prisma = {
|
||||
measurementReminder: {
|
||||
// v1.18.1 — `satisfyReminder` now writes via a conditional
|
||||
// `updateMany` so the forward-only guard re-asserts at the DB row
|
||||
// (close the cron-vs-worker TOCTOU). `updateManyCount` simulates a
|
||||
// racing writer having already advanced the row (count === 0).
|
||||
// v1.18.1 — `satisfyReminder` writes via a conditional `updateMany` so
|
||||
// the forward-only guard re-asserts at the DB row (close the
|
||||
// cron-vs-worker TOCTOU). `updateManyCount` simulates a racing writer
|
||||
// having already advanced the row (count === 0).
|
||||
updateMany: vi.fn(
|
||||
async ({
|
||||
where,
|
||||
@@ -34,8 +42,14 @@ function makePrisma(updateManyCount = 1) {
|
||||
},
|
||||
),
|
||||
},
|
||||
measurementReminderEvent: {
|
||||
create: vi.fn(async ({ data }: { data: Record<string, unknown> }) => {
|
||||
events.push(data);
|
||||
return { id: "evt", ...data };
|
||||
}),
|
||||
},
|
||||
};
|
||||
return { prisma, updates };
|
||||
return { prisma, updates, events };
|
||||
}
|
||||
|
||||
function reminder(
|
||||
@@ -43,11 +57,14 @@ function reminder(
|
||||
) {
|
||||
return {
|
||||
id: "r1",
|
||||
userId: "u1",
|
||||
intervalDays: 7,
|
||||
rrule: null,
|
||||
anchorDate: null,
|
||||
notifyHour: 9,
|
||||
nextDueAt: null,
|
||||
lastSatisfiedAt: null,
|
||||
lastSkippedAt: null,
|
||||
createdAt: new Date("2026-06-01T00:00:00Z"),
|
||||
...overrides,
|
||||
};
|
||||
@@ -58,7 +75,13 @@ describe("satisfyReminder", () => {
|
||||
const { prisma, updates } = makePrisma();
|
||||
const at = new Date("2026-06-14T18:00:00Z");
|
||||
|
||||
const result = await satisfyReminder(prisma as never, reminder(), TZ, at);
|
||||
const result = await satisfyReminder(
|
||||
prisma as never,
|
||||
reminder(),
|
||||
TZ,
|
||||
at,
|
||||
"manual",
|
||||
);
|
||||
|
||||
expect(result.satisfied).toBe(true);
|
||||
expect(result.nextDueAt).toBeInstanceOf(Date);
|
||||
@@ -74,7 +97,7 @@ describe("satisfyReminder", () => {
|
||||
const { prisma } = makePrisma();
|
||||
const at = new Date("2026-06-14T18:00:00Z");
|
||||
|
||||
await satisfyReminder(prisma as never, reminder(), TZ, at);
|
||||
await satisfyReminder(prisma as never, reminder(), TZ, at, "manual");
|
||||
|
||||
const call = prisma.measurementReminder.updateMany.mock
|
||||
.calls[0][0] as unknown as {
|
||||
@@ -88,15 +111,23 @@ describe("satisfyReminder", () => {
|
||||
});
|
||||
|
||||
it("treats a racing-writer updateMany count of 0 as a forward-only no-op", async () => {
|
||||
const { prisma } = makePrisma(0);
|
||||
const { prisma, events } = makePrisma(0);
|
||||
const at = new Date("2026-06-14T18:00:00Z");
|
||||
|
||||
const result = await satisfyReminder(prisma as never, reminder(), TZ, at);
|
||||
const result = await satisfyReminder(
|
||||
prisma as never,
|
||||
reminder(),
|
||||
TZ,
|
||||
at,
|
||||
"manual",
|
||||
);
|
||||
|
||||
// The in-memory guard passed (lastSatisfiedAt null) but the DB write
|
||||
// matched no row — a concurrent satisfy already advanced it. No-op.
|
||||
// matched no row — a concurrent satisfy already advanced it. No-op, and
|
||||
// crucially NO ledger row: an unapplied satisfy must leave no history.
|
||||
expect(result.satisfied).toBe(false);
|
||||
expect(result.nextDueAt).toBeNull();
|
||||
expect(events).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("advances when the event is strictly after the existing lastSatisfiedAt", async () => {
|
||||
@@ -109,6 +140,7 @@ describe("satisfyReminder", () => {
|
||||
reminder({ lastSatisfiedAt: prev }),
|
||||
TZ,
|
||||
at,
|
||||
"manual",
|
||||
);
|
||||
|
||||
expect(result.satisfied).toBe(true);
|
||||
@@ -125,6 +157,7 @@ describe("satisfyReminder", () => {
|
||||
reminder({ lastSatisfiedAt: prev }),
|
||||
TZ,
|
||||
stale,
|
||||
"manual",
|
||||
);
|
||||
|
||||
expect(result.satisfied).toBe(false);
|
||||
@@ -142,9 +175,198 @@ describe("satisfyReminder", () => {
|
||||
reminder({ lastSatisfiedAt: same }),
|
||||
TZ,
|
||||
same,
|
||||
"manual",
|
||||
);
|
||||
|
||||
expect(result.satisfied).toBe(false);
|
||||
expect(prisma.measurementReminder.updateMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("clears the snooze cursor on satisfy", async () => {
|
||||
const { prisma, updates } = makePrisma();
|
||||
const at = new Date("2026-06-14T18:00:00Z");
|
||||
|
||||
await satisfyReminder(prisma as never, reminder(), TZ, at, "manual");
|
||||
|
||||
expect(updates[0].data.snoozedUntil).toBeNull();
|
||||
});
|
||||
|
||||
it("appends a SATISFIED ledger row with write-time onTime (on time)", async () => {
|
||||
const { prisma, events } = makePrisma();
|
||||
const due = new Date("2026-06-15T07:00:00Z");
|
||||
const at = new Date("2026-06-14T18:00:00Z"); // before due → on time
|
||||
|
||||
await satisfyReminder(
|
||||
prisma as never,
|
||||
reminder({ nextDueAt: due }),
|
||||
TZ,
|
||||
at,
|
||||
"auto_measurement",
|
||||
);
|
||||
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0]).toMatchObject({
|
||||
userId: "u1",
|
||||
reminderId: "r1",
|
||||
kind: "SATISFIED",
|
||||
occurredAt: at,
|
||||
onTime: true,
|
||||
source: "auto_measurement",
|
||||
});
|
||||
});
|
||||
|
||||
it("derives onTime=false when the event lands after the pre-event due instant", async () => {
|
||||
const { prisma, events } = makePrisma();
|
||||
const due = new Date("2026-06-10T07:00:00Z");
|
||||
const late = new Date("2026-06-14T18:00:00Z");
|
||||
|
||||
await satisfyReminder(
|
||||
prisma as never,
|
||||
reminder({ nextDueAt: due }),
|
||||
TZ,
|
||||
late,
|
||||
"manual",
|
||||
);
|
||||
|
||||
expect(events[0].onTime).toBe(false);
|
||||
});
|
||||
|
||||
it("never lets a backdated satisfy re-anchor behind a fresh skip (max anchor)", async () => {
|
||||
const { prisma, updates } = makePrisma();
|
||||
const skippedAt = new Date("2026-06-20T10:00:00Z");
|
||||
const backdated = new Date("2026-06-14T18:00:00Z"); // real event, older sync
|
||||
|
||||
const result = await satisfyReminder(
|
||||
prisma as never,
|
||||
reminder({ lastSkippedAt: skippedAt }),
|
||||
TZ,
|
||||
backdated,
|
||||
"auto_measurement",
|
||||
);
|
||||
|
||||
// The event is honestly recorded as lastSatisfiedAt …
|
||||
expect(result.satisfied).toBe(true);
|
||||
expect(updates[0].data.lastSatisfiedAt).toEqual(backdated);
|
||||
// … but the reschedule anchors on the LATER skip: next due lands a full
|
||||
// interval after the skip decision, never behind it.
|
||||
expect((updates[0].data.nextDueAt as Date).getTime()).toBeGreaterThan(
|
||||
skippedAt.getTime(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("skipReminder", () => {
|
||||
it("stamps lastSkippedAt, increments skipCount, clears snooze — and never touches lastSatisfiedAt", async () => {
|
||||
const { prisma, updates } = makePrisma();
|
||||
const at = new Date("2026-06-14T18:00:00Z");
|
||||
|
||||
const result = await skipReminder(prisma as never, reminder(), TZ, at);
|
||||
|
||||
expect(result.skipped).toBe(true);
|
||||
expect(updates).toHaveLength(1);
|
||||
expect(updates[0].data.lastSkippedAt).toEqual(at);
|
||||
expect(updates[0].data.skipCount).toEqual({ increment: 1 });
|
||||
expect(updates[0].data.snoozedUntil).toBeNull();
|
||||
// THE hard invariant of the whole feature: a skip is not a completion.
|
||||
expect("lastSatisfiedAt" in updates[0].data).toBe(false);
|
||||
});
|
||||
|
||||
it("restarts a rolling interval from the skip instant", async () => {
|
||||
const { prisma, updates } = makePrisma();
|
||||
const at = new Date("2026-06-14T18:00:00Z");
|
||||
|
||||
const result = await skipReminder(prisma as never, reminder(), TZ, at);
|
||||
|
||||
// +7d at the 09:00 notify hour, strictly after the skip.
|
||||
expect(result.nextDueAt).toBeInstanceOf(Date);
|
||||
expect((updates[0].data.nextDueAt as Date).getTime()).toBeGreaterThan(
|
||||
at.getTime() + 6 * 24 * 60 * 60 * 1000,
|
||||
);
|
||||
});
|
||||
|
||||
it("walks an rrule cadence to the next occurrence strictly after the skip", async () => {
|
||||
const { prisma, updates } = makePrisma();
|
||||
const at = new Date("2026-06-14T18:00:00Z");
|
||||
|
||||
const result = await skipReminder(
|
||||
prisma as never,
|
||||
reminder({ intervalDays: null, rrule: "FREQ=YEARLY" }),
|
||||
TZ,
|
||||
at,
|
||||
);
|
||||
|
||||
expect(result.skipped).toBe(true);
|
||||
expect((updates[0].data.nextDueAt as Date).getTime()).toBeGreaterThan(
|
||||
at.getTime(),
|
||||
);
|
||||
});
|
||||
|
||||
it("is a forward-only no-op at or behind the last satisfy OR the last skip", async () => {
|
||||
const { prisma, events } = makePrisma();
|
||||
const cursor = new Date("2026-06-17T08:00:00Z");
|
||||
const stale = new Date("2026-06-10T08:00:00Z");
|
||||
|
||||
const bySatisfy = await skipReminder(
|
||||
prisma as never,
|
||||
reminder({ lastSatisfiedAt: cursor }),
|
||||
TZ,
|
||||
stale,
|
||||
);
|
||||
const bySkip = await skipReminder(
|
||||
prisma as never,
|
||||
reminder({ lastSkippedAt: cursor }),
|
||||
TZ,
|
||||
stale,
|
||||
);
|
||||
|
||||
expect(bySatisfy.skipped).toBe(false);
|
||||
expect(bySkip.skipped).toBe(false);
|
||||
expect(prisma.measurementReminder.updateMany).not.toHaveBeenCalled();
|
||||
expect(events).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("re-asserts forward-only against BOTH cursors in the conditional updateMany", async () => {
|
||||
const { prisma } = makePrisma();
|
||||
const at = new Date("2026-06-14T18:00:00Z");
|
||||
|
||||
await skipReminder(prisma as never, reminder(), TZ, at);
|
||||
|
||||
const call = prisma.measurementReminder.updateMany.mock
|
||||
.calls[0][0] as unknown as {
|
||||
where: { id: string; AND: unknown[] };
|
||||
};
|
||||
expect(call.where.id).toBe("r1");
|
||||
expect(call.where.AND).toEqual([
|
||||
{ OR: [{ lastSatisfiedAt: null }, { lastSatisfiedAt: { lt: at } }] },
|
||||
{ OR: [{ lastSkippedAt: null }, { lastSkippedAt: { lt: at } }] },
|
||||
]);
|
||||
});
|
||||
|
||||
it("treats a racing-writer updateMany count of 0 as a no-op with no ledger row", async () => {
|
||||
const { prisma, events } = makePrisma(0);
|
||||
const at = new Date("2026-06-14T18:00:00Z");
|
||||
|
||||
const result = await skipReminder(prisma as never, reminder(), TZ, at);
|
||||
|
||||
expect(result.skipped).toBe(false);
|
||||
expect(events).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("appends a SKIPPED ledger row with source 'skip' and write-time onTime", async () => {
|
||||
const { prisma, events } = makePrisma();
|
||||
const due = new Date("2026-06-10T07:00:00Z");
|
||||
const at = new Date("2026-06-14T18:00:00Z"); // skipping while overdue
|
||||
|
||||
await skipReminder(prisma as never, reminder({ nextDueAt: due }), TZ, at);
|
||||
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0]).toMatchObject({
|
||||
userId: "u1",
|
||||
reminderId: "r1",
|
||||
kind: "SKIPPED",
|
||||
occurredAt: at,
|
||||
onTime: false,
|
||||
source: "skip",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -37,6 +37,16 @@ export interface MeasurementReminderDtoShape {
|
||||
location: string | null;
|
||||
nextDueAt: string | null;
|
||||
lastSatisfiedAt: string | null;
|
||||
/**
|
||||
* v1.37.20 (#223) — the three skip/snooze fields are resolved timestamps
|
||||
* plus a counter; clients only ever compare against the clock (snoozed =
|
||||
* `snoozedUntil > now`; skipped cycle = `lastSkippedAt > lastSatisfiedAt`)
|
||||
* and never recompute cadence. `lastSatisfiedAt` stays the ONLY completed
|
||||
* signal — skip and snooze never move it.
|
||||
*/
|
||||
snoozedUntil: string | null;
|
||||
lastSkippedAt: string | null;
|
||||
skipCount: number;
|
||||
enabled: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
@@ -85,6 +95,9 @@ export function toMeasurementReminderDto(
|
||||
lastSatisfiedAt: row.lastSatisfiedAt
|
||||
? row.lastSatisfiedAt.toISOString()
|
||||
: null,
|
||||
snoozedUntil: row.snoozedUntil ? row.snoozedUntil.toISOString() : null,
|
||||
lastSkippedAt: row.lastSkippedAt ? row.lastSkippedAt.toISOString() : null,
|
||||
skipCount: row.skipCount,
|
||||
enabled: row.enabled,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
|
||||
@@ -26,6 +26,13 @@ export interface ResolvableReminder {
|
||||
measurementType: import("@/generated/prisma/client").MeasurementType | null;
|
||||
anchorDate: Date | null;
|
||||
lastSatisfiedAt: Date | null;
|
||||
/**
|
||||
* v1.37.20 (#223) — the last honest skip. Part of the floor: a reading
|
||||
* OLDER than a fresh skip must not auto-resolve the cycle the user just
|
||||
* skipped. Required (not optional) so no call site can silently feed a
|
||||
* pre-skip shape and reopen the gap.
|
||||
*/
|
||||
lastSkippedAt: Date | null;
|
||||
createdAt: Date;
|
||||
/**
|
||||
* Optional so the many callers that construct this shape by hand keep
|
||||
@@ -36,12 +43,26 @@ export interface ResolvableReminder {
|
||||
}
|
||||
|
||||
/**
|
||||
* The floor a satisfying event must land strictly after: the last satisfy,
|
||||
* else the anchor, else the create instant. A reading inside the current
|
||||
* due cycle means the user already measured.
|
||||
* The floor a satisfying event must land strictly after: the later of the
|
||||
* last satisfy and the last skip, else the anchor, else the create instant.
|
||||
* A reading inside the current due cycle means the user already measured.
|
||||
*
|
||||
* v1.37.20 (#223) — the skip arm: skipping restarts the cycle at the skip
|
||||
* instant, so a reading that predates the skip belongs to the cycle the
|
||||
* user deliberately let go and must not resolve the fresh one. Without it
|
||||
* a backdated sync would silently undo the skip decision.
|
||||
*/
|
||||
export function satisfactionFloor(reminder: ResolvableReminder): Date {
|
||||
return reminder.lastSatisfiedAt ?? reminder.anchorDate ?? reminder.createdAt;
|
||||
const cursor =
|
||||
reminder.lastSatisfiedAt !== null || reminder.lastSkippedAt !== null
|
||||
? new Date(
|
||||
Math.max(
|
||||
reminder.lastSatisfiedAt?.getTime() ?? 0,
|
||||
reminder.lastSkippedAt?.getTime() ?? 0,
|
||||
),
|
||||
)
|
||||
: null;
|
||||
return cursor ?? reminder.anchorDate ?? reminder.createdAt;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -8,21 +8,30 @@
|
||||
* reschedule logic — one place stamps `lastSatisfiedAt` and recomputes the
|
||||
* server-authoritative `nextDueAt`.
|
||||
*
|
||||
* Two invariants the primitive owns:
|
||||
* Invariants the primitive owns:
|
||||
*
|
||||
* 1. **Forward-only.** `lastSatisfiedAt` only ever moves forward. A
|
||||
* cron poll and an ingest enqueue can both fire for the same reading;
|
||||
* the second is a no-op (returns `false`) rather than re-stamping an
|
||||
* older instant. This is what makes the cron a safe idempotent
|
||||
* safety-net behind the eventful hook.
|
||||
* 2. **Re-anchored reschedule.** `nextDueAt` is recomputed from the
|
||||
* satisfy instant via the canonical recurrence engine
|
||||
* (`computeReminderNextDueAt`), so a freshly-satisfied reminder rolls
|
||||
* strictly past the current due cycle and the self-tracker never gets
|
||||
* nagged.
|
||||
* 2. **Re-anchored reschedule.** `nextDueAt` is recomputed via the
|
||||
* canonical recurrence engine (`computeReminderNextDueAt`) from
|
||||
* `max(satisfiedAt, lastSkippedAt)` — the satisfy instant, unless the
|
||||
* user has since skipped, in which case a BACKDATED real result is
|
||||
* recorded as `lastSatisfiedAt` but never drags the due date back
|
||||
* behind the skip decision (v1.37.20, #223).
|
||||
* 3. **Ledger append.** A successful satisfy appends one
|
||||
* `MeasurementReminderEvent` row (iOS #68) with `onTime` derived at
|
||||
* write time against the pre-event `nextDueAt` — the only moment that
|
||||
* value still exists.
|
||||
* 4. **Snooze clearing.** A satisfy recomputes `nextDueAt`, so it nulls
|
||||
* `snoozedUntil` — the snooze cursor never outlives the cycle it
|
||||
* pushed back.
|
||||
*
|
||||
* Pure-ish: it reads the row fields it is handed and issues one Prisma
|
||||
* update. The caller supplies the reminder row + the user's timezone.
|
||||
* Pure-ish: it reads the row fields it is handed and issues one conditional
|
||||
* update plus one ledger insert. The caller supplies the reminder row, the
|
||||
* user's timezone, and which engine path it is (`source`).
|
||||
*/
|
||||
import type { PrismaClient } from "@/generated/prisma/client";
|
||||
import {
|
||||
@@ -30,17 +39,43 @@ import {
|
||||
type ReminderScheduleInput,
|
||||
} from "@/lib/measurement-reminders/scheduling";
|
||||
|
||||
/**
|
||||
* Which engine path produced a ledger event. Closed set — the ledger's
|
||||
* `source` column is asserted here, not free text.
|
||||
*
|
||||
* Declared as a runtime tuple as well as a type (v1.37.20, #223 / iOS #68):
|
||||
* the backup payload schema validates the `source` a restore is handed
|
||||
* against this exact set, and a Zod enum needs the values at runtime. One
|
||||
* declaration, so the validator cannot drift from the engine.
|
||||
*/
|
||||
export const REMINDER_EVENT_SOURCES = [
|
||||
"manual",
|
||||
"auto_measurement",
|
||||
"auto_lab",
|
||||
"telegram",
|
||||
"vaccination",
|
||||
"encounter",
|
||||
"skip",
|
||||
] as const;
|
||||
|
||||
export type ReminderEventSource = (typeof REMINDER_EVENT_SOURCES)[number];
|
||||
|
||||
/**
|
||||
* The reminder fields `satisfyReminder` needs. A subset of the Prisma row
|
||||
* so callers (and tests) can construct it without the full model.
|
||||
* `userId` and the pre-event `nextDueAt` feed the ledger append;
|
||||
* `lastSkippedAt` feeds the skip-aware re-anchor.
|
||||
*/
|
||||
export interface SatisfiableReminder {
|
||||
id: string;
|
||||
userId: string;
|
||||
intervalDays: number | null;
|
||||
rrule: string | null;
|
||||
anchorDate: Date | null;
|
||||
notifyHour: number;
|
||||
nextDueAt: Date | null;
|
||||
lastSatisfiedAt: Date | null;
|
||||
lastSkippedAt: Date | null;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
@@ -53,20 +88,40 @@ export interface SatisfyResult {
|
||||
nextDueAt: Date | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* `onTime` is derived at write time, against the reminder's `nextDueAt` as
|
||||
* it stood BEFORE this event re-anchored it: the event landed at or before
|
||||
* the due instant, or the cadence had no computable due (a one-shot whose
|
||||
* slot already fired counts as late only while a due instant exists to be
|
||||
* late against). After the write the pre-event value is gone, which is why
|
||||
* clients consume this resolved boolean and never re-derive it.
|
||||
*/
|
||||
function deriveOnTime(preEventNextDueAt: Date | null, occurredAt: Date) {
|
||||
return (
|
||||
preEventNextDueAt === null ||
|
||||
occurredAt.getTime() <= preEventNextDueAt.getTime()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a reminder's cadence satisfied at `satisfiedAt` and reschedule.
|
||||
*
|
||||
* Forward-only: if `satisfiedAt` is not strictly after the existing
|
||||
* `lastSatisfiedAt`, nothing is written and `{ satisfied: false }` is
|
||||
* returned. Otherwise stamps `lastSatisfiedAt = satisfiedAt`, recomputes
|
||||
* `nextDueAt` from the satisfy instant, persists both, and returns
|
||||
* `nextDueAt` anchored at `max(satisfiedAt, lastSkippedAt)`, clears any
|
||||
* snooze, appends the SATISFIED ledger row, and returns
|
||||
* `{ satisfied: true, nextDueAt }`.
|
||||
*
|
||||
* HARD INVARIANT (the mirror of the skip primitive's): only a satisfy ever
|
||||
* writes `lastSatisfiedAt`. Skip and snooze never touch it.
|
||||
*/
|
||||
export async function satisfyReminder(
|
||||
prisma: PrismaClient,
|
||||
reminder: SatisfiableReminder,
|
||||
timezone: string,
|
||||
satisfiedAt: Date,
|
||||
source: Exclude<ReminderEventSource, "skip">,
|
||||
): Promise<SatisfyResult> {
|
||||
// Forward-only guard. A null `lastSatisfiedAt` always advances. Equal
|
||||
// instants are a no-op so a cron poll behind an already-applied ingest
|
||||
@@ -78,19 +133,26 @@ export async function satisfyReminder(
|
||||
return { satisfied: false, nextDueAt: null };
|
||||
}
|
||||
|
||||
// v1.37.20 (#223) — skip-aware anchor: a backdated real result (an old
|
||||
// reading synced late, a corrected visit date) is honestly recorded as
|
||||
// `lastSatisfiedAt`, but the NEXT due date never lands behind a skip the
|
||||
// user made after that event. Without the max() a fresh skip's re-anchor
|
||||
// would be silently undone by any older reading arriving afterwards.
|
||||
const anchorAt =
|
||||
reminder.lastSkippedAt !== null &&
|
||||
reminder.lastSkippedAt.getTime() > satisfiedAt.getTime()
|
||||
? reminder.lastSkippedAt
|
||||
: satisfiedAt;
|
||||
|
||||
const scheduleInput: ReminderScheduleInput = {
|
||||
intervalDays: reminder.intervalDays,
|
||||
rrule: reminder.rrule,
|
||||
anchorDate: reminder.anchorDate,
|
||||
notifyHour: reminder.notifyHour,
|
||||
lastSatisfiedAt: satisfiedAt,
|
||||
lastSatisfiedAt: anchorAt,
|
||||
createdAt: reminder.createdAt,
|
||||
};
|
||||
const nextDueAt = computeReminderNextDueAt(
|
||||
scheduleInput,
|
||||
timezone,
|
||||
satisfiedAt,
|
||||
);
|
||||
const nextDueAt = computeReminderNextDueAt(scheduleInput, timezone, anchorAt);
|
||||
|
||||
// v1.18.1 — close the forward-only TOCTOU: the in-memory guard above can
|
||||
// pass concurrently in the cron poll AND the eventful worker for the same
|
||||
@@ -104,11 +166,123 @@ export async function satisfyReminder(
|
||||
id: reminder.id,
|
||||
OR: [{ lastSatisfiedAt: null }, { lastSatisfiedAt: { lt: satisfiedAt } }],
|
||||
},
|
||||
data: { lastSatisfiedAt: satisfiedAt, nextDueAt },
|
||||
// A recompute of `nextDueAt` clears the snooze cursor — the real event
|
||||
// ended the cycle the snooze was pushing back.
|
||||
data: { lastSatisfiedAt: satisfiedAt, nextDueAt, snoozedUntil: null },
|
||||
});
|
||||
if (result.count === 0) {
|
||||
return { satisfied: false, nextDueAt: null };
|
||||
}
|
||||
|
||||
// Ledger append (iOS #68): exactly one row per applied satisfy, from
|
||||
// every path, because every path is this primitive. Written after the
|
||||
// conditional update so a forward-only no-op leaves no row.
|
||||
await prisma.measurementReminderEvent.create({
|
||||
data: {
|
||||
userId: reminder.userId,
|
||||
reminderId: reminder.id,
|
||||
kind: "SATISFIED",
|
||||
occurredAt: satisfiedAt,
|
||||
onTime: deriveOnTime(reminder.nextDueAt, satisfiedAt),
|
||||
source,
|
||||
},
|
||||
});
|
||||
|
||||
return { satisfied: true, nextDueAt };
|
||||
}
|
||||
|
||||
export interface SkipResult {
|
||||
/** True when the skip applied; false when it was a forward-only no-op
|
||||
* (the row already advanced past the skip instant). */
|
||||
skipped: boolean;
|
||||
/** The recomputed next-due instant when skipped, else `null`. */
|
||||
nextDueAt: Date | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.37.20 (#223) — honestly skip the current due cycle at `skippedAt`
|
||||
* (always the server clock; the route takes no body).
|
||||
*
|
||||
* The interval restarts from the skip instant: `nextDueAt` is recomputed
|
||||
* with the skip standing in as the rolling anchor (an rrule cadence walks
|
||||
* to its next occurrence strictly after the skip). `lastSkippedAt` is
|
||||
* stamped, `skipCount` incremented, any snooze cleared.
|
||||
*
|
||||
* HARD INVARIANT: `lastSatisfiedAt` is NEVER touched here. A skip is not a
|
||||
* completion — the ledger records it as `SKIPPED` and every surface that
|
||||
* shows "last done" keeps showing the last real result.
|
||||
*
|
||||
* Forward-only against BOTH cursors: a skip at/behind the last satisfy or
|
||||
* the last skip is a no-op, enforced in the conditional `updateMany` (the
|
||||
* same TOCTOU pattern `satisfyReminder` uses).
|
||||
*/
|
||||
export async function skipReminder(
|
||||
prisma: PrismaClient,
|
||||
reminder: SatisfiableReminder,
|
||||
timezone: string,
|
||||
skippedAt: Date,
|
||||
): Promise<SkipResult> {
|
||||
const floor = Math.max(
|
||||
reminder.lastSatisfiedAt?.getTime() ?? 0,
|
||||
reminder.lastSkippedAt?.getTime() ?? 0,
|
||||
);
|
||||
if (floor > 0 && skippedAt.getTime() <= floor) {
|
||||
return { skipped: false, nextDueAt: null };
|
||||
}
|
||||
|
||||
// The skip stands in as the rolling anchor: skip + interval at the notify
|
||||
// hour, or the next rrule occurrence strictly after the skip.
|
||||
const scheduleInput: ReminderScheduleInput = {
|
||||
intervalDays: reminder.intervalDays,
|
||||
rrule: reminder.rrule,
|
||||
anchorDate: reminder.anchorDate,
|
||||
notifyHour: reminder.notifyHour,
|
||||
lastSatisfiedAt: skippedAt,
|
||||
createdAt: reminder.createdAt,
|
||||
};
|
||||
const nextDueAt = computeReminderNextDueAt(
|
||||
scheduleInput,
|
||||
timezone,
|
||||
skippedAt,
|
||||
);
|
||||
|
||||
const result = await prisma.measurementReminder.updateMany({
|
||||
where: {
|
||||
id: reminder.id,
|
||||
AND: [
|
||||
{
|
||||
OR: [
|
||||
{ lastSatisfiedAt: null },
|
||||
{ lastSatisfiedAt: { lt: skippedAt } },
|
||||
],
|
||||
},
|
||||
{
|
||||
OR: [{ lastSkippedAt: null }, { lastSkippedAt: { lt: skippedAt } }],
|
||||
},
|
||||
],
|
||||
},
|
||||
// `lastSatisfiedAt` is conspicuously absent — see the invariant above.
|
||||
data: {
|
||||
lastSkippedAt: skippedAt,
|
||||
skipCount: { increment: 1 },
|
||||
snoozedUntil: null,
|
||||
nextDueAt,
|
||||
},
|
||||
});
|
||||
if (result.count === 0) {
|
||||
return { skipped: false, nextDueAt: null };
|
||||
}
|
||||
|
||||
await prisma.measurementReminderEvent.create({
|
||||
data: {
|
||||
userId: reminder.userId,
|
||||
reminderId: reminder.id,
|
||||
kind: "SKIPPED",
|
||||
occurredAt: skippedAt,
|
||||
onTime: deriveOnTime(reminder.nextDueAt, skippedAt),
|
||||
source: "skip",
|
||||
},
|
||||
});
|
||||
|
||||
return { skipped: true, nextDueAt };
|
||||
}
|
||||
@@ -13,6 +13,10 @@ import {
|
||||
updateMeasurementReminderSchema,
|
||||
measurementReminderDto,
|
||||
measurementReminderCompletionDto,
|
||||
measurementReminderSkipDto,
|
||||
measurementReminderHistoryDto,
|
||||
snoozeMeasurementReminderSchema,
|
||||
listReminderHistorySchema,
|
||||
} from "@/lib/validations/measurement-reminders";
|
||||
import {
|
||||
dataEnvelope,
|
||||
@@ -206,4 +210,89 @@ export const measurementReminderPaths: NonNullable<ZodOpenApiObject["paths"]> =
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/measurement-reminders/{id}/skip": {
|
||||
post: {
|
||||
tags: ["MeasurementReminders"],
|
||||
summary: "Skip the current due cycle (v1.37.20)",
|
||||
description:
|
||||
"Honestly skips the current due cycle — no body; the skip instant is the server clock. The interval restarts from the skip (rolling: skip + N days at the notify hour; rrule: next occurrence strictly after the skip), lastSkippedAt is stamped, skipCount incremented, any snooze cleared, and a SKIPPED row lands in the completion ledger. lastSatisfiedAt is never touched: a skip is not a completion. Screening reminders are skippable (the satisfy-side 409 guards claimed fulfilment; a skip claims the opposite). Owner-scoped; an appointment (encounter) reminder 404s.",
|
||||
requestParams: { path: z.object({ id: z.string() }) },
|
||||
responses: {
|
||||
...recordRefusal(),
|
||||
"200": {
|
||||
description:
|
||||
"Skip applied (skipped=true) or forward-only no-op (skipped=false); reminder carries the canonical post-skip DTO.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
measurementReminderSkipDto,
|
||||
"MeasurementReminderSkipEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...reminderNotFound,
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/measurement-reminders/{id}/snooze": {
|
||||
post: {
|
||||
tags: ["MeasurementReminders"],
|
||||
summary: "Snooze to a calendar day (v1.37.20)",
|
||||
description:
|
||||
"Pushes the current due date back to a named calendar day (YYYY-MM-DD), resolved server-side to the reminder's notifyHour in the profile timezone. snoozedUntil and nextDueAt are set to the same instant, so every due-state consumer moves together and the cursor self-expires. The regular cadence is untouched — lastSatisfiedAt, lastSkippedAt and the anchor stay put. Must be at least tomorrow and at most five years out (422 otherwise). Repeated snoozes: last one wins. Owner-scoped; an appointment reminder 404s.",
|
||||
requestParams: { path: z.object({ id: z.string() }) },
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": { schema: snoozeMeasurementReminderSchema },
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
...recordRefusal(),
|
||||
"200": {
|
||||
description: "Snooze applied; reminder carries the resolved DTO.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
z.object({ reminder: measurementReminderDto }),
|
||||
"MeasurementReminderSnoozeEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...reminderNotFound,
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/measurement-reminders/{id}/history": {
|
||||
get: {
|
||||
tags: ["MeasurementReminders"],
|
||||
summary: "Completion ledger, newest first (v1.37.20)",
|
||||
description:
|
||||
"Paginated completion ledger for one reminder (iOS #68): one row per satisfy (from any path) and per skip, newest first. onTime is derived server-side at write time against the due instant that was current when the event landed. History begins at the release that introduced the ledger — the single-cursor engine holds nothing to backfill from. Owner-scoped; an appointment reminder 404s.",
|
||||
requestParams: {
|
||||
path: z.object({ id: z.string() }),
|
||||
query: listReminderHistorySchema,
|
||||
},
|
||||
responses: {
|
||||
...recordRefusal(),
|
||||
"200": {
|
||||
description: "The reminder's completion ledger page.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
measurementReminderHistoryDto,
|
||||
"MeasurementReminderHistoryEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...reminderNotFound,
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -15,4 +15,7 @@ export const measurementReminderKeys = {
|
||||
measurementReminderDelete: () => ["measurement-reminders", "delete"] as const,
|
||||
measurementReminderSatisfy: () =>
|
||||
["measurement-reminders", "satisfy"] as const,
|
||||
/** v1.37.20 (#223) — honest skip + snooze mutations. */
|
||||
measurementReminderSkip: () => ["measurement-reminders", "skip"] as const,
|
||||
measurementReminderSnooze: () => ["measurement-reminders", "snooze"] as const,
|
||||
};
|
||||
@@ -151,6 +151,17 @@ export function recordActivityVerbLine(
|
||||
return t("recordSharing.activityVerb.measurementReminderSatisfy", {
|
||||
name,
|
||||
});
|
||||
// v1.37.20 (#223) — the honest skip and the snooze read differently in
|
||||
// the feed on purpose: one says a cycle was let go, the other that a due
|
||||
// date moved. Neither reads as "done".
|
||||
case "measurementReminder.skip":
|
||||
return t("recordSharing.activityVerb.measurementReminderSkip", {
|
||||
name,
|
||||
});
|
||||
case "measurementReminder.snooze":
|
||||
return t("recordSharing.activityVerb.measurementReminderSnooze", {
|
||||
name,
|
||||
});
|
||||
|
||||
case "labResult.update":
|
||||
return t("recordSharing.activityVerb.labResultUpdate", { name });
|
||||
|
||||
@@ -838,13 +838,16 @@ export async function handleCallback(update: TelegramUpdate) {
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
// v1.19.2 — the target metric; drives the optional numeric capture.
|
||||
measurementType: true,
|
||||
intervalDays: true,
|
||||
rrule: true,
|
||||
anchorDate: true,
|
||||
notifyHour: true,
|
||||
nextDueAt: true,
|
||||
lastSatisfiedAt: true,
|
||||
lastSkippedAt: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
@@ -857,7 +860,7 @@ export async function handleCallback(update: TelegramUpdate) {
|
||||
return;
|
||||
}
|
||||
const tz = user.timezone || DEFAULT_TIMEZONE;
|
||||
await satisfyReminder(prisma, reminder, tz, new Date());
|
||||
await satisfyReminder(prisma, reminder, tz, new Date(), "telegram");
|
||||
await answerTelegramCallbackQuery(
|
||||
botToken,
|
||||
callback.id,
|
||||
|
||||
@@ -100,12 +100,15 @@ export async function satisfyBoostersForDose(
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
vaccinationAntigen: true,
|
||||
intervalDays: true,
|
||||
rrule: true,
|
||||
anchorDate: true,
|
||||
notifyHour: true,
|
||||
nextDueAt: true,
|
||||
lastSatisfiedAt: true,
|
||||
lastSkippedAt: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
@@ -125,11 +128,14 @@ export async function satisfyBoostersForDose(
|
||||
for (const reminder of ordered) {
|
||||
const row: SatisfiableReminder = {
|
||||
id: reminder.id,
|
||||
userId: reminder.userId,
|
||||
intervalDays: reminder.intervalDays,
|
||||
rrule: reminder.rrule,
|
||||
anchorDate: reminder.anchorDate,
|
||||
notifyHour: reminder.notifyHour,
|
||||
nextDueAt: reminder.nextDueAt,
|
||||
lastSatisfiedAt: reminder.lastSatisfiedAt,
|
||||
lastSkippedAt: reminder.lastSkippedAt,
|
||||
createdAt: reminder.createdAt,
|
||||
};
|
||||
// `satisfyReminder` types its client as the full `PrismaClient` because
|
||||
@@ -142,6 +148,7 @@ export async function satisfyBoostersForDose(
|
||||
row,
|
||||
timezone,
|
||||
occurredAt,
|
||||
"vaccination",
|
||||
);
|
||||
if (result.satisfied) satisfiedReminderIds.push(reminder.id);
|
||||
}
|
||||
|
||||
@@ -210,6 +210,9 @@ export const measurementReminderDto = z
|
||||
location: z.string().nullable(),
|
||||
nextDueAt: z.iso.datetime({ offset: true }).nullable(),
|
||||
lastSatisfiedAt: z.iso.datetime({ offset: true }).nullable(),
|
||||
snoozedUntil: z.iso.datetime({ offset: true }).nullable(),
|
||||
lastSkippedAt: z.iso.datetime({ offset: true }).nullable(),
|
||||
skipCount: z.number().int(),
|
||||
enabled: z.boolean(),
|
||||
createdAt: z.iso.datetime({ offset: true }),
|
||||
updatedAt: z.iso.datetime({ offset: true }),
|
||||
@@ -217,7 +220,7 @@ export const measurementReminderDto = z
|
||||
.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. origin distinguishes a user-created (VORSORGE) reminder from one minted by a Coach cadence suggestion (COACH); endsOn bounds a finite course window (null = open-ended).",
|
||||
"A Vorsorge reminder. nextDueAt is server-computed (server-authoritative). A free-text reminder carries measurementType=null and resolves only on a manual satisfy. origin distinguishes a user-created (VORSORGE) reminder from one minted by a Coach cadence suggestion (COACH); endsOn bounds a finite course window (null = open-ended). snoozedUntil/lastSkippedAt/skipCount are resolved values: snoozed means snoozedUntil > now, a skipped cycle means lastSkippedAt > lastSatisfiedAt; clients compare against the clock and never recompute cadence. lastSatisfiedAt stays the only completed signal — skip and snooze never move it.",
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -240,5 +243,93 @@ export const measurementReminderCompletionDto = z
|
||||
.meta({
|
||||
id: "MeasurementReminderCompletion",
|
||||
description:
|
||||
"Result of an explicit reminder completion. completed=true when this call advanced lastSatisfiedAt; completed=false when an earlier satisfy or a matching reading had already fulfilled the current cycle (idempotent no-op). reminder is the canonical post-completion DTO.",
|
||||
"Result of an explicit reminder completion. completed=true when this call advanced lastSatisfiedAt; completed=false when an earlier satisfy or a matching reading already fulfilled the current cycle (idempotent no-op). reminder is the canonical post-completion DTO.",
|
||||
});
|
||||
|
||||
/**
|
||||
* v1.37.20 (#223) — snooze body. Day precision on purpose: the client names
|
||||
* a local calendar day, the server resolves it to that day's notifyHour in
|
||||
* the profile timezone. No client-supplied instant ever anchors the cadence.
|
||||
* The range gate (tomorrow .. +5 years) lives in the route, where the
|
||||
* profile timezone is known.
|
||||
*/
|
||||
export const snoozeMeasurementReminderSchema = z
|
||||
.object({
|
||||
until: z.iso.date(),
|
||||
})
|
||||
.meta({
|
||||
id: "MeasurementReminderSnooze",
|
||||
description:
|
||||
"Push a reminder's current due date back to a calendar day (YYYY-MM-DD). The server resolves the day to the reminder's notifyHour in the profile timezone. Must be at least tomorrow and at most five years out. The regular cadence is untouched: lastSatisfiedAt and the anchor stay where they are, and the reminder resumes its normal interval after the snoozed cycle.",
|
||||
});
|
||||
|
||||
export type SnoozeMeasurementReminderInput = z.infer<
|
||||
typeof snoozeMeasurementReminderSchema
|
||||
>;
|
||||
|
||||
/**
|
||||
* v1.37.20 (#223) — skip result. `skipped=false` is the forward-only no-op
|
||||
* (a concurrent skip or satisfy already advanced the row).
|
||||
*/
|
||||
export const measurementReminderSkipDto = z
|
||||
.object({
|
||||
skipped: z.boolean(),
|
||||
reminder: measurementReminderDto,
|
||||
})
|
||||
.meta({
|
||||
id: "MeasurementReminderSkip",
|
||||
description:
|
||||
"Result of skipping the current due cycle. skipped=true when this call recorded the skip and restarted the interval from the skip instant; skipped=false when a concurrent skip or satisfy already advanced the row (idempotent no-op). A skip never touches lastSatisfiedAt — it is recorded honestly as a skip, not a completion.",
|
||||
});
|
||||
|
||||
/**
|
||||
* v1.37.20 (iOS #68) — completion-ledger query + row. History starts with
|
||||
* the release that ships the ledger; the single-cursor engine holds nothing
|
||||
* to backfill from.
|
||||
*/
|
||||
export const listReminderHistorySchema = z
|
||||
.object({
|
||||
limit: z.coerce.number().int().min(1).max(100).default(50),
|
||||
offset: z.coerce.number().int().min(0).default(0),
|
||||
})
|
||||
.meta({
|
||||
id: "MeasurementReminderHistoryQuery",
|
||||
description: "Pagination for the reminder completion ledger.",
|
||||
});
|
||||
|
||||
export const measurementReminderEventDto = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
kind: z.enum(["SATISFIED", "SKIPPED"]),
|
||||
occurredAt: z.iso.datetime({ offset: true }),
|
||||
onTime: z.boolean(),
|
||||
source: z.enum([
|
||||
"manual",
|
||||
"auto_measurement",
|
||||
"auto_lab",
|
||||
"telegram",
|
||||
"vaccination",
|
||||
"encounter",
|
||||
"skip",
|
||||
]),
|
||||
createdAt: z.iso.datetime({ offset: true }),
|
||||
})
|
||||
.meta({
|
||||
id: "MeasurementReminderEvent",
|
||||
description:
|
||||
"One completion-ledger row: an honest fulfilment (SATISFIED) or an honest skip (SKIPPED). onTime is derived server-side at write time against the due instant that was current when the event landed — clients never re-derive it. History begins at the release that introduced the ledger.",
|
||||
});
|
||||
|
||||
export const measurementReminderHistoryDto = z
|
||||
.object({
|
||||
events: z.array(measurementReminderEventDto),
|
||||
meta: z.object({
|
||||
total: z.number().int(),
|
||||
limit: z.number().int(),
|
||||
offset: z.number().int(),
|
||||
}),
|
||||
})
|
||||
.meta({
|
||||
id: "MeasurementReminderHistory",
|
||||
description: "Paginated completion ledger for one reminder, newest first.",
|
||||
});
|
||||
+16
@@ -240,6 +240,22 @@ export const ADMITTED_MUTATING_HANDLERS = [
|
||||
"manage",
|
||||
"update",
|
||||
),
|
||||
handler(
|
||||
"app/api/measurement-reminders/[id]/skip/route.ts",
|
||||
"/api/measurement-reminders/[id]/skip",
|
||||
"POST",
|
||||
"measurements",
|
||||
"manage",
|
||||
"update",
|
||||
),
|
||||
handler(
|
||||
"app/api/measurement-reminders/[id]/snooze/route.ts",
|
||||
"/api/measurement-reminders/[id]/snooze",
|
||||
"POST",
|
||||
"measurements",
|
||||
"manage",
|
||||
"update",
|
||||
),
|
||||
handler(
|
||||
"app/api/labs/[id]/route.ts",
|
||||
"/api/labs/[id]",
|
||||
|
||||
Reference in new issue
Block a user