From 5619783648863bb428eed36b7cbb7d041ff23029 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 14 Aug 2026 09:23:58 +0200 Subject: [PATCH] Let a checkup be skipped or pushed back, and remember every outcome MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/api/openapi.yaml | 1980 ++++++++++++++++- messages/de.json | 22 +- messages/en.json | 22 +- messages/es.json | 22 +- messages/fr.json | 22 +- messages/it.json | 22 +- messages/pl.json | 22 +- .../encounter-reminder-exclusion.test.ts | 28 +- src/__tests__/sharing-surface-guard.test.ts | 30 +- .../__tests__/skip-snooze-history.test.ts | 314 +++ .../[id]/complete/route.ts | 8 +- .../[id]/history/route.ts | 72 + .../api/measurement-reminders/[id]/route.ts | 3 + .../[id]/satisfy/route.ts | 2 +- .../measurement-reminders/[id]/skip/route.ts | 77 + .../[id]/snooze/route.ts | 134 ++ .../delegated-write-affordances.test.tsx | 3 + .../vorsorge-section.tsx | 208 +- src/hooks/use-measurement-reminders.ts | 33 +- src/lib/encounters/service.ts | 6 + src/lib/jobs/measurement-reminder.ts | 4 + src/lib/mcp/__tests__/tools.test.ts | 9 + src/lib/mcp/tools.ts | 11 + .../__tests__/resolve.test.ts | 58 + .../__tests__/satisfy.test.ts | 244 +- src/lib/measurement-reminders/dto.ts | 13 + src/lib/measurement-reminders/resolve.ts | 29 +- src/lib/measurement-reminders/satisfy.ts | 206 +- .../openapi/routes/measurement-reminders.ts | 89 + src/lib/query-keys/measurement-reminders.ts | 3 + src/lib/record-activity/activity-verb.ts | 11 + src/lib/telegram-webhook-handlers.ts | 5 +- src/lib/vaccinations/satisfy-matching.ts | 7 + src/lib/validations/measurement-reminders.ts | 95 +- tests/fixtures/v137/sharing-matrix.ts | 16 + 35 files changed, 3715 insertions(+), 115 deletions(-) create mode 100644 src/app/api/measurement-reminders/[id]/__tests__/skip-snooze-history.test.ts create mode 100644 src/app/api/measurement-reminders/[id]/history/route.ts create mode 100644 src/app/api/measurement-reminders/[id]/skip/route.ts create mode 100644 src/app/api/measurement-reminders/[id]/snooze/route.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index e86c3cfb4..64f302d78 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -8906,6 +8906,312 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "422": *a8 "429": *a4 + /api/mood-entries: + get: + tags: + - Mood + summary: List mood entries + description: The record's mood entries, filtered and paged. Soft-deleted (tombstoned) rows are hidden; the + `/api/sync/changes` feed is where tombstones surface. Every row carries the same `MoodEntryDTO` shape the write + paths answer, structured-tag keys and rated factors included, so the edit form pre-populates without per-row + requests. A malformed query 422s with every issue listed (`meta.errorCode` = `mood.list.invalid`). + parameters: + - in: query + name: mood + schema: + type: string + enum: + - SUPER_GUT + - GUT + - OKAY + - SCHLECHT + - LAUSIG + - in: query + name: source + schema: + type: string + enum: + - MANUAL + - MOODLOG + - WEB + - TELEGRAM + - DAYLIO + - in: query + name: from + schema: + type: string + pattern: ^\d{4}-\d{2}-\d{2}$ + - in: query + name: to + schema: + type: string + pattern: ^\d{4}-\d{2}-\d{2}$ + - in: query + name: limit + schema: + default: 100 + type: integer + minimum: 1 + maximum: 500 + - in: query + name: offset + schema: + default: 0 + type: integer + minimum: 0 + maximum: 9007199254740991 + - in: query + name: sortBy + schema: + default: moodLoggedAt + type: string + enum: + - date + - mood + - score + - moodLoggedAt + - source + - in: query + name: sortDir + schema: + default: desc + type: string + enum: + - asc + - desc + responses: + "200": + description: One page of entries plus paging meta. + content: + application/json: + schema: + $ref: "#/components/schemas/MoodEntryListEnvelope" + "401": *a1 + "403": *a6 + "422": *a3 + "429": *a4 + post: + tags: + - Mood + summary: Create a mood entry + description: Writes the entry, its structured-tag links and its day context in one transaction — a link or context + failure rolls the entry back rather than letting a retry mint a duplicate. Idempotent via the `Idempotency-Key` + header. With `externalId` the write UPSERTS on `(userId, source, externalId)` (a re-post updates the row in + place and restates pleasantness from the label, while A2-A5 and a stored context are only touched when the + request carries them); without it, a same-`(date, moodLoggedAt)` re-post 409s. A delegated manager cannot supply + `externalId` (422, `meta.errorCode` = `mood.create.external_id_not_delegable`) — it is a device's contract with + its own rows. A rated factor outside its catalog scale 422s (`mood.ratedFactor.out_of_range`); a malformed body + 422s (`mood.create.invalid`). An unstable `externalId` (an object description or memory address that rotates per + launch) is refused at validation. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CreateMoodEntryRequest" + responses: + "201": + description: Entry created (or upserted in place via `externalId`), with its persisted tag keys, rated factors and + context. + content: + application/json: + schema: + $ref: "#/components/schemas/MoodEntryEnvelope" + "401": *a1 + "403": *a6 + "409": &a12 + description: A mood entry with the same `(date, moodLoggedAt)` instant already exists. `meta.errorCode` = + `mood.duplicate_timestamp`. Only a request without `externalId` can answer this — with one, the write + upserts in place. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a3 + "429": *a4 + /api/mood-entries/{id}: + get: + tags: + - Mood + summary: Read one mood entry + description: The full entry with its day context, so the edit surface loads both in one request. A soft-deleted row + 404s, matching the list read's invariant. + parameters: + - in: path + name: id + schema: + type: string + required: true + responses: + "200": + description: The entry. + content: + application/json: + schema: + $ref: "#/components/schemas/MoodEntryEnvelope" + "401": *a1 + "403": *a6 + "404": *a11 + "422": *a3 + "429": *a4 + put: + tags: + - Mood + summary: Edit a mood entry + description: "Per-field edit: omitted fields keep their stored value; `tagKeys`, `ratedFactors` and `context` replace + their whole set when present (`null` clears). Editing `mood` re-derives `score` and — when the label actually + moved — pleasantness `a1`; editing `moodLoggedAt` re-anchors the row's `date` and `tz` to the account's display + timezone. Bumps `syncVersion` so paired clients reconcile last-writer-wins. A soft-deleted row 404s + (`meta.errorCode` = `mood.not_found`); an edit landing on another row's `(date, moodLoggedAt)` 409s + (`mood.duplicate_timestamp`); a rated factor outside its catalog scale 422s (`mood.ratedFactor.out_of_range`); a + malformed body 422s (`mood.update.invalid`)." + parameters: + - in: path + name: id + schema: + type: string + required: true + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/UpdateMoodEntryRequest" + responses: + "200": + description: The entry after the edit, with its persisted tag keys, rated factors and context. + content: + application/json: + schema: + $ref: "#/components/schemas/MoodEntryEnvelope" + "401": *a1 + "403": *a6 + "404": *a11 + "409": *a12 + "422": *a3 + "429": *a4 + delete: + tags: + - Mood + summary: Delete a mood entry (soft) + description: Tombstones the row (`deletedAt` set, `syncVersion` bumped) rather than hard-deleting, so the + `/api/sync/changes` feed carries the tombstone to paired clients that were offline. Invisible to every normal + read from here on; `POST /api/mood-entries/restore` puts it back. Re-deleting an already-tombstoned row is a + harmless idempotent no-op. + parameters: + - in: path + name: id + schema: + type: string + required: true + responses: + "200": + description: Entry tombstoned. + content: + application/json: + schema: + $ref: "#/components/schemas/MoodEntryDeleteEnvelope" + "401": *a1 + "403": *a6 + "404": *a11 + "422": *a3 + "429": *a4 + /api/mood-entries/restore: + post: + tags: + - Mood + summary: Restore soft-deleted mood entries + description: Un-tombstones every owned, currently-deleted id in one statement — the delete-Undo affordance. Clears + `deletedAt` and bumps `syncVersion` so the row re-surfaces in normal reads and rides the `/api/sync/changes` + feed as an upsert. A forged / foreign / not-deleted id is a silent no-op, never a 404 existence leak. Idempotent + via the `Idempotency-Key` header. Rate-limited 60/min (keyed on the ACTING user, so a manager burns their own + allowance). A malformed body 422s (`meta.errorCode` = `mood.restore.invalid`). + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/MoodEntryIdsRequest" + responses: + "200": + description: "`restored` counts the rows actually un-tombstoned." + content: + application/json: + schema: + $ref: "#/components/schemas/MoodEntriesRestoreEnvelope" + "401": *a1 + "403": *a6 + "422": *a3 + "429": *a4 + /api/mood-entries/bulk-delete: + post: + tags: + - Mood + summary: Soft-delete mood entries in bulk + description: Tombstones every owned, not-already-deleted id in one statement — the management list's multi-select + delete, matching the single-DELETE tombstone contract. A forged / foreign id is a silent no-op, never a 404 + existence leak. Idempotent via the `Idempotency-Key` header. Rate-limited 60/min (keyed on the ACTING user). A + malformed body 422s (`meta.errorCode` = `mood.bulk-delete.invalid`). + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/MoodEntryIdsRequest" + responses: + "200": + description: "`deleted` counts the rows actually tombstoned." + content: + application/json: + schema: + $ref: "#/components/schemas/MoodEntriesBulkDeleteEnvelope" + "401": *a1 + "403": *a6 + "422": *a3 + "429": *a4 + /api/mood/linked-context: + get: + tags: + - Mood + summary: The linked figures of one local day + description: "What the modules that own sleep, activity, vitals and body symptoms already know about one day, resolved + at read time and never stored on the mood row — a corrected sleep session shows up here with nothing to re-sync. + Keyed by local day rather than entry id, deliberately: the capture sheet needs the figures BEFORE an entry + exists. `tz` anchors the day's boundaries — the capture sheet sends the browser's zone, the edit dialog the + entry's stored `tz`; absent falls back to the account's display timezone. Whole-record read: the payload crosses + into the measurements and illness sections from a mood surface, so a section-scoped grant is refused outright + rather than served a filtered day that would read as empty. Gated: 403 `module.disabled` when the mood module is + off. A malformed query 422s (`meta.errorCode` = `mood.linkedContext.invalid`)." + parameters: + - in: query + name: date + schema: + type: string + pattern: ^\d{4}-\d{2}-\d{2}$ + description: The local day to resolve, YYYY-MM-DD. + required: true + description: The local day to resolve, YYYY-MM-DD. + - in: query + name: tz + schema: + description: IANA zone the day is anchored to. Send the entry's stored `tz` when editing; omit to use the account's + display timezone. + type: string + minLength: 1 + maxLength: 64 + description: IANA zone the day is anchored to. Send the entry's stored `tz` when editing; omit to use the account's + display timezone. + responses: + "200": + description: The day's linked blocks; absence is explicit per figure and per module. + content: + application/json: + schema: + $ref: "#/components/schemas/MoodLinkedContextEnvelope" + "401": *a1 + "403": *a7 + "422": *a3 + "429": *a4 /api/settings/reminder-thresholds: get: tags: @@ -9056,6 +9362,51 @@ paths: $ref: "#/components/schemas/CsvImportTooLargeEnvelope" "422": *a3 "429": *a4 + /api/import: + post: + tags: + - Import + summary: Import measurements and mood entries from JSON + description: "Restores a JSON export (the structure `GET /api/export/json` emits): up to 10 000 measurements plus 10 000 + mood entries per call, body ≤ 16 MB. Rows carrying an `externalId` upsert idempotently (re-import updates in + place); rows without one are first-write-wins and dedup against the existing unique keys, so an exact duplicate + is `skipped` rather than doubled. A row with an unstable `externalId` is skipped while the rest of the file + still lands. Every imported measurement stores `source = IMPORT`; every imported mood entry re-derives + pleasantness from its label. Writes are per-row, not transactional: on a mid-file write failure the response is + 503 (`meta.errorCode` = `import.write_failed`, `meta.retryable` = true) with `meta.stats` carrying what landed — + rows already written stay written, and a retry is only safe against duplication for rows that carry an + `externalId`. Validation is fail-fast (the 422 carries the first issue's message, not the multi-issue envelope). + Rate-limited 5/hour per user (shared `import:` bucket with the CSV importer)." + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/JsonImportRequest" + responses: + "200": + description: Import completed; per-section outcome counts. + content: + application/json: + schema: + $ref: "#/components/schemas/JsonImportStatsEnvelope" + "401": *a1 + "413": + description: Body exceeds the 16 MB limit. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a3 + "429": *a4 + "503": + description: A write failed partway through the file. `meta.errorCode` = `import.write_failed`, `meta.retryable` = true, + `meta.stats` carries the counts that landed before the failure. Rows already written stay written; retry the + same file — rows with `externalId` re-import idempotently, rows without one may duplicate. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" /api/measurement-reminders: get: tags: @@ -9118,7 +9469,7 @@ paths: $ref: "#/components/schemas/MeasurementReminderEnvelope" "401": *a1 "403": *a6 - "404": &a12 + "404": &a13 description: Measurement reminder not found / not owned. content: application/json: @@ -9153,7 +9504,7 @@ paths: $ref: "#/components/schemas/MeasurementReminderPatchEnvelope" "401": *a1 "403": *a6 - "404": *a12 + "404": *a13 "422": *a3 "429": *a4 delete: @@ -9176,7 +9527,7 @@ paths: $ref: "#/components/schemas/MeasurementReminderDeleteEnvelope" "401": *a1 "403": *a6 - "404": *a12 + "404": *a13 "422": *a3 "429": *a4 /api/measurement-reminders/{id}/satisfy: @@ -9201,7 +9552,7 @@ paths: $ref: "#/components/schemas/MeasurementReminderSatisfyEnvelope" "401": *a1 "403": *a6 - "404": *a12 + "404": *a13 "422": *a3 "429": *a4 /api/measurement-reminders/{id}/complete: @@ -9229,7 +9580,112 @@ paths: $ref: "#/components/schemas/MeasurementReminderCompleteEnvelope" "401": *a1 "403": *a6 - "404": *a12 + "404": *a13 + "422": *a3 + "429": *a4 + /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." + parameters: + - in: path + name: id + schema: + type: string + required: true + responses: + "200": + description: Skip applied (skipped=true) or forward-only no-op (skipped=false); reminder carries the canonical post-skip + DTO. + content: + application/json: + schema: + $ref: "#/components/schemas/MeasurementReminderSkipEnvelope" + "401": *a1 + "403": *a6 + "404": *a13 + "422": *a3 + "429": *a4 + /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." + parameters: + - in: path + name: id + schema: + type: string + required: true + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/MeasurementReminderSnooze" + responses: + "200": + description: Snooze applied; reminder carries the resolved DTO. + content: + application/json: + schema: + $ref: "#/components/schemas/MeasurementReminderSnoozeEnvelope" + "401": *a1 + "403": *a6 + "404": *a13 + "422": *a3 + "429": *a4 + /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." + parameters: + - in: path + name: id + schema: + type: string + required: true + - in: query + name: limit + schema: + default: 50 + type: integer + minimum: 1 + maximum: 100 + - in: query + name: offset + schema: + default: 0 + type: integer + minimum: 0 + maximum: 9007199254740991 + responses: + "200": + description: The reminder's completion ledger page. + content: + application/json: + schema: + $ref: "#/components/schemas/MeasurementReminderHistoryEnvelope" + "401": *a1 + "403": *a6 + "404": *a13 "422": *a3 "429": *a4 /api/labs: @@ -9411,7 +9867,7 @@ paths: $ref: "#/components/schemas/GetLabResultResponse" "401": *a1 "403": *a6 - "404": &a13 + "404": &a14 description: Lab result not found (or owned by another user). content: application/json: @@ -9506,7 +9962,7 @@ paths: $ref: "#/components/schemas/UpdateLabResultResponse" "401": *a1 "403": *a6 - "404": *a13 + "404": *a14 "422": *a3 "429": *a4 delete: @@ -9529,7 +9985,7 @@ paths: $ref: "#/components/schemas/DeleteLabResultResponse" "401": *a1 "403": *a6 - "404": *a13 + "404": *a14 "422": *a3 "429": *a4 /api/labs/restore: @@ -9759,7 +10215,7 @@ paths: $ref: "#/components/schemas/CreateBiomarkerResponse" "401": *a1 "403": *a6 - "409": &a15 + "409": &a16 description: A biomarker with this name already exists for the caller. content: application/json: @@ -9788,7 +10244,7 @@ paths: $ref: "#/components/schemas/GetBiomarkerResponse" "401": *a1 "403": *a6 - "404": &a14 + "404": &a15 description: Biomarker not found (or owned by another user). content: application/json: @@ -9856,8 +10312,8 @@ paths: $ref: "#/components/schemas/UpdateBiomarkerResponse" "401": *a1 "403": *a6 - "404": *a14 - "409": *a15 + "404": *a15 + "409": *a16 "422": *a3 "429": *a4 delete: @@ -9880,7 +10336,7 @@ paths: schema: $ref: "#/components/schemas/DeleteBiomarkerResponse" "401": *a1 - "404": *a14 + "404": *a15 "422": *a3 "429": *a4 /api/encounters: @@ -10087,7 +10543,7 @@ paths: $ref: "#/components/schemas/GetEncounterEnvelope" "401": *a1 "403": *a6 - "404": &a16 + "404": &a17 description: Visit not found (or owned by another user). content: application/json: @@ -10188,7 +10644,7 @@ paths: $ref: "#/components/schemas/UpdateEncounterEnvelope" "401": *a1 "403": *a6 - "404": *a16 + "404": *a17 "422": *a3 "429": *a4 delete: @@ -10213,7 +10669,7 @@ paths: $ref: "#/components/schemas/DeleteEncounterEnvelope" "401": *a1 "403": *a6 - "404": *a16 + "404": *a17 "422": *a3 "429": *a4 /api/encounters/{id}/restore: @@ -10239,7 +10695,7 @@ paths: $ref: "#/components/schemas/RestoreEncounterEnvelope" "401": *a1 "403": *a6 - "404": *a16 + "404": *a17 "422": *a3 "429": *a4 /api/encounters/{id}/links: @@ -10288,7 +10744,7 @@ paths: schema: $ref: "#/components/schemas/LinkEncounterEnvelope" "401": *a1 - "404": *a16 + "404": *a17 "422": *a3 "429": *a4 delete: @@ -10336,7 +10792,7 @@ paths: schema: $ref: "#/components/schemas/UnlinkEncounterEnvelope" "401": *a1 - "404": *a16 + "404": *a17 "422": *a3 "429": *a4 /api/practitioners: @@ -10445,7 +10901,7 @@ paths: $ref: "#/components/schemas/GetPractitionerEnvelope" "401": *a1 "403": *a6 - "404": &a17 + "404": &a18 description: Practitioner not found (or owned by another user). content: application/json: @@ -10510,7 +10966,7 @@ paths: $ref: "#/components/schemas/UpdatePractitionerEnvelope" "401": *a1 "403": *a6 - "404": *a17 + "404": *a18 "422": *a3 "429": *a4 delete: @@ -10534,7 +10990,7 @@ paths: $ref: "#/components/schemas/DeletePractitionerEnvelope" "401": *a1 "403": *a6 - "404": *a17 + "404": *a18 "422": *a3 "429": *a4 /api/practitioners/{id}/restore: @@ -10560,7 +11016,7 @@ paths: $ref: "#/components/schemas/RestorePractitionerEnvelope" "401": *a1 "403": *a6 - "404": *a17 + "404": *a18 "422": *a3 "429": *a4 /api/vaccinations: @@ -10726,7 +11182,7 @@ paths: $ref: "#/components/schemas/GetVaccinationEnvelope" "401": *a1 "403": *a6 - "404": &a18 + "404": &a19 description: Vaccination not found (or owned by another user). content: application/json: @@ -10833,7 +11289,7 @@ paths: $ref: "#/components/schemas/UpdateVaccinationEnvelope" "401": *a1 "403": *a6 - "404": *a18 + "404": *a19 "422": *a3 "429": *a4 delete: @@ -10858,7 +11314,7 @@ paths: $ref: "#/components/schemas/DeleteVaccinationEnvelope" "401": *a1 "403": *a6 - "404": *a18 + "404": *a19 "422": *a3 "429": *a4 /api/vaccinations/{id}/restore: @@ -10883,7 +11339,7 @@ paths: $ref: "#/components/schemas/RestoreVaccinationEnvelope" "401": *a1 "403": *a6 - "404": *a18 + "404": *a19 "422": *a3 "429": *a4 /api/vaccinations/{id}/booster: @@ -10941,7 +11397,7 @@ paths: $ref: "#/components/schemas/MintVaccinationBoosterEnvelope" "401": *a1 "403": *a6 - "404": *a18 + "404": *a19 "422": *a3 "429": *a4 /api/vaccinations/suggest: @@ -11016,7 +11472,7 @@ paths: schema: $ref: "#/components/schemas/LinkVaccinationEnvelope" "401": *a1 - "404": *a18 + "404": *a19 "422": *a3 "429": *a4 delete: @@ -11062,7 +11518,7 @@ paths: schema: $ref: "#/components/schemas/UnlinkVaccinationEnvelope" "401": *a1 - "404": *a18 + "404": *a19 "422": *a3 "429": *a4 /api/illness/episodes: @@ -11190,7 +11646,7 @@ paths: $ref: "#/components/schemas/GetIllnessEpisodeEnvelope" "401": *a1 "403": *a6 - "404": &a19 + "404": &a20 description: Illness episode not found (or owned by another user). content: application/json: @@ -11269,7 +11725,7 @@ paths: $ref: "#/components/schemas/UpdateIllnessEpisodeEnvelope" "401": *a1 "403": *a6 - "404": *a19 + "404": *a20 "422": *a3 "429": *a4 delete: @@ -11293,7 +11749,7 @@ paths: $ref: "#/components/schemas/DeleteIllnessEpisodeEnvelope" "401": *a1 "403": *a6 - "404": *a19 + "404": *a20 "422": *a3 "429": *a4 /api/illness/episodes/{id}/restore: @@ -11320,7 +11776,7 @@ paths: $ref: "#/components/schemas/RestoreIllnessEpisodeEnvelope" "401": *a1 "403": *a6 - "404": *a19 + "404": *a20 "422": *a3 "429": *a4 /api/illness/episodes/{id}/resolve: @@ -11357,7 +11813,7 @@ paths: $ref: "#/components/schemas/ResolveIllnessEpisodeEnvelope" "401": *a1 "403": *a6 - "404": *a19 + "404": *a20 "422": *a3 "429": *a4 /api/illness/episodes/{id}/day-logs: @@ -11408,7 +11864,7 @@ paths: $ref: "#/components/schemas/GetIllnessDayLogEnvelope" "401": *a1 "403": *a6 - "404": *a19 + "404": *a20 "422": *a3 "429": *a4 post: @@ -11488,7 +11944,7 @@ paths: $ref: "#/components/schemas/UpsertIllnessDayLogCreatedEnvelope" "401": *a1 "403": *a6 - "404": *a19 + "404": *a20 "422": *a3 "429": *a4 /api/illness/episodes/{id}/correlation: @@ -11515,7 +11971,7 @@ paths: schema: $ref: "#/components/schemas/IllnessCorrelationEnvelope" "401": *a1 - "404": *a19 + "404": *a20 "422": *a3 "429": *a4 /api/illness/insights: @@ -11788,7 +12244,7 @@ paths: $ref: "#/components/schemas/GetAllergyEnvelope" "401": *a1 "403": *a6 - "404": &a20 + "404": &a21 description: Allergy not found (or owned by another user). content: application/json: @@ -11871,7 +12327,7 @@ paths: $ref: "#/components/schemas/UpdateAllergyEnvelope" "401": *a1 "403": *a6 - "404": *a20 + "404": *a21 "422": *a3 "429": *a4 delete: @@ -11895,7 +12351,7 @@ paths: $ref: "#/components/schemas/DeleteAllergyEnvelope" "401": *a1 "403": *a6 - "404": *a20 + "404": *a21 "422": *a3 "429": *a4 /api/family-history: @@ -12003,7 +12459,7 @@ paths: $ref: "#/components/schemas/GetFamilyHistoryEnvelope" "401": *a1 "403": *a6 - "404": &a21 + "404": &a22 description: Family history entry not found (or owned by another user). content: application/json: @@ -12072,7 +12528,7 @@ paths: $ref: "#/components/schemas/UpdateFamilyHistoryEnvelope" "401": *a1 "403": *a6 - "404": *a21 + "404": *a22 "422": *a3 "429": *a4 delete: @@ -12096,7 +12552,7 @@ paths: $ref: "#/components/schemas/DeleteFamilyHistoryEnvelope" "401": *a1 "403": *a6 - "404": *a21 + "404": *a22 "422": *a3 "429": *a4 /api/documents/inbound: @@ -13249,7 +13705,7 @@ paths: schema: $ref: "#/components/schemas/CreateCustomMetricResponse" "401": *a1 - "409": &a23 + "409": &a24 description: A custom metric with this name already exists for the caller. content: application/json: @@ -13278,7 +13734,7 @@ paths: $ref: "#/components/schemas/GetCustomMetricResponse" "401": *a1 "403": *a6 - "404": &a22 + "404": &a23 description: Custom metric not found (or owned by another user). content: application/json: @@ -13342,8 +13798,8 @@ paths: schema: $ref: "#/components/schemas/UpdateCustomMetricResponse" "401": *a1 - "404": *a22 - "409": *a23 + "404": *a23 + "409": *a24 "422": *a3 "429": *a4 delete: @@ -13366,7 +13822,7 @@ paths: schema: $ref: "#/components/schemas/DeleteCustomMetricResponse" "401": *a1 - "404": *a22 + "404": *a23 "422": *a3 "429": *a4 /api/custom-metrics/{id}/entries: @@ -13410,7 +13866,7 @@ paths: $ref: "#/components/schemas/ListCustomMetricEntriesEnvelope" "401": *a1 "403": *a6 - "404": *a22 + "404": *a23 "422": *a3 "429": *a4 post: @@ -13454,7 +13910,7 @@ paths: schema: $ref: "#/components/schemas/CreateCustomMetricEntryResponse" "401": *a1 - "404": *a22 + "404": *a23 "422": *a3 "429": *a4 /api/custom-metrics/{id}/entries/{entryId}: @@ -13504,7 +13960,7 @@ paths: schema: $ref: "#/components/schemas/UpdateCustomMetricEntryResponse" "401": *a1 - "404": &a24 + "404": &a25 description: Custom metric entry not found (or owned by another user). content: application/json: @@ -13536,7 +13992,7 @@ paths: schema: $ref: "#/components/schemas/DeleteCustomMetricEntryResponse" "401": *a1 - "404": *a24 + "404": *a25 "422": *a3 "429": *a4 /api/onboarding/disclaimer: @@ -13706,7 +14162,7 @@ paths: schema: $ref: "#/components/schemas/NutrientOverviewEnvelope" "401": *a1 - "403": &a25 + "403": &a26 description: >- The opt-in `nutrients` module is off for this account (errorCode `module.disabled`). Stop syncing and do not retry; offer the enable-in-settings hint. Only run the HealthKit read-authorization flow while the module is @@ -13761,7 +14217,7 @@ paths: schema: $ref: "#/components/schemas/NutrientDailySeriesEnvelope" "401": *a1 - "403": *a25 + "403": *a26 "422": *a3 "429": *a4 /api/nutrients/water: @@ -13788,7 +14244,7 @@ paths: schema: $ref: "#/components/schemas/NutrientWaterWriteEnvelope" "401": *a1 - "403": *a25 + "403": *a26 "422": *a3 "429": *a4 /api/integrations/healthkit: @@ -14126,7 +14582,7 @@ paths: schema: $ref: "#/components/schemas/RecordActivityEnvelope" "401": *a1 - "403": &a26 + "403": &a27 description: Refused. `meta.errorCode` is `sharing.not_permitted` when the request was made while acting on another account (grant management is never delegable), or `sharing.access.denied` when the caller may not act as the account they named. The latter is byte-identical for an account that does not exist and one that granted @@ -14151,7 +14607,7 @@ paths: schema: $ref: "#/components/schemas/AccountGrantListEnvelope" "401": *a1 - "403": *a26 + "403": *a27 "422": *a3 "429": *a4 post: @@ -14246,7 +14702,7 @@ paths: schema: $ref: "#/components/schemas/AcceptedAccountGrantEnvelope" "401": *a1 - "403": *a26 + "403": *a27 "404": description: No such invitation for this account. content: @@ -14290,7 +14746,7 @@ paths: schema: $ref: "#/components/schemas/RevokedAccountGrantEnvelope" "401": *a1 - "403": *a26 + "403": *a27 "404": description: No such grant on this account's record. content: @@ -14331,7 +14787,7 @@ paths: schema: $ref: "#/components/schemas/RenouncedAccountGrantEnvelope" "401": *a1 - "403": *a26 + "403": *a27 "404": description: No such grant held by this account. content: @@ -14630,7 +15086,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 - "403": *a26 + "403": *a27 "409": description: "The record this browser session is on is not the record the request asserted. `meta.errorCode` is `sharing.session.changed`. It is not a refusal of access — the caller may well be entitled to both records — @@ -17146,6 +17602,686 @@ components: token. The two manage cards write different fields of the same blob; echo the token to have a stale write refused with 409 instead of resurrecting the other card's overwritten field. Omit it for the legacy unconditional write. + CreateMoodEntryRequest: + type: object + properties: + mood: + type: string + enum: + - SUPER_GUT + - GUT + - OKAY + - SCHLECHT + - LAUSIG + tags: + maxItems: 20 + type: array + items: + type: string + maxLength: 50 + tagKeys: + maxItems: 30 + type: array + items: + type: string + maxLength: 60 + ratedFactors: + maxItems: 30 + type: array + items: + type: object + properties: + key: + type: string + maxLength: 60 + rating: + type: integer + minimum: 1 + maximum: 5 + required: + - key + - rating + a1: + anyOf: + - type: integer + minimum: 0 + maximum: 10 + - type: "null" + a2: + anyOf: + - type: integer + minimum: 0 + maximum: 10 + - type: "null" + a3: + anyOf: + - type: integer + minimum: 0 + maximum: 10 + - type: "null" + a4: + anyOf: + - type: integer + minimum: 0 + maximum: 10 + - type: "null" + a5: + anyOf: + - type: integer + minimum: 0 + maximum: 10 + - type: "null" + note: + type: string + maxLength: 500 + context: + anyOf: + - type: object + properties: + workStatus: + anyOf: + - type: string + enum: + - off + - partial + - regular + - overtime + - type: "null" + workMinutes: + anyOf: + - type: integer + minimum: 0 + maximum: 1440 + - type: "null" + overtimeMinutes: + anyOf: + - type: integer + minimum: 0 + maximum: 1440 + - type: "null" + workLoad: + anyOf: + - type: integer + minimum: 0 + maximum: 10 + - type: "null" + workSatisfaction: + anyOf: + - type: integer + minimum: 0 + maximum: 10 + - type: "null" + contactCircles: + anyOf: + - maxItems: 8 + type: array + items: + type: string + enum: + - partner + - child + - family + - friends + - colleagues + - acquaintances + - professional + - other + - type: "null" + contactForm: + anyOf: + - type: string + enum: + - inPerson + - phone + - video + - written + - type: "null" + contactExtent: + anyOf: + - type: string + enum: + - brief + - medium + - extended + - type: "null" + contactQuality: + anyOf: + - type: integer + minimum: 0 + maximum: 10 + - type: "null" + contactSupport: + anyOf: + - type: integer + minimum: 0 + maximum: 10 + - type: "null" + leisureCategories: + anyOf: + - maxItems: 11 + type: array + items: + type: string + enum: + - film + - reading + - gaming + - music + - creative + - outing + - garden + - cooking + - timeWithChild + - relaxation + - other + - type: "null" + leisureMinutes: + anyOf: + - type: integer + minimum: 0 + maximum: 1440 + - type: "null" + leisureJoy: + anyOf: + - type: integer + minimum: 0 + maximum: 10 + - type: "null" + leisureRecovery: + anyOf: + - type: integer + minimum: 0 + maximum: 10 + - type: "null" + eventType: + anyOf: + - type: string + enum: + - goodNews + - badNews + - success + - disappointment + - conflict + - appointment + - familyEvent + - change + - unexpected + - type: "null" + eventValence: + anyOf: + - type: integer + minimum: -5 + maximum: 5 + - type: "null" + eventAt: + anyOf: + - type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + - type: "null" + note: + anyOf: + - type: string + maxLength: 500 + - type: "null" + - type: "null" + moodLoggedAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + source: + default: MANUAL + type: string + enum: + - MANUAL + - MOODLOG + - WEB + - TELEGRAM + - DAYLIO + externalId: + type: string + minLength: 1 + maxLength: 120 + required: + - mood + - moodLoggedAt + description: "Single mood-entry create. `mood` is the five-point label; the server derives `score` (1-5) and + pleasantness `a1` from it, so a one-tap check-in writes a complete entry. `a1`-`a5` are the level-A self-state + values (0-10): absent says nothing, a number is the answer, an explicit null takes an answer back (`a1` falls + back to the derivation instead of clearing). With `externalId` the create UPSERTS on `(userId, source, + externalId)` — the idempotent re-post iOS drives over Bearer; without it a same-`(date, moodLoggedAt)` re-post + 409s. `moodLoggedAt` is bounded: no future instants beyond a 5-min clock-skew tolerance, nothing before 1900." + UpdateMoodEntryRequest: + type: object + properties: + mood: + type: string + enum: + - SUPER_GUT + - GUT + - OKAY + - SCHLECHT + - LAUSIG + tags: + anyOf: + - maxItems: 20 + type: array + items: + type: string + maxLength: 50 + - type: "null" + tagKeys: + anyOf: + - maxItems: 30 + type: array + items: + type: string + maxLength: 60 + - type: "null" + ratedFactors: + anyOf: + - maxItems: 30 + type: array + items: + type: object + properties: + key: + type: string + maxLength: 60 + rating: + type: integer + minimum: 1 + maximum: 5 + required: + - key + - rating + - type: "null" + a1: + anyOf: + - type: integer + minimum: 0 + maximum: 10 + - type: "null" + a2: + anyOf: + - type: integer + minimum: 0 + maximum: 10 + - type: "null" + a3: + anyOf: + - type: integer + minimum: 0 + maximum: 10 + - type: "null" + a4: + anyOf: + - type: integer + minimum: 0 + maximum: 10 + - type: "null" + a5: + anyOf: + - type: integer + minimum: 0 + maximum: 10 + - type: "null" + note: + anyOf: + - type: string + maxLength: 500 + - type: "null" + context: + anyOf: + - type: object + properties: + workStatus: + anyOf: + - type: string + enum: + - off + - partial + - regular + - overtime + - type: "null" + workMinutes: + anyOf: + - type: integer + minimum: 0 + maximum: 1440 + - type: "null" + overtimeMinutes: + anyOf: + - type: integer + minimum: 0 + maximum: 1440 + - type: "null" + workLoad: + anyOf: + - type: integer + minimum: 0 + maximum: 10 + - type: "null" + workSatisfaction: + anyOf: + - type: integer + minimum: 0 + maximum: 10 + - type: "null" + contactCircles: + anyOf: + - maxItems: 8 + type: array + items: + type: string + enum: + - partner + - child + - family + - friends + - colleagues + - acquaintances + - professional + - other + - type: "null" + contactForm: + anyOf: + - type: string + enum: + - inPerson + - phone + - video + - written + - type: "null" + contactExtent: + anyOf: + - type: string + enum: + - brief + - medium + - extended + - type: "null" + contactQuality: + anyOf: + - type: integer + minimum: 0 + maximum: 10 + - type: "null" + contactSupport: + anyOf: + - type: integer + minimum: 0 + maximum: 10 + - type: "null" + leisureCategories: + anyOf: + - maxItems: 11 + type: array + items: + type: string + enum: + - film + - reading + - gaming + - music + - creative + - outing + - garden + - cooking + - timeWithChild + - relaxation + - other + - type: "null" + leisureMinutes: + anyOf: + - type: integer + minimum: 0 + maximum: 1440 + - type: "null" + leisureJoy: + anyOf: + - type: integer + minimum: 0 + maximum: 10 + - type: "null" + leisureRecovery: + anyOf: + - type: integer + minimum: 0 + maximum: 10 + - type: "null" + eventType: + anyOf: + - type: string + enum: + - goodNews + - badNews + - success + - disappointment + - conflict + - appointment + - familyEvent + - change + - unexpected + - type: "null" + eventValence: + anyOf: + - type: integer + minimum: -5 + maximum: 5 + - type: "null" + eventAt: + anyOf: + - type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + - type: "null" + note: + anyOf: + - type: string + maxLength: 500 + - type: "null" + - type: "null" + moodLoggedAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + description: "Per-field mood-entry edit. Omitted fields keep the stored value. `tagKeys`, `ratedFactors` and `context` + replace their whole set when present (`null` clears). `a2`-`a5`: explicit null clears; `a1` null re-derives from + the label. Editing `mood` without `a1` re-derives pleasantness the same way it re-derives `score`. Editing + `moodLoggedAt` re-anchors the row's `date` (and `tz`) to the account's display timezone." + MoodEntryIdsRequest: + type: object + properties: + ids: + minItems: 1 + maxItems: 200 + type: array + items: + type: string + minLength: 1 + required: + - ids + description: "1-200 mood-entry ids, scoped to the resolved record. A forged / foreign / non-matching id is a silent + no-op, never a 404 existence leak: the mutation `where` pins the record owner's id." + JsonImportRequest: + type: object + properties: + measurements: + maxItems: 10000 + type: array + items: + $ref: "#/components/schemas/JsonImportMeasurement" + moodEntries: + maxItems: 10000 + type: array + items: + $ref: "#/components/schemas/JsonImportMoodEntry" + description: JSON import payload, matching the structure `GET /api/export/json` emits. Both sections optional; up to 10 + 000 rows each. + JsonImportMeasurement: + type: object + properties: + type: + type: string + enum: + - WEIGHT + - BLOOD_PRESSURE_SYS + - BLOOD_PRESSURE_DIA + - PULSE + - BODY_FAT + - SLEEP_DURATION + - ACTIVITY_STEPS + - BLOOD_GLUCOSE + - TOTAL_BODY_WATER + - BONE_MASS + - OXYGEN_SATURATION + - HEART_RATE_VARIABILITY + - RESTING_HEART_RATE + - ACTIVE_ENERGY_BURNED + - FLIGHTS_CLIMBED + - WALKING_RUNNING_DISTANCE + - VO2_MAX + - BODY_TEMPERATURE + - FAT_FREE_MASS + - FAT_MASS + - MUSCLE_MASS + - SKIN_TEMPERATURE + - PULSE_WAVE_VELOCITY + - VASCULAR_AGE + - VISCERAL_FAT + - AUDIO_EXPOSURE_ENV + - AUDIO_EXPOSURE_HEADPHONE + - TIME_IN_DAYLIGHT + - WALKING_STEADINESS + - AUDIO_EXPOSURE_EVENT + - RESPIRATORY_RATE + - BODY_MASS_INDEX + - LEAN_BODY_MASS + - WALKING_HEART_RATE_AVERAGE + - WALKING_ASYMMETRY + - WALKING_DOUBLE_SUPPORT + - WALKING_STEP_LENGTH + - WALKING_SPEED + - CARDIO_RECOVERY + - WRIST_TEMPERATURE + - FALL_COUNT + - SIX_MINUTE_WALK_DISTANCE + - STAIR_ASCENT_SPEED + - STAIR_DESCENT_SPEED + - BREATHING_DISTURBANCES + - IRREGULAR_RHYTHM_NOTIFICATION + - HIGH_HEART_RATE_EVENT + - LOW_HEART_RATE_EVENT + - WALKING_STEADINESS_EVENT + - BREATHING_DISTURBANCE_EVENT + - RECOVERY_SCORE + - STRESS_SCORE + - STRAIN_SCORE + - HRV_RMSSD + - DAY_STRAIN + - WORKOUT_STRAIN + - SLEEP_PERFORMANCE + - SLEEP_EFFICIENCY + - SLEEP_CONSISTENCY + - SLEEP_NEED + - ENERGY_EXPENDITURE_KJ + - AVERAGE_HEART_RATE + - MAX_HEART_RATE + - SLEEP_DISTURBANCE_COUNT + - ANS_CHARGE + - CARDIO_LOAD + - SLEEP_SCORE + - BODY_TEMPERATURE_DEVIATION + - RESILIENCE + - PHQ9_SCORE + - GAD7_SCORE + - GRIP_STRENGTH + - PAIN_NRS + - WAIST_CIRCUMFERENCE + - WAIST_TO_HEIGHT + - WHO5_SCORE + - SCI_SCORE + value: + type: number + description: Checked against the type's server-side plausibility range; an out-of-range value fails the whole request + with 422. + unit: + type: string + measuredAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + description: "Bounded: no future instants beyond a 5-min clock-skew tolerance, nothing before 1900." + glucoseContext: + description: Optional even on a BLOOD_GLUCOSE row — a sensor export classifies nothing per sample. + type: string + enum: + - FASTING + - POSTPRANDIAL + - RANDOM + - BEDTIME + source: + description: "Accepted for export-format compatibility and ignored: every imported row stores `source = IMPORT`." + type: string + notes: + description: Encrypted at rest. + type: string + externalId: + description: Source-stable id making re-import idempotent (upsert on `(userId, type, source=IMPORT, externalId)`; a + re-import updates the row in place). Absent → first-write-wins create with a NULL key, which DOES duplicate + on re-import. An unstable id (an object description or memory address that rotates per launch) skips THIS + row, never the file. + type: string + minLength: 1 + maxLength: 120 + required: + - type + - value + - unit + - measuredAt + description: One measurement row of a JSON import file. + JsonImportMoodEntry: + type: object + properties: + date: + type: string + pattern: ^\d{4}-\d{2}-\d{2}$ + description: Local-day label, YYYY-MM-DD. + mood: + type: string + enum: + - SUPER_GUT + - GUT + - OKAY + - SCHLECHT + - LAUSIG + score: + type: integer + minimum: 1 + maximum: 5 + description: Stored as sent — the export format's own field. Pleasantness (`a1`) is re-derived from `mood` on every + imported row, never taken from this. + tags: + description: JSON-encoded array of tag strings, stored verbatim. + type: string + loggedAt: + description: Bounded like `measuredAt`. Absent → the server derives a stable per-row instant within the `date` day, so a + re-import of the same file dedups instead of duplicating. + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + externalId: + description: "Source-stable id (e.g. a Daylio row id) making re-import idempotent (upsert on `(userId, source=IMPORT, + externalId)`). Same stability rule as the measurement `externalId`: an unstable id skips the row." + type: string + minLength: 1 + maxLength: 120 + required: + - date + - mood + - score + description: One mood-entry row of a JSON import file. MeasurementReminderCreate: type: object properties: @@ -17262,6 +18398,19 @@ components: type: boolean description: Partial edit of a Vorsorge reminder. Omitted fields are left untouched; nextDueAt is recomputed server-side after the patch applies. + MeasurementReminderSnooze: + type: object + properties: + until: + type: string + format: date + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$ + required: + - until + 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." RestoreLabResultsRequest: type: object properties: @@ -34670,6 +35819,499 @@ components: - data - error additionalProperties: false + MoodEntryListEnvelope: + type: object + properties: + data: + type: object + properties: + entries: + type: array + items: + $ref: "#/components/schemas/MoodEntryDTO" + meta: + type: object + properties: + total: + type: integer + minimum: 0 + maximum: 9007199254740991 + limit: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + offset: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + required: + - total + - limit + - offset + additionalProperties: false + required: + - entries + - meta + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + MoodEntryDTO: + type: object + properties: + id: + type: string + userId: + type: string + date: + type: string + description: "YYYY-MM-DD, anchored to the row's `tz` (legacy rows with `tz: null` read as Europe/Berlin)." + mood: + type: string + enum: + - SUPER_GUT + - GUT + - OKAY + - SCHLECHT + - LAUSIG + score: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: The legacy 1-5 axis, server-derived from `mood`. + a1: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + description: Pleasantness, 0-10. Server-derived from `mood` on every write when the request carried none, so it is + populated even for rows a client posted without it. Consume the value; do not re-derive it. + a2: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + description: "Stress, 0-10, INVERSE-oriented: a higher value is more stress. NULL when nobody answered it." + a3: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + description: Energy, 0-10, or null. + a4: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + description: Connectedness, 0-10, or null. + a5: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + description: Stability, 0-10, or null. + tags: + type: array + items: + type: string + description: Free-text tag list, decoded from storage; `[]` when none. + note: + anyOf: + - type: string + - type: "null" + description: Free-text note, decrypted for the response. AES-256-GCM at rest; the ciphertext never rides the wire. + source: + type: string + enum: + - MANUAL + - MOODLOG + - WEB + - TELEGRAM + - DAYLIO + externalId: + anyOf: + - type: string + - type: "null" + description: The sync client's source-stable dedup id, or null. + moodLoggedAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + tz: + anyOf: + - type: string + - type: "null" + description: IANA zone the `date` label is anchored to. Null on legacy rows (read as Europe/Berlin). + syncedAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + createdAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + updatedAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + syncVersion: + type: integer + minimum: 0 + maximum: 9007199254740991 + description: LWW reconciliation counter; mood is last-writer-wins by it. + deletedAt: + type: "null" + description: "Always null on this surface: tombstoned rows are hidden from the list and 404 on a direct GET. Tombstones + surface on the `/api/sync/changes` feed instead." + tagKeys: + type: array + items: + type: string + description: Binary structured-tag keys linked to the entry. + ratedFactors: + type: array + items: + $ref: "#/components/schemas/MoodEntryRatedFactor" + context: + anyOf: + - $ref: "#/components/schemas/MoodContextOutput" + - type: "null" + description: The day's context, or null when the entry carries none. Null and an object of nulls are different answers. + required: + - id + - userId + - date + - mood + - score + - a1 + - a2 + - a3 + - a4 + - a5 + - tags + - note + - source + - externalId + - moodLoggedAt + - tz + - syncedAt + - createdAt + - updatedAt + - syncVersion + - deletedAt + - tagKeys + - ratedFactors + - context + additionalProperties: false + description: One mood entry as every entry surface answers it — list rows, single GET, create and edit responses share + this shape, so a client hydrating from a write response renders without a refetch. + MoodEntryRatedFactor: + type: object + properties: + key: + type: string + rating: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + required: + - key + - rating + additionalProperties: false + description: One rated factor on an entry, with its per-entry score. + MoodEntryEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/MoodEntryDTO" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + MoodEntryDeleteEnvelope: + type: object + properties: + data: + type: object + properties: + deleted: + type: boolean + const: true + required: + - deleted + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + MoodEntriesRestoreEnvelope: + type: object + properties: + data: + type: object + properties: + restored: + type: integer + minimum: 0 + maximum: 9007199254740991 + required: + - restored + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + MoodEntriesBulkDeleteEnvelope: + type: object + properties: + data: + type: object + properties: + deleted: + type: integer + minimum: 0 + maximum: 9007199254740991 + required: + - deleted + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + MoodLinkedContextEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/MoodLinkedContext" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + MoodLinkedContext: + type: object + properties: + day: + type: string + description: The local day the figures are about, YYYY-MM-DD. + sleep: + anyOf: + - type: object + properties: + available: + type: boolean + const: false + reason: + type: string + const: module-disabled + required: + - available + - reason + additionalProperties: false + - type: object + properties: + available: + type: boolean + const: true + asleep: + description: Time asleep (minutes) for the night the day woke up on. + $ref: "#/components/schemas/MoodLinkedFigure" + inBed: + description: Time in bed (minutes), when any writer recorded a bed window. + $ref: "#/components/schemas/MoodLinkedFigure" + required: + - available + - asleep + - inBed + additionalProperties: false + activity: + anyOf: + - type: object + properties: + available: + type: boolean + const: false + reason: + type: string + const: module-disabled + required: + - available + - reason + additionalProperties: false + - type: object + properties: + available: + type: boolean + const: true + steps: + $ref: "#/components/schemas/MoodLinkedFigure" + activeEnergy: + $ref: "#/components/schemas/MoodLinkedFigure" + required: + - available + - steps + - activeEnergy + additionalProperties: false + vitals: + anyOf: + - type: object + properties: + available: + type: boolean + const: false + reason: + type: string + const: module-disabled + required: + - available + - reason + additionalProperties: false + - type: object + properties: + available: + type: boolean + const: true + restingHeartRate: + $ref: "#/components/schemas/MoodLinkedFigure" + heartRateVariability: + $ref: "#/components/schemas/MoodLinkedFigure" + required: + - available + - restingHeartRate + - heartRateVariability + additionalProperties: false + body: + anyOf: + - type: object + properties: + available: + type: boolean + const: false + reason: + type: string + const: module-disabled + required: + - available + - reason + additionalProperties: false + - type: object + properties: + available: + type: boolean + const: true + logged: + type: boolean + description: Whether an illness day-log exists for this day at all. + functionalImpact: + description: How much the day was limited, 0-3, as the illness module records it. + $ref: "#/components/schemas/MoodLinkedFigure" + symptoms: + type: array + items: + type: string + description: Symptoms linked to the day-log, by catalogue key. + episodeId: + anyOf: + - type: string + - type: "null" + description: The episode to open in the illness module, when any. + required: + - available + - logged + - functionalImpact + - symptoms + - episodeId + additionalProperties: false + required: + - day + - sleep + - activity + - vitals + - body + additionalProperties: false + description: "What the modules that own sleep, activity, vitals and body symptoms already know about one local day. + Nothing here is stored on the mood row — every figure is resolved from its owning module at read time, + cross-source de-duplicated to one canonical source per metric. A switched-off module blanks its whole block + (`available: false`)." + MoodLinkedFigure: + anyOf: + - type: object + properties: + present: + type: boolean + const: false + required: + - present + additionalProperties: false + - type: object + properties: + present: + type: boolean + const: true + value: + type: number + unit: + type: string + required: + - present + - value + - unit + additionalProperties: false + description: "A figure that either exists or honestly does not. `{ present: false }` means the data is not recorded — + never zero, never an error." GetReminderThresholdsResponse: type: object properties: @@ -34920,6 +36562,47 @@ components: - data - error additionalProperties: false + JsonImportStatsEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/JsonImportStats" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + JsonImportStats: + type: object + properties: + measurements: + type: integer + minimum: 0 + maximum: 9007199254740991 + description: Measurement rows written (inserted or updated in place). + moodEntries: + type: integer + minimum: 0 + maximum: 9007199254740991 + description: Mood-entry rows written (inserted or updated in place). + skipped: + type: integer + minimum: 0 + maximum: 9007199254740991 + description: "Rows not written: duplicates of existing rows, and rows carrying an unstable `externalId`." + required: + - measurements + - moodEntries + - skipped + additionalProperties: false + description: Per-section outcome counts of a JSON import. MeasurementReminderListEnvelope: type: object properties: @@ -34997,6 +36680,22 @@ components: format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" + snoozedUntil: + anyOf: + - type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + - type: "null" + lastSkippedAt: + anyOf: + - type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + - type: "null" + skipCount: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 enabled: type: boolean createdAt: @@ -35020,14 +36719,19 @@ components: - location - nextDueAt - lastSatisfiedAt + - snoozedUntil + - lastSkippedAt + - skipCount - enabled - createdAt - updatedAt additionalProperties: false - description: A Vorsorge reminder. nextDueAt is server-computed (server-authoritative). A free-text reminder carries + 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). + 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." MeasurementReminderCreatedEnvelope: type: object properties: @@ -35148,8 +36852,152 @@ components: - reminder additionalProperties: false 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. + 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. + MeasurementReminderSkipEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/MeasurementReminderSkip" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + MeasurementReminderSkip: + type: object + properties: + skipped: + type: boolean + reminder: + $ref: "#/components/schemas/MeasurementReminderDTO" + required: + - skipped + - reminder + additionalProperties: false + 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. + MeasurementReminderSnoozeEnvelope: + type: object + properties: + data: + type: object + properties: + reminder: + $ref: "#/components/schemas/MeasurementReminderDTO" + required: + - reminder + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + MeasurementReminderHistoryEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/MeasurementReminderHistory" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + MeasurementReminderHistory: + type: object + properties: + events: + type: array + items: + $ref: "#/components/schemas/MeasurementReminderEvent" + meta: + type: object + properties: + total: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + limit: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + offset: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + required: + - total + - limit + - offset + additionalProperties: false + required: + - events + - meta + additionalProperties: false + description: Paginated completion ledger for one reminder, newest first. + MeasurementReminderEvent: + type: object + properties: + id: + type: string + kind: + type: string + enum: + - SATISFIED + - SKIPPED + occurredAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + onTime: + type: boolean + source: + type: string + enum: + - manual + - auto_measurement + - auto_lab + - telegram + - vaccination + - encounter + - skip + createdAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + required: + - id + - kind + - occurredAt + - onTime + - source + - createdAt + additionalProperties: false + 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." ListLabResultsEnvelope: type: object properties: diff --git a/messages/de.json b/messages/de.json index e9b6b1877..604e98166 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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", diff --git a/messages/en.json b/messages/en.json index ce24821f3..846315bde 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/es.json b/messages/es.json index c84064132..ba5f61255 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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", diff --git a/messages/fr.json b/messages/fr.json index 8243162db..2035de369 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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", diff --git a/messages/it.json b/messages/it.json index b4c8313c9..a3ffb3c95 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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", diff --git a/messages/pl.json b/messages/pl.json index c250f3c8d..5d494ecd6 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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", diff --git a/src/__tests__/encounter-reminder-exclusion.test.ts b/src/__tests__/encounter-reminder-exclusion.test.ts index a136120ec..99817264f 100644 --- a/src/__tests__/encounter-reminder-exclusion.test.ts +++ b/src/__tests__/encounter-reminder-exclusion.test.ts @@ -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, ); }); diff --git a/src/__tests__/sharing-surface-guard.test.ts b/src/__tests__/sharing-surface-guard.test.ts index b9d3f9897..d37eee72c 100644 --- a/src/__tests__/sharing-surface-guard.test.ts +++ b/src/__tests__/sharing-surface-guard.test.ts @@ -1140,6 +1140,18 @@ const DELEGABLE_ROUTES: Record = { 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 = { 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 = { * 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 }) => diff --git a/src/app/api/measurement-reminders/[id]/__tests__/skip-snooze-history.test.ts b/src/app/api/measurement-reminders/[id]/__tests__/skip-snooze-history.test.ts new file mode 100644 index 000000000..8483ec1f6 --- /dev/null +++ b/src/app/api/measurement-reminders/[id]/__tests__/skip-snooze-history.test.ts @@ -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: 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); + }); +}); diff --git a/src/app/api/measurement-reminders/[id]/complete/route.ts b/src/app/api/measurement-reminders/[id]/complete/route.ts index 23fcc71b6..a993579cd 100644 --- a/src/app/api/measurement-reminders/[id]/complete/route.ts +++ b/src/app/api/measurement-reminders/[id]/complete/route.ts @@ -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 }, diff --git a/src/app/api/measurement-reminders/[id]/history/route.ts b/src/app/api/measurement-reminders/[id]/history/route.ts new file mode 100644 index 000000000..19872c4fc --- /dev/null +++ b/src/app/api/measurement-reminders/[id]/history/route.ts @@ -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 }, + }); + }, +); diff --git a/src/app/api/measurement-reminders/[id]/route.ts b/src/app/api/measurement-reminders/[id]/route.ts index a8785b2ae..f51d742ca 100644 --- a/src/app/api/measurement-reminders/[id]/route.ts +++ b/src/app/api/measurement-reminders/[id]/route.ts @@ -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 }, diff --git a/src/app/api/measurement-reminders/[id]/satisfy/route.ts b/src/app/api/measurement-reminders/[id]/satisfy/route.ts index 7af4ccbb7..d5f35d63d 100644 --- a/src/app/api/measurement-reminders/[id]/satisfy/route.ts +++ b/src/app/api/measurement-reminders/[id]/satisfy/route.ts @@ -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 }, diff --git a/src/app/api/measurement-reminders/[id]/skip/route.ts b/src/app/api/measurement-reminders/[id]/skip/route.ts new file mode 100644 index 000000000..7405a958c --- /dev/null +++ b/src/app/api/measurement-reminders/[id]/skip/route.ts @@ -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), + }); + }, +); diff --git a/src/app/api/measurement-reminders/[id]/snooze/route.ts b/src/app/api/measurement-reminders/[id]/snooze/route.ts new file mode 100644 index 000000000..bc5f06826 --- /dev/null +++ b/src/app/api/measurement-reminders/[id]/snooze/route.ts @@ -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) }); + }, +); diff --git a/src/components/__tests__/delegated-write-affordances.test.tsx b/src/components/__tests__/delegated-write-affordances.test.tsx index 9e800346c..957d780ea 100644 --- a/src/components/__tests__/delegated-write-affordances.test.tsx +++ b/src/components/__tests__/delegated-write-affordances.test.tsx @@ -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", diff --git a/src/components/measurement-reminders/vorsorge-section.tsx b/src/components/measurement-reminders/vorsorge-section.tsx index 74f771938..c034989be 100644 --- a/src/components/measurement-reminders/vorsorge-section.tsx +++ b/src/components/measurement-reminders/vorsorge-section.tsx @@ -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 } /> @@ -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({ {t("measurementReminders.edit")} + {/* 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). */} + setPostponeOpen(true)} + > + + {t("measurementReminders.postpone.menuItem")} + {/* 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({ ); + // 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 = ( + { + if (!open) closePostpone(); + }} + title={t("measurementReminders.postpone.title")} + description={t("measurementReminders.postpone.description")} + footer={ + <> + + + + } + > +
+ {/* Quick chips write the date field rather than firing directly, so + the person always sees (and can adjust) what will be sent. */} +
+ {SNOOZE_CHIP_DAYS.map((days) => { + const chipDate = isoDayInTz(now + days * DAY_MS, displayTz); + return ( + + ); + })} +
+
+ + +
+ {/* 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. */} +
+

+ {t("measurementReminders.skip.heading")} +

+

+ {t("measurementReminders.skip.explainer")} +

+ +
+
+
+ ); + + const skipConfirmDialog = ( + + + + + {t("measurementReminders.skip.confirmTitle")} + + + {t("measurementReminders.skip.explainer")} + + + + {t("common.cancel")} + { + onSkip(); + closePostpone(); + }} + > + {t("measurementReminders.skip.action")} + + + + + ); + + // 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 ? ( +
+ {isSnoozed && reminder.snoozedUntil && ( +

+ {t("measurementReminders.snoozedUntil", { + date: fmt.date(new Date(reminder.snoozedUntil)), + })} +

+ )} + {isSkippedCycle && reminder.lastSkippedAt && ( +

+ {t("measurementReminders.skippedOn", { + date: fmt.date(new Date(reminder.lastSkippedAt)), + })} + {reminder.skipCount > 1 + ? ` · ${t("measurementReminders.skipCount", { + count: reminder.skipCount, + })}` + : null} +

+ )} +
+ ) : null; + // v1.18.6 (MOD-03) — compact list row variant. if (view === "list") { return ( @@ -1030,6 +1228,7 @@ function VorsorgeCard({ {categoryLabel} {cadenceText ? {cadenceText} : null} + {skipSnoozeMeta}
{/* The same gate the cards branch applies through @@ -1078,6 +1277,8 @@ function VorsorgeCard({ {deleteDialog} + {postponeSheet} + {skipConfirmDialog} ); } @@ -1156,6 +1357,7 @@ function VorsorgeCard({ /> {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({ {deleteDialog} + {postponeSheet} + {skipConfirmDialog} ); } diff --git a/src/hooks/use-measurement-reminders.ts b/src/hooks/use-measurement-reminders.ts index de2c35f8c..b42a564c8 100644 --- a/src/hooks/use-measurement-reminders.ts +++ b/src/hooks/use-measurement-reminders.ts @@ -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 }; } diff --git a/src/lib/encounters/service.ts b/src/lib/encounters/service.ts index 7951e187e..705596437 100644 --- a/src/lib/encounters/service.ts +++ b/src/lib/encounters/service.ts @@ -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", ); } diff --git a/src/lib/jobs/measurement-reminder.ts b/src/lib/jobs/measurement-reminder.ts index c53f6c365..1151a35ab 100644 --- a/src/lib/jobs/measurement-reminder.ts +++ b/src/lib/jobs/measurement-reminder.ts @@ -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; diff --git a/src/lib/mcp/__tests__/tools.test.ts b/src/lib/mcp/__tests__/tools.test.ts index 25013a93d..af886b612 100644 --- a/src/lib/mcp/__tests__/tools.test.ts +++ b/src/lib/mcp/__tests__/tools.test.ts @@ -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 <<>>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", diff --git a/src/lib/mcp/tools.ts b/src/lib/mcp/tools.ts index 95695bb7d..87216ec11 100644 --- a/src/lib/mcp/tools.ts +++ b/src/lib/mcp/tools.ts @@ -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, }; }); diff --git a/src/lib/measurement-reminders/__tests__/resolve.test.ts b/src/lib/measurement-reminders/__tests__/resolve.test.ts index 13c665ef7..ac06f2e70 100644 --- a/src/lib/measurement-reminders/__tests__/resolve.test.ts +++ b/src/lib/measurement-reminders/__tests__/resolve.test.ts @@ -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", () => { diff --git a/src/lib/measurement-reminders/__tests__/satisfy.test.ts b/src/lib/measurement-reminders/__tests__/satisfy.test.ts index 251737bd8..a557dab13 100644 --- a/src/lib/measurement-reminders/__tests__/satisfy.test.ts +++ b/src/lib/measurement-reminders/__tests__/satisfy.test.ts @@ -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; data: Record; }> = []; + const events: Array> = []; 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 }) => { + 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", + }); + }); }); diff --git a/src/lib/measurement-reminders/dto.ts b/src/lib/measurement-reminders/dto.ts index 0890cfd8f..0f53f286a 100644 --- a/src/lib/measurement-reminders/dto.ts +++ b/src/lib/measurement-reminders/dto.ts @@ -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(), diff --git a/src/lib/measurement-reminders/resolve.ts b/src/lib/measurement-reminders/resolve.ts index 8af993000..fbc664c45 100644 --- a/src/lib/measurement-reminders/resolve.ts +++ b/src/lib/measurement-reminders/resolve.ts @@ -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; } /** diff --git a/src/lib/measurement-reminders/satisfy.ts b/src/lib/measurement-reminders/satisfy.ts index 50ada950c..b167bc025 100644 --- a/src/lib/measurement-reminders/satisfy.ts +++ b/src/lib/measurement-reminders/satisfy.ts @@ -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, ): Promise { // 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 { + 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 }; +} diff --git a/src/lib/openapi/routes/measurement-reminders.ts b/src/lib/openapi/routes/measurement-reminders.ts index d0309e006..f4eb4a2b4 100644 --- a/src/lib/openapi/routes/measurement-reminders.ts +++ b/src/lib/openapi/routes/measurement-reminders.ts @@ -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 = }, }, }, + "/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, + }, + }, + }, }; diff --git a/src/lib/query-keys/measurement-reminders.ts b/src/lib/query-keys/measurement-reminders.ts index f5cce1af2..d60214a99 100644 --- a/src/lib/query-keys/measurement-reminders.ts +++ b/src/lib/query-keys/measurement-reminders.ts @@ -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, }; diff --git a/src/lib/record-activity/activity-verb.ts b/src/lib/record-activity/activity-verb.ts index 61f2746bf..b9e7a128d 100644 --- a/src/lib/record-activity/activity-verb.ts +++ b/src/lib/record-activity/activity-verb.ts @@ -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 }); diff --git a/src/lib/telegram-webhook-handlers.ts b/src/lib/telegram-webhook-handlers.ts index 822b20460..5d05f0239 100644 --- a/src/lib/telegram-webhook-handlers.ts +++ b/src/lib/telegram-webhook-handlers.ts @@ -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, diff --git a/src/lib/vaccinations/satisfy-matching.ts b/src/lib/vaccinations/satisfy-matching.ts index 8518b622c..7ec77fcac 100644 --- a/src/lib/vaccinations/satisfy-matching.ts +++ b/src/lib/vaccinations/satisfy-matching.ts @@ -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); } diff --git a/src/lib/validations/measurement-reminders.ts b/src/lib/validations/measurement-reminders.ts index 22dad2305..31e9d2f6a 100644 --- a/src/lib/validations/measurement-reminders.ts +++ b/src/lib/validations/measurement-reminders.ts @@ -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.", }); diff --git a/tests/fixtures/v137/sharing-matrix.ts b/tests/fixtures/v137/sharing-matrix.ts index 37de10ffd..e90d4748e 100644 --- a/tests/fixtures/v137/sharing-matrix.ts +++ b/tests/fixtures/v137/sharing-matrix.ts @@ -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]",