diff --git a/CHANGELOG.md b/CHANGELOG.md index 9bccae692..3679ccca1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,28 @@ # Changelog +## [1.37.20] — 2026-08-14 + +### Added + +- A checkup reminder can now be postponed or skipped. Postponing moves the next due date to a day you pick; skipping closes the current cycle and restarts the interval from today, without pretending the checkup happened. Every completion and every skip lands in a new history, kept per reminder, including whether it happened on time, so the checkups page and the API can show how a reminder has actually been handled over the years. +- The dashboard hero can show the reminder rail instead of the health score. The choice lives in the dashboard settings and does nothing until you change it. +- The restore dialog shows what a backup contains before you confirm: per-type counts read from the stored file, with an honest note when the preview cannot be computed. +- A mental-health screening shows its per-item answers on the detail view, so you can see which questions carried the score, not only the total. +- Document search understands German and English medical synonyms: searching for "Blutdruck" finds a report that says "blood pressure", and the other way round. +- A deleted custom-metric entry can be brought back with one tap for a short while instead of being gone the moment you tap. + +### Changed + +- Onboarding says plainly that Apple Health data arrives through the iPhone app, and the Apple Health card reports the backfill progress the server actually sees: accepted counts, the oldest reading, and how recently data last arrived. +- The measurements and mood pages keep their filters in the address bar, so a filtered view survives a reload and can be shared as a link, the way documents already worked. +- The sleep-stage tooltip labels one night's figures as main sleep, naps and total sleep instead of an averaged label that fit none of them. +- The daily digest is available offline like the rest of the app shell. +- Backups carry the checkup reminders and their new completion history end to end, and an appointment or vaccination that references a reminder keeps that link across a restore. + +### Fixed + +- Apple Health ECG exports in the single-column layout import correctly. Apple writes two CSV shapes; the importer understood only the paired one and skipped every recording in the other, reporting each row as malformed. Both layouts parse now, and the declared unit is converted before validation. + ## [1.37.19] — 2026-08-14 ### Changed @@ -20,6 +43,8 @@ - A simultaneous double-submit of the same mood entry reports "duplicate" with the winning entry instead of a confusing error. - The intake-event API description now documents every field the server actually returns, and the workout list description matches the real nullability, so generated clients decode without surprises. +## [1.37.18] — 2026-08-14 + ### Fixed - Coach reminders tied to a moment ("remind me when I next log blood pressure", "next time I open the app") now actually fire. The app has offered these since v1.22 and confirmed them, but nothing ever evaluated them; a matching measurement or the next app open now surfaces the reminder, and the daily sweep backs it up. diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index e86c3cfb4..78f641f45 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.37.19 + version: 1.37.20 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. @@ -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: @@ -11658,6 +12114,32 @@ paths: "403": *a6 "422": *a3 "429": *a4 + /api/mental-health/assessments/{id}: + get: + tags: + - Mental health + summary: Read one administration with its decrypted answers + description: "Returns a single screener administration including the decrypted per-item answers (and the PHQ-9's + unscored functional follow-up when answered) — the one surface that reads them back. When the stored blob cannot + be decrypted the read degrades to `items: null` + `itemsUnavailable: true` instead of failing; the total / band + / flag still answer. A soft-deleted or foreign row answers 404." + parameters: + - in: path + name: id + schema: + type: string + required: true + responses: + "200": + description: The administration with its answer breakdown. + content: + application/json: + schema: + $ref: "#/components/schemas/GetMentalHealthAssessmentEnvelope" + "401": *a1 + "403": *a6 + "422": *a3 + "429": *a4 /api/allergies: get: tags: @@ -11788,7 +12270,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 +12353,7 @@ paths: $ref: "#/components/schemas/UpdateAllergyEnvelope" "401": *a1 "403": *a6 - "404": *a20 + "404": *a21 "422": *a3 "429": *a4 delete: @@ -11895,7 +12377,7 @@ paths: $ref: "#/components/schemas/DeleteAllergyEnvelope" "401": *a1 "403": *a6 - "404": *a20 + "404": *a21 "422": *a3 "429": *a4 /api/family-history: @@ -12003,7 +12485,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 +12554,7 @@ paths: $ref: "#/components/schemas/UpdateFamilyHistoryEnvelope" "401": *a1 "403": *a6 - "404": *a21 + "404": *a22 "422": *a3 "429": *a4 delete: @@ -12096,7 +12578,7 @@ paths: $ref: "#/components/schemas/DeleteFamilyHistoryEnvelope" "401": *a1 "403": *a6 - "404": *a21 + "404": *a22 "422": *a3 "429": *a4 /api/documents/inbound: @@ -13249,7 +13731,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 +13760,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 +13824,8 @@ paths: schema: $ref: "#/components/schemas/UpdateCustomMetricResponse" "401": *a1 - "404": *a22 - "409": *a23 + "404": *a23 + "409": *a24 "422": *a3 "429": *a4 delete: @@ -13366,7 +13848,7 @@ paths: schema: $ref: "#/components/schemas/DeleteCustomMetricResponse" "401": *a1 - "404": *a22 + "404": *a23 "422": *a3 "429": *a4 /api/custom-metrics/{id}/entries: @@ -13410,7 +13892,7 @@ paths: $ref: "#/components/schemas/ListCustomMetricEntriesEnvelope" "401": *a1 "403": *a6 - "404": *a22 + "404": *a23 "422": *a3 "429": *a4 post: @@ -13454,7 +13936,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 +13986,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: @@ -13516,7 +13998,8 @@ paths: tags: - Custom metrics summary: Delete a logged value - description: Hard-deletes the value. Audits as `customMetricEntry.delete`. + description: Tombstones the value (soft delete, v1.37.20); the sibling entries/restore endpoint clears the tombstone. + Audits as `customMetricEntry.delete`. parameters: - in: path name: id @@ -13536,7 +14019,37 @@ paths: schema: $ref: "#/components/schemas/DeleteCustomMetricEntryResponse" "401": *a1 - "404": *a24 + "404": *a25 + "422": *a3 + "429": *a4 + /api/custom-metrics/{id}/entries/restore: + post: + tags: + - Custom metrics + summary: Restore tombstoned values (v1.37.20) + description: Clears deletedAt on every owned, currently-tombstoned entry of this metric named in ids (1..200). A + foreign, live or mismatched-parent id is a silent no-op, never an existence leak. Backs the delete toast's Undo. + Audits as `customMetricEntry.restore`. + parameters: + - in: path + name: id + schema: + type: string + required: true + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/RestoreCustomMetricEntriesRequest" + responses: + "200": + description: Count of entries restored. + content: + application/json: + schema: + $ref: "#/components/schemas/RestoreCustomMetricEntriesResponse" + "401": *a1 "422": *a3 "429": *a4 /api/onboarding/disclaimer: @@ -13706,7 +14219,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 +14274,7 @@ paths: schema: $ref: "#/components/schemas/NutrientDailySeriesEnvelope" "401": *a1 - "403": *a25 + "403": *a26 "422": *a3 "429": *a4 /api/nutrients/water: @@ -13788,7 +14301,7 @@ paths: schema: $ref: "#/components/schemas/NutrientWaterWriteEnvelope" "401": *a1 - "403": *a25 + "403": *a26 "422": *a3 "429": *a4 /api/integrations/healthkit: @@ -14071,7 +14584,7 @@ paths: - Dashboard summary: Reset the calling user's dashboard widget layout description: Resets the tile layout and Today-highlight visibility. It preserves the stored comparison baseline, chart - overlays, selected score rings, and hero ring order. Idempotent. + overlays, selected score rings, hero ring order, and hero primary content. Idempotent. responses: "200": description: Web-controlled layout settings reset; preserved dashboard preferences are returned with the defaults. @@ -14126,7 +14639,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 +14664,7 @@ paths: schema: $ref: "#/components/schemas/AccountGrantListEnvelope" "401": *a1 - "403": *a26 + "403": *a27 "422": *a3 "429": *a4 post: @@ -14246,7 +14759,7 @@ paths: schema: $ref: "#/components/schemas/AcceptedAccountGrantEnvelope" "401": *a1 - "403": *a26 + "403": *a27 "404": description: No such invitation for this account. content: @@ -14290,7 +14803,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 +14844,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 +15143,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 +17659,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 +18455,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: @@ -17334,6 +18540,18 @@ components: - de required: - message + RestoreCustomMetricEntriesRequest: + type: object + properties: + ids: + minItems: 1 + maxItems: 200 + type: array + items: + type: string + minLength: 1 + required: + - ids DisclaimerAckRequest: type: object properties: @@ -17725,6 +18943,14 @@ components: - tension_window - same_time_baseline - upcoming_visit + hero: + description: 'Primary hero-card content on the dashboard: "score" (the health-score read, default) or "reminders" (the + Today-highlight rail promoted into the hero slot). Missing resolves to "score"; the stored default is + omitted from responses.' + type: string + enum: + - score + - reminders baseUpdatedAt: description: "Optimistic-concurrency base token: the `updatedAt` the client last read for this resource. Omit it for the legacy unconditional write (older clients are unaffected). When present, the write is guarded on the stored @@ -34670,6 +35896,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 +36639,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 +36757,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 +36796,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 +36929,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: @@ -37842,7 +39767,8 @@ components: additionalProperties: false description: One completed screener administration. `severityBand` is a descriptive label for the screen (never a diagnosis). `item9Flagged` is true when the PHQ-9 self-harm item was answered > 0 — the client MUST show crisis - resources when it is. Raw per-item answers are intentionally absent. + resources when it is. Raw per-item answers are intentionally absent from list rows; the by-id detail read + returns them decrypted. CreateMentalHealthAssessmentEnvelope: type: object properties: @@ -37904,6 +39830,96 @@ components: - id - contacts additionalProperties: false + GetMentalHealthAssessmentEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/GetMentalHealthAssessmentResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + GetMentalHealthAssessmentResponse: + type: object + properties: + assessment: + $ref: "#/components/schemas/MentalHealthAssessmentDetail" + required: + - assessment + additionalProperties: false + MentalHealthAssessmentDetail: + type: object + properties: + id: + type: string + instrument: + type: string + enum: + - PHQ9 + - GAD7 + - WHO5 + - SCI + locale: + type: string + version: + type: string + totalScore: + type: number + severityBand: + type: string + item9Flagged: + type: boolean + crisisShownAt: + anyOf: + - type: string + - type: "null" + takenAt: + type: string + createdAt: + type: string + items: + anyOf: + - type: array + items: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + functionalDifficulty: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + itemsUnavailable: + type: boolean + required: + - id + - instrument + - locale + - version + - totalScore + - severityBand + - item9Flagged + - crisisShownAt + - takenAt + - createdAt + - items + - functionalDifficulty + - itemsUnavailable + additionalProperties: false + description: "One administration with its decrypted per-item answers, in the order the instrument presents its items + (PHQ-9/GAD-7 answers 0–3, SCI 0–4, WHO-5 0–5). `functionalDifficulty` is the PHQ-9's unscored functional + follow-up when it was answered. When the stored blob cannot be decrypted the read degrades honestly: `items` is + null, `itemsUnavailable` is true, and the denormalised score fields still answer." ListAllergiesEnvelope: type: object properties: @@ -39566,6 +41582,31 @@ components: - data - error additionalProperties: false + RestoreCustomMetricEntriesResponse: + type: object + properties: + data: + type: object + properties: + restored: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + required: + - restored + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false DisclaimerAckEnvelope: type: object properties: @@ -39955,6 +41996,12 @@ components: type: array items: $ref: "#/components/schemas/MetricFreshnessEntry" + syncProgress: + description: Backfill/sync progress summary. Present on the GET read (null when the progress read failed); omitted from + the PATCH echo. + anyOf: + - $ref: "#/components/schemas/AppleHealthSyncProgress" + - type: "null" required: - entries - lastSyncedAt @@ -40038,6 +42085,29 @@ components: additionalProperties: false description: Per-metric-type last-seen timestamp with the server-computed staleness flag. Only types that have actually delivered appear — absence is absence, never an invented row. + AppleHealthSyncProgress: + type: object + properties: + recordsAccepted: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: Measurement + workout rows carrying the `APPLE_HEALTH` source (live batch ingest and the one-shot export + import both write it). Soft-deleted measurement rows are excluded. + oldestMeasuredAt: + 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" + description: Earliest instant reached across those rows (`measuredAt` for measurements, `startedAt` for workouts); null + when nothing has arrived. During a first-run backfill this walks backwards as history lands. + required: + - recordsAccepted + - oldestMeasuredAt + additionalProperties: false + description: "Server-side Apple Health backfill progress: how many rows have been accepted and how far back in time they + reach. Only what the server actually knows — no ETA, no device-side queue state." HealthKitConfigPatchEnvelope: type: object properties: @@ -40750,6 +42820,14 @@ components: - tension_window - same_time_baseline - upcoming_visit + hero: + description: 'Primary hero-card content on the dashboard: "score" (the health-score read, default) or "reminders" (the + Today-highlight rail promoted into the hero slot). Missing resolves to "score"; the stored default is + omitted from responses.' + type: string + enum: + - score + - reminders updatedAt: description: "Optimistic-concurrency token: the stored row's `updatedAt` at read/write time. Echo it back as `baseUpdatedAt` on the next write. Opaque." diff --git a/docs/ops/deploy.md b/docs/ops/deploy.md index c162169cd..57c3fd31e 100644 --- a/docs/ops/deploy.md +++ b/docs/ops/deploy.md @@ -131,6 +131,34 @@ without touching the file: - `HEALTHLOG_IMAGE_REF=:v1.16.5` — pin to a release tag (still mutable). - `HEALTHLOG_IMAGE_REF=@sha256:` — pin to the immutable digest. +#### Bumping a digest pin by hand + +A digest-pinned instance never follows a release on its own — an image +promote does not touch the pin, so moving it is part of shipping. The +mechanic, end to end: + +1. Resolve the new release's digest from the registry (the manifest-list + digest, which covers both architectures): + + ```sh + docker buildx imagetools inspect ghcr.io/mbombeck/healthlog:v1.37.20 \ + | grep -m1 Digest + ``` + +2. Set `HEALTHLOG_IMAGE_REF=@sha256:` where the compose + environment lives — the `.env` next to the compose file, or the + deployment platform's env vars for the app service. + +3. Deploy as usual. + +4. Verify `/api/version` on the target — and verify it **three times a + few seconds apart**, not once: during the container swap a single + probe can be answered by the outgoing container (or a cache) and read + as success while the old release keeps running. Three stable answers + reporting the shipped `version` + `buildSha` are the signal; a deploy + that finishes suspiciously fast (under ~30 s) is a warning that + nothing was actually pulled. + > **A pin is sticky, and a stale pin is silent.** Once set, every later deploy > ships that reference until someone changes it. The deploy still reports > success, the tag still builds, the image still publishes, and the running diff --git a/e2e/settings-integrations-mobile.spec.ts b/e2e/settings-integrations-mobile.spec.ts index b06f1bb41..f827442c8 100644 --- a/e2e/settings-integrations-mobile.spec.ts +++ b/e2e/settings-integrations-mobile.spec.ts @@ -243,6 +243,9 @@ test.describe("Apple Health guidance", () => { lastSyncedAt: null, syncHealth: { verdict: "pending_first_sync", since: null }, metricFreshness: [], + // #778 — nothing accepted yet, so the card must show no + // progress section for this pending-first-sync account. + syncProgress: { recordsAccepted: 0, oldestMeasuredAt: null }, }, error: null, }), diff --git a/messages/de.json b/messages/de.json index e9b6b1877..69ac50dd5 100644 --- a/messages/de.json +++ b/messages/de.json @@ -123,7 +123,7 @@ }, "profile": { "update": { - "nothingSaved": "Es wurde nichts gespeichert. Bitte das markierte Feld korrigieren und erneut versuchen.", + "nothingSaved": "Es wurde nichts gespeichert. Korrigiere das markierte Feld und versuche es erneut.", "invalidBody": "Die Profilaktualisierung konnte nicht gelesen werden.", "emailInUse": "Diese E-Mail-Adresse wird bereits verwendet." } @@ -516,6 +516,9 @@ "heroItemsTitle": "Highlights für heute", "heroItemsDescription": "Wähle aus, welche Eintragstypen in den Highlights für heute erscheinen.", "heroItemsNotificationNote": "Benachrichtigungseinstellungen werden separat verwaltet.", + "heroContentTitle": "Obere Karte zeigt", + "heroContentScore": "Gesundheitsscore und Tagesüberblick", + "heroContentReminders": "Highlights für heute", "heroItems": { "coach_checkin": "Coach-Check-ins", "dose_window": "Medikamentendosen", @@ -664,13 +667,13 @@ "formReset": "Formular zurücksetzen", "saveError": "Fehler beim Speichern", "duplicateTimestamp": "Zum gewählten Zeitpunkt existiert bereits ein Messwert dieses Typs.", - "serverOwnedSource": "Werte aus einer verbundenen Quelle lassen sich nicht bearbeiten – Zeitpunkt und Notiz bleiben änderbar.", + "serverOwnedSource": "Werte aus einer verbundenen Quelle lassen sich nicht bearbeiten — Zeitpunkt und Notiz bleiben änderbar.", "deleteError": "Fehler beim Löschen", "deletedToast": "Messwert gelöscht", "restoredToast": "Messwert wiederhergestellt", "restoreError": "Wiederherstellen fehlgeschlagen", - "invalidValue": "Bitte einen gültigen Wert eingeben.", - "invalidTimestamp": "Bitte einen gültigen Zeitpunkt wählen.", + "invalidValue": "Gib einen gültigen Wert ein.", + "invalidTimestamp": "Wähle einen gültigen Zeitpunkt.", "loginRequired": "Bitte anmelden, um Messwerte zu sehen.", "allTypes": "Alle Typen", "measurementCount": "{count} Messwerte", @@ -805,7 +808,7 @@ "deletedToast": "Eintrag gelöscht", "restoredToast": "Eintrag wiederhergestellt", "restoreError": "Wiederherstellen fehlgeschlagen", - "invalidTimestamp": "Bitte einen gültigen Zeitpunkt wählen.", + "invalidTimestamp": "Wähle einen gültigen Zeitpunkt.", "deleteConfirmTitle": "Eintrag löschen?", "deleteConfirmDescription": "Dieser Stimmungseintrag wird gelöscht. Direkt danach kannst du das kurz rückgängig machen.", "pageInfo": "Seite {page} von {total}", @@ -916,7 +919,7 @@ "archive": "Archivieren", "restore": "Wiederherstellen", "archivedTitle": "Archivierte Tags", - "archivedDescription": "Archivierte Tags bleiben in deinen bisherigen Einträgen erhalten – sie verschwinden nur aus der Auswahl.", + "archivedDescription": "Archivierte Tags bleiben in deinen bisherigen Einträgen erhalten — sie verschwinden nur aus der Auswahl.", "archivedEmpty": "Nichts archiviert.", "purgeTitle": "Tag endgültig löschen?", "purgeBody": "Der Tag wird aus {count} bisherigen Einträgen entfernt. Das lässt sich nicht rückgängig machen.", @@ -941,7 +944,7 @@ }, "sectionMoreTags": "Weitere Tags", "sectionNoteDetails": "Notiz & Details", - "quickTagsHelp": "Häufig genutzte Tags – ein Tipp genügt", + "quickTagsHelp": "Häufig genutzte Tags — ein Tipp genügt", "quickTagsExpand": "Weitere", "loadError": "Stimmungseinträge konnten nicht geladen werden.", "sectionLevelA": "Gefühlslage", @@ -1119,8 +1122,8 @@ "dialogDescription": "Diese Medikamente sind jetzt fällig. Bestätige, um sie alle als genommen zu erfassen.", "confirm": "Alle einnehmen ({count})", "successToast": "{count} Einnahmen erfasst", - "partialToast": "{taken} erfasst, {failed} fehlgeschlagen – bitte erneut versuchen", - "failedToast": "Keine Einnahme erfasst – bitte erneut versuchen" + "partialToast": "{taken} erfasst, {failed} fehlgeschlagen — bitte erneut versuchen", + "failedToast": "Keine Einnahme erfasst — bitte erneut versuchen" }, "logIntake": { "dialogTitle": "Einnahme erfassen", @@ -1151,7 +1154,7 @@ "importUploadFile": "JSON-Datei hochladen", "importPaste": "JSON hier einfügen…", "importSelected": "Ausgewählt: {name}", - "importFileLoaded": "Datei \"{name}\" geladen", + "importFileLoaded": "Datei „{name}“ geladen", "importInvalidJson": "Datei enthält kein gültiges JSON", "importNoArray": "Kein Array gefunden", "importFailed": "Import fehlgeschlagen", @@ -1204,10 +1207,10 @@ "skipped": "Übersprungen", "intakeToastTaken": "{name} als genommen erfasst", "intakeToastSkipped": "{name} übersprungen", - "intakeToastFailed": "{name} konnte nicht erfasst werden – bitte erneut versuchen", + "intakeToastFailed": "{name} konnte nicht erfasst werden — bitte erneut versuchen", "intakeUndo": "Rückgängig", "intakeUndone": "Eintrag zurückgenommen", - "intakeUndoFailed": "Rückgängig fehlgeschlagen – im Verlauf korrigieren", + "intakeUndoFailed": "Rückgängig fehlgeschlagen — im Verlauf korrigieren", "glp1LastInjection": "{label}", "glp1NextInjectionDays": "{label} (in {days} Tagen)", "glp1NextInjectionToday": "heute", @@ -1238,7 +1241,7 @@ "deleteCta": "Löschen", "deleteError": "Eintrag konnte nicht gelöscht werden", "errorSeverity": "Schweregrad muss zwischen 1 und 5 liegen", - "errorEntry": "Bitte ein Symptom auswählen", + "errorEntry": "Wähle ein Symptom aus", "categories": { "gi": "Magen-Darm", "metabolic": "Stoffwechsel", @@ -1287,7 +1290,7 @@ "rolling": "Methotrexat 7,5 mg alle 7 Tage nach der letzten Spritze" }, "error": { - "empty": "Bitte zuerst eine Beschreibung eingeben.", + "empty": "Gib zuerst eine Beschreibung ein.", "tooLong": "Beschreibung muss unter 2000 Zeichen bleiben.", "rateLimit": "Zu viele Anfragen — bitte kurz warten und erneut versuchen.", "noProvider": "Kein KI-Anbieter konfiguriert — bitte in den Einstellungen einrichten.", @@ -1443,7 +1446,7 @@ "dosesPerUnitHint": "Dosen pro Pen / Ampulle — leer lassen, wenn kein Bestand getrackt wird.", "short": "Dosis", "unitsPerDoseLabel": "Einheiten pro Dosis", - "unitsPerDoseHint": "z. B. 2 Tabletten à 2 mg für eine 4-mg-Dosis – oder ½ Tablette bei geteilter Dosis" + "unitsPerDoseHint": "z. B. 2 Tabletten à 2 mg für eine 4-mg-Dosis — oder ½ Tablette bei geteilter Dosis" }, "step4": { "title": "Über welchen Zeitraum nimmst du es?", @@ -1593,7 +1596,7 @@ }, "injectionSiteBodyOutlineAriaLabel": "Körperumriss", "injectionSitePickerAriaLabel": "Injektionsstelle auswählen", - "injectionSiteRecommendedAriaLabel": "{site} – empfohlene nächste Stelle", + "injectionSiteRecommendedAriaLabel": "{site} — empfohlene nächste Stelle", "injectionSiteLegendAriaLabel": "Legende der Markierungen", "injectionSiteLegendRecommended": "Empfohlene nächste Stelle", "injectionSiteLegendLastUsed": "Zuletzt verwendete Stelle", @@ -1707,7 +1710,7 @@ "dialogTitle": "Einnahme bearbeiten", "takenAtLabel": "Eingenommen um", "scheduledForHint": "Geplant: {dateTime}", - "farFromScheduledWarning": "Liegt weit vom geplanten Zeitpunkt entfernt – Datum prüfen.", + "farFromScheduledWarning": "Liegt weit vom geplanten Zeitpunkt entfernt — Datum prüfen.", "skippedLabel": "Übersprungen", "save": "Speichern", "cancel": "Abbrechen", @@ -1751,10 +1754,10 @@ }, "addDialog": { "title": "Einnahme hinzufügen", - "description": "Dosis nachtragen – auch rückwirkend oder außerplanmäßig.", + "description": "Dosis nachtragen — auch rückwirkend oder außerplanmäßig.", "takenAtLabel": "Genommen um", "skippedLabel": "Übersprungen", - "nudgeHint": "Liegt nah am Slot {slot} – beim Speichern fragen wir, ob zugeordnet werden soll.", + "nudgeHint": "Liegt nah am Slot {slot} — beim Speichern fragen wir, ob zugeordnet werden soll.", "cancel": "Abbrechen", "submit": "Speichern" }, @@ -1772,7 +1775,7 @@ "unpinnedToast": "Zuordnung gelöst", "dueContext": "fällig gewesen: {time} ({delta})", "confirmTitle": "Slot {time} zuordnen?", - "confirmBody": "Die Einnahme zählt für diesen Slot als verspätet – die Quote wird nicht geschönt. Die Zuordnung lässt sich jederzeit wieder lösen.", + "confirmBody": "Die Einnahme zählt für diesen Slot als verspätet — die Quote wird nicht geschönt. Die Zuordnung lässt sich jederzeit wieder lösen.", "confirmCancel": "Abbrechen", "confirmAction": "Zuordnen" } @@ -1832,7 +1835,7 @@ "title": "Verlauf löschen", "helper": "Es sind {count} Einnahmen für dieses Medikament gespeichert.", "button": "Verlauf löschen", - "dialogTitle": "Verlauf wirklich löschen?", + "dialogTitle": "Verlauf löschen?", "dialogBody": "Die {count} Einnahmen werden unwiderruflich gelöscht. Das Medikament selbst bleibt.", "toast": "Verlauf gelöscht", "failed": "Verlauf konnte nicht gelöscht werden." @@ -1852,7 +1855,7 @@ "saveFailed": "Phasen konnten nicht gespeichert werden." }, "api": { - "caption": "Endpunkt für „{name}\"", + "caption": "Endpunkt für „{name}“", "copyUrl": "URL kopieren", "urlCopied": "URL kopiert", "mintToken": "Token erzeugen", @@ -2035,7 +2038,7 @@ "timesLabel": "Einnahmezeiten", "fromLabel": "Von", "untilLabel": "Bis", - "untilHint": "„Bis\" ist der Tag, ab dem der nachfolgende Plan galt (ausschließlich).", + "untilHint": "„Bis“ ist der Tag, ab dem der nachfolgende Plan galt (ausschließlich).", "validationTimes": "Mindestens eine Uhrzeit angeben.", "validationRangeMissing": "Von- und Bis-Datum angeben.", "validationRangeOrder": "Das Von-Datum muss vor dem Bis-Datum liegen.", @@ -2096,16 +2099,16 @@ "tableStockDoses": "{count} Dosen", "tableStockRunway": "≈ {days} Tage übrig", "cardLowStockRunway": "Vorrat: ≈ {days} Tage", - "takeActionFor": "Einnehmen – {name}", - "skipActionFor": "Überspringen – {name}", + "takeActionFor": "Einnehmen — {name}", + "skipActionFor": "Überspringen — {name}", "reorderTitle": "Reihenfolge anpassen", - "reorderDescription": "Bestimme die Reihenfolge deiner Medikamente – sie gilt für die Karten- und die Tabellenansicht.", + "reorderDescription": "Bestimme die Reihenfolge deiner Medikamente — sie gilt für die Karten- und die Tabellenansicht.", "reorderSaved": "Reihenfolge gespeichert", "reorderSaveFailed": "Reihenfolge konnte nicht gespeichert werden", "customize": "Medikamente anpassen", "efficacy": { "title": "Wirkung", - "intro": "Wie sich die Messwerte, die dieses Medikament beeinflussen soll, rund um den Beginn der Einnahme entwickelt haben. Es zeigt den zeitlichen Zusammenhang – eine Assoziation, keine Ursache – und keine Empfehlung zu Behandlung oder Dosis.", + "intro": "Wie sich die Messwerte, die dieses Medikament beeinflussen soll, rund um den Beginn der Einnahme entwickelt haben. Es zeigt den zeitlichen Zusammenhang — eine Assoziation, keine Ursache — und keine Empfehlung zu Behandlung oder Dosis.", "notEligible": "Für dieses Medikament gibt es keinen zugehörigen Messwert.", "startFallback": "Es ist kein Startdatum hinterlegt, daher dient der erste erfasste Wert als Bezugspunkt.", "noStart": "Hinterlege ein Startdatum, um die Werte vor und nach Beginn zu vergleichen.", @@ -2128,16 +2131,16 @@ "error": "Diese Änderung konnte nicht gespeichert werden. Bitte erneut versuchen." }, "beforeAfter": { - "summary": "Ø {before} davor → Ø {after} seitdem – eine Veränderung von {delta}.", + "summary": "Ø {before} davor → Ø {after} seitdem — eine Veränderung von {delta}.", "counts": "Basierend auf {beforeCount} Werten davor und {afterCount} seitdem.", - "needBefore": "Noch zu wenige Werte vor dem Beginn – für einen Vergleich werden etwa {weeks} Wochen davor und danach benötigt.", - "needAfter": "Noch zu wenige Werte seit dem Beginn – für einen Vergleich werden etwa {weeks} Wochen davor und danach benötigt.", + "needBefore": "Noch zu wenige Werte vor dem Beginn — für einen Vergleich werden etwa {weeks} Wochen davor und danach benötigt.", + "needAfter": "Noch zu wenige Werte seit dem Beginn — für einen Vergleich werden etwa {weeks} Wochen davor und danach benötigt.", "needStart": "Hinterlege ein Startdatum, um die Werte vor und nach Beginn zu vergleichen.", - "needData": "Noch zu wenige Werte – für einen Vergleich werden etwa {weeks} Wochen vor und nach dem Beginn benötigt." + "needData": "Noch zu wenige Werte — für einen Vergleich werden etwa {weeks} Wochen vor und nach dem Beginn benötigt." }, "insights": { "title": "Medikamenten-Wirkung", - "subtitle": "Was mit den Zielwerten deiner Medikamente zeitlich zusammenfällt – nur ein Zusammenhang, kein Urteil.", + "subtitle": "Was mit den Zielwerten deiner Medikamente zeitlich zusammenfällt — nur ein Zusammenhang, kein Urteil.", "link": "Wirkung öffnen", "empty": "Noch nicht genug Daten, um deine Medikamente mit ihren Zielwerten in Beziehung zu setzen." }, @@ -2566,7 +2569,7 @@ "emptyAction": "Briefing erstellen", "loadingLabel": "Briefing wird zusammengestellt", "noProviderTitle": "Kein KI-Anbieter verbunden", - "noProviderDescription": "Richte HealthLog auf ein lokales Modell (Ollama, LM Studio – nichts verlässt dein Netzwerk) oder hinterlege deinen eigenen OpenAI- oder Anthropic-Schlüssel, und das tägliche Briefing fasst deine eigenen Messwerte in klarer Sprache zusammen. Die Einrichtung dauert etwa eine Minute.", + "noProviderDescription": "Richte HealthLog auf ein lokales Modell (Ollama, LM Studio — nichts verlässt dein Netzwerk) oder hinterlege deinen eigenen OpenAI- oder Anthropic-Schlüssel, und das tägliche Briefing fasst deine eigenen Messwerte in klarer Sprache zusammen. Die Einrichtung dauert etwa eine Minute.", "noProviderAction": "Zu den KI-Einstellungen", "staleNoProviderHint": "Dieses Briefing kann nicht aktualisiert werden — es ist kein KI-Anbieter verbunden.", "refreshFailedHint": "Briefing konnte nicht aktualisiert werden — es wird das letzte angezeigt.", @@ -2784,7 +2787,7 @@ "settingsVerbosityDefault": "Standard", "settingsVerbosityDetailed": "Ausführlich", "settingsVerbosityConciseHint": "Der knappe Tonfall hält Antworten ohnehin kurz — die Ausführlichkeit ist dabei fest auf „Kurz“ gesetzt.", - "settingsSourcesPointer": "Welche Datenquellen der Coach nutzen darf und das Standard-Analysefenster verwaltest du direkt im Coach-Chat (Leiste „Worauf ich zugreife\").", + "settingsSourcesPointer": "Welche Datenquellen der Coach nutzen darf und das Standard-Analysefenster verwaltest du direkt im Coach-Chat (Leiste „Worauf ich zugreife“).", "settingsSourcesPointerLink": "Coach öffnen", "settingsExcludeLabel": "Metriken ausschließen", "settingsExcludeHint": "Der Coach sieht keine Daten der ausgewählten Metriken.", @@ -2919,7 +2922,7 @@ "correlationBpCompliance": "Wie hängt meine Therapietreue mit meinem Blutdruck zusammen? Geh mit mir durch, was dieser Zusammenhang für mich bedeutet.", "correlationMoodPulse": "Gibt es einen echten Zusammenhang zwischen meiner Stimmung und meinem Puls? Hilf mir, diesen Zusammenhang zu lesen.", "correlationWeightWeekday": "Folgt mein Gewicht einem Wochenmuster? Was sagt mir dieser Wochentags-Zusammenhang?", - "recommendation": "Erzähl mir mehr zu dieser Empfehlung: \"{text}\" — warum trifft sie auf mich zu, und was sollte ich tun?" + "recommendation": "Erzähl mir mehr zu dieser Empfehlung: „{text}“ — warum trifft sie auf mich zu, und was sollte ich tun?" }, "assessmentPrompt": "Erkläre mir meine {metric}-Einschätzung — was bedeutet sie und was sollte ich tun?", "attach": { @@ -2927,7 +2930,7 @@ "menuUploadNew": "Neues Dokument hochladen", "trigger": "Dokumente anhängen", "pickerTitle": "Dokumente anhängen", - "pickerDescription": "Der Coach liest nur die angehängten Dokumente – nicht deine Gesundheitsdaten.", + "pickerDescription": "Der Coach liest nur die angehängten Dokumente — nicht deine Gesundheitsdaten.", "pickerSearchPlaceholder": "Dokumente durchsuchen…", "pickerEmpty": "Noch keine Dokumente in deinem Tresor.", "pickerNotIndexed": "Noch nicht indexiert", @@ -2935,13 +2938,13 @@ "pickerConfirm": "Anhängen ({count})", "pillRemove": "{title} entfernen", "indexing": "Wird indexiert…", - "indexingHint": "Ein Dokument wird noch indexiert – du kannst senden, sobald es bereit ist.", - "fencedHint": "Diese Unterhaltung läuft ohne Zugriff auf deine Gesundheitsdaten – der Coach liest nur die angehängten Dokumente.", + "indexingHint": "Ein Dokument wird noch indexiert — du kannst senden, sobald es bereit ist.", + "fencedHint": "Diese Unterhaltung läuft ohne Zugriff auf deine Gesundheitsdaten — der Coach liest nur die angehängten Dokumente.", "documentsAttachedOne": "1 Dokument angehängt", "documentsAttachedOther": "{count} Dokumente angehängt", "railBadge": "Dokument-Chat (abgeschottet)", "uploadError": "Upload fehlgeschlagen. Bitte versuche es erneut.", - "notFoundError": "Dieser Dokumentlink ist nicht mehr gültig – es wird ein normaler Chat gestartet." + "notFoundError": "Dieser Dokumentlink ist nicht mehr gültig — es wird ein normaler Chat gestartet." }, "interrupted": { "note": "Diese Antwort wurde unterbrochen, bevor sie fertig war.", @@ -3076,11 +3079,11 @@ }, "explainer": { "bloodPressureBody": "Der Blutdruck ist die Kraft, die das Blut auf die Gefäßwände ausübt, angegeben als systolisch zu diastolisch (Spitzenwert beim Herzschlag über dem Ruhewert dazwischen). Dauerhaft hohe Werte belasten Herz und Gefäße auf Dauer.", - "pulseBody": "Der Puls gibt an, wie oft das Herz pro Minute schlägt. Eine Einzelmessung spiegelt den Moment wider – in Ruhe liegt er meist bei 60–100 Schlägen pro Minute und steigt natürlicherweise bei Bewegung, Stress oder Koffein.", + "pulseBody": "Der Puls gibt an, wie oft das Herz pro Minute schlägt. Eine Einzelmessung spiegelt den Moment wider — in Ruhe liegt er meist bei 60–100 Schlägen pro Minute und steigt natürlicherweise bei Bewegung, Stress oder Koffein.", "weightBody": "Das Körpergewicht ist eines der einfachsten Signale für die langfristige Energiebilanz und die allgemeine Gesundheit. Der Trend über Wochen zählt weit mehr als ein einzelner Wert, der mit Flüssigkeit, Ernährung und Tageszeit schwankt.", "bmiBody": "Der BMI ist dein Gewicht geteilt durch deine Körpergröße zum Quadrat und ordnet dich einer groben Gewichtskategorie zu. Er ist ein Orientierungswert, keine Diagnose, und unterscheidet nicht zwischen Muskeln und Fett.", "sleepBody": "Die Schlafdauer ist die tatsächlich geschlafene Zeit pro Nacht, aufgeteilt in Leicht-, Tief- und REM-Schlaf. Die meisten Erwachsenen brauchen etwa sieben bis neun Stunden; gleichmäßige Menge und Qualität unterstützen Erholung, Stimmung und Stoffwechsel.", - "moodBody": "Die Stimmungserfassung hält auf einer einfachen Skala fest, wie du dich fühlst. Regelmäßiges Protokollieren zeigt Muster – wie Schlaf, Aktivität oder Medikamente mit dem Befinden zusammenhängen –, die ein einzelner Tag nie offenbart.", + "moodBody": "Die Stimmungserfassung hält auf einer einfachen Skala fest, wie du dich fühlst. Regelmäßiges Protokollieren zeigt Muster — wie Schlaf, Aktivität oder Medikamente mit dem Befinden zusammenhängen –, die ein einzelner Tag nie offenbart.", "medicationsBody": "Die Therapietreue zeigt, wie zuverlässig du deine Medikamente wie verordnet einnimmst, hier als Anteil der erwarteten Dosen, die du erfasst hast. Eine gleichmäßige Einnahme entscheidet oft darüber, ob eine Therapie wie beabsichtigt wirkt.", "restingHrBody": "Der Ruhepuls ist die Herzfrequenz in völliger Ruhe, meist über Nacht gemessen. Ein niedriger Ruhepuls deutet in der Regel auf eine bessere Herz-Kreislauf-Fitness hin, ein steigender Trend kann ein frühes Zeichen für Stress oder Krankheit sein.", "hrvBody": "Die HRV ist die kleine Schwankung der Zeit zwischen aufeinanderfolgenden Herzschlägen. Eine höhere Variabilität spiegelt meist ein gut erholtes, anpassungsfähiges Nervensystem wider, ein anhaltender Rückgang kann auf Erschöpfung, Stress oder mangelnde Erholung hinweisen.", @@ -3126,14 +3129,14 @@ "recoveryBody": "Die Erholung zeigt, wie gut sich dein Körper regeneriert hat — gestützt auf nächtliche Signale wie Herzfrequenzvariabilität und Ruhepuls. Es ist ein geräteabgeleiteter Näherungswert an deinem eigenen Ausgangswert, keine klinische Messung, und zwischen Geräten nicht vergleichbar. Ein hoher Wert ist grünes Licht, mehr zu geben; ein niedrigerer deutet an, dass dein System noch aufbaut.", "strainBody": "Die Belastung erfasst, wie viel kardiovaskuläre und körperliche Last du deinem Körper zugemutet hast — von Workouts bis zu einem aktiven Tag auf den Beinen. Es ist ein geräteabgeleiteter Index, gemessen an deinem eigenen üblichen Aufwand, keine klinische Messung, und der Kennwert ist zwischen Geräten nicht vergleichbar.", "stressBody": "Stress ist hier eine Geräte-Heuristik aus Mustern der Herzfrequenzvariabilität — keine Cortisol-Messung und nicht, wie du dich bewusst fühlst, und auch kein klinischer Stress-Score. Lies ihn an deinem eigenen Ausgangswert; zwischen Geräten ist er nicht vergleichbar. Ein niedrigerer Wert deutet auf einen ruhigen, gut regulierten Zustand hin; ein höherer ist eine Einladung, Erholung einzuplanen.", - "recoveryPageBody": "Die Erholung fasst die nächtlichen Signale deines Wearables zusammen – Recharge, Belastung, Load sowie deine Herzfrequenz- und Energiewerte des Tages – und zeigt, wie gut sich dein Körper erholt hat. Achte eher auf das Muster über mehrere Tage als auf einen einzelnen Wert; ein schwächerer Wert ist eine Einladung, kürzerzutreten, kein Alarm.", + "recoveryPageBody": "Die Erholung fasst die nächtlichen Signale deines Wearables zusammen — Recharge, Belastung, Load sowie deine Herzfrequenz- und Energiewerte des Tages — und zeigt, wie gut sich dein Körper erholt hat. Achte eher auf das Muster über mehrere Tage als auf einen einzelnen Wert; ein schwächerer Wert ist eine Einladung, kürzerzutreten, kein Alarm.", "pulsePressureBody": "Der Pulsdruck ist die Differenz zwischen deinem systolischen und diastolischen Wert (systolisch minus diastolisch). Als allgemeine Orientierung sind etwa 40 mmHg in Ruhe typisch; eine große Differenz über rund 60 mmHg im mittleren Alter kann auf versteifte Arterien hinweisen, eine kleine Differenz unter etwa 25 mmHg kann mit niedrigem Schlagvolumen einhergehen (StatPearls/NIH 2023). Ein Gesprächsanlass, keine Diagnose.", - "meanArterialPressureBody": "Der mittlere arterielle Druck ist der durchschnittliche Druck, den deine Organe über einen Herzschlag sehen – grob der diastolische Wert plus ein Drittel der Differenz zum systolischen. Als allgemeine Orientierung liegt ein Ruhewert bei etwa 70–100 mmHg (StatPearls/NIH 2023). Die oft genannte Grenze von ~60 mmHg ist ein Konzept der Akutmedizin, kein Alarm für die Selbstmessung.", - "cycleEducationBody": "Dein Zyklus durchläuft vier hormongesteuerte Phasen, und seine Länge und Regelmäßigkeit sind ein gut zugängliches Fenster auf die allgemeine Gesundheit. Als allgemeine Orientierung dauern Zyklen bei Erwachsenen typischerweise 21–35 Tage (klinische Referenzen nennen 24–38), mit einer Blutung von rund fünf Tagen (ACOG 2015). Die Lutealphase ist der stabile Anker, daher schätzt man den Eisprung am besten durch Rückrechnen von der nächsten erwarteten Periode – nie über einen festen „Tag 14“. Ein vorhergesagtes fruchtbares Fenster ist eine Schätzung, die mehrere erfasste Zyklen braucht und ungefähr bleibt, und die Basaltemperatur bestätigt den Eisprung im Nachhinein, statt ihn vorherzusagen. Dies ist keine Verhütungsmethode.", + "meanArterialPressureBody": "Der mittlere arterielle Druck ist der durchschnittliche Druck, den deine Organe über einen Herzschlag sehen — grob der diastolische Wert plus ein Drittel der Differenz zum systolischen. Als allgemeine Orientierung liegt ein Ruhewert bei etwa 70–100 mmHg (StatPearls/NIH 2023). Die oft genannte Grenze von ~60 mmHg ist ein Konzept der Akutmedizin, kein Alarm für die Selbstmessung.", + "cycleEducationBody": "Dein Zyklus durchläuft vier hormongesteuerte Phasen, und seine Länge und Regelmäßigkeit sind ein gut zugängliches Fenster auf die allgemeine Gesundheit. Als allgemeine Orientierung dauern Zyklen bei Erwachsenen typischerweise 21–35 Tage (klinische Referenzen nennen 24–38), mit einer Blutung von rund fünf Tagen (ACOG 2015). Die Lutealphase ist der stabile Anker, daher schätzt man den Eisprung am besten durch Rückrechnen von der nächsten erwarteten Periode — nie über einen festen „Tag 14“. Ein vorhergesagtes fruchtbares Fenster ist eine Schätzung, die mehrere erfasste Zyklen braucht und ungefähr bleibt, und die Basaltemperatur bestätigt den Eisprung im Nachhinein, statt ihn vorherzusagen. Dies ist keine Verhütungsmethode.", "sleepHygieneBody": "Einige evidenzbasierte Gewohnheiten unterstützen den Schlaf: gleichbleibende Zubett- und Aufstehzeiten auch am Wochenende, helles Morgenlicht und gedämpfte Abende, ein kühles, dunkles und ruhiges Zimmer sowie der Verzicht auf Koffein am Nachmittag, späten Alkohol und große späte Mahlzeiten (CDC 2024). Regelmäßigkeit sagt die Gesundheit mindestens so stark voraus wie die Dauer, und die Gesamtschlafzeit eines Wearables ist weit verlässlicher als seine Aufteilung in Tief-, Leicht- und REM-Schlaf." }, "bpDerived": { - "pulsePressure": "Der Pulsdruck (die Differenz zwischen deinen Werten) beträgt etwa {value} mmHg – rund 40 ist in Ruhe typisch.", + "pulsePressure": "Der Pulsdruck (die Differenz zwischen deinen Werten) beträgt etwa {value} mmHg — rund 40 ist in Ruhe typisch.", "meanArterialPressure": "Der mittlere arterielle Druck (der Durchschnitt, den deine Organe sehen) liegt bei etwa {value} mmHg, normalerweise um 70–100.", "caveat": "Diese Werte werden als allgemeine Orientierung aus deiner letzten Messung abgeleitet, nicht als Diagnose." }, @@ -3167,8 +3170,9 @@ }, "sleep": { "title": "Schlaf", - "description": "Phasen-Aufschlüsselung und nächtliche Dauer – gezogen aus Apple Health.", - "headlineTitle": "Durchschnitt pro Nacht", + "description": "Phasen-Aufschlüsselung und nächtliche Dauer — gezogen aus Apple Health.", + "mainSleep": "Hauptschlaf", + "totalSleep": "Schlaf gesamt", "compositionTitle": "Phasen-Verteilung", "compositionSubtitle": "Letzte {nights} Nächte", "compositionAriaLabel": "Schlafphasen-Verteilung über {nights} Nächte", @@ -3184,11 +3188,11 @@ "inBed": "Im Bett", "nap": "Nickerchen", "napCount": "{count} Nickerchen", - "unavailable": "Noch keine Phasen-Daten – Schlafphasen erscheinen hier, sobald eine verbundene Quelle sie liefert." + "unavailable": "Noch keine Phasen-Daten — Schlafphasen erscheinen hier, sobald eine verbundene Quelle sie liefert." }, "empty": { "title": "Noch keine Schlafdaten", - "description": "Verbinde eine Quelle wie Apple Health oder ein Wearable – oder erfasse Schlaf manuell – und diese Ansicht füllt sich automatisch.", + "description": "Verbinde eine Quelle wie Apple Health oder ein Wearable — oder erfasse Schlaf manuell — und diese Ansicht füllt sich automatisch.", "action": "Geräte verwalten" }, "debt": { @@ -3385,11 +3389,11 @@ "clinical": { "title": "Glukose-Überblick", "subtitle": "Ein Blick auf deine letzten {days} Tage, aus {count} Messungen.", - "spotCaveat": "Das sind Schätzungen aus einzelnen Stichproben-Messungen, kein Profil eines kontinuierlichen Sensors – lies sie als Richtung, nicht als klinisches Urteil.", + "spotCaveat": "Das sind Schätzungen aus einzelnen Stichproben-Messungen, kein Profil eines kontinuierlichen Sensors — lies sie als Richtung, nicht als klinisches Urteil.", "labsLink": "Laborergebnisse? HbA1c und Nüchternglukose aus einer Blutabnahme trägst du unter Labor ein.", "learningTitle": "Lerne deine Glukose noch kennen", - "learningBody": "Bisher sind das {count} über {days}. Mit etwas mehr Verlauf wird das Bild klarer – mess weiter oder lass es ein verbundenes Gerät ergänzen.", - "learningBodyEmpty": "Sobald ein paar Messungen vorliegen – von dir erfasst oder von einem Gerät synchronisiert – nehmen Zeit-im-Zielbereich, Durchschnitt und Schwankung hier Gestalt an.", + "learningBody": "Bisher sind das {count} über {days}. Mit etwas mehr Verlauf wird das Bild klarer — mess weiter oder lass es ein verbundenes Gerät ergänzen.", + "learningBodyEmpty": "Sobald ein paar Messungen vorliegen — von dir erfasst oder von einem Gerät synchronisiert — nehmen Zeit-im-Zielbereich, Durchschnitt und Schwankung hier Gestalt an.", "readingsCount": "{count} Messungen", "readingsCountOne": "1 Messung", "daysCount": "{days} Tage", @@ -3427,7 +3431,7 @@ "intro": "Forschungsnahe Kennzahlen für den genaueren Blick. Jede fasst deine Messungen zu einer risikogewichteten Zahl zusammen.", "jIndex": { "label": "J-Index", - "help": "Ein einzelner Wert aus Durchschnitt und Streuung – niedriger steht für engere Kontrolle." + "help": "Ein einzelner Wert aus Durchschnitt und Streuung — niedriger steht für engere Kontrolle." }, "lbgi": { "label": "Risiko für Tiefwerte (LBGI)", @@ -3723,7 +3727,7 @@ "inTargetCaption": "der letzten Tage im guten Stimmungsbereich", "narrative": { "title": "Das fällt auf", - "weekdayDip": "Deine Stimmung sinkt am stärksten am {weekday} – {delta} unter deinem Wochenschnitt.", + "weekdayDip": "Deine Stimmung sinkt am stärksten am {weekday} — {delta} unter deinem Wochenschnitt.", "weekdayPeak": "{weekday} ist durchgehend dein bester Tag mit einem Schnitt von {value}.", "trendUp": "Deine Stimmung steigt über die letzten {days} Tage (+{delta} gegenüber dem Vorzeitraum).", "trendDown": "Deine Stimmung sinkt über die letzten {days} Tage (-{delta} gegenüber dem Vorzeitraum).", @@ -3732,7 +3736,7 @@ "tagLift": "Tage mit „{tag}“ liegen im Schnitt {delta} über deiner allgemeinen Stimmung.", "tagDrop": "Tage mit „{tag}“ liegen {delta} unter deinem Durchschnitt.", "inTarget": "Du warst an {pct} % der letzten 30 Tage in deinem guten Stimmungsbereich.", - "streak": "Du hast deine Stimmung {days} Tage in Folge erfasst – weiter so.", + "streak": "Du hast deine Stimmung {days} Tage in Folge erfasst — weiter so.", "timeOfDay": "Am {bucket} fühlst du dich am besten, im Schnitt {value}." }, "timeOfDay": { @@ -3803,7 +3807,7 @@ "title": "Was auffällt", "subheading": "Entdeckte Zusammenhänge", "footer": "Herausgefiltert aus {tested} Tag-zu-Tag-Paaren, die über all deine Werte getestet wurden, nach der Korrektur für Fehlentdeckungen.", - "disclaimer": "Statistisch gefiltert, aber weiterhin beobachtend – ein Muster, das einen Blick wert ist, keine Ursache.", + "disclaimer": "Statistisch gefiltert, aber weiterhin beobachtend — ein Muster, das einen Blick wert ist, keine Ursache.", "moodUp": "Höheres {factor} geht in deinen Daten tendenziell mit besserer Stimmung am Folgetag einher.", "moodDown": "Höheres {factor} geht in deinen Daten tendenziell mit schlechterer Stimmung am Folgetag einher.", "outcomeUp": "Bessere Stimmung geht in deinen Daten tendenziell mit höherem {factor} am Folgetag einher.", @@ -4183,7 +4187,7 @@ "regenerateSettling": "Die Einschätzung wird noch erstellt", "regenerateSettleFailed": "Die Aktualisierung konnte nicht abgeschlossen werden. Bitte später erneut versuchen.", "deviceScore": { - "learning": "Deine Basislinie wird noch gelernt – ein paar weitere Nächte vervollständigen den Verlauf.", + "learning": "Deine Basislinie wird noch gelernt — ein paar weitere Nächte vervollständigen den Verlauf.", "average": "30-Tage-Durchschnitt {value}", "unitScore": "Punkte", "unitMinutes": "Min.", @@ -4196,14 +4200,14 @@ }, "recovery": { "title": "Erholung", - "description": "Wie gut sich dein Körper erholt hat – die Erholungs-, Belastungs- und Load-Signale, die dein Wearable über Nacht aufzeichnet.", + "description": "Wie gut sich dein Körper erholt hat — die Erholungs-, Belastungs- und Load-Signale, die dein Wearable über Nacht aufzeichnet.", "scoreCrossLink": "Den zusammengesetzten Erholungs-Score ansehen", "emptyTitle": "Noch keine Erholungsdaten", "empty": "Noch keine Erholungsdaten. Verbinde ein WHOOP-, Polar- oder Oura-Konto, dann füllen sich diese Werte, sobald dein Gerät synchronisiert.", "block": { - "ansChargeExplainer": "Wie aufgeladen dein autonomes Nervensystem in den Tag startet – ein Recharge-Wert aus deinen nächtlichen Herz- und Atemsignalen.", + "ansChargeExplainer": "Wie aufgeladen dein autonomes Nervensystem in den Tag startet — ein Recharge-Wert aus deinen nächtlichen Herz- und Atemsignalen.", "dayStrainExplainer": "Die kardiovaskuläre Tagesbelastung auf der Belastungsskala deines Geräts. Lies sie im Verhältnis zu deiner Erholung, nicht als Ziel für sich.", - "workoutStrainExplainer": "Wie fordernd ein einzelnes Training auf derselben Belastungsskala war – die kardiovaskuläre Last dieser Einheit.", + "workoutStrainExplainer": "Wie fordernd ein einzelnes Training auf derselben Belastungsskala war — die kardiovaskuläre Last dieser Einheit.", "cardioLoadExplainer": "Ein Trainingslast-Näherungswert, der die akut aufgebaute kardiovaskuläre Belastung abbildet. Vergleiche ihn mit deinen eigenen letzten Tagen.", "averageHeartRateExplainer": "Deine durchschnittliche Herzfrequenz über den ganzen Tag bzw. Zyklus, wie dein Wearable sie meldet.", "maxHeartRateExplainer": "Deine höchste Herzfrequenz über den ganzen Tag bzw. Zyklus, wie dein Wearable sie meldet.", @@ -4479,7 +4483,7 @@ }, "wrapUp": { "title": "Das war die Tour", - "body": "Diese Führung läuft einmalig – sie öffnet sich nicht von selbst erneut. Alles Gezeigte bleibt erhalten, und du kannst Funktionen jederzeit unter Einstellungen → Module ein- oder ausschalten." + "body": "Diese Führung läuft einmalig — sie öffnet sich nicht von selbst erneut. Alles Gezeigte bleibt erhalten, und du kannst Funktionen jederzeit unter Einstellungen → Module ein- oder ausschalten." } } }, @@ -4494,8 +4498,8 @@ }, "goals": { "title": "Was möchtest du erfassen?", - "body": "Wähle die Bereiche, die dir wichtig sind – wir zeigen sie zuerst auf deinem Dashboard an, und sie helfen dir zu entscheiden, was du als Nächstes einrichtest.", - "helpHint": "Ein bis drei Schwerpunkte funktionieren erfahrungsgemäß am besten – mehr geht aber jederzeit.", + "body": "Wähle die Bereiche, die dir wichtig sind — wir zeigen sie zuerst auf deinem Dashboard an, und sie helfen dir zu entscheiden, was du als Nächstes einrichtest.", + "helpHint": "Ein bis drei Schwerpunkte funktionieren erfahrungsgemäß am besten — mehr geht aber jederzeit.", "options": { "weightManagement": "Gewicht im Griff", "bpTracking": "Blutdruck", @@ -4507,7 +4511,7 @@ }, "source": { "title": "Quelle verbinden", - "body": "Werte automatisch übernehmen oder weiter manuell eintragen. Hier wird nichts für dich eingerichtet – jede Karte zeigt nur, wo du sie verbindest. Geräte-Quellen brauchen eine kostenlose Entwickler-App auf deinem eigenen Konto (etwa 5 Minuten); die Anleitung auf jeder Karte führt dich durch.", + "body": "Werte automatisch übernehmen oder weiter manuell eintragen. Hier wird nichts für dich eingerichtet — jede Karte zeigt nur, wo du sie verbindest. Geräte-Quellen brauchen eine kostenlose Entwickler-App auf deinem eigenen Konto (etwa 5 Minuten); die Anleitung auf jeder Karte führt dich durch.", "recommended": "Empfohlen", "manual": { "title": "Manuelle Eingabe", @@ -4517,18 +4521,19 @@ "title": "Withings", "body": "Waagen, Blutdruckmesser und Tracker von Withings synchronisieren automatisch, sobald verbunden.", "cta": "Withings verbinden", - "setupCta": "In den Einstellungen einrichten", - "setupHint": "Braucht zuerst deine eigene, kostenlose Withings-Entwickler-App – etwa 5 Minuten, einmalig." + "setupCta": "Einrichten", + "setupHint": "Braucht zuerst deine eigene, kostenlose Withings-Entwickler-App — etwa 5 Minuten, einmalig." }, "more": { "title": "Weitere Quellen", - "body": "Verbinde eine davon in den Einstellungen, wann immer du bereit bist – die meisten brauchen zuerst eine kostenlose Entwickler-App auf deinem eigenen Konto.", + "body": "Verbinde eine davon in den Einstellungen, wann immer du bereit bist — die meisten brauchen zuerst eine kostenlose Entwickler-App auf deinem eigenen Konto.", "appleHealth": "Apple Health", "whoop": "WHOOP", "oura": "Oura", "polar": "Polar", "nightscout": "Nightscout", - "fitbit": "Fitbit" + "fitbit": "Fitbit", + "appleHealthHint": "Apple Health hat keine Web-Verbindung — schließe das Onboarding hier ab und richte Apple Health danach in der HealthLog-App auf deinem iPhone ein." }, "import": "Bestehende Daten importieren" }, @@ -4553,7 +4558,7 @@ "title": "Alles bereit", "body": "Willkommen an Bord. Dein Dashboard wartet.", "returnCta": "Zum Dashboard", - "learning": "Manche Einblicke – Glukose, Schlafrhythmus, Blutdrucktrends, Zyklus – schärfen sich in den ersten ein bis zwei Wochen, sobald sich deine Daten ansammeln. Leere Kacheln heißen jetzt nur, dass wir deine Basislinie noch lernen.", + "learning": "Manche Einblicke — Glukose, Schlafrhythmus, Blutdrucktrends, Zyklus — schärfen sich in den ersten ein bis zwei Wochen, sobald sich deine Daten ansammeln. Leere Kacheln heißen jetzt nur, dass wir deine Basislinie noch lernen.", "connectCta": "Gerät verbinden", "logCta": "Ersten Wert eintragen", "importCta": "Bestehende Daten importieren", @@ -4592,33 +4597,33 @@ "anamnesis": { "title": "Zu deiner Gesundheit (optional)", "subtitle": "Etwas Kontext, damit HealthLog deine Werte fairer einordnet.", - "intro": "Teile so viel oder so wenig, wie du möchtest – jedes Feld ist optional und jederzeit in den Einstellungen änderbar.", + "intro": "Teile so viel oder so wenig, wie du möchtest — jedes Feld ist optional und jederzeit in den Einstellungen änderbar.", "conditionsLabel": "Bestehende Erkrankungen", "conditionsPlaceholder": "z. B. Bluthochdruck, Typ-2-Diabetes", "conditionsHint": "Hilft HealthLog, deine Werte im Kontext einzuordnen, statt Erwartbares zu melden.", "allergiesLabel": "Allergien oder Unverträglichkeiten", "allergiesPlaceholder": "z. B. Penicillin, Laktose", - "privacyNote": "Verschlüsselt gespeichert und nur für besseren Kontext genutzt. Medikamente fragen wir hier nicht ab – die kannst du jederzeit auf der Medikamenten-Seite ergänzen." + "privacyNote": "Verschlüsselt gespeichert und nur für besseren Kontext genutzt. Medikamente fragen wir hier nicht ab — die kannst du jederzeit auf der Medikamenten-Seite ergänzen." }, "ai": { "panelTitle": "So sehen Insights aus", - "panelIntro": "Coach, das tägliche Briefing und die Einschätzungen je Messwert übersetzen deine eigenen Zahlen in Klartext. So sieht das aus – bevor du irgendetwas verbindest.", + "panelIntro": "Coach, das tägliche Briefing und die Einschätzungen je Messwert übersetzen deine eigenen Zahlen in Klartext. So sieht das aus — bevor du irgendetwas verbindest.", "showSample": "Beispiel-Briefing ansehen", "sampleTitle": "Tägliches Briefing", "sampleTag": "Beispiel", "sampleAriaLabel": "Beispielhaftes tägliches Briefing", - "sampleBody": "Gewicht in zwei Wochen um 1,2 kg gesunken. Ruhepuls stabil bei 58 bpm. Blutdruck an 6 von 7 Tagen im Zielbereich – der einzige hohe Wert folgte auf ein spätes, salziges Abendessen.", - "sampleCaption": "Nur ein Beispiel – nicht deine eigenen Daten. So liest sich ein Briefing, sobald Insights aktiv sind.", + "sampleBody": "Gewicht in zwei Wochen um 1,2 kg gesunken. Ruhepuls stabil bei 58 bpm. Blutdruck an 6 von 7 Tagen im Zielbereich — der einzige hohe Wert folgte auf ein spätes, salziges Abendessen.", + "sampleCaption": "Nur ein Beispiel — nicht deine eigenen Daten. So liest sich ein Briefing, sobald Insights aktiv sind.", "ladderTitle": "Drei Wege, es zu aktivieren", - "ladderLocalTitle": "Lokales Modell – nichts verlässt dein Netzwerk", - "ladderLocalBody": "Richte HealthLog auf Ollama oder LM Studio auf deinem eigenen Rechner aus. Kostenlos und vollständig privat – der ruhige Standard.", + "ladderLocalTitle": "Lokales Modell — nichts verlässt dein Netzwerk", + "ladderLocalBody": "Richte HealthLog auf Ollama oder LM Studio auf deinem eigenen Rechner aus. Kostenlos und vollständig privat — der ruhige Standard.", "ladderByokTitle": "Eigenen Schlüssel verwenden", "ladderByokBody": "Nutze deinen eigenen OpenAI- oder Anthropic-Schlüssel. Er läuft über dein Konto und deine Abrechnung, und die API-Stufe hält deine Daten standardmäßig aus dem Training heraus.", "ladderOauthTitle": "Mit einem Abo anmelden", - "ladderOauthBody": "Verbinde ein ChatGPT-Konto – keine Kosten pro Token, aber die Bedingungen der Verbraucherstufe können Inhalte fürs Training nutzen. Deshalb eine bewusste Wahl, nie der Standard für deine Gesundheitsdaten.", - "keylessLine": "HealthLog ist auch ohne KI vollständig nutzbar. Deine Kacheln, Diagramme, Trends und Exporte funktionieren alle – KI ergänzt nur den Klartext obendrauf.", + "ladderOauthBody": "Verbinde ein ChatGPT-Konto — keine Kosten pro Token, aber die Bedingungen der Verbraucherstufe können Inhalte fürs Training nutzen. Deshalb eine bewusste Wahl, nie der Standard für deine Gesundheitsdaten.", + "keylessLine": "HealthLog ist auch ohne KI vollständig nutzbar. Deine Kacheln, Diagramme, Trends und Exporte funktionieren alle — KI ergänzt nur den Klartext obendrauf.", "setupCta": "Einrichten (etwa eine Minute)", - "sharedKeyNote": "Diese Instanz stellt bereits einen gemeinsamen KI-Anbieter bereit, deshalb funktionieren Insights für dich sofort – keine Einrichtung nötig." + "sharedKeyNote": "Diese Instanz stellt bereits einen gemeinsamen KI-Anbieter bereit, deshalb funktionieren Insights für dich sofort — keine Einrichtung nötig." } }, "gettingStarted": { @@ -5227,14 +5232,14 @@ "storedDays": "{count} Tage Wetter gespeichert.", "home": { "title": "Heimatort", - "description": "Ein grober Standort auf Stadtebene – niemals eine genaue Spur. Wetter und Tageslicht werden für diesen Ort an Tagen ohne Ortszeitraum abgerufen, ab dem Tag der Festlegung.", + "description": "Ein grober Standort auf Stadtebene — niemals eine genaue Spur. Wetter und Tageslicht werden für diesen Ort an Tagen ohne Ortszeitraum abgerufen, ab dem Tag der Festlegung.", "current": "Aktuell: {label}", "none": "Noch kein Heimatort festgelegt.", "since": "Wetter wird ab {date} erfasst. Frühere Tage bleiben leer, bis du einen Ortszeitraum hinzufügst." }, "travel": { "title": "Ortszeiträume", - "description": "Füge einen Ort und einen Zeitraum hinzu – eine Reise oder einen früheren Abschnitt deiner Verlaufsdaten. Der Zeitraum überschreibt für seine Tage deinen Wohnort, damit das Wetter zu deinem tatsächlichen Aufenthalt passt. So korrigierst du die Vergangenheit.", + "description": "Füge einen Ort und einen Zeitraum hinzu — eine Reise oder einen früheren Abschnitt deiner Verlaufsdaten. Der Zeitraum überschreibt für seine Tage deinen Wohnort, damit das Wetter zu deinem tatsächlichen Aufenthalt passt. So korrigierst du die Vergangenheit.", "none": "Noch keine Ortszeiträume.", "add": "{label} hinzufügen", "remove": "Ortszeitraum entfernen", @@ -5555,7 +5560,7 @@ "coachMemory": { "title": "Woran sich der Coach erinnert", "description": "Dauerhafte Angaben, die du dem Coach mitgeteilt hast.", - "empty": "Der Coach hat noch keine Angaben gespeichert. Im Gespräch merkt er sich dauerhafte Details, die du teilst – deine Ziele, Beschwerden, Vorlieben – und du kannst sie hier jederzeit löschen.", + "empty": "Der Coach hat noch keine Angaben gespeichert. Im Gespräch merkt er sich dauerhafte Details, die du teilst — deine Ziele, Beschwerden, Vorlieben — und du kannst sie hier jederzeit löschen.", "learnedPrefix": "gelernt {when}", "categoryPreference": "Vorlieben", "categoryCondition": "Beschwerden", @@ -5572,7 +5577,7 @@ "forgetAllConfirmBody": "Dadurch werden alle gespeicherten Angaben entfernt. Der Coach beginnt beim nächsten Gespräch von vorn. Das lässt sich nicht rückgängig machen.", "forgetAllConfirmAction": "Alles vergessen", "cancel": "Abbrechen", - "summaryNote": "Der Coach behält außerdem eine kurze, fortlaufende Zusammenfassung jedes Gesprächs. Dafür gibt es keine Einzelsteuerung – wenn du ein Gespräch löschst, wird auch dessen Zusammenfassung entfernt.", + "summaryNote": "Der Coach behält außerdem eine kurze, fortlaufende Zusammenfassung jedes Gesprächs. Dafür gibt es keine Einzelsteuerung — wenn du ein Gespräch löschst, wird auch dessen Zusammenfassung entfernt.", "loadError": "Konnte nicht laden, woran sich der Coach erinnert.", "forgotToast": "Angabe vergessen.", "forgotError": "Konnte diese Angabe nicht vergessen. Bitte erneut versuchen.", @@ -5637,7 +5642,7 @@ "autoRead": { "title": "Dokumente automatisch mit KI lesen", "subLabel": "Dokumente werden von deinem konfigurierten KI-Anbieter gelesen und indiziert, ohne dass du jedes Dokument einzeln bestätigen musst.", - "honesty": "Wenn aktiviert, wird jedes hochgeladene Dokument an deinen konfigurierten KI-Anbieter gesendet, um gelesen, beschrieben und für die Suche indiziert zu werden – ohne Bestätigung pro Dokument. Das Einschalten wirkt außerdem rückwirkend: Bereits hochgeladene Dokumente ohne Zusammenfassung werden ebenfalls gesendet, bis zu 200 pro Einschaltvorgang. Das geschieht auf dem Server, und der Inhalt des Dokuments verlässt dabei diese Maschine in Richtung dieses Anbieters. Wenn dein konfigurierter Anbieter eine Abo-Verbindung ist (ein angemeldetes KI-Konto statt eines API-Schlüssels), gelten dessen eigene Datenschutzeinstellungen – Abo-Anbieter dürfen den Inhalt zur Verbesserung ihrer Modelle verwenden, und kein Auftragsverarbeitungsvertrag deckt das ab. Dokumente, die von einem selbst gehosteten lokalen Modell gelesen werden, verlassen deinen Server nie. Standardmäßig aus; aktiviere es nur, wenn du diesen Kompromiss für deine Dokumente akzeptierst.", + "honesty": "Wenn aktiviert, wird jedes hochgeladene Dokument an deinen konfigurierten KI-Anbieter gesendet, um gelesen, beschrieben und für die Suche indiziert zu werden — ohne Bestätigung pro Dokument. Das Einschalten wirkt außerdem rückwirkend: Bereits hochgeladene Dokumente ohne Zusammenfassung werden ebenfalls gesendet, bis zu 200 pro Einschaltvorgang. Das geschieht auf dem Server, und der Inhalt des Dokuments verlässt dabei diese Maschine in Richtung dieses Anbieters. Wenn dein konfigurierter Anbieter eine Abo-Verbindung ist (ein angemeldetes KI-Konto statt eines API-Schlüssels), gelten dessen eigene Datenschutzeinstellungen — Abo-Anbieter dürfen den Inhalt zur Verbesserung ihrer Modelle verwenden, und kein Auftragsverarbeitungsvertrag deckt das ab. Dokumente, die von einem selbst gehosteten lokalen Modell gelesen werden, verlassen deinen Server nie. Standardmäßig aus; aktiviere es nur, wenn du diesen Kompromiss für deine Dokumente akzeptierst.", "confirm": "Verstanden — aktivieren", "cancel": "Abbrechen" }, @@ -5967,7 +5972,7 @@ }, "notificationStatus": { "title": "Zustellstatus", - "description": "Pro Kanal aktueller Status; \"Automatisch deaktiviert\" stoppt nach dauerhaften Fehlern.", + "description": "Status je Kanal — „Automatisch deaktiviert“ heißt: nach dauerhaften Fehlern gestoppt.", "emptyDescription": "Richte unter Einstellungen › Integrationen › Zustellkanäle einen Kanal ein.", "stateActive": "Aktiv", "stateAutoDisabled": "Automatisch deaktiviert", @@ -6169,7 +6174,7 @@ } }, "integrationRedirectGuide": { - "intro": "Die Verbindung läuft über deine eigene {provider}-Entwickler-App – etwa 5 Minuten Einrichtung:", + "intro": "Die Verbindung läuft über deine eigene {provider}-Entwickler-App — etwa 5 Minuten Einrichtung:", "step1": "Erstelle eine App im {provider}-Entwicklerportal.", "step2": "Setze deren Weiterleitungs-/Callback-URL auf:", "step3": "Trage die dort erhaltene Client-ID und das Client-Secret oben ein." @@ -6283,7 +6288,7 @@ "privacy": { "encryption": { "title": "Verschlüsselt gespeichert", - "summary": "Deine sensiblen Daten werden mit {algorithm} verschlüsselt gespeichert – über {columns} Felder in {models} Datensatztypen.", + "summary": "Deine sensiblen Daten werden mit {algorithm} verschlüsselt gespeichert — über {columns} Felder in {models} Datensatztypen.", "statement": "Deine sensiblen Daten werden mit AES-256-GCM verschlüsselt gespeichert." }, "stored": { @@ -6377,7 +6382,7 @@ }, "appleHealth": { "title": "Apple Health", - "description": "Die Live-Synchronisierung erfolgt über die HealthLog-iOS-App – nicht über eine Web- oder OAuth-Verbindung.", + "description": "Die Live-Synchronisierung erfolgt über die HealthLog-iOS-App — nicht über eine Web- oder OAuth-Verbindung.", "status": { "unavailable": "Apple-Health-Status nicht verfügbar" }, @@ -6387,6 +6392,13 @@ "importNote": "Du brauchst ältere Daten oder kannst die iOS-App nicht nutzen? Der Apple-Health-XML/ZIP-Upload ist ein einmaliger Ersatzweg und aktiviert keine Live-Synchronisierung.", "importAction": "Einmaligen Import öffnen", "staleNote": "Seit über einer Woche sind keine Apple-Health-Daten angekommen. Öffne die HealthLog-App auf deinem iPhone und prüfe die Apple-Health-Berechtigungen.", + "progress": { + "title": "Sync-Fortschritt", + "receivedLabel": "Empfangene Datensätze", + "oldestLabel": "Älteste erreichte Messung", + "flowing": "In der letzten Stunde sind Daten angekommen — die iPhone-App liefert weiter.", + "waiting": "Warten auf weitere Daten von der iPhone-App. Die Übertragung läuft weiter, wenn die App geöffnet ist oder iOS ihr Hintergrundzeit gewährt — eine ruhige Phase während des ersten großen Abgleichs ist normal, kein Fehler." + }, "delivery": { "title": "Übermittlung", "lastTriggerLabel": "Letzte Synchronisierung kam", @@ -6578,7 +6590,7 @@ "title": "Modulverfügbarkeit", "description": "Schalte ein Modul für den gesamten Server ab.", "saveError": "Modulverfügbarkeit konnte nicht aktualisiert werden.", - "detail": "Ein hier deaktiviertes Modul ist für jedes Konto aus – unabhängig von den persönlichen Einstellungen. Kerndaten (Gewicht, Blutdruck, Puls, Medikamente) sind immer aktiv und lassen sich nicht abschalten." + "detail": "Ein hier deaktiviertes Modul ist für jedes Konto aus — unabhängig von den persönlichen Einstellungen. Kerndaten (Gewicht, Blutdruck, Puls, Medikamente) sind immer aktiv und lassen sich nicht abschalten." }, "invites": { "title": "Einladungen", @@ -6689,7 +6701,7 @@ "auditDetails": "Details", "deleteAllData": "Alle Daten global löschen", "deleteAllDescription": "Löscht alle Datensätze aller Konten; die Konten selbst und ihre Zugangsdaten bleiben erhalten.", - "deleteAllConfirm": "Alle Daten wirklich löschen?", + "deleteAllConfirm": "Alle Daten löschen?", "deleteAllConfirmDescription": "Dieser Vorgang löscht unwiderruflich alle Datensätze aller Konten in der gesamten Datenbank, nicht eine Auswahl von Tabellen. Erhalten bleiben: die Nutzerkonten und ihre Anmelde-Zugangsdaten, die Konfiguration der Instanz und die Berechtigungen, über die betreute Profile erreichbar bleiben. Ein Beleg für diesen Vorgang wird danach ins Audit-Log geschrieben; die Audit-Historie davor geht mit allem anderen.", "deleteButton": "Alle Daten löschen", "deleteAllPromptLabel": "Tippe {token} zum Fortfahren", @@ -6891,6 +6903,23 @@ "restoreTitle": "Aus Backup wiederherstellen", "restoreDescription": "Dies ersetzt ALLE aktuellen Daten von {username} mit dem Snapshot von {when}. Vorhandene Messungen, Medikamente, Einnahmen, Stimmungseinträge, Benachrichtigungs-Kanäle, Push-Abos und Telegram-Lösch-Aufträge werden entfernt. Falls im Snapshot enthalten, überschreiben instanzweite Einstellungen die Konfiguration der gesamten Installation.", "restorePromptLabel": "Tippe RESTORE zum Fortfahren", + "previewTitle": "Inhalt der Sicherung", + "previewLoading": "Inhalt wird gelesen…", + "previewUnavailable": "Inhalt konnte nicht gelesen werden — die Wiederherstellung bleibt möglich.", + "previewEmpty": "Die Datei enthält keine Datensätze.", + "previewOther": "Weitere Datensätze", + "previewMeasurements": "Messwerte", + "previewMedications": "Medikamente und Einnahmen", + "previewMood": "Stimmungseinträge", + "previewLabs": "Laborwerte", + "previewDocuments": "Dokumente", + "previewWorkouts": "Workouts", + "previewCycles": "Zyklusdaten", + "previewVisits": "Arztbesuche", + "previewVaccinations": "Impfungen", + "previewReminders": "Vorsorge-Erinnerungen", + "previewCustomMetrics": "Eigene Messgrößen", + "previewHealthScores": "Score-Verlauf", "restoreConfirm": "Jetzt wiederherstellen", "restoreInProgress": "Wiederherstellung läuft…", "restoreSuccess": "Wiederherstellung abgeschlossen", @@ -6963,7 +6992,7 @@ }, "carrier": "Netzbetreiber", "mfaRequired": "Zweiten Faktor verlangen", - "mfaRequiredDescription": "Jedes Konto ohne aktiven zweiten Faktor wird nach der Anmeldung zur Einrichtung geleitet. Das fordert Web-Nutzer zur Einrichtung auf – es blockiert jedoch keinen direkten API- oder nativen (Bearer-)Zugriff für noch nicht eingerichtete Konten.", + "mfaRequiredDescription": "Jedes Konto ohne aktiven zweiten Faktor wird nach der Anmeldung zur Einrichtung geleitet. Das fordert Web-Nutzer zur Einrichtung auf — es blockiert jedoch keinen direkten API- oder nativen (Bearer-)Zugriff für noch nicht eingerichtete Konten.", "mfaEnforcedOn": "MFA an", "mfaEnforcedOff": "MFA aus", "mfaEnforcedOnHint": "Für diesen Nutzer ist ein zweiter Faktor erforderlich. Klicken, um die Anforderung aufzuheben.", @@ -7738,7 +7767,7 @@ "empty": "Noch nicht genug Zyklen und Messwerte für ein Phasenmuster. Logge weiter — das füllt sich, je länger deine Historie wird." }, "crosstab": { - "description": "Wie sich jeder Messwert zwischen deiner Luteal- und Follikelphase vergleicht – auf deinen eigenen Daten.", + "description": "Wie sich jeder Messwert zwischen deiner Luteal- und Follikelphase vergleicht — auf deinen eigenen Daten.", "pairLabel": "{metric} · Luteal vs. Follikel", "detail": "Luteal {lutealAvg} {unit} ({lutealDays} Tage) vs. Follikel {follicularAvg} {unit} ({follicularDays} Tage).", "metricRestingHeartRate": "Ruhepuls", @@ -7778,7 +7807,7 @@ }, "context": { "MENSTRUAL": "Die Hormonspiegel sind jetzt am niedrigsten, weshalb sich diese Tage wie ein ruhiger Neustart anfühlen können. Manche bemerken Krämpfe, leichteren Schlaf oder den Wunsch, einen Gang zurückzuschalten; andere kommen fast unverändert durch. All das liegt im normalen Rahmen, und wie es sich anfühlt, kann von Zyklus zu Zyklus wechseln.", - "FOLLICULAR": "Mit steigendem Östrogen beschreiben viele diese Tage als hell und offen – der Schlaf fällt oft leichter und der Kopf fühlt sich klarer an. Die Basaltemperatur bleibt meist im niedrigeren Bereich, bis kurz vor dem Eisprung. Die Länge dieser Phase schwankt am stärksten – von Person zu Person und von Zyklus zu Zyklus.", + "FOLLICULAR": "Mit steigendem Östrogen beschreiben viele diese Tage als hell und offen — der Schlaf fällt oft leichter und der Kopf fühlt sich klarer an. Die Basaltemperatur bleibt meist im niedrigeren Bereich, bis kurz vor dem Eisprung. Die Länge dieser Phase schwankt am stärksten — von Person zu Person und von Zyklus zu Zyklus.", "OVULATORY": "Das Östrogen erreicht kurz vor dem Eisprung seinen Höhepunkt, oft begleitet von einem kurzen Stimmungshoch und einem leichten Anstieg der Basaltemperatur in den ein, zwei Tagen danach. Der Zervixschleim wird in diesem Fenster meist klarer und dehnbarer. Ein kurzes Ziehen auf einer Seite bemerken manche; viele spüren gar nichts, und beides ist völlig normal.", "LUTEAL": "Das Progesteron führt durch die zweite Zyklushälfte, und die Basaltemperatur liegt meist etwas höher als zuvor im Zyklus. Manche bemerken im Lauf der Tage Veränderungen bei Schlaf, Appetit, Stimmung oder Energie, während andere durchgehend ausgeglichen bleiben. Das Muster ist persönlich und sieht von Monat zu Monat oft anders aus." }, @@ -7865,8 +7894,8 @@ "ariaUnknown": "Noch kein aktiver Zyklus", "caption": "deines Zyklus", "firstPeriodCta": "Erste Periode erfassen", - "periodOverdue": "Deine Periode ist später als üblich – die Tageszählung pausiert, bis die nächste Periode erfasst ist.", - "periodOverdueDays": "Deine Periode ist {days} Tage später als üblich – die Tageszählung pausiert, bis die nächste Periode erfasst ist.", + "periodOverdue": "Deine Periode ist später als üblich — die Tageszählung pausiert, bis die nächste Periode erfasst ist.", + "periodOverdueDays": "Deine Periode ist {days} Tage später als üblich — die Tageszählung pausiert, bis die nächste Periode erfasst ist.", "logPeriodCta": "Periode erfassen" }, "calendar": { @@ -7878,7 +7907,7 @@ "prevMonth": "Vorheriger Monat", "nextMonth": "Nächster Monat", "loading": "Kalender wird geladen…", - "learning": "Wir lernen deinen Zyklus – erfasse mindestens drei volle Zyklen, dann füllen sich fruchtbares Fenster, Eisprung und Phasen. Bis dahin zeigt der Kalender nur, was du erfasst hast.", + "learning": "Wir lernen deinen Zyklus — erfasse mindestens drei volle Zyklen, dann füllen sich fruchtbares Fenster, Eisprung und Phasen. Bis dahin zeigt der Kalender nur, was du erfasst hast.", "legendSymptom": "Symptome", "legendOvulationConfirmed": "Bestätigter Eisprung", "flowSpotting": "Schmierblutung", @@ -7994,7 +8023,7 @@ "remindersDescription": "Perioden-Erinnerungen pro Kanal in den Benachrichtigungseinstellungen aktivieren.", "remindersLink": "Benachrichtigungseinstellungen", "fertileReminder": "Erinnerung an das fruchtbare Fenster", - "fertileReminderDescription": "Erhalte einige Tage vor deinem geschätzten fruchtbaren Fenster einen Hinweis. Standardmäßig aus – aktiviere sie pro Kanal in den Benachrichtigungseinstellungen." + "fertileReminderDescription": "Erhalte einige Tage vor deinem geschätzten fruchtbaren Fenster einen Hinweis. Standardmäßig aus — aktiviere sie pro Kanal in den Benachrichtigungseinstellungen." }, "testResult": { "POSITIVE": "Positiv", @@ -8027,15 +8056,15 @@ "fieldInfo": { "bbt": "Deine Ruhetemperatur, morgens als Erstes vor dem Aufstehen gemessen. Nach dem Eisprung steigt sie leicht an (etwa 0,2–0,3 °C).", "bbtLabel": "Was ist die Basaltemperatur?", - "mucus": "Der Ausfluss verändert sich im Zyklus – von trocken oder klebrig zu cremig, dann wässrig und spinnbar rund um die fruchtbarsten Tage.", + "mucus": "Der Ausfluss verändert sich im Zyklus — von trocken oder klebrig zu cremig, dann wässrig und spinnbar rund um die fruchtbarsten Tage.", "mucusLabel": "Was ist Zervixschleim?", - "ovulationTest": "Ein Urintest für zu Hause, der den LH-Anstieg erkennt – den Hormonschub etwa 24–36 h vor dem Eisprung. Ein positives Ergebnis markiert die fruchtbarste Phase.", + "ovulationTest": "Ein Urintest für zu Hause, der den LH-Anstieg erkennt — den Hormonschub etwa 24–36 h vor dem Eisprung. Ein positives Ergebnis markiert die fruchtbarste Phase.", "ovulationTestLabel": "Was ist ein Ovulationstest?", "spotting": "Leichte Blutung außerhalb der Periode.", "spottingLabel": "Was ist Schmierblutung zwischen den Perioden?", "pregnancyTest": "Ergebnis eines Schwangerschaftstests (hCG) für zu Hause.", "pregnancyTestLabel": "Was ist ein Schwangerschaftstest?", - "progesteroneTest": "Ein Test, der bestätigt, dass der Eisprung stattgefunden hat – das Progesteron steigt danach an.", + "progesteroneTest": "Ein Test, der bestätigt, dass der Eisprung stattgefunden hat — das Progesteron steigt danach an.", "progesteroneTestLabel": "Was ist ein Progesterontest?", "intercourse": "Ob an diesem Tag Geschlechtsverkehr stattgefunden hat.", "intercourseLabel": "Was erfasst Geschlechtsverkehr?", @@ -8085,6 +8114,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", @@ -8240,7 +8286,7 @@ "takenAt": "Probendatum", "note": "Notiz", "notePlaceholder": "Optionaler Kontext zu diesem Wert", - "requiredError": "Bitte einen Wert eingeben.", + "requiredError": "Gib einen Wert ein.", "save": "Speichern", "saveAndAddAnother": "Speichern & nächsten Wert", "savedToast": "Wert gespeichert", @@ -8249,7 +8295,7 @@ "biomarkerPlaceholder": "Biomarker auswählen", "defineNew": "+ Neuen Biomarker hinzufügen", "noBiomarkersHint": "Lege zuerst einen Biomarker an, um einen Wert zu erfassen.", - "pickBiomarkerError": "Bitte zuerst einen Biomarker auswählen.", + "pickBiomarkerError": "Wähle zuerst einen Biomarker aus.", "resultType": "Ergebnistyp", "numeric": "Numerisch", "qualitative": "Qualitativ", @@ -8270,7 +8316,7 @@ "editDescription": "Wert, Datum oder Notiz dieser Messung korrigieren.", "editedToast": "Wert aktualisiert", "editError": "Wert konnte nicht aktualisiert werden", - "editTimestampError": "Bitte ein gültiges Datum eingeben.", + "editTimestampError": "Gib ein gültiges Datum ein.", "editReading": "Wert bearbeiten", "deleteReading": "Wert löschen", "restoredToast": "Wert wiederhergestellt", @@ -8388,36 +8434,36 @@ "bloodCount": "Blutbild" }, "desc": { - "total-cholesterol": "Das Gesamtcholesterin ist die Summe des Cholesterins über alle Lipoprotein-Partikel im Blut, einschließlich der LDL-, HDL- und Remnant-Fraktion. Cholesterin selbst ist ein unverzichtbarer Baustein für Zellmembranen, Hormone und Gallensäuren. Als einzelner Wert bleibt es ein grobes Maß, denn sowohl ein hoher HDL- als auch ein hoher LDL-Anteil heben die Summe – deshalb liest man es meist gemeinsam mit den Einzelfraktionen. Erfasst wird es vor allem im Rahmen der langfristigen kardiovaskulären Risikoeinschätzung.", - "ldl": "Das LDL-Cholesterin ist das von Low-Density-Lipoprotein-Partikeln transportierte Cholesterin – die Fraktion, die Cholesterin zu den Geweben bringt, es aber auch in den Gefäßwänden ablagern kann. Über Jahre trägt diese Ablagerung zur arteriosklerotischen Plaque bei, weshalb LDL einer der am genauesten beobachteten Lipidmarker ist. Eine geringere langfristige Belastung geht in der Regel mit einem niedrigeren kardiovaskulären Risiko einher. Bewertet wird es nach Risikokategorie, nicht gegen einen festen Normwert.", + "total-cholesterol": "Das Gesamtcholesterin ist die Summe des Cholesterins über alle Lipoprotein-Partikel im Blut, einschließlich der LDL-, HDL- und Remnant-Fraktion. Cholesterin selbst ist ein unverzichtbarer Baustein für Zellmembranen, Hormone und Gallensäuren. Als einzelner Wert bleibt es ein grobes Maß, denn sowohl ein hoher HDL- als auch ein hoher LDL-Anteil heben die Summe — deshalb liest man es meist gemeinsam mit den Einzelfraktionen. Erfasst wird es vor allem im Rahmen der langfristigen kardiovaskulären Risikoeinschätzung.", + "ldl": "Das LDL-Cholesterin ist das von Low-Density-Lipoprotein-Partikeln transportierte Cholesterin — die Fraktion, die Cholesterin zu den Geweben bringt, es aber auch in den Gefäßwänden ablagern kann. Über Jahre trägt diese Ablagerung zur arteriosklerotischen Plaque bei, weshalb LDL einer der am genauesten beobachteten Lipidmarker ist. Eine geringere langfristige Belastung geht in der Regel mit einem niedrigeren kardiovaskulären Risiko einher. Bewertet wird es nach Risikokategorie, nicht gegen einen festen Normwert.", "hdl": "Das HDL-Cholesterin ist das von High-Density-Lipoprotein-Partikeln transportierte Cholesterin, die überschüssiges Cholesterin aus den Geweben aufnehmen und zur Wiederverwertung oder Ausscheidung zur Leber zurückführen. Wegen dieser Transportfunktion wird es oft als die schützende Fraktion bezeichnet. Sehr niedrige Werte gehen tendenziell mit höherem kardiovaskulärem Risiko einher; ein künstliches Anheben hat das Risiko jedoch nicht zuverlässig gesenkt, weshalb man es als Marker und nicht als Zielgröße liest. In der Regel wird es zusammen mit LDL und Triglyzeriden beurteilt.", "triglycerides": "Triglyzeride sind die wichtigste Form von Fett im Blut, die sowohl aus der Nahrung stammt als auch von der Leber gebildet wird, und sie dienen als zentraler Energiespeicher. Nach Mahlzeiten steigen die Werte, daher wird die Probe für die Vergleichbarkeit meist nüchtern entnommen. Dauerhaft hohe Triglyzeride werden mit metabolischem und kardiovaskulärem Risiko in Verbindung gebracht und treten häufig gemeinsam mit Insulinresistenz und niedrigem HDL auf. Sie werden als ein Bestandteil des gesamten Lipidbilds gelesen.", "fasting-glucose": "Der Nüchternblutzucker ist die Zuckerkonzentration im Blut nach einer nächtlichen Nüchternphase und spiegelt wider, wie der Körper seinen Grundblutzucker ohne kürzliche Nahrungsaufnahme hält. Glukose ist der wichtigste unmittelbare Brennstoff des Körpers, und Insulin hält sie normalerweise in einem engen Bereich. Eine Nüchternmessung schließt den Effekt kürzlicher Mahlzeiten aus und ist damit ein Standardscreening dafür, wie die Glukoseregulation funktioniert. Er gehört zu den Markern, mit denen die Stoffwechselgesundheit über die Zeit beurteilt wird.", "hba1c": "Der HbA1c ist der Anteil des Hämoglobins in den roten Blutkörperchen, der an Glukose gebunden ist und sich im Verhältnis zur durchschnittlichen Blutzuckerbelastung anreichert. Da rote Blutkörperchen etwa drei Monate leben, gibt er den durchschnittlichen Blutzucker der vorangegangenen zwei bis drei Monate wieder und nicht einen einzelnen Augenblick. Das macht ihn zu einem stabilen, mahlzeitenunabhängigen Maß der Blutzuckereinstellung. Seine Aussagekraft kann durch Erkrankungen beeinträchtigt sein, die die Lebensdauer der roten Blutkörperchen verändern.", "tsh": "Das TSH (Thyreoidea-stimulierendes Hormon) wird von der Hirnanhangdrüse ausgeschüttet, um die Schilddrüse zur Hormonproduktion anzuregen, und es passt sich nach oben oder unten an, um diese im Gleichgewicht zu halten. Aufgrund dieses Regelkreises ist das TSH das empfindlichste Erstlinien-Screening der Schilddrüsenfunktion: Es bewegt sich meist, bevor die Schilddrüsenhormone selbst abweichen. Ein erhöhter oder erniedrigter Wert deutet auf eine Unter- oder Überfunktion hin und führt in der Regel zur direkten Bestimmung der Schilddrüsenhormone. Schwangerschaft und Alter verschieben den erwarteten Bereich.", - "ft3": "Das freie T3 (Trijodthyronin) ist die ungebundene, biologisch aktive Form des wirksameren Schilddrüsenhormons – der Anteil, der den Geweben zur Verfügung steht. Der größte Teil des zirkulierenden T3 ist an Trägerproteine gebunden und inaktiv, sodass der freie Anteil die tatsächliche Hormonverfügbarkeit widerspiegelt. Gemessen wird es zur Beurteilung der Schilddrüsenfunktion, besonders gemeinsam mit TSH und freiem T4, wenn Befunde unklar sind. Die Werte sind stark assayabhängig und werden im klinischen Zusammenhang gelesen.", + "ft3": "Das freie T3 (Trijodthyronin) ist die ungebundene, biologisch aktive Form des wirksameren Schilddrüsenhormons — der Anteil, der den Geweben zur Verfügung steht. Der größte Teil des zirkulierenden T3 ist an Trägerproteine gebunden und inaktiv, sodass der freie Anteil die tatsächliche Hormonverfügbarkeit widerspiegelt. Gemessen wird es zur Beurteilung der Schilddrüsenfunktion, besonders gemeinsam mit TSH und freiem T4, wenn Befunde unklar sind. Die Werte sind stark assayabhängig und werden im klinischen Zusammenhang gelesen.", "ft4": "Das freie T4 (Thyroxin) ist die ungebundene Form des wichtigsten Hormons, das die Schilddrüse ins Blut abgibt; es dient als Reservoir, das die Gewebe bei Bedarf in das aktivere T3 umwandeln. Der freie Anteil spiegelt wider, was dem Körper tatsächlich zur Verfügung steht, unabhängig von der Menge der Trägerproteine. In der Regel wird es zusammen mit dem TSH bestimmt, um Probleme der Schilddrüse von Störungen der hypophysären Steuerung zu unterscheiden. Schwangerschaft und bestimmte Medikamente können es verschieben.", - "ferritin": "Ferritin ist das wichtigste Eisenspeicherprotein des Körpers, und sein Blutwert spiegelt grob die gesamten Eisenreserven wider. Ein niedriger Wert deutet auf erschöpfte Speicher hin, ein hoher Wert kann eine Eisenüberladung anzeigen – oder schlicht eine Entzündung, denn Ferritin steigt auch als Akute-Phase-Protein. Aus diesem Grund wird es oft zusammen mit anderen Eisenmarkern und Entzündungszeichen beurteilt. Es ist eines der zentralen Maße zur Einschätzung des Eisenstatus.", - "transferrin-saturation": "Die Transferrinsättigung ist der Prozentsatz des Transferrins – des Proteins, das Eisen durch das Blut transportiert –, der aktuell mit Eisen beladen ist. Sie verknüpft die Menge des zirkulierenden Eisens mit der Eisentransportkapazität und liefert damit eine Momentaufnahme der Eisenverfügbarkeit, die Ferritins Blick auf die Speicher ergänzt. Eine niedrige Sättigung deutet auf einen Eisenmangel hin, ein hoher Wert kann eine Überladung anzeigen. Sie wird zusammen mit Ferritin und dem gesamten Eisenpanel gelesen.", + "ferritin": "Ferritin ist das wichtigste Eisenspeicherprotein des Körpers, und sein Blutwert spiegelt grob die gesamten Eisenreserven wider. Ein niedriger Wert deutet auf erschöpfte Speicher hin, ein hoher Wert kann eine Eisenüberladung anzeigen — oder schlicht eine Entzündung, denn Ferritin steigt auch als Akute-Phase-Protein. Aus diesem Grund wird es oft zusammen mit anderen Eisenmarkern und Entzündungszeichen beurteilt. Es ist eines der zentralen Maße zur Einschätzung des Eisenstatus.", + "transferrin-saturation": "Die Transferrinsättigung ist der Prozentsatz des Transferrins — des Proteins, das Eisen durch das Blut transportiert –, der aktuell mit Eisen beladen ist. Sie verknüpft die Menge des zirkulierenden Eisens mit der Eisentransportkapazität und liefert damit eine Momentaufnahme der Eisenverfügbarkeit, die Ferritins Blick auf die Speicher ergänzt. Eine niedrige Sättigung deutet auf einen Eisenmangel hin, ein hoher Wert kann eine Überladung anzeigen. Sie wird zusammen mit Ferritin und dem gesamten Eisenpanel gelesen.", "vitamin-d": "Vitamin D wird als 25-Hydroxy-Vitamin-D gemessen, der zirkulierenden Speicherform und dem Standardmarker des gesamten Vitamin-D-Status. Der Körper bildet es in der dem Sonnenlicht ausgesetzten Haut und nimmt einen Teil über die Nahrung auf; es unterstützt den Kalziumhaushalt, die Knochengesundheit und die Immunfunktion. Die Werte schwanken mit Jahreszeit, geografischer Breite, Hauttyp und Nahrungsergänzung. Erfasst wird es, um über die Zeit einzuschätzen, ob die Speicher ausreichend gefüllt sind.", - "vitamin-b12": "Vitamin B12 (Cobalamin) ist ein essenzielles Vitamin, das vor allem aus tierischen Lebensmitteln stammt und größtenteils in der Leber gespeichert wird. Es wird für die Bildung roter Blutkörperchen, die DNA-Synthese und die Gesundheit des Nervensystems benötigt. Ein länger anhaltender Mangel – durch geringe Zufuhr oder gestörte Aufnahme – kann zu einer Blutarmut und nervenbedingten Beschwerden führen. Gemessen wird es zur Beurteilung des Ernährungsstatus, insbesondere dort, wo Zufuhr oder Aufnahme eingeschränkt sein können.", + "vitamin-b12": "Vitamin B12 (Cobalamin) ist ein essenzielles Vitamin, das vor allem aus tierischen Lebensmitteln stammt und größtenteils in der Leber gespeichert wird. Es wird für die Bildung roter Blutkörperchen, die DNA-Synthese und die Gesundheit des Nervensystems benötigt. Ein länger anhaltender Mangel — durch geringe Zufuhr oder gestörte Aufnahme — kann zu einer Blutarmut und nervenbedingten Beschwerden führen. Gemessen wird es zur Beurteilung des Ernährungsstatus, insbesondere dort, wo Zufuhr oder Aufnahme eingeschränkt sein können.", "folate": "Folat (Vitamin B9) ist ein B-Vitamin, das in grünem Blattgemüse, Hülsenfrüchten und angereicherten Lebensmitteln vorkommt und für die Bildung roter Blutkörperchen und die DNA-Synthese zentral ist. Es arbeitet eng mit Vitamin B12 zusammen, und ein Mangel an einem von beiden kann eine ähnliche Form der Blutarmut hervorrufen. Eine ausreichende Folatversorgung ist besonders rund um die frühe Schwangerschaft für die gesunde Entwicklung des Kindes wichtig. Gemessen wird es zur Beurteilung des Ernährungsstatus und der Ursache bestimmter Anämien.", "hs-crp": "Das hochsensitive CRP misst das C-reaktive Protein, einen Marker, den die Leber als Reaktion auf Entzündungen ausschüttet, mit einem Verfahren, das auf die für das kardiovaskuläre Risiko relevanten niedrigen Konzentrationen abgestimmt ist. Anders als das Standard-CRP für akute Infektionen erfasst die hochsensitive Variante die feine, chronische Entzündung, die mit Gefäßerkrankungen verbunden ist. Da jede kürzliche Infektion oder Verletzung den Wert anhebt, wird er außerhalb akuter Erkrankung beurteilt und oft mit einer Wiederholungsprobe bestätigt. Er wird als ein Beitrag zum gesamten kardiovaskulären Risiko gelesen.", "creatinine": "Kreatinin ist ein Abfallprodukt des normalen Muskelstoffwechsels, das die Nieren mit recht gleichbleibender Rate aus dem Blut filtern. Da die Produktion verhältnismäßig konstant ist, dient der Blutwert als praktisches Maß dafür, wie gut die Nieren Abfallstoffe ausscheiden. Die Werte werden auch von Muskelmasse, Flüssigkeitshaushalt und Ernährung beeinflusst, weshalb sie meist gemeinsam mit der geschätzten Filtrationsrate beurteilt werden. Es ist ein zentraler Marker der Nierenfunktion.", "egfr": "Die eGFR (geschätzte glomeruläre Filtrationsrate) schätzt, wie viel Blut die Nieren pro Minute filtern, berechnet aus dem Kreatinin zusammen mit Alter und Geschlecht. Sie übersetzt einen rohen Kreatininwert in ein unmittelbarer verständliches Maß der Nierenfunktion. Eine über die Zeit sinkende eGFR ist der wichtigste Weg, um chronische Nierenveränderungen einzustufen und zu verfolgen. Als Schätzwert wird sie als Trend und nicht als einzelner exakter Zahlenwert gelesen.", "alt": "Die ALT (Alanin-Aminotransferase) ist ein vorwiegend in den Leberzellen konzentriertes Enzym, das ins Blut freigesetzt wird, wenn diese Zellen belastet oder geschädigt sind. Da sie verhältnismäßig leberspezifisch ist, gehört sie zu den wichtigsten Markern, um eine Leberschädigung oder -entzündung zu erkennen. Die Werte können bei Fettleber, Virushepatitis, Alkohol oder bestimmten Medikamenten steigen. In der Regel wird sie zusammen mit der AST und den übrigen Markern des Leberpanels gelesen.", - "ast": "Die AST (Aspartat-Aminotransferase) ist ein Enzym, das in der Leber, aber auch in Muskel, Herz und roten Blutkörperchen vorkommt und bei einer Schädigung dieser Gewebe freigesetzt wird. Da sie weniger leberspezifisch ist als die ALT, werden beide gemeinsam beurteilt – ihr Verhältnis kann Hinweise auf Quelle und Art des Problems geben. Intensive körperliche Belastung oder eine Muskelverletzung können sie unabhängig von der Leber anheben. Sie ist eines der Standardenzyme des Leberpanels.", - "ggt": "Die GGT (Gamma-Glutamyltransferase) ist ein Enzym in der Leber und den Gallenwegen, das besonders bei einem gestörten Gallenfluss oder bei Alkoholexposition ansteigt. Häufig wird sie genutzt, um zu klären, warum ein anderer Lebermarker erhöht ist – etwa um eine Quelle in der Leber oder den Gallenwegen zu unterscheiden. Sie ist empfindlich, aber wenig spezifisch und wird daher im Zusammenhang mit dem übrigen Leberpanel gelesen. Sie ist einer von mehreren Markern zur Beurteilung der Leber- und Gallengesundheit.", + "ast": "Die AST (Aspartat-Aminotransferase) ist ein Enzym, das in der Leber, aber auch in Muskel, Herz und roten Blutkörperchen vorkommt und bei einer Schädigung dieser Gewebe freigesetzt wird. Da sie weniger leberspezifisch ist als die ALT, werden beide gemeinsam beurteilt — ihr Verhältnis kann Hinweise auf Quelle und Art des Problems geben. Intensive körperliche Belastung oder eine Muskelverletzung können sie unabhängig von der Leber anheben. Sie ist eines der Standardenzyme des Leberpanels.", + "ggt": "Die GGT (Gamma-Glutamyltransferase) ist ein Enzym in der Leber und den Gallenwegen, das besonders bei einem gestörten Gallenfluss oder bei Alkoholexposition ansteigt. Häufig wird sie genutzt, um zu klären, warum ein anderer Lebermarker erhöht ist — etwa um eine Quelle in der Leber oder den Gallenwegen zu unterscheiden. Sie ist empfindlich, aber wenig spezifisch und wird daher im Zusammenhang mit dem übrigen Leberpanel gelesen. Sie ist einer von mehreren Markern zur Beurteilung der Leber- und Gallengesundheit.", "sodium": "Natrium ist der wichtigste Elektrolyt in der Flüssigkeit außerhalb der Körperzellen und der zentrale Regler von Flüssigkeitshaushalt und Blutvolumen. Der Körper steuert es eng über Durst, Hormone und die Nieren, sodass der Blutwert eher das Gleichgewicht zwischen Wasser und Salz widerspiegelt als die Zufuhr allein. Abweichende Werte signalisieren meist eine Störung des Wasserhaushalts und nicht des Natriums selbst. Es ist ein Kernbestandteil des grundlegenden Stoffwechsel- und Elektrolytpanels.", "potassium": "Kalium ist der wichtigste Elektrolyt innerhalb der Körperzellen und für die Nervenleitung und den gleichmäßigen Rhythmus des Herzmuskels unverzichtbar. Sein Blutwert wird durch die Nieren und hormonelle Signale in einem engen Bereich gehalten, da sowohl hohe als auch niedrige Werte die elektrische Aktivität des Herzens stören können. Die Werte können durch Nierenfunktion, Flüssigkeitshaushalt und mehrere gängige Medikamente beeinflusst werden. Es ist ein Kernmaß des Elektrolytpanels.", "hemoglobin": "Hämoglobin ist das eisenhaltige Protein in den roten Blutkörperchen, das Sauerstoff in der Lunge bindet und zu den Geweben transportiert. Seine Konzentration ist das wichtigste Maß für die Sauerstofftransportkapazität des Blutes und der zentrale Marker zum Erkennen einer Blutarmut. Niedrige Werte können auf Blutverlust, Eisen- oder Vitaminmangel oder eine verringerte Bildung hinweisen, hohe Werte können auf Flüssigkeitsmangel oder sauerstoffarme Bedingungen folgen. Es ist ein Eckpfeiler des großen Blutbilds.", "hematocrit": "Der Hämatokrit ist der Anteil der roten Blutkörperchen am Blutvolumen, ausgedrückt als Prozentwert. Er bewegt sich eng mit dem Hämoglobin und gibt einen ergänzenden Blick auf die Erythrozytenmasse und die Sauerstofftransportkapazität des Blutes. Der Flüssigkeitshaushalt beeinflusst ihn, da Veränderungen des Plasmavolumens den Anteil verschieben, selbst wenn die Zahl der roten Blutkörperchen stabil bleibt. Er wird zusammen mit dem Hämoglobin und dem übrigen Blutbild gelesen.", "wbc": "Die Leukozytenzahl ist die Gesamtzahl der im Blut zirkulierenden Immunzellen, der mobilen Abwehr des Körpers gegen Infektionen. Sie steigt als Reaktion auf Infektion, Entzündung oder Stress und kann fallen, wenn das Knochenmark unterdrückt ist oder bei manchen Immunerkrankungen. Da sie mehrere unterschiedliche Zelltypen zusammenfasst, wird eine deutliche Veränderung oft durch ein Differenzialblutbild aufgeschlüsselt. Sie ist ein Kernbestandteil des großen Blutbilds.", "platelets": "Thrombozyten sind kleine Zellfragmente im Blut, die sich zusammenlagern und den ersten Pfropf bilden, der eine Blutung aus einem geschädigten Gefäß stoppt. Ihre Zahl spiegelt das Gleichgewicht zwischen Bildung im Knochenmark und Verbrauch oder Abbau wider. Eine niedrige Zahl kann die Blutungsneigung erhöhen, eine hohe Zahl kann auf eine Entzündung oder andere Reize folgen. Sie sind Teil des großen Blutbilds und ein grundlegendes Maß der Gerinnungsfähigkeit.", - "apob": "Apolipoprotein B ist das Strukturprotein, das jedes der atherogenen Lipoprotein-Partikel trägt; seine Messung zählt daher diese Partikel direkt und nicht das von ihnen transportierte Cholesterin. Da auf jedem LDL-, VLDL- und verwandten Partikel genau ein ApoB-Molekül sitzt, kann es kardiovaskuläres Risiko erfassen, das ein LDL-Cholesterinwert übersieht – besonders wenn Partikelzahl und Cholesteringehalt auseinandergehen. Aus diesem Grund wird es zunehmend genutzt, um die lipidbasierte Risikoeinschätzung zu verfeinern. Beurteilt wird es zusammen mit dem üblichen Lipidpanel.", + "apob": "Apolipoprotein B ist das Strukturprotein, das jedes der atherogenen Lipoprotein-Partikel trägt; seine Messung zählt daher diese Partikel direkt und nicht das von ihnen transportierte Cholesterin. Da auf jedem LDL-, VLDL- und verwandten Partikel genau ein ApoB-Molekül sitzt, kann es kardiovaskuläres Risiko erfassen, das ein LDL-Cholesterinwert übersieht — besonders wenn Partikelzahl und Cholesteringehalt auseinandergehen. Aus diesem Grund wird es zunehmend genutzt, um die lipidbasierte Risikoeinschätzung zu verfeinern. Beurteilt wird es zusammen mit dem üblichen Lipidpanel.", "lp-a": "Lipoprotein(a) ist ein LDL-ähnliches Partikel mit einem zusätzlich angehefteten Protein, dessen Spiegel weitgehend genetisch festgelegt ist und ein Leben lang recht stabil bleibt. Ein erhöhtes Lp(a) ist ein unabhängiger, vererbter Risikofaktor für Herz-Kreislauf-Erkrankungen und eine Verengung der Aortenklappe. Da es sich kaum verändert, wird es meist einmal bestimmt, um das lebenslange Risiko einer Person festzulegen, statt es wiederholt zu verfolgen. Es wird in molaren Einheiten angegeben und als Teil des gesamten kardiovaskulären Risikos gelesen.", - "fasting-insulin": "Das Nüchterninsulin ist der Spiegel des Insulins – des Hormons, das Glukose aus dem Blut in die Zellen schleust –, gemessen nach einer nächtlichen Nüchternphase. Zusammen mit dem Nüchternblutzucker hilft es, die Insulinresistenz abzuschätzen, also den Zustand, in dem der Körper mehr Insulin braucht, um den Blutzucker im Bereich zu halten. Ein hohes Nüchterninsulin bei normalem Blutzucker kann ein frühes Zeichen dafür sein, dass dieser Ausgleich im Gange ist. Gelesen wird es als Teil eines Stoffwechselbilds und nicht für sich allein.", - "omega-3-index": "Der Omega-3-Index ist der Anteil von EPA und DHA – den beiden langkettigen marinen Omega-3-Fettsäuren – an den Fettsäuren in den Membranen der roten Blutkörperchen. Da rote Blutkörperchen sich langsam erneuern, spiegelt er die langfristige Omega-3-Zufuhr wider und nicht eine kürzliche Mahlzeit. Ein höherer Index wurde in Beobachtungsstudien mit einem niedrigeren kardiovaskulären Risiko in Verbindung gebracht. Er wird als stabiler Marker des Omega-3-Status verfolgt, der über Monate auf die Ernährung reagiert." + "fasting-insulin": "Das Nüchterninsulin ist der Spiegel des Insulins — des Hormons, das Glukose aus dem Blut in die Zellen schleust –, gemessen nach einer nächtlichen Nüchternphase. Zusammen mit dem Nüchternblutzucker hilft es, die Insulinresistenz abzuschätzen, also den Zustand, in dem der Körper mehr Insulin braucht, um den Blutzucker im Bereich zu halten. Ein hohes Nüchterninsulin bei normalem Blutzucker kann ein frühes Zeichen dafür sein, dass dieser Ausgleich im Gange ist. Gelesen wird es als Teil eines Stoffwechselbilds und nicht für sich allein.", + "omega-3-index": "Der Omega-3-Index ist der Anteil von EPA und DHA — den beiden langkettigen marinen Omega-3-Fettsäuren — an den Fettsäuren in den Membranen der roten Blutkörperchen. Da rote Blutkörperchen sich langsam erneuern, spiegelt er die langfristige Omega-3-Zufuhr wider und nicht eine kürzliche Mahlzeit. Ein höherer Index wurde in Beobachtungsstudien mit einem niedrigeren kardiovaskulären Risiko in Verbindung gebracht. Er wird als stabiler Marker des Omega-3-Status verfolgt, der über Monate auf die Ernährung reagiert." }, "apob": "Apolipoprotein B (ApoB)", "lp-a": "Lipoprotein(a)", @@ -8435,7 +8481,7 @@ "acceptImageOnly": "JPEG, PNG oder WebP", "extracting": "Befund wird gelesen…", "readingOnDevice": "Wird auf deinem Gerät gelesen…", - "localModeHint": "Wird auf deinem Gerät gelesen und als Text gesendet. Weniger genau – prüfe jeden Wert.", + "localModeHint": "Wird auf deinem Gerät gelesen und als Text gesendet. Weniger genau — prüfe jeden Wert.", "rangeFromScan": "Referenzbereich aus dem Scan übernommen", "rangeVerify": "Referenzbereich prüfen", "reviewTitle": "Vor dem Speichern prüfen", @@ -8452,7 +8498,7 @@ "newBiomarker": "Neuer Biomarker — wird angelegt", "linksExisting": "Verknüpft mit vorhandenem {name}", "duplicateWarning": "Ein passender Wert existiert bereits", - "lowConfidence": "Bitte diesen Wert überprüfen", + "lowConfidence": "Prüfe diesen Wert", "lowConfidenceBadge": "Geringe Sicherheit", "valueUnreadable": "Wert unleserlich — bitte manuell eingeben", "saveSelected": "{count} ausgewählte speichern", @@ -8807,7 +8853,7 @@ "countOne": "Du hast in diesem Zeitraum 1 Episode erfasst.", "countOther": "Du hast in diesem Zeitraum {count} Episoden erfasst.", "typicalGap": "Typische Erholungslücke: {days} Tage zwischen Besserung und normalisierten Werten.", - "typicalGapWithDriver": "Typische Erholungslücke: {days} Tage – du fühlst dich meist besser, bevor sich {vital} normalisiert.", + "typicalGapWithDriver": "Typische Erholungslücke: {days} Tage — du fühlst dich meist besser, bevor sich {vital} normalisiert.", "typicalGapLearning": "Noch ein paar abgeschlossene Episoden und hier erscheint eine typische Erholungslücke.", "none": "In diesem Zeitraum keine Episoden erfasst.", "analyze": "Erholungslücke analysieren" @@ -8868,8 +8914,8 @@ "stop": "Ich messe genug", "accepted": "Erinnerung gesetzt", "dismissed": "Okay, das schlage ich nicht noch einmal vor", - "stopped": "Verstanden – ich schlage keine Erinnerungen mehr vor", - "failed": "Hat nicht geklappt – bitte erneut versuchen", + "stopped": "Verstanden — ich schlage keine Erinnerungen mehr vor", + "failed": "Hat nicht geklappt — bitte erneut versuchen", "cadence": { "weightDaily": "Wieg dich täglich, ungefähr zur selben Zeit", "bp722": "Miss deinen Blutdruck eine Woche lang zweimal täglich", @@ -9035,7 +9081,9 @@ "flaggedBadge": "Hilfe angezeigt", "chartTitle": "Punkteverlauf", "totalLabel": "Punkte", - "bandLabel": "Einstufung" + "bandLabel": "Einstufung", + "answersToggle": "Antworten", + "answersUnavailable": "Die gespeicherten Antworten dieses Check-ins können nicht entschlüsselt werden." }, "crisis": { "title": "Du musst das nicht allein durchstehen", @@ -9739,6 +9787,8 @@ "editedToast": "Wert aktualisiert", "editError": "Wert konnte nicht aktualisiert werden", "deletedToast": "Wert gelöscht", + "restoredToast": "Messwert wiederhergestellt", + "restoreError": "Wiederherstellen fehlgeschlagen", "deleteError": "Wert konnte nicht gelöscht werden" }, "chart": { @@ -9911,7 +9961,7 @@ "scoreLabel": "Gesundheitsscore", "deltaVsBaseline": "{delta} ggü. deinem Ausgangswert", "deltaFlat": "Im Bereich deines Ausgangswerts", - "sleepPending": "Der Schlaf von letzter Nacht fehlt noch – der heutige Wert ist vorläufig.", + "sleepPending": "Der Schlaf von letzter Nacht fehlt noch — der heutige Wert ist vorläufig.", "worthALook": "Einen Blick wert", "allClear": "Heute braucht nichts deine Aufmerksamkeit.", "ringLink": "{metric}-Details öffnen" @@ -10071,7 +10121,7 @@ "errorNoAccount": "Auf dieser Instanz gibt es kein Konto mit diesem Namen.", "errorDuplicate": "Dieses Konto hat bereits Zugriff auf deine Akte.", "errorSelf": "Das ist dein eigenes Konto.", - "errorInvalid": "Bitte einen Benutzernamen oder eine E-Mail-Adresse eingeben.", + "errorInvalid": "Gib einen Benutzernamen oder eine E-Mail-Adresse ein.", "errorRateLimited": "Zu viele Einladungen. Bitte später noch einmal versuchen.", "errorFailed": "Die Einladung konnte nicht gesendet werden. Bitte noch einmal versuchen.", "errorScope": "So lassen sich diese Bereiche nicht teilen. Wähle mindestens einen Bereich aus oder teile die gesamte Akte.", @@ -10208,6 +10258,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..aec395564 100644 --- a/messages/en.json +++ b/messages/en.json @@ -516,6 +516,9 @@ "heroItemsTitle": "Today highlights", "heroItemsDescription": "Choose which item types can appear in Today highlights.", "heroItemsNotificationNote": "Notification settings are managed separately.", + "heroContentTitle": "Top card shows", + "heroContentScore": "Health score and daily summary", + "heroContentReminders": "Today highlights", "heroItems": { "coach_checkin": "Coach check-ins", "dose_window": "Medication doses", @@ -3168,7 +3171,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", @@ -4517,7 +4521,7 @@ "title": "Withings", "body": "Scales, blood-pressure cuffs and trackers from Withings sync automatically once connected.", "cta": "Connect Withings", - "setupCta": "Set up in Settings", + "setupCta": "Set up", "setupHint": "Needs your own free Withings developer app first — about 5 minutes, one time." }, "more": { @@ -4528,7 +4532,8 @@ "oura": "Oura", "polar": "Polar", "nightscout": "Nightscout", - "fitbit": "Fitbit" + "fitbit": "Fitbit", + "appleHealthHint": "Apple Health has no web connection — finish onboarding here, then open the HealthLog app on your iPhone to complete Apple Health setup." }, "import": "Import existing history" }, @@ -5967,7 +5972,7 @@ }, "notificationStatus": { "title": "Delivery status", - "description": "Per-channel state; \"Auto-disabled\" stops retries after permanent errors.", + "description": "State of each channel — “Auto-disabled” means retries stopped after permanent errors.", "emptyDescription": "Set up a channel under Settings › Integrations › Delivery channels.", "stateActive": "Active", "stateAutoDisabled": "Auto-disabled", @@ -6387,6 +6392,13 @@ "importNote": "Need older history or cannot use the iOS app? The Apple Health XML/ZIP upload is a one-shot fallback and does not enable live sync.", "importAction": "Open one-shot import", "staleNote": "No Apple Health data has arrived for over a week. Open the HealthLog app on your iPhone and check the Apple Health permissions.", + "progress": { + "title": "Sync progress", + "receivedLabel": "Records received", + "oldestLabel": "Oldest reading reached", + "flowing": "Data arrived within the last hour — the iPhone app is still delivering.", + "waiting": "Waiting for the iPhone app to send more data. Delivery continues when the app is open or when iOS grants it background time — a quiet stretch during the first big backfill is normal, not an error." + }, "delivery": { "title": "Delivery", "lastTriggerLabel": "Latest sync arrived", @@ -6891,6 +6903,23 @@ "restoreTitle": "Restore from backup", "restoreDescription": "This will replace ALL current data for {username} with the snapshot from {when}. Existing measurements, medications, intake events, mood entries, notification channels, push subscriptions and queued Telegram deletions will be deleted. If included in the snapshot, instance-wide settings overwrite the configuration for the entire installation.", "restorePromptLabel": "Type RESTORE to continue", + "previewTitle": "What this backup contains", + "previewLoading": "Reading contents…", + "previewUnavailable": "Contents could not be read — restoring is still possible.", + "previewEmpty": "The file contains no records.", + "previewOther": "Other records", + "previewMeasurements": "Measurements", + "previewMedications": "Medications and intakes", + "previewMood": "Mood entries", + "previewLabs": "Lab results", + "previewDocuments": "Documents", + "previewWorkouts": "Workouts", + "previewCycles": "Cycle data", + "previewVisits": "Doctor visits", + "previewVaccinations": "Vaccinations", + "previewReminders": "Preventive-care reminders", + "previewCustomMetrics": "Custom metrics", + "previewHealthScores": "Score history", "restoreConfirm": "Restore now", "restoreInProgress": "Restoring…", "restoreSuccess": "Restore complete", @@ -8085,6 +8114,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", @@ -9035,7 +9081,9 @@ "flaggedBadge": "Support shown", "chartTitle": "Score trend", "totalLabel": "Score", - "bandLabel": "Category" + "bandLabel": "Category", + "answersToggle": "Answers", + "answersUnavailable": "The stored answers for this check-in cannot be decrypted." }, "crisis": { "title": "You don't have to face this alone", @@ -9739,6 +9787,8 @@ "editedToast": "Value updated", "editError": "Could not update the value", "deletedToast": "Value deleted", + "restoredToast": "Value restored", + "restoreError": "Restore failed", "deleteError": "Could not delete the value" }, "chart": { @@ -10208,6 +10258,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..fc40940dd 100644 --- a/messages/es.json +++ b/messages/es.json @@ -516,6 +516,9 @@ "heroItemsTitle": "Destacados de hoy", "heroItemsDescription": "Elige qué tipos de elementos pueden aparecer en los destacados de hoy.", "heroItemsNotificationNote": "Los ajustes de notificaciones se gestionan por separado.", + "heroContentTitle": "La tarjeta superior muestra", + "heroContentScore": "Puntuación de salud y resumen del día", + "heroContentReminders": "Destacados de hoy", "heroItems": { "coach_checkin": "Revisión con Coach", "dose_window": "Dosis de medicamentos", @@ -3168,7 +3171,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", @@ -4517,7 +4521,7 @@ "title": "Withings", "body": "Las básculas, los tensiómetros y los trackers de Withings se sincronizan automáticamente una vez conectados.", "cta": "Conectar Withings", - "setupCta": "Configurar en Ajustes", + "setupCta": "Configurar", "setupHint": "Necesita primero tu propia app de desarrollador de Withings, gratuita — unos 5 minutos, una sola vez." }, "more": { @@ -4528,7 +4532,8 @@ "oura": "Oura", "polar": "Polar", "nightscout": "Nightscout", - "fitbit": "Fitbit" + "fitbit": "Fitbit", + "appleHealthHint": "Apple Health no tiene conexión web — termina aquí la puesta en marcha y luego abre la app HealthLog en tu iPhone para completar la configuración de Apple Health." }, "import": "Importar historial existente" }, @@ -5967,7 +5972,7 @@ }, "notificationStatus": { "title": "Fiabilidad de los canales", - "description": "Estado por canal; «Desactivado automáticamente» detiene los reintentos tras errores permanentes.", + "description": "Estado de cada canal — «Desactivado automáticamente» significa detenido tras errores permanentes.", "emptyDescription": "Configura un canal en Ajustes › Integraciones › Canales de entrega.", "stateActive": "Activo", "stateAutoDisabled": "Desactivado automáticamente", @@ -6387,6 +6392,13 @@ "importNote": "¿Necesitas un historial anterior o no puedes usar la app de iOS? La carga XML/ZIP de Apple Health es una alternativa puntual y no activa la sincronización en vivo.", "importAction": "Abrir importación puntual", "staleNote": "Hace más de una semana que no llegan datos de Apple Health. Abre la app HealthLog en tu iPhone y revisa los permisos de Apple Health.", + "progress": { + "title": "Progreso de sincronización", + "receivedLabel": "Registros recibidos", + "oldestLabel": "Lectura más antigua alcanzada", + "flowing": "Han llegado datos en la última hora — la app del iPhone sigue enviando.", + "waiting": "Esperando a que la app del iPhone envíe más datos. La transferencia continúa cuando la app está abierta o cuando iOS le concede tiempo en segundo plano — una pausa durante la primera sincronización grande es normal, no un error." + }, "delivery": { "title": "Entrega", "lastTriggerLabel": "La última sincronización llegó", @@ -6891,6 +6903,23 @@ "restoreTitle": "Restaurar desde copia de seguridad", "restoreDescription": "Esto reemplazará TODOS los datos actuales de {username} con la instantánea del {when}. Se eliminarán las mediciones, los medicamentos, los eventos de toma, las entradas de ánimo, los canales de notificación, las suscripciones push y las eliminaciones de Telegram en cola existentes.", "restorePromptLabel": "Escribe RESTORE para continuar", + "previewTitle": "Contenido de la copia", + "previewLoading": "Leyendo el contenido…", + "previewUnavailable": "No se pudo leer el contenido — restaurar sigue siendo posible.", + "previewEmpty": "El archivo no contiene registros.", + "previewOther": "Otros registros", + "previewMeasurements": "Mediciones", + "previewMedications": "Medicamentos y tomas", + "previewMood": "Registros de ánimo", + "previewLabs": "Resultados de laboratorio", + "previewDocuments": "Documentos", + "previewWorkouts": "Entrenamientos", + "previewCycles": "Datos del ciclo", + "previewVisits": "Visitas médicas", + "previewVaccinations": "Vacunas", + "previewReminders": "Recordatorios preventivos", + "previewCustomMetrics": "Métricas propias", + "previewHealthScores": "Historial de puntuación", "restoreConfirm": "Restaurar ahora", "restoreInProgress": "Restaurando...", "restoreSuccess": "Restauración completada", @@ -8085,6 +8114,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", @@ -9035,7 +9081,9 @@ "flaggedBadge": "Apoyo mostrado", "chartTitle": "Evolución de la puntuación", "totalLabel": "Puntuación", - "bandLabel": "Categoría" + "bandLabel": "Categoría", + "answersToggle": "Respuestas", + "answersUnavailable": "Las respuestas guardadas de esta revisión no se pueden descifrar." }, "crisis": { "title": "No tienes que afrontar esto a solas", @@ -9739,6 +9787,8 @@ "editedToast": "Valor actualizado", "editError": "No se pudo actualizar el valor", "deletedToast": "Valor eliminado", + "restoredToast": "Valor restaurado", + "restoreError": "No se pudo restaurar", "deleteError": "No se pudo eliminar el valor" }, "chart": { @@ -10208,6 +10258,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..458da22de 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -516,6 +516,9 @@ "heroItemsTitle": "Temps forts du jour", "heroItemsDescription": "Choisis les types d’éléments qui peuvent apparaître dans les temps forts du jour.", "heroItemsNotificationNote": "Les réglages des notifications sont gérés séparément.", + "heroContentTitle": "La carte du haut affiche", + "heroContentScore": "Score de santé et résumé du jour", + "heroContentReminders": "Temps forts du jour", "heroItems": { "coach_checkin": "Bilan avec Coach", "dose_window": "Doses de médicaments", @@ -3168,7 +3171,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", @@ -4517,7 +4521,7 @@ "title": "Withings", "body": "Balances, tensiomètres et trackers Withings se synchronisent automatiquement une fois connectés.", "cta": "Connecter Withings", - "setupCta": "Configurer dans les Réglages", + "setupCta": "Configurer", "setupHint": "Nécessite d'abord votre propre application développeur Withings, gratuite — environ 5 minutes, une seule fois." }, "more": { @@ -4528,7 +4532,8 @@ "oura": "Oura", "polar": "Polar", "nightscout": "Nightscout", - "fitbit": "Fitbit" + "fitbit": "Fitbit", + "appleHealthHint": "Apple Health n'a pas de connexion web — terminez d'abord la configuration ici, puis ouvrez l'app HealthLog sur votre iPhone pour finaliser la configuration d'Apple Health." }, "import": "Importer un historique existant" }, @@ -5967,7 +5972,7 @@ }, "notificationStatus": { "title": "Fiabilité des canaux", - "description": "État par canal ; « Désactivé automatiquement » arrête les nouvelles tentatives après des erreurs permanentes.", + "description": "État de chaque canal — « Désactivé automatiquement » signifie arrêté après des erreurs permanentes.", "emptyDescription": "Ajoutez un canal dans Paramètres › Intégrations › Canaux de diffusion.", "stateActive": "Actif", "stateAutoDisabled": "Désactivé automatiquement", @@ -6387,6 +6392,13 @@ "importNote": "Besoin d’un ancien historique ou impossible d’utiliser l’app iOS ? L’envoi XML/ZIP Apple Health est une solution ponctuelle et n’active pas la synchronisation en direct.", "importAction": "Ouvrir l’import ponctuel", "staleNote": "Aucune donnée Apple Health n'est arrivée depuis plus d'une semaine. Ouvrez l'app HealthLog sur votre iPhone et vérifiez les autorisations Apple Health.", + "progress": { + "title": "Progression de la synchronisation", + "receivedLabel": "Enregistrements reçus", + "oldestLabel": "Mesure la plus ancienne atteinte", + "flowing": "Des données sont arrivées au cours de la dernière heure — l'app iPhone continue d'envoyer.", + "waiting": "En attente de nouvelles données de l'app iPhone. Le transfert reprend quand l'app est ouverte ou quand iOS lui accorde du temps en arrière-plan — une accalmie pendant le premier grand rattrapage est normale, pas une erreur." + }, "delivery": { "title": "Livraison", "lastTriggerLabel": "La dernière synchronisation est arrivée", @@ -6891,6 +6903,23 @@ "restoreTitle": "Restaurer à partir d'une sauvegarde", "restoreDescription": "Cette action remplacera TOUTES les données actuelles de {username} par l'instantané du {when}. Les mesures, médicaments, événements de prise, entrées d'humeur, canaux de notification, abonnements push et suppressions Telegram en file d'attente existants seront supprimés.", "restorePromptLabel": "Tapez RESTORE pour continuer", + "previewTitle": "Contenu de la sauvegarde", + "previewLoading": "Lecture du contenu…", + "previewUnavailable": "Le contenu n'a pas pu être lu — la restauration reste possible.", + "previewEmpty": "Le fichier ne contient aucun enregistrement.", + "previewOther": "Autres enregistrements", + "previewMeasurements": "Mesures", + "previewMedications": "Médicaments et prises", + "previewMood": "Entrées d'humeur", + "previewLabs": "Résultats de laboratoire", + "previewDocuments": "Documents", + "previewWorkouts": "Entraînements", + "previewCycles": "Données de cycle", + "previewVisits": "Visites médicales", + "previewVaccinations": "Vaccinations", + "previewReminders": "Rappels de prévention", + "previewCustomMetrics": "Mesures personnalisées", + "previewHealthScores": "Historique du score", "restoreConfirm": "Restaurer maintenant", "restoreInProgress": "Restauration en cours...", "restoreSuccess": "Restauration terminée", @@ -8085,6 +8114,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é", @@ -9035,7 +9081,9 @@ "flaggedBadge": "Aide affichée", "chartTitle": "Évolution du score", "totalLabel": "Score", - "bandLabel": "Catégorie" + "bandLabel": "Catégorie", + "answersToggle": "Réponses", + "answersUnavailable": "Les réponses enregistrées de ce bilan ne peuvent pas être déchiffrées." }, "crisis": { "title": "Tu n'as pas à affronter cela seul(e)", @@ -9739,6 +9787,8 @@ "editedToast": "Valeur mise à jour", "editError": "Impossible de mettre à jour la valeur", "deletedToast": "Valeur supprimée", + "restoredToast": "Valeur restaurée", + "restoreError": "Échec de la restauration", "deleteError": "Impossible de supprimer la valeur" }, "chart": { @@ -10208,6 +10258,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..a39e062c2 100644 --- a/messages/it.json +++ b/messages/it.json @@ -516,6 +516,9 @@ "heroItemsTitle": "In evidenza oggi", "heroItemsDescription": "Scegli quali tipi di elementi possono apparire nella sezione In evidenza oggi.", "heroItemsNotificationNote": "Le impostazioni delle notifiche vengono gestite separatamente.", + "heroContentTitle": "La scheda in alto mostra", + "heroContentScore": "Punteggio di salute e riepilogo del giorno", + "heroContentReminders": "In evidenza oggi", "heroItems": { "coach_checkin": "Verifiche con Coach", "dose_window": "Dosi dei farmaci", @@ -3168,7 +3171,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", @@ -4517,7 +4521,7 @@ "title": "Withings", "body": "Bilance, misuratori di pressione e tracker Withings si sincronizzano automaticamente una volta collegati.", "cta": "Connetti Withings", - "setupCta": "Configura nelle Impostazioni", + "setupCta": "Configura", "setupHint": "Richiede prima la tua app sviluppatore Withings gratuita — circa 5 minuti, una tantum." }, "more": { @@ -4528,7 +4532,8 @@ "oura": "Oura", "polar": "Polar", "nightscout": "Nightscout", - "fitbit": "Fitbit" + "fitbit": "Fitbit", + "appleHealthHint": "Apple Health non ha una connessione web — completa qui la configurazione iniziale, poi apri l'app HealthLog sul tuo iPhone per completare la configurazione di Apple Health." }, "import": "Importa la cronologia esistente" }, @@ -5967,7 +5972,7 @@ }, "notificationStatus": { "title": "Affidabilità dei canali", - "description": "Stato per canale; «Disattivato automaticamente» interrompe i tentativi dopo errori permanenti.", + "description": "Stato di ogni canale — «Disattivato automaticamente» significa fermato dopo errori permanenti.", "emptyDescription": "Aggiungi un canale in Impostazioni › Integrazioni › Canali di recapito.", "stateActive": "Attivo", "stateAutoDisabled": "Disattivato automaticamente", @@ -6387,6 +6392,13 @@ "importNote": "Ti serve lo storico precedente o non puoi usare l’app iOS? Il caricamento XML/ZIP di Apple Health è un’alternativa una tantum e non attiva la sincronizzazione in tempo reale.", "importAction": "Apri importazione una tantum", "staleNote": "Da più di una settimana non arrivano dati da Apple Health. Apri l'app HealthLog sull'iPhone e controlla i permessi di Apple Health.", + "progress": { + "title": "Avanzamento della sincronizzazione", + "receivedLabel": "Record ricevuti", + "oldestLabel": "Lettura più vecchia raggiunta", + "flowing": "Nell'ultima ora sono arrivati dati — l'app per iPhone sta ancora inviando.", + "waiting": "In attesa che l'app per iPhone invii altri dati. Il trasferimento continua quando l'app è aperta o quando iOS le concede tempo in background — una pausa durante il primo grande recupero è normale, non un errore." + }, "delivery": { "title": "Consegna", "lastTriggerLabel": "L'ultima sincronizzazione è arrivata", @@ -6891,6 +6903,23 @@ "restoreTitle": "Ripristina dal backup", "restoreDescription": "Questa operazione sostituirà TUTTI i dati attuali di {username} con l'istantanea del {when}. Misurazioni, farmaci, eventi di assunzione, voci dell'umore, canali di notifica, sottoscrizioni push ed eliminazioni Telegram in coda esistenti verranno eliminati.", "restorePromptLabel": "Digita RESTORE per continuare", + "previewTitle": "Contenuto del backup", + "previewLoading": "Lettura del contenuto…", + "previewUnavailable": "Impossibile leggere il contenuto — il ripristino resta possibile.", + "previewEmpty": "Il file non contiene record.", + "previewOther": "Altri record", + "previewMeasurements": "Misurazioni", + "previewMedications": "Farmaci e assunzioni", + "previewMood": "Voci dell'umore", + "previewLabs": "Risultati di laboratorio", + "previewDocuments": "Documenti", + "previewWorkouts": "Allenamenti", + "previewCycles": "Dati del ciclo", + "previewVisits": "Visite mediche", + "previewVaccinations": "Vaccinazioni", + "previewReminders": "Promemoria di prevenzione", + "previewCustomMetrics": "Metriche personalizzate", + "previewHealthScores": "Storico del punteggio", "restoreConfirm": "Ripristina ora", "restoreInProgress": "Ripristino in corso...", "restoreSuccess": "Ripristino completato", @@ -8085,6 +8114,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", @@ -9035,7 +9081,9 @@ "flaggedBadge": "Supporto mostrato", "chartTitle": "Andamento del punteggio", "totalLabel": "Punteggio", - "bandLabel": "Categoria" + "bandLabel": "Categoria", + "answersToggle": "Risposte", + "answersUnavailable": "Le risposte salvate di questo check-in non possono essere decifrate." }, "crisis": { "title": "Non devi affrontare tutto questo da solo/a", @@ -9739,6 +9787,8 @@ "editedToast": "Valore aggiornato", "editError": "Impossibile aggiornare il valore", "deletedToast": "Valore eliminato", + "restoredToast": "Valore ripristinato", + "restoreError": "Ripristino non riuscito", "deleteError": "Impossibile eliminare il valore" }, "chart": { @@ -10208,6 +10258,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..39a1c7476 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -516,6 +516,9 @@ "heroItemsTitle": "Dzisiejsze wyróżnienia", "heroItemsDescription": "Wybierz typy elementów, które mogą pojawiać się w dzisiejszych wyróżnieniach.", "heroItemsNotificationNote": "Ustawieniami powiadomień zarządzasz osobno.", + "heroContentTitle": "Górna karta pokazuje", + "heroContentScore": "Wynik zdrowia i podsumowanie dnia", + "heroContentReminders": "Dzisiejsze wyróżnienia", "heroItems": { "coach_checkin": "Podsumowania z Coachem", "dose_window": "Dawki leków", @@ -3168,7 +3171,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", @@ -4517,7 +4521,7 @@ "title": "Withings", "body": "Wagi, ciśnieniomierze i trackery Withings synchronizują się automatycznie po połączeniu.", "cta": "Połącz Withings", - "setupCta": "Skonfiguruj w Ustawieniach", + "setupCta": "Skonfiguruj", "setupHint": "Najpierw wymaga własnej, darmowej aplikacji deweloperskiej Withings — około 5 minut, jednorazowo." }, "more": { @@ -4528,7 +4532,8 @@ "oura": "Oura", "polar": "Polar", "nightscout": "Nightscout", - "fitbit": "Fitbit" + "fitbit": "Fitbit", + "appleHealthHint": "Apple Health nie ma połączenia przez przeglądarkę — dokończ tu wstępną konfigurację, a następnie otwórz aplikację HealthLog na iPhonie, aby dokończyć konfigurację Apple Health." }, "import": "Importuj istniejącą historię" }, @@ -5967,7 +5972,7 @@ }, "notificationStatus": { "title": "Niezawodność kanałów", - "description": "Stan każdego kanału; „Automatycznie wyłączone” przerywa ponawianie po trwałych błędach.", + "description": "Stan każdego kanału — „Automatycznie wyłączone” oznacza zatrzymanie po trwałych błędach.", "emptyDescription": "Dodaj kanał w Ustawienia › Integracje › Kanały dostarczania.", "stateActive": "Aktywne", "stateAutoDisabled": "Automatycznie wyłączone", @@ -6387,6 +6392,13 @@ "importNote": "Potrzebujesz starszej historii lub nie możesz użyć aplikacji iOS? Przesłanie pliku XML/ZIP z Apple Health jest jednorazową alternatywą i nie włącza synchronizacji na żywo.", "importAction": "Otwórz jednorazowy import", "staleNote": "Od ponad tygodnia nie napłynęły żadne dane z Apple Health. Otwórz aplikację HealthLog na iPhonie i sprawdź uprawnienia Apple Health.", + "progress": { + "title": "Postęp synchronizacji", + "receivedLabel": "Odebrane rekordy", + "oldestLabel": "Najstarszy osiągnięty pomiar", + "flowing": "W ciągu ostatniej godziny dotarły dane — aplikacja na iPhonie nadal je przesyła.", + "waiting": "Oczekiwanie na kolejne dane z aplikacji na iPhonie. Przesyłanie trwa dalej, gdy aplikacja jest otwarta lub gdy iOS przydzieli jej czas w tle — cisza podczas pierwszego dużego uzupełniania historii jest normalna, to nie błąd." + }, "delivery": { "title": "Dostarczanie", "lastTriggerLabel": "Ostatnia synchronizacja dotarła", @@ -6891,6 +6903,23 @@ "restoreTitle": "Przywróć z kopii zapasowej", "restoreDescription": "Spowoduje to zastąpienie WSZYSTKICH bieżących danych {username} migawką z {when}. Istniejące pomiary, leki, zdarzenia przyjęcia, wpisy nastroju, kanały powiadomień, subskrypcje push i zakolejkowane usunięcia Telegram zostaną usunięte.", "restorePromptLabel": "Wpisz RESTORE, aby kontynuować", + "previewTitle": "Zawartość kopii", + "previewLoading": "Odczytywanie zawartości…", + "previewUnavailable": "Nie udało się odczytać zawartości — przywracanie jest nadal możliwe.", + "previewEmpty": "Plik nie zawiera rekordów.", + "previewOther": "Inne rekordy", + "previewMeasurements": "Pomiary", + "previewMedications": "Leki i dawki", + "previewMood": "Wpisy nastroju", + "previewLabs": "Wyniki badań", + "previewDocuments": "Dokumenty", + "previewWorkouts": "Treningi", + "previewCycles": "Dane cyklu", + "previewVisits": "Wizyty lekarskie", + "previewVaccinations": "Szczepienia", + "previewReminders": "Przypomnienia profilaktyczne", + "previewCustomMetrics": "Własne metryki", + "previewHealthScores": "Historia wyniku", "restoreConfirm": "Przywróć teraz", "restoreInProgress": "Przywracanie...", "restoreSuccess": "Przywracanie zakończone", @@ -8085,6 +8114,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", @@ -9035,7 +9081,9 @@ "flaggedBadge": "Pokazano wsparcie", "chartTitle": "Przebieg wyniku", "totalLabel": "Wynik", - "bandLabel": "Kategoria" + "bandLabel": "Kategoria", + "answersToggle": "Odpowiedzi", + "answersUnavailable": "Nie można odszyfrować zapisanych odpowiedzi tego sprawdzenia." }, "crisis": { "title": "Nie musisz mierzyć się z tym sam(a)", @@ -9739,6 +9787,8 @@ "editedToast": "Zaktualizowano wartość", "editError": "Nie udało się zaktualizować wartości", "deletedToast": "Usunięto wartość", + "restoredToast": "Przywrócono wartość", + "restoreError": "Nie udało się przywrócić", "deleteError": "Nie udało się usunąć wartości" }, "chart": { @@ -10208,6 +10258,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/package.json b/package.json index 2f6550160..ba01c99ae 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.37.19", + "version": "1.37.20", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "PolyForm-Noncommercial-1.0.0", "homepage": "https://healthlog.dev", diff --git a/prisma/migrations/0334_vorsorge_skip_ledger/migration.sql b/prisma/migrations/0334_vorsorge_skip_ledger/migration.sql new file mode 100644 index 000000000..f9e157775 --- /dev/null +++ b/prisma/migrations/0334_vorsorge_skip_ledger/migration.sql @@ -0,0 +1,54 @@ +-- Refs #223 + iOS #68 — Vorsorge snooze/skip + the completion ledger. +-- All additive; every existing reminder keeps its exact behaviour. + +-- Snooze cursor: non-NULL and in the future = "pushed back until this +-- instant". The snooze write sets next_due_at to the SAME instant, so every +-- due-state consumer moves with it; expiry is the clock passing the value. +ALTER TABLE "measurement_reminders" ADD COLUMN "snoozed_until" TIMESTAMP(3); + +-- Last honest skip. A skip re-anchors next_due_at at the skip instant but +-- never moves last_satisfied_at — done and skipped stay distinguishable. +ALTER TABLE "measurement_reminders" ADD COLUMN "last_skipped_at" TIMESTAMP(3); + +-- Lifetime skip counter ("skipped N times"). +ALTER TABLE "measurement_reminders" ADD COLUMN "skip_count" INTEGER NOT NULL DEFAULT 0; + +-- The completion ledger (iOS #68). The engine is single-cursor, so before +-- this table each satisfy overwrote the last and history was unrecoverable. +-- Append-only: one row per satisfy / skip, written by the one satisfy +-- primitive and the skip route. History starts with this release — the +-- cursor holds nothing to backfill from. +CREATE TYPE "measurement_reminder_event_kind" AS ENUM ('SATISFIED', 'SKIPPED'); + +CREATE TABLE "measurement_reminder_events" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "reminder_id" TEXT NOT NULL, + "kind" "measurement_reminder_event_kind" NOT NULL, + "occurred_at" TIMESTAMP(3) NOT NULL, + "on_time" BOOLEAN NOT NULL, + "source" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "measurement_reminder_events_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX "measurement_reminder_events_reminder_id_occurred_at_idx" + ON "measurement_reminder_events"("reminder_id", "occurred_at"); + +CREATE INDEX "measurement_reminder_events_user_id_idx" + ON "measurement_reminder_events"("user_id"); + +ALTER TABLE "measurement_reminder_events" + ADD CONSTRAINT "measurement_reminder_events_user_id_fkey" + FOREIGN KEY ("user_id") REFERENCES "users"("id") + ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "measurement_reminder_events" + ADD CONSTRAINT "measurement_reminder_events_reminder_id_fkey" + FOREIGN KEY ("reminder_id") REFERENCES "measurement_reminders"("id") + ON DELETE CASCADE ON UPDATE CASCADE; + +-- Custom-metric entries gain the soft-delete tombstone every peer entry +-- surface already has (undo parity — the one hard delete left). +ALTER TABLE "custom_metric_entries" ADD COLUMN "deleted_at" TIMESTAMP(3); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 25c7d873d..af1b34cd0 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -102,6 +102,10 @@ model User { // Codex OAuth (ChatGPT subscription) codexAccessTokenEncrypted String? @map("codex_access_token_encrypted") codexRefreshTokenEncrypted String? @map("codex_refresh_token_encrypted") + /// @internal: dead mirror of the expiry inside the encrypted Codex creds — + /// the refresh path reads the decrypted blob's own `expiresAt` + /// (`src/lib/ai/provider.ts`), never this column. Kept (and kept written) + /// for wire/backup compatibility rather than dropped. codexTokenExpiresAt DateTime? @map("codex_token_expires_at") codexConnectedAt DateTime? @map("codex_connected_at") codexConnectionStatus String @default("disconnected") @map("codex_connection_status") @@ -879,6 +883,9 @@ model User { // Server computes the canonical `nextDueAt`; the cron fires through the // existing notification dispatcher. Soft-deleted. measurementReminders MeasurementReminder[] + // v1.37.20 — completion ledger behind the reminders (iOS #68): one + // append-only row per satisfy / skip. Cascade-deleted with the account. + measurementReminderEvents MeasurementReminderEvent[] // v1.17.1 — minimal structured lab store (LDL, HbA1c, ferritin, TSH, …) // that pairs with the Vorsorge "annual blood panel" reminder so a result // has somewhere to land. Soft-deleted. @@ -1045,6 +1052,9 @@ model EnvironmentContext { /// that won precedence). lat Float lon Float + /// @internal: human-readable label of the resolved location, stored as + /// operator/debug provenance beside the coordinates; readers select the + /// numeric and weather columns only, never this label. locationLabel String @map("location_label") source EnvironmentLocationSource @@ -2416,6 +2426,9 @@ model PersonalRecord { /// FK to the Measurement row that achieved the record. SET NULL /// on delete so the historical fact survives even if the /// underlying measurement is later removed. + /// @internal: audit-trail provenance written by the PR detection worker; + /// no read path selects it — the record row itself carries the surfaced + /// value/unit/achievedAt. sourceMeasurementId String? @map("source_measurement_id") source MeasurementSource @default(MANUAL) externalId String? @map("external_id") @@ -3581,10 +3594,16 @@ model McpOAuthConnection { model InviteToken { id String @id @default(cuid()) tokenHash String @unique @map("token_hash") + /// Consumed through the `creator` relation: the admin invites list + /// (`src/app/api/admin/invites/route.ts`) selects the creating admin's + /// id + username via it. createdBy String @map("created_by") createdAt DateTime @default(now()) @map("created_at") expiresAt DateTime @map("expires_at") usedAt DateTime? @map("used_at") + /// LAST consumer only (overwritten on multi-use invites; the full history + /// lives in `InviteRedemption`). Consumed through the `consumer` relation + /// by the admin invites list (`src/app/api/admin/invites/route.ts`). usedBy String? @map("used_by") maxUses Int @default(1) @map("max_uses") uses Int @default(0) @@ -3641,8 +3660,11 @@ model RefreshToken { /// When this token was used (one-time-use). Set during rotation; a second /// use of the same token is a hard fail and triggers reuse-detection. usedAt DateTime? @map("used_at") - /// Pointer to the row that replaced this one during rotation. Helps audit - /// trail and reuse-detection ("this token was already exchanged into X"). + /// Pointer to the row that replaced this one during rotation ("this token + /// was already exchanged into X"). + /// @internal: audit-trail bookkeeping written by the rotation path + /// (`src/lib/auth/refresh-token.ts`); reuse-detection keys on + /// `usedAt`/`tokenHash`, so no code path reads this pointer. replacedById String? @map("replaced_by_id") userAgent String? @map("user_agent") ipAddress String? @map("ip_address") @@ -4920,12 +4942,18 @@ model ImportJob { failureReason String? @map("failure_reason") /// Bytes uploaded — surfaced to the operator audit trail. uploadBytes Int @map("upload_bytes") - /// SHA-256 hex digest of the uploaded `export.zip`. Lets the - /// kick-off endpoint short-circuit a re-upload of the exact same - /// file back to the existing job id without re-queueing. + /// SHA-256 hex digest of the uploaded `export.zip`. Consumer: both kick-off + /// endpoints (`src/app/api/import/apple-health-export/route.ts` and the + /// admin variant) dedupe on (userId, uploadSha256, parserRevision) and + /// short-circuit a re-upload of the exact same file back to the existing + /// job id without re-queueing. A prior `failed` job is excluded from the + /// match (issue #486), so the same bytes can always be retried. uploadSha256 String? @map("upload_sha256") /// Parser semantics revision. Existing archives migrated at revision 1 can - /// be deliberately reprocessed when the current parser revision advances. + /// be deliberately reprocessed when the current parser revision advances: + /// the kick-off dedupe lookup (see `uploadSha256`) pins the CURRENT + /// `APPLE_HEALTH_IMPORT_PARSER_REVISION`, so a job parsed under an older + /// revision never short-circuits a fresh upload of the same bytes. parserRevision Int @default(1) @map("parser_revision") /// Apple's `ExportDate` attribute string-formatted, parsed during /// the unpack phase. NULL until the parser sees it. @@ -5187,6 +5215,20 @@ model MeasurementReminder { /// excluded everywhere), whereas a booster is the engine's native case — /// recurring, satisfiable, cadence-bearing. vaccinationAntigen String? @map("vaccination_antigen") + /// v1.37.20 — snooze cursor. Non-NULL and in the future = "pushed back until + /// this instant". The snooze write sets `nextDueAt` to the SAME instant, so + /// every due-state consumer (digest, dashboard, cron, AI block) moves with + /// it without knowing snooze exists. Self-expiring: once the instant passes, + /// the clock comparison stops being true and no cleanup is needed. Any + /// recompute of `nextDueAt` (satisfy, skip, cadence PATCH) nulls it. + snoozedUntil DateTime? @map("snoozed_until") + /// v1.37.20 — last honest skip. A skip re-anchors `nextDueAt` at the skip + /// instant but NEVER moves `lastSatisfiedAt` — done and skipped stay + /// distinguishable forever. "Skipped cycle" derives as + /// `lastSkippedAt > lastSatisfiedAt` (or non-NULL with no satisfy yet). + lastSkippedAt DateTime? @map("last_skipped_at") + /// v1.37.20 — lifetime skip counter ("skipped N times"). + skipCount Int @default(0) @map("skip_count") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") /// Soft-delete tombstone. NULL = live; non-NULL = deleted at the instant. @@ -5201,6 +5243,10 @@ model MeasurementReminder { /// this reminder. SetNull on the far side — deleting the reminder leaves /// the vaccination record intact. vaccinations VaccinationRecord[] + /// v1.37.20 — the completion ledger (iOS #68): every satisfy and every + /// skip appends one row. Cascade: the ledger has no meaning without its + /// reminder. + events MeasurementReminderEvent[] @@index([userId, deletedAt]) @@index([userId, nextDueAt]) @@ -5234,6 +5280,55 @@ enum ReminderOrigin { @@map("reminder_origin") } +/// v1.37.20 — what a completion-ledger row records: an honest fulfilment +/// (`SATISFIED` — a matching reading landed or the user marked it done) or an +/// honest skip (`SKIPPED` — the user deliberately let this cycle go, which is +/// NOT a completion and never touches `lastSatisfiedAt`). +enum MeasurementReminderEventKind { + SATISFIED + SKIPPED + + @@map("measurement_reminder_event_kind") +} + +/// v1.37.20 — the Vorsorge completion ledger (iOS #68). The reminder engine is +/// single-cursor (`nextDueAt` + `lastSatisfiedAt`), so before this table each +/// satisfy overwrote the previous one and history was unrecoverable. Every +/// path through the ONE satisfy primitive (`satisfyReminder`) and the skip +/// route appends one row here; nothing ever updates or deletes a row short of +/// deleting the reminder itself. History starts at the release that ships +/// this — the single cursor holds nothing to backfill from, which the iOS #68 +/// thread records as agreed. +model MeasurementReminderEvent { + id String @id @default(cuid()) + userId String @map("user_id") + reminderId String @map("reminder_id") + kind MeasurementReminderEventKind + /// The event instant: the satisfy instant (which may be the backdated + /// reading's own timestamp) or the skip instant (always the server clock). + occurredAt DateTime @map("occurred_at") + /// Derived AT WRITE TIME against the reminder's `nextDueAt` as it stood + /// before the event re-anchored it: `true` when the event landed at or + /// before the due instant (or the cadence had no computable due). Clients + /// (web + iOS) consume this resolved value and never re-derive it — the + /// pre-event `nextDueAt` is gone after the write, so write time is the only + /// honest moment to answer the question. + onTime Boolean @map("on_time") + /// Which path produced the event — a closed set of engine call sites + /// (`manual`, `auto_measurement`, `auto_lab`, `telegram`, `vaccination`, + /// `encounter`, `skip`), asserted in code, not free text. + source String + createdAt DateTime @default(now()) @map("created_at") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + reminder MeasurementReminder @relation(fields: [reminderId], references: [id], onDelete: Cascade) + + /// The history endpoint reads newest-first per reminder. + @@index([reminderId, occurredAt]) + @@index([userId]) + @@map("measurement_reminder_events") +} + // ─── Lab results ───────────────────────────────────────────── /// v1.17.1 — minimal structured lab store pairing with the Vorsorge "annual @@ -5432,6 +5527,10 @@ model CustomMetricEntry { measuredAt DateTime @map("measured_at") note String? createdAt DateTime @default(now()) @map("created_at") + /// v1.37.20 — soft-delete tombstone, peer parity with Measurement / + /// MoodEntry (every other entry surface deletes recoverably with an undo + /// toast; custom-metric entries were the one hard delete). NULL = live. + deletedAt DateTime? @map("deleted_at") user User @relation(fields: [userId], references: [id], onDelete: Cascade) metric CustomMetric @relation(fields: [customMetricId], references: [id], onDelete: Cascade) @@ -5695,6 +5794,8 @@ model CoachConversation { // conversation keeps memory of what came before without re-sending every // turn to the provider. AES-256-GCM (same codec as CoachMessage.encrypted). summaryEncrypted Bytes? @map("summary_encrypted") + /// @internal: diagnostic timestamp of the last summary rewrite — the elision + /// logic keys on `summaryTurnCount`, not on this instant; nothing reads it. summaryUpdatedAt DateTime? @map("summary_updated_at") summaryTurnCount Int @default(0) @map("summary_turn_count") @@ -7713,7 +7814,12 @@ model DocumentContentIndex { /// Provider type that produced the text (diagnostic only), or null on the /// text-ocr path. providerType String? @map("provider_type") - /// Tokeniser algorithm version — bump to force a re-index on a scheme change. + /// Tokeniser algorithm version — bump `CONTENT_TOKENIZER_VERSION` + /// (`src/lib/documents/content-index.ts`) to force a re-index on a scheme + /// change. Consumer: the content-index backfill job re-tokenises any row + /// whose stored version trails the current one from the decrypted + /// `textEncrypted` (`retokeniseStaleRows` in + /// `src/lib/jobs/document-content-index-backfill.ts`). tokenizerVersion String @map("tokenizer_version") createdAt DateTime @default(now()) @map("created_at") @@ -7750,7 +7856,8 @@ model DocumentThumbnail { height Int /// Plaintext byte length of the JPEG (pre-encryption), for `Content-Length`. byteSize Int @map("byte_size") - /// Source MIME the preview was rendered from (diagnostic only). + /// @internal: source MIME the preview was rendered from — operator-side + /// diagnostic provenance only; no query selects it and no client surfaces it. sourceMime String @map("source_mime") createdAt DateTime @default(now()) @map("created_at") diff --git a/public/sw.js b/public/sw.js index 922bedb3c..b008e89bd 100644 --- a/public/sw.js +++ b/public/sw.js @@ -36,7 +36,7 @@ try { // v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild. const CACHE_VERSION = (typeof self !== "undefined" && self.__APP_VERSION__) || - /* @sw-version-fallback */ "v1.37.19"; + /* @sw-version-fallback */ "v1.37.20"; const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`; const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`; // v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*` @@ -79,6 +79,9 @@ const MAX_DATA_ENTRIES = 60; // and the version probe are eligible. const API_READ_ALLOWLIST = [ "/api/dashboard/snapshot", + // v1.37.20 — daily digest: the Today rail's offline read. Keep in + // lockstep with src/lib/pwa/sw-cache-policy.ts. + "/api/daily/digest", "/api/dashboard/widgets", "/api/measurements", "/api/medications", diff --git a/src/__tests__/backup-plan-classification.test.ts b/src/__tests__/backup-plan-classification.test.ts index 00811393c..fca11a857 100644 --- a/src/__tests__/backup-plan-classification.test.ts +++ b/src/__tests__/backup-plan-classification.test.ts @@ -462,6 +462,20 @@ describe("every backed-up model travels both ways, or is named as debt", () => { ).toEqual([]); }); + it("proves the reminder engine models are on the covered side", () => { + // v1.37.20 (#223 / iOS #68) — the reminder came off the debt register and + // the completion ledger landed carried from the release that introduced + // it. Pinned by name so a future edit cannot quietly move either back to + // COVERAGE_PENDING: the ledger is the only copy of every satisfy and skip + // before the latest one, so "pending" would be a silent history loss, not + // a deferral. + for (const model of ["MeasurementReminder", "MeasurementReminderEvent"]) { + expect(BACKED_UP_MODELS).toContain(model); + expect(TWO_ENDED_MODELS).toContain(model); + expect(COVERAGE_PENDING).not.toHaveProperty(model); + } + }); + it("proves the structured-profile models are on the covered side", () => { // These account-owned rows are exported and restored explicitly rather // than being treated as reconstructible data. diff --git a/src/__tests__/data-wipe-completeness.test.ts b/src/__tests__/data-wipe-completeness.test.ts index af4b2d4b5..d2ceaf154 100644 --- a/src/__tests__/data-wipe-completeness.test.ts +++ b/src/__tests__/data-wipe-completeness.test.ts @@ -280,4 +280,36 @@ describe("data-wipe completeness", () => { OR: [{ recordUserId: "u1" }, { recipientUserId: "u1" }], }); }); + + it("freezes the set of user-linked tables that carry no foreign key", () => { + // v1.37.20 (A6-19) — NO_FK_BY_DESIGN, as an assertion instead of a pair + // of FK migrations. The two Telegram plumbing tables link to an account + // by a bare `userId` string on purpose: both hold a bounded, short-lived + // window of chat state (scheduled message deletions, prompt contexts) + // that a cron already reaps, and a cascade would buy nothing the wipe + // plan does not already do — rule 1 catches them because the column NAME + // matches the convention. That is the exact fragility this test freezes: + // a THIRD such table would also pass rule 1 silently, without anyone + // deciding it should. So the set is closed here; growing it is a + // decision made in this file, with a reason, or by adding the FK. + const NO_FK_BY_DESIGN = new Set([ + "TelegramScheduledDeletion", + "TelegramPromptContext", + ]); + const bareUserIdModels = [...models.entries()] + .filter( + ([name, shape]) => + name !== "User" && + shape.scalars.includes("userId") && + !shape.relations.some((rel) => rel.target === "User"), + ) + .map(([name]) => name) + .sort(); + expect(bareUserIdModels).toEqual([...NO_FK_BY_DESIGN].sort()); + // And both stay in the wipe plan — the assertion above is about schema + // shape, this one about the plan actually reaching them. + for (const model of NO_FK_BY_DESIGN) { + expect(WIPE_MODELS).toContain(model); + } + }); }); 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..09cd78e1b 100644 --- a/src/__tests__/sharing-surface-guard.test.ts +++ b/src/__tests__/sharing-surface-guard.test.ts @@ -875,6 +875,10 @@ const DELEGABLE_ROUTES: Record = { domain: "mind", why: "The record's screener history. The module gate resolves against the record, so a delegate sees the surface only where the owner switched it on; the rate limit on the POST arm is untouched and that arm still refuses under a switch.", }, + "app/api/mental-health/assessments/[id]/route.ts": { + domain: "mind", + why: "One administration of the record, fetch-then-guard against the resolved user, WITH its decrypted per-item answers. Admitted at the same level as the list beside it deliberately: the mind read grant already serves the mood diary's decrypted prose and the list already publishes the item-9 flag, so the per-item values sit inside what the domain shows, not beyond it. The route comment carries the argument; the module gate resolves against the record like the sibling.", + }, "app/api/anamnesis/facts/route.ts": { domain: "profile", why: "The record's health-profile facts, read through `readHealthProfileFacts(user.id)`. The module holds write helpers, and the GET calls none of them.", @@ -1140,6 +1144,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 +1493,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 +1937,16 @@ 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. + * + * The screener detail read adds one: the per-administration read that + * decrypts the stored item answers, on the record list at the same level as + * the screener history beside it. 229 -> 230. */ -const FROZEN_ENTRY_COUNT = 224; +const FROZEN_ENTRY_COUNT = 230; /** * The two surfaces that authenticate a Bearer token outside `requireAuth` — @@ -2673,7 +2707,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/__tests__/success-affordance-guard.test.ts b/src/__tests__/success-affordance-guard.test.ts index 8d5ccc2ef..79b1b0a9e 100644 --- a/src/__tests__/success-affordance-guard.test.ts +++ b/src/__tests__/success-affordance-guard.test.ts @@ -140,7 +140,9 @@ const PINNED_AFFORDANCES: Record< "toast.success": 1, }, "src/components/custom-metrics/custom-metric-history-list.tsx": { - "toast.success": 2, + // v1.37.20 (A3-11) — the third success is the restore confirmation + // behind the delete toast's Undo action. + "toast.success": 3, }, "src/components/cycle/cycle-phase-crosstab.tsx": { "text-success": 1 }, // v1.36.x — two call sites, one decision: the intake toast routes through diff --git a/src/__tests__/v137-wave-zero-fixtures.test.ts b/src/__tests__/v137-wave-zero-fixtures.test.ts index 97250c15b..34bfae2db 100644 --- a/src/__tests__/v137-wave-zero-fixtures.test.ts +++ b/src/__tests__/v137-wave-zero-fixtures.test.ts @@ -26,7 +26,7 @@ describe("sharing and handler inventories", () => { }); it("keeps every admitted mutation uniquely addressable and fully controlled", () => { - expect(ADMITTED_MUTATING_HANDLERS).toHaveLength(75); + expect(ADMITTED_MUTATING_HANDLERS).toHaveLength(77); expect( new Set( ADMITTED_MUTATING_HANDLERS.map( diff --git a/src/app/api/admin/backups/[id]/restore/__tests__/round-trip.test.ts b/src/app/api/admin/backups/[id]/restore/__tests__/round-trip.test.ts index 4d42c8519..9d6921831 100644 --- a/src/app/api/admin/backups/[id]/restore/__tests__/round-trip.test.ts +++ b/src/app/api/admin/backups/[id]/restore/__tests__/round-trip.test.ts @@ -292,6 +292,10 @@ function sourceClient() { encounterConditionLink: { findMany: vi.fn().mockResolvedValue([]) }, vaccinationRecord: { findMany: vi.fn().mockResolvedValue([]) }, vaccinationDocumentLink: { findMany: vi.fn().mockResolvedValue([]) }, + // The reminder engine tables (v1.37.20, #223 / iOS #68). Empty for the + // same reason as the visit tables above. + measurementReminder: { findMany: vi.fn().mockResolvedValue([]) }, + measurementReminderEvent: { findMany: vi.fn().mockResolvedValue([]) }, customMetric: { findMany: vi.fn().mockResolvedValue([ { diff --git a/src/app/api/admin/backups/[id]/restore/route.ts b/src/app/api/admin/backups/[id]/restore/route.ts index 13976d771..20506c963 100644 --- a/src/app/api/admin/backups/[id]/restore/route.ts +++ b/src/app/api/admin/backups/[id]/restore/route.ts @@ -50,6 +50,7 @@ import { restoreIntradayProfileData } from "@/lib/export/intraday-profile-backup import { restoreHealthScoreData } from "@/lib/export/health-score-backup"; import { restoreVisitsData } from "@/lib/export/visits-backup"; import { restoreVaccinationsData } from "@/lib/export/vaccinations-backup"; +import { restoreRemindersData } from "@/lib/export/reminders-backup"; import { invalidateUserData } from "@/lib/cache/invalidate"; export const dynamic = "force-dynamic"; @@ -95,6 +96,8 @@ interface RestoreResponse { encounterLinks: number; vaccinations: number; vaccinationLinks: number; + measurementReminders: number; + measurementReminderEvents: number; }; } @@ -1329,12 +1332,28 @@ const handler = apiHandler( }); } - // Visits, the address book and the three link tables. Last on - // purpose: a link is written only when both of its ends exist, and - // the documents above are the final far side to land. Both ends of - // this section live in `src/lib/export/visits-backup.ts`, so a grep - // of THIS file alone will not find them — the same delegation as the - // cycle, profile, intraday and score restores above. + // The Vorsorge reminders and their completion ledger (v1.37.20, + // #223 / iOS #68), BEFORE the visits and the vaccinations: both of + // those remap a `reminderId` against the reminders in the database, + // so the reminders have to be in the database first or every + // appointment and booster reference would read as unresolvable and + // drop. Both ends of this section live in + // `src/lib/export/reminders-backup.ts`. + const remindersCleared = await restoreRemindersData( + tx, + ownerId, + payload, + skips, + ); + + // Visits, the address book and the three link tables. After the + // documents and the reminders on purpose: a link is written only + // when both of its ends exist, and the encounter's reminder + // reference remaps against the rows the branch above has just + // written. Both ends of this section live in + // `src/lib/export/visits-backup.ts`, so a grep of THIS file alone + // will not find them — the same delegation as the cycle, profile, + // intraday and score restores above. const visitsCleared = await restoreVisitsData( tx, ownerId, @@ -1343,10 +1362,10 @@ const handler = apiHandler( ); // The immunization log, after the visits: a dose remaps its - // practitioner and its encounter against rows the branch above has - // just written, and its document link needs the documents that - // landed before that. Both ends of this section live in - // `src/lib/export/vaccinations-backup.ts`. + // practitioner, its encounter and its booster reminder against rows + // the branches above have just written, and its document link needs + // the documents that landed before that. Both ends of this section + // live in `src/lib/export/vaccinations-backup.ts`. const vaccinationsCleared = await restoreVaccinationsData( tx, ownerId, @@ -1385,6 +1404,9 @@ const handler = apiHandler( encounterLinks: visitsCleared.encounterLinks, vaccinations: vaccinationsCleared.vaccinations, vaccinationLinks: vaccinationsCleared.vaccinationLinks, + measurementReminders: remindersCleared.measurementReminders, + measurementReminderEvents: + remindersCleared.measurementReminderEvents, }; return { cleared, skipped: summarizeRestoreSkips(skips) }; }, @@ -1537,6 +1559,8 @@ const handler = apiHandler( encounterLinks: summary.encounterLinks, vaccinations: summary.vaccinations, vaccinationLinks: summary.vaccinationLinks, + measurementReminders: summary.measurementReminders, + measurementReminderEvents: summary.measurementReminderEvents, }, }, }); diff --git a/src/app/api/admin/backups/[id]/summary/__tests__/route.test.ts b/src/app/api/admin/backups/[id]/summary/__tests__/route.test.ts new file mode 100644 index 000000000..d06486115 --- /dev/null +++ b/src/app/api/admin/backups/[id]/summary/__tests__/route.test.ts @@ -0,0 +1,95 @@ +/** + * v1.37.20 — restore preview: the summary read decrypts and validates the + * stored file through the SAME helpers the restore uses, and only ever + * reads. Watched red: written against the route before its annotate landed; + * the not-found and decrypt-failure arms were asserted first. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/api-handler", () => ({ + apiHandler: unknown>(fn: T) => fn, + requireAdmin: vi.fn(async () => ({ user: { id: "admin-1" } })), + HttpError: class HttpError extends Error { + constructor( + public status: number, + message: string, + ) { + super(message); + } + }, +})); + +vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); + +const findUniqueMock = vi.fn(); +vi.mock("@/lib/db", () => ({ + prisma: { + dataBackup: { findUnique: (...a: unknown[]) => findUniqueMock(...a) }, + }, +})); + +const decryptMock = vi.fn(); +vi.mock("@/lib/crypto", () => ({ + decrypt: (...a: unknown[]) => decryptMock(...a), +})); + +const parseMock = vi.fn(); +const summarizeMock = vi.fn(); +vi.mock("@/lib/validations/backup", () => ({ + parseBackupPayload: (...a: unknown[]) => parseMock(...a), + summarizeBackup: (...a: unknown[]) => summarizeMock(...a), + isCompatibleSchemaVersion: vi.fn(() => true), +})); + +import { GET } from "../route"; + +const request = new NextRequest( + "http://localhost/api/admin/backups/b1/summary", +); +const params = { params: Promise.resolve({ id: "b1" }) }; + +beforeEach(() => { + vi.clearAllMocks(); + findUniqueMock.mockResolvedValue({ + id: "b1", + userId: "u1", + data: "ciphertext", + createdAt: new Date("2026-08-01T00:00:00Z"), + user: { id: "u1", username: "self-hoster" }, + }); + decryptMock.mockReturnValue("{}"); + parseMock.mockReturnValue({ schemaVersion: 3 }); + summarizeMock.mockReturnValue({ measurements: 12, moodEntries: 3 }); +}); + +describe("GET /api/admin/backups/[id]/summary", () => { + it("answers the file's counts without writing anything", async () => { + const res = await GET(request, params); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.data.summary).toEqual({ measurements: 12, moodEntries: 3 }); + expect(body.data.owner).toBe("self-hoster"); + }); + + it("404s an unknown backup id", async () => { + findUniqueMock.mockResolvedValue(null); + await expect(GET(request, params)).rejects.toMatchObject({ status: 404 }); + }); + + it("degrades to an error response when the file cannot be decrypted", async () => { + decryptMock.mockImplementation(() => { + throw new Error("bad key"); + }); + const res = await GET(request, params); + expect(res.status).toBe(500); + }); + + it("422s a file that fails schema validation", async () => { + parseMock.mockImplementation(() => { + throw new Error("nope"); + }); + const res = await GET(request, params); + expect(res.status).toBe(422); + }); +}); diff --git a/src/app/api/admin/backups/[id]/summary/route.ts b/src/app/api/admin/backups/[id]/summary/route.ts new file mode 100644 index 000000000..fe32c81e7 --- /dev/null +++ b/src/app/api/admin/backups/[id]/summary/route.ts @@ -0,0 +1,79 @@ +/** + * v1.37.20 — restore preview: what a backup file contains, BEFORE the + * operator confirms a restore. + * + * The restore dialog used to ask for the typed confirmation on nothing but + * a filename and a date; the counts only appeared in the audit row after + * the rows were already replaced. This read decrypts and schema-validates + * the stored file exactly the way the restore itself does (same decrypt, + * same `parseBackupPayload`, same compatibility gate) and answers with the + * `summarizeBackup` counts — so what the operator confirms is what the + * restore will write, derived from the same code path. Read-only; nothing + * is touched. + */ +import { NextRequest } from "next/server"; + +import { prisma } from "@/lib/db"; +import { apiHandler, HttpError, requireAdmin } from "@/lib/api-handler"; +import { apiError, apiSuccess } from "@/lib/api-response"; +import { decrypt } from "@/lib/crypto"; +import { annotate } from "@/lib/logging/context"; +import { + isCompatibleSchemaVersion, + parseBackupPayload, + summarizeBackup, +} from "@/lib/validations/backup"; + +export const dynamic = "force-dynamic"; + +type RouteParams = { params: Promise<{ id: string }> }; + +export const GET = apiHandler( + async (_request: NextRequest, { params }: RouteParams) => { + const { user: admin } = await requireAdmin(); + void admin; + const { id } = await params; + + const backup = await prisma.dataBackup.findUnique({ + where: { id }, + include: { user: { select: { id: true, username: true } } }, + }); + if (!backup) { + throw new HttpError(404, "Backup not found"); + } + + let plaintext: string; + try { + plaintext = decrypt(backup.data); + } catch { + return apiError("Failed to decrypt backup payload", 500); + } + + let payload; + try { + payload = parseBackupPayload(plaintext); + } catch { + return apiError("Backup payload failed schema validation", 422); + } + + if (!isCompatibleSchemaVersion(payload.schemaVersion)) { + return apiError( + `Backup schema version ${payload.schemaVersion} is not restorable by this release`, + 422, + ); + } + + const summary = summarizeBackup(payload); + + annotate({ + action: { name: "admin.backups.summary" }, + meta: { backupId: id, ownerId: backup.userId }, + }); + + return apiSuccess({ + summary, + owner: backup.user?.username ?? null, + createdAt: backup.createdAt.toISOString(), + }); + }, +); diff --git a/src/app/api/custom-metrics/[id]/entries/[entryId]/route.ts b/src/app/api/custom-metrics/[id]/entries/[entryId]/route.ts index 84c60839d..aaaa0b7ff 100644 --- a/src/app/api/custom-metrics/[id]/entries/[entryId]/route.ts +++ b/src/app/api/custom-metrics/[id]/entries/[entryId]/route.ts @@ -22,8 +22,11 @@ import { updateCustomMetricEntrySchema } from "@/lib/validations/custom-metrics" * * PATCH applies a partial edit (`data` built field-by-field; an explicit `null` * on `note` clears it, an omitted key leaves the column untouched). DELETE - * hard-deletes the value. Every read/write is narrowed by both `userId` and the - * parent `customMetricId`; a cross-user or mismatched-parent id surfaces as 404. + * tombstones the value (v1.37.20, A3-11 — undo parity with every peer entry + * surface; the sibling `restore` route clears the tombstone for the toast's + * Undo). Every read/write is narrowed by both `userId` and the parent + * `customMetricId`; a cross-user, tombstoned or mismatched-parent id surfaces + * as 404. */ type RouteParams = { params: Promise<{ id: string; entryId: string }> }; @@ -34,7 +37,12 @@ export const PATCH = apiHandler( const { id, entryId } = await params; const existing = await prisma.customMetricEntry.findFirst({ - where: { id: entryId, userId: user.id, customMetricId: id }, + where: { + id: entryId, + userId: user.id, + customMetricId: id, + deletedAt: null, + }, }); if (!existing) { return apiError("Custom metric entry not found", 404); @@ -104,13 +112,22 @@ export const DELETE = apiHandler( const { id, entryId } = await params; const existing = await prisma.customMetricEntry.findFirst({ - where: { id: entryId, userId: user.id, customMetricId: id }, + where: { + id: entryId, + userId: user.id, + customMetricId: id, + deletedAt: null, + }, }); if (!existing) { return apiError("Custom metric entry not found", 404); } - await prisma.customMetricEntry.delete({ where: { id: entryId } }); + // Tombstone, not a hard delete: the Undo toast's restore clears it. + await prisma.customMetricEntry.update({ + where: { id: entryId }, + data: { deletedAt: new Date() }, + }); await auditLog("customMetricEntry.delete", { userId: user.id, diff --git a/src/app/api/custom-metrics/[id]/entries/__tests__/soft-delete-restore.test.ts b/src/app/api/custom-metrics/[id]/entries/__tests__/soft-delete-restore.test.ts new file mode 100644 index 000000000..8c0d14034 --- /dev/null +++ b/src/app/api/custom-metrics/[id]/entries/__tests__/soft-delete-restore.test.ts @@ -0,0 +1,173 @@ +/** + * v1.37.20 (A3-11) — custom-metric entries move from hard delete to the + * tombstone + Undo contract every peer entry surface carries. + * + * Watched red: before the conversion the DELETE verb called + * `customMetricEntry.delete` and no restore route existed — the first two + * suites below fail against that state (no `update` with a `deletedAt` + * stamp; module not found), which is the proof the assertions bite. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/db", () => ({ + prisma: { + customMetricEntry: { + findFirst: vi.fn(), + update: vi.fn(), + updateMany: vi.fn(), + delete: vi.fn(), + }, + auditLog: { create: vi.fn().mockResolvedValue({}) }, + }, +})); + +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); + +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/cache/invalidate", () => ({ + invalidateUserCorrelationPatterns: vi.fn(), +})); + +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { DELETE } from "../[entryId]/route"; +import { POST as RESTORE } from "../restore/route"; +import { getSession } from "@/lib/auth/session"; +import { prisma } from "@/lib/db"; + +const SESSION_OK = { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { id: "user-1", username: "u", role: "USER" as const, locale: "en" }, +}; + +const ENTRY = { + id: "e-1", + userId: "user-1", + customMetricId: "cm-1", + value: 42, + unit: "kg", + measuredAt: new Date("2026-06-10T00:00:00.000Z"), + note: null, + createdAt: new Date("2026-06-10T00:00:00.000Z"), + deletedAt: null, +}; + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never); + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); +}); + +describe("DELETE /api/custom-metrics/[id]/entries/[entryId]", () => { + it("tombstones instead of hard-deleting", async () => { + vi.mocked(prisma.customMetricEntry.findFirst).mockResolvedValue( + ENTRY as never, + ); + vi.mocked(prisma.customMetricEntry.update).mockResolvedValue({} as never); + + const res = await DELETE( + new NextRequest("http://localhost/api/custom-metrics/cm-1/entries/e-1", { + method: "DELETE", + }), + { params: Promise.resolve({ id: "cm-1", entryId: "e-1" }) }, + ); + + expect(res.status).toBe(200); + expect(prisma.customMetricEntry.delete).not.toHaveBeenCalled(); + const call = vi.mocked(prisma.customMetricEntry.update).mock.calls[0][0]; + expect(call.where).toEqual({ id: "e-1" }); + expect(call.data.deletedAt).toBeInstanceOf(Date); + }); + + it("404s an already-tombstoned entry (the lookup filters the tombstone)", async () => { + vi.mocked(prisma.customMetricEntry.findFirst).mockResolvedValue(null); + + const res = await DELETE( + new NextRequest("http://localhost/api/custom-metrics/cm-1/entries/e-1", { + method: "DELETE", + }), + { params: Promise.resolve({ id: "cm-1", entryId: "e-1" }) }, + ); + + expect(res.status).toBe(404); + const where = vi.mocked(prisma.customMetricEntry.findFirst).mock + .calls[0]![0]!.where!; + expect(where.deletedAt).toBeNull(); + expect(prisma.customMetricEntry.update).not.toHaveBeenCalled(); + }); +}); + +describe("POST /api/custom-metrics/[id]/entries/restore", () => { + function restoreRequest(body: unknown): NextRequest { + return new NextRequest( + "http://localhost/api/custom-metrics/cm-1/entries/restore", + { + method: "POST", + body: JSON.stringify(body), + headers: { "content-type": "application/json" }, + }, + ); + } + + it("clears the tombstone scoped to owner + parent metric", async () => { + vi.mocked(prisma.customMetricEntry.updateMany).mockResolvedValue({ + count: 1, + } as never); + + const res = await RESTORE(restoreRequest({ ids: ["e-1"] }), { + params: Promise.resolve({ id: "cm-1" }), + }); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.data).toEqual({ restored: 1 }); + const call = vi.mocked(prisma.customMetricEntry.updateMany).mock + .calls[0][0]; + expect(call.where).toEqual({ + id: { in: ["e-1"] }, + userId: "user-1", + customMetricId: "cm-1", + deletedAt: { not: null }, + }); + expect(call.data).toEqual({ deletedAt: null }); + }); + + it("answers restored: 0 for foreign / live ids — a silent no-op, not a leak", async () => { + vi.mocked(prisma.customMetricEntry.updateMany).mockResolvedValue({ + count: 0, + } as never); + + const res = await RESTORE(restoreRequest({ ids: ["not-mine"] }), { + params: Promise.resolve({ id: "cm-1" }), + }); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.data).toEqual({ restored: 0 }); + }); + + it("422s an empty id list", async () => { + const res = await RESTORE(restoreRequest({ ids: [] }), { + params: Promise.resolve({ id: "cm-1" }), + }); + expect(res.status).toBe(422); + expect(prisma.customMetricEntry.updateMany).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/custom-metrics/[id]/entries/restore/route.ts b/src/app/api/custom-metrics/[id]/entries/restore/route.ts new file mode 100644 index 000000000..1ff0bea2e --- /dev/null +++ b/src/app/api/custom-metrics/[id]/entries/restore/route.ts @@ -0,0 +1,79 @@ +/** + * `POST /api/custom-metrics/{id}/entries/restore` — un-tombstone for the + * entry delete-Undo affordance (v1.37.20, A3-11). + * + * Body: `{ ids: string[] }` — 1..200 entry ids, scoped to the caller and the + * parent metric. + * + * Clears `deletedAt` on every owned, currently-tombstoned row of THIS metric + * in one `updateMany`, so the value re-surfaces in normal reads. A forged / + * foreign / live / mismatched-parent id is a silent no-op (never a 404 + * existence leak): the mutation `where` pins `userId` and `customMetricId`, + * so it only ever touches the caller's rows. Mirrors `/api/labs/restore`. + * Same session auth as the delete beside it — custom-metric entry writes are + * not delegable. + */ +import { NextRequest } from "next/server"; +import { z } from "zod/v4"; + +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { auditLog } from "@/lib/auth/audit"; +import { + apiSuccess, + getClientIp, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; +import { invalidateUserCorrelationPatterns } from "@/lib/cache/invalidate"; +import { prisma } from "@/lib/db"; +import { annotate } from "@/lib/logging/context"; + +const MAX_IDS_PER_BATCH = 200; + +const restoreSchema = z.object({ + ids: z.array(z.string().min(1)).min(1).max(MAX_IDS_PER_BATCH), +}); + +type RouteParams = { params: Promise<{ id: string }> }; + +export const POST = apiHandler( + async (request: NextRequest, { params }: RouteParams) => { + const { user } = await requireAuth(); + const { id } = await params; + + const { data: rawBody, error: jsonError } = await safeJson(request, { + maxBytes: 64 * 1024, + }); + if (jsonError) return jsonError; + + const parsed = restoreSchema.safeParse(rawBody); + if (!parsed.success) { + return returnAllZodIssues(parsed.error, 422); + } + + const { count } = await prisma.customMetricEntry.updateMany({ + where: { + id: { in: parsed.data.ids }, + userId: user.id, + customMetricId: id, + deletedAt: { not: null }, + }, + data: { deletedAt: null }, + }); + + if (count > 0) invalidateUserCorrelationPatterns(user.id); + + await auditLog("customMetricEntry.restore", { + userId: user.id, + ipAddress: getClientIp(request), + details: { customMetricId: id, restored: count }, + }); + + annotate({ + action: { name: "custom-metric.entry.restore" }, + meta: { customMetricId: id, restored: count }, + }); + + return apiSuccess({ restored: count }); + }, +); diff --git a/src/app/api/custom-metrics/[id]/entries/route.ts b/src/app/api/custom-metrics/[id]/entries/route.ts index 2defc447f..71a54f7e0 100644 --- a/src/app/api/custom-metrics/[id]/entries/route.ts +++ b/src/app/api/custom-metrics/[id]/entries/route.ts @@ -57,7 +57,7 @@ export const GET = apiHandler( } const { limit, offset, sortDir } = parsed.data; - const where = { userId: user.id, customMetricId: id }; + const where = { userId: user.id, customMetricId: id, deletedAt: null }; const [rows, total] = await Promise.all([ prisma.customMetricEntry.findMany({ diff --git a/src/app/api/custom-metrics/route.ts b/src/app/api/custom-metrics/route.ts index e3c0d948d..d4d98b477 100644 --- a/src/app/api/custom-metrics/route.ts +++ b/src/app/api/custom-metrics/route.ts @@ -36,11 +36,12 @@ export const GET = apiHandler(async () => { orderBy: { name: "asc" }, include: { entries: { + where: { deletedAt: null }, orderBy: { measuredAt: "desc" }, take: 1, select: { value: true, unit: true, measuredAt: true }, }, - _count: { select: { entries: true } }, + _count: { select: { entries: { where: { deletedAt: null } } } }, }, }); @@ -164,12 +165,12 @@ async function postCustomMetric(request: NextRequest) { if (existing) { const [latestEntry, count] = await Promise.all([ prisma.customMetricEntry.findFirst({ - where: { userId: user.id, customMetricId: created.id }, + where: { userId: user.id, customMetricId: created.id, deletedAt: null }, orderBy: { measuredAt: "desc" }, select: { value: true, unit: true, measuredAt: true }, }), prisma.customMetricEntry.count({ - where: { userId: user.id, customMetricId: created.id }, + where: { userId: user.id, customMetricId: created.id, deletedAt: null }, }), ]); latest = latestEntry ?? null; diff --git a/src/app/api/dashboard/widgets/__tests__/route.test.ts b/src/app/api/dashboard/widgets/__tests__/route.test.ts index 095282ea7..03fcb3375 100644 --- a/src/app/api/dashboard/widgets/__tests__/route.test.ts +++ b/src/app/api/dashboard/widgets/__tests__/route.test.ts @@ -967,3 +967,93 @@ describe("DELETE /api/dashboard/widgets — web-owned reset scope", () => { expect(body.data.updatedAt).toBe(advanced.toISOString()); }); }); + +describe("dashboard widgets — hero primary content", () => { + const weightOnly = [ + { id: "weight" as const, visible: true, tileVisible: true, order: 0 }, + ]; + + it("accepts and persists hero: 'reminders'", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + dashboardWidgetsJson: null, + } as never); + vi.mocked(prisma.user.update).mockResolvedValue({} as never); + + const res = await callPut( + makeReq({ version: 1, widgets: weightOnly, hero: "reminders" }), + ); + expect(res.status).toBe(200); + + const updateArg = vi.mocked(prisma.user.update).mock + .calls[0]?.[0] as unknown as { + data: { dashboardWidgetsJson: { hero?: string } }; + }; + expect(updateArg.data.dashboardWidgetsJson.hero).toBe("reminders"); + + const body = (await res.json()) as { data: { hero?: string } }; + expect(body.data.hero).toBe("reminders"); + }); + + it("rejects a hero value outside the closed enum with 422", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + dashboardWidgetsJson: null, + } as never); + vi.mocked(prisma.user.update).mockResolvedValue({} as never); + + const res = await callPut( + makeReq({ version: 1, widgets: weightOnly, hero: "banner" }), + ); + expect(res.status).toBe(422); + expect(vi.mocked(prisma.user.update)).not.toHaveBeenCalled(); + }); + + it("keeps the stored hero choice when the client omits the field", async () => { + // A layout save from a client that predates the field (the native + // client, or a stale web tab) must not silently reset the choice. + const stored: DashboardLayout = serializeDashboardLayout({ + version: 1, + widgets: weightOnly, + hero: "reminders", + }); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + dashboardWidgetsJson: stored, + } as never); + vi.mocked(prisma.user.update).mockResolvedValue({} as never); + + const res = await callPut(makeReq({ version: 1, widgets: weightOnly })); + expect(res.status).toBe(200); + + const updateArg = vi.mocked(prisma.user.update).mock + .calls[0]?.[0] as unknown as { + data: { dashboardWidgetsJson: { hero?: string } }; + }; + expect(updateArg.data.dashboardWidgetsJson.hero).toBe("reminders"); + }); + + it("honours an explicitly sent 'score' over the stored 'reminders'", async () => { + const stored: DashboardLayout = serializeDashboardLayout({ + version: 1, + widgets: weightOnly, + hero: "reminders", + }); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + dashboardWidgetsJson: stored, + } as never); + vi.mocked(prisma.user.update).mockResolvedValue({} as never); + + const res = await callPut( + makeReq({ version: 1, widgets: weightOnly, hero: "score" }), + ); + expect(res.status).toBe(200); + + const updateArg = vi.mocked(prisma.user.update).mock + .calls[0]?.[0] as unknown as { + data: { dashboardWidgetsJson: { hero?: string } }; + }; + // "score" is the default and serializes as an omitted field. + expect(updateArg.data.dashboardWidgetsJson.hero).toBeUndefined(); + + const body = (await res.json()) as { data: { hero?: string } }; + expect(body.data.hero).toBeUndefined(); + }); +}); diff --git a/src/app/api/dashboard/widgets/route.ts b/src/app/api/dashboard/widgets/route.ts index 4d8514d04..7cd00455e 100644 --- a/src/app/api/dashboard/widgets/route.ts +++ b/src/app/api/dashboard/widgets/route.ts @@ -31,6 +31,7 @@ import { MAX_SELECTED_SCORE_RINGS, HERO_RING_IDS, MAX_HERO_RING_ORDER, + HERO_PRIMARY_CONTENTS, layoutNeedsPreserveRead, mergePreservedLayoutFields, type DashboardLayout, @@ -158,6 +159,15 @@ const layoutSchema = z.object({ .array(z.enum(PRIORITY_ITEM_KINDS)) .max(PRIORITY_ITEM_KINDS.length) .optional(), + // Hero primary content — what the dashboard hero card leads with: + // "score" (default) or "reminders" (the worth-a-look rail promoted into + // the hero slot). This widens the stored `dashboardWidgetsJson` shape + // ADDITIVELY: the field is optional on the wire and omitted from storage + // at the default, so the native client (which mirrors this blob) can + // keep round-tripping layouts untouched until it learns the field. + // Same preserve-when-absent contract as `comparisonBaseline`; the + // resolver drops unknown stored values back to "score" on read. + hero: z.enum(HERO_PRIMARY_CONTENTS).optional(), // v1.32.16 (issue #581) — the optimistic-concurrency base token used to // live here. v1.32.21 (R5a) moved it to the shared `takeBaseToken` helper, // which strips it BEFORE this parse: the token is transport, not a layout diff --git a/src/app/api/export/__tests__/per-type-routes.test.ts b/src/app/api/export/__tests__/per-type-routes.test.ts index 2237b5c1f..dbd70b192 100644 --- a/src/app/api/export/__tests__/per-type-routes.test.ts +++ b/src/app/api/export/__tests__/per-type-routes.test.ts @@ -38,6 +38,10 @@ vi.mock("@/lib/db", () => ({ encounterConditionLink: { findMany: vi.fn().mockResolvedValue([]) }, vaccinationRecord: { findMany: vi.fn().mockResolvedValue([]) }, vaccinationDocumentLink: { findMany: vi.fn().mockResolvedValue([]) }, + // The reminder engine tables (v1.37.20, #223 / iOS #68). Empty for the + // same reason as the visit tables above. + measurementReminder: { findMany: vi.fn().mockResolvedValue([]) }, + measurementReminderEvent: { findMany: vi.fn().mockResolvedValue([]) }, // v1.15.0 — cycle tables read by the full-backup helper. cycleProfile: { findUnique: vi.fn() }, menstrualCycle: { findMany: vi.fn() }, @@ -121,6 +125,10 @@ beforeEach(() => { vi.mocked(prisma.vaccinationDocumentLink.findMany).mockResolvedValue( [] as never, ); + vi.mocked(prisma.measurementReminder.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.measurementReminderEvent.findMany).mockResolvedValue( + [] as never, + ); }); afterEach(() => { diff --git a/src/app/api/export/encrypted/__tests__/route.test.ts b/src/app/api/export/encrypted/__tests__/route.test.ts index 47fc52e16..e124d475f 100644 --- a/src/app/api/export/encrypted/__tests__/route.test.ts +++ b/src/app/api/export/encrypted/__tests__/route.test.ts @@ -36,6 +36,10 @@ vi.mock("@/lib/db", () => ({ encounterConditionLink: { findMany: vi.fn().mockResolvedValue([]) }, vaccinationRecord: { findMany: vi.fn().mockResolvedValue([]) }, vaccinationDocumentLink: { findMany: vi.fn().mockResolvedValue([]) }, + // The reminder engine tables (v1.37.20, #223 / iOS #68). Empty for the + // same reason as the visit tables above. + measurementReminder: { findMany: vi.fn().mockResolvedValue([]) }, + measurementReminderEvent: { findMany: vi.fn().mockResolvedValue([]) }, cycleProfile: { findUnique: vi.fn().mockResolvedValue(null) }, menstrualCycle: { findMany: vi.fn().mockResolvedValue([]) }, cycleDayLog: { findMany: vi.fn().mockResolvedValue([]) }, diff --git a/src/app/api/integrations/healthkit/__tests__/route.test.ts b/src/app/api/integrations/healthkit/__tests__/route.test.ts index 067113a7f..26ac0eae7 100644 --- a/src/app/api/integrations/healthkit/__tests__/route.test.ts +++ b/src/app/api/integrations/healthkit/__tests__/route.test.ts @@ -6,9 +6,10 @@ vi.mock("@/lib/db", () => ({ user: { findUnique: vi.fn(), update: vi.fn() }, // v1.4.43 W6 — audit-ledger breadcrumb for validation-failed paths. auditLog: { create: vi.fn() }, - // Per-metric freshness for the APPLE_HEALTH source. - measurement: { groupBy: vi.fn() }, - workout: { groupBy: vi.fn() }, + // Per-metric freshness + first-run sync progress for the APPLE_HEALTH + // source (#778). + measurement: { groupBy: vi.fn(), aggregate: vi.fn() }, + workout: { groupBy: vi.fn(), aggregate: vi.fn() }, }, toJson: (v: T) => v, })); @@ -54,6 +55,14 @@ beforeEach(() => { vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never); vi.mocked(prisma.measurement.groupBy).mockResolvedValue([] as never); vi.mocked(prisma.workout.groupBy).mockResolvedValue([] as never); + vi.mocked(prisma.measurement.aggregate).mockResolvedValue({ + _count: { _all: 0 }, + _min: { measuredAt: null }, + } as never); + vi.mocked(prisma.workout.aggregate).mockResolvedValue({ + _count: { _all: 0 }, + _min: { startedAt: null }, + } as never); }); const callGet = GET as unknown as (req: NextRequest) => Promise; @@ -227,6 +236,10 @@ describe("GET /api/integrations/healthkit — sync health", () => { lastSeenAt: string; stale: boolean; }>; + syncProgress: { + recordsAccepted: number; + oldestMeasuredAt: string | null; + } | null; }; async function read(): Promise { @@ -327,6 +340,43 @@ describe("GET /api/integrations/healthkit — sync health", () => { expect(body.lastBackgroundSyncAt).toBeNull(); }); + // #778 — a first-run backfill was invisible from the web: nothing said how + // many records had arrived or how far back they reached. The GET now carries + // the two figures the server actually holds. + it("publishes the accepted-row count and the oldest instant reached", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + healthKitConfigJson: null, + healthKitLastSyncedAt: ago(60 * 60 * 1000), + } as never); + vi.mocked(prisma.measurement.aggregate).mockResolvedValue({ + _count: { _all: 4200 }, + _min: { measuredAt: new Date("2019-05-04T06:00:00.000Z") }, + } as never); + vi.mocked(prisma.workout.aggregate).mockResolvedValue({ + _count: { _all: 12 }, + _min: { startedAt: new Date("2021-01-01T09:00:00.000Z") }, + } as never); + + const body = await read(); + expect(body.syncProgress).toEqual({ + recordsAccepted: 4212, + oldestMeasuredAt: "2019-05-04T06:00:00.000Z", + }); + }); + + it("degrades to a null progress summary rather than failing the config read", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + healthKitConfigJson: null, + healthKitLastSyncedAt: ago(DAY), + } as never); + vi.mocked(prisma.measurement.aggregate).mockRejectedValue( + new Error("aggregate hiccup"), + ); + const body = await read(); + expect(body.syncProgress).toBeNull(); + expect(body.syncHealth.verdict).toBe("fresh"); + }); + it("degrades to no per-metric data rather than failing the config read", async () => { vi.mocked(prisma.user.findUnique).mockResolvedValue({ healthKitConfigJson: null, diff --git a/src/app/api/integrations/healthkit/route.ts b/src/app/api/integrations/healthkit/route.ts index 8509b12ba..e9428e4c1 100644 --- a/src/app/api/integrations/healthkit/route.ts +++ b/src/app/api/integrations/healthkit/route.ts @@ -20,6 +20,7 @@ import { import { annotate } from "@/lib/logging/context"; import { prisma, toJson } from "@/lib/db"; import { auditLog } from "@/lib/auth/audit"; +import { getAppleHealthSyncProgress } from "@/lib/integrations/apple-health-progress"; import { getSourceFreshness } from "@/lib/integrations/metric-freshness"; import { classifyMetricFreshness, @@ -168,6 +169,16 @@ export const GET = apiHandler(async () => { const samples = await getSourceFreshness(user.id, "APPLE_HEALTH").catch( () => [], ); + // Issue #778 — a first-run backfill used to be invisible from the web: the + // card said "last sync 2 minutes ago" while giving no sense of how much had + // arrived or how far back it reached. These two figures (row count + oldest + // instant) are everything the server actually knows about backfill + // progress; the phone's queue and throttle state stay on the phone and are + // not guessed at. Fail-soft for the same reason the freshness read is — + // a progress line is never worth 500-ing the config the iOS client needs. + const syncProgress = await getAppleHealthSyncProgress(user.id).catch( + () => null, + ); return apiSuccess({ entries, @@ -176,6 +187,7 @@ export const GET = apiHandler(async () => { lastBackgroundSyncAt, syncHealth, metricFreshness: classifyMetricFreshness(samples, syncHealth.verdict), + syncProgress, }); }); 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/app/api/mental-health/assessments/[id]/__tests__/route.test.ts b/src/app/api/mental-health/assessments/[id]/__tests__/route.test.ts new file mode 100644 index 000000000..be19cee4b --- /dev/null +++ b/src/app/api/mental-health/assessments/[id]/__tests__/route.test.ts @@ -0,0 +1,241 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +/** + * `GET /api/mental-health/assessments/[id]` — the per-administration detail + * read that finally consumes `responsesEncrypted` (audit finding: the column + * was write-only; its single reader was key rotation). + * + * Pins the two halves of the contract: + * (a) the owner's detail read decrypts the stored blob and returns the + * per-item answers + the unscored functional follow-up, and + * (b) a decrypt failure (key gap / corruption / shape drift) degrades to + * `items: null` + `itemsUnavailable: true` while the denormalised + * total / band / flag still answer — never a 500 for the whole row + * (the records-DTO fail-soft precedent). + */ + +vi.mock("@/lib/db", () => ({ + prisma: { + appSettings: { findUnique: vi.fn().mockResolvedValue(null) }, + mentalHealthAssessment: { findFirst: vi.fn() }, + }, +})); + +vi.mock("@/lib/modules/gate", async (importOriginal) => ({ + ...(await importOriginal()), + requireModuleEnabled: vi.fn().mockResolvedValue({ enabled: true }), + resolveModuleMap: vi.fn().mockResolvedValue({}), +})); + +vi.mock("@/lib/ai/coach/bytes-codec", () => ({ + decryptFromBytes: vi.fn(), +})); + +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { GET } from "../route"; +import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec"; +import { getSession } from "@/lib/auth/session"; +import { requireModuleEnabled } from "@/lib/modules/gate"; +import { prisma } from "@/lib/db"; + +const SESSION_OK = { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { + id: "user-1", + username: "testuser", + role: "USER" as const, + locale: "en", + }, +}; + +const callGet = GET as unknown as ( + req: NextRequest, + ctx: { params: Promise<{ id: string }> }, +) => Promise; + +function makeReq( + id: string, +): [NextRequest, { params: Promise<{ id: string }> }] { + return [ + new NextRequest( + new URL(`http://localhost/api/mental-health/assessments/${id}`), + { method: "GET" }, + ), + { params: Promise.resolve({ id }) }, + ]; +} + +/** A stored PHQ-9 administration as the select returns it. */ +function storedRow(overrides: Record = {}) { + return { + id: "mha_1", + userId: "user-1", + instrument: "PHQ9", + locale: "en", + version: "standard", + totalScore: 10, + severityBand: "moderate", + item9Flagged: true, + crisisShownAt: new Date("2026-06-28T00:00:00.000Z"), + takenAt: new Date("2026-06-28T00:00:00.000Z"), + createdAt: new Date("2026-06-28T00:00:00.000Z"), + responsesEncrypted: Buffer.from("ciphertext"), + ...overrides, + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true }); +}); + +describe("GET /api/mental-health/assessments/[id]", () => { + it("returns the decrypted per-item answers + functional follow-up for the owner", async () => { + vi.mocked(prisma.mentalHealthAssessment.findFirst).mockResolvedValue( + storedRow() as never, + ); + vi.mocked(decryptFromBytes).mockReturnValue( + JSON.stringify({ + items: [1, 1, 1, 1, 1, 1, 1, 1, 2], + functionalDifficulty: 1, + schema: 1, + }), + ); + + const res = await callGet(...makeReq("mha_1")); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { + assessment: { + id: string; + totalScore: number; + items: number[] | null; + functionalDifficulty: number | null; + itemsUnavailable: boolean; + }; + }; + }; + expect(body.data.assessment.id).toBe("mha_1"); + expect(body.data.assessment.items).toEqual([1, 1, 1, 1, 1, 1, 1, 1, 2]); + expect(body.data.assessment.functionalDifficulty).toBe(1); + expect(body.data.assessment.itemsUnavailable).toBe(false); + // The denormalised score fields ride along unchanged. + expect(body.data.assessment.totalScore).toBe(10); + }); + + it("omits functionalDifficulty as null when the stored blob has none", async () => { + vi.mocked(prisma.mentalHealthAssessment.findFirst).mockResolvedValue( + storedRow({ + instrument: "GAD7", + totalScore: 6, + item9Flagged: false, + }) as never, + ); + vi.mocked(decryptFromBytes).mockReturnValue( + JSON.stringify({ items: [1, 2, 0, 1, 1, 1, 0], schema: 1 }), + ); + + const res = await callGet(...makeReq("mha_1")); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { + assessment: { + items: number[] | null; + functionalDifficulty: number | null; + }; + }; + }; + expect(body.data.assessment.items).toEqual([1, 2, 0, 1, 1, 1, 0]); + expect(body.data.assessment.functionalDifficulty).toBeNull(); + }); + + it("degrades to items: null + itemsUnavailable on a decrypt failure — never a 500", async () => { + vi.mocked(prisma.mentalHealthAssessment.findFirst).mockResolvedValue( + storedRow() as never, + ); + vi.mocked(decryptFromBytes).mockImplementation(() => { + throw new Error("Unknown encryption key id: v9"); + }); + + const res = await callGet(...makeReq("mha_1")); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { + assessment: { + totalScore: number; + items: number[] | null; + itemsUnavailable: boolean; + }; + }; + }; + expect(body.data.assessment.items).toBeNull(); + expect(body.data.assessment.itemsUnavailable).toBe(true); + // The score half of the row still answers. + expect(body.data.assessment.totalScore).toBe(10); + }); + + it("degrades on a malformed or shape-drifted plaintext (wrong item count)", async () => { + vi.mocked(prisma.mentalHealthAssessment.findFirst).mockResolvedValue( + storedRow() as never, + ); + // 3 answers on a stored PHQ-9 row: pairing them to the 9 official item + // texts would mislabel every answer, so the read refuses to guess. + vi.mocked(decryptFromBytes).mockReturnValue( + JSON.stringify({ items: [1, 2, 3], schema: 1 }), + ); + + const res = await callGet(...makeReq("mha_1")); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { + assessment: { items: number[] | null; itemsUnavailable: boolean }; + }; + }; + expect(body.data.assessment.items).toBeNull(); + expect(body.data.assessment.itemsUnavailable).toBe(true); + }); + + it("404s another user's row without leaking its existence", async () => { + vi.mocked(prisma.mentalHealthAssessment.findFirst).mockResolvedValue( + storedRow({ userId: "user-2" }) as never, + ); + const res = await callGet(...makeReq("mha_1")); + expect(res.status).toBe(404); + // The item content never left the guard. + expect(decryptFromBytes).not.toHaveBeenCalled(); + }); + + it("404s a missing row", async () => { + vi.mocked(prisma.mentalHealthAssessment.findFirst).mockResolvedValue( + null as never, + ); + const res = await callGet(...makeReq("mha_missing")); + expect(res.status).toBe(404); + }); + + it("returns the module 403 when the mental-health module is off", async () => { + const { apiError } = await import("@/lib/api-response"); + vi.mocked(requireModuleEnabled).mockResolvedValue({ + enabled: false, + response: apiError("disabled", 403, { errorCode: "module.disabled" }), + }); + const res = await callGet(...makeReq("mha_1")); + expect(res.status).toBe(403); + expect(prisma.mentalHealthAssessment.findFirst).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/mental-health/assessments/[id]/route.ts b/src/app/api/mental-health/assessments/[id]/route.ts new file mode 100644 index 000000000..3472b760a --- /dev/null +++ b/src/app/api/mental-health/assessments/[id]/route.ts @@ -0,0 +1,175 @@ +/** + * `GET /api/mental-health/assessments/[id]` — one screener administration WITH + * its decrypted per-item answers. + * + * The list route beside this one denormalises total + band + item-9 flag for + * cheap history reads and deliberately never touches `responsesEncrypted`. + * Until this route existed nothing read the blob back at all (its only + * consumer was key rotation), so the stored clinical detail — including the + * PHQ-9 item-9 answer — was invisible to the person it belongs to. This is + * the read side of that write. + * + * GATING — same declaration as the sibling list read (`read`, `mind`), on + * purpose: the mind domain already serves decrypted free text at this level + * (the mood entry's encrypted note on `GET /api/mood-entries/[id]`, the Coach + * reminder notes), and the list read already publishes `item9Flagged` — the + * fact that the self-harm item was answered non-zero. A delegate the owner + * trusted with a mind-scope read grant therefore learns nothing here that is + * categorically beyond what the domain already shows; splitting the per-item + * values onto a stricter gate than the mood diary's prose would be an + * inconsistency, not a protection. The module gate resolves against the + * RECORD, so the surface stays unreachable wherever the owner keeps the + * module off. + * + * DECRYPT FAILURES degrade honestly (the records-DTO fail-soft precedent): + * `items: null` + `itemsUnavailable: true`, with the denormalised score + * fields still answering — a key gap on one row never 500s the read. A + * plaintext whose item count no longer matches the instrument degrades the + * same way, because pairing answers to the wrong official item texts would be + * worse than showing none. + */ +import { NextRequest } from "next/server"; +import { prisma } from "@/lib/db"; +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; +import { apiError, apiSuccess } from "@/lib/api-response"; +import { annotate, getEvent } from "@/lib/logging/context"; +import { requireModuleEnabled } from "@/lib/modules/gate"; +import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec"; +import { + INSTRUMENTS, + type InstrumentDefinition, + type InstrumentId, +} from "@/lib/mental-health/instruments"; + +type RouteParams = { params: Promise<{ id: string }> }; + +interface DecodedResponses { + /** Per-item answers in presentation order, or null when unreadable. */ + items: number[] | null; + /** The unscored PHQ-9 functional follow-up, when it was answered. */ + functionalDifficulty: number | null; + itemsUnavailable: boolean; +} + +const UNAVAILABLE: DecodedResponses = { + items: null, + functionalDifficulty: null, + itemsUnavailable: true, +}; + +/** + * Decrypt + decode the stored `{ items, functionalDifficulty?, schema: 1 }` + * blob, fail-soft. Every answer is re-checked against the instrument's own + * ceiling before it is served: the write path validated exactly these bounds, + * so anything outside them is corruption, not data. + */ +function decodeResponses( + buf: Uint8Array, + def: InstrumentDefinition, +): DecodedResponses { + let parsed: unknown; + try { + parsed = JSON.parse(decryptFromBytes(buf)); + } catch (err) { + // Undecryptable payload (key gap / corruption): fail soft but log it + // (F-CRYPTO-2) so a systemic key gap surfaces instead of reading blank. + getEvent()?.addWarning( + `assessment responses decrypt failed: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + return UNAVAILABLE; + } + if (typeof parsed !== "object" || parsed === null) return UNAVAILABLE; + const { items, functionalDifficulty } = parsed as { + items?: unknown; + functionalDifficulty?: unknown; + }; + const valid = + Array.isArray(items) && + items.length === def.itemCount && + items.every( + (v) => Number.isInteger(v) && (v as number) >= 0 && v <= def.itemMax, + ); + if (!valid) { + getEvent()?.addWarning( + "assessment responses shape drifted from the instrument definition", + ); + return UNAVAILABLE; + } + return { + items: items as number[], + functionalDifficulty: + Number.isInteger(functionalDifficulty) && + (functionalDifficulty as number) >= 0 && + (functionalDifficulty as number) <= 3 + ? (functionalDifficulty as number) + : null, + itemsUnavailable: false, + }; +} + +export const GET = apiHandler( + async (_request: NextRequest, { params }: RouteParams) => { + const { user } = await requireRecordAuth("read", "mind"); + + // Opt-in module (default OFF) — the same record-resolved gate as the list. + const gate = await requireModuleEnabled(user.id, "mentalHealth"); + if (!gate.enabled) return gate.response; + + const { id } = await params; + + // Fetch-then-guard against the resolved user (the by-id sibling pattern); + // a soft-deleted row 404s like it does on the list. + const row = await prisma.mentalHealthAssessment.findFirst({ + where: { id, deletedAt: null }, + select: { + id: true, + userId: true, + instrument: true, + locale: true, + version: true, + totalScore: true, + severityBand: true, + item9Flagged: true, + crisisShownAt: true, + takenAt: true, + createdAt: true, + responsesEncrypted: true, + }, + }); + if (!row || row.userId !== user.id) { + return apiError("Assessment not found", 404); + } + + const def = INSTRUMENTS[row.instrument as InstrumentId]; + const decoded = decodeResponses(row.responsesEncrypted, def); + + annotate({ + action: { name: "mental-health.detail" }, + // The instrument + degrade flag only — item answers are NEVER logged. + meta: { + instrument: row.instrument, + items_unavailable: decoded.itemsUnavailable, + }, + }); + + return apiSuccess({ + assessment: { + id: row.id, + instrument: row.instrument, + locale: row.locale, + version: row.version, + totalScore: row.totalScore, + severityBand: row.severityBand, + item9Flagged: row.item9Flagged, + crisisShownAt: row.crisisShownAt?.toISOString() ?? null, + takenAt: row.takenAt.toISOString(), + createdAt: row.createdAt.toISOString(), + items: decoded.items, + functionalDifficulty: decoded.functionalDifficulty, + itemsUnavailable: decoded.itemsUnavailable, + }, + }); + }, +); diff --git a/src/app/api/mental-health/assessments/route.ts b/src/app/api/mental-health/assessments/route.ts index c89f61540..a37b223b3 100644 --- a/src/app/api/mental-health/assessments/route.ts +++ b/src/app/api/mental-health/assessments/route.ts @@ -13,7 +13,8 @@ * interpretation, no AI ingestion of item content, no third-party alerting. * * GET lists the caller's assessments (totals + bands + flags only — item-level - * answers are excluded from this surface by construction). + * answers are excluded from this surface by construction; the by-id sibling + * route is the one read that decrypts them back). */ import { NextRequest } from "next/server"; import { prisma } from "@/lib/db"; diff --git a/src/app/measurements/page.tsx b/src/app/measurements/page.tsx index 3f753ed8e..f03556902 100644 --- a/src/app/measurements/page.tsx +++ b/src/app/measurements/page.tsx @@ -12,7 +12,6 @@ import { } from "@/components/measurements/measurement-form"; import { MeasurementList } from "@/components/measurements/measurement-list"; import { CustomMetricList } from "@/components/custom-metrics/custom-metric-list"; -import { MEASUREMENT_TYPE_LABEL_KEYS } from "@/components/measurements/measurement-list-meta"; import { Button } from "@/components/ui/button"; import { PageHeader } from "@/components/ui/page-header"; import { PageAuthGate } from "@/components/ui/page-auth-gate"; @@ -64,18 +63,6 @@ export default function MeasurementsPage() { const addRequestParam = addParam ? `${addParam}\u0000${returnToParam ?? ""}` : null; - // v1.18.7 (Wave E) — a `?type=` deep link (e.g. the - // Vorsorge card's "Show measurements") seeds the list's type filter on - // first paint. Captured once via a lazy initializer so the value is - // stable even after the effect below strips the query string; an unknown - // token is dropped (validated against the canonical type-label map), so a - // stale link never traps the user on a broken filter. - const typeParam = searchParams.get("type"); - const [initialType] = useState(() => - typeParam && typeParam in MEASUREMENT_TYPE_LABEL_KEYS - ? typeParam - : undefined, - ); const [dialogOpen, setDialogOpen] = useState(() => initialAdd != null); const [defaultType, setDefaultType] = useState( () => initialAdd ?? undefined, @@ -118,15 +105,21 @@ export default function MeasurementsPage() { } } - // Drop the query string so the back-button leaves the user on - // `/measurements` rather than re-opening the dialog (or re-seeding the - // type filter). The type filter is already seeded into the list's own - // state via `initialType`, so stripping the param doesn't lose it. + // Drop the one-shot `?add=` / `?returnTo=` params so the back-button + // leaves the user on `/measurements` rather than re-opening the dialog. + // Every OTHER param is preserved: the list's filter state (`?type=`, + // `?source=`, …) now lives in the URL (see `measurement-list-filters.ts`) + // and stripping it would lose the filters this page is meant to keep. // Effect-driven (client-only) because `router.replace` is not callable // during SSR. useEffect(() => { - if (addParam || typeParam || returnToParam) router.replace("/measurements"); - }, [addParam, typeParam, returnToParam, router]); + if (!addParam && !returnToParam) return; + const next = new URLSearchParams(searchParams.toString()); + next.delete("add"); + next.delete("returnTo"); + const search = next.toString(); + router.replace(search ? `/measurements?${search}` : "/measurements"); + }, [addParam, returnToParam, searchParams, router]); useEffect(() => { if (!isLoading && !isAuthenticated) { @@ -214,7 +207,6 @@ export default function MeasurementsPage() { setReturnTo(null); setDialogOpen(true); }} - initialType={initialType} /> diff --git a/src/app/page-client.tsx b/src/app/page-client.tsx index 5287c60f8..408330c7e 100644 --- a/src/app/page-client.tsx +++ b/src/app/page-client.tsx @@ -967,6 +967,9 @@ export default function DashboardPageClient({ ) : !mounted || digestQuery.isLoading ? ( 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/admin/backups-section.tsx b/src/components/admin/backups-section.tsx index 7950bc323..abcf78d0e 100644 --- a/src/components/admin/backups-section.tsx +++ b/src/components/admin/backups-section.tsx @@ -68,6 +68,46 @@ import { apiFetch, apiFetchRaw, apiGet, apiPost } from "@/lib/api/api-fetch"; * Mirrors the wipe dialog's pattern but adds the typed gate because the * blast radius is bigger (re-creates rows, not just deletes). */ +/** + * v1.37.20 — the sections a restore preview names, in render order. Each + * entry sums one or more `BackupSummary` counters under one human label; + * every counter NOT named here still reaches the operator through the + * trailing "other records" line, so a future summary key can be forgotten + * here without silently vanishing from the preview. + */ +const RESTORE_PREVIEW_SECTIONS: ReadonlyArray<{ + labelKey: string; + keys: readonly string[]; +}> = [ + { labelKey: "previewMeasurements", keys: ["measurements"] }, + { + labelKey: "previewMedications", + keys: ["medications", "intakeEvents", "medicationSideEffects"], + }, + { labelKey: "previewMood", keys: ["moodEntries"] }, + { labelKey: "previewLabs", keys: ["labResults", "biomarkers"] }, + { labelKey: "previewDocuments", keys: ["documents"] }, + { labelKey: "previewWorkouts", keys: ["workouts"] }, + { labelKey: "previewCycles", keys: ["cycles", "cycleDayLogs"] }, + { + labelKey: "previewVisits", + keys: ["encounters", "practitioners", "encounterLinks"], + }, + { + labelKey: "previewVaccinations", + keys: ["vaccinations", "vaccinationLinks"], + }, + { + labelKey: "previewReminders", + keys: ["measurementReminders", "measurementReminderEvents"], + }, + { + labelKey: "previewCustomMetrics", + keys: ["customMetrics", "customMetricEntries"], + }, + { labelKey: "previewHealthScores", keys: ["healthScoreRecords"] }, +]; + function RestoreRowDialog({ row, pending, @@ -83,6 +123,39 @@ function RestoreRowDialog({ const [typed, setTyped] = useState(""); const matched = typed.trim() === "RESTORE"; + // v1.37.20 — restore preview: fetch what the file contains the moment the + // dialog opens, so the typed confirmation is an informed one. Derived from + // the same decrypt + schema path the restore itself runs. A failed preview + // never blocks the restore — it states its own absence instead. + const preview = useQuery({ + queryKey: queryKeys.adminBackupSummary(row.id), + queryFn: () => + apiGet<{ summary: Record }>( + `/api/admin/backups/${row.id}/summary`, + ), + enabled: open, + staleTime: 60_000, + }); + const numericSummary: Record = {}; + if (preview.data?.summary) { + for (const [key, value] of Object.entries(preview.data.summary)) { + if (typeof value === "number") numericSummary[key] = value; + } + } + const namedKeys = new Set( + RESTORE_PREVIEW_SECTIONS.flatMap((section) => section.keys), + ); + const previewRows = RESTORE_PREVIEW_SECTIONS.map((section) => ({ + labelKey: section.labelKey, + count: section.keys.reduce( + (sum, key) => sum + (numericSummary[key] ?? 0), + 0, + ), + })).filter((entry) => entry.count > 0); + const otherCount = Object.entries(numericSummary) + .filter(([key]) => !namedKeys.has(key)) + .reduce((sum, [, value]) => sum + value, 0); + return ( + {/* v1.37.20 — what the file contains, before the typed gate. */} +
+

+ {t("admin.section.backups.previewTitle")} +

+ {preview.isLoading && ( +

+ {t("admin.section.backups.previewLoading")} +

+ )} + {preview.isError && ( +

+ {t("admin.section.backups.previewUnavailable")} +

+ )} + {preview.isSuccess && ( +
    + {previewRows.map((entry) => ( +
  • + + {t(`admin.section.backups.${entry.labelKey}`)} + + + {fmt.integer(entry.count)} + +
  • + ))} + {otherCount > 0 && ( +
  • + + {t("admin.section.backups.previewOther")} + + + {fmt.integer(otherCount)} + +
  • + )} + {previewRows.length === 0 && otherCount === 0 && ( +
  • + {t("admin.section.backups.previewEmpty")} +
  • + )} +
+ )} +