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:
Marc-André Bombeck committed 2026-08-14 09:23:58 +02:00
1 parent cd12b3886e
commit 5619783648
35 files changed
+3715 -115

No files matched your search

@@ -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,
},
},
},
};