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]",