diff --git a/CHANGELOG.md b/CHANGELOG.md index d35835d13..9bccae692 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,24 @@ # Changelog -## [1.37.18] — 2026-08-14 +## [1.37.19] — 2026-08-14 + +### Changed + +- Medication stock understands per-slot doses everywhere now. The remaining-doses figure weighs each scheduled slot by its own dose, the API carries the resolved dose per slot plus a runway in days, and the low-stock notification runs on the same arithmetic, so the app, the API and the push can no longer disagree about how long a supply lasts. +- A document whose summary job died no longer says "being generated" forever. After an hour the detail view says honestly that no summary is available and offers the manual action, and an hourly sweep repairs stuck documents in the background. +- The daily coach reminder sweep counts how often it has surfaced an ignored reminder and quietly dismisses it after three mornings instead of nagging indefinitely. +- Accepting an updated disclaimer is now tracked by version, so a future revision can actually ask again. +- The restore report lists practitioners, appointments, vaccinations and their links, and states what was written, not only what was cleared. The portable export names any field that could not be decrypted instead of exporting it as if it were never set. +- Changing your timezone re-folds the medication compliance statistics so the day boundaries match your new zone. +- Long health histories are honest again in two places: the all-time summary includes readings older than five years instead of silently truncating, and the AI context falls back to live data when a pre-computed band is missing. +- A cleaner copy voice across all six languages: shorter consent buttons, a source-neutral workouts empty state, consistent toast punctuation, "Navigations-Chips" instead of "Pills" in German, and a medication reminder chip that no longer clips mid-sentence. +- Settings pages that had been permanently redirected are no longer built, the retired placeholder is gone, and the about page is reachable from the privacy page and the settings. + +### Fixed + +- A measurement reminder claims its notification slot before sending, so a crash mid-send can no longer deliver the same reminder twice. +- 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. ### Fixed diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 2491c59a9..e86c3cfb4 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.18 + version: 1.37.19 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. @@ -3934,6 +3934,79 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "422": *a3 "429": *a4 + get: + tags: + - Medications + summary: List a medication's intake events + description: "Paged intake history for one medication (tombstoned rows excluded). `status` filters by action state: + `all` (default, byte-stable pre-v1.4.37 contract), `taken`, `skipped`, or `completed` (taken OR skipped — hides + the ambiguous never-confirmed rows). Sorting by `takenAt` pins NULLs last so skipped/planned rows do not float + above real timestamps." + parameters: + - in: path + name: id + schema: + type: string + required: true + - in: query + name: limit + schema: + default: 25 + 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: scheduledFor + type: string + enum: + - scheduledFor + - takenAt + - source + - createdAt + - in: query + name: sortDir + schema: + default: desc + type: string + enum: + - asc + - desc + - in: query + name: status + schema: + default: all + type: string + enum: + - all + - taken + - skipped + - completed + responses: + "200": + description: Intake event page. + content: + application/json: + schema: + $ref: "#/components/schemas/ListMedicationIntakeEventsResponse" + "401": *a1 + "403": *a6 + "404": + description: Medication not found (or owned by another user). + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a3 + "429": *a4 /api/medications/{id}/inventory: get: tags: @@ -17853,6 +17926,18 @@ components: - type: string - type: "null" description: Per-schedule dose override. NULL means the schedule inherits `Medication.dose`. + unitsPerDose: + anyOf: + - type: number + - type: "null" + description: v1.37.10 (#219) — per-slot inventory-units override (may be a split-pill fraction). NULL means the schedule + inherits the medication-level `unitsPerDose`. Kept raw so an edit surface can distinguish an explicit value + from inheritance; consumers wanting the effective figure read `resolvedUnitsPerDose`. + resolvedUnitsPerDose: + type: number + description: v1.37.19 — the EFFECTIVE units one dose of this slot consumes, resolved server-side (`schedule.unitsPerDose + ?? medication.unitsPerDose`, matching the intake consumption resolver). Always present; clients never + re-derive the inheritance rule. daysOfWeek: anyOf: - type: string @@ -17917,6 +18002,8 @@ components: - windowEnd - label - dose + - unitsPerDose + - resolvedUnitsPerDose - daysOfWeek - timesOfDay - reminderGraceMinutes @@ -22842,19 +22929,27 @@ components: id: type: string sportType: - type: string + anyOf: + - type: string + - type: "null" startedAt: - 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)))$ + 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" endedAt: - 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)))$ + 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" durationSec: - type: integer - minimum: 0 - maximum: 9007199254740991 + anyOf: + - type: integer + minimum: 0 + maximum: 9007199254740991 + - type: "null" distanceM: anyOf: - type: number @@ -23639,9 +23734,20 @@ components: minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" - description: "v1.16.10 — dose-derived stock: `floor(stockUnitsRemaining / unitsPerDose)`, where `unitsPerDose` may be a - fraction (½ tablet ⇒ twice the doses). Stays a whole-dose count. NULL when inventory tracking is off. Drives + description: "v1.16.10 — dose-derived stock as a whole-dose count. v1.37.19 — slot-aware: the divisor is the + schedule-weighted average units per dose (each slot's `resolvedUnitsPerDose` weighted by its cadence share), + falling back to the medication-level `unitsPerDose` when no schedule derives a consumption rate. + `unitsPerDose` may be a fraction (½ tablet ⇒ twice the doses). NULL when inventory tracking is off. Drives the table view's Bestand column. Read-only — aggregated, not stored." + runwayDays: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + description: v1.37.19 — projected whole days the usable stock covers under the slot-aware burn rate (the same math the + low-stock notification engine runs, so the wire and the push can never disagree). NULL = inventory tracking + off or no consuming cadence derivable; 0 = tracking on, supply ran out. Read-only — computed, not stored. required: - id - name @@ -23673,6 +23779,7 @@ components: - todayEventCount - stockUnitsRemaining - stockDosesRemaining + - runwayDays additionalProperties: false description: List-row variant of the medication resource enriched with the joined `category`, the v1.32.25 `externalSource` provenance echo, `lastTakenAt`, `todayEventCount`, and the v1.16.10 aggregated stock fields @@ -23841,6 +23948,27 @@ components: $ref: "#/components/schemas/MedicationScheduleOutput" category: $ref: "#/components/schemas/MedicationCategory" + stockUnitsRemaining: + anyOf: + - type: number + - type: "null" + description: "v1.37.19 — usable inventory units left (same semantics as the list entry's field): NULL = inventory + tracking off; 0 = tracking on, supply ran out." + stockDosesRemaining: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + description: v1.37.19 — slot-aware dose-derived stock, mirroring the list entry's field. + runwayDays: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + description: v1.37.19 — projected whole days the usable stock covers under the slot-aware burn rate; NULL = tracking off + or no consuming cadence. required: - id - name @@ -23865,6 +23993,9 @@ components: - updatedAt - schedules - category + - stockUnitsRemaining + - stockDosesRemaining + - runwayDays additionalProperties: false description: Detail variant of the medication resource enriched with the joined `category`. The base medication fields are inlined; see the `Medication` component for their semantics. @@ -24048,6 +24179,17 @@ components: - type: "null" skipped: type: boolean + autoMissed: + type: boolean + description: True when the nightly cron closed the slot as missed (no user action). Auto-missed rows count against + adherence but never consume inventory. + attributionSource: + type: string + enum: + - AUTO + - USER_PIN + description: "How the row bound to its schedule slot: AUTO = the write path's nearest-slot resolution; USER_PIN = the + user explicitly pinned the slot (the dedup converge keeps the pinned row)." source: type: string enum: @@ -24064,6 +24206,56 @@ components: 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)))$ + injectionSite: + anyOf: + - type: string + enum: + - ABDOMEN_LEFT + - ABDOMEN_RIGHT + - ABDOMEN_UPPER_LEFT + - ABDOMEN_UPPER_RIGHT + - THIGH_LEFT + - THIGH_RIGHT + - UPPER_ARM_LEFT + - UPPER_ARM_RIGHT + - type: "null" + description: Recorded injection site for a site-tracked medication (GLP-1 rotation surface). NULL when the medication + does not track sites or none was recorded. + doseTaken: + anyOf: + - type: string + - type: "null" + description: Free-text dose actually taken when it differed from the scheduled dose (titration weeks, split doses). NULL + = the scheduled dose. + inventoryConsumption: + anyOf: + - {} + - type: "null" + description: JSON ledger of the container decrements this intake caused (`[{itemId, units}]`), written by the + consumption hook. NULL when nothing was consumed (skipped / no tracked inventory). + externalId: + anyOf: + - type: string + - type: "null" + description: v1.28 — client-supplied stable id for externally-mirrored intakes (Apple Health sync); the dedup key for + re-synced rows. NULL for native rows. + syncVersion: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: Monotonic per-row version for incremental sync readers; bumps on every mutation. + deletedAt: + 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: Soft-delete tombstone. Non-null rows are excluded from every list/aggregate read; sync readers use it to + propagate deletions. required: - id - userId @@ -24071,12 +24263,22 @@ components: - scheduledFor - takenAt - skipped + - autoMissed + - attributionSource - source - idempotencyKey - createdAt + - updatedAt + - injectionSite + - doseTaken + - inventoryConsumption + - externalId + - syncVersion + - deletedAt additionalProperties: false - description: Single dose log row. `takenAt` is non-null for confirmed intakes; `skipped:true` represents a - deliberately-missed dose (no inventory consumption). + description: Single dose log row — the FULL row shape both the intake POST (201/200) and the intake list GET return. + `takenAt` is non-null for confirmed intakes; `skipped:true` represents a deliberately-missed dose (no inventory + consumption). CreateMedicationIntakeResponse: type: object properties: @@ -24094,6 +24296,52 @@ components: - data - error additionalProperties: false + ListMedicationIntakeEventsResponse: + type: object + properties: + data: + type: object + properties: + events: + type: array + items: + $ref: "#/components/schemas/MedicationIntakeEvent" + meta: + type: object + properties: + total: + type: integer + minimum: 0 + maximum: 9007199254740991 + limit: + type: integer + exclusiveMinimum: 0 + maximum: 9007199254740991 + offset: + type: integer + minimum: 0 + maximum: 9007199254740991 + required: + - total + - limit + - offset + additionalProperties: false + required: + - events + - meta + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false ListMedicationInventoryResponse: type: object properties: @@ -41545,6 +41793,18 @@ components: - type: string - type: "null" description: Per-schedule dose override. NULL means the schedule inherits `Medication.dose`. + unitsPerDose: + anyOf: + - type: number + - type: "null" + description: v1.37.10 (#219) — per-slot inventory-units override (may be a split-pill fraction). NULL means the schedule + inherits the medication-level `unitsPerDose`. Kept raw so an edit surface can distinguish an explicit value + from inheritance; consumers wanting the effective figure read `resolvedUnitsPerDose`. + resolvedUnitsPerDose: + type: number + description: v1.37.19 — the EFFECTIVE units one dose of this slot consumes, resolved server-side (`schedule.unitsPerDose + ?? medication.unitsPerDose`, matching the intake consumption resolver). Always present; clients never + re-derive the inheritance rule. daysOfWeek: anyOf: - type: string @@ -41609,6 +41869,8 @@ components: - windowEnd - label - dose + - unitsPerDose + - resolvedUnitsPerDose - daysOfWeek - timesOfDay - reminderGraceMinutes diff --git a/e2e/account-sharing.spec.ts b/e2e/account-sharing.spec.ts index 2ed1ab6ce..2b4453ddc 100644 --- a/e2e/account-sharing.spec.ts +++ b/e2e/account-sharing.spec.ts @@ -363,9 +363,49 @@ test.describe("account sharing", () => { // Let the offline persister flush the delegate's own dashboard to // IndexedDB (1 s debounce). That snapshot is the stale-paint source this // whole assertion is aimed at — without it the check would pass against a - // disk that was empty anyway. + // disk that was empty anyway. Poll the persister's own store for the + // written snapshot instead of sleeping past the debounce (C3): the + // condition IS the flush, so a slow CI box waits exactly as long as it + // needs to and a fast one moves on immediately. await page.goto("/"); - await page.waitForTimeout(2000); + // Gate on rendered content first (the dashboard must actually mount and + // populate the query cache before there is anything to persist). + await expect(page.locator('[data-slot="coach-fab"]')).toBeVisible({ + timeout: 10_000, + }); + await expect + .poll( + () => + page.evaluate( + () => + new Promise((resolve) => { + const req = indexedDB.open("healthlog-query-cache", 1); + req.onerror = () => resolve(false); + req.onsuccess = () => { + const db = req.result; + try { + const get = db + .transaction("kv", "readonly") + .objectStore("kv") + .get("react-query"); + get.onsuccess = () => { + db.close(); + resolve(get.result !== undefined); + }; + get.onerror = () => { + db.close(); + resolve(false); + }; + } catch { + db.close(); + resolve(false); + } + }; + }), + ), + { timeout: 10_000 }, + ) + .toBe(true); await openSwitcher(page); await page.locator('[data-slot="account-switcher-entry"]').click(); diff --git a/e2e/documents.spec.ts b/e2e/documents.spec.ts index 96d537e0d..c8dc20e21 100644 --- a/e2e/documents.spec.ts +++ b/e2e/documents.spec.ts @@ -392,7 +392,9 @@ test.describe("document vault", () => { await sheet.getByRole("button", { name: "Delete" }).click(); // Undo toast → restore → the card returns. - await expect(page.getByText("Document deleted.")).toBeVisible(); + await expect( + page.getByText("Document deleted", { exact: true }), + ).toBeVisible(); await expect(openButton(page, `${title}.pdf`)).not.toBeVisible(); await page.getByRole("button", { name: "Undo" }).click(); await expect(openButton(page, `${title}.pdf`)).toBeVisible(); @@ -559,6 +561,11 @@ test.describe("document vault", () => { // to the dialog after Chromium's PDF viewer grabs it, so Escape works // regardless of when the user presses it. await expect(dialog.locator("iframe")).toBeVisible(); + // True grace (C3): Chromium's PDF viewer steals focus from inside the + // iframe and the sheet hands it back — both moves are invisible to the + // outer document (activeElement is "the iframe" before AND after the + // grab), so there is no condition to poll on. The window only needs to + // outlive the focus dance. await page.waitForTimeout(250); await page.keyboard.press("Escape"); await expect(dialog).not.toBeVisible(); @@ -573,6 +580,8 @@ test.describe("document vault", () => { await expect(button).toBeVisible(); await button.dispatchEvent("touchstart"); + // Not a wait-for-condition sleep (C3): this IS the gesture — the finger + // stays down past the long-press threshold before lifting. await page.waitForTimeout(700); await button.dispatchEvent("touchend"); diff --git a/e2e/utils/mock-dashboard-snapshot.ts b/e2e/utils/mock-dashboard-snapshot.ts index a01771183..400c47e41 100644 --- a/e2e/utils/mock-dashboard-snapshot.ts +++ b/e2e/utils/mock-dashboard-snapshot.ts @@ -116,7 +116,6 @@ function summary(partial: Partial): DataSummary { avg30: null, slope7: null, slope30: null, - slope90: null, anomalyCount: 0, avg30LastMonth: null, avg30LastYear: null, diff --git a/e2e/v137-record-session-fence.spec.ts b/e2e/v137-record-session-fence.spec.ts index 5868b39b6..91dfc229d 100644 --- a/e2e/v137-record-session-fence.spec.ts +++ b/e2e/v137-record-session-fence.spec.ts @@ -326,11 +326,19 @@ test.describe.serial("FENCE record-session fence in the browser", () => { const accountId = await entry.getAttribute("data-account-id"); expect(accountId).not.toBeNull(); + let switchCommitted = false; await initiator.route("**/api/account/switch", async (route) => { await route.fetch(); + switchCommitted = true; await route.abort(); }); await entry.click({ noWaitAfter: true }); + // Poll for the server round-trip having landed (the observable half of + // the setup), THEN hold a short fixed window — this one is a true + // absence-assertion grace (C3): "the peer never painted guessed state" + // has no completion signal to poll on, only time in which the wrong + // paint could have happened. + await expect.poll(() => switchCommitted, { timeout: 20_000 }).toBe(true); await initiator.waitForTimeout(500); // The peer must never paint owner-ready state it guessed at … @@ -386,13 +394,19 @@ test.describe.serial("FENCE record-session fence in the browser", () => { // Hold the response, close the tab while it is in flight. The server has // committed; nothing will ever broadcast the commit. const held = deferred(); + // The commit is observable: flag once the server round-trip inside the + // route handler finishes, and poll for it instead of a fixed sleep + // (C3) — the tab must close AFTER the write landed, and this waits + // exactly that long. + let switchCommitted = false; await initiator.route("**/api/account/switch", async (route) => { await route.fetch(); + switchCommitted = true; await held.promise; await route.abort(); }); await entry.click({ noWaitAfter: true }); - await initiator.waitForTimeout(300); + await expect.poll(() => switchCommitted, { timeout: 20_000 }).toBe(true); held.release(); await initiator.close(); @@ -591,10 +605,18 @@ test.describe.serial("FENCE record-session fence in the browser", () => { expect(held.scope).toBe(accountId); // Now move the context out from under it, and only then let it land. + // "Out from under it" is observable: the exit posts + // `/api/account/switch`, so poll for that request having been issued + // instead of sleeping and hoping the click got that far (C3). + let exitSwitchIssued = false; + await page.route("**/api/account/switch", async (route) => { + exitSwitchIssued = true; + await route.continue(); + }); const leaving = page .locator('[data-slot="shared-record-banner-exit"]') .click({ noWaitAfter: true }); - await page.waitForTimeout(500); + await expect.poll(() => exitSwitchIssued, { timeout: 20_000 }).toBe(true); held.release?.(); await leaving.catch(() => {}); diff --git a/messages/de.json b/messages/de.json index 4e2c2468d..e9b6b1877 100644 --- a/messages/de.json +++ b/messages/de.json @@ -560,7 +560,7 @@ "viewAll": "Alle anzeigen", "empty": { "title": "Noch keine Workouts", - "cta": "Öffne die Apple-Health-App auf dem iPhone, damit Workouts hier erscheinen." + "cta": "Verbinde eine Datenquelle in den Einstellungen, damit Workouts hier erscheinen." } }, "metric": { @@ -729,9 +729,12 @@ "sourceWhoop": "WHOOP", "sourceFitbit": "Fitbit", "bulkDeleteSuccess": "{count} Messwerte gelöscht", + "bulkDeleteSuccessOne": "Messwert gelöscht", "bulkDeleteError": "Fehler beim Löschen der ausgewählten Messungen", "bulkDeleteConfirmTitle": "{count} Messwerte löschen?", + "bulkDeleteConfirmTitleOne": "Diesen Messwert löschen?", "bulkDeleteConfirmBody": "Die {count} ausgewählten Messungen werden gelöscht. Direkt danach kannst du das kurz rückgängig machen.", + "bulkDeleteConfirmBodyOne": "Die ausgewählte Messung wird gelöscht. Direkt danach kannst du das kurz rückgängig machen.", "typePhq9Score": "PHQ-9-Wert", "typeGad7Score": "GAD-7-Wert", "typeGripStrength": "Griffstärke", @@ -879,9 +882,12 @@ "sourceTelegram": "Telegram", "sourceDaylio": "Daylio", "bulkDeleteSuccess": "{count} Einträge gelöscht", + "bulkDeleteSuccessOne": "Eintrag gelöscht", "bulkDeleteError": "Fehler beim Löschen der ausgewählten Einträge", "bulkDeleteConfirmTitle": "{count} Einträge löschen?", + "bulkDeleteConfirmTitleOne": "Diesen Eintrag löschen?", "bulkDeleteConfirmBody": "Die {count} ausgewählten Stimmungseinträge werden gelöscht. Direkt danach kannst du das kurz rückgängig machen.", + "bulkDeleteConfirmBodyOne": "Der ausgewählte Stimmungseintrag wird gelöscht. Direkt danach kannst du das kurz rückgängig machen.", "customize": "Stimmungs-Tags anpassen", "addTagInline": "Tag hinzufügen", "manage": { @@ -1599,7 +1605,7 @@ "siteThighRight": "Rechter Oberschenkel", "siteUpperArmLeft": "Linker Oberarm", "siteUpperArmRight": "Rechter Oberarm", - "sideEffectTagsHelp": "Tippe einen Chip, um ihn zu den Mood-Tags hinzuzufügen.", + "sideEffectTagsHelp": "Tippe einen Chip, um ihn zu den Stimmungs-Tags hinzuzufügen.", "sideEffectTagNausea": "Übelkeit", "sideEffectTagConstipation": "Verstopfung", "sideEffectTagDiarrhea": "Durchfall", @@ -1779,7 +1785,8 @@ "enabledToast": "Erinnerungen aktiv", "disabledToast": "Erinnerungen aus", "toggleFailed": "Erinnerungs-Einstellung konnte nicht geändert werden.", - "clientManagedChip": "Dein iPhone steuert die Erinnerungen für dieses Medikament." + "clientManagedChip": "Vom iPhone verwaltet", + "clientManagedHint": "Dein iPhone steuert die Erinnerungen für dieses Medikament." }, "settings": { "title": "Einstellungen", @@ -1932,7 +1939,7 @@ "addQuantityHelper": "1–1000 Einheiten pro Packung oder Behälter.", "addExpiryLabel": "Ablaufdatum (optional)", "addSubmit": "Erfassen", - "addSuccess": "Bestand erfasst.", + "addSuccess": "Bestand erfasst", "addFailed": "Bestand konnte nicht erfasst werden.", "adjustButton": "Anpassen", "adjustTitle": "Behälter korrigieren", @@ -1940,11 +1947,11 @@ "adjustQuantityLabel": "Verbleibende Einheiten", "adjustHelper": "Kapazität: {total} Einheiten.", "adjustSubmit": "Speichern", - "adjustSuccess": "Bestand aktualisiert.", + "adjustSuccess": "Bestand aktualisiert", "adjustFailed": "Bestand konnte nicht aktualisiert werden.", "deleteTitle": "Packung löschen?", "deleteDescription": "Entfernt diese Packung oder diesen Behälter dauerhaft aus dem Bestand. Protokollierte Einnahmen bleiben unverändert.", - "deleteSuccess": "Bestand gelöscht.", + "deleteSuccess": "Bestand gelöscht", "deleteFailed": "Bestand konnte nicht gelöscht werden.", "containerTypeLabel": "Behälterart", "containerType": { @@ -2139,7 +2146,7 @@ "scaleToggleLabel": "Diagrammskala" }, "import": { - "resultSuccess": "{imported} Einnahmen importiert.", + "resultSuccess": "{imported} Einnahmen importiert", "resultPartial": "{imported} importiert, {skipped} übersprungen.", "resultNothing": "Nichts importiert: alle {skipped} Einträge wurden übersprungen.", "resultAlreadyRecorded": "{recorded} Einnahmen sind bereits erfasst. Es wurde nichts Neues hinzugefügt.", @@ -2400,7 +2407,7 @@ "dragHandle": "Zum Sortieren ziehen", "show": "Einblenden", "hide": "Ausblenden", - "manageInSettings": "Pills & Detailseiten in den Einstellungen verwalten", + "manageInSettings": "Chips & Detailseiten in den Einstellungen verwalten", "groupSleep": "Schlaf", "groupMood": "Stimmung", "groupEvents": "Ereignisse", @@ -2461,7 +2468,7 @@ "yellow": "Einige Bereiche brauchen Aufmerksamkeit", "red": "Mindestens ein Bereich liegt außerhalb des Referenzbereichs" }, - "insufficient": "{count} von {required} Feldern der Gesundheit haben genug aktuelle Daten. Der Score braucht {required}, mindestens eines davon körperlich gemessen.", + "insufficient": "{count} von {required} Gesundheitsbereichen haben genug aktuelle Daten. Der Score braucht {required}, mindestens einen davon körperlich gemessen.", "anatomyToggle": "Wie dieser Score zustande kommt", "notScored": "Noch nicht bewertet ({count})", "method": "Der Score ist der gleich gewichtete Mittelwert der Säulen, die für ihn zählen, auf einer Skala von 0 bis 100. Jede Säule wird an einem veröffentlichten Referenzbereich gemessen, und die schwächste Säule kann den Gesamtbereich bestimmen. Eine Säule ohne ausreichend aktuelle Daten bleibt außen vor und zählt nicht als null. Die Referenzbereiche stammen aus veröffentlichten Quellen; welche Säulen zählen, entscheidest du selbst, deshalb ist die zusammengefasste Zahl selbst kein klinischer Standard.", @@ -2776,6 +2783,7 @@ "settingsVerbosityBrief": "Kurz", "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\").", "settingsSourcesPointerLink": "Coach öffnen", "settingsExcludeLabel": "Metriken ausschließen", @@ -2787,7 +2795,7 @@ "settingsDefaultWindowLabel": "Standard-Analysezeitraum", "settingsDefaultWindowHint": "Der Coach liest pro Chat diesen Zeitraum, sofern du im Drawer-Kopf nichts anderes auswählst.", "settingsSave": "Speichern", - "settingsSaved": "Gespeichert.", + "settingsSaved": "Gespeichert", "settingsCancel": "Abbrechen", "feedbackHelpful": "Hilfreich", "feedbackUnhelpful": "Nicht ganz", @@ -4159,13 +4167,13 @@ "overviewDescription": "Abschnitte ein- oder ausblenden und ihre Reihenfolge auf der Insights-Übersicht festlegen." }, "pillOrder": { - "title": "Navigations-Pills sortieren", - "description": "Ziehe die Pills in die gewünschte Reihenfolge und blende sie über das Augensymbol ein oder aus.", + "title": "Navigations-Chips sortieren", + "description": "Ziehe die Chips in die gewünschte Reihenfolge und blende sie über das Augensymbol ein oder aus.", "dragHandle": "Verschieben", - "saveSuccess": "Reihenfolge der Pills gespeichert.", - "saveError": "Speichern der Pill-Reihenfolge fehlgeschlagen.", + "saveSuccess": "Reihenfolge der Chips gespeichert", + "saveError": "Speichern der Chip-Reihenfolge fehlgeschlagen.", "save": "Speichern", - "empty": "Noch keine Navigations-Pills verfügbar.", + "empty": "Noch keine Navigations-Chips verfügbar.", "detail": "Ausgeblendete Detailseiten verschwinden auch aus der oberen Navigation." }, "customize": "Insights anpassen", @@ -4833,8 +4841,6 @@ } }, "dashboard": { - "title": "Dashboard", - "description": "Kachel-Layout und Reihenfolge.", "subtitle": "Ordne die Kacheln auf deinem Dashboard an." }, "thresholds": { @@ -4855,9 +4861,9 @@ "resetDefaultsConfirm": "Reihenfolge zurücksetzen", "moveUp": "Nach oben", "moveDown": "Nach unten", - "saveSuccess": "Quellen-Priorität gespeichert.", + "saveSuccess": "Quellen-Priorität gespeichert", "saveError": "Quellen-Priorität konnte nicht gespeichert werden.", - "resetSuccess": "Quellen-Priorität zurückgesetzt.", + "resetSuccess": "Quellen-Priorität zurückgesetzt", "perMetricToggle": "Pro Metrik anpassen ({count})", "perMetricHelp": "Metriken mit individueller Reihenfolge. Über diese Liste lässt sich eine einzelne Metrik mit einem Klick auf den Standard zurücksetzen, ohne die anderen anzufassen.", "perMetricEmpty": "Keine individuellen Anpassungen — jede Metrik nutzt die globale Reihenfolge oben.", @@ -5026,7 +5032,7 @@ "failed": "Der Import ist fehlgeschlagen. Bitte erneut versuchen.", "readFailed": "Diese Datei konnte nicht gelesen werden.", "resultSummary": "{measurements} Messwerte und {moods} Stimmungseinträge importiert, {skipped} übersprungen.", - "resultSuccess": "{measurements} Messwerte und {moods} Stimmungseinträge importiert.", + "resultSuccess": "{measurements} Messwerte und {moods} Stimmungseinträge importiert", "resultNothing": "Nichts importiert. Alle {skipped} Einträge wurden übersprungen.", "resultEmpty": "Diese Datei enthielt weder Messwerte noch Stimmungseinträge." }, @@ -5050,10 +5056,10 @@ "previewSummary": "Vorschau: {inserted} würden importiert, {skipped} übersprungen.", "resultSummary": "{inserted} neu importiert und {updated} aktualisiert, {skipped} übersprungen.", "rowError": "Zeile {line}: {reason}", - "resultSuccess": "{inserted} neu importiert und {updated} aktualisiert.", + "resultSuccess": "{inserted} neu importiert und {updated} aktualisiert", "resultNothing": "Nichts importiert. Alle {skipped} Zeilen wurden übersprungen.", "resultEmpty": "Diese Datei hat eine Kopfzeile, aber keine Datenzeilen.", - "previewSuccess": "Vorschau: {inserted} würden importiert.", + "previewSuccess": "Vorschau: {inserted} würden importiert", "previewNothing": "Vorschau: nichts würde importiert. Alle {skipped} Zeilen würden übersprungen.", "skipGroupOne": "{count} Zeile übersprungen: {reason}", "skipGroupFew": "{count} Zeilen übersprungen: {reason}", @@ -5144,38 +5150,26 @@ "subtitle": "Teile schreibgeschützten Zugriff auf deine Daten." }, "insights": { - "title": "Insights", - "description": "Reihenfolge der Übersicht und der Navigations-Pills.", - "subtitle": "Ordne die Übersichts-Bereiche und die Reihenfolge der Pills an." + "subtitle": "Ordne die Übersichts-Bereiche und die Reihenfolge der Chips an." }, "medications": { - "title": "Medikamente", - "description": "Listenansicht, Reihenfolge und Injektionsstellen deiner Medikamente.", "subtitle": "Verwalte Ansicht und Reihenfolge deiner Medikamentenliste." }, "mood": { - "title": "Stimmungs-Tags", - "description": "Gruppen, Tags und archivierte Tags der Stimmungs-Auswahl.", "subtitle": "Organisiere deine Stimmungs-Tags, Gruppen und Auswahlreihenfolge." }, "labs": { - "title": "Laborwerte", - "description": "Ansicht, Sortierung und Biomarker deiner Laborwerte.", "subtitle": "Ansicht, Sortierung und Biomarker verwalten." }, "illness": { - "title": "Krankheitstagebuch", - "description": "Ansicht und Reihenfolge deiner Krankheitsepisoden.", "subtitle": "Ansicht und Reihenfolge der Episoden verwalten." }, "vorsorge": { - "title": "Vorsorge", - "description": "Ansicht, Reihenfolge und einzelne Vorsorge-Erinnerungen.", "subtitle": "Ansicht, Reihenfolge und einzelne Erinnerungen verwalten." }, "modules": { "title": "Module", - "saved": "Gespeichert.", + "saved": "Gespeichert", "error": "Konnte nicht gespeichert werden. Bitte erneut versuchen.", "toggleable": { "title": "Was du trackst", @@ -5223,7 +5217,7 @@ "use": "Verwenden", "select": "Auswählen", "saveError": "Änderung konnte nicht gespeichert werden.", - "homeSaved": "Heimatort gespeichert.", + "homeSaved": "Heimatort gespeichert", "travelAdded": "Ortszeitraum hinzugefügt.", "travelRemoved": "Ortszeitraum entfernt.", "backfillQueued": "Nacherfassung eingeplant.", @@ -5280,7 +5274,7 @@ "current": "Deine gespeicherte Auswahl" }, "save": "Speichern", - "saved": "Gespeichert.", + "saved": "Gespeichert", "error": "Konnte nicht gespeichert werden. Bitte versuche es erneut.", "loadFailed": "Deine Auswahl konnte nicht geladen werden.", "eligibilityFailed": "Es ließ sich nicht prüfen, für welche Bereiche gerade Daten vorliegen. Die Schalter unten funktionieren trotzdem.", @@ -5318,7 +5312,7 @@ "auto": "Automatisch (Sprache)", "h24": "24 Stunden", "h12": "12 Stunden (AM/PM)", - "saved": "Stundenformat gespeichert.", + "saved": "Stundenformat gespeichert", "saveError": "Speichern des Stundenformats fehlgeschlagen." }, "dateFormat": { @@ -5328,7 +5322,7 @@ "dmy": "TT.MM.JJJJ", "mdy": "MM/TT/JJJJ", "ymd": "JJJJ-MM-TT", - "saved": "Datumsformat gespeichert.", + "saved": "Datumsformat gespeichert", "saveError": "Speichern des Datumsformats fehlgeschlagen." }, "username": "Nutzername", @@ -5622,7 +5616,7 @@ "clearBody": "Entfernt nur den Freitext. Allergien und Unverträglichkeiten bleiben unverändert.", "clearConfirm": "Notiz leeren", "save": "Speichern", - "savedToast": "Über-mich-Notiz gespeichert.", + "savedToast": "Über-mich-Notiz gespeichert", "clearedToast": "Über-mich-Notiz gelöscht.", "saveError": "Speichern fehlgeschlagen. Bitte versuche es erneut.", "loadError": "Die Notiz konnte nicht geladen werden.", @@ -5644,14 +5638,14 @@ "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.", - "confirm": "Ich verstehe – automatisches KI-Lesen aktivieren", + "confirm": "Verstanden — aktivieren", "cancel": "Abbrechen" }, "centralCodex": { "title": "Gemeinsamen KI-Zugang des Servers nutzen", "subLabel": "Leite KI-Anfragen über die vom Betreiber für diesen Server eingerichtete Verbindung — zusätzlich zu deinen eigenen Anbietern als Rückfallebene.", "honesty": "Der gemeinsame Zugang ist ein einzelnes angemeldetes KI-Konto, das der Betreiber für alle auf diesem Server verbunden hat — nicht dein eigener Schlüssel. Anfragen darüber werden vom Anbieter standardmäßig zum Training genutzt und von keiner Auftragsverarbeitungsvereinbarung gedeckt, die Stunden- und Wochenlimits des Kontos teilst du dir mit allen anderen, die zugestimmt haben, und deine eigenen Anbieter werden weiterhin zuerst versucht. Aktiviere es nur, wenn du diesen Kompromiss akzeptierst.", - "confirm": "Verstanden — gemeinsame Verbindung nutzen", + "confirm": "Verstanden — Verbindung nutzen", "cancel": "Abbrechen" }, "consent": { @@ -5673,7 +5667,7 @@ "description": "Lege fest, wie Distanzen und Geschwindigkeiten in Diagrammen und Kacheln angezeigt werden.", "metric": "Metrisch", "imperial": "Imperial", - "saved": "Einheiten-Einstellung gespeichert.", + "saved": "Einheiten-Einstellung gespeichert", "saveError": "Einheiten-Einstellung konnte nicht gespeichert werden." } }, @@ -5861,7 +5855,7 @@ "errorReconnect": "Fehler — neu verbinden", "warningServerError": "Sync fehlgeschlagen", "failureCount": "{count}/{threshold}", - "parkedReconnect": "Pausiert — manuell wieder verbinden", + "parkedReconnect": "Pausiert — neu verbinden", "notConnected": "Nicht verbunden", "justNow": "gerade eben", "minutesAgo": "vor {count} min", @@ -5945,6 +5939,7 @@ "repository": "Quellcode", "changelog": "Changelog", "docs": "Dokumentation", + "credits": "Credits & Datenquellen", "linksHeading": "Quellen & Dokumentation", "newerAvailable": "Neue Version: {tag}" }, @@ -6038,7 +6033,7 @@ "expiryInvalid": "Die Gültigkeit muss zwischen 1 und {max} Tagen liegen.", "create": "Link erstellen", "tokenCreated": "Freigabe-Link erstellt", - "copied": "In die Zwischenablage kopiert.", + "copied": "In die Zwischenablage kopiert", "activeTitle": "Aktive Links", "noActive": "Keine aktiven Freigabe-Links.", "created": "Erstellt", @@ -6190,7 +6185,7 @@ "revokeSession": "Abmelden", "revokeError": "Anfrage konnte nicht abgeschlossen werden. Bitte erneut versuchen.", "signOutEverywhere": "Überall sonst abmelden", - "signOutEverywhereDone": "{count} andere Sitzung(en) abgemeldet.", + "signOutEverywhereDone": "{count} andere Sitzung(en) abgemeldet", "activityTitle": "Sicherheitsaktivität", "activityDescription": "Letzte Anmeldungen, Sicherheitsänderungen und Datenexporte deines Kontos.", "activityEmpty": "Noch keine Sicherheitsaktivität.", @@ -6271,7 +6266,7 @@ "revokeError": "Gerät konnte nicht entfernt werden. Bitte erneut versuchen.", "revoke": "Entfernen", "revokeAll": "Alle Geräte vergessen", - "revokeAllDone": "{count} vertrauenswürdige(s) Gerät(e) entfernt.", + "revokeAllDone": "{count} vertrauenswürdige(s) Gerät(e) entfernt", "unnamed": "Unbekanntes Gerät", "current": "Dieses Gerät", "expires": "Vertraut bis {date}", @@ -6596,13 +6591,13 @@ "maxUsesLabel": "Maximale Nutzungen", "maxUsesHint": "Wie viele Registrierungen dieser Link zulässt (1–50).", "createConfirm": "Einladung erstellen", - "createdToast": "Einladung erstellt.", + "createdToast": "Einladung erstellt", "createError": "Einladung konnte nicht erstellt werden.", "tokenTitle": "Einladungslink", "tokenOnceHint": "Nur jetzt kopierbar — kopiere den Link oder lass den QR-Code scannen.", "hashExplainer": "Gespeichert wird nur ein Hash des Links — er kann später nicht erneut angezeigt werden. Geht er verloren: Einladung widerrufen und neu erstellen.", "copyLink": "Link kopieren", - "copiedToast": "Link kopiert.", + "copiedToast": "Link kopiert", "copyError": "Kopieren fehlgeschlagen.", "qrAlt": "QR-Code des Einladungslinks", "loadError": "Einladungen konnten nicht geladen werden.", @@ -7345,7 +7340,7 @@ "saveError": "Speichern fehlgeschlagen. Bitte später erneut versuchen.", "hourLabel": "Erinnerungszeit", "hourAria": "Stunde der Stimmungs-Erinnerung", - "hourSavedToast": "Erinnerungszeit aktualisiert.", + "hourSavedToast": "Erinnerungszeit aktualisiert", "detail": "Greift nur auf bereits eingerichtete Kanäle (Telegram, ntfy, Web Push, Apple Push) zurück und schweigt, sobald du heute geloggt hast." }, "coachNudge": { @@ -7386,8 +7381,8 @@ "leadLabel": "Vorlaufzeit zum Nachbestellen (Tage)", "leadAria": "Vorlaufzeit zum Nachbestellen in Tagen", "leadHelp": "Die Warnung kommt um so viele Tage plus ein Dosisintervall früher, damit der Nachschub vor deiner letzten Dosis eintrifft. Ein einzelnes Medikament kann diesen Wert auf seinem Bestand-Tab überschreiben.", - "leadSavedToast": "Vorlaufzeit gespeichert.", - "savedToast": "Vorratsschwelle gespeichert.", + "leadSavedToast": "Vorlaufzeit gespeichert", + "savedToast": "Vorratsschwelle gespeichert", "enabledToast": "Vorratswarnungen aktiviert.", "disabledToast": "Vorratswarnungen deaktiviert.", "saveError": "Speichern fehlgeschlagen. Bitte später erneut versuchen.", @@ -8047,10 +8042,10 @@ "protected": "Ob beim Verkehr verhütet wurde.", "protectedLabel": "Was bedeutet geschützt?" }, - "saveSuccess": "Eintrag gespeichert.", - "deleteSuccess": "Eintrag gelöscht.", - "periodStartSaved": "Periodenbeginn gespeichert.", - "periodEndSaved": "Periodenende gespeichert.", + "saveSuccess": "Eintrag gespeichert", + "deleteSuccess": "Eintrag gelöscht", + "periodStartSaved": "Periodenbeginn gespeichert", + "periodEndSaved": "Periodenende gespeichert", "startedPeriodOnDate": "Periode hat an diesem Tag begonnen", "endedPeriodOnDate": "Periode an diesem Tag beendet" }, @@ -9311,7 +9306,7 @@ "freeText": { "label": "Freitext-Ergänzung (Allergien & Unverträglichkeiten)", "hint": "Eine Freitext-Ergänzung zur strukturierten Liste oben, für Kontext, den ein einzelner Eintrag nicht abbilden kann.", - "savedToast": "Allergie-Notiz gespeichert.", + "savedToast": "Allergie-Notiz gespeichert", "saveError": "Speichern fehlgeschlagen. Bitte versuche es erneut." } }, @@ -9370,7 +9365,7 @@ "conditionsLabel": "Chronische Erkrankungen", "focusLabel": "Worauf der Coach achtet", "loadError": "Vorerkrankungen konnten nicht geladen werden.", - "savedToast": "Erkrankungen gespeichert.", + "savedToast": "Erkrankungen gespeichert", "saveError": "Speichern fehlgeschlagen. Bitte versuche es erneut." }, "emergency": { @@ -9474,7 +9469,7 @@ }, "toast": { "duplicate": "Bereits gespeichert — das vorhandene Dokument wird hervorgehoben.", - "deleted": "Dokument gelöscht.", + "deleted": "Dokument gelöscht", "restoreFailed": "Das Dokument konnte nicht wiederhergestellt werden." }, "timeline": { @@ -9647,9 +9642,9 @@ "unverifiedNoteOther": "{count} Werte müssen noch geprüft werden und bleiben offen.", "finish": "Prüfung abschließen", "confirmFailed": "Die Prüfung konnte nicht gespeichert werden. Versuche es erneut.", - "savedToastOne": "1 Wert gespeichert.", - "savedToastFew": "{count} Werte gespeichert.", - "savedToastOther": "{count} Werte gespeichert.", + "savedToastOne": "1 Wert gespeichert", + "savedToastFew": "{count} Werte gespeichert", + "savedToastOther": "{count} Werte gespeichert", "nothingSavedToast": "Prüfung gespeichert. Es wurden keine Werte übernommen.", "extract": "Laborwerte auslesen", "extracting": "Liest aus…", @@ -10190,7 +10185,7 @@ "errorRateLimit": "Zu viele Profile in der letzten Stunde angelegt. Warte etwas und versuche es erneut." }, "toast": { - "savedTo": "In der Akte von {name} gespeichert." + "savedTo": "In der Akte von {name} gespeichert" }, "activityVerb": { "measurementCreate": "{name} hat einen Messwert eingetragen", diff --git a/messages/en.json b/messages/en.json index 9ac68943d..ce24821f3 100644 --- a/messages/en.json +++ b/messages/en.json @@ -560,7 +560,7 @@ "viewAll": "View all", "empty": { "title": "No workouts yet", - "cta": "Open Apple Health on your iPhone to sync workouts here." + "cta": "Connect a data source in Settings to see workouts here." } }, "metric": { @@ -729,9 +729,12 @@ "sourceWhoop": "WHOOP", "sourceFitbit": "Fitbit", "bulkDeleteSuccess": "{count} measurements deleted", + "bulkDeleteSuccessOne": "Measurement deleted", "bulkDeleteError": "Error deleting the selected measurements", "bulkDeleteConfirmTitle": "Delete {count} measurements?", + "bulkDeleteConfirmTitleOne": "Delete this measurement?", "bulkDeleteConfirmBody": "The {count} selected measurements will be deleted. You can undo this for a short moment afterwards.", + "bulkDeleteConfirmBodyOne": "The selected measurement will be deleted. You can undo this for a short moment afterwards.", "typePhq9Score": "PHQ-9 score", "typeGad7Score": "GAD-7 score", "typeGripStrength": "Grip strength", @@ -879,9 +882,12 @@ "sourceTelegram": "Telegram", "sourceDaylio": "Daylio", "bulkDeleteSuccess": "{count} entries deleted", + "bulkDeleteSuccessOne": "Entry deleted", "bulkDeleteError": "Error deleting the selected entries", "bulkDeleteConfirmTitle": "Delete {count} entries?", + "bulkDeleteConfirmTitleOne": "Delete this entry?", "bulkDeleteConfirmBody": "The {count} selected mood entries will be deleted. You can undo this for a short moment afterwards.", + "bulkDeleteConfirmBodyOne": "The selected mood entry will be deleted. You can undo this for a short moment afterwards.", "customize": "Customize mood tags", "addTagInline": "Add tag", "manage": { @@ -1779,7 +1785,8 @@ "enabledToast": "Reminders on", "disabledToast": "Reminders off", "toggleFailed": "Could not change the reminder setting.", - "clientManagedChip": "Your iPhone manages reminders for this medication." + "clientManagedChip": "Managed by iPhone", + "clientManagedHint": "Your iPhone manages reminders for this medication." }, "settings": { "title": "Settings", @@ -1932,7 +1939,7 @@ "addQuantityHelper": "1–1000 units per pack or container.", "addExpiryLabel": "Expiry date (optional)", "addSubmit": "Register", - "addSuccess": "Supply registered.", + "addSuccess": "Supply registered", "addFailed": "The supply could not be registered.", "adjustButton": "Adjust", "adjustTitle": "Correct container", @@ -1940,11 +1947,11 @@ "adjustQuantityLabel": "Remaining units", "adjustHelper": "Capacity: {total} units.", "adjustSubmit": "Save", - "adjustSuccess": "Stock updated.", + "adjustSuccess": "Stock updated", "adjustFailed": "The stock could not be updated.", "deleteTitle": "Delete container?", "deleteDescription": "Permanently removes this pack or container from the stock. Logged intakes stay unchanged.", - "deleteSuccess": "Stock entry deleted.", + "deleteSuccess": "Stock entry deleted", "deleteFailed": "The stock entry could not be deleted.", "containerTypeLabel": "Container type", "containerType": { @@ -2139,7 +2146,7 @@ "scaleToggleLabel": "Chart scale" }, "import": { - "resultSuccess": "{imported} intakes imported.", + "resultSuccess": "{imported} intakes imported", "resultPartial": "{imported} imported, {skipped} skipped.", "resultNothing": "Nothing was imported: all {skipped} entries were skipped.", "resultAlreadyRecorded": "{recorded} intakes are already recorded. Nothing new was added.", @@ -2402,7 +2409,7 @@ "dragHandle": "Drag to reorder", "show": "Show", "hide": "Hide", - "manageInSettings": "Manage pills & detail pages in Settings", + "manageInSettings": "Manage chips & detail pages in Settings", "groupSleep": "Sleep", "groupMood": "Mood", "groupEvents": "Events", @@ -2776,6 +2783,7 @@ "settingsVerbosityBrief": "Brief", "settingsVerbosityDefault": "Default", "settingsVerbosityDetailed": "Detailed", + "settingsVerbosityConciseHint": "The concise tone already keeps replies short — verbosity is pinned to Brief while it is active.", "settingsSourcesPointer": "Which data clusters the coach may draw on and the default analysis window are managed directly in the coach chat (\"What I draw on\" rail).", "settingsSourcesPointerLink": "Open the coach", "settingsExcludeLabel": "Exclude metrics", @@ -2787,7 +2795,7 @@ "settingsDefaultWindowLabel": "Default analysis window", "settingsDefaultWindowHint": "The Coach reads this much history per chat unless you override it from the drawer header.", "settingsSave": "Save", - "settingsSaved": "Saved.", + "settingsSaved": "Saved", "settingsCancel": "Cancel", "feedbackHelpful": "Helpful", "feedbackUnhelpful": "Not quite", @@ -4159,13 +4167,13 @@ "overviewDescription": "Show or hide sections and set their order on the Insights overview." }, "pillOrder": { - "title": "Sort the navigation pills", - "description": "Drag the pills into the order you want and show or hide them via the eye icon.", + "title": "Sort the navigation chips", + "description": "Drag the chips into the order you want and show or hide them via the eye icon.", "dragHandle": "Reorder", - "saveSuccess": "Pill order saved.", - "saveError": "Saving the pill order failed.", + "saveSuccess": "Chip order saved", + "saveError": "Saving the chip order failed.", "save": "Save", - "empty": "No navigation pills available yet.", + "empty": "No navigation chips available yet.", "detail": "Hidden detail pages are also removed from the top navigation." }, "customize": "Customize Insights", @@ -4833,8 +4841,6 @@ } }, "dashboard": { - "title": "Dashboard", - "description": "Tile layout and order.", "subtitle": "Arrange the tiles on your dashboard." }, "thresholds": { @@ -4855,9 +4861,9 @@ "resetDefaultsConfirm": "Reset order", "moveUp": "Move up", "moveDown": "Move down", - "saveSuccess": "Source priority saved.", + "saveSuccess": "Source priority saved", "saveError": "Saving the source priority failed.", - "resetSuccess": "Source priority reset to defaults.", + "resetSuccess": "Source priority reset to defaults", "perMetricToggle": "Per-metric overrides ({count})", "perMetricHelp": "Metrics you have customised away from the default ladder. Use this list to roll any single metric back to its constant default without touching the rest.", "perMetricEmpty": "No per-metric overrides set — every metric uses the global ladder above.", @@ -5026,7 +5032,7 @@ "failed": "The import failed. Please try again.", "readFailed": "Couldn't read that file.", "resultSummary": "Imported {measurements} measurements and {moods} mood entries, {skipped} skipped.", - "resultSuccess": "Imported {measurements} measurements and {moods} mood entries.", + "resultSuccess": "Imported {measurements} measurements and {moods} mood entries", "resultNothing": "Nothing was imported. All {skipped} entries were skipped.", "resultEmpty": "That file carried no measurements and no mood entries." }, @@ -5050,10 +5056,10 @@ "previewSummary": "Preview: {inserted} would import, {skipped} skipped.", "resultSummary": "Imported {inserted} new and updated {updated}, {skipped} skipped.", "rowError": "Line {line}: {reason}", - "resultSuccess": "Imported {inserted} new and updated {updated}.", + "resultSuccess": "Imported {inserted} new and updated {updated}", "resultNothing": "Nothing was imported. All {skipped} rows were skipped.", "resultEmpty": "That file has a header but no data rows.", - "previewSuccess": "Preview: {inserted} would import.", + "previewSuccess": "Preview: {inserted} would import", "previewNothing": "Preview: nothing would import. All {skipped} rows would be skipped.", "skipGroupOne": "{count} row skipped: {reason}", "skipGroupFew": "{count} rows skipped: {reason}", @@ -5144,38 +5150,26 @@ "subtitle": "Share read-only access to your data." }, "insights": { - "title": "Insights", - "description": "Overview and navigation-pill order.", - "subtitle": "Arrange your overview sections and pill order." + "subtitle": "Arrange your overview sections and chip order." }, "medications": { - "title": "Medications", - "description": "List view, order, and injection sites for your medications.", "subtitle": "Manage your medication list view and order." }, "mood": { - "title": "Mood tags", - "description": "Groups, tags, and archived tags for the mood picker.", "subtitle": "Organize your mood tags, groups, and picker order." }, "labs": { - "title": "Labs", - "description": "View, sort order, and biomarkers for your labs.", "subtitle": "Manage the view, sort order, and biomarkers." }, "illness": { - "title": "Illness journal", - "description": "View and order of your illness episodes.", "subtitle": "Manage the view and order of episodes." }, "vorsorge": { - "title": "Checkups", - "description": "View, order, and individual checkup reminders.", "subtitle": "Manage the view, order, and individual reminders." }, "modules": { "title": "Modules", - "saved": "Saved.", + "saved": "Saved", "error": "Couldn't save. Please try again.", "toggleable": { "title": "What you track", @@ -5223,7 +5217,7 @@ "use": "Use", "select": "Select", "saveError": "Could not save your change.", - "homeSaved": "Home location saved.", + "homeSaved": "Home location saved", "travelAdded": "Location period added.", "travelRemoved": "Location period removed.", "backfillQueued": "Backfill queued.", @@ -5280,7 +5274,7 @@ "current": "Your saved selection" }, "save": "Save", - "saved": "Saved.", + "saved": "Saved", "error": "Couldn't save. Please try again.", "loadFailed": "Couldn't load your selection.", "eligibilityFailed": "Couldn't check which pillars currently have data. The switches below still work.", @@ -5318,7 +5312,7 @@ "auto": "Automatic (language)", "h24": "24-hour", "h12": "12-hour (AM/PM)", - "saved": "Hour format saved.", + "saved": "Hour format saved", "saveError": "Saving the hour format failed." }, "dateFormat": { @@ -5328,7 +5322,7 @@ "dmy": "DD.MM.YYYY", "mdy": "MM/DD/YYYY", "ymd": "YYYY-MM-DD", - "saved": "Date format saved.", + "saved": "Date format saved", "saveError": "Saving the date format failed." }, "username": "Username", @@ -5622,7 +5616,7 @@ "clearBody": "This removes the free-text note only. Your allergies and intolerances stay exactly as they are.", "clearConfirm": "Clear note", "save": "Save", - "savedToast": "About-me note saved.", + "savedToast": "About-me note saved", "clearedToast": "About-me note cleared.", "saveError": "Could not save. Please try again.", "loadError": "The note could not be loaded.", @@ -5644,14 +5638,14 @@ "title": "Read documents automatically with AI", "subLabel": "Documents are read and indexed by your configured AI provider without a per-document tap.", "honesty": "When on, each document you upload is sent to your configured AI provider to be read, described, and indexed for search — with no per-document confirmation. Turning it on also reaches backwards: documents you have already uploaded that carry no summary yet are sent too, up to 200 each time you switch it on. This happens on the server and the document's contents leave this machine to that provider. If your configured provider is a subscription connection (a signed-in AI account rather than an API key), that provider's own data settings apply — subscription providers may use the content to improve their models, and no data-processing agreement covers it. Documents read by a self-hosted local model never leave your server. Off by default; turn it on only if you accept this trade for your documents.", - "confirm": "I understand — turn on automatic AI reading", + "confirm": "I understand — turn on", "cancel": "Cancel" }, "centralCodex": { "title": "Use the server's shared AI access", "subLabel": "Route AI requests through the connection the operator set up for this server, alongside your own providers as a fallback.", "honesty": "The shared access is a single signed-in AI account the operator connected for everyone on this server — not your own key. Requests you make on it are trained on by the provider by default and no data-processing agreement covers them, the account's hourly and weekly limits are shared with every other user who opted in, and your own configured providers are still tried first. Turn it on only if you accept this trade.", - "confirm": "I understand — use the shared connection", + "confirm": "I understand — use connection", "cancel": "Cancel" }, "consent": { @@ -5673,7 +5667,7 @@ "description": "Choose how distances and speeds are shown across charts and tiles.", "metric": "Metric", "imperial": "Imperial", - "saved": "Unit preference saved.", + "saved": "Unit preference saved", "saveError": "Could not save your unit preference." } }, @@ -5861,7 +5855,7 @@ "errorReconnect": "Error — reconnect", "warningServerError": "Sync failing", "failureCount": "{count}/{threshold}", - "parkedReconnect": "Paused — reconnect manually", + "parkedReconnect": "Paused — reconnect", "notConnected": "Not connected", "justNow": "just now", "minutesAgo": "{count} min ago", @@ -5945,6 +5939,7 @@ "repository": "Source code", "changelog": "Changelog", "docs": "Documentation", + "credits": "Credits & data attributions", "linksHeading": "Sources & docs", "newerAvailable": "Newer version: {tag}" }, @@ -6038,7 +6033,7 @@ "expiryInvalid": "Expiry must be between 1 and {max} days.", "create": "Create link", "tokenCreated": "Share link created", - "copied": "Copied to clipboard.", + "copied": "Copied to clipboard", "activeTitle": "Active links", "noActive": "No active share links.", "created": "Created", @@ -6190,7 +6185,7 @@ "revokeSession": "Sign out", "revokeError": "Could not complete the request. Please try again.", "signOutEverywhere": "Sign out everywhere else", - "signOutEverywhereDone": "Signed out {count} other session(s).", + "signOutEverywhereDone": "Signed out {count} other session(s)", "activityTitle": "Security activity", "activityDescription": "Recent sign-ins, security changes, and data exports on your account.", "activityEmpty": "No security activity yet.", @@ -6271,7 +6266,7 @@ "revokeError": "Could not revoke the device. Please try again.", "revoke": "Revoke", "revokeAll": "Forget all devices", - "revokeAllDone": "Forgot {count} trusted device(s).", + "revokeAllDone": "Forgot {count} trusted device(s)", "unnamed": "Unknown device", "current": "This device", "expires": "Trusted until {date}", @@ -6596,13 +6591,13 @@ "maxUsesLabel": "Maximum uses", "maxUsesHint": "How many signups this link admits (1–50).", "createConfirm": "Create invite", - "createdToast": "Invite created.", + "createdToast": "Invite created", "createError": "Could not create the invite.", "tokenTitle": "Invite link", "tokenOnceHint": "Copyable only now — copy the link or have the QR code scanned.", "hashExplainer": "Only a hash of the link is stored, so it cannot be shown again later. If it gets lost, revoke the invite and create a new one.", "copyLink": "Copy link", - "copiedToast": "Link copied.", + "copiedToast": "Link copied", "copyError": "Copy failed.", "qrAlt": "QR code for the invite link", "loadError": "Could not load the invites.", @@ -7345,7 +7340,7 @@ "saveError": "Save failed. Please try again.", "hourLabel": "Reminder time", "hourAria": "Mood reminder hour", - "hourSavedToast": "Reminder time updated.", + "hourSavedToast": "Reminder time updated", "detail": "Routes only through channels you've already configured (Telegram, ntfy, Web Push, Apple Push) and stays silent once you've logged today." }, "coachNudge": { @@ -7386,8 +7381,8 @@ "leadLabel": "Reorder lead time (days)", "leadAria": "Reorder lead time in days", "leadHelp": "The alert fires this many days plus one dose-interval earlier so a refill arrives before your last dose. A single medication can override this on its supply tab.", - "leadSavedToast": "Reorder lead time saved.", - "savedToast": "Low-stock threshold saved.", + "leadSavedToast": "Reorder lead time saved", + "savedToast": "Low-stock threshold saved", "enabledToast": "Low-stock alerts enabled.", "disabledToast": "Low-stock alerts disabled.", "saveError": "Could not save. Please try again.", @@ -8047,10 +8042,10 @@ "protected": "Whether contraception was used during intercourse.", "protectedLabel": "What does protected mean?" }, - "saveSuccess": "Entry saved.", - "deleteSuccess": "Entry deleted.", - "periodStartSaved": "Period start saved.", - "periodEndSaved": "End of period saved.", + "saveSuccess": "Entry saved", + "deleteSuccess": "Entry deleted", + "periodStartSaved": "Period start saved", + "periodEndSaved": "End of period saved", "startedPeriodOnDate": "My period started this day", "endedPeriodOnDate": "My period ended this day" }, @@ -9311,7 +9306,7 @@ "freeText": { "label": "Free-text note (allergies & intolerances)", "hint": "A free-text supplement to the structured list above, for context a single entry cannot carry.", - "savedToast": "Allergy note saved.", + "savedToast": "Allergy note saved", "saveError": "Could not save. Please try again." } }, @@ -9370,7 +9365,7 @@ "conditionsLabel": "Chronic conditions", "focusLabel": "What the Coach watches", "loadError": "Couldn't load your conditions.", - "savedToast": "Conditions saved.", + "savedToast": "Conditions saved", "saveError": "Could not save. Please try again." }, "emergency": { @@ -9474,7 +9469,7 @@ }, "toast": { "duplicate": "Already stored — highlighting the existing document.", - "deleted": "Document deleted.", + "deleted": "Document deleted", "restoreFailed": "Couldn't restore the document." }, "timeline": { @@ -9647,9 +9642,9 @@ "unverifiedNoteOther": "{count} values still need checking and stay pending.", "finish": "Finish review", "confirmFailed": "The review couldn't be saved. Try again.", - "savedToastOne": "1 value saved.", - "savedToastFew": "{count} values saved.", - "savedToastOther": "{count} values saved.", + "savedToastOne": "1 value saved", + "savedToastFew": "{count} values saved", + "savedToastOther": "{count} values saved", "nothingSavedToast": "Review saved. No values were added.", "extract": "Extract lab values", "extracting": "Extracting…", @@ -10190,7 +10185,7 @@ "errorRateLimit": "Too many profiles created in the last hour. Wait a while and try again." }, "toast": { - "savedTo": "Saved to {name}'s record." + "savedTo": "Saved to {name}'s record" }, "activityVerb": { "measurementCreate": "{name} added a reading", diff --git a/messages/es.json b/messages/es.json index b46e7ab59..c84064132 100644 --- a/messages/es.json +++ b/messages/es.json @@ -560,7 +560,7 @@ "viewAll": "Ver todos", "empty": { "title": "Aún no hay entrenamientos", - "cta": "Abre la app Apple Salud en el iPhone para sincronizar los entrenamientos." + "cta": "Conecta una fuente de datos en los ajustes para ver aquí los entrenamientos." } }, "metric": { @@ -729,9 +729,12 @@ "sourceWhoop": "WHOOP", "sourceFitbit": "Fitbit", "bulkDeleteSuccess": "{count} mediciones eliminadas", + "bulkDeleteSuccessOne": "Medición eliminada", "bulkDeleteError": "Error al eliminar las mediciones seleccionadas", "bulkDeleteConfirmTitle": "¿Eliminar {count} mediciones?", + "bulkDeleteConfirmTitleOne": "¿Eliminar esta medición?", "bulkDeleteConfirmBody": "Las {count} mediciones seleccionadas se eliminarán. Justo después podrás deshacerlo durante un breve momento.", + "bulkDeleteConfirmBodyOne": "La medición seleccionada se eliminará. Justo después podrás deshacerlo durante un breve momento.", "typePhq9Score": "Puntuación PHQ-9", "typeGad7Score": "Puntuación GAD-7", "typeGripStrength": "Fuerza de agarre", @@ -879,9 +882,12 @@ "sourceTelegram": "Telegram", "sourceDaylio": "Daylio", "bulkDeleteSuccess": "{count} entradas eliminadas", + "bulkDeleteSuccessOne": "Entrada eliminada", "bulkDeleteError": "Error al eliminar las entradas seleccionadas", "bulkDeleteConfirmTitle": "¿Eliminar {count} entradas?", + "bulkDeleteConfirmTitleOne": "¿Eliminar esta entrada?", "bulkDeleteConfirmBody": "Las {count} entradas de estado de ánimo seleccionadas se eliminarán. Justo después podrás deshacerlo durante un breve momento.", + "bulkDeleteConfirmBodyOne": "La entrada de estado de ánimo seleccionada se eliminará. Justo después podrás deshacerlo durante un breve momento.", "customize": "Personalizar etiquetas de ánimo", "addTagInline": "Añadir etiqueta", "manage": { @@ -1779,7 +1785,8 @@ "enabledToast": "Recordatorios activados", "disabledToast": "Recordatorios desactivados", "toggleFailed": "No se pudo cambiar el ajuste de recordatorios.", - "clientManagedChip": "Tu iPhone gestiona los recordatorios de este medicamento." + "clientManagedChip": "Gestionado por el iPhone", + "clientManagedHint": "Tu iPhone gestiona los recordatorios de este medicamento." }, "settings": { "title": "Ajustes", @@ -1932,7 +1939,7 @@ "addQuantityHelper": "1–1000 unidades por envase o recipiente.", "addExpiryLabel": "Fecha de caducidad (opcional)", "addSubmit": "Registrar", - "addSuccess": "Existencias registradas.", + "addSuccess": "Existencias registradas", "addFailed": "No se pudieron registrar las existencias.", "adjustButton": "Ajustar", "adjustTitle": "Corregir envase", @@ -1940,11 +1947,11 @@ "adjustQuantityLabel": "Unidades restantes", "adjustHelper": "Capacidad: {total} unidades.", "adjustSubmit": "Guardar", - "adjustSuccess": "Existencias actualizadas.", + "adjustSuccess": "Existencias actualizadas", "adjustFailed": "No se pudieron actualizar las existencias.", "deleteTitle": "¿Eliminar envase?", "deleteDescription": "Elimina permanentemente este envase o recipiente de las existencias. Las tomas registradas no cambian.", - "deleteSuccess": "Entrada de existencias eliminada.", + "deleteSuccess": "Entrada de existencias eliminada", "deleteFailed": "No se pudo eliminar la entrada de existencias.", "containerTypeLabel": "Tipo de envase", "containerType": { @@ -2139,7 +2146,7 @@ "scaleToggleLabel": "Escala del gráfico" }, "import": { - "resultSuccess": "{imported} tomas importadas.", + "resultSuccess": "{imported} tomas importadas", "resultPartial": "{imported} importadas, {skipped} omitidas.", "resultNothing": "No se importó nada: se omitieron las {skipped} entradas.", "resultAlreadyRecorded": "{recorded} tomas ya están registradas. No se añadió nada nuevo.", @@ -2776,6 +2783,7 @@ "settingsVerbosityBrief": "Breve", "settingsVerbosityDefault": "Estándar", "settingsVerbosityDetailed": "Detallado", + "settingsVerbosityConciseHint": "El tono conciso ya mantiene las respuestas cortas: la extensión queda fijada en «Breve» mientras esté activo.", "settingsSourcesPointer": "Los grupos de datos que el coach puede usar y la ventana de análisis predeterminada se gestionan directamente en el chat del coach (panel «En qué me baso»).", "settingsSourcesPointerLink": "Abrir el coach", "settingsExcludeLabel": "Excluir métricas", @@ -2787,7 +2795,7 @@ "settingsDefaultWindowLabel": "Periodo de análisis por defecto", "settingsDefaultWindowHint": "El coach lee este periodo por chat, salvo que elijas otro en la cabecera del cajón.", "settingsSave": "Guardar", - "settingsSaved": "Guardado.", + "settingsSaved": "Guardado", "settingsCancel": "Cancelar", "feedbackHelpful": "Útil", "feedbackUnhelpful": "Poco útil", @@ -4162,7 +4170,7 @@ "title": "Ordenar las pestañas de navegación", "description": "Arrastra las pestañas al orden que quieras y muéstralas u ocúltalas con el icono del ojo.", "dragHandle": "Reordenar", - "saveSuccess": "Orden de las pestañas guardado.", + "saveSuccess": "Orden de las pestañas guardado", "saveError": "No se pudo guardar el orden de las pestañas.", "save": "Guardar", "empty": "Aún no hay pestañas de navegación disponibles.", @@ -4833,8 +4841,6 @@ } }, "dashboard": { - "title": "Panel", - "description": "Diseño y orden de los mosaicos.", "subtitle": "Organiza los mosaicos de tu panel." }, "thresholds": { @@ -4855,9 +4861,9 @@ "resetDefaultsConfirm": "Restablecer orden", "moveUp": "Subir", "moveDown": "Bajar", - "saveSuccess": "Prioridad de fuentes guardada.", + "saveSuccess": "Prioridad de fuentes guardada", "saveError": "No se pudo guardar la prioridad de fuentes.", - "resetSuccess": "Prioridad de fuentes restablecida a los valores predeterminados.", + "resetSuccess": "Prioridad de fuentes restablecida a los valores predeterminados", "perMetricToggle": "Ajustes por métrica ({count})", "perMetricHelp": "Métricas que has personalizado respecto al orden predeterminado. Usa esta lista para devolver cualquier métrica concreta a su valor predeterminado sin tocar las demás.", "perMetricEmpty": "No hay ajustes por métrica — todas las métricas usan el orden global de arriba.", @@ -5026,7 +5032,7 @@ "failed": "La importación falló. Inténtalo de nuevo.", "readFailed": "No se pudo leer ese archivo.", "resultSummary": "{measurements} mediciones y {moods} entradas de ánimo importadas, {skipped} omitidas.", - "resultSuccess": "{measurements} mediciones y {moods} entradas de ánimo importadas.", + "resultSuccess": "{measurements} mediciones y {moods} entradas de ánimo importadas", "resultNothing": "No se importó nada. Se omitieron las {skipped} entradas.", "resultEmpty": "Ese archivo no contenía mediciones ni entradas de ánimo." }, @@ -5050,10 +5056,10 @@ "previewSummary": "Vista previa: {inserted} se importarían, {skipped} omitidas.", "resultSummary": "{inserted} nuevas importadas y {updated} actualizadas, {skipped} omitidas.", "rowError": "Línea {line}: {reason}", - "resultSuccess": "{inserted} nuevas importadas y {updated} actualizadas.", + "resultSuccess": "{inserted} nuevas importadas y {updated} actualizadas", "resultNothing": "No se importó nada. Se omitieron las {skipped} filas.", "resultEmpty": "Ese archivo tiene encabezado pero ninguna fila de datos.", - "previewSuccess": "Vista previa: {inserted} se importarían.", + "previewSuccess": "Vista previa: {inserted} se importarían", "previewNothing": "Vista previa: no se importaría nada. Se omitirían las {skipped} filas.", "skipGroupOne": "{count} fila omitida: {reason}", "skipGroupFew": "{count} filas omitidas: {reason}", @@ -5144,38 +5150,26 @@ "subtitle": "Comparte acceso de solo lectura a tus datos." }, "insights": { - "title": "Análisis", - "description": "Orden de la vista general y de las pestañas de navegación.", - "subtitle": "Organiza las secciones de tu resumen y el orden de las píldoras." + "subtitle": "Organiza las secciones de tu resumen y el orden de las pestañas." }, "medications": { - "title": "Medicamentos", - "description": "Vista de lista, orden y zonas de inyección de tus medicamentos.", "subtitle": "Gestiona la vista y el orden de tu lista de medicamentos." }, "mood": { - "title": "Etiquetas de ánimo", - "description": "Grupos, etiquetas y etiquetas archivadas del selector de ánimo.", "subtitle": "Organiza tus etiquetas de ánimo, grupos y orden del selector." }, "labs": { - "title": "Laboratorio", - "description": "Vista, orden y biomarcadores de tu laboratorio.", "subtitle": "Gestiona la vista, el orden y los biomarcadores." }, "illness": { - "title": "Diario de enfermedad", - "description": "Vista y orden de tus episodios de enfermedad.", "subtitle": "Gestiona la vista y el orden de los episodios." }, "vorsorge": { - "title": "Revisiones", - "description": "Vista, orden y recordatorios de revisión individuales.", "subtitle": "Gestiona la vista, el orden y los recordatorios individuales." }, "modules": { "title": "Módulos", - "saved": "Guardado.", + "saved": "Guardado", "error": "No se pudo guardar. Inténtalo de nuevo.", "toggleable": { "title": "Qué registras", @@ -5223,7 +5217,7 @@ "use": "Usar", "select": "Seleccionar", "saveError": "No se pudo guardar el cambio.", - "homeSaved": "Ubicación principal guardada.", + "homeSaved": "Ubicación principal guardada", "travelAdded": "Periodo de ubicación añadido.", "travelRemoved": "Periodo de ubicación eliminado.", "backfillQueued": "Relleno programado.", @@ -5280,7 +5274,7 @@ "current": "Tu selección guardada" }, "save": "Guardar", - "saved": "Guardado.", + "saved": "Guardado", "error": "No se pudo guardar. Inténtalo de nuevo.", "loadFailed": "No se pudo cargar tu selección.", "eligibilityFailed": "No se pudo comprobar qué pilares tienen datos ahora mismo. Los interruptores de abajo siguen funcionando.", @@ -5318,7 +5312,7 @@ "auto": "Automático (idioma)", "h24": "24 horas", "h12": "12 horas (AM/PM)", - "saved": "Formato horario guardado.", + "saved": "Formato horario guardado", "saveError": "No se pudo guardar el formato horario." }, "dateFormat": { @@ -5328,7 +5322,7 @@ "dmy": "DD.MM.AAAA", "mdy": "MM/DD/AAAA", "ymd": "AAAA-MM-DD", - "saved": "Formato de fecha guardado.", + "saved": "Formato de fecha guardado", "saveError": "No se pudo guardar el formato de fecha." }, "username": "Nombre de usuario", @@ -5622,7 +5616,7 @@ "clearBody": "Solo se elimina el texto libre. Tus alergias e intolerancias permanecen tal cual.", "clearConfirm": "Vaciar nota", "save": "Guardar", - "savedToast": "Nota «Sobre mí» guardada.", + "savedToast": "Nota «Sobre mí» guardada", "clearedToast": "Nota «Sobre mí» eliminada.", "saveError": "No se pudo guardar. Inténtalo de nuevo.", "loadError": "No se pudo cargar la nota.", @@ -5644,14 +5638,14 @@ "title": "Leer documentos automáticamente con IA", "subLabel": "Los documentos son leídos e indexados por tu proveedor de IA configurado, sin confirmar cada documento.", "honesty": "Cuando está activado, cada documento que subes se envía a tu proveedor de IA configurado para leerlo, describirlo e indexarlo para la búsqueda, sin confirmación por documento. Activarlo también actúa de forma retroactiva: los documentos que ya has subido y que aún no tienen resumen también se envían, hasta 200 cada vez que lo activas. Esto ocurre en el servidor y el contenido del documento sale de esta máquina hacia ese proveedor. Si tu proveedor configurado es una conexión de suscripción (una cuenta de IA con sesión iniciada en lugar de una clave de API), se aplican sus propios ajustes de datos: los proveedores de suscripción pueden usar el contenido para mejorar sus modelos, y ningún acuerdo de tratamiento de datos lo cubre. Los documentos leídos por un modelo local autoalojado nunca salen de tu servidor. Desactivado de forma predeterminada; actívalo solo si aceptas este compromiso para tus documentos.", - "confirm": "Lo entiendo: activar la lectura automática con IA", + "confirm": "Entendido: activar", "cancel": "Cancelar" }, "centralCodex": { "title": "Usar el acceso de IA compartido del servidor", "subLabel": "Enruta las solicitudes de IA a través de la conexión que el operador configuró para este servidor, junto a tus propios proveedores como alternativa.", "honesty": "El acceso compartido es una única cuenta de IA con sesión iniciada que el operador conectó para todos en este servidor, no tu propia clave. Las solicitudes que hagas se usan de forma predeterminada para entrenar los modelos del proveedor y ningún acuerdo de tratamiento de datos las cubre, los límites por hora y semanales de la cuenta se comparten con todos los demás que se han unido, y tus propios proveedores se prueban primero. Actívalo solo si aceptas este compromiso.", - "confirm": "Entendido: usar la conexión compartida", + "confirm": "Entendido: usar la conexión", "cancel": "Cancelar" }, "consent": { @@ -5673,7 +5667,7 @@ "description": "Elige cómo se muestran las distancias y velocidades en los gráficos y mosaicos.", "metric": "Métrico", "imperial": "Imperial", - "saved": "Preferencia de unidades guardada.", + "saved": "Preferencia de unidades guardada", "saveError": "No se pudo guardar tu preferencia de unidades." } }, @@ -5861,7 +5855,7 @@ "errorReconnect": "Error — reconectar", "warningServerError": "Sincronización con errores", "failureCount": "{count}/{threshold}", - "parkedReconnect": "Pausado — reconectar manualmente", + "parkedReconnect": "Pausado — reconectar", "notConnected": "No conectado", "justNow": "ahora mismo", "minutesAgo": "hace {count} min", @@ -5945,6 +5939,7 @@ "repository": "Código fuente", "changelog": "Changelog", "docs": "Documentación", + "credits": "Créditos y atribuciones de datos", "linksHeading": "Fuentes y documentación", "newerAvailable": "Nueva versión: {tag}" }, @@ -6038,7 +6033,7 @@ "expiryInvalid": "La caducidad debe estar entre 1 y {max} días.", "create": "Crear enlace", "tokenCreated": "Enlace para compartir creado", - "copied": "Copiado al portapapeles.", + "copied": "Copiado al portapapeles", "activeTitle": "Enlaces activos", "noActive": "No hay enlaces para compartir activos.", "created": "Creado", @@ -6190,7 +6185,7 @@ "revokeSession": "Cerrar sesión", "revokeError": "No se pudo completar la solicitud. Inténtalo de nuevo.", "signOutEverywhere": "Cerrar sesión en los demás dispositivos", - "signOutEverywhereDone": "Se cerraron {count} sesión(es).", + "signOutEverywhereDone": "Se cerraron {count} sesión(es)", "activityTitle": "Actividad de seguridad", "activityDescription": "Inicios de sesión, cambios de seguridad y exportaciones recientes de tu cuenta.", "activityEmpty": "Aún no hay actividad de seguridad.", @@ -6271,7 +6266,7 @@ "revokeError": "No se pudo revocar el dispositivo. Inténtalo de nuevo.", "revoke": "Revocar", "revokeAll": "Olvidar todos los dispositivos", - "revokeAllDone": "Se olvidaron {count} dispositivo(s) de confianza.", + "revokeAllDone": "Se olvidaron {count} dispositivo(s) de confianza", "unnamed": "Dispositivo desconocido", "current": "Este dispositivo", "expires": "De confianza hasta el {date}", @@ -6596,13 +6591,13 @@ "maxUsesLabel": "Usos máximos", "maxUsesHint": "Cuántos registros admite este enlace (1–50).", "createConfirm": "Crear invitación", - "createdToast": "Invitación creada.", + "createdToast": "Invitación creada", "createError": "No se pudo crear la invitación.", "tokenTitle": "Enlace de invitación", "tokenOnceHint": "Solo se puede copiar ahora: copia el enlace o deja que escaneen el código QR.", "hashExplainer": "Solo se guarda un hash del enlace, por lo que no se puede volver a mostrar. Si se pierde, revoca la invitación y crea una nueva.", "copyLink": "Copiar enlace", - "copiedToast": "Enlace copiado.", + "copiedToast": "Enlace copiado", "copyError": "No se pudo copiar.", "qrAlt": "Código QR del enlace de invitación", "loadError": "No se pudieron cargar las invitaciones.", @@ -7345,7 +7340,7 @@ "saveError": "No se pudo guardar. Inténtalo de nuevo.", "hourLabel": "Hora del recordatorio", "hourAria": "Hora del recordatorio de ánimo", - "hourSavedToast": "Hora del recordatorio actualizada.", + "hourSavedToast": "Hora del recordatorio actualizada", "detail": "Se envía solo por canales ya configurados (Telegram, ntfy, Web Push, Apple Push) y permanece en silencio una vez que has registrado el ánimo hoy." }, "coachNudge": { @@ -7386,8 +7381,8 @@ "leadLabel": "Plazo de reposición (días)", "leadAria": "Plazo de reposición en días", "leadHelp": "El aviso se envía estos días más un intervalo de dosis antes, para que la reposición llegue antes de tu última dosis. Un medicamento concreto puede anular este valor en su pestaña de existencias.", - "leadSavedToast": "Plazo de reposición guardado.", - "savedToast": "Umbral de existencias guardado.", + "leadSavedToast": "Plazo de reposición guardado", + "savedToast": "Umbral de existencias guardado", "enabledToast": "Avisos de existencias bajas activados.", "disabledToast": "Avisos de existencias bajas desactivados.", "saveError": "No se pudo guardar. Inténtalo de nuevo.", @@ -8047,10 +8042,10 @@ "protected": "Si se usó anticoncepción durante las relaciones.", "protectedLabel": "¿Qué significa protegido?" }, - "saveSuccess": "Entrada guardada.", - "deleteSuccess": "Entrada eliminada.", - "periodStartSaved": "Inicio del periodo guardado.", - "periodEndSaved": "Fin del periodo guardado.", + "saveSuccess": "Entrada guardada", + "deleteSuccess": "Entrada eliminada", + "periodStartSaved": "Inicio del periodo guardado", + "periodEndSaved": "Fin del periodo guardado", "startedPeriodOnDate": "Mi periodo empezó este día", "endedPeriodOnDate": "Mi periodo terminó este día" }, @@ -9311,7 +9306,7 @@ "freeText": { "label": "Nota de texto libre (alergias e intolerancias)", "hint": "Un complemento de texto libre a la lista estructurada de arriba, para contexto que una entrada individual no puede reflejar.", - "savedToast": "Nota de alergias guardada.", + "savedToast": "Nota de alergias guardada", "saveError": "No se pudo guardar. Inténtalo de nuevo." } }, @@ -9370,7 +9365,7 @@ "conditionsLabel": "Enfermedades crónicas", "focusLabel": "Qué vigila el Coach", "loadError": "No se pudieron cargar tus enfermedades.", - "savedToast": "Enfermedades guardadas.", + "savedToast": "Enfermedades guardadas", "saveError": "No se pudo guardar. Inténtalo de nuevo." }, "emergency": { @@ -9474,7 +9469,7 @@ }, "toast": { "duplicate": "Ya estaba guardado — se resalta el documento existente.", - "deleted": "Documento eliminado.", + "deleted": "Documento eliminado", "restoreFailed": "No se pudo restaurar el documento." }, "timeline": { @@ -9647,9 +9642,9 @@ "unverifiedNoteOther": "{count} valores aún necesitan comprobación y quedan pendientes.", "finish": "Terminar revisión", "confirmFailed": "No se pudo guardar la revisión. Inténtalo de nuevo.", - "savedToastOne": "1 valor guardado.", - "savedToastFew": "{count} valores guardados.", - "savedToastOther": "{count} valores guardados.", + "savedToastOne": "1 valor guardado", + "savedToastFew": "{count} valores guardados", + "savedToastOther": "{count} valores guardados", "nothingSavedToast": "Revisión guardada. No se añadió ningún valor.", "extract": "Extraer valores de laboratorio", "extracting": "Extrayendo…", @@ -10190,7 +10185,7 @@ "errorRateLimit": "Demasiados perfiles creados en la última hora. Espera un poco y vuelve a intentarlo." }, "toast": { - "savedTo": "Guardado en el historial de {name}." + "savedTo": "Guardado en el historial de {name}" }, "activityVerb": { "measurementCreate": "{name} añadió una medición", diff --git a/messages/fr.json b/messages/fr.json index 821569cf0..8243162db 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -560,7 +560,7 @@ "viewAll": "Tout voir", "empty": { "title": "Aucun entraînement", - "cta": "Ouvre l'app Apple Santé sur ton iPhone pour synchroniser les entraînements." + "cta": "Connecte une source de données dans les réglages pour afficher les entraînements ici." } }, "metric": { @@ -729,9 +729,12 @@ "sourceWhoop": "WHOOP", "sourceFitbit": "Fitbit", "bulkDeleteSuccess": "{count} mesures supprimées", + "bulkDeleteSuccessOne": "Mesure supprimée", "bulkDeleteError": "Erreur lors de la suppression des mesures sélectionnées", "bulkDeleteConfirmTitle": "Supprimer {count} mesures ?", + "bulkDeleteConfirmTitleOne": "Supprimer cette mesure ?", "bulkDeleteConfirmBody": "Les {count} mesures sélectionnées seront supprimées. Vous pourrez annuler cette action pendant un court instant juste après.", + "bulkDeleteConfirmBodyOne": "La mesure sélectionnée sera supprimée. Vous pourrez annuler cette action pendant un court instant juste après.", "typePhq9Score": "Score PHQ-9", "typeGad7Score": "Score GAD-7", "typeGripStrength": "Force de préhension", @@ -879,9 +882,12 @@ "sourceTelegram": "Telegram", "sourceDaylio": "Daylio", "bulkDeleteSuccess": "{count} entrées supprimées", + "bulkDeleteSuccessOne": "Entrée supprimée", "bulkDeleteError": "Erreur lors de la suppression des entrées sélectionnées", "bulkDeleteConfirmTitle": "Supprimer {count} entrées ?", + "bulkDeleteConfirmTitleOne": "Supprimer cette entrée ?", "bulkDeleteConfirmBody": "Les {count} entrées d'humeur sélectionnées seront supprimées. Vous pourrez annuler cette action pendant un court instant juste après.", + "bulkDeleteConfirmBodyOne": "L'entrée d'humeur sélectionnée sera supprimée. Vous pourrez annuler cette action pendant un court instant juste après.", "customize": "Personnaliser les étiquettes d’humeur", "addTagInline": "Ajouter une étiquette", "manage": { @@ -1779,7 +1785,8 @@ "enabledToast": "Rappels activés", "disabledToast": "Rappels désactivés", "toggleFailed": "Impossible de modifier le réglage des rappels.", - "clientManagedChip": "Votre iPhone gère les rappels de ce médicament." + "clientManagedChip": "Géré par l’iPhone", + "clientManagedHint": "Votre iPhone gère les rappels de ce médicament." }, "settings": { "title": "Réglages", @@ -1932,7 +1939,7 @@ "addQuantityHelper": "1–1000 unités par boîte ou contenant.", "addExpiryLabel": "Date de péremption (facultatif)", "addSubmit": "Enregistrer", - "addSuccess": "Stock enregistré.", + "addSuccess": "Stock enregistré", "addFailed": "Le stock n'a pas pu être enregistré.", "adjustButton": "Ajuster", "adjustTitle": "Corriger le contenant", @@ -1940,11 +1947,11 @@ "adjustQuantityLabel": "Unités restantes", "adjustHelper": "Capacité : {total} unités.", "adjustSubmit": "Enregistrer", - "adjustSuccess": "Stock mis à jour.", + "adjustSuccess": "Stock mis à jour", "adjustFailed": "Le stock n'a pas pu être mis à jour.", "deleteTitle": "Supprimer le contenant ?", "deleteDescription": "Supprime définitivement cette boîte ou ce contenant du stock. Les prises enregistrées restent inchangées.", - "deleteSuccess": "Entrée de stock supprimée.", + "deleteSuccess": "Entrée de stock supprimée", "deleteFailed": "L'entrée de stock n'a pas pu être supprimée.", "containerTypeLabel": "Type de contenant", "containerType": { @@ -2139,7 +2146,7 @@ "scaleToggleLabel": "Échelle du graphique" }, "import": { - "resultSuccess": "{imported} prises importées.", + "resultSuccess": "{imported} prises importées", "resultPartial": "{imported} importées, {skipped} ignorées.", "resultNothing": "Rien n’a été importé : les {skipped} entrées ont été ignorées.", "resultAlreadyRecorded": "{recorded} prises sont déjà enregistrées. Rien de nouveau n’a été ajouté.", @@ -2776,6 +2783,7 @@ "settingsVerbosityBrief": "Bref", "settingsVerbosityDefault": "Standard", "settingsVerbosityDetailed": "Détaillé", + "settingsVerbosityConciseHint": "Le ton concis garde déjà les réponses courtes — la longueur reste fixée sur « Bref » tant qu'il est actif.", "settingsSourcesPointer": "Les groupes de données que le coach peut utiliser et la fenêtre d'analyse par défaut se gèrent directement dans le chat du coach (volet « Ce sur quoi je m'appuie »).", "settingsSourcesPointerLink": "Ouvrir le coach", "settingsExcludeLabel": "Exclure des métriques", @@ -2787,7 +2795,7 @@ "settingsDefaultWindowLabel": "Période d’analyse par défaut", "settingsDefaultWindowHint": "Le coach lit cette période par chat, sauf si tu en choisis une autre dans l’en-tête du panneau.", "settingsSave": "Enregistrer", - "settingsSaved": "Enregistré.", + "settingsSaved": "Enregistré", "settingsCancel": "Annuler", "feedbackHelpful": "Utile", "feedbackUnhelpful": "Peu utile", @@ -4162,7 +4170,7 @@ "title": "Trier les onglets de navigation", "description": "Faites glisser les onglets dans l'ordre souhaité et affichez-les ou masquez-les via l'icône en forme d'œil.", "dragHandle": "Réorganiser", - "saveSuccess": "Ordre des onglets enregistré.", + "saveSuccess": "Ordre des onglets enregistré", "saveError": "L'enregistrement de l'ordre des onglets a échoué.", "save": "Enregistrer", "empty": "Aucun onglet de navigation disponible pour le moment.", @@ -4833,8 +4841,6 @@ } }, "dashboard": { - "title": "Tableau de bord", - "description": "Disposition et ordre des tuiles.", "subtitle": "Organisez les tuiles de votre tableau de bord." }, "thresholds": { @@ -4855,9 +4861,9 @@ "resetDefaultsConfirm": "Réinitialiser l’ordre", "moveUp": "Monter", "moveDown": "Descendre", - "saveSuccess": "Priorité des sources enregistrée.", + "saveSuccess": "Priorité des sources enregistrée", "saveError": "Échec de l’enregistrement de la priorité des sources.", - "resetSuccess": "Priorité des sources rétablie aux valeurs par défaut.", + "resetSuccess": "Priorité des sources rétablie aux valeurs par défaut", "perMetricToggle": "Réglages par métrique ({count})", "perMetricHelp": "Métriques que vous avez personnalisées par rapport à l’ordre par défaut. Utilisez cette liste pour rétablir une métrique donnée à sa valeur par défaut sans toucher aux autres.", "perMetricEmpty": "Aucun réglage par métrique — chaque métrique utilise l’ordre global ci-dessus.", @@ -5026,7 +5032,7 @@ "failed": "L'import a échoué. Veuillez réessayer.", "readFailed": "Impossible de lire ce fichier.", "resultSummary": "{measurements} mesures et {moods} entrées d'humeur importées, {skipped} ignorées.", - "resultSuccess": "{measurements} mesures et {moods} entrées d'humeur importées.", + "resultSuccess": "{measurements} mesures et {moods} entrées d'humeur importées", "resultNothing": "Rien n'a été importé. Les {skipped} entrées ont toutes été ignorées.", "resultEmpty": "Ce fichier ne contenait ni mesures ni entrées d'humeur." }, @@ -5050,10 +5056,10 @@ "previewSummary": "Aperçu : {inserted} seraient importées, {skipped} ignorées.", "resultSummary": "{inserted} nouvelles importées et {updated} mises à jour, {skipped} ignorées.", "rowError": "Ligne {line} : {reason}", - "resultSuccess": "{inserted} nouvelles importées et {updated} mises à jour.", + "resultSuccess": "{inserted} nouvelles importées et {updated} mises à jour", "resultNothing": "Rien n'a été importé. Les {skipped} lignes ont toutes été ignorées.", "resultEmpty": "Ce fichier a un en-tête mais aucune ligne de données.", - "previewSuccess": "Aperçu : {inserted} seraient importées.", + "previewSuccess": "Aperçu : {inserted} seraient importées", "previewNothing": "Aperçu : rien ne serait importé. Les {skipped} lignes seraient toutes ignorées.", "skipGroupOne": "{count} ligne ignorée : {reason}", "skipGroupFew": "{count} lignes ignorées : {reason}", @@ -5144,38 +5150,26 @@ "subtitle": "Partagez un accès en lecture seule à vos données." }, "insights": { - "title": "Analyses", - "description": "Ordre de la vue d'ensemble et des onglets de navigation.", - "subtitle": "Organisez les sections d'aperçu et l'ordre des pastilles." + "subtitle": "Organisez les sections d'aperçu et l'ordre des onglets." }, "medications": { - "title": "Médicaments", - "description": "Vue liste, ordre et sites d’injection de vos médicaments.", "subtitle": "Gérez l'affichage et l'ordre de votre liste de médicaments." }, "mood": { - "title": "Étiquettes d’humeur", - "description": "Groupes, étiquettes et étiquettes archivées du sélecteur d’humeur.", "subtitle": "Organisez vos tags d'humeur, groupes et ordre du sélecteur." }, "labs": { - "title": "Analyses", - "description": "Affichage, tri et biomarqueurs de vos analyses.", "subtitle": "Gérez l’affichage, le tri et les biomarqueurs." }, "illness": { - "title": "Journal de maladie", - "description": "Affichage et ordre de vos épisodes de maladie.", "subtitle": "Gérez l’affichage et l’ordre des épisodes." }, "vorsorge": { - "title": "Examens", - "description": "Affichage, ordre et rappels d’examen individuels.", "subtitle": "Gérez l’affichage, l’ordre et les rappels individuels." }, "modules": { "title": "Modules", - "saved": "Enregistré.", + "saved": "Enregistré", "error": "Échec de l'enregistrement. Veuillez réessayer.", "toggleable": { "title": "Ce que vous suivez", @@ -5223,7 +5217,7 @@ "use": "Utiliser", "select": "Sélectionner", "saveError": "Impossible d'enregistrer la modification.", - "homeSaved": "Lieu de référence enregistré.", + "homeSaved": "Lieu de référence enregistré", "travelAdded": "Période de localisation ajoutée.", "travelRemoved": "Période de localisation supprimée.", "backfillQueued": "Récupération planifiée.", @@ -5280,7 +5274,7 @@ "current": "Votre sélection enregistrée" }, "save": "Enregistrer", - "saved": "Enregistré.", + "saved": "Enregistré", "error": "Enregistrement impossible. Veuillez réessayer.", "loadFailed": "Impossible de charger votre sélection.", "eligibilityFailed": "Impossible de vérifier quels piliers disposent de données. Les interrupteurs ci-dessous fonctionnent quand même.", @@ -5318,7 +5312,7 @@ "auto": "Automatique (langue)", "h24": "24 heures", "h12": "12 heures (AM/PM)", - "saved": "Format horaire enregistré.", + "saved": "Format horaire enregistré", "saveError": "Échec de l’enregistrement du format horaire." }, "dateFormat": { @@ -5328,7 +5322,7 @@ "dmy": "JJ.MM.AAAA", "mdy": "MM/JJ/AAAA", "ymd": "AAAA-MM-JJ", - "saved": "Format de date enregistré.", + "saved": "Format de date enregistré", "saveError": "Échec de l’enregistrement du format de date." }, "username": "Nom d'utilisateur", @@ -5622,7 +5616,7 @@ "clearBody": "Seul le texte libre est supprimé. Vos allergies et intolérances restent inchangées.", "clearConfirm": "Vider la note", "save": "Enregistrer", - "savedToast": "Note « À propos de moi » enregistrée.", + "savedToast": "Note « À propos de moi » enregistrée", "clearedToast": "Note « À propos de moi » supprimée.", "saveError": "Impossible d'enregistrer. Veuillez réessayer.", "loadError": "Impossible de charger la note.", @@ -5644,14 +5638,14 @@ "title": "Lire les documents automatiquement avec l'IA", "subLabel": "Les documents sont lus et indexés par votre fournisseur d'IA configuré, sans validation document par document.", "honesty": "Lorsque cette option est activée, chaque document que vous téléversez est envoyé à votre fournisseur d'IA configuré pour être lu, décrit et indexé pour la recherche, sans confirmation par document. L'activation agit aussi rétroactivement : les documents que vous avez déjà téléversés et qui n'ont pas encore de résumé sont envoyés eux aussi, jusqu'à 200 à chaque activation. Cela se produit sur le serveur et le contenu du document quitte cette machine vers ce fournisseur. Si votre fournisseur configuré est une connexion par abonnement (un compte d'IA connecté plutôt qu'une clé API), ses propres paramètres de données s'appliquent : les fournisseurs par abonnement peuvent utiliser le contenu pour améliorer leurs modèles, et aucun accord de traitement des données ne le couvre. Les documents lus par un modèle local auto-hébergé ne quittent jamais votre serveur. Désactivé par défaut ; activez-le uniquement si vous acceptez ce compromis pour vos documents.", - "confirm": "Je comprends — activer la lecture automatique par l'IA", + "confirm": "Compris — activer", "cancel": "Annuler" }, "centralCodex": { "title": "Utiliser l'accès IA partagé du serveur", "subLabel": "Acheminez les requêtes IA via la connexion que l'opérateur a configurée pour ce serveur, en complément de vos propres fournisseurs comme solution de repli.", "honesty": "L'accès partagé est un unique compte IA connecté que l'opérateur a relié pour tout le monde sur ce serveur, et non votre propre clé. Les requêtes que vous y faites servent par défaut à entraîner les modèles du fournisseur et aucun accord de traitement des données ne les couvre, les limites horaires et hebdomadaires du compte sont partagées avec tous les autres qui ont accepté, et vos propres fournisseurs sont essayés en premier. Ne l'activez que si vous acceptez ce compromis.", - "confirm": "J'ai compris — utiliser la connexion partagée", + "confirm": "Compris — utiliser la connexion", "cancel": "Annuler" }, "consent": { @@ -5673,7 +5667,7 @@ "description": "Choisissez l'affichage des distances et des vitesses dans les graphiques et les tuiles.", "metric": "Métrique", "imperial": "Impérial", - "saved": "Préférence d'unités enregistrée.", + "saved": "Préférence d'unités enregistrée", "saveError": "Impossible d'enregistrer votre préférence d'unités." } }, @@ -5861,7 +5855,7 @@ "errorReconnect": "Erreur — reconnecter", "warningServerError": "Échec de synchronisation", "failureCount": "{count}/{threshold}", - "parkedReconnect": "En pause — reconnecter manuellement", + "parkedReconnect": "En pause — reconnecter", "notConnected": "Non connecté", "justNow": "à l’instant", "minutesAgo": "il y a {count} min", @@ -5945,6 +5939,7 @@ "repository": "Code source", "changelog": "Changelog", "docs": "Documentation", + "credits": "Crédits et attributions de données", "linksHeading": "Sources et documentation", "newerAvailable": "Nouvelle version : {tag}" }, @@ -6038,7 +6033,7 @@ "expiryInvalid": "L'expiration doit être comprise entre 1 et {max} jours.", "create": "Créer le lien", "tokenCreated": "Lien de partage créé", - "copied": "Copié dans le presse-papiers.", + "copied": "Copié dans le presse-papiers", "activeTitle": "Liens actifs", "noActive": "Aucun lien de partage actif.", "created": "Créé", @@ -6190,7 +6185,7 @@ "revokeSession": "Déconnecter", "revokeError": "Impossible de traiter la demande. Veuillez réessayer.", "signOutEverywhere": "Se déconnecter partout ailleurs", - "signOutEverywhereDone": "{count} autre(s) session(s) déconnectée(s).", + "signOutEverywhereDone": "{count} autre(s) session(s) déconnectée(s)", "activityTitle": "Activité de sécurité", "activityDescription": "Connexions récentes, modifications de sécurité et exports de données de votre compte.", "activityEmpty": "Aucune activité de sécurité pour l'instant.", @@ -6271,7 +6266,7 @@ "revokeError": "Impossible de révoquer l'appareil. Veuillez réessayer.", "revoke": "Révoquer", "revokeAll": "Oublier tous les appareils", - "revokeAllDone": "{count} appareil(s) de confiance oublié(s).", + "revokeAllDone": "{count} appareil(s) de confiance oublié(s)", "unnamed": "Appareil inconnu", "current": "Cet appareil", "expires": "De confiance jusqu'au {date}", @@ -6596,13 +6591,13 @@ "maxUsesLabel": "Utilisations maximales", "maxUsesHint": "Nombre d'inscriptions que ce lien autorise (1–50).", "createConfirm": "Créer l'invitation", - "createdToast": "Invitation créée.", + "createdToast": "Invitation créée", "createError": "Impossible de créer l'invitation.", "tokenTitle": "Lien d'invitation", "tokenOnceHint": "Copiable uniquement maintenant — copiez le lien ou faites scanner le code QR.", "hashExplainer": "Seul un hachage du lien est stocké : il ne pourra plus être affiché. En cas de perte, révoquez l'invitation et créez-en une nouvelle.", "copyLink": "Copier le lien", - "copiedToast": "Lien copié.", + "copiedToast": "Lien copié", "copyError": "Échec de la copie.", "qrAlt": "Code QR du lien d'invitation", "loadError": "Impossible de charger les invitations.", @@ -7345,7 +7340,7 @@ "saveError": "Échec de l’enregistrement. Réessayez.", "hourLabel": "Heure du rappel", "hourAria": "Heure du rappel d’humeur", - "hourSavedToast": "Heure du rappel mise à jour.", + "hourSavedToast": "Heure du rappel mise à jour", "detail": "Utilise uniquement les canaux déjà configurés (Telegram, ntfy, Web Push, Apple Push) et reste silencieux dès que tu as noté ton humeur aujourd’hui." }, "coachNudge": { @@ -7386,8 +7381,8 @@ "leadLabel": "Délai de réapprovisionnement (jours)", "leadAria": "Délai de réapprovisionnement en jours", "leadHelp": "L’alerte est envoyée ce nombre de jours plus un intervalle de dose plus tôt, pour qu’un réapprovisionnement arrive avant ta dernière dose. Un médicament donné peut remplacer cette valeur dans son onglet stock.", - "leadSavedToast": "Délai de réapprovisionnement enregistré.", - "savedToast": "Seuil de stock enregistré.", + "leadSavedToast": "Délai de réapprovisionnement enregistré", + "savedToast": "Seuil de stock enregistré", "enabledToast": "Alertes de stock faible activées.", "disabledToast": "Alertes de stock faible désactivées.", "saveError": "Échec de l’enregistrement. Réessayez.", @@ -8047,10 +8042,10 @@ "protected": "Si une contraception a été utilisée pendant le rapport.", "protectedLabel": "Que signifie protégé ?" }, - "saveSuccess": "Entrée enregistrée.", - "deleteSuccess": "Entrée supprimée.", - "periodStartSaved": "Début des règles enregistré.", - "periodEndSaved": "Fin des règles enregistrée.", + "saveSuccess": "Entrée enregistrée", + "deleteSuccess": "Entrée supprimée", + "periodStartSaved": "Début des règles enregistré", + "periodEndSaved": "Fin des règles enregistrée", "startedPeriodOnDate": "Mes règles ont commencé ce jour-là", "endedPeriodOnDate": "Mes règles se sont terminées ce jour-là" }, @@ -9311,7 +9306,7 @@ "freeText": { "label": "Note en texte libre (allergies et intolérances)", "hint": "Un complément en texte libre à la liste structurée ci-dessus, pour un contexte qu'une entrée seule ne peut pas refléter.", - "savedToast": "Note d'allergies enregistrée.", + "savedToast": "Note d'allergies enregistrée", "saveError": "Impossible d'enregistrer. Veuillez réessayer." } }, @@ -9370,7 +9365,7 @@ "conditionsLabel": "Affections chroniques", "focusLabel": "Ce que le Coach surveille", "loadError": "Impossible de charger vos antécédents.", - "savedToast": "Affections enregistrées.", + "savedToast": "Affections enregistrées", "saveError": "Impossible d'enregistrer. Veuillez réessayer." }, "emergency": { @@ -9474,7 +9469,7 @@ }, "toast": { "duplicate": "Déjà enregistré — le document existant est mis en évidence.", - "deleted": "Document supprimé.", + "deleted": "Document supprimé", "restoreFailed": "Impossible de restaurer le document." }, "timeline": { @@ -9647,9 +9642,9 @@ "unverifiedNoteOther": "{count} valeurs restent à vérifier et demeurent en attente.", "finish": "Terminer la vérification", "confirmFailed": "La vérification n’a pas pu être enregistrée. Réessayez.", - "savedToastOne": "1 valeur enregistrée.", - "savedToastFew": "{count} valeurs enregistrées.", - "savedToastOther": "{count} valeurs enregistrées.", + "savedToastOne": "1 valeur enregistrée", + "savedToastFew": "{count} valeurs enregistrées", + "savedToastOther": "{count} valeurs enregistrées", "nothingSavedToast": "Vérification enregistrée. Aucune valeur n’a été ajoutée.", "extract": "Extraire les valeurs de laboratoire", "extracting": "Extraction…", @@ -10190,7 +10185,7 @@ "errorRateLimit": "Trop de profils créés dans la dernière heure. Patientez un peu et réessayez." }, "toast": { - "savedTo": "Enregistré dans le dossier de {name}." + "savedTo": "Enregistré dans le dossier de {name}" }, "activityVerb": { "measurementCreate": "{name} a ajouté une mesure", diff --git a/messages/it.json b/messages/it.json index b60ec764f..b4c8313c9 100644 --- a/messages/it.json +++ b/messages/it.json @@ -560,7 +560,7 @@ "viewAll": "Vedi tutti", "empty": { "title": "Nessun allenamento", - "cta": "Apri l'app Salute di Apple sull'iPhone per sincronizzare gli allenamenti." + "cta": "Collega una fonte di dati nelle impostazioni per vedere qui gli allenamenti." } }, "metric": { @@ -729,9 +729,12 @@ "sourceWhoop": "WHOOP", "sourceFitbit": "Fitbit", "bulkDeleteSuccess": "{count} misurazioni eliminate", + "bulkDeleteSuccessOne": "Misurazione eliminata", "bulkDeleteError": "Errore durante l'eliminazione delle misurazioni selezionate", "bulkDeleteConfirmTitle": "Eliminare {count} misurazioni?", + "bulkDeleteConfirmTitleOne": "Eliminare questa misurazione?", "bulkDeleteConfirmBody": "Le {count} misurazioni selezionate verranno eliminate. Subito dopo potrai annullare l’operazione per un breve momento.", + "bulkDeleteConfirmBodyOne": "La misurazione selezionata verrà eliminata. Subito dopo potrai annullare l’operazione per un breve momento.", "typePhq9Score": "Punteggio PHQ-9", "typeGad7Score": "Punteggio GAD-7", "typeGripStrength": "Forza di presa", @@ -879,9 +882,12 @@ "sourceTelegram": "Telegram", "sourceDaylio": "Daylio", "bulkDeleteSuccess": "{count} voci eliminate", + "bulkDeleteSuccessOne": "Voce eliminata", "bulkDeleteError": "Errore durante l'eliminazione delle voci selezionate", "bulkDeleteConfirmTitle": "Eliminare {count} voci?", + "bulkDeleteConfirmTitleOne": "Eliminare questa voce?", "bulkDeleteConfirmBody": "Le {count} voci dell'umore selezionate verranno eliminate. Subito dopo potrai annullare l’operazione per un breve momento.", + "bulkDeleteConfirmBodyOne": "La voce dell'umore selezionata verrà eliminata. Subito dopo potrai annullare l’operazione per un breve momento.", "customize": "Personalizza i tag dell’umore", "addTagInline": "Aggiungi tag", "manage": { @@ -1779,7 +1785,8 @@ "enabledToast": "Promemoria attivi", "disabledToast": "Promemoria disattivati", "toggleFailed": "Impossibile modificare l’impostazione dei promemoria.", - "clientManagedChip": "Il tuo iPhone gestisce i promemoria di questo farmaco." + "clientManagedChip": "Gestito dall’iPhone", + "clientManagedHint": "Il tuo iPhone gestisce i promemoria di questo farmaco." }, "settings": { "title": "Impostazioni", @@ -1932,7 +1939,7 @@ "addQuantityHelper": "1–1000 unità per confezione o contenitore.", "addExpiryLabel": "Data di scadenza (facoltativa)", "addSubmit": "Registra", - "addSuccess": "Scorta registrata.", + "addSuccess": "Scorta registrata", "addFailed": "Impossibile registrare la scorta.", "adjustButton": "Correggi", "adjustTitle": "Correggi il contenitore", @@ -1940,11 +1947,11 @@ "adjustQuantityLabel": "Unità rimanenti", "adjustHelper": "Capacità: {total} unità.", "adjustSubmit": "Salva", - "adjustSuccess": "Scorta aggiornata.", + "adjustSuccess": "Scorta aggiornata", "adjustFailed": "Impossibile aggiornare la scorta.", "deleteTitle": "Eliminare la confezione?", "deleteDescription": "Rimuove definitivamente questa confezione o contenitore dalla scorta. Le assunzioni registrate restano invariate.", - "deleteSuccess": "Scorta eliminata.", + "deleteSuccess": "Scorta eliminata", "deleteFailed": "Impossibile eliminare la scorta.", "containerTypeLabel": "Tipo di contenitore", "containerType": { @@ -2139,7 +2146,7 @@ "scaleToggleLabel": "Scala del grafico" }, "import": { - "resultSuccess": "{imported} assunzioni importate.", + "resultSuccess": "{imported} assunzioni importate", "resultPartial": "{imported} importate, {skipped} ignorate.", "resultNothing": "Non è stato importato nulla: tutte le {skipped} voci sono state ignorate.", "resultAlreadyRecorded": "{recorded} assunzioni sono già registrate. Non è stato aggiunto nulla di nuovo.", @@ -2776,6 +2783,7 @@ "settingsVerbosityBrief": "Breve", "settingsVerbosityDefault": "Standard", "settingsVerbosityDetailed": "Dettagliata", + "settingsVerbosityConciseHint": "Il tono conciso mantiene già le risposte brevi: la verbosità resta fissata su «Breve» finché è attivo.", "settingsSourcesPointer": "I gruppi di dati che il coach può usare e la finestra di analisi predefinita si gestiscono direttamente nella chat del coach (pannello «Su cosa mi baso»).", "settingsSourcesPointerLink": "Apri il coach", "settingsExcludeLabel": "Escludi metriche", @@ -2787,7 +2795,7 @@ "settingsDefaultWindowLabel": "Periodo di analisi predefinito", "settingsDefaultWindowHint": "Il coach legge questo periodo per chat, salvo che tu non scelga diversamente nell’intestazione del drawer.", "settingsSave": "Salva", - "settingsSaved": "Salvato.", + "settingsSaved": "Salvato", "settingsCancel": "Annulla", "feedbackHelpful": "Utile", "feedbackUnhelpful": "Poco utile", @@ -4162,7 +4170,7 @@ "title": "Ordina le schede di navigazione", "description": "Trascina le schede nell'ordine desiderato e mostrale o nascondile con l'icona dell'occhio.", "dragHandle": "Riordina", - "saveSuccess": "Ordine delle schede salvato.", + "saveSuccess": "Ordine delle schede salvato", "saveError": "Salvataggio dell'ordine delle schede non riuscito.", "save": "Salva", "empty": "Nessuna scheda di navigazione ancora disponibile.", @@ -4833,8 +4841,6 @@ } }, "dashboard": { - "title": "Cruscotto", - "description": "Layout e ordine dei riquadri.", "subtitle": "Disponi i riquadri della tua dashboard." }, "thresholds": { @@ -4855,9 +4861,9 @@ "resetDefaultsConfirm": "Reimposta ordine", "moveUp": "Sposta su", "moveDown": "Sposta giù", - "saveSuccess": "Priorità delle fonti salvata.", + "saveSuccess": "Priorità delle fonti salvata", "saveError": "Salvataggio della priorità delle fonti non riuscito.", - "resetSuccess": "Priorità delle fonti ripristinata ai valori predefiniti.", + "resetSuccess": "Priorità delle fonti ripristinata ai valori predefiniti", "perMetricToggle": "Personalizzazioni per metrica ({count})", "perMetricHelp": "Metriche che hai personalizzato rispetto all’ordine predefinito. Usa questo elenco per riportare una singola metrica al suo valore predefinito senza toccare le altre.", "perMetricEmpty": "Nessuna personalizzazione per metrica — ogni metrica usa l’ordine globale qui sopra.", @@ -5026,7 +5032,7 @@ "failed": "L'importazione non è riuscita. Riprova.", "readFailed": "Impossibile leggere il file.", "resultSummary": "{measurements} misurazioni e {moods} voci dell'umore importate, {skipped} saltate.", - "resultSuccess": "{measurements} misurazioni e {moods} voci dell'umore importate.", + "resultSuccess": "{measurements} misurazioni e {moods} voci dell'umore importate", "resultNothing": "Non è stato importato nulla. Tutte le {skipped} voci sono state saltate.", "resultEmpty": "Questo file non conteneva né misurazioni né voci dell'umore." }, @@ -5050,10 +5056,10 @@ "previewSummary": "Anteprima: {inserted} verrebbero importate, {skipped} saltate.", "resultSummary": "{inserted} nuove importate e {updated} aggiornate, {skipped} saltate.", "rowError": "Riga {line}: {reason}", - "resultSuccess": "{inserted} nuove importate e {updated} aggiornate.", + "resultSuccess": "{inserted} nuove importate e {updated} aggiornate", "resultNothing": "Non è stato importato nulla. Tutte le {skipped} righe sono state saltate.", "resultEmpty": "Questo file ha un'intestazione ma nessuna riga di dati.", - "previewSuccess": "Anteprima: {inserted} verrebbero importate.", + "previewSuccess": "Anteprima: {inserted} verrebbero importate", "previewNothing": "Anteprima: non verrebbe importato nulla. Tutte le {skipped} righe verrebbero saltate.", "skipGroupOne": "{count} riga saltata: {reason}", "skipGroupFew": "{count} righe saltate: {reason}", @@ -5144,38 +5150,26 @@ "subtitle": "Condividi l'accesso in sola lettura ai tuoi dati." }, "insights": { - "title": "Approfondimenti", - "description": "Ordine della panoramica e delle schede di navigazione.", - "subtitle": "Disponi le sezioni della panoramica e l'ordine dei pill." + "subtitle": "Disponi le sezioni della panoramica e l'ordine delle schede." }, "medications": { - "title": "Farmaci", - "description": "Vista elenco, ordine e siti di iniezione dei tuoi farmaci.", "subtitle": "Gestisci vista e ordine del tuo elenco di farmaci." }, "mood": { - "title": "Tag dell’umore", - "description": "Gruppi, tag e tag archiviati del selettore dell’umore.", "subtitle": "Organizza i tag dell'umore, i gruppi e l'ordine del selettore." }, "labs": { - "title": "Esami", - "description": "Vista, ordinamento e biomarcatori dei tuoi esami.", "subtitle": "Gestisci la vista, l’ordinamento e i biomarcatori." }, "illness": { - "title": "Diario delle malattie", - "description": "Vista e ordine dei tuoi episodi di malattia.", "subtitle": "Gestisci la vista e l’ordine degli episodi." }, "vorsorge": { - "title": "Controlli", - "description": "Vista, ordine e singoli promemoria di controllo.", "subtitle": "Gestisci la vista, l’ordine e i singoli promemoria." }, "modules": { "title": "Moduli", - "saved": "Salvato.", + "saved": "Salvato", "error": "Salvataggio non riuscito. Riprova.", "toggleable": { "title": "Cosa monitori", @@ -5223,7 +5217,7 @@ "use": "Usa", "select": "Seleziona", "saveError": "Impossibile salvare la modifica.", - "homeSaved": "Posizione principale salvata.", + "homeSaved": "Posizione principale salvata", "travelAdded": "Periodo di posizione aggiunto.", "travelRemoved": "Periodo di posizione rimosso.", "backfillQueued": "Recupero pianificato.", @@ -5280,7 +5274,7 @@ "current": "La tua selezione salvata" }, "save": "Salva", - "saved": "Salvato.", + "saved": "Salvato", "error": "Non è stato possibile salvare. Riprova.", "loadFailed": "Non è stato possibile caricare la tua selezione.", "eligibilityFailed": "Non è stato possibile verificare quali pilastri hanno dati al momento. Gli interruttori qui sotto funzionano comunque.", @@ -5318,7 +5312,7 @@ "auto": "Automatico (lingua)", "h24": "24 ore", "h12": "12 ore (AM/PM)", - "saved": "Formato orario salvato.", + "saved": "Formato orario salvato", "saveError": "Salvataggio del formato orario non riuscito." }, "dateFormat": { @@ -5328,7 +5322,7 @@ "dmy": "GG.MM.AAAA", "mdy": "MM/GG/AAAA", "ymd": "AAAA-MM-GG", - "saved": "Formato data salvato.", + "saved": "Formato data salvato", "saveError": "Salvataggio del formato data non riuscito." }, "username": "Nome utente", @@ -5622,7 +5616,7 @@ "clearBody": "Rimuove solo il testo libero. Allergie e intolleranze restano invariate.", "clearConfirm": "Svuota nota", "save": "Salva", - "savedToast": "Nota «Su di me» salvata.", + "savedToast": "Nota «Su di me» salvata", "clearedToast": "Nota «Su di me» eliminata.", "saveError": "Impossibile salvare. Riprova.", "loadError": "Impossibile caricare la nota.", @@ -5644,14 +5638,14 @@ "title": "Leggi i documenti automaticamente con l'IA", "subLabel": "I documenti vengono letti e indicizzati dal provider di IA configurato, senza confermare ogni documento.", "honesty": "Quando è attivo, ogni documento che carichi viene inviato al provider di IA configurato per essere letto, descritto e indicizzato per la ricerca, senza conferma per singolo documento. Attivarlo agisce anche retroattivamente: i documenti che hai già caricato e che non hanno ancora un riassunto vengono inviati anch'essi, fino a 200 per ogni attivazione. Questo avviene sul server e il contenuto del documento lascia questa macchina verso quel provider. Se il provider configurato è una connessione in abbonamento (un account IA con accesso effettuato anziché una chiave API), si applicano le sue impostazioni sui dati: i provider in abbonamento possono usare il contenuto per migliorare i propri modelli e nessun accordo sul trattamento dei dati lo copre. I documenti letti da un modello locale self-hosted non lasciano mai il tuo server. Disattivato per impostazione predefinita; attivalo solo se accetti questo compromesso per i tuoi documenti.", - "confirm": "Ho capito: attiva la lettura automatica con l'IA", + "confirm": "Ho capito — attiva", "cancel": "Annulla" }, "centralCodex": { "title": "Usa l'accesso IA condiviso del server", "subLabel": "Instrada le richieste IA attraverso la connessione che l'operatore ha configurato per questo server, insieme ai tuoi provider come ripiego.", "honesty": "L'accesso condiviso è un unico account IA con accesso eseguito che l'operatore ha collegato per tutti su questo server, non la tua chiave. Le richieste che vi effettui vengono usate per impostazione predefinita per addestrare i modelli del provider e nessun accordo sul trattamento dei dati le copre, i limiti orari e settimanali dell'account sono condivisi con tutti gli altri che hanno aderito, e i tuoi provider vengono comunque provati per primi. Attivalo solo se accetti questo compromesso.", - "confirm": "Ho capito — usa la connessione condivisa", + "confirm": "Ho capito — usa la connessione", "cancel": "Annulla" }, "consent": { @@ -5673,7 +5667,7 @@ "description": "Scegli come vengono mostrate distanze e velocità nei grafici e nei riquadri.", "metric": "Metrico", "imperial": "Imperiale", - "saved": "Preferenza unità salvata.", + "saved": "Preferenza unità salvata", "saveError": "Impossibile salvare la preferenza delle unità." } }, @@ -5861,7 +5855,7 @@ "errorReconnect": "Errore — riconnetti", "warningServerError": "Sincronizzazione in errore", "failureCount": "{count}/{threshold}", - "parkedReconnect": "In pausa — riconnettere manualmente", + "parkedReconnect": "In pausa — riconnetti", "notConnected": "Non connesso", "justNow": "proprio ora", "minutesAgo": "{count} min fa", @@ -5945,6 +5939,7 @@ "repository": "Codice sorgente", "changelog": "Changelog", "docs": "Documentazione", + "credits": "Crediti e attribuzioni dei dati", "linksHeading": "Fonti e documentazione", "newerAvailable": "Nuova versione: {tag}" }, @@ -6038,7 +6033,7 @@ "expiryInvalid": "La scadenza deve essere tra 1 e {max} giorni.", "create": "Crea link", "tokenCreated": "Link di condivisione creato", - "copied": "Copiato negli appunti.", + "copied": "Copiato negli appunti", "activeTitle": "Link attivi", "noActive": "Nessun link di condivisione attivo.", "created": "Creato", @@ -6190,7 +6185,7 @@ "revokeSession": "Disconnetti", "revokeError": "Impossibile completare la richiesta. Riprova.", "signOutEverywhere": "Disconnetti ovunque", - "signOutEverywhereDone": "Disconnesse {count} altra/e sessione/i.", + "signOutEverywhereDone": "Disconnesse {count} altra/e sessione/i", "activityTitle": "Attività di sicurezza", "activityDescription": "Accessi recenti, modifiche di sicurezza ed esportazioni di dati del tuo account.", "activityEmpty": "Nessuna attività di sicurezza.", @@ -6271,7 +6266,7 @@ "revokeError": "Impossibile revocare il dispositivo. Riprova.", "revoke": "Revoca", "revokeAll": "Dimentica tutti i dispositivi", - "revokeAllDone": "Dimenticati {count} dispositivo/i attendibile/i.", + "revokeAllDone": "Dimenticati {count} dispositivo/i attendibile/i", "unnamed": "Dispositivo sconosciuto", "current": "Questo dispositivo", "expires": "Attendibile fino al {date}", @@ -6596,13 +6591,13 @@ "maxUsesLabel": "Utilizzi massimi", "maxUsesHint": "Quante registrazioni consente questo link (1–50).", "createConfirm": "Crea invito", - "createdToast": "Invito creato.", + "createdToast": "Invito creato", "createError": "Impossibile creare l'invito.", "tokenTitle": "Link di invito", "tokenOnceHint": "Copiabile solo adesso: copia il link o fai scansionare il codice QR.", "hashExplainer": "Viene salvato solo un hash del link: non potrà più essere mostrato. Se va perso, revoca l'invito e creane uno nuovo.", "copyLink": "Copia link", - "copiedToast": "Link copiato.", + "copiedToast": "Link copiato", "copyError": "Copia non riuscita.", "qrAlt": "Codice QR del link di invito", "loadError": "Impossibile caricare gli inviti.", @@ -7345,7 +7340,7 @@ "saveError": "Salvataggio non riuscito. Riprova.", "hourLabel": "Ora del promemoria", "hourAria": "Ora del promemoria dell’umore", - "hourSavedToast": "Ora del promemoria aggiornata.", + "hourSavedToast": "Ora del promemoria aggiornata", "detail": "Usa solo i canali già configurati (Telegram, ntfy, Web Push, Apple Push) e tace una volta registrato l’umore di oggi." }, "coachNudge": { @@ -7386,8 +7381,8 @@ "leadLabel": "Tempo di riordino (giorni)", "leadAria": "Tempo di riordino in giorni", "leadHelp": "L’avviso arriva questo numero di giorni più un intervallo di dose in anticipo, così la fornitura arriva prima dell’ultima dose. Un singolo farmaco può sovrascrivere questo valore nella sua scheda scorte.", - "leadSavedToast": "Tempo di riordino salvato.", - "savedToast": "Soglia delle scorte salvata.", + "leadSavedToast": "Tempo di riordino salvato", + "savedToast": "Soglia delle scorte salvata", "enabledToast": "Avvisi di scorte basse attivati.", "disabledToast": "Avvisi di scorte basse disattivati.", "saveError": "Salvataggio non riuscito. Riprova.", @@ -8047,10 +8042,10 @@ "protected": "Se durante il rapporto è stata usata la contraccezione.", "protectedLabel": "Cosa significa protetto?" }, - "saveSuccess": "Voce salvata.", - "deleteSuccess": "Voce eliminata.", - "periodStartSaved": "Inizio delle mestruazioni salvato.", - "periodEndSaved": "Fine delle mestruazioni salvata.", + "saveSuccess": "Voce salvata", + "deleteSuccess": "Voce eliminata", + "periodStartSaved": "Inizio delle mestruazioni salvato", + "periodEndSaved": "Fine delle mestruazioni salvata", "startedPeriodOnDate": "Le mestruazioni sono iniziate questo giorno", "endedPeriodOnDate": "Le mestruazioni sono finite questo giorno" }, @@ -9311,7 +9306,7 @@ "freeText": { "label": "Nota a testo libero (allergie e intolleranze)", "hint": "Un'integrazione a testo libero all'elenco strutturato qui sopra, per il contesto che una singola voce non può contenere.", - "savedToast": "Nota sulle allergie salvata.", + "savedToast": "Nota sulle allergie salvata", "saveError": "Impossibile salvare. Riprova." } }, @@ -9370,7 +9365,7 @@ "conditionsLabel": "Patologie croniche", "focusLabel": "Cosa tiene d'occhio il Coach", "loadError": "Impossibile caricare le tue patologie.", - "savedToast": "Patologie salvate.", + "savedToast": "Patologie salvate", "saveError": "Impossibile salvare. Riprova." }, "emergency": { @@ -9474,7 +9469,7 @@ }, "toast": { "duplicate": "Già archiviato — il documento esistente viene evidenziato.", - "deleted": "Documento eliminato.", + "deleted": "Documento eliminato", "restoreFailed": "Impossibile ripristinare il documento." }, "timeline": { @@ -9647,9 +9642,9 @@ "unverifiedNoteOther": "{count} valori devono ancora essere controllati e restano in sospeso.", "finish": "Concludi la revisione", "confirmFailed": "La revisione non è stata salvata. Riprova.", - "savedToastOne": "1 valore salvato.", - "savedToastFew": "{count} valori salvati.", - "savedToastOther": "{count} valori salvati.", + "savedToastOne": "1 valore salvato", + "savedToastFew": "{count} valori salvati", + "savedToastOther": "{count} valori salvati", "nothingSavedToast": "Revisione salvata. Nessun valore è stato aggiunto.", "extract": "Estrai i valori di laboratorio", "extracting": "Estrazione…", @@ -10190,7 +10185,7 @@ "errorRateLimit": "Troppi profili creati nell'ultima ora. Aspetta un po' e riprova." }, "toast": { - "savedTo": "Salvato nella cartella di {name}." + "savedTo": "Salvato nella cartella di {name}" }, "activityVerb": { "measurementCreate": "{name} ha aggiunto una misurazione", diff --git a/messages/pl.json b/messages/pl.json index 8a8535afd..c250f3c8d 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -560,7 +560,7 @@ "viewAll": "Zobacz wszystkie", "empty": { "title": "Brak treningów", - "cta": "Otwórz aplikację Apple Zdrowie na iPhonie, aby zsynchronizować treningi." + "cta": "Połącz źródło danych w ustawieniach, aby zobaczyć tu treningi." } }, "metric": { @@ -729,9 +729,12 @@ "sourceWhoop": "WHOOP", "sourceFitbit": "Fitbit", "bulkDeleteSuccess": "Usunięto pomiary: {count}", + "bulkDeleteSuccessOne": "Usunięto pomiar", "bulkDeleteError": "Błąd podczas usuwania wybranych pomiarów", "bulkDeleteConfirmTitle": "Usunąć pomiary ({count})?", + "bulkDeleteConfirmTitleOne": "Usunąć ten pomiar?", "bulkDeleteConfirmBody": "Wybrane pomiary ({count}) zostaną usunięte. Zaraz po usunięciu możesz to przez chwilę cofnąć.", + "bulkDeleteConfirmBodyOne": "Wybrany pomiar zostanie usunięty. Zaraz po usunięciu możesz to przez chwilę cofnąć.", "typePhq9Score": "Wynik PHQ-9", "typeGad7Score": "Wynik GAD-7", "typeGripStrength": "Siła chwytu", @@ -879,9 +882,12 @@ "sourceTelegram": "Telegram", "sourceDaylio": "Daylio", "bulkDeleteSuccess": "Usunięto wpisy: {count}", + "bulkDeleteSuccessOne": "Usunięto wpis", "bulkDeleteError": "Błąd podczas usuwania wybranych wpisów", "bulkDeleteConfirmTitle": "Usunąć wpisy ({count})?", + "bulkDeleteConfirmTitleOne": "Usunąć ten wpis?", "bulkDeleteConfirmBody": "Wybrane wpisy nastroju ({count}) zostaną usunięte. Zaraz po usunięciu możesz to przez chwilę cofnąć.", + "bulkDeleteConfirmBodyOne": "Wybrany wpis nastroju zostanie usunięty. Zaraz po usunięciu możesz to przez chwilę cofnąć.", "customize": "Dostosuj tagi nastroju", "addTagInline": "Dodaj tag", "manage": { @@ -1779,7 +1785,8 @@ "enabledToast": "Przypomnienia włączone", "disabledToast": "Przypomnienia wyłączone", "toggleFailed": "Nie udało się zmienić ustawienia przypomnień.", - "clientManagedChip": "Twój iPhone zarządza przypomnieniami dla tego leku." + "clientManagedChip": "Zarządzane przez iPhone’a", + "clientManagedHint": "Twój iPhone zarządza przypomnieniami dla tego leku." }, "settings": { "title": "Ustawienia", @@ -1932,7 +1939,7 @@ "addQuantityHelper": "1–1000 jednostek na opakowanie lub pojemnik.", "addExpiryLabel": "Data ważności (opcjonalnie)", "addSubmit": "Zarejestruj", - "addSuccess": "Zapas zarejestrowany.", + "addSuccess": "Zapas zarejestrowany", "addFailed": "Nie udało się zarejestrować zapasu.", "adjustButton": "Skoryguj", "adjustTitle": "Popraw opakowanie", @@ -1940,11 +1947,11 @@ "adjustQuantityLabel": "Pozostałe jednostki", "adjustHelper": "Pojemność: {total} jednostek.", "adjustSubmit": "Zapisz", - "adjustSuccess": "Zapas zaktualizowany.", + "adjustSuccess": "Zapas zaktualizowany", "adjustFailed": "Nie udało się zaktualizować zapasu.", "deleteTitle": "Usunąć opakowanie?", "deleteDescription": "Trwale usuwa to opakowanie lub pojemnik z zapasu. Zarejestrowane dawki pozostają bez zmian.", - "deleteSuccess": "Zapas usunięty.", + "deleteSuccess": "Zapas usunięty", "deleteFailed": "Nie udało się usunąć zapasu.", "containerTypeLabel": "Rodzaj pojemnika", "containerType": { @@ -2139,7 +2146,7 @@ "scaleToggleLabel": "Skala wykresu" }, "import": { - "resultSuccess": "Zaimportowano przyjęcia: {imported}.", + "resultSuccess": "Zaimportowano przyjęcia: {imported}", "resultPartial": "Zaimportowano {imported}, pominięto {skipped}.", "resultNothing": "Nic nie zaimportowano: pominięto wszystkie wpisy ({skipped}).", "resultAlreadyRecorded": "Przyjęcia już zapisane: {recorded}. Nie dodano niczego nowego.", @@ -2402,7 +2409,7 @@ "dragHandle": "Przeciągnij, aby zmienić kolejność", "show": "Pokaż", "hide": "Ukryj", - "manageInSettings": "Zarządzaj pigułkami i stronami szczegółów w ustawieniach", + "manageInSettings": "Zarządzaj chipami i stronami szczegółów w ustawieniach", "groupSleep": "Sen", "groupMood": "Nastrój", "groupEvents": "Zdarzenia", @@ -2776,6 +2783,7 @@ "settingsVerbosityBrief": "Krótka", "settingsVerbosityDefault": "Standardowa", "settingsVerbosityDetailed": "Szczegółowa", + "settingsVerbosityConciseHint": "Zwięzły ton i tak skraca odpowiedzi — szczegółowość pozostaje ustawiona na „Krótko”, dopóki jest aktywny.", "settingsSourcesPointer": "Tym, z jakich grup danych może korzystać coach, oraz domyślnym oknem analizy zarządzasz bezpośrednio w czacie coacha (panel „Z czego korzystam”).", "settingsSourcesPointerLink": "Otwórz coacha", "settingsExcludeLabel": "Wyklucz metryki", @@ -2787,7 +2795,7 @@ "settingsDefaultWindowLabel": "Domyślny okres analizy", "settingsDefaultWindowHint": "Coach czyta ten okres na rozmowę, chyba że wybierzesz inny w nagłówku panelu.", "settingsSave": "Zapisz", - "settingsSaved": "Zapisano.", + "settingsSaved": "Zapisano", "settingsCancel": "Anuluj", "feedbackHelpful": "Przydatne", "feedbackUnhelpful": "Mało przydatne", @@ -4159,13 +4167,13 @@ "overviewDescription": "Pokaż lub ukryj sekcje i ustaw ich kolejność w przeglądzie Analiz." }, "pillOrder": { - "title": "Sortuj pigułki nawigacyjne", - "description": "Przeciągnij pigułki w wybranej kolejności i pokaż lub ukryj je ikoną oka.", + "title": "Sortuj chipy nawigacyjne", + "description": "Przeciągnij chipy w wybranej kolejności i pokaż lub ukryj je ikoną oka.", "dragHandle": "Zmień kolejność", - "saveSuccess": "Zapisano kolejność pigułek.", - "saveError": "Zapisanie kolejności pigułek nie powiodło się.", + "saveSuccess": "Zapisano kolejność chipów", + "saveError": "Zapisanie kolejności chipów nie powiodło się.", "save": "Zapisz", - "empty": "Brak dostępnych pigułek nawigacyjnych.", + "empty": "Brak dostępnych chipów nawigacyjnych.", "detail": "Ukryte strony szczegółów znikają również z górnej nawigacji." }, "customize": "Dostosuj Analizy", @@ -4833,8 +4841,6 @@ } }, "dashboard": { - "title": "Pulpit", - "description": "Układ i kolejność kafelków.", "subtitle": "Rozmieść kafelki na pulpicie." }, "thresholds": { @@ -4855,9 +4861,9 @@ "resetDefaultsConfirm": "Zresetuj kolejność", "moveUp": "W górę", "moveDown": "W dół", - "saveSuccess": "Zapisano priorytet źródeł.", + "saveSuccess": "Zapisano priorytet źródeł", "saveError": "Zapisanie priorytetu źródeł nie powiodło się.", - "resetSuccess": "Przywrócono domyślny priorytet źródeł.", + "resetSuccess": "Przywrócono domyślny priorytet źródeł", "perMetricToggle": "Ustawienia dla poszczególnych metryk ({count})", "perMetricHelp": "Metryki dostosowane względem domyślnej kolejności. Użyj tej listy, aby przywrócić pojedynczą metrykę do wartości domyślnej, nie zmieniając pozostałych.", "perMetricEmpty": "Brak indywidualnych ustawień — każda metryka korzysta z globalnej kolejności powyżej.", @@ -5026,7 +5032,7 @@ "failed": "Import nie powiódł się. Spróbuj ponownie.", "readFailed": "Nie można odczytać tego pliku.", "resultSummary": "Zaimportowano {measurements} pomiarów i {moods} wpisów nastroju, pominięto {skipped}.", - "resultSuccess": "Zaimportowano {measurements} pomiarów i {moods} wpisów nastroju.", + "resultSuccess": "Zaimportowano {measurements} pomiarów i {moods} wpisów nastroju", "resultNothing": "Nic nie zaimportowano. Pominięto wszystkie {skipped} wpisów.", "resultEmpty": "Ten plik nie zawierał pomiarów ani wpisów nastroju." }, @@ -5050,10 +5056,10 @@ "previewSummary": "Podgląd: {inserted} zostanie zaimportowanych, pominięto {skipped}.", "resultSummary": "Zaimportowano {inserted} nowych i zaktualizowano {updated}, pominięto {skipped}.", "rowError": "Wiersz {line}: {reason}", - "resultSuccess": "Zaimportowano {inserted} nowych i zaktualizowano {updated}.", + "resultSuccess": "Zaimportowano {inserted} nowych i zaktualizowano {updated}", "resultNothing": "Nic nie zaimportowano. Pominięto wszystkie {skipped} wierszy.", "resultEmpty": "Ten plik ma nagłówek, ale nie zawiera wierszy danych.", - "previewSuccess": "Podgląd: {inserted} zostanie zaimportowanych.", + "previewSuccess": "Podgląd: {inserted} zostanie zaimportowanych", "previewNothing": "Podgląd: nic nie zostanie zaimportowane. Pominięte zostaną wszystkie {skipped} wierszy.", "skipGroupOne": "Pominięto {count} wiersz: {reason}", "skipGroupFew": "Pominięto {count} wiersze: {reason}", @@ -5144,38 +5150,26 @@ "subtitle": "Udostępnij dostęp tylko do odczytu do swoich danych." }, "insights": { - "title": "Analizy", - "description": "Kolejność przeglądu i pigułek nawigacyjnych.", - "subtitle": "Rozmieść sekcje przeglądu i kolejność etykiet." + "subtitle": "Rozmieść sekcje przeglądu i kolejność chipów." }, "medications": { - "title": "Leki", - "description": "Widok listy, kolejność i miejsca wstrzyknięć Twoich leków.", "subtitle": "Zarządzaj widokiem i kolejnością listy leków." }, "mood": { - "title": "Tagi nastroju", - "description": "Grupy, tagi i zarchiwizowane tagi w wyborze nastroju.", "subtitle": "Uporządkuj tagi nastroju, grupy i kolejność wyboru." }, "labs": { - "title": "Wyniki badań", - "description": "Widok, sortowanie i biomarkery Twoich wyników badań.", "subtitle": "Zarządzaj widokiem, sortowaniem i biomarkerami." }, "illness": { - "title": "Dziennik chorób", - "description": "Widok i kolejność Twoich epizodów chorobowych.", "subtitle": "Zarządzaj widokiem i kolejnością epizodów." }, "vorsorge": { - "title": "Badania", - "description": "Widok, kolejność i poszczególne przypomnienia o badaniach.", "subtitle": "Zarządzaj widokiem, kolejnością i poszczególnymi przypomnieniami." }, "modules": { "title": "Moduły", - "saved": "Zapisano.", + "saved": "Zapisano", "error": "Nie udało się zapisać. Spróbuj ponownie.", "toggleable": { "title": "Co śledzisz", @@ -5223,7 +5217,7 @@ "use": "Użyj", "select": "Wybierz", "saveError": "Nie udało się zapisać zmiany.", - "homeSaved": "Zapisano lokalizację domową.", + "homeSaved": "Zapisano lokalizację domową", "travelAdded": "Dodano okres lokalizacji.", "travelRemoved": "Usunięto okres lokalizacji.", "backfillQueued": "Zaplanowano uzupełnianie.", @@ -5280,7 +5274,7 @@ "current": "Twój zapisany wybór" }, "save": "Zapisz", - "saved": "Zapisano.", + "saved": "Zapisano", "error": "Nie udało się zapisać. Spróbuj ponownie.", "loadFailed": "Nie udało się wczytać Twojego wyboru.", "eligibilityFailed": "Nie udało się sprawdzić, które filary mają teraz dane. Przełączniki poniżej i tak działają.", @@ -5318,7 +5312,7 @@ "auto": "Automatycznie (język)", "h24": "24-godzinny", "h12": "12-godzinny (AM/PM)", - "saved": "Zapisano format godziny.", + "saved": "Zapisano format godziny", "saveError": "Nie udało się zapisać formatu godziny." }, "dateFormat": { @@ -5328,7 +5322,7 @@ "dmy": "DD.MM.RRRR", "mdy": "MM/DD/RRRR", "ymd": "RRRR-MM-DD", - "saved": "Zapisano format daty.", + "saved": "Zapisano format daty", "saveError": "Nie udało się zapisać formatu daty." }, "username": "Nazwa użytkownika", @@ -5622,7 +5616,7 @@ "clearBody": "Usuwa tylko tekst notatki. Alergie i nietolerancje pozostają bez zmian.", "clearConfirm": "Wyczyść notatkę", "save": "Zapisz", - "savedToast": "Notatka „O mnie” zapisana.", + "savedToast": "Notatka „O mnie” zapisana", "clearedToast": "Notatka „O mnie” usunięta.", "saveError": "Nie udało się zapisać. Spróbuj ponownie.", "loadError": "Nie udało się wczytać notatki.", @@ -5644,14 +5638,14 @@ "title": "Automatyczne czytanie dokumentów przez AI", "subLabel": "Dokumenty są czytane i indeksowane przez skonfigurowanego dostawcę AI bez potwierdzania każdego dokumentu.", "honesty": "Po włączeniu każdy przesłany dokument jest wysyłany do skonfigurowanego dostawcy AI, aby go odczytać, opisać i zaindeksować do wyszukiwania — bez potwierdzania każdego dokumentu. Włączenie działa też wstecz: dokumenty już przesłane, które nie mają jeszcze streszczenia, również zostaną wysłane — do 200 przy każdym włączeniu. Dzieje się to na serwerze, a treść dokumentu opuszcza to urządzenie i trafia do tego dostawcy. Jeśli skonfigurowany dostawca to połączenie subskrypcyjne (zalogowane konto AI zamiast klucza API), obowiązują jego własne ustawienia danych — dostawcy subskrypcyjni mogą wykorzystywać treść do ulepszania swoich modeli i nie obejmuje tego żadna umowa powierzenia przetwarzania danych. Dokumenty odczytywane przez samodzielnie hostowany model lokalny nigdy nie opuszczają Twojego serwera. Domyślnie wyłączone; włącz je tylko wtedy, gdy akceptujesz ten kompromis dla swoich dokumentów.", - "confirm": "Rozumiem — włącz automatyczne czytanie przez AI", + "confirm": "Rozumiem — włącz", "cancel": "Anuluj" }, "centralCodex": { "title": "Użyj wspólnego dostępu AI serwera", "subLabel": "Kieruj zapytania AI przez połączenie skonfigurowane przez operatora dla tego serwera, obok Twoich własnych dostawców jako rozwiązanie awaryjne.", "honesty": "Wspólny dostęp to jedno zalogowane konto AI, które operator podłączył dla wszystkich na tym serwerze — nie Twój własny klucz. Zapytania, które przez nie wysyłasz, są domyślnie wykorzystywane do trenowania modeli dostawcy i nie obejmuje ich żadna umowa powierzenia przetwarzania danych, godzinowe i tygodniowe limity konta dzielisz z wszystkimi innymi, którzy się zgodzili, a Twoi właśni dostawcy są próbowani jako pierwsi. Włącz to tylko, jeśli akceptujesz ten kompromis.", - "confirm": "Rozumiem — użyj wspólnego połączenia", + "confirm": "Rozumiem — użyj połączenia", "cancel": "Anuluj" }, "consent": { @@ -5673,7 +5667,7 @@ "description": "Wybierz sposób wyświetlania odległości i prędkości na wykresach i kafelkach.", "metric": "Metryczne", "imperial": "Imperialne", - "saved": "Zapisano preferencję jednostek.", + "saved": "Zapisano preferencję jednostek", "saveError": "Nie udało się zapisać preferencji jednostek." } }, @@ -5861,7 +5855,7 @@ "errorReconnect": "Błąd — połącz ponownie", "warningServerError": "Synchronizacja nie działa", "failureCount": "{count}/{threshold}", - "parkedReconnect": "Wstrzymane — połącz ręcznie", + "parkedReconnect": "Wstrzymane — połącz ponownie", "notConnected": "Nie połączono", "justNow": "przed chwilą", "minutesAgo": "{count} min temu", @@ -5945,6 +5939,7 @@ "repository": "Kod źródłowy", "changelog": "Changelog", "docs": "Dokumentacja", + "credits": "Autorzy i źródła danych", "linksHeading": "Źródła i dokumentacja", "newerAvailable": "Nowa wersja: {tag}" }, @@ -6038,7 +6033,7 @@ "expiryInvalid": "Wygaśnięcie musi mieścić się między 1 a {max} dniami.", "create": "Utwórz link", "tokenCreated": "Link udostępniania utworzony", - "copied": "Skopiowano do schowka.", + "copied": "Skopiowano do schowka", "activeTitle": "Aktywne linki", "noActive": "Brak aktywnych linków udostępniania.", "created": "Utworzono", @@ -6190,7 +6185,7 @@ "revokeSession": "Wyloguj", "revokeError": "Nie udało się zrealizować żądania. Spróbuj ponownie.", "signOutEverywhere": "Wyloguj na pozostałych urządzeniach", - "signOutEverywhereDone": "Wylogowano {count} inną/e sesję/e.", + "signOutEverywhereDone": "Wylogowano {count} inną/e sesję/e", "activityTitle": "Aktywność zabezpieczeń", "activityDescription": "Ostatnie logowania, zmiany zabezpieczeń i eksporty danych Twojego konta.", "activityEmpty": "Brak aktywności zabezpieczeń.", @@ -6271,7 +6266,7 @@ "revokeError": "Nie udało się cofnąć urządzenia. Spróbuj ponownie.", "revoke": "Cofnij", "revokeAll": "Zapomnij wszystkie urządzenia", - "revokeAllDone": "Zapomniano {count} zaufane urządzenie/a.", + "revokeAllDone": "Zapomniano {count} zaufane urządzenie/a", "unnamed": "Nieznane urządzenie", "current": "To urządzenie", "expires": "Zaufane do {date}", @@ -6596,13 +6591,13 @@ "maxUsesLabel": "Maksymalna liczba użyć", "maxUsesHint": "Ile rejestracji dopuszcza ten link (1–50).", "createConfirm": "Utwórz zaproszenie", - "createdToast": "Zaproszenie utworzone.", + "createdToast": "Zaproszenie utworzone", "createError": "Nie udało się utworzyć zaproszenia.", "tokenTitle": "Link z zaproszeniem", "tokenOnceHint": "Do skopiowania tylko teraz — skopiuj link lub udostępnij kod QR do zeskanowania.", "hashExplainer": "Zapisywany jest tylko hash linku — nie można go później ponownie wyświetlić. W razie utraty unieważnij zaproszenie i utwórz nowe.", "copyLink": "Kopiuj link", - "copiedToast": "Link skopiowany.", + "copiedToast": "Link skopiowany", "copyError": "Kopiowanie nie powiodło się.", "qrAlt": "Kod QR linku z zaproszeniem", "loadError": "Nie udało się wczytać zaproszeń.", @@ -7345,7 +7340,7 @@ "saveError": "Zapis nieudany. Spróbuj ponownie.", "hourLabel": "Godzina przypomnienia", "hourAria": "Godzina przypomnienia o nastroju", - "hourSavedToast": "Godzina przypomnienia zaktualizowana.", + "hourSavedToast": "Godzina przypomnienia zaktualizowana", "detail": "Wysyłane tylko skonfigurowanymi już kanałami (Telegram, ntfy, Web Push, Apple Push) i milknie, gdy dziś zapisano nastrój." }, "coachNudge": { @@ -7386,8 +7381,8 @@ "leadLabel": "Czas na ponowne zamówienie (dni)", "leadAria": "Czas na ponowne zamówienie w dniach", "leadHelp": "Alert pojawia się o tyle dni plus jeden interwał dawki wcześniej, aby nowy zapas dotarł przed Twoją ostatnią dawką. Pojedynczy lek może nadpisać tę wartość na swojej karcie zapasu.", - "leadSavedToast": "Czas na ponowne zamówienie zapisany.", - "savedToast": "Próg zapasu zapisany.", + "leadSavedToast": "Czas na ponowne zamówienie zapisany", + "savedToast": "Próg zapasu zapisany", "enabledToast": "Alerty niskiego zapasu włączone.", "disabledToast": "Alerty niskiego zapasu wyłączone.", "saveError": "Zapis nieudany. Spróbuj ponownie.", @@ -8047,10 +8042,10 @@ "protected": "Czy podczas stosunku zastosowano antykoncepcję.", "protectedLabel": "Co oznacza zabezpieczony?" }, - "saveSuccess": "Wpis zapisany.", - "deleteSuccess": "Wpis usunięty.", - "periodStartSaved": "Początek miesiączki zapisany.", - "periodEndSaved": "Koniec miesiączki zapisany.", + "saveSuccess": "Wpis zapisany", + "deleteSuccess": "Wpis usunięty", + "periodStartSaved": "Początek miesiączki zapisany", + "periodEndSaved": "Koniec miesiączki zapisany", "startedPeriodOnDate": "Miesiączka zaczęła się tego dnia", "endedPeriodOnDate": "Miesiączka skończyła się tego dnia" }, @@ -9311,7 +9306,7 @@ "freeText": { "label": "Notatka tekstowa (alergie i nietolerancje)", "hint": "Tekstowe uzupełnienie ustrukturyzowanej listy powyżej, na kontekst, którego pojedynczy wpis nie odda.", - "savedToast": "Notatka o alergiach zapisana.", + "savedToast": "Notatka o alergiach zapisana", "saveError": "Nie udało się zapisać. Spróbuj ponownie." } }, @@ -9370,7 +9365,7 @@ "conditionsLabel": "Choroby przewlekłe", "focusLabel": "Na co zwraca uwagę Coach", "loadError": "Nie udało się wczytać Twoich chorób.", - "savedToast": "Choroby zapisane.", + "savedToast": "Choroby zapisane", "saveError": "Nie udało się zapisać. Spróbuj ponownie." }, "emergency": { @@ -9474,7 +9469,7 @@ }, "toast": { "duplicate": "Już zapisano — istniejący dokument został wyróżniony.", - "deleted": "Dokument usunięty.", + "deleted": "Dokument usunięty", "restoreFailed": "Nie udało się przywrócić dokumentu." }, "timeline": { @@ -9647,9 +9642,9 @@ "unverifiedNoteOther": "{count} wartości wymaga jeszcze sprawdzenia i pozostaje w oczekiwaniu.", "finish": "Zakończ przegląd", "confirmFailed": "Nie udało się zapisać przeglądu. Spróbuj ponownie.", - "savedToastOne": "Zapisano 1 wartość.", - "savedToastFew": "Zapisano {count} wartości.", - "savedToastOther": "Zapisano {count} wartości.", + "savedToastOne": "Zapisano 1 wartość", + "savedToastFew": "Zapisano {count} wartości", + "savedToastOther": "Zapisano {count} wartości", "nothingSavedToast": "Przegląd zapisany. Nie dodano żadnych wartości.", "extract": "Odczytaj wyniki laboratoryjne", "extracting": "Odczytywanie…", @@ -10190,7 +10185,7 @@ "errorRateLimit": "Zbyt wiele profili utworzonych w ostatniej godzinie. Odczekaj chwilę i spróbuj ponownie." }, "toast": { - "savedTo": "Zapisano w karcie użytkownika {name}." + "savedTo": "Zapisano w karcie użytkownika {name}" }, "activityVerb": { "measurementCreate": "{name} dodał pomiar", diff --git a/package.json b/package.json index b78bd601a..2f6550160 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.37.18", + "version": "1.37.19", "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/public/sw.js b/public/sw.js index 65e635e18..922bedb3c 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.18"; + /* @sw-version-fallback */ "v1.37.19"; 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/*` diff --git a/src/__tests__/record-settings-contract.test.ts b/src/__tests__/record-settings-contract.test.ts index 0e1c2b3d0..d1af128c7 100644 --- a/src/__tests__/record-settings-contract.test.ts +++ b/src/__tests__/record-settings-contract.test.ts @@ -67,7 +67,12 @@ describe("record settings contract", () => { guardianWritable: false, }); expect(classifySettingsDestination("ai").kind).toBe("unavailable"); - expect(classifySettingsDestination("dashboard").kind).toBe( + // v1.37.19 — "dashboard" left SETTINGS_SECTION_SLUGS (its URL is + // owned by a permanent redirect to /settings/layout/dashboard), so an + // unknown destination falls closed to plain unavailable; "layout" is + // the surviving adult-shared-unavailable representative. + expect(classifySettingsDestination("dashboard").kind).toBe("unavailable"); + expect(classifySettingsDestination("layout").kind).toBe( "adult-shared-unavailable", ); expect(isGuardianSettingsWriteAllowed("account")).toBe(true); diff --git a/src/app/api/admin/backups/[id]/restore/route.ts b/src/app/api/admin/backups/[id]/restore/route.ts index 6bfb9b39b..13976d771 100644 --- a/src/app/api/admin/backups/[id]/restore/route.ts +++ b/src/app/api/admin/backups/[id]/restore/route.ts @@ -1532,6 +1532,11 @@ const handler = apiHandler( customMetrics: summary.customMetrics, customMetricEntries: summary.customMetricEntries, correlationPatterns: summary.correlationPatterns, + practitioners: summary.practitioners, + encounters: summary.encounters, + encounterLinks: summary.encounterLinks, + vaccinations: summary.vaccinations, + vaccinationLinks: summary.vaccinationLinks, }, }, }); diff --git a/src/app/api/analytics/__tests__/route.test.ts b/src/app/api/analytics/__tests__/route.test.ts index 44a4903f7..18c2f8552 100644 --- a/src/app/api/analytics/__tests__/route.test.ts +++ b/src/app/api/analytics/__tests__/route.test.ts @@ -329,7 +329,6 @@ describe("GET /api/analytics", () => { r2_7: 0.4, slope30: -0.02, r2_30: 0.3, - slope90: -0.01, r2_90: 0.2, }, ] as never) diff --git a/src/app/api/analytics/route.ts b/src/app/api/analytics/route.ts index 08fa120b3..ea430bc31 100644 --- a/src/app/api/analytics/route.ts +++ b/src/app/api/analytics/route.ts @@ -233,19 +233,21 @@ async function buildAnalyticsResponse(user: AuthedUser, locale: Locale) { // identical to the previous fan-out output for every field the // dashboard actually consumes: // - `count / latest / min / max / mean` from DAY buckets - // - `avg7 / avg30 / slope7 / slope30 / slope90` from the narrow query + // - `avg7 / avg30 / slope7 / slope30` from the narrow query // - `avg30LastMonth` from the same narrow query (added in this // release; previously only the live walk produced it) // - `avg30LastYear` populated for any type whose WMY tier carries // the year-ago window // - `lastSeenByType` from the `DISTINCT ON (type)` latest read // - // The two fields the slim path leaves at default values are - // `anomalyCount` (always 0) — the insights pipeline consumes it from - // its own `comprehensive-aggregator` narrow query, never from this - // route — and `avg30LastYear` on types with no WMY coverage, which - // matches the pre-fix behaviour for tenants whose year-ago window - // happened to fall outside the 425-day floor. + // `anomalyCount` and `slope90` left this wire in v1.37.19: the slim + // path could never compute the former (it always reported 0) and no + // client component read the latter — the insights pipeline computes + // both from its own `summarize()` / comprehensive-aggregator reads. + // The one field the slim path leaves at a default value is + // `avg30LastYear` on types with no WMY coverage, which matches the + // pre-fix behaviour for tenants whose year-ago window happened to + // fall outside the 425-day floor. const slim = await computeSummariesSlice(user.id); const results = slim.summaries; const lastSeenByType = slim.lastSeenByType; diff --git a/src/app/api/auth/me/route.ts b/src/app/api/auth/me/route.ts index 1c2a7520d..425bfa33a 100644 --- a/src/app/api/auth/me/route.ts +++ b/src/app/api/auth/me/route.ts @@ -110,6 +110,10 @@ export const GET = apiHandler(async () => { // when never acknowledged; the onboarding welcome gate reads this to // decide whether to require the acknowledgment before "Get started". disclaimerAcknowledgedAt: user.disclaimerAcknowledgedAt, + // The version that acknowledgment was given for. The welcome gate + // compares it against the current DISCLAIMER_VERSION so a revised + // disclaimer re-prompts instead of riding an old acknowledgment. + disclaimerAcknowledgedVersion: user.disclaimerAcknowledgedVersion, // v1.18.6 — resumable module-tour progress. Null when the user has // not started the tour; otherwise the resume point the launcher // seeds its index from. Fail-soft parse: a corrupt blob degrades to diff --git a/src/app/api/auth/me/timezone/__tests__/route.test.ts b/src/app/api/auth/me/timezone/__tests__/route.test.ts index c8b42b530..8fa2a9216 100644 --- a/src/app/api/auth/me/timezone/__tests__/route.test.ts +++ b/src/app/api/auth/me/timezone/__tests__/route.test.ts @@ -23,6 +23,14 @@ vi.mock("@/lib/db-compat", () => ({ ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), })); +// v1.37.19 (A6-12) — the zone change enqueues a compliance re-fold. +const refoldMock = vi.hoisted(() => + vi.fn(async () => ({ enqueued: true, error: null })), +); +vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({ + enqueueUserMedicationComplianceBackfill: refoldMock, +})); + vi.mock("next/headers", () => ({ headers: vi.fn(async () => ({ get: () => null })), cookies: vi.fn(async () => ({ @@ -96,6 +104,34 @@ describe("PUT /api/auth/me/timezone", () => { expect(res.status).toBe(422); }); + // Watched red: with the re-fold enqueue removed from the route (the + // pre-v1.37.19 write), the changed-zone case fails — the compliance + // rollup's day keys were minted under the old zone and nothing ever + // re-bucketed them (the coverage probe compares counts, not keys). + it("enqueues a compliance re-fold when the zone actually changes", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + timezone: "Europe/Berlin", + } as never); + vi.mocked(prisma.user.update).mockResolvedValue({} as never); + + const res = await PUT(mkReq({ timezone: "America/New_York" })); + expect(res.status).toBe(200); + expect(refoldMock).toHaveBeenCalledWith("user-1"); + }); + + it("does not re-fold when the zone is unchanged", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + timezone: "Europe/Berlin", + } as never); + vi.mocked(prisma.user.update).mockResolvedValue({} as never); + + const res = await PUT(mkReq({ timezone: "Europe/Berlin" })); + expect(res.status).toBe(200); + expect(refoldMock).not.toHaveBeenCalled(); + }); + it("trims whitespace before validating", async () => { vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); vi.mocked(prisma.user.findUnique).mockResolvedValue({ diff --git a/src/app/api/auth/me/timezone/route.ts b/src/app/api/auth/me/timezone/route.ts index 1ac07297a..0bc0c0503 100644 --- a/src/app/api/auth/me/timezone/route.ts +++ b/src/app/api/auth/me/timezone/route.ts @@ -29,6 +29,7 @@ import { import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { invalidateUserTimezone, isValidTimezone } from "@/lib/tz/resolver"; +import { enqueueUserMedicationComplianceBackfill } from "@/lib/rollups/medication-compliance-rollups"; export const PUT = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); @@ -60,6 +61,24 @@ export const PUT = apiHandler(async (request: NextRequest) => { invalidateUserTimezone(user.id); + // v1.37.19 (A6-12) — the medication-compliance rollup keys its rows by + // the LOCAL day the dose fell on, minted under the timezone in force at + // write time. A zone change re-buckets those days, so the stored keys go + // stale (and the coverage probe compares counts, not keys — it cannot + // notice). Re-fold the trailing window through the ordinary backfill + // job; best-effort, the singleton key collapses rapid re-picks. + if ((before?.timezone ?? null) !== tz) { + const refold = await enqueueUserMedicationComplianceBackfill(user.id); + annotate({ + meta: { + timezone_compliance_refold_enqueued: refold.enqueued, + ...(refold.error + ? { timezone_compliance_refold_error: refold.error } + : {}), + }, + }); + } + await auditLog("user.timezone.update", { userId: user.id, ipAddress: getClientIp(request), diff --git a/src/app/api/documents/inbound/route.ts b/src/app/api/documents/inbound/route.ts index 60d26ffdf..f4ebcbe0a 100644 --- a/src/app/api/documents/inbound/route.ts +++ b/src/app/api/documents/inbound/route.ts @@ -631,40 +631,37 @@ export const GET = apiHandler(async (request: Request) => { } } - // Condition links for the page in ONE grouped query (no N+1). - const [linkMap, visitLinkMap] = await Promise.all([ - loadConditionLinks( - user.id, - page.map((d) => d.id), - ), - loadDocumentEncounterLinks( - user.id, - page.map((d) => d.id), - ), + // Condition/visit links + the content-index and thumbnail probes for the + // page, all in ONE Promise.all — four independent grouped queries (no + // N+1, never a ciphertext/blob column), and the vault polls this list + // every few seconds so two serialized round-trips were pure added + // latency (A5-4). + const pageIds = page.map((d) => d.id); + const [linkMap, visitLinkMap, indexedRows, thumbRows] = await Promise.all([ + loadConditionLinks(user.id, pageIds), + loadDocumentEncounterLinks(user.id, pageIds), + // Which of the page's documents have a content index (drives the + // searchable status + the provenance the UI reads to tell an AI-read + // document from a locally-indexed one). + page.length > 0 + ? prisma.documentContentIndex.findMany({ + where: { userId: user.id, documentId: { in: pageIds } }, + select: { documentId: true, source: true }, + }) + : Promise.resolve([]), + // Which of the page's documents have a preview thumbnail (gates the + // card's ). Grouped on the 1:1 side table. + page.length > 0 + ? prisma.documentThumbnail.findMany({ + where: { userId: user.id, documentId: { in: pageIds } }, + select: { documentId: true }, + }) + : Promise.resolve([]), ]); - - // Which of the page's documents have a content index (drives the searchable - // status + the provenance the UI reads to tell an AI-read document from a - // locally-indexed one). One grouped query; never the ciphertext. const indexSources = new Map(); - if (page.length > 0) { - const indexed = await prisma.documentContentIndex.findMany({ - where: { userId: user.id, documentId: { in: page.map((d) => d.id) } }, - select: { documentId: true, source: true }, - }); - for (const row of indexed) indexSources.set(row.documentId, row.source); - } - - // Which of the page's documents have a preview thumbnail (gates the card's - // ). One grouped query on the 1:1 side table; never the blob column. + for (const row of indexedRows) indexSources.set(row.documentId, row.source); const thumbnailIds = new Set(); - if (page.length > 0) { - const thumbs = await prisma.documentThumbnail.findMany({ - where: { userId: user.id, documentId: { in: page.map((d) => d.id) } }, - select: { documentId: true }, - }); - for (const row of thumbs) thumbnailIds.add(row.documentId); - } + for (const row of thumbRows) thumbnailIds.add(row.documentId); annotate({ action: { name: "documents.inbound.list" }, diff --git a/src/app/api/insights/cards/route.ts b/src/app/api/insights/cards/route.ts index 7f1734587..23dc79ae7 100644 --- a/src/app/api/insights/cards/route.ts +++ b/src/app/api/insights/cards/route.ts @@ -253,7 +253,7 @@ export const GET = apiHandler(async () => { bpPctInTarget, weightSlope30: summaries.WEIGHT?.slope30?.slope ?? null, pulseAvg30: summaries.PULSE?.avg30 ?? null, - pulseAnomalyCount: summaries.PULSE?.anomalyCount, + pulseAnomalyCount: summaries.PULSE?.anomalyCount ?? 0, medications: medicationCompliance, }); diff --git a/src/app/api/insights/comprehensive/route.ts b/src/app/api/insights/comprehensive/route.ts index e0919bb23..a3cf6e902 100644 --- a/src/app/api/insights/comprehensive/route.ts +++ b/src/app/api/insights/comprehensive/route.ts @@ -535,7 +535,7 @@ export async function buildComprehensiveResponse(user: AuthedUser) { bpPctInTarget, weightSlope30: summaries.WEIGHT?.slope30?.slope ?? null, pulseAvg30: summaries.PULSE?.avg30 ?? null, - pulseAnomalyCount: summaries.PULSE?.anomalyCount, + pulseAnomalyCount: summaries.PULSE?.anomalyCount ?? 0, medications: medCompliance.map((m) => ({ name: m.name, compliance7: m.compliance7, diff --git a/src/app/api/measurements/[id]/__tests__/route.test.ts b/src/app/api/measurements/[id]/__tests__/route.test.ts index 97d7f7c7c..c7dfd55af 100644 --- a/src/app/api/measurements/[id]/__tests__/route.test.ts +++ b/src/app/api/measurements/[id]/__tests__/route.test.ts @@ -28,6 +28,9 @@ vi.mock("@/lib/cache/invalidate", () => ({ vi.mock("@/lib/rollups/measurement-rollups", () => ({ recomputeBucketsForMeasurement: vi.fn().mockResolvedValue(undefined), })); +vi.mock("@/lib/rollups/after-measurement-mutation", () => ({ + afterMeasurementMutation: vi.fn().mockResolvedValue(undefined), +})); vi.mock("@/lib/insights/comprehensive-generate", () => ({ invalidateStatusInsightsForTypes: vi.fn().mockResolvedValue(undefined), })); diff --git a/src/app/api/measurements/[id]/route.ts b/src/app/api/measurements/[id]/route.ts index 1c425ce1c..e43e59801 100644 --- a/src/app/api/measurements/[id]/route.ts +++ b/src/app/api/measurements/[id]/route.ts @@ -18,8 +18,7 @@ import { } from "@/lib/validations/measurement"; import { encryptNote, shapeMeasurementNotes } from "@/lib/crypto/note-cipher"; import { invalidateUserMeasurements } from "@/lib/cache/invalidate"; -import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate"; -import { recomputeBucketsForMeasurement } from "@/lib/rollups/measurement-rollups"; +import { afterMeasurementMutation } from "@/lib/rollups/after-measurement-mutation"; import { Prisma } from "@/generated/prisma/client"; import { NextRequest } from "next/server"; import { z } from "zod"; @@ -235,41 +234,14 @@ export const PUT = apiHandler( // edit — hard-evict so the SWR readers don't serve the pre-edit body. invalidateUserMeasurements(user.id, { evict: true }); - // v1.5.0 — refresh the rollup row for the affected day. When the - // measuredAt moved across day boundaries (or the row was re-typed) - // both the old and the new bucket need a recompute. Best-effort - // — a populator hiccup never fails the user's edit. - try { - await recomputeBucketsForMeasurement( - user.id, - measurement.type, - measurement.measuredAt, - ); - if ( - existing.measuredAt.getTime() !== measurement.measuredAt.getTime() || - existing.type !== measurement.type - ) { - await recomputeBucketsForMeasurement( - user.id, - existing.type, - existing.measuredAt, - ); - } - } catch (err) { - console.warn("[measurements] rollup recompute failed", err); - } - - // v1.8.0 — drop the cached per-metric assessment rows this edit - // dirties so the next mount / nightly warm pass regenerates against - // the new value. An edit can re-type a row, so invalidate both the - // old and the new type's scopes. Fire-and-forget: never blocks the - // user's edit. - invalidateStatusInsightsForTypes(user.id, [ - existing.type, - measurement.type, - ]).catch((err) => { - console.warn("[measurements] status-insight invalidate failed", err); - }); + // v1.37.19 (C2-F1) — shared post-mutation tail. Both the OLD and the + // NEW identity ride the list: an edit can move the row across day + // boundaries or re-type it, and the helper collapses identical + // (type, day) pairs so the common in-place edit fires once. + await afterMeasurementMutation(user.id, [ + { type: measurement.type, measuredAt: measurement.measuredAt }, + { type: existing.type, measuredAt: existing.measuredAt }, + ]); return apiSuccess(shapeMeasurementNotes(measurement)); }, @@ -328,26 +300,11 @@ export const DELETE = apiHandler( // body. invalidateUserMeasurements(user.id, { evict: true }); - // v1.5.0 — refresh the rollup row for the affected day (the - // recompute drops the row when the day's measurement count goes - // to zero). Best-effort — a populator hiccup never fails the - // user's delete. - try { - await recomputeBucketsForMeasurement( - user.id, - existing.type, - existing.measuredAt, - ); - } catch (err) { - console.warn("[measurements] rollup recompute failed", err); - } - - // v1.8.0 — drop the cached per-metric assessment rows this deletion - // dirties so the next mount / nightly warm pass regenerates against - // the reduced history. Fire-and-forget: never blocks the user's delete. - invalidateStatusInsightsForTypes(user.id, [existing.type]).catch((err) => { - console.warn("[measurements] status-insight invalidate failed", err); - }); + // v1.37.19 (C2-F1) — shared post-mutation tail (the recompute drops + // the rollup row when the day's measurement count goes to zero). + await afterMeasurementMutation(user.id, [ + { type: existing.type, measuredAt: existing.measuredAt }, + ]); return apiSuccess({ deleted: true }); }, diff --git a/src/app/api/measurements/batch/__tests__/aggregated-hr-wire.test.ts b/src/app/api/measurements/batch/__tests__/aggregated-hr-wire.test.ts index b6858aad2..a46987f29 100644 --- a/src/app/api/measurements/batch/__tests__/aggregated-hr-wire.test.ts +++ b/src/app/api/measurements/batch/__tests__/aggregated-hr-wire.test.ts @@ -117,6 +117,9 @@ vi.mock("@/lib/rollups/measurement-rollups", () => ({ recomputeBucketsForMeasurement: vi.fn().mockResolvedValue(undefined), collapseToTypeDayKeys: vi.fn(() => []), })); +vi.mock("@/lib/rollups/after-measurement-mutation", () => ({ + afterMeasurementMutation: vi.fn().mockResolvedValue(undefined), +})); vi.mock("next/headers", () => ({ headers: vi.fn(async () => ({ get: () => null })), diff --git a/src/app/api/measurements/batch/__tests__/hr-bucket-observability.test.ts b/src/app/api/measurements/batch/__tests__/hr-bucket-observability.test.ts index 35300709e..b5e36298c 100644 --- a/src/app/api/measurements/batch/__tests__/hr-bucket-observability.test.ts +++ b/src/app/api/measurements/batch/__tests__/hr-bucket-observability.test.ts @@ -124,6 +124,9 @@ vi.mock("@/lib/rollups/measurement-rollups", () => ({ recomputeBucketsForMeasurement: vi.fn().mockResolvedValue(undefined), collapseToTypeDayKeys: vi.fn(() => []), })); +vi.mock("@/lib/rollups/after-measurement-mutation", () => ({ + afterMeasurementMutation: vi.fn().mockResolvedValue(undefined), +})); vi.mock("next/headers", () => ({ headers: vi.fn(async () => ({ get: () => null })), diff --git a/src/app/api/measurements/batch/__tests__/insert-returning-arrivals.test.ts b/src/app/api/measurements/batch/__tests__/insert-returning-arrivals.test.ts index ea49626b6..9b551c5ac 100644 --- a/src/app/api/measurements/batch/__tests__/insert-returning-arrivals.test.ts +++ b/src/app/api/measurements/batch/__tests__/insert-returning-arrivals.test.ts @@ -97,6 +97,9 @@ vi.mock("@/lib/rollups/measurement-rollups", () => ({ recomputeBucketsForMeasurement: vi.fn().mockResolvedValue(undefined), collapseToTypeDayKeys: vi.fn(() => []), })); +vi.mock("@/lib/rollups/after-measurement-mutation", () => ({ + afterMeasurementMutation: vi.fn().mockResolvedValue(undefined), +})); vi.mock("@/lib/daily/morning-refresh-trigger", () => ({ maybeEnqueueMorningRefresh: vi.fn().mockResolvedValue(undefined), })); diff --git a/src/app/api/measurements/batch/__tests__/sync-trigger.test.ts b/src/app/api/measurements/batch/__tests__/sync-trigger.test.ts index 9ad2c0104..fa834286f 100644 --- a/src/app/api/measurements/batch/__tests__/sync-trigger.test.ts +++ b/src/app/api/measurements/batch/__tests__/sync-trigger.test.ts @@ -124,6 +124,9 @@ vi.mock("@/lib/rollups/measurement-rollups", () => ({ recomputeBucketsForMeasurement: vi.fn().mockResolvedValue(undefined), collapseToTypeDayKeys: vi.fn(() => []), })); +vi.mock("@/lib/rollups/after-measurement-mutation", () => ({ + afterMeasurementMutation: vi.fn().mockResolvedValue(undefined), +})); vi.mock("next/headers", () => ({ headers: vi.fn(async () => ({ get: () => null })), diff --git a/src/app/api/measurements/batch/__tests__/timestamp-plausibility.test.ts b/src/app/api/measurements/batch/__tests__/timestamp-plausibility.test.ts index b31590da7..5c79d949e 100644 --- a/src/app/api/measurements/batch/__tests__/timestamp-plausibility.test.ts +++ b/src/app/api/measurements/batch/__tests__/timestamp-plausibility.test.ts @@ -76,6 +76,9 @@ vi.mock("@/lib/rollups/measurement-rollups", () => ({ recomputeBucketsForMeasurement: vi.fn().mockResolvedValue(undefined), collapseToTypeDayKeys: vi.fn(() => []), })); +vi.mock("@/lib/rollups/after-measurement-mutation", () => ({ + afterMeasurementMutation: vi.fn().mockResolvedValue(undefined), +})); vi.mock("@/lib/daily/morning-refresh-trigger", () => ({ maybeEnqueueMorningRefresh: vi.fn().mockResolvedValue(undefined), diff --git a/src/app/api/measurements/batch/__tests__/unstable-external-id.test.ts b/src/app/api/measurements/batch/__tests__/unstable-external-id.test.ts index cc209dea2..e7369d833 100644 --- a/src/app/api/measurements/batch/__tests__/unstable-external-id.test.ts +++ b/src/app/api/measurements/batch/__tests__/unstable-external-id.test.ts @@ -74,6 +74,9 @@ vi.mock("@/lib/rollups/measurement-rollups", () => ({ recomputeBucketsForMeasurement: vi.fn().mockResolvedValue(undefined), collapseToTypeDayKeys: vi.fn(() => []), })); +vi.mock("@/lib/rollups/after-measurement-mutation", () => ({ + afterMeasurementMutation: vi.fn().mockResolvedValue(undefined), +})); vi.mock("@/lib/daily/morning-refresh-trigger", () => ({ maybeEnqueueMorningRefresh: vi.fn().mockResolvedValue(undefined), diff --git a/src/app/api/measurements/batch/route.ts b/src/app/api/measurements/batch/route.ts index 1199e2354..4d5b532bb 100644 --- a/src/app/api/measurements/batch/route.ts +++ b/src/app/api/measurements/batch/route.ts @@ -77,11 +77,7 @@ import { maybeEnqueueMorningRefresh } from "@/lib/daily/morning-refresh-trigger" import { emitDataArrival } from "@/lib/arrivals/emit-shared"; import { groupRowsByArrivalKind } from "@/lib/arrivals/measurement-kind"; import { invalidateUserMeasurements } from "@/lib/cache/invalidate"; -import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate"; -import { - recomputeBucketsForMeasurement, - collapseToTypeDayKeys, -} from "@/lib/rollups/measurement-rollups"; +import { afterMeasurementMutation } from "@/lib/rollups/after-measurement-mutation"; import { Prisma, type MeasurementType } from "@/generated/prisma/client"; // v1.4.25 W16c — historical-backfill threshold for PR push @@ -894,30 +890,10 @@ async function postBatch(request: NextRequest): Promise { // measurement routes already pass `{ evict: true }` for the same reason. invalidateUserMeasurements(user.id, { evict: true }); - // v1.5.0 — refresh the persistent rollup table for every distinct - // (type, day) the batch touched. Updates must participate as well as - // inserts, so this deliberately retains the prepared-row scope. Collapsed - // by day so a 500-row Apple Health batch fires only one recompute per - // type/day pair. Best-effort — a populator hiccup never fails ingest. - try { - const insertedKeys = collapseToTypeDayKeys(writtenIdentities); - for (const k of insertedKeys) { - await recomputeBucketsForMeasurement(user.id, k.type, k.measuredAt); - } - - // v1.8.0 — drop the cached per-metric assessment rows the ingested - // types dirty so the next mount / nightly warm pass regenerates - // them against the new data instead of serving stale text. - // Fire-and-forget: never a blocker on the user's ingest. - invalidateStatusInsightsForTypes( - user.id, - insertedKeys.map((k) => k.type), - ).catch((err) => { - console.warn("[measurements] status-insight invalidate failed", err); - }); - } catch (err) { - console.warn("[measurements] rollup recompute failed", err); - } + // v1.37.19 (C2-F1) — shared post-mutation tail for every distinct + // (type, day) the batch touched. Updates participate as well as + // inserts, so this deliberately retains the prepared-row scope. + await afterMeasurementMutation(user.id, writtenIdentities); // S4 — Apple-Health sleep is the third sleep transport. If any SLEEP_DURATION // row for last night just landed, kick the debounced morning refresh so the diff --git a/src/app/api/measurements/bulk-delete/route.ts b/src/app/api/measurements/bulk-delete/route.ts index b70ab26de..3db8d1032 100644 --- a/src/app/api/measurements/bulk-delete/route.ts +++ b/src/app/api/measurements/bulk-delete/route.ts @@ -30,11 +30,7 @@ import { import { withIdempotency } from "@/lib/idempotency"; import { checkRateLimit } from "@/lib/rate-limit"; import { invalidateUserMeasurements } from "@/lib/cache/invalidate"; -import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate"; -import { - recomputeBucketsForMeasurement, - collapseToTypeDayKeys, -} from "@/lib/rollups/measurement-rollups"; +import { afterMeasurementMutation } from "@/lib/rollups/after-measurement-mutation"; import type { MeasurementType } from "@/generated/prisma/client"; const MAX_IDS_PER_BATCH = 200; @@ -117,36 +113,16 @@ async function postBulkDelete(request: NextRequest): Promise { // SWR readers don't serve the pre-delete body. invalidateUserMeasurements(user.id, { evict: true }); - // Collapse the deleted rows to the unique `(type, day)` set BEFORE - // recomputing so a 200-row delete spanning one day fires ~1 recompute - // per type, not 200 (mirrors the batch-insert path). Best-effort — a - // populator hiccup never fails the user's delete. - const keys = collapseToTypeDayKeys( + // v1.37.19 (C2-F1) — shared post-mutation tail (identities were + // collected BEFORE the destructive statement, while readable). + await afterMeasurementMutation( + user.id, affected.map((row) => ({ type: row.type as MeasurementType, measuredAt: row.measuredAt, })), + "measurements bulk-delete", ); - try { - for (const k of keys) { - await recomputeBucketsForMeasurement(user.id, k.type, k.measuredAt); - } - } catch (err) { - console.warn("[measurements] bulk-delete rollup recompute failed", err); - } - - // v1.8.0 — drop the cached per-metric assessment rows the deletion - // dirties so the next mount / nightly warm pass regenerates against - // the reduced history. Fire-and-forget: never blocks the delete. - const affectedTypes = Array.from(new Set(keys.map((k) => k.type))); - if (affectedTypes.length > 0) { - invalidateStatusInsightsForTypes(user.id, affectedTypes).catch((err) => { - console.warn( - "[measurements] bulk-delete status-insight invalidate failed", - err, - ); - }); - } } return apiSuccess({ deleted: count }); diff --git a/src/app/api/measurements/by-external-ids/route.ts b/src/app/api/measurements/by-external-ids/route.ts index 9914cb12b..82a419b61 100644 --- a/src/app/api/measurements/by-external-ids/route.ts +++ b/src/app/api/measurements/by-external-ids/route.ts @@ -42,11 +42,7 @@ import { safeJson, } from "@/lib/api-response"; import { invalidateUserMeasurements } from "@/lib/cache/invalidate"; -import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate"; -import { - recomputeBucketsForMeasurement, - collapseToTypeDayKeys, -} from "@/lib/rollups/measurement-rollups"; +import { afterMeasurementMutation } from "@/lib/rollups/after-measurement-mutation"; const MAX_BATCH_ENTRIES = 500; @@ -163,29 +159,9 @@ async function deleteByExternalIds(request: NextRequest): Promise { if (result.count > 0) { invalidateUserMeasurements(user.id); - // v1.5.0 — refresh the rollup row for every distinct (type, day) - // tuple the deletion touched. Collapsed by day so the same - // morning's deletes fold into one recompute per type. Best- - // effort — a populator hiccup never fails the user's reconciliation. - try { - const keys = collapseToTypeDayKeys(affectedRows); - for (const k of keys) { - await recomputeBucketsForMeasurement(user.id, k.type, k.measuredAt); - } - } catch (err) { - console.warn("[measurements] rollup recompute failed", err); - } - - // v1.8.0 — drop the cached per-metric assessment rows the deleted - // types dirty so the next mount / nightly warm pass regenerates - // against the reconciled history. Fire-and-forget: never blocks the - // iOS reconciliation. - invalidateStatusInsightsForTypes( - user.id, - affectedRows.map((row) => row.type), - ).catch((err) => { - console.warn("[measurements] status-insight invalidate failed", err); - }); + // v1.37.19 (C2-F1) — shared post-mutation tail (identities collected + // BEFORE the destructive statement, while readable). + await afterMeasurementMutation(user.id, affectedRows); } return apiSuccess({ deletedCount: result.count }); diff --git a/src/app/api/measurements/restore/route.ts b/src/app/api/measurements/restore/route.ts index 8c2231473..d413ce609 100644 --- a/src/app/api/measurements/restore/route.ts +++ b/src/app/api/measurements/restore/route.ts @@ -32,11 +32,7 @@ import { import { withIdempotency } from "@/lib/idempotency"; import { checkRateLimit } from "@/lib/rate-limit"; import { invalidateUserMeasurements } from "@/lib/cache/invalidate"; -import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate"; -import { - recomputeBucketsForMeasurement, - collapseToTypeDayKeys, -} from "@/lib/rollups/measurement-rollups"; +import { afterMeasurementMutation } from "@/lib/rollups/after-measurement-mutation"; import type { MeasurementType } from "@/generated/prisma/client"; const MAX_IDS_PER_BATCH = 200; @@ -116,35 +112,16 @@ async function postRestore(request: NextRequest): Promise { // hard-evict so the SWR readers don't serve the pre-restore body. invalidateUserMeasurements(user.id, { evict: true }); - // Collapse the restored rows to the unique `(type, day)` set BEFORE - // recomputing — mirrors the bulk-delete path. Best-effort: a - // populator hiccup never fails the user's restore. - const keys = collapseToTypeDayKeys( + // v1.37.19 (C2-F1) — shared post-mutation tail; mirrors the + // bulk-delete path. + await afterMeasurementMutation( + user.id, affected.map((row) => ({ type: row.type as MeasurementType, measuredAt: row.measuredAt, })), + "measurements restore", ); - try { - for (const k of keys) { - await recomputeBucketsForMeasurement(user.id, k.type, k.measuredAt); - } - } catch (err) { - console.warn("[measurements] restore rollup recompute failed", err); - } - - // Drop the cached per-metric assessment rows the restore dirties so - // the next mount / nightly warm pass regenerates against the - // restored history. Fire-and-forget: never blocks the restore. - const affectedTypes = Array.from(new Set(keys.map((k) => k.type))); - if (affectedTypes.length > 0) { - invalidateStatusInsightsForTypes(user.id, affectedTypes).catch((err) => { - console.warn( - "[measurements] restore status-insight invalidate failed", - err, - ); - }); - } } return apiSuccess({ restored: count }); diff --git a/src/app/api/measurements/route.ts b/src/app/api/measurements/route.ts index 790c02efc..0856b77ef 100644 --- a/src/app/api/measurements/route.ts +++ b/src/app/api/measurements/route.ts @@ -35,13 +35,9 @@ import { encryptNote, shapeMeasurementNotes } from "@/lib/crypto/note-cipher"; import { invalidateUserMeasurements } from "@/lib/cache/invalidate"; import { emitInsertedMeasurementArrivals } from "@/lib/arrivals/measurement-emit"; import { maybeEnqueueMorningRefresh } from "@/lib/daily/morning-refresh-trigger"; -import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate"; import { enqueueReminderSatisfy } from "@/lib/jobs/reminder-satisfy"; import { runSafetyFloorCheck } from "@/lib/illness/safety-floor-check"; -import { - recomputeBucketsForMeasurement, - collapseToTypeDayKeys, -} from "@/lib/rollups/measurement-rollups"; +import { afterMeasurementMutation } from "@/lib/rollups/after-measurement-mutation"; import { loadUserSourcePriority } from "@/lib/rollups/measurement-read"; import { readDailySeries } from "@/lib/measurements/daily-series-read"; import { @@ -908,32 +904,14 @@ async function postMeasurement(request: NextRequest) { { action: "measurement.safety_floor.check" }, ); - // v1.5.0 — refresh the persistent rollup table for every distinct - // (type, day) the batch touched so the next analytics / coach read - // hits the cache rather than falling through to live aggregation. - // Collapsed by day so a multi-entry batch on the same morning fires - // one DAY recompute per type instead of one per row. Best-effort - // — a populator hiccup never fails the user's write. - try { - const keys = collapseToTypeDayKeys( - results.map((r) => ({ type: r.type, measuredAt: r.measuredAt })), - ); - for (const k of keys) { - await recomputeBucketsForMeasurement(user.id, k.type, k.measuredAt); - } - - // v1.8.0 — drop the cached per-metric assessment rows the ingested - // types dirty so the next mount / nightly warm pass regenerates - // them against the new data. Fire-and-forget: never blocks ingest. - invalidateStatusInsightsForTypes( - user.id, - keys.map((k) => k.type), - ).catch((err) => { - console.warn("[measurements] status-insight invalidate failed", err); - }); - } catch (err) { - console.warn("[measurements] rollup recompute failed", err); - } + // v1.37.19 (C2-F1) — the shared post-mutation tail: rollup recompute + // (collapsed per (type, day)) + status-insight re-warm, both + // best-effort. One helper so a future write surface cannot remember + // one leg and forget the other. + await afterMeasurementMutation( + user.id, + results.map((r) => ({ type: r.type, measuredAt: r.measuredAt })), + ); return apiSuccess(shapedResults, 201); } @@ -1080,22 +1058,9 @@ async function postMeasurement(request: NextRequest) { // pg-boss; WEEK / MONTH / YEAR recomputes are enqueued under the // hood. Best-effort — a populator hiccup never fails the user's // write. - try { - await recomputeBucketsForMeasurement( - user.id, - measurement.type, - measurement.measuredAt, - ); - } catch (err) { - console.warn("[measurements] rollup recompute failed", err); - } - - // v1.8.0 — drop the cached per-metric assessment rows this type - // dirties so the next mount / nightly warm pass regenerates against - // the new row. Fire-and-forget: never blocks the user's write. - invalidateStatusInsightsForTypes(user.id, [measurement.type]).catch((err) => { - console.warn("[measurements] status-insight invalidate failed", err); - }); + await afterMeasurementMutation(user.id, [ + { type: measurement.type, measuredAt: measurement.measuredAt }, + ]); return apiSuccess(shapeMeasurementNotes(measurement), 201); } diff --git a/src/app/api/medications/[id]/glp1/route.ts b/src/app/api/medications/[id]/glp1/route.ts index e32baa702..c9f76d0c5 100644 --- a/src/app/api/medications/[id]/glp1/route.ts +++ b/src/app/api/medications/[id]/glp1/route.ts @@ -110,7 +110,12 @@ export const GET = apiHandler( prefsRow?.notificationPrefs ?? null, medication.reorderLeadDays, ); - const schedules: RunwaySchedule[] = medication.schedules; + const schedules: RunwaySchedule[] = medication.schedules.map((sched) => ({ + ...sched, + // Decimal → number so the slot-aware runway math reads the wire shape. + unitsPerDose: + sched.unitsPerDose === null ? null : Number(sched.unitsPerDose), + })); /** runway ≤ effective trigger AND the alert is enabled. */ const isLowStock = (dosesRemaining: number): boolean => { if (runwayFloor === null) return false; diff --git a/src/app/api/medications/[id]/route.ts b/src/app/api/medications/[id]/route.ts index 6dd257d6e..97be7fbd8 100644 --- a/src/app/api/medications/[id]/route.ts +++ b/src/app/api/medications/[id]/route.ts @@ -33,6 +33,10 @@ import { recomputeMedicationComplianceForDay, } from "@/lib/rollups/medication-compliance-rollups"; import { assertMedicationOwnership } from "@/lib/medications/route-guards"; +import { + effectiveUnitsPerDose, + estimateUnitsRunwayDays, +} from "@/components/medications/detail/supply-runway"; import { serializeScheduleUnitsPerDose } from "@/lib/medications/schedule-units-dto"; import { hhmmToMinutesOrNull } from "@/lib/medications/scheduling/hhmm"; import { getUserTodayBounds } from "@/lib/tz/local-day"; @@ -177,13 +181,59 @@ export const GET = apiHandler( }, }); + // v1.37.19 — stock + runway on the detail wire, mirroring the list + // payload's semantics: NULL stock = inventory tracking off, 0 = the + // supply ran out; runwayDays via the slot-aware burn rate. Published + // resolved so no client re-derives the inheritance / cadence math. + const [usableStock, anyItemCount] = await Promise.all([ + prisma.medicationInventoryItem.aggregate({ + where: { + userId: user.id, + medicationId: id, + state: { in: ["ACTIVE", "IN_USE"] }, + unitsRemaining: { gt: 0 }, + }, + _sum: { unitsRemaining: true }, + }), + prisma.medicationInventoryItem.count({ + where: { userId: user.id, medicationId: id }, + }), + ]); + const schedulesDto = serializeScheduleUnitsPerDose( + medication.schedules, + medication.unitsPerDose, + ); + const stockUnitsRemaining = + anyItemCount > 0 ? Number(usableStock._sum.unitsRemaining ?? 0) : null; + const stockDosesRemaining = + stockUnitsRemaining === null + ? null + : Math.floor( + stockUnitsRemaining / + effectiveUnitsPerDose( + schedulesDto, + Number(medication.unitsPerDose), + ), + ); + const runwayDays = + stockUnitsRemaining === null + ? null + : estimateUnitsRunwayDays( + stockUnitsRemaining, + schedulesDto, + Number(medication.unitsPerDose), + ); + return apiSuccess({ ...medication, unitsPerDose: Number(medication.unitsPerDose), - schedules: serializeScheduleUnitsPerDose(medication.schedules), + schedules: schedulesDto, category, nextDueAt: display ? display.at.toISOString() : null, nextDueOverdue: display?.overdue ?? false, + stockUnitsRemaining, + stockDosesRemaining, + runwayDays, }); }, ); @@ -836,7 +886,10 @@ export const PUT = apiHandler( return apiSuccess({ ...medication, unitsPerDose: Number(medication.unitsPerDose), - schedules: serializeScheduleUnitsPerDose(medication.schedules), + schedules: serializeScheduleUnitsPerDose( + medication.schedules, + medication.unitsPerDose, + ), category: normalizedCategory, }); }, diff --git a/src/app/api/medications/route.ts b/src/app/api/medications/route.ts index 4a90d9936..1c51f2860 100644 --- a/src/app/api/medications/route.ts +++ b/src/app/api/medications/route.ts @@ -95,7 +95,10 @@ async function respondWithExistingMirror( ...medication, unitsPerDose: Number(medication.unitsPerDose), // #219 — Decimal → number for the per-schedule column too. - schedules: serializeScheduleUnitsPerDose(medication.schedules), + schedules: serializeScheduleUnitsPerDose( + medication.schedules, + Number(medication.unitsPerDose), + ), category, }); } @@ -413,7 +416,10 @@ export const POST = apiHandler(async (request: NextRequest) => { ...medication, unitsPerDose: Number(medication.unitsPerDose), // #219 — Decimal → number for the per-schedule column too. - schedules: serializeScheduleUnitsPerDose(medication.schedules), + schedules: serializeScheduleUnitsPerDose( + medication.schedules, + medication.unitsPerDose, + ), category: normalizedCategory, }, 201, diff --git a/src/app/api/mental-health/assessments/route.ts b/src/app/api/mental-health/assessments/route.ts index bd4253f9b..c89f61540 100644 --- a/src/app/api/mental-health/assessments/route.ts +++ b/src/app/api/mental-health/assessments/route.ts @@ -27,6 +27,7 @@ import { sanitiseZodIssues, } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; +import { afterMeasurementMutation } from "@/lib/rollups/after-measurement-mutation"; import { auditLog } from "@/lib/auth/audit"; import { withIdempotency } from "@/lib/idempotency"; import { enqueueReminderSatisfy } from "@/lib/jobs/reminder-satisfy"; @@ -355,6 +356,17 @@ async function postAssessment(request: NextRequest): Promise { }); invalidateUserHealthScore(user.id); + // v1.37.19 (A6-13) — the projection row is a Measurement like any other: + // route it through the shared post-mutation tail so the (type, day) + // rollup bucket and the cached status assessment converge without + // waiting for a nightly discovery pass. + await afterMeasurementMutation(user.id, [ + { + type: INSTRUMENT_MEASUREMENT_TYPE[id] as MeasurementType, + measuredAt: when, + }, + ]); + // v1.27.6 — a screening can be planned as a Vorsorge reminder keyed on // PHQ9_SCORE / GAD7_SCORE. Kick the eventful satisfy worker so completing // a check-in resolves the reminder immediately (the ingest-route diff --git a/src/app/api/mood-entries/bulk/__tests__/route.test.ts b/src/app/api/mood-entries/bulk/__tests__/route.test.ts index 9062e59dc..e897edbdc 100644 --- a/src/app/api/mood-entries/bulk/__tests__/route.test.ts +++ b/src/app/api/mood-entries/bulk/__tests__/route.test.ts @@ -19,7 +19,12 @@ import { NextRequest } from "next/server"; // assertion below is vacuous. It SHARES the model mocks so every existing // per-model assertion in this file keeps working unchanged. const { moodEntryMock, txClient } = vi.hoisted(() => { - const moodEntryMock = { findMany: vi.fn(), upsert: vi.fn() }; + const moodEntryMock = { + findMany: vi.fn(), + upsert: vi.fn(), + // v1.37.19 (A6-18) — the P2002-race arm resolves the winning row. + findFirst: vi.fn(), + }; return { moodEntryMock, txClient: { __brand: "transaction-client", moodEntry: moodEntryMock }, @@ -462,6 +467,49 @@ describe("POST /api/mood-entries/bulk — structured tagKeys (v1.12.0)", () => { expect(json.data.entries[0].status).toBe("skipped"); expect(json.data.entries[1].status).toBe("inserted"); }); + + // Watched red: with the P2002 arm removed from the loop catch (the + // pre-v1.37.19 route), this fails — a concurrent batch losing the + // `(userId, source, externalId)` race was reported as "skipped" with a + // raw Prisma message although the row IS stored (the racing twin wrote + // it). It is the plain duplicate the sequential probe would have found. + it("classifies a lost P2002 race as duplicate with the winning row id", async () => { + const p2002 = Object.assign(new Error("Unique constraint failed"), { + code: "P2002", + }); + vi.mocked(moodEntryMock.upsert).mockRejectedValueOnce(p2002); + vi.mocked(moodEntryMock.findFirst).mockResolvedValueOnce({ + id: "winner-1", + } as never); + vi.mocked(moodEntryMock.findMany).mockResolvedValueOnce([] as never); + + const res = await POST( + postReq({ + entries: [ + { + mood: "GUT", + moodLoggedAt: "2026-05-16T08:00:00.000Z", + source: "MOODLOG", + externalId: "hk-race-1", + }, + ], + }), + ); + expect(res.status).toBe(200); + const json = (await res.json()) as { + data: { + inserted: number; + duplicates: number; + skipped: unknown[]; + entries: Array<{ status: string; id?: string; externalId?: string }>; + }; + }; + expect(json.data.entries[0].status).toBe("duplicate"); + expect(json.data.entries[0].id).toBe("winner-1"); + expect(json.data.duplicates).toBe(1); + expect(json.data.inserted).toBe(0); + expect(json.data.skipped).toEqual([]); + }); }); describe("POST /api/mood-entries/bulk — unstable external ids", () => { diff --git a/src/app/api/mood-entries/bulk/route.ts b/src/app/api/mood-entries/bulk/route.ts index 00a50e75b..29bb815d7 100644 --- a/src/app/api/mood-entries/bulk/route.ts +++ b/src/app/api/mood-entries/bulk/route.ts @@ -522,6 +522,39 @@ async function postBulk(request: NextRequest): Promise { }); } } catch (err: unknown) { + // v1.37.19 (A6-18) — P2002 = a concurrent batch raced the same + // NULL-distinct `(userId, source, externalId)` key and won. That is + // the plain duplicate the sequential probe would have classified; + // reporting it as "skipped" with a raw Prisma message told the + // client its row was dropped when it is in fact stored. Resolve the + // winning row's id so the caller's cursor advances, exactly like + // the intake bulk route. + if ( + typeof err === "object" && + err !== null && + "code" in err && + (err as { code: string }).code === "P2002" && + entry.externalId + ) { + const winner = await prisma.moodEntry.findFirst({ + where: { + userId: user.id, + // Same resolution as the upsert key: the schema defaults + // `source` to "MANUAL" at parse time. + source: entry.source, + externalId: entry.externalId, + }, + select: { id: true }, + }); + duplicates += 1; + results.push({ + index: i, + status: "duplicate", + ...(winner ? { id: winner.id } : {}), + externalId: entry.externalId, + }); + continue; + } const reason = err instanceof Error ? err.message.slice(0, 120) : "upsert_failed"; skipped.push({ index: i, reason }); diff --git a/src/app/privacy/page.tsx b/src/app/privacy/page.tsx index 6be6417ef..915eb8666 100644 --- a/src/app/privacy/page.tsx +++ b/src/app/privacy/page.tsx @@ -2057,6 +2057,18 @@ export default function PrivacyPage() { . Policy version {POLICY_VERSION}. Last updated {LAST_UPDATED}.

+ {/* The GeoLite2 EULA wants the attribution page reachable from the + running app — this and the Settings→About row are the two + inbound links to /about. */} +

+ + About HealthLog — credits & data attributions + +

diff --git a/src/app/settings/[section]/page.tsx b/src/app/settings/[section]/page.tsx index ee1f341af..572123d39 100644 --- a/src/app/settings/[section]/page.tsx +++ b/src/app/settings/[section]/page.tsx @@ -10,27 +10,16 @@ import { AdvancedSection } from "@/components/settings/advanced-section"; import { AiSection } from "@/components/settings/ai-section"; import { ApiSection } from "@/components/settings/api-section"; import { CoachSection } from "@/components/settings/coach-section"; -import { DashboardSection } from "@/components/settings/dashboard-section"; import { EnvironmentSection } from "@/components/settings/environment-section"; import { ExportSection } from "@/components/settings/export-section"; import { GesundheitsakteSection } from "@/components/settings/gesundheitsakte-section"; -import { IllnessSection } from "@/components/settings/illness-section"; -import { InsightsSection } from "@/components/settings/insights-section"; import { IntegrationsSection } from "@/components/settings/integrations-section"; -import { LabsSection } from "@/components/settings/labs-section"; import { LayoutSection } from "@/components/settings/layout-section"; -import { MedicationsSection } from "@/components/settings/medications-section"; import { McpSection } from "@/components/settings/mcp-section"; import { ModulesSection } from "@/components/settings/modules-section"; -import { MoodSection } from "@/components/settings/mood-section"; import { NotificationsSection } from "@/components/settings/notifications-section"; import { PrivacySection } from "@/components/settings/privacy-section"; import { ScoreSection } from "@/components/settings/score-section"; -import { SectionPlaceholder } from "@/components/settings/section-placeholder"; -import { VorsorgeSection } from "@/components/settings/vorsorge-section"; -// v1.18.1 (D4) — `sources` is a standalone left-side entry (split out of the -// Integrations sub-tabs). v1.25.3 — `channels` folded into Notifications. -import { SharingSection } from "@/components/settings/sharing-section"; import { SourcesSection } from "@/components/settings/sources-section"; import { ThresholdsSection } from "@/components/settings/thresholds-section"; import { @@ -72,21 +61,13 @@ const SECTION_COMPONENTS: Record< sources: SourcesSection, notifications: NotificationsSection, layout: LayoutSection, - dashboard: DashboardSection, - insights: InsightsSection, - medications: MedicationsSection, - mood: MoodSection, - labs: LabsSection, - illness: IllnessSection, environment: EnvironmentSection, anamnesis: AnamnesisSection, score: ScoreSection, - vorsorge: VorsorgeSection, thresholds: ThresholdsSection, api: ApiSection, mcp: McpSection, gesundheitsakte: GesundheitsakteSection, - sharing: SharingSection, export: ExportSection, advanced: AdvancedSection, privacy: PrivacySection, @@ -107,32 +88,25 @@ export default async function SettingsSectionPage({ params }: PageProps) { notFound(); } + // `SECTION_COMPONENTS` is an exhaustive Record over the slug union, so a + // slug without a wired component is a compile error — the old runtime + // `` fallback could never render and was removed. const SectionComponent = SECTION_COMPONENTS[section]; - // Defensive fallback: in theory unreachable since `isSettingsSectionSlug` - // guards the slug, but a future slug added to `SETTINGS_SECTION_SLUGS` - // without a wired component would otherwise crash silently — placeholder - // surfaces the gap visually instead. // v1.18.6.1 — the heading + subtitle (and the Layout-hub "← back" link) - // now live in ``, which places them in their own grid row + // live in ``, which places them in their own grid row // spanning only the content column so the left nav's first item lines up // with the top of the first card. The page body is pure card content, // wrapped in the labelled `
` so the historic // `settings-section--title` `aria-labelledby` linkage still resolves. - const body = SectionComponent ? ( -
- -
- ) : ( - - ); - return ( - {body} +
+ +
); diff --git a/src/app/settings/page.tsx b/src/app/settings/page.tsx index dd87ccfd6..ad45441fa 100644 --- a/src/app/settings/page.tsx +++ b/src/app/settings/page.tsx @@ -5,10 +5,8 @@ import { permanentRedirect } from "next/navigation"; * * Pre-v1.4 the entire settings UI lived in this single 3000+ LOC file. As part * of the v1.4 settings split (PR series A2), each concern moved to its own - * route under `/settings/[section]/page.tsx`. The historic monolith is - * preserved verbatim in `page.legacy.tsx` for section-by-section extraction in - * the follow-up PRs (A2-account, A2-about, A2-ai, A2-integrations, - * A2-notifications, A2-rest). + * route under `/settings/[section]/page.tsx`; the extraction finished long + * ago and the historic monolith is gone. * * 308 (`permanentRedirect`) keeps the request method intact and is the right * answer for a permanent address change — bookmarks, deep-links, and any diff --git a/src/components/dashboard/__tests__/recent-workouts-tile.test.tsx b/src/components/dashboard/__tests__/recent-workouts-tile.test.tsx index cfea873bc..e57a6e70f 100644 --- a/src/components/dashboard/__tests__/recent-workouts-tile.test.tsx +++ b/src/components/dashboard/__tests__/recent-workouts-tile.test.tsx @@ -92,7 +92,7 @@ describe("", () => { expect(html).not.toContain('data-slot="recent-workouts-empty"'); }); - it("renders the empty-state with the Apple-Health onboarding cue", () => { + it("renders the empty-state with the source-neutral onboarding cue", () => { mockResult.data = { workouts: [], meta: { total: 0, limit: 3, offset: 0, droppedDuplicates: 0 }, @@ -100,7 +100,10 @@ describe("", () => { const html = render(); expect(html).toContain('data-slot="recent-workouts-empty"'); expect(html).toContain("No workouts yet"); - expect(html).toContain("Apple Health"); + // v1.37.19 (A1-7) — the CTA no longer assumes an iPhone with Apple + // Health; WHOOP/Polar/Oura/Withings users get the same honest cue. + expect(html).toContain("Connect a data source in Settings"); + expect(html).not.toContain("Apple Health"); }); it("renders the populated list with deep-links into each detail page", () => { diff --git a/src/components/dashboard/range-display.tsx b/src/components/dashboard/range-display.tsx index 4c16758f5..66256bac1 100644 --- a/src/components/dashboard/range-display.tsx +++ b/src/components/dashboard/range-display.tsx @@ -21,7 +21,7 @@ export interface RangeDisplayConfig { * mean / median / avg7 / avg30 / avg30LastMonth / avg30LastYear) divides * by 60; the slope tuples scale their `slope` (per-day rate) by the same * factor so the trend arrow stays consistent; count / direction / - * confidence / anomalyCount are unit-free and pass through. + * confidence are unit-free and pass through. */ export function toHoursSummary(s: DataSummary): DataSummary { const h = (v: number | null | undefined): number | null => @@ -43,7 +43,6 @@ export function toHoursSummary(s: DataSummary): DataSummary { avg30LastYear: h(s.avg30LastYear), slope7: scaleSlope(s.slope7), slope30: scaleSlope(s.slope30), - slope90: scaleSlope(s.slope90), }; } diff --git a/src/components/documents/__tests__/use-document-upload-settle.test.ts b/src/components/documents/__tests__/use-document-upload-settle.test.ts new file mode 100644 index 000000000..baf75b8a6 --- /dev/null +++ b/src/components/documents/__tests__/use-document-upload-settle.test.ts @@ -0,0 +1,44 @@ +/** + * The upload manager's settle decision (§3.2 + the fresh-upload flash). + * + * Watched red: with `settleDecision` returning `flashId: null` on the + * non-duplicate branch (the pre-v1.37.19 behaviour — only the duplicate + * path flashed the row), the fresh-upload case below fails. A fresh upload + * used to give no feedback beyond its queue row disappearing. + */ +import { describe, expect, it } from "vitest"; + +import { settleDecision } from "../use-document-upload"; +import type { UploadResult } from "../vault-utils"; + +function success(duplicate: boolean): UploadResult { + return { + ok: true, + duplicate, + document: { id: "doc-1" }, + } as unknown as UploadResult; +} + +describe("settleDecision", () => { + it("flashes the NEW row on a fresh upload (no toast)", () => { + expect(settleDecision(success(false))).toEqual({ + removeFromQueue: true, + toastDuplicate: false, + flashId: "doc-1", + }); + }); + + it("flashes the EXISTING row and toasts on a duplicate", () => { + expect(settleDecision(success(true))).toEqual({ + removeFromQueue: true, + toastDuplicate: true, + flashId: "doc-1", + }); + }); + + it("keeps a failed upload in the queue with no flash", () => { + expect( + settleDecision({ ok: false, reason: "generic" } as UploadResult), + ).toEqual({ removeFromQueue: false, toastDuplicate: false, flashId: null }); + }); +}); diff --git a/src/components/documents/use-document-upload.ts b/src/components/documents/use-document-upload.ts index 8f7977a29..ee6f5c1d2 100644 --- a/src/components/documents/use-document-upload.ts +++ b/src/components/documents/use-document-upload.ts @@ -136,6 +136,29 @@ function uploadViaXhr( }); } +/** + * The pure settle decision — what a finished upload does to the UI. + * + * Every success flashes the row (fresh upload → the new row, duplicate → + * the existing row); only the duplicate additionally toasts. Exported for + * the unit test: until v1.37.19 only the duplicate branch flashed, so a + * fresh upload gave no feedback beyond its queue row disappearing. + */ +export function settleDecision(result: UploadResult): { + removeFromQueue: boolean; + toastDuplicate: boolean; + flashId: string | null; +} { + if (!result.ok) { + return { removeFromQueue: false, toastDuplicate: false, flashId: null }; + } + return { + removeFromQueue: true, + toastDuplicate: result.duplicate, + flashId: result.document.id, + }; +} + export interface DocumentUploadManager { /** Live queue — uploading entries first-in-first, then error entries. */ items: UploadQueueItem[]; @@ -196,16 +219,17 @@ export function useDocumentUpload( const settle = useCallback( (localId: string, result: UploadResult) => { - if (result.ok) { - removeItem(localId); - void invalidateKeys(queryClient, [queryKeys.documents()]); - if (result.duplicate) { - toast.info(t("documents.toast.duplicate")); - flashHighlight(result.document.id); - } + if (!result.ok) { + patchItem(localId, { status: "error", failure: result, progress: 0 }); return; } - patchItem(localId, { status: "error", failure: result, progress: 0 }); + const decision = settleDecision(result); + removeItem(localId); + void invalidateKeys(queryClient, [queryKeys.documents()]); + if (decision.toastDuplicate) { + toast.info(t("documents.toast.duplicate")); + } + if (decision.flashId) flashHighlight(decision.flashId); }, [flashHighlight, patchItem, queryClient, removeItem, t], ); diff --git a/src/components/insights/__tests__/device-score-surfaces.test.tsx b/src/components/insights/__tests__/device-score-surfaces.test.tsx index 4aa8994ed..b97208e08 100644 --- a/src/components/insights/__tests__/device-score-surfaces.test.tsx +++ b/src/components/insights/__tests__/device-score-surfaces.test.tsx @@ -64,7 +64,6 @@ function summary(over: Partial): DataSummary { avg30: null, slope7: null, slope30: null, - slope90: null, anomalyCount: 0, ...over, }; diff --git a/src/components/insights/__tests__/insights-edit-mode-manager.test.tsx b/src/components/insights/__tests__/insights-edit-mode-manager.test.tsx index f9b21c002..d39d4fad5 100644 --- a/src/components/insights/__tests__/insights-edit-mode-manager.test.tsx +++ b/src/components/insights/__tests__/insights-edit-mode-manager.test.tsx @@ -52,7 +52,7 @@ describe(" — detail-page manager retired (v1.15.20)", () => expect(html).toContain( 'href="/settings/layout/insights#insights-pill-order"', ); - expect(html).toContain("Manage pills & detail pages in Settings"); + expect(html).toContain("Manage chips & detail pages in Settings"); }); it("still renders the section rows with their eye toggles", () => { diff --git a/src/components/insights/__tests__/insights-tab-strip-memo.test.tsx b/src/components/insights/__tests__/insights-tab-strip-memo.test.tsx index 6345c1c0c..84fe96a32 100644 --- a/src/components/insights/__tests__/insights-tab-strip-memo.test.tsx +++ b/src/components/insights/__tests__/insights-tab-strip-memo.test.tsx @@ -25,7 +25,6 @@ function fakeSummary(count: number): DataSummary { avg30: null, slope7: null, slope30: null, - slope90: null, anomalyCount: 0, }; } diff --git a/src/components/insights/__tests__/insights-tab-strip.test.tsx b/src/components/insights/__tests__/insights-tab-strip.test.tsx index d8e103e8a..3aedbc6f9 100644 --- a/src/components/insights/__tests__/insights-tab-strip.test.tsx +++ b/src/components/insights/__tests__/insights-tab-strip.test.tsx @@ -36,7 +36,6 @@ function fakeSummary(count: number): DataSummary { avg30: null, slope7: null, slope30: null, - slope90: null, anomalyCount: 0, }; } diff --git a/src/components/insights/__tests__/metric-stat-strip.test.tsx b/src/components/insights/__tests__/metric-stat-strip.test.tsx index c85632ecc..051a28c65 100644 --- a/src/components/insights/__tests__/metric-stat-strip.test.tsx +++ b/src/components/insights/__tests__/metric-stat-strip.test.tsx @@ -36,7 +36,6 @@ const populated: DataSummary = { avg30: 70, slope7: null, slope30: null, - slope90: null, anomalyCount: 0, avg30LastMonth: null, avg30LastYear: null, diff --git a/src/components/insights/glucose/__tests__/glucose-clinical-panel.test.tsx b/src/components/insights/glucose/__tests__/glucose-clinical-panel.test.tsx index 51b5b8359..cd3a60b1e 100644 --- a/src/components/insights/glucose/__tests__/glucose-clinical-panel.test.tsx +++ b/src/components/insights/glucose/__tests__/glucose-clinical-panel.test.tsx @@ -20,7 +20,6 @@ function summary(count: number): DataSummary { avg30: null, slope7: null, slope30: null, - slope90: null, anomalyCount: 0, }; } diff --git a/src/components/measurement-reminders/vorsorge-dashboard-card.tsx b/src/components/measurement-reminders/vorsorge-dashboard-card.tsx index 543dbf566..ac5c57599 100644 --- a/src/components/measurement-reminders/vorsorge-dashboard-card.tsx +++ b/src/components/measurement-reminders/vorsorge-dashboard-card.tsx @@ -147,6 +147,7 @@ export function VorsorgeDashboardCard() { size="sm" icon={CalendarClock} title={t("measurementReminders.sectionTitle")} + titleAs="h2" /> diff --git a/src/components/measurement-reminders/vorsorge-section.tsx b/src/components/measurement-reminders/vorsorge-section.tsx index 66b0b30dd..74f771938 100644 --- a/src/components/measurement-reminders/vorsorge-section.tsx +++ b/src/components/measurement-reminders/vorsorge-section.tsx @@ -1011,8 +1011,10 @@ function VorsorgeCard({ if (view === "list") { return ( <> - - + {/* Density lives on the Card (py-3 vs the default py-4 md:py-6); + the content slot keeps its own px-4 md:px-6 contract. */} + +
diff --git a/src/components/measurements/measurement-list.tsx b/src/components/measurements/measurement-list.tsx index 912e9c5d7..8b7b25a69 100644 --- a/src/components/measurements/measurement-list.tsx +++ b/src/components/measurements/measurement-list.tsx @@ -625,7 +625,9 @@ export function MeasurementList({ await refetchInactiveDailyReads(queryClient); clearSelection(); toast.success( - t("measurements.bulkDeleteSuccess", { count: String(deleted) }), + deleted === 1 + ? t("measurements.bulkDeleteSuccessOne") + : t("measurements.bulkDeleteSuccess", { count: String(deleted) }), { action: { label: t("common.undo"), @@ -1389,7 +1391,9 @@ export function MeasurementList({ )}

{m.notes && ( -

+ // User data is never muted — the desktop table + // renders the same note in the foreground colour. +

{truncateComment(m.notes)}

)} @@ -1462,12 +1466,20 @@ export function MeasurementList({ onClear={clearSelection} onConfirmDelete={onConfirmBulkDelete} isDeleting={bulkDeleteMutation.isPending} - confirmTitle={t("measurements.bulkDeleteConfirmTitle", { - count: String(selectedOnPage), - })} - confirmBody={t("measurements.bulkDeleteConfirmBody", { - count: String(selectedOnPage), - })} + confirmTitle={ + selectedOnPage === 1 + ? t("measurements.bulkDeleteConfirmTitleOne") + : t("measurements.bulkDeleteConfirmTitle", { + count: String(selectedOnPage), + }) + } + confirmBody={ + selectedOnPage === 1 + ? t("measurements.bulkDeleteConfirmBodyOne") + : t("measurements.bulkDeleteConfirmBody", { + count: String(selectedOnPage), + }) + } /> {/* Pagination */} diff --git a/src/components/medications/__tests__/inventory-section-dates.test.tsx b/src/components/medications/__tests__/inventory-section-dates.test.tsx index 2e52e26dc..adf6c3dd5 100644 --- a/src/components/medications/__tests__/inventory-section-dates.test.tsx +++ b/src/components/medications/__tests__/inventory-section-dates.test.tsx @@ -18,6 +18,17 @@ import { renderToStaticMarkup } from "react-dom/server"; import { I18nProvider } from "@/lib/i18n/context"; +// Now-anchored future expiry (the fixed 2027-06-01 was a fuse: once the +// calendar passes it, an "expiring later" fixture silently becomes an +// expired one and the assertions rot). ~300 days out keeps it a plainly +// future date on every run; the expected strings derive from the same +// instant so they can never drift from the fixture. +const FUTURE_EXPIRY = new Date(Date.now() + 300 * 24 * 60 * 60 * 1000); +const FUTURE_EXPIRY_YMD = FUTURE_EXPIRY.toISOString().slice(0, 10); +const FUTURE_EXPIRY_ISO = `${FUTURE_EXPIRY_YMD}T00:00:00.000Z`; +const [FE_Y, FE_M, FE_D] = FUTURE_EXPIRY_YMD.split("-"); +const FUTURE_EXPIRY_DE = `${FE_D}.${FE_M}.${FE_Y}`; + const src = readFileSync( resolve( process.cwd(), @@ -135,13 +146,13 @@ describe(" — item-row dates", () => { ...BASE, state: "ACTIVE", firstUseAt: null, - printedExpiry: "2027-06-01T00:00:00.000Z", + printedExpiry: FUTURE_EXPIRY_ISO, }, ]); const html = render( , ); - expect(html).toContain("Haltbar bis 01.06.2027"); + expect(html).toContain(`Haltbar bis ${FUTURE_EXPIRY_DE}`); }); it("separates the two dates when the row carries both", () => { @@ -149,14 +160,14 @@ describe(" — item-row dates", () => { { ...BASE, firstUseAt: "2026-06-12T00:00:00.000Z", - printedExpiry: "2027-06-01T00:00:00.000Z", + printedExpiry: FUTURE_EXPIRY_ISO, }, ]); const html = render( , ); expect(html).toContain("Geöffnet 12.06.2026"); - expect(html).toContain("Haltbar bis 01.06.2027"); + expect(html).toContain(`Haltbar bis ${FUTURE_EXPIRY_DE}`); expect(html).toContain("·"); }); @@ -174,7 +185,7 @@ describe(" — editable dates", () => { const item: Row = { ...BASE, firstUseAt: "2026-06-12T00:00:00.000Z", - printedExpiry: "2027-06-01T00:00:00.000Z", + printedExpiry: FUTURE_EXPIRY_ISO, }; function renderDialog(over: Partial = {}): string { @@ -191,7 +202,7 @@ describe(" — editable dates", () => { const html = renderDialog(); expect(html).toContain('id="inventory-edit-expiry"'); expect(html).toContain("Aufgedrucktes Haltbarkeitsdatum"); - expect(html).toContain('value="2027-06-01"'); + expect(html).toContain(`value="${FUTURE_EXPIRY_YMD}"`); }); it("offers an opening-date field prefilled from the item", () => { @@ -205,7 +216,7 @@ describe(" — editable dates", () => { const html = renderDialog({ firstUseAt: null, printedExpiry: null }); expect(html).toContain('id="inventory-edit-expiry"'); expect(html).toContain('id="inventory-edit-opened"'); - expect(html).not.toContain("2027-06-01"); + expect(html).not.toContain(FUTURE_EXPIRY_YMD); expect(html).not.toContain("2026-06-12"); }); diff --git a/src/components/medications/__tests__/supply-runway.test.ts b/src/components/medications/__tests__/supply-runway.test.ts index 77ea502d7..fc8deee61 100644 --- a/src/components/medications/__tests__/supply-runway.test.ts +++ b/src/components/medications/__tests__/supply-runway.test.ts @@ -8,8 +8,11 @@ import { describe, it, expect } from "vitest"; import { cadenceIntervalDays, classifyLowStockState, + effectiveUnitsPerDose, estimateDailyDoseCount, + estimateDailyUnitsCount, estimateRunwayDays, + estimateUnitsRunwayDays, lowStockTriggerDays, supplyRunwayDates, type RunwaySchedule, @@ -290,3 +293,55 @@ describe("supplyRunwayDates — v1.17.0", () => { expect(reorderBy.toISOString().slice(0, 10)).toBe("2026-06-01"); }); }); + +// v1.37.19 (iOS #25 parity) — slot-aware burn rate. +// +// Watched red: with `estimateDailyUnitsCount` ignoring per-slot +// `unitsPerDose` (the pre-fix behaviour — dose rate × medication-level +// units only), the mixed-dose case below computes 2 units/day instead of +// 1.5 and both the effective-units and the runway assertions fail. +describe("slot-aware units math (#219 parity)", () => { + const wholeMorning = schedule({ timesOfDay: ["08:00"], unitsPerDose: 1 }); + const halfNoon = schedule({ timesOfDay: ["12:00"], unitsPerDose: 0.5 }); + + it("weights each slot's own unitsPerDose by its cadence share", () => { + // 1 unit + 0.5 unit per day = 1.5 units/day. + expect(estimateDailyUnitsCount([wholeMorning, halfNoon], 1)).toBeCloseTo( + 1.5, + ); + }); + + it("inherits the medication level for a NULL per-slot value", () => { + expect( + estimateDailyUnitsCount( + [schedule({ timesOfDay: ["08:00"], unitsPerDose: null }), halfNoon], + 2, + ), + ).toBeCloseTo(2.5); + }); + + it("derives the schedule-weighted average units per dose", () => { + // 1.5 units over 2 doses per day → 0.75 units per dose. + expect(effectiveUnitsPerDose([wholeMorning, halfNoon], 1)).toBeCloseTo( + 0.75, + ); + }); + + it("falls back to the medication level with no consuming schedule", () => { + expect(effectiveUnitsPerDose([], 2)).toBe(2); + expect(effectiveUnitsPerDose([], 0)).toBe(1); + }); + + it("projects the units runway under the slot-aware rate", () => { + // 15 units at 1.5 units/day → 10 days. + expect(estimateUnitsRunwayDays(15, [wholeMorning, halfNoon], 1)).toBe(10); + }); + + it("is honest about an exhausted supply (0 days, not null)", () => { + expect(estimateUnitsRunwayDays(0, [wholeMorning], 1)).toBe(0); + }); + + it("returns null with no consuming cadence", () => { + expect(estimateUnitsRunwayDays(10, [], 1)).toBeNull(); + }); +}); diff --git a/src/components/medications/detail/medication-detail-tabs.tsx b/src/components/medications/detail/medication-detail-tabs.tsx index 5e4d35a3a..905cb3b30 100644 --- a/src/components/medications/detail/medication-detail-tabs.tsx +++ b/src/components/medications/detail/medication-detail-tabs.tsx @@ -154,6 +154,12 @@ export interface MedicationDetailSnapshot { * directly; no client-side recurrence walking. */ nextDueAt?: string | null; + /** + * v1.37.19 — server-resolved slot-aware runway (days); null = inventory + * tracking off or no consuming cadence. Preferred over the client + * estimate; the estimate remains only as the stale-payload fallback. + */ + runwayDays?: number | null; schedules: ScheduleSnapshot[]; } @@ -424,7 +430,11 @@ export function MedicationDetailTabs({ medication.unitsPerDose && medication.unitsPerDose > 0 ? medication.unitsPerDose : 1; - const runwayDays = estimateRunwayDays(dosesRemaining, medication.schedules); + // v1.37.19 — prefer the server's slot-aware runway from the detail GET. + const runwayDays = + medication.runwayDays !== undefined + ? medication.runwayDays + : estimateRunwayDays(dosesRemaining, medication.schedules); return (
diff --git a/src/components/medications/detail/supply-runway.ts b/src/components/medications/detail/supply-runway.ts index bbc36a58f..77c58b4a5 100644 --- a/src/components/medications/detail/supply-runway.ts +++ b/src/components/medications/detail/supply-runway.ts @@ -18,61 +18,128 @@ export interface RunwaySchedule { timesOfDay?: string[]; rrule?: string | null; rollingIntervalDays?: number | null; + /** + * v1.37.19 (#219 parity) — per-slot units override as a plain number + * (the wire shape). `null`/`undefined` inherits the medication-level + * `unitsPerDose`, exactly like the consumption path's resolver. + */ + unitsPerDose?: number | null; } /** - * Approximate doses per day across every schedule: times-of-day count, - * scaled down by the cadence (rolling interval, weekly day picks + - * interval weeks, monthly/yearly RRULEs). + * Approximate doses per day for ONE schedule: times-of-day count, scaled + * down by the cadence (rolling interval, weekly day picks + interval + * weeks, monthly/yearly RRULEs). + */ +function scheduleDailyDoseCount(s: RunwaySchedule): number { + const times = + s.timesOfDay && s.timesOfDay.length > 0 ? s.timesOfDay.length : 1; + if (typeof s.rollingIntervalDays === "number" && s.rollingIntervalDays >= 1) { + return times / s.rollingIntervalDays; + } + const rrule = s.rrule ?? ""; + if (/FREQ=MONTHLY/.test(rrule)) { + return times / 30; + } + if (/FREQ=YEARLY/.test(rrule)) { + return times / 365; + } + // FREQ=WEEKLY;BYDAY=…;INTERVAL=… is the modern weekly encoding (the + // create path stores the cadence on the rrule and leaves daysOfWeek + // empty). Honour the BYDAY pick count and the week INTERVAL: a + // once-weekly injection is one dose per 7 days, a bi-weekly one per + // 14. Without this branch a weekly rrule fell through to the legacy + // daysOfWeek fallback below with daysPerWeek=7, over-estimating the + // rate ~7× (≈14× bi-weekly) and firing low-stock alerts far too early. + if (/FREQ=WEEKLY/.test(rrule)) { + const byday = /BYDAY=([^;]+)/.exec(rrule); + const bydayCount = + byday && byday[1].length > 0 ? byday[1].split(",").length : 1; + const intervalMatch = /INTERVAL=(\d+)/.exec(rrule); + const interval = + intervalMatch && Number(intervalMatch[1]) >= 1 + ? Number(intervalMatch[1]) + : 1; + return (times * bydayCount) / (7 * interval); + } + const { daysOfWeek, intervalWeeks } = parseScheduleRecurrence(s.daysOfWeek); + const daysPerWeek = daysOfWeek.length > 0 ? daysOfWeek.length : 7; + const weeks = intervalWeeks >= 1 ? intervalWeeks : 1; + return (times * daysPerWeek) / (7 * weeks); +} + +/** + * Approximate doses per day across every schedule. */ export function estimateDailyDoseCount(schedules: RunwaySchedule[]): number { let perDay = 0; for (const s of schedules) { - const times = - s.timesOfDay && s.timesOfDay.length > 0 ? s.timesOfDay.length : 1; - if ( - typeof s.rollingIntervalDays === "number" && - s.rollingIntervalDays >= 1 - ) { - perDay += times / s.rollingIntervalDays; - continue; - } - const rrule = s.rrule ?? ""; - if (/FREQ=MONTHLY/.test(rrule)) { - perDay += times / 30; - continue; - } - if (/FREQ=YEARLY/.test(rrule)) { - perDay += times / 365; - continue; - } - // FREQ=WEEKLY;BYDAY=…;INTERVAL=… is the modern weekly encoding (the - // create path stores the cadence on the rrule and leaves daysOfWeek - // empty). Honour the BYDAY pick count and the week INTERVAL: a - // once-weekly injection is one dose per 7 days, a bi-weekly one per - // 14. Without this branch a weekly rrule fell through to the legacy - // daysOfWeek fallback below with daysPerWeek=7, over-estimating the - // rate ~7× (≈14× bi-weekly) and firing low-stock alerts far too early. - if (/FREQ=WEEKLY/.test(rrule)) { - const byday = /BYDAY=([^;]+)/.exec(rrule); - const bydayCount = - byday && byday[1].length > 0 ? byday[1].split(",").length : 1; - const intervalMatch = /INTERVAL=(\d+)/.exec(rrule); - const interval = - intervalMatch && Number(intervalMatch[1]) >= 1 - ? Number(intervalMatch[1]) - : 1; - perDay += (times * bydayCount) / (7 * interval); - continue; - } - const { daysOfWeek, intervalWeeks } = parseScheduleRecurrence(s.daysOfWeek); - const daysPerWeek = daysOfWeek.length > 0 ? daysOfWeek.length : 7; - const weeks = intervalWeeks >= 1 ? intervalWeeks : 1; - perDay += (times * daysPerWeek) / (7 * weeks); + perDay += scheduleDailyDoseCount(s); } return perDay; } +/** + * v1.37.19 (#219 parity) — approximate inventory UNITS consumed per day, + * honouring each slot's own `unitsPerDose` where set. The pre-fix figure + * multiplied the dose rate by the medication-level units alone, which + * over- or under-stated the burn rate for any medication whose slots + * carry different doses (a whole tablet in the morning, a half at noon). + */ +export function estimateDailyUnitsCount( + schedules: RunwaySchedule[], + medicationUnitsPerDose: number, +): number { + const fallback = medicationUnitsPerDose > 0 ? medicationUnitsPerDose : 1; + let perDayUnits = 0; + for (const s of schedules) { + const perSlot = + typeof s.unitsPerDose === "number" && s.unitsPerDose > 0 + ? s.unitsPerDose + : fallback; + perDayUnits += scheduleDailyDoseCount(s) * perSlot; + } + return perDayUnits; +} + +/** + * The schedule-weighted average units one dose consumes — the divisor that + * turns a units pool into an honest doses-remaining figure for a + * medication with per-slot doses. Falls back to the medication level when + * no schedule derives a consumption rate. + */ +export function effectiveUnitsPerDose( + schedules: RunwaySchedule[], + medicationUnitsPerDose: number, +): number { + const fallback = medicationUnitsPerDose > 0 ? medicationUnitsPerDose : 1; + const perDay = estimateDailyDoseCount(schedules); + if (perDay <= 0) return fallback; + const perDayUnits = estimateDailyUnitsCount( + schedules, + medicationUnitsPerDose, + ); + return perDayUnits > 0 ? perDayUnits / perDay : fallback; +} + +/** + * v1.37.19 (#219 parity) — whole days a UNITS pool covers under the + * slot-aware burn rate, or `null` when no consuming schedule exists. + * Zero units with a consuming schedule is honestly 0 days. + */ +export function estimateUnitsRunwayDays( + unitsRemaining: number, + schedules: RunwaySchedule[], + medicationUnitsPerDose: number, +): number | null { + const perDayUnits = estimateDailyUnitsCount( + schedules, + medicationUnitsPerDose, + ); + if (perDayUnits <= 0) return null; + return Math.floor(Math.max(0, unitsRemaining) / perDayUnits); +} + /** * Whole days the remaining supply covers, or `null` when no estimate is * possible (no supply, no consuming schedule). diff --git a/src/components/medications/glp1-medication-card.tsx b/src/components/medications/glp1-medication-card.tsx index 3f270f4f1..a5499c887 100644 --- a/src/components/medications/glp1-medication-card.tsx +++ b/src/components/medications/glp1-medication-card.tsx @@ -124,6 +124,8 @@ export interface Glp1Medication { nextDueOverdue?: boolean; /** v1.16.10 — dose-derived stock from the list payload; null = inventory tracking off. */ stockDosesRemaining?: number | null; + /** v1.37.19 — server-resolved slot-aware runway (days); null = off/no cadence. */ + runwayDays?: number | null; /** v1.17.0 — per-medication reorder lead override (days); null = inherit the user default. */ reorderLeadDays?: number | null; schedules: ScheduleLite[]; @@ -370,17 +372,21 @@ export function Glp1MedicationCard({ medication.reorderLeadDays != null ? medication.reorderLeadDays : (thresholds?.reorderLeadDays ?? 10); + // v1.37.19 — prefer the server's slot-aware `runwayDays`; client + // estimate only as the stale-payload fallback. const stockRunwayDays = medication.stockDosesRemaining == null ? null - : medication.stockDosesRemaining > 0 - ? estimateRunwayDays( - medication.stockDosesRemaining, - medication.schedules, - ) - : estimateDailyDoseCount(medication.schedules) > 0 - ? 0 - : null; + : medication.runwayDays !== undefined + ? medication.runwayDays + : medication.stockDosesRemaining > 0 + ? estimateRunwayDays( + medication.stockDosesRemaining, + medication.schedules, + ) + : estimateDailyDoseCount(medication.schedules) > 0 + ? 0 + : null; const lowStockTrigger = lowStockFloor === null ? null diff --git a/src/components/medications/medication-card.tsx b/src/components/medications/medication-card.tsx index afb30fd04..1c8f2f839 100644 --- a/src/components/medications/medication-card.tsx +++ b/src/components/medications/medication-card.tsx @@ -105,6 +105,8 @@ interface Medication { asNeeded?: boolean; /** v1.16.10 — dose-derived stock from the list payload; null = inventory tracking off. */ stockDosesRemaining?: number | null; + /** v1.37.19 — server-resolved slot-aware runway (days); null = off/no cadence. */ + runwayDays?: number | null; /** * v1.17.0 — optional per-medication reorder lead override (days); null / * absent = inherit the user-level reorderLeadDays default. Widens the @@ -313,17 +315,22 @@ export function MedicationCard({ medication.reorderLeadDays != null ? medication.reorderLeadDays : (thresholds?.reorderLeadDays ?? 10); + // v1.37.19 — prefer the server's slot-aware `runwayDays` (published on + // the list wire); the client estimate stays only as the stale-payload + // fallback. const stockRunwayDays = medication.asNeeded || medication.stockDosesRemaining == null ? null - : medication.stockDosesRemaining > 0 - ? estimateRunwayDays( - medication.stockDosesRemaining, - medication.schedules, - ) - : estimateDailyDoseCount(medication.schedules) > 0 - ? 0 - : null; + : medication.runwayDays !== undefined + ? medication.runwayDays + : medication.stockDosesRemaining > 0 + ? estimateRunwayDays( + medication.stockDosesRemaining, + medication.schedules, + ) + : estimateDailyDoseCount(medication.schedules) > 0 + ? 0 + : null; const lowStockTrigger = lowStockFloor === null ? null diff --git a/src/components/medications/medication-table.tsx b/src/components/medications/medication-table.tsx index 2e9269c30..a278b3c60 100644 --- a/src/components/medications/medication-table.tsx +++ b/src/components/medications/medication-table.tsx @@ -94,6 +94,8 @@ export interface TableMedication { asNeeded?: boolean; /** v1.16.10 — dose-derived stock from the list payload; null = inventory tracking off. */ stockDosesRemaining?: number | null; + /** v1.37.19 — server-resolved slot-aware runway (days); null = off/no cadence. */ + runwayDays?: number | null; schedules: TableSchedule[]; } @@ -599,10 +601,14 @@ function MedicationTableRowItem({ // Projected days the stock covers — the same coarse estimate the // detail Übersicht renders, so the column answers "do I need a // refill" without opening the medication. + // v1.37.19 — prefer the server's slot-aware `runwayDays`; client + // estimate only as the stale-payload fallback. const runwayDays = - stock === null || stock === undefined - ? null - : estimateRunwayDays(stock, medication.schedules); + medication.runwayDays !== undefined + ? medication.runwayDays + : stock === null || stock === undefined + ? null + : estimateRunwayDays(stock, medication.schedules); // Warn tier (amber) on the SAME predicate as the low-stock // notification: projected runway days strictly below the user's // threshold. Threshold OFF (null) → no amber tier; an exhausted stock diff --git a/src/components/medications/sections/notifications-section.tsx b/src/components/medications/sections/notifications-section.tsx index f5d2e2480..fa78453b3 100644 --- a/src/components/medications/sections/notifications-section.tsx +++ b/src/components/medications/sections/notifications-section.tsx @@ -150,14 +150,20 @@ export function NotificationsBody({ {clientManaged ? ( // v1.5.5 §9.6 — iPhone-managed path. The switch is hidden; // the read-only chip tells the user where the toggle lives. -

{t("medications.detail.notifications.clientManagedChip")} -

+

+ {t("medications.detail.notifications.clientManagedHint")} +

+
) : (
- {/* Verbosity */} + {/* Verbosity. The concise tone forces "brief" server-side + (system-prompt.ts, buildPrefsPrefix), so the picker is + disabled there — showing "brief" — with a hint instead of + a live control that silently does nothing. */}
diff --git a/src/components/settings/import-panel/__tests__/import-result-view.test.tsx b/src/components/settings/import-panel/__tests__/import-result-view.test.tsx index de6506f2f..1cc073cff 100644 --- a/src/components/settings/import-panel/__tests__/import-result-view.test.tsx +++ b/src/components/settings/import-panel/__tests__/import-result-view.test.tsx @@ -107,7 +107,7 @@ describe(" — result states", () => { ); expect(html).toContain('data-outcome="success"'); expect(html).toContain("text-success"); - expect(html).toContain("Imported 4 new and updated 1."); + expect(html).toContain("Imported 4 new and updated 1"); expect(html).not.toContain("skipped"); }); diff --git a/src/components/settings/integrations/__tests__/oauth-provider-card.test.tsx b/src/components/settings/integrations/__tests__/oauth-provider-card.test.tsx index 1e01d2f37..b4fc2d3ef 100644 --- a/src/components/settings/integrations/__tests__/oauth-provider-card.test.tsx +++ b/src/components/settings/integrations/__tests__/oauth-provider-card.test.tsx @@ -62,7 +62,7 @@ describe("OAuthProviderCard — parked + test + data-link parity", () => { expect(html).toContain('data-state="parked"'); expect(html).toContain('data-testid="polar-parked-banner"'); expect(html).toContain('data-testid="polar-resume-button"'); - expect(html).toContain("reconnect manually"); + expect(html).toContain("Paused — reconnect"); // The parked banner uses the same warning treatment as the WHOOP card. expect(html).toContain("border-warning/30 bg-warning/10"); }); diff --git a/src/components/settings/record-settings-section-gate.tsx b/src/components/settings/record-settings-section-gate.tsx index aaf697477..621112ccc 100644 --- a/src/components/settings/record-settings-section-gate.tsx +++ b/src/components/settings/record-settings-section-gate.tsx @@ -32,7 +32,6 @@ const MANAGED_RECORD_SETTINGS_FAMILY_BY_SECTION = { account: "profile", modules: "modules", notifications: "notifications", - insights: "insights", thresholds: "thresholds", coach: "coach", } as const; diff --git a/src/components/settings/section-placeholder.tsx b/src/components/settings/section-placeholder.tsx deleted file mode 100644 index eff2f4430..000000000 --- a/src/components/settings/section-placeholder.tsx +++ /dev/null @@ -1,119 +0,0 @@ -"use client"; - -/** - * `` — the temporary body of every `/settings/[section]` - * route until the matching extraction PR lands. - * - * The shell + routing surface ship in PR A2-shell so reviewers can validate - * the URL structure in isolation. The follow-up PRs (A2-account, A2-about, - * A2-ai, A2-integrations, A2-notifications, A2-rest) replace this placeholder - * with the real content for each slug, copying from - * `src/app/settings/page.legacy.tsx`. - */ - -import { - Bell, - Blocks, - Bot, - CalendarCheck, - CloudSun, - Download, - ClipboardList, - FileHeart, - FlaskConical, - Gauge, - Info, - KeyRound, - Plug, - Layers, - LayoutDashboard, - Link2, - Lock, - Pill, - Settings2, - Share2, - ShieldCheck, - SlidersHorizontal, - Smile, - Sparkles, - Thermometer, - TrendingUp, - User, - UsersRound, - type LucideIcon, -} from "lucide-react"; - -import { EmptyState } from "@/components/ui/empty-state"; -import { useTranslations } from "@/lib/i18n/context"; -import type { SettingsSectionSlug } from "./section-slugs"; - -const SLUG_ICON: Record = { - account: User, - security: ShieldCheck, - access: UsersRound, - modules: Blocks, - integrations: Link2, - sources: Layers, - notifications: Bell, - layout: LayoutDashboard, - dashboard: LayoutDashboard, - insights: TrendingUp, - medications: Pill, - mood: Smile, - labs: FlaskConical, - illness: Thermometer, - environment: CloudSun, - anamnesis: ClipboardList, - score: Gauge, - vorsorge: CalendarCheck, - thresholds: SlidersHorizontal, - ai: Sparkles, - coach: Bot, - api: KeyRound, - mcp: Plug, - gesundheitsakte: FileHeart, - sharing: Share2, - export: Download, - advanced: Settings2, - privacy: Lock, - about: Info, -}; - -export interface SectionPlaceholderProps { - slug: SettingsSectionSlug; -} - -export function SectionPlaceholder({ slug }: SectionPlaceholderProps) { - const { t } = useTranslations(); - const Icon = SLUG_ICON[slug]; - const sectionTitle = t(`settings.sections.${slug}.title`); - const sectionDescription = t(`settings.sections.${slug}.description`); - - // v1.4.33 A4 — `` is a defensive fallback for - // slugs added to SETTINGS_SECTION_SLUGS without a matching component. - // The type system blocks that path today, so the EmptyState body is - // effectively unreachable. We retire the dedicated locale key - // (`settings.sections.placeholder.coming_soon`) and inherit the - // section's own description so the guard still renders meaningful - // copy if it ever does fire — no more dead-string maintenance across - // six locale files. - return ( -
-
-

- {sectionTitle} -

-

{sectionDescription}

-
- - } - title={sectionTitle} - description={sectionDescription} - /> -
- ); -} diff --git a/src/components/settings/section-slugs.ts b/src/components/settings/section-slugs.ts index d73f5eb53..65ec120e8 100644 --- a/src/components/settings/section-slugs.ts +++ b/src/components/settings/section-slugs.ts @@ -71,13 +71,15 @@ export const SETTINGS_SECTION_SLUGS = [ // 301-redirects to `/settings/notifications#channels` (next.config.ts). "sources", "notifications", + // v1.37.19 — the per-module personalization slugs (`dashboard`, + // `insights`, `medications`, `mood`, `labs`, `illness`, `vorsorge`) and + // `sharing` left this list. Their URLs are owned by permanent redirects + // in `next.config.ts` (`/settings/` → `/settings/layout/`, + // `/settings/sharing` → `/settings/gesundheitsakte#sharing`), so the + // dynamic route could never serve them — prerendering eight unreachable + // pages and keeping eight dead component wirings. The redirects, the + // section components, and the layout-hub subpages are unchanged. "layout", - "dashboard", - "insights", - "medications", - "mood", - "labs", - "illness", // v1.25 (W-ENV) — "Umwelt" (environmental context): home location, travel // overrides, and the weather/daylight backfill. Module-gated on the opt-in // `environment` module (the nav entry only shows when it is on). @@ -92,7 +94,6 @@ export const SETTINGS_SECTION_SLUGS = [ // shown) and folding the two together is what kept the score's // composition invisible. "score", - "vorsorge", "thresholds", "ai", // v1.18.0 (S5) — `coach` gathers the Coach preference cards (disable @@ -106,13 +107,6 @@ export const SETTINGS_SECTION_SLUGS = [ // access surfaces). Not nav-gated — the card carries its own enable toggle. "mcp", "gesundheitsakte", - // v1.18.7 — `sharing` (clinician share links) sits directly after - // `gesundheitsakte`, before `export`: minting a time-boxed read-only link - // to the health record belongs next to the health-record export. Always - // available (no module gate), like account / export. The backing model, - // the `/api/share-links` routes, and the public `/c/[token]` view are - // unchanged — this restores only the owner Settings surface. - "sharing", "export", "advanced", // v1.23 — "Data & Privacy": a single surface that assembles the already- diff --git a/src/hooks/use-auth.ts b/src/hooks/use-auth.ts index 44e884d5a..39eaba9dc 100644 --- a/src/hooks/use-auth.ts +++ b/src/hooks/use-auth.ts @@ -89,6 +89,13 @@ export interface AuthUser { * rather than failing the shape. */ disclaimerAcknowledgedAt?: string | null; + /** + * The disclaimer version that acknowledgment was given for. The welcome + * gate compares it against the current `DISCLAIMER_VERSION` so a revised + * disclaimer re-prompts instead of riding an old acknowledgment. Optional + * for the same stale-payload reason as the timestamp above. + */ + disclaimerAcknowledgedVersion?: string | null; /** * v1.5.5 — relative URL of the user's self-hosted avatar, served * from `/api/user/avatar/{id}?v={updatedAtMs}`. Replaces the diff --git a/src/lib/ai/__tests__/provider-runner.test.ts b/src/lib/ai/__tests__/provider-runner.test.ts index b34a5fd1e..51affa883 100644 --- a/src/lib/ai/__tests__/provider-runner.test.ts +++ b/src/lib/ai/__tests__/provider-runner.test.ts @@ -10,6 +10,19 @@ import { // provider-health ledger. These pure chain tests do not stand up a DB, // so swap the default for a no-op; the dedicated ledger-aware cases // below inject an in-memory ledger explicitly. +// v1.37.19 (A7-2) — the runner consults the day's recorded spend before an +// operator-funded hop. These pure chain tests do not stand up a DB, so the +// ledger read is swapped for a controllable in-memory figure. +const budgetState = vi.hoisted(() => ({ spent: 0 })); +vi.mock("../coach/budget", async () => { + const actual = + await vi.importActual("../coach/budget"); + return { + ...actual, + readDailySpend: vi.fn(async () => budgetState.spent), + }; +}); + vi.mock("../provider-health-ledger", async () => { const actual = await vi.importActual< typeof import("../provider-health-ledger") @@ -97,6 +110,7 @@ function err(status: number, msg = "boom"): Error & { httpStatus: number } { beforeEach(() => { clearLastWorkingProviderCache(); + budgetState.spent = 0; }); afterEach(() => { @@ -671,3 +685,76 @@ describe("runStreamingRawCompletionWithFallback", () => { expect(result.fallbackHops.map((h) => h.providerType)).toEqual(["openai"]); }); }); + +// v1.37.19 (A7-2) — hop-time operator-cost guard. +// +// Watched red: with the `isOperatorFundedProvider` check removed from +// `runRawChain` (the pre-fix walker), the exhausted-cap case below fails — +// the admin-openai fallback was invoked and spent operator money although +// the request had only been reserved under the user-plan ceiling. +describe("runRawCompletionWithFallback — operator-cost cap at hop time", () => { + const params: CompletionParams = singleUserTurn({ system: "s", user: "u" }); + + it("refuses the admin-* fallback hop once the operator cap is exhausted", async () => { + budgetState.spent = 200_000; // OPERATOR_COST_CAP + const codex = new ScriptedProvider({ + type: "codex", + script: [{ ok: false, error: err(500) }], + }); + const adminOpenai = new ScriptedProvider({ script: [{ ok: true }] }); + + await expect( + runRawCompletionWithFallback({ + userId: "u-cap", + providers: [ + { providerType: "codex", instance: codex }, + { providerType: "admin-openai", instance: adminOpenai }, + ], + params, + }), + ).rejects.toMatchObject({ + attempts: [ + expect.objectContaining({ providerType: "codex" }), + expect.objectContaining({ + providerType: "admin-openai", + failureReason: "operator-cost-cap-exhausted", + }), + ], + }); + // The operator-funded provider was never invoked. + expect(adminOpenai.callCount).toBe(0); + }); + + it("lets the admin-* fallback run while the operator cap has headroom", async () => { + budgetState.spent = 100; + const codex = new ScriptedProvider({ + type: "codex", + script: [{ ok: false, error: err(500) }], + }); + const adminOpenai = new ScriptedProvider({ script: [{ ok: true }] }); + + const outcome = await runRawCompletionWithFallback({ + userId: "u-headroom", + providers: [ + { providerType: "codex", instance: codex }, + { providerType: "admin-openai", instance: adminOpenai }, + ], + params, + }); + expect(outcome.workingProvider.providerType).toBe("admin-openai"); + expect(adminOpenai.callCount).toBe(1); + }); + + it("never consults the ledger for a user-funded chain", async () => { + budgetState.spent = 999_999_999; + const openai = new ScriptedProvider({ script: [{ ok: true }] }); + const outcome = await runRawCompletionWithFallback({ + userId: "u-own-key", + providers: [{ providerType: "openai", instance: openai }], + params, + }); + // The user's own key is not the operator's money — the cap does not + // apply and the call proceeds regardless of the recorded spend. + expect(outcome.workingProvider.providerType).toBe("openai"); + }); +}); diff --git a/src/lib/ai/coach/budget.ts b/src/lib/ai/coach/budget.ts index 35c026222..d559a8e82 100644 --- a/src/lib/ai/coach/budget.ts +++ b/src/lib/ai/coach/budget.ts @@ -64,13 +64,43 @@ export function resolveDailyCap( chain: ReadonlyArray<{ providerType: ProviderChainType }>, ): number { const primary = chain[0]?.providerType; - return primary === "admin-openai" || - primary === "admin-codex" || - primary === undefined + return primary === undefined || isOperatorFundedProvider(primary) ? OPERATOR_COST_CAP : USER_PLAN_CAP; } +/** + * v1.37.19 (A7-2) — does this provider spend the OPERATOR's money? + * `admin-openai` (the server's own API key) and `admin-codex` (the + * server's shared ChatGPT account) do; everything else is the user's own + * egress. One predicate so `resolveDailyCap` and the chain walker's + * hop-time guard cannot drift on the classification. + */ +export function isOperatorFundedProvider(type: ProviderChainType): boolean { + return type === "admin-openai" || type === "admin-codex"; +} + +/** + * v1.37.19 (A7-2) — the day's recorded spend for one user (0 when no row). + * + * Read by the chain walker's hop-time operator-cap guard: a request whose + * PRIMARY provider runs on the user's own key reserves under the generous + * `USER_PLAN_CAP`, but the chain may still FALL BACK onto an operator-funded + * `admin-*` entry (or the health-ledger reorder may promote one) — and that + * hop must not spend past `OPERATOR_COST_CAP` just because the reservation + * was checked against the wrong owner's ceiling. + */ +export async function readDailySpend( + userId: string, + dateKey: string = buildDateKey(), +): Promise { + const row = await prisma.coachUsage.findUnique({ + where: { userId_dateKey: { userId, dateKey } }, + select: { totalTokens: true }, + }); + return row?.totalTokens ?? 0; +} + /** * Build the UTC day-key for a given clock. Defaults to "now". * diff --git a/src/lib/ai/provider-runner.ts b/src/lib/ai/provider-runner.ts index cc62c83da..823e380f1 100644 --- a/src/lib/ai/provider-runner.ts +++ b/src/lib/ai/provider-runner.ts @@ -6,6 +6,11 @@ import { type ProviderSkipHint, } from "./provider-health-ledger"; import { annotate } from "@/lib/logging/context"; +import { + isOperatorFundedProvider, + OPERATOR_COST_CAP, + readDailySpend, +} from "@/lib/ai/coach/budget"; /** * The local model is the guaranteed floor (Epic B Pillar 4): a @@ -394,6 +399,39 @@ async function runRawChain( for (let i = 0; i < ordered.length; i += 1) { const candidate = ordered[i]; + // v1.37.19 (A7-2) — hop-time operator-cost guard. A request whose + // primary provider runs on the USER's own key reserves its budget + // under the generous user-plan ceiling; when the chain then falls + // back onto an operator-funded `admin-*` entry (or the health-ledger + // reorder promotes one), that hop would spend the OPERATOR's money + // under the wrong owner's cap. Check the day's recorded spend + // against OPERATOR_COST_CAP before letting an operator-funded + // candidate run; an exhausted cap skips the candidate exactly like a + // failed hop (next entry, or AllProvidersFailedError at the end). + if (isOperatorFundedProvider(candidate.providerType)) { + // Fail OPEN on a ledger read error: the reservation layer still + // meters every call against the same ledger, so a transient read + // failure here must not take the whole fallback chain down with it. + const spent = await readDailySpend(userId).catch(() => 0); + if (spent >= OPERATOR_COST_CAP) { + const hop: FallbackHop = { + providerType: candidate.providerType, + attempt: i + 1, + failureReason: "operator-cost-cap-exhausted", + httpStatus: null, + }; + hops.push(hop); + annotate({ + action: { name: "ai.chain.operator_cap_refused" }, + meta: { + [`ai_chain_hop_${i + 1}_provider`]: candidate.providerType, + [`ai_chain_hop_${i + 1}_reason`]: "operator-cost-cap-exhausted", + operator_cap_spent: spent, + }, + }); + continue; + } + } try { const result = await invoke(candidate); rememberWorkingProvider(userId, candidate.providerType); diff --git a/src/lib/analytics/__tests__/merge-slim-thick.test.ts b/src/lib/analytics/__tests__/merge-slim-thick.test.ts index 57384a8e5..3f104c015 100644 --- a/src/lib/analytics/__tests__/merge-slim-thick.test.ts +++ b/src/lib/analytics/__tests__/merge-slim-thick.test.ts @@ -29,7 +29,6 @@ const stubSummary = (): DataSummary => ({ avg30: 77.9, slope7: null, slope30: { slope: -0.05, direction: "down", confidence: 0.5 }, - slope90: null, anomalyCount: 0, }); diff --git a/src/lib/analytics/__tests__/summaries-slice.test.ts b/src/lib/analytics/__tests__/summaries-slice.test.ts index 2d18dcbd6..ce8c10f70 100644 --- a/src/lib/analytics/__tests__/summaries-slice.test.ts +++ b/src/lib/analytics/__tests__/summaries-slice.test.ts @@ -99,6 +99,10 @@ beforeEach(() => { ROLLUP_FIND_MANY.mockResolvedValue([]); ROLLUP_FIND_FIRST.mockResolvedValue(null); MEASUREMENT_FIND_FIRST.mockResolvedValue(null); + // v1.37.19 (A6-10) — default the tagged-template RAW reads (the coverage + // probe + the pre-fold all-time remainder) to empty; individual tests + // queue their own `mockResolvedValueOnce` sequences on top. + RAW.mockResolvedValue([]); }); afterEach(() => { @@ -132,8 +136,6 @@ describe("computeSummariesSlice", () => { avg30: null, slope7: null, slope30: null, - slope90: null, - anomalyCount: 0, avg30LastMonth: null, avg30LastYear: null, }); @@ -187,7 +189,10 @@ describe("computeSummariesSlice", () => { expect(weight.mean).toBe(82.05); expect(weight.avg7).toBe(81.9); expect(weight.avg30).toBe(82.1); - expect(weight.anomalyCount).toBe(0); + // anomalyCount / slope90 left the wire shape in v1.37.19 — computed + // by the insights pipeline's own reads, never serialised here. + expect("anomalyCount" in weight).toBe(false); + expect("slope90" in weight).toBe(false); expect(weight.avg30LastMonth).toBeNull(); expect(weight.avg30LastYear).toBeNull(); expect(weight.slope7).toEqual({ @@ -200,11 +205,6 @@ describe("computeSummariesSlice", () => { direction: "stable", confidence: 0.42, }); - expect(weight.slope90).toEqual({ - slope: 0.001, - direction: "stable", - confidence: 0.12, - }); }); it("returns a null slope tuple when the SQL slope is null (insufficient rows)", async () => { @@ -238,7 +238,6 @@ describe("computeSummariesSlice", () => { const result = await computeSummariesSlice("user-1"); expect(result.summaries.PULSE.slope7).toBeNull(); expect(result.summaries.PULSE.slope30).toBeNull(); - expect(result.summaries.PULSE.slope90).toBeNull(); }); it("surfaces lastSeenByType from the DISTINCT ON pass's measured_at", async () => { @@ -409,8 +408,9 @@ describe("computeSummariesSlice", () => { // 1 RAW coverage probe + 3 UNSAFE data queries (narrow aggregate // + latests + rollup GROUP BY; v1.4.37.2 — the prior `findMany` - // is gone). No heavy aggregate. - expect(RAW).toHaveBeenCalledTimes(1); + // is gone). No heavy aggregate. v1.37.19 (A6-10) — the second RAW + // call is the pre-fold all-time remainder splice. + expect(RAW).toHaveBeenCalledTimes(2); expect(UNSAFE).toHaveBeenCalledTimes(3); // v1.4.40 W-WMY-WIRE — the year-ago baseline probe runs // `readBestGranularityRollups(userId, type, 395)` per @@ -423,6 +423,63 @@ describe("computeSummariesSlice", () => { // now runs to compose slope / r² / sd: 1 accumulator + 3 WMY = 4. expect(ROLLUP_FIND_MANY).toHaveBeenCalledTimes(4); }); + + // Watched red: with the pre-fold splice removed from the rollup path + // (the pre-v1.37.19 assembly used the GROUP BY figures verbatim), the + // count / min / max / mean assertions fail — an account with rows + // older than the 5-year fold window had its "all-time" figures + // silently truncated to the window while the live fallback reported + // the true numbers. + it("splices rows older than the fold window into the all-time figures (A6-10)", async () => { + RAW.mockResolvedValueOnce([{ type: "WEIGHT", has_buckets: true }]) // probe + .mockResolvedValueOnce([ + // pre-fold remainder: 5 ancient readings, heavier and wider. + { type: "WEIGHT", count: 5, min: 70, max: 95, mean: 90 }, + ]); + UNSAFE.mockResolvedValueOnce([ + { type: "WEIGHT", avg7: 82, avg30: 82.5, median: 82.1 }, + ]) + .mockResolvedValueOnce([ + { type: "WEIGHT", value: 82.7, measured_at: new Date() }, + ]) + .mockResolvedValueOnce([ + { type: "WEIGHT", count: 20, min: 79.5, max: 84.0, mean: 82.0 }, + ]); + + const result = await computeSummariesSlice("user-prefold"); + const weight = result.summaries.WEIGHT; + + // 20 in-window + 5 pre-fold. + expect(weight.count).toBe(25); + // Envelope widens to the ancient extremes. + expect(weight.min).toBe(70); + expect(weight.max).toBe(95); + // Weighted mean: (82*20 + 90*5) / 25 = 83.6. + expect(weight.mean).toBe(83.6); + // Windowed figures stay window-scoped. + expect(weight.avg7).toBe(82); + }); + + it("surfaces a type whose only rows are pre-fold (no DAY bucket)", async () => { + RAW.mockResolvedValueOnce([ + { type: "WEIGHT", has_buckets: true }, + ]).mockResolvedValueOnce([ + { type: "HEIGHT", count: 2, min: 180, max: 181, mean: 180.5 }, + ]); + UNSAFE.mockResolvedValueOnce([]) // narrows + .mockResolvedValueOnce([]) // latests + .mockResolvedValueOnce([]); // rollup GROUP BY + + const result = await computeSummariesSlice("user-prefold-only"); + const height = result.summaries.HEIGHT; + expect(height.count).toBe(2); + expect(height.min).toBe(180); + expect(height.max).toBe(181); + expect(height.mean).toBe(180.5); + // No in-window rows: every windowed field stays null. + expect(height.avg7).toBeNull(); + expect(height.slope30).toBeNull(); + }); }); /** @@ -681,17 +738,18 @@ describe("computeSummariesSlice", () => { expect(windowedSql).toBeDefined(); const sql = windowedSql as string; - // Every regression window (7/30/90 for slope + r²) anchors on the - // day-truncated UTC boundary. Six FILTERs total. - for (const days of ["7 days", "30 days", "90 days"]) { + // Every regression window (7/30 for slope + r²) anchors on the + // day-truncated UTC boundary. Four FILTERs total (the 90-day pair + // left with slope90 in v1.37.19). + for (const days of ["7 days", "30 days"]) { expect(sql).toContain( `(date_trunc('day', NOW() AT TIME ZONE 'UTC') - INTERVAL '${days}') AT TIME ZONE 'UTC'`, ); } const anchored = sql.match( - /date_trunc\('day', NOW\(\) AT TIME ZONE 'UTC'\) - INTERVAL '(?:7|30|90) days'\) AT TIME ZONE 'UTC'/g, + /date_trunc\('day', NOW\(\) AT TIME ZONE 'UTC'\) - INTERVAL '(?:7|30) days'\) AT TIME ZONE 'UTC'/g, ); - expect(anchored?.length).toBe(6); + expect(anchored?.length).toBe(4); // The regression windows must NOT fall back to the bare wall-clock // `NOW() - INTERVAL 'N days'` bound the warm path never uses for the diff --git a/src/lib/analytics/summaries-slice.ts b/src/lib/analytics/summaries-slice.ts index f476826ab..7043ca2fc 100644 --- a/src/lib/analytics/summaries-slice.ts +++ b/src/lib/analytics/summaries-slice.ts @@ -41,8 +41,9 @@ * Fields the slim shape intentionally omits (the dashboard never * reads them on first paint): * - `anomalyCount`: would require an extra per-type read for the - * z-score loop. The Coach / insights paths still get it via the - * default slice. + * z-score loop. v1.37.19 removed it from the wire shape entirely — + * the insights pipeline computes its own via `summarize()` / + * the comprehensive aggregator, and no client component reads it. * - `avg30LastMonth` / `avg30LastYear`: the dashboard tile delta * callout uses them, but only when the comparison-baseline * widget is enabled — that path already pre-fetches the default @@ -61,7 +62,10 @@ import { prisma } from "@/lib/db"; import type { DataSummary } from "@/lib/analytics/trends"; import { measurementTypeEnum } from "@/lib/validations/measurement"; import { annotate } from "@/lib/logging/context"; -import { ensureUserRollupsFresh } from "@/lib/rollups/measurement-rollups"; +import { + ensureUserRollupsFresh, + ROLLUP_FOLD_WINDOW_MS, +} from "@/lib/rollups/measurement-rollups"; import { collapseRollupRowsBySource, composeWindowedRegression, @@ -172,8 +176,6 @@ interface WindowedAggregateRow extends NarrowAggregateRow { r2_7: number | null; slope30: number | null; r2_30: number | null; - slope90: number | null; - r2_90: number | null; } interface LatestRow { @@ -222,8 +224,6 @@ function emptySummary(): DataSummary { avg30: null, slope7: null, slope30: null, - slope90: null, - anomalyCount: 0, avg30LastMonth: null, avg30LastYear: null, }; @@ -378,7 +378,7 @@ async function withSleepNightTotals( // Preserve the slope tuples the night summary doesn't compute (the // dashboard tile reads slope30); recompute them off the night series is // out of scope here — the per-night `summarize()` already fills slope7 / - // slope30 / slope90 from the night DataPoints, so use them directly. + // slope30 from the night DataPoints, so use them directly. slice.summaries.SLEEP_DURATION = summary; // Additive, observational: the latest night's main session saw two // writer buckets with clearly different asleep totals. Round to whole @@ -424,13 +424,22 @@ async function computeFromRollups(userId: string): Promise { ); const rankM = buildSourceRankCase(priorityJson, 'm."type"', 'm."source"'); - const [narrows, latests, dayBuckets, accumulatorRows] = await Promise.all([ - // The FROM is restricted to the canonical-source rows per (type, day): the - // inner DISTINCT ON picks the ladder-winning source for each day, and the - // join keeps only that source's readings so the 90-day AVG / median / slope - // never blend two devices that both reported the same vital. - prisma.$queryRawUnsafe( - ` + // v1.37.19 (A6-10) — where the fold window starts. DAY rollup buckets + // only exist inside the trailing `ROLLUP_FOLD_WINDOW_MS` (5 y), so the + // rollup-composed "all-time" figures silently truncated to that window + // for accounts with older history, while the cold live path reported + // the true unbounded numbers. The pre-fold remainder query below + // splices the older rows back in. + const foldWindowStart = new Date(Date.now() - ROLLUP_FOLD_WINDOW_MS); + + const [narrows, latests, dayBuckets, accumulatorRows, preFoldRemainder] = + await Promise.all([ + // The FROM is restricted to the canonical-source rows per (type, day): the + // inner DISTINCT ON picks the ladder-winning source for each day, and the + // join keeps only that source's readings so the 90-day AVG / median / slope + // never blend two devices that both reported the same vital. + prisma.$queryRawUnsafe( + ` SELECT m."type"::text AS type, AVG(m."value") FILTER ( @@ -451,13 +460,13 @@ async function computeFromRollups(userId: string): Promise { FROM ${canonicalMeasurementsFrom(rankUnqualified, "90 days")} GROUP BY m."type" `, - userId, - ), - // v1.11.1 — the latest tile reflects the canonical source for the latest - // day, matching the chart: order by latest day first, then the ladder rank - // (canonical source wins), then the latest reading of that source. - prisma.$queryRawUnsafe( - ` + userId, + ), + // v1.11.1 — the latest tile reflects the canonical source for the latest + // day, matching the chart: order by latest day first, then the ladder rank + // (canonical source wins), then the latest reading of that source. + prisma.$queryRawUnsafe( + ` SELECT DISTINCT ON (m."type") m."type"::text AS type, m."value"::double precision AS value, @@ -467,24 +476,24 @@ async function computeFromRollups(userId: string): Promise { AND m."deleted_at" IS NULL ORDER BY m."type", date_trunc('day', m."measured_at") DESC, (${rankM}), m."measured_at" DESC `, - userId, - ), - // v1.4.37.2 hotfix — the v1.4.35 implementation read EVERY DAY - // rollup bucket for the user (`findMany` without a `bucketStart` - // window) and then composed `count / min / max / mean` in JS. - // On tenants with large measurement partitions that materialised - // as a six-figure row transfer + a JS loop = multi-second per - // cache miss, even with the rollup table hot. The slim slice's - // contract is the all-time count / min / max / mean per type — - // exactly what a SQL `GROUP BY type` returns in a single - // round-trip. - // - // v1.11.1 — rows are now per source. Collapse each (type, day) to the - // ladder-canonical source via DISTINCT ON before the all-time aggregate, - // so a dual-source vital is counted once. Still one server-side pass — - // the DISTINCT ON + GROUP BY runs in Postgres and returns one row/type. - prisma.$queryRawUnsafe( - ` + userId, + ), + // v1.4.37.2 hotfix — the v1.4.35 implementation read EVERY DAY + // rollup bucket for the user (`findMany` without a `bucketStart` + // window) and then composed `count / min / max / mean` in JS. + // On tenants with large measurement partitions that materialised + // as a six-figure row transfer + a JS loop = multi-second per + // cache miss, even with the rollup table hot. The slim slice's + // contract is the all-time count / min / max / mean per type — + // exactly what a SQL `GROUP BY type` returns in a single + // round-trip. + // + // v1.11.1 — rows are now per source. Collapse each (type, day) to the + // ladder-canonical source via DISTINCT ON before the all-time aggregate, + // so a dual-source vital is counted once. Still one server-side pass — + // the DISTINCT ON + GROUP BY runs in Postgres and returns one row/type. + prisma.$queryRawUnsafe( + ` WITH collapsed AS ( SELECT DISTINCT ON ("type", "bucket_start") "type" AS type, @@ -509,37 +518,81 @@ async function computeFromRollups(userId: string): Promise { FROM collapsed GROUP BY "type" `, - userId, - ), - // v1.20.0 F6 — trailing 90-day DAY rollup rows carrying the per-bucket - // regression accumulators. The windowed slope / r² / sd compose from - // these (composeWindowedRegression) instead of the live REGR_* scan the - // narrow query used to run. Rows are per source; collapsed per type - // below so a dual-source vital contributes only its canonical source's - // accumulators. Bounded to 90 days (≤ ~90 rows/type), the same window - // the dropped slope columns covered. - prisma.measurementRollup.findMany({ - where: { userId, - granularity: "DAY", - bucketStart: { - gte: startOfUtcDay(new Date(Date.now() - 90 * DAY_MS)), + ), + // v1.20.0 F6 — trailing 90-day DAY rollup rows carrying the per-bucket + // regression accumulators. The windowed slope / r² / sd compose from + // these (composeWindowedRegression) instead of the live REGR_* scan the + // narrow query used to run. Rows are per source; collapsed per type + // below so a dual-source vital contributes only its canonical source's + // accumulators. Bounded to 90 days (≤ ~90 rows/type), the same window + // the dropped slope columns covered. + prisma.measurementRollup.findMany({ + where: { + userId, + granularity: "DAY", + bucketStart: { + gte: startOfUtcDay(new Date(Date.now() - 90 * DAY_MS)), + }, }, - }, - orderBy: [{ type: "asc" }, { bucketStart: "asc" }], - select: { - type: true, - source: true, - bucketStart: true, - count: true, - mean: true, - sumX: true, - sumXy: true, - sumXx: true, - sumYy: true, - }, - }), - ]); + orderBy: [{ type: "asc" }, { bucketStart: "asc" }], + select: { + type: true, + source: true, + bucketStart: true, + count: true, + mean: true, + sumX: true, + sumXy: true, + sumXx: true, + sumYy: true, + }, + }), + // v1.37.19 (A6-10) — all-time remainder OLDER than the fold window. + // One grouped aggregate over the pre-fold rows (index range scan on + // `(user_id, measured_at)`; empty for any account younger than the + // window). Spliced into the per-type figures below so "all-time" + // means all time on the rollup path too. Deliberately without the + // source-priority collapse: pre-fold history predates the rollup + // tier's per-day canonicalisation, and a per-day DISTINCT ON over a + // multi-year span is the cost this splice exists to avoid — for + // count/min/max/mean over years-old data the raw figures are the + // honest ones. + prisma.$queryRaw< + Array<{ + type: string; + count: number; + min: number; + max: number; + mean: number; + }> + >` + SELECT + m."type"::text AS type, + COUNT(*)::int AS count, + MIN(m."value")::double precision AS min, + MAX(m."value")::double precision AS max, + AVG(m."value")::double precision AS mean + FROM measurements m + WHERE m."user_id" = ${userId} + AND m."deleted_at" IS NULL + AND m."measured_at" < ${foldWindowStart} + GROUP BY m."type" + `, + ]); + + const preFoldByType = new Map< + string, + { count: number; min: number; max: number; mean: number } + >(); + for (const row of preFoldRemainder) { + preFoldByType.set(row.type, { + count: Number(row.count), + min: Number(row.min), + max: Number(row.max), + mean: Number(row.mean), + }); + } // v1.20.0 F6 — collapse the 90-day accumulator rows to the canonical // source per (type, day), then build a per-type accumulator-bucket list so @@ -656,23 +709,57 @@ async function computeFromRollups(userId: string): Promise { } const reg7 = composeWindowedRegression(accBuckets, since7); const reg30 = composeWindowedRegression(accBuckets, since30); - const reg90 = composeWindowedRegression(accBuckets, since90); + // v1.37.19 (A6-10) — splice the pre-fold remainder into the all-time + // figures: the rollup tier only covers the trailing fold window, so + // count / min / max / mean composed from it alone truncated "all + // time" to 5 y. Windowed fields (avg7/30, median, slopes) are + // unaffected — their windows sit far inside the fold window. + const pre = preFoldByType.get(row.type); + const allCount = row.count + (pre?.count ?? 0); + const allMin = pre ? Math.min(row.min, pre.min) : row.min; + const allMax = pre ? Math.max(row.max, pre.max) : row.max; + const allMean = pre + ? (row.mean * row.count + pre.mean * pre.count) / allCount + : row.mean; summaries[row.type] = { - count: row.count, + count: allCount, latest, - min: round2(row.min), - max: round2(row.max), - mean: round2(row.mean), + min: round2(allMin), + max: round2(allMax), + mean: round2(allMean), median: round2(narrow?.median ?? null), avg7: round2(narrow?.avg7 ?? null), avg30: round2(narrow?.avg30 ?? null), slope7: buildSlope(reg7.slope, reg7.r2), slope30: buildSlope(reg30.slope, reg30.r2), - slope90: buildSlope(reg90.slope, reg90.r2), - anomalyCount: 0, avg30LastMonth: round2(narrow?.avg30_last_month ?? null), avg30LastYear: null, }; + if (pre) totalRows += pre.count; + } + + // v1.37.19 (A6-10) — a type whose ONLY rows are older than the fold + // window has no DAY bucket at all; without this arm it read as "never + // logged" on the rollup path while the live path reported its history. + for (const [type, pre] of preFoldByType) { + if (typesWithData.includes(type)) continue; + typeCount += 1; + totalRows += pre.count; + typesWithData.push(type); + summaries[type] = { + count: pre.count, + latest: latestByType.get(type) ?? null, + min: round2(pre.min), + max: round2(pre.max), + mean: round2(pre.mean), + median: null, + avg7: null, + avg30: null, + slope7: null, + slope30: null, + avg30LastMonth: null, + avg30LastYear: null, + }; } // v1.4.40 W-WMY-WIRE — populate `avg30LastYear` per type from the @@ -833,19 +920,7 @@ async function computeFromLiveAggregate( EXTRACT(EPOCH FROM m."measured_at") / 86400.0 ) FILTER ( WHERE m."measured_at" >= (date_trunc('day', NOW() AT TIME ZONE 'UTC') - INTERVAL '30 days') AT TIME ZONE 'UTC' - )::double precision AS r2_30, - REGR_SLOPE( - m."value", - EXTRACT(EPOCH FROM m."measured_at") / 86400.0 - ) FILTER ( - WHERE m."measured_at" >= (date_trunc('day', NOW() AT TIME ZONE 'UTC') - INTERVAL '90 days') AT TIME ZONE 'UTC' - )::double precision AS slope90, - REGR_R2( - m."value", - EXTRACT(EPOCH FROM m."measured_at") / 86400.0 - ) FILTER ( - WHERE m."measured_at" >= (date_trunc('day', NOW() AT TIME ZONE 'UTC') - INTERVAL '90 days') AT TIME ZONE 'UTC' - )::double precision AS r2_90 + )::double precision AS r2_30 FROM ${canonicalMeasurementsFrom(rankUnqualified, "90 days")} GROUP BY m."type" `, @@ -925,8 +1000,6 @@ async function computeFromLiveAggregate( avg30: round2(win?.avg30 ?? null), slope7: buildSlope(win?.slope7 ?? null, win?.r2_7 ?? null), slope30: buildSlope(win?.slope30 ?? null, win?.r2_30 ?? null), - slope90: buildSlope(win?.slope90 ?? null, win?.r2_90 ?? null), - anomalyCount: 0, avg30LastMonth: round2(win?.avg30_last_month ?? null), avg30LastYear: null, }; @@ -974,7 +1047,7 @@ async function computeFromLiveAggregate( * Why this helper exists * ---------------------- * The slim `computeSummariesSlice` caps its windowed columns at 90 d - * (`slope7 / slope30 / slope90`). The v1.5 multi-year trend feature + * (`slope7 / slope30`). The v1.5 multi-year trend feature * + the Coach drawer's "history" tile need linearly composable stats * (count / min / max / mean / sum) over much larger windows — 1 y, * 2 y, 3 y. Hitting the live `measurements` table for a 3-year span diff --git a/src/lib/analytics/trends.ts b/src/lib/analytics/trends.ts index c87402820..242f30bea 100644 --- a/src/lib/analytics/trends.ts +++ b/src/lib/analytics/trends.ts @@ -143,8 +143,15 @@ export interface DataSummary { avg30: number | null; slope7: TrendSlope | null; slope30: TrendSlope | null; - slope90: TrendSlope | null; - anomalyCount: number; + /** + * In-process only — the insights feature builder reads it as the + * outlier/anomaly figure for its prompt blocks. The `/api/analytics` + * wire never serialises it (the slim SQL path cannot compute it and + * always reported 0), and no client component reads it. `slope90` was + * removed from this shape entirely in v1.37.19 for the same reason: + * computed on every request, read by nobody. + */ + anomalyCount?: number; /** * v1.4.16 phase B8 — average value over the 30-day window starting * 30 days before today, i.e. the "last month" prior period the @@ -176,7 +183,6 @@ export function summarize(data: DataPoint[]): DataSummary { avg30: null, slope7: null, slope30: null, - slope90: null, anomalyCount: 0, avg30LastMonth: null, avg30LastYear: null, @@ -267,7 +273,6 @@ export function summarize(data: DataPoint[]): DataSummary { : null, slope7: trendSlope(data, 7), slope30: trendSlope(data, 30), - slope90: trendSlope(data, 90), anomalyCount: detectAnomalies(data).length, avg30LastMonth, avg30LastYear, diff --git a/src/lib/dashboard/__tests__/snapshot.test.ts b/src/lib/dashboard/__tests__/snapshot.test.ts index 73ca46678..56b551b16 100644 --- a/src/lib/dashboard/__tests__/snapshot.test.ts +++ b/src/lib/dashboard/__tests__/snapshot.test.ts @@ -167,7 +167,6 @@ const emptySummary = { avg30: null, slope7: null, slope30: null, - slope90: null, anomalyCount: 0, }; diff --git a/src/lib/documents/__tests__/document-summary-serialisation.test.ts b/src/lib/documents/__tests__/document-summary-serialisation.test.ts index a4be9e6b1..dc5c0e88a 100644 --- a/src/lib/documents/__tests__/document-summary-serialisation.test.ts +++ b/src/lib/documents/__tests__/document-summary-serialisation.test.ts @@ -68,7 +68,7 @@ describe("serialiseDocumentDetail — summary state", () => { expect(dto.summaryGeneratedAt).toBe("2026-02-15T09:00:00.000Z"); }); - it.each(["NONE", "PENDING", "WITHHELD", "UNAVAILABLE"] as const)( + it.each(["NONE", "WITHHELD", "UNAVAILABLE"] as const)( "passes %s through with no summary", (summaryState) => { const dto = serialiseDocumentDetail(row({ summaryState }), []); @@ -78,6 +78,30 @@ describe("serialiseDocumentDetail — summary state", () => { }, ); + it("passes a FRESH PENDING through — the job is credibly in flight", () => { + const dto = serialiseDocumentDetail( + row({ summaryState: "PENDING", updatedAt: new Date() }), + [], + ); + expect(dto.summaryState).toBe("PENDING"); + }); + + // Watched red: with the stale-PENDING branch removed from + // `serialiseDocumentDetail` (the pre-v1.37.19 serialiser), this case fails — + // a summary job dead past its retry budget left the sheet saying + // "generating" forever, with no manual action offered. + it("degrades a PENDING older than the TTL to UNAVAILABLE", () => { + const dto = serialiseDocumentDetail( + row({ + summaryState: "PENDING", + updatedAt: new Date(Date.now() - 2 * 60 * 60 * 1000), + }), + [], + ); + expect(dto.summary).toBeNull(); + expect(dto.summaryState).toBe("UNAVAILABLE"); + }); + it("degrades an undecryptable READY row to UNAVAILABLE", () => { // A rotated-away key. There is no summary to show, so the DTO must not // keep promising one — and the ciphertext is never returned either. diff --git a/src/lib/documents/store.ts b/src/lib/documents/store.ts index 15022db31..7f322a15b 100644 --- a/src/lib/documents/store.ts +++ b/src/lib/documents/store.ts @@ -46,6 +46,16 @@ export type DocumentContentCodec = (typeof DOCUMENT_CONTENT_CODECS)[number]; /** The codec every NEW upload is written with. */ export const ACTIVE_DOCUMENT_CODEC: DocumentContentCodec = "binary2"; +/** + * How long a `summaryState: "PENDING"` claim stays credible. The summary job + * retries twice within minutes; a PENDING whose row has not been touched for + * an hour is a dead promise (worker crash past the retry budget, or a + * delete-during-queue then restore). Shared by the read-time heal in + * `serialiseDocumentDetail` and the hourly persistence sweep + * (`jobs/document-summary-reaper.ts`) so the two ends cannot drift. + */ +export const SUMMARY_PENDING_TTL_MS = 60 * 60 * 1000; + /** Encrypt the raw document bytes into the `Bytes` payload the schema stores. */ export function encryptDocumentToBytes(bytes: Buffer): Uint8Array { const ciphertext = encrypt(bytes.toString("base64")); @@ -291,6 +301,17 @@ export function serialiseDocumentDetail( // READY with no ciphertext should be unreachable; trust the bytes, not the // flag, rather than promising a summary that is not there. summaryState = "UNAVAILABLE"; + } else if ( + summaryState === "PENDING" && + Date.now() - doc.updatedAt.getTime() > SUMMARY_PENDING_TTL_MS + ) { + // A PENDING older than the TTL is a dead promise — the job died past its + // retry budget and nothing will resolve it. Degrade to UNAVAILABLE so the + // sheet stops saying "generating" forever and offers the manual generate + // action instead. The hourly reaper persists the same heal + // (`jobs/document-summary-reaper.ts`); this read-time arm just makes the + // very next open honest without waiting for the tick. + summaryState = "UNAVAILABLE"; } return { ...serialiseDocument( diff --git a/src/lib/export/__tests__/profile-backup-decrypt-failures.test.ts b/src/lib/export/__tests__/profile-backup-decrypt-failures.test.ts new file mode 100644 index 000000000..48b03e390 --- /dev/null +++ b/src/lib/export/__tests__/profile-backup-decrypt-failures.test.ts @@ -0,0 +1,81 @@ +/** + * v1.37.19 (A6-9) — the portable export's decryptFailures manifest. + * + * Watched red: with the collector dropped from `decryptProfileFieldSoft` + * (the pre-fix fail-soft that only nulled the field), the first case fails + * — an export with an unreadable emergency note was byte-identical to one + * where the note was never written, so the loss was invisible to the + * person restoring elsewhere. + */ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/ai/coach/bytes-codec", () => ({ + decryptFromBytes: (buf: Uint8Array) => { + const tag = Buffer.from(buf).toString("utf8"); + if (tag === "__bad__") throw new Error("unknown key id"); + return `dec:${tag}`; + }, + encryptToBytes: (s: string) => new Uint8Array(Buffer.from(s)), +})); + +import { buildProfileBackupSection } from "../profile-backup"; + +function bytes(tag: string): Uint8Array { + return new Uint8Array(Buffer.from(tag, "utf8")); +} + +function makePrisma(profileRow: Record | null) { + return { + userHealthProfile: { findUnique: vi.fn(async () => profileRow) }, + customMetric: { findMany: vi.fn(async () => []) }, + healthProfileFactRevision: { findMany: vi.fn(async () => []) }, + correlationPattern: { findMany: vi.fn(async () => []) }, + } as never; +} + +const PROFILE = { + id: "hp-1", + aboutMeEncrypted: bytes("about"), + conditionsEncrypted: null, + allergiesEncrypted: null, + coachFocusEncrypted: null, + pendingQuestionsEncrypted: null, + aiIncludedSections: undefined, + emergencyBloodType: null, + organDonorStatus: null, + advanceDirectiveStatus: null, + emergencyContactsEncrypted: null, + emergencyImplantsEncrypted: null, + emergencyNoteEncrypted: bytes("__bad__"), + createdAt: new Date("2026-08-01T00:00:00Z"), + updatedAt: new Date("2026-08-01T00:00:00Z"), +}; + +describe("buildProfileBackupSection — decryptFailures manifest", () => { + it("discloses an unreadable field in the file instead of a silent null", async () => { + const section = await buildProfileBackupSection(makePrisma(PROFILE), "u1"); + + // The field is still fail-soft null (one unreadable column must not + // cost the rest of the backup) … + expect(section.healthProfile?.emergencyNote).toBeNull(); + expect(section.healthProfile?.aboutMe).toBe("dec:about"); + // … but the loss is named in the manifest. + expect(section.decryptFailures).toEqual(["healthProfile.emergencyNote"]); + }); + + it("reports an empty manifest when everything decrypts", async () => { + const section = await buildProfileBackupSection( + makePrisma({ ...PROFILE, emergencyNoteEncrypted: bytes("note") }), + "u1", + ); + expect(section.decryptFailures).toEqual([]); + }); + + it("never decrypts on the disaster-recovery path (empty manifest)", async () => { + const section = await buildProfileBackupSection(makePrisma(PROFILE), "u1", { + purpose: "disaster-recovery", + }); + expect(section.decryptFailures).toEqual([]); + expect(section.healthProfile?.emergencyNote).toBeNull(); + }); +}); diff --git a/src/lib/export/profile-backup.ts b/src/lib/export/profile-backup.ts index 955b90c87..50253e6b0 100644 --- a/src/lib/export/profile-backup.ts +++ b/src/lib/export/profile-backup.ts @@ -175,6 +175,16 @@ export interface ProfileBackupSection { customMetrics: CustomMetricBackupEntry[]; healthProfileFacts: HealthProfileFactBackupEntry[]; correlationPatterns: CorrelationPatternBackupEntry[]; + /** + * v1.37.19 (A6-9) — field paths whose ciphertext this instance could not + * open while building a PORTABLE export (fail-soft nulls). Disclosed in + * the file itself so an export with a nulled emergency field no longer + * reads byte-identical to one where the field was never written — the + * person restoring elsewhere can see exactly what was lost. Always empty + * on the disaster-recovery path (DR carries ciphertext verbatim and + * never decrypts). + */ + decryptFailures: string[]; } export interface ProfileBackupCounts { @@ -198,6 +208,8 @@ export interface ProfileBackupCounts { function decryptProfileFieldSoft( buf: Uint8Array | null, field: string, + /** v1.37.19 (A6-9) — collector for the file's decryptFailures manifest. */ + failures?: string[], ): string | null { if (!buf || buf.byteLength === 0) return null; try { @@ -208,6 +220,7 @@ function decryptProfileFieldSoft( err instanceof Error ? err.message : String(err) }`, ); + failures?.push(`healthProfile.${field}`); return null; } } @@ -235,6 +248,9 @@ export async function buildProfileBackupSection( options: ProfileBackupOptions = {}, ): Promise { const disasterRecovery = options.purpose === "disaster-recovery"; + // v1.37.19 (A6-9) — every fail-soft decrypt below records its field path + // here; the list rides the exported file as its decryptFailures manifest. + const decryptFailures: string[] = []; const [profileRow, metricRows, factRows, patternRows] = await Promise.all([ prisma.userHealthProfile.findUnique({ where: { userId } }), @@ -296,18 +312,22 @@ export async function buildProfileBackupSection( aboutMe: decryptProfileFieldSoft( profileRow.aboutMeEncrypted, "aboutMe", + decryptFailures, ), conditions: decryptProfileFieldSoft( profileRow.conditionsEncrypted, "conditions", + decryptFailures, ), allergies: decryptProfileFieldSoft( profileRow.allergiesEncrypted, "allergies", + decryptFailures, ), coachFocus: decryptProfileFieldSoft( profileRow.coachFocusEncrypted, "coachFocus", + decryptFailures, ), aiIncludedSections: (profileRow.aiIncludedSections as HealthProfileAiSection[] | undefined) ?? [ @@ -319,14 +339,17 @@ export async function buildProfileBackupSection( emergencyContacts: decryptProfileFieldSoft( profileRow.emergencyContactsEncrypted, "emergencyContacts", + decryptFailures, ), emergencyImplants: decryptProfileFieldSoft( profileRow.emergencyImplantsEncrypted, "emergencyImplants", + decryptFailures, ), emergencyNote: decryptProfileFieldSoft( profileRow.emergencyNoteEncrypted, "emergencyNote", + decryptFailures, ), } : null; @@ -353,6 +376,7 @@ export async function buildProfileBackupSection( const value = decryptProfileFieldSoft( fact.valueEncrypted, `fact.${fact.kind}`, + decryptFailures, ); const kind = fact.kind as HealthProfileFactKind; if ( @@ -456,6 +480,7 @@ export async function buildProfileBackupSection( healthProfileFacts, customMetrics, correlationPatterns, + decryptFailures, }; } diff --git a/src/lib/insights/__tests__/comprehensive-aggregator.test.ts b/src/lib/insights/__tests__/comprehensive-aggregator.test.ts index 07c377a9a..131104667 100644 --- a/src/lib/insights/__tests__/comprehensive-aggregator.test.ts +++ b/src/lib/insights/__tests__/comprehensive-aggregator.test.ts @@ -327,8 +327,6 @@ describe("buildComprehensiveAggregate", () => { r2_7: 0.65, slope30: -0.005, r2_30: 0.42, - slope90: 0.001, - r2_90: 0.12, }, ]) .mockResolvedValueOnce([ @@ -360,11 +358,6 @@ describe("buildComprehensiveAggregate", () => { direction: "stable", confidence: 0.42, }); - expect(weight.slope90).toEqual({ - slope: 0.001, - direction: "stable", - confidence: 0.12, - }); expect(result.totalMeasurements).toBe(42); expect(result.firstMeasurementAt).toBeInstanceOf(Date); @@ -400,8 +393,6 @@ describe("buildComprehensiveAggregate", () => { r2_7: null, slope30: null, r2_30: null, - slope90: null, - r2_90: null, }, ]) .mockResolvedValueOnce([ diff --git a/src/lib/insights/__tests__/metric-availability.test.ts b/src/lib/insights/__tests__/metric-availability.test.ts index 85cf8f031..9b6575167 100644 --- a/src/lib/insights/__tests__/metric-availability.test.ts +++ b/src/lib/insights/__tests__/metric-availability.test.ts @@ -24,7 +24,6 @@ function fakeSummary(count: number): DataSummary { avg30: null, slope7: null, slope30: null, - slope90: null, anomalyCount: 0, }; } diff --git a/src/lib/insights/comprehensive-aggregator.ts b/src/lib/insights/comprehensive-aggregator.ts index c96c1b736..9f10da2e0 100644 --- a/src/lib/insights/comprehensive-aggregator.ts +++ b/src/lib/insights/comprehensive-aggregator.ts @@ -44,7 +44,7 @@ * * - A **narrow** `$queryRaw` still fires for the non-composable * fields: `stddev`, `anomalyCount`, `avg7 / avg30 / avg30LastMonth`, - * and the `slope7 / slope30 / slope90` tuples. These don't compose + * and the `slope7 / slope30` tuples. These don't compose * linearly across DAY buckets so live SQL stays canonical, but the * query no longer carries the redundant `COUNT(*)` / `MIN` / `MAX` * / `AVG` columns that duplicated the bucket-derived values. @@ -145,8 +145,6 @@ interface HeavyAggregateRow { r2_7: number | null; slope30: number | null; r2_30: number | null; - slope90: number | null; - r2_90: number | null; } /** @@ -569,7 +567,6 @@ async function buildFromRollups( } const reg7 = composeWindowedRegression(accBuckets, since7); const reg30 = composeWindowedRegression(accBuckets, since30); - const reg90 = composeWindowedRegression(accBuckets, since90); summaries[type] = { count: composed.count, latest: latest?.value ?? null, @@ -585,7 +582,6 @@ async function buildFromRollups( avg30: round2(narrow?.avg30 ?? null), slope7: buildSlope(reg7.slope, reg7.r2), slope30: buildSlope(reg30.slope, reg30.r2), - slope90: buildSlope(reg90.slope, reg90.r2), anomalyCount: Number(narrow?.anomaly_count ?? 0), avg30LastMonth: round2(narrow?.avg30_last_month ?? null), // Legacy semantics: the 90-day window guarantees no rows from a @@ -732,19 +728,7 @@ async function buildFromLiveAggregate( EXTRACT(EPOCH FROM m."measured_at") / 86400.0 ) FILTER ( WHERE m."measured_at" >= (date_trunc('day', NOW() AT TIME ZONE 'UTC') - INTERVAL '30 days') AT TIME ZONE 'UTC' - )::double precision AS r2_30, - REGR_SLOPE( - m."value", - EXTRACT(EPOCH FROM m."measured_at") / 86400.0 - ) FILTER ( - WHERE m."measured_at" >= (date_trunc('day', NOW() AT TIME ZONE 'UTC') - INTERVAL '90 days') AT TIME ZONE 'UTC' - )::double precision AS slope90, - REGR_R2( - m."value", - EXTRACT(EPOCH FROM m."measured_at") / 86400.0 - ) FILTER ( - WHERE m."measured_at" >= (date_trunc('day', NOW() AT TIME ZONE 'UTC') - INTERVAL '90 days') AT TIME ZONE 'UTC' - )::double precision AS r2_90 + )::double precision AS r2_30 FROM cm m JOIN window_stats ws ON ws."type" = m."type" GROUP BY m."type", ws.stddev_value @@ -819,7 +803,6 @@ async function buildFromLiveAggregate( avg30: round2(row.avg30), slope7: buildSlope(row.slope7, row.r2_7), slope30: buildSlope(row.slope30, row.r2_30), - slope90: buildSlope(row.slope90, row.r2_90), anomalyCount: Number(row.anomaly_count), avg30LastMonth: round2(row.avg30_last_month), avg30LastYear: null, diff --git a/src/lib/insights/features.ts b/src/lib/insights/features.ts index b0598de15..ed1fa38b7 100644 --- a/src/lib/insights/features.ts +++ b/src/lib/insights/features.ts @@ -1101,7 +1101,7 @@ export async function extractFeatures( ? summary.max : null, slope30: summary.slope30?.slope ?? null, - outlierCount: summary.anomalyCount, + outlierCount: summary.anomalyCount ?? 0, bmi, coverage: computeCoverage(weightData, now), }; @@ -1232,7 +1232,7 @@ export async function extractFeatures( ? summary.max : null, slope30: summary.slope30?.slope ?? null, - anomalyCount: summary.anomalyCount, + anomalyCount: summary.anomalyCount ?? 0, coverage: computeCoverage(pulseData, now), }; } diff --git a/src/lib/insights/score-row.ts b/src/lib/insights/score-row.ts index 09b6ebce6..98d63211a 100644 --- a/src/lib/insights/score-row.ts +++ b/src/lib/insights/score-row.ts @@ -23,6 +23,7 @@ */ import type { MeasurementType, PrismaClient } from "@/generated/prisma/client"; import { annotate } from "@/lib/logging/context"; +import { afterMeasurementMutation } from "@/lib/rollups/after-measurement-mutation"; const MS_PER_DAY = 24 * 60 * 60 * 1000; @@ -98,6 +99,14 @@ export async function upsertScoreRow( measuredAt, }, }); + + // v1.37.19 (A6-13) — a score row is a Measurement like any other: route + // it through the shared post-mutation tail so the (type, day) rollup + // bucket and the cached status assessment converge on the same tick that + // wrote the score, instead of waiting for a discovery pass. + await afterMeasurementMutation(args.userId, [ + { type: args.type, measuredAt }, + ]); } /** The tally every score pass returns. */ diff --git a/src/lib/jobs/__tests__/coach-reminder-sweep.test.ts b/src/lib/jobs/__tests__/coach-reminder-sweep.test.ts index 30e089b45..015b10d74 100644 --- a/src/lib/jobs/__tests__/coach-reminder-sweep.test.ts +++ b/src/lib/jobs/__tests__/coach-reminder-sweep.test.ts @@ -237,6 +237,46 @@ describe("runCoachReminderSweep", () => { expect(where.contextCue).toEqual({ not: "NEXT_APP_OPEN" }); }); + // Watched red: with the nag-cap pass removed from the sweep this fails on + // the `nagDismissed` assertion and on the missing `dismissed` updateMany — + // the pre-fix sweep never enforced the cap the surfaceCount schema comment + // promised, so an ignored reminder nagged indefinitely. + it("charges every ignored day and auto-dismisses at the nag cap", async () => { + const prisma = makePrisma({}); + prisma.coachReminder.updateMany + .mockResolvedValueOnce({ count: 2 }) // increment pass + .mockResolvedValueOnce({ count: 1 }); // dismiss pass + + const summary = await runCoachReminderSweep(prisma as never, NOW); + expect(summary.nagDismissed).toBe(1); + + const [incrementCall, dismissCall] = prisma.coachReminder.updateMany.mock + .calls as unknown as [ + [ + { + where: { status: string; lastSurfacedAt: { lte: Date } }; + data: { surfaceCount: { increment: number } }; + }, + ], + [ + { + where: { status: string; surfaceCount: { gte: number } }; + data: { status: string }; + }, + ], + ]; + // Only rows already stale for ~a day are charged — the tick that wrote + // the surfacing message must not also count the first ignored day. + expect(incrementCall[0].where.status).toBe("surfaced"); + expect(incrementCall[0].where.lastSurfacedAt.lte.getTime()).toBeLessThan( + NOW.getTime(), + ); + expect(incrementCall[0].data.surfaceCount).toEqual({ increment: 1 }); + // At the cap the row flips to `dismissed` — silence, not another nag. + expect(dismissCall[0].where.surfaceCount).toEqual({ gte: 3 }); + expect(dismissCall[0].data.status).toBe("dismissed"); + }); + it("skips an undecryptable plan without sinking the tick", async () => { const prisma = makePrisma({ plans: [ diff --git a/src/lib/jobs/__tests__/document-summary-reaper.test.ts b/src/lib/jobs/__tests__/document-summary-reaper.test.ts new file mode 100644 index 000000000..48ee31e9a --- /dev/null +++ b/src/lib/jobs/__tests__/document-summary-reaper.test.ts @@ -0,0 +1,32 @@ +/** + * The hourly stale-PENDING summary reaper (the persistence arm of the heal + * `serialiseDocumentDetail` applies at read time). + * + * Watched red: with the TTL predicate dropped from the updateMany `where` + * (healing EVERY pending row), the cutoff assertion fails — a summary job + * legitimately in flight must never be claimed. + */ +import { describe, expect, it, vi } from "vitest"; + +import { SUMMARY_PENDING_TTL_MS } from "@/lib/documents/store"; + +import { reapStalePendingSummaries } from "../document-summary-reaper"; + +describe("reapStalePendingSummaries", () => { + it("heals only PENDING rows older than the TTL, to UNAVAILABLE", async () => { + const updateMany = vi.fn(async () => ({ count: 3 })); + const prisma = { inboundDocument: { updateMany } } as never; + const now = new Date("2026-08-14T12:00:00.000Z"); + + const healed = await reapStalePendingSummaries(prisma, now); + + expect(healed).toBe(3); + expect(updateMany).toHaveBeenCalledWith({ + where: { + summaryState: "PENDING", + updatedAt: { lt: new Date(now.getTime() - SUMMARY_PENDING_TTL_MS) }, + }, + data: { summaryState: "UNAVAILABLE" }, + }); + }); +}); diff --git a/src/lib/jobs/__tests__/measurement-reminder.test.ts b/src/lib/jobs/__tests__/measurement-reminder.test.ts index bf813ce74..5ba9cb414 100644 --- a/src/lib/jobs/__tests__/measurement-reminder.test.ts +++ b/src/lib/jobs/__tests__/measurement-reminder.test.ts @@ -16,7 +16,20 @@ * * The Prisma surface is stubbed manually to avoid a testcontainer boot. */ -import { describe, it, expect, vi } from "vitest"; +import { beforeEach, describe, it, expect, vi } from "vitest"; + +// v1.37.19 (A6-7) — the tick claims the slot BEFORE provider egress via +// the shared record-event ledger. The manual Prisma double has no +// notification_events surface, so the claim is mocked controllable: +// default granted, individual cases flip it to prove the guard. +const claimState = vi.hoisted(() => ({ granted: true })); +const claimMock = vi.hoisted(() => vi.fn(async () => claimState.granted)); +vi.mock("@/lib/notifications/reminder-dedup", async () => { + const actual = await vi.importActual< + typeof import("@/lib/notifications/reminder-dedup") + >("@/lib/notifications/reminder-dedup"); + return { ...actual, claimNotificationEvent: claimMock }; +}); import { evaluateMeasurementReminderDue, @@ -35,6 +48,11 @@ const OK: DispatchOutcome = { const TZ = "Europe/Berlin"; +beforeEach(() => { + claimState.granted = true; + claimMock.mockClear(); +}); + // 09:00 Berlin in June = 07:00Z. const NINE_LOCAL = new Date("2026-06-15T07:00:00Z"); @@ -228,6 +246,51 @@ describe("runMeasurementReminderTick", () => { expect(updates[0].data.nextDueAt).toBeInstanceOf(Date); }); + // Watched red: with the claim-before-dispatch guard removed from the + // tick (the pre-v1.37.19 order: dispatch first, advance after), the + // denied-claim case below fails — the provider was contacted although + // another worker (or a crash-recovery replay) already owned the slot. + it("claims the slot BEFORE provider egress and skips when denied", async () => { + claimState.granted = false; + const { prisma, updates } = makePrisma({ + reminders: [reminder({})], + measurementMatch: null, + }); + const dispatch = vi.fn(async () => OK); + + const summary = await runMeasurementReminderTick( + prisma as never, + NINE_LOCAL, + { dispatch }, + ); + + expect(claimMock).toHaveBeenCalledTimes(1); + expect(dispatch).not.toHaveBeenCalled(); + expect(summary.skippedAlreadyClaimed).toBe(1); + expect(summary.dispatched).toBe(0); + // No advance either — the claimant owns the slot's lifecycle. + expect(updates).toHaveLength(0); + }); + + it("keys the claim on the reminder + the user-local date", async () => { + const { prisma } = makePrisma({ + reminders: [reminder({})], + measurementMatch: null, + }); + const dispatch = vi.fn(async () => OK); + + await runMeasurementReminderTick(prisma as never, NINE_LOCAL, { dispatch }); + + expect(claimMock).toHaveBeenCalledWith( + prisma, + expect.objectContaining({ + eventType: "MEASUREMENT_REMINDER", + dedupKey: expect.stringMatching(/^measurement:.+:2026-06-15$/), + }), + ); + expect(dispatch).toHaveBeenCalledTimes(1); + }); + it("dispatches a due reminder and advances nextDueAt past now (ledger-free dedup)", async () => { const { prisma, updates } = makePrisma({ reminders: [reminder({})], diff --git a/src/lib/jobs/coach-reminder-sweep.ts b/src/lib/jobs/coach-reminder-sweep.ts index bd89611cf..a15ff20a5 100644 --- a/src/lib/jobs/coach-reminder-sweep.ts +++ b/src/lib/jobs/coach-reminder-sweep.ts @@ -64,12 +64,30 @@ const SURFACE_BATCH = 500; /** Bound the context-backstop fan-out per tick for the same reason. */ const CONTEXT_BACKSTOP_BATCH = 500; +/** + * How many surfacings an untouched reminder gets before it auto-dismisses — + * the cap the `surfaceCount` schema comment has promised since v1.22. A + * `surfaced` row keeps riding the prompt injection and the suggested-actions + * rail; every daily tick it is still sitting there counts as one more + * surfacing, and at the cap the row flips to `dismissed` so an ignored + * reminder cannot nag indefinitely. + */ +export const REMINDER_NAG_CAP = 3; +/** + * A row surfaced within this window is not re-counted — the tick that wrote + * the surfacing message must not also charge the first "still ignored" day. + * 20 h (not 24) so the daily 05:20 cron never misses by clock drift. + */ +const NAG_STALE_MS = 20 * 60 * 60 * 1000; + export interface CoachReminderSweepSummary { /** Reminders whose moment arrived AND that reached the conversation. */ remindersDue: number; planReviewsMinted: number; /** Context-cue reminders the daily backstop surfaced. */ contextSurfaced: number; + /** Ignored reminders auto-dismissed at the nag cap. */ + nagDismissed: number; errored: number; } @@ -97,6 +115,7 @@ export async function runCoachReminderSweep( remindersDue: 0, planReviewsMinted: 0, contextSurfaced: 0, + nagDismissed: 0, errored: 0, }; @@ -232,5 +251,30 @@ export async function runCoachReminderSweep( } } + // ── 4. nag cap ──────────────────────────────────────────────── + // + // A `surfaced` reminder stays in the prompt injection and the + // suggested-actions rail until the user acts on it. Charge every tick + // it has already sat there for a day as one more surfacing, then + // auto-dismiss the rows that reached the cap. Two bulk statements, no + // message: dismissal is silence, not another nag. + await prisma.coachReminder.updateMany({ + where: { + deletedAt: null, + status: "surfaced", + lastSurfacedAt: { lte: new Date(now.getTime() - NAG_STALE_MS) }, + }, + data: { surfaceCount: { increment: 1 } }, + }); + const capped = await prisma.coachReminder.updateMany({ + where: { + deletedAt: null, + status: "surfaced", + surfaceCount: { gte: REMINDER_NAG_CAP }, + }, + data: { status: "dismissed" }, + }); + summary.nagDismissed = capped.count; + return summary; } diff --git a/src/lib/jobs/document-summary-reaper.ts b/src/lib/jobs/document-summary-reaper.ts new file mode 100644 index 000000000..f3e7b9362 --- /dev/null +++ b/src/lib/jobs/document-summary-reaper.ts @@ -0,0 +1,77 @@ +/** + * Hourly reaper for document-summary rows stuck on PENDING. + * + * PENDING promises the reader "a job will resolve this" — and the enqueue + * side only claims it once a pg-boss job genuinely exists. But a job can + * still die past its retry budget (worker crash, provider outage outliving + * retryLimit:2), and a soft-deleted-then-restored document can outlive the + * queue row entirely. Nothing then ever writes a terminal state, so the + * detail sheet says "wird generiert" forever and the OpenAPI-exported state + * keeps exporting the lie. + * + * Two ends close it (both against the same TTL): + * - read-time: `serialiseDocumentDetail` degrades a stale PENDING to + * UNAVAILABLE, so the very next detail open is honest; + * - this sweep: persists the same heal, so list chips, exports and any + * other reader converge too. Mirrors the orphan-ImportJob reconcile + * precedent (a stuck in-flight marker must converge to a visible + * terminal state within a couple of ticks). + * + * UNAVAILABLE is the honest terminal here — "could not produce" — and the + * detail view offers the manual generate button in that state, so the user + * regains the action a dead PENDING was hiding. A job that does eventually + * run after the heal still wins: the summary writer flips any non-READY + * state to READY when it lands. + */ +import { type Job } from "pg-boss"; +import { withBackgroundEvent } from "@/lib/logging/background"; +import { jobDone, jobFailed, type JobOutcome } from "@/lib/jobs/job-outcome"; +import { getWorkerPrisma } from "@/lib/jobs/reminder/shared"; +import { SUMMARY_PENDING_TTL_MS } from "@/lib/documents/store"; +import type { PrismaClient } from "@/generated/prisma/client"; + +export const DOCUMENT_SUMMARY_REAPER_QUEUE = "document-summary-reaper"; +/** Hourly at :50 — off the maintenance herd's minute marks. */ +export const DOCUMENT_SUMMARY_REAPER_CRON = "50 * * * *"; + +export interface DocumentSummaryReaperPayload { + triggeredAt?: string; +} + +/** + * Flip PENDING rows whose last write is older than the TTL to UNAVAILABLE. + * Returns the number of rows healed. `updatedAt` is the staleness clock — + * the PENDING claim bumps it, and any later legitimate progress does too, + * so a row still moving is never claimed. + */ +export async function reapStalePendingSummaries( + prisma: PrismaClient, + now: Date = new Date(), +): Promise { + const cutoff = new Date(now.getTime() - SUMMARY_PENDING_TTL_MS); + const { count } = await prisma.inboundDocument.updateMany({ + where: { summaryState: "PENDING", updatedAt: { lt: cutoff } }, + data: { summaryState: "UNAVAILABLE" }, + }); + return count; +} + +export async function handleDocumentSummaryReaper( + jobs: Job[], +): Promise { + void jobs; + return withBackgroundEvent("job.document_summary_reaper", async (evt) => { + const prisma = getWorkerPrisma(); + try { + const healed = await reapStalePendingSummaries(prisma); + evt.setAction({ name: "documents.summary.reap_stale_pending" }); + evt.addMeta("summary_pending_healed", healed); + // A tick with nothing stuck heals zero rows and is still a run that + // did its work — the zero is the fact, not an absence of one. + return jobDone({ summary_pending_healed: healed }); + } catch (err) { + evt.addWarning(`document-summary-reaper failed: ${err}`); + return jobFailed("document summary reaper failed", err); + } + }); +} diff --git a/src/lib/jobs/job-outcome.ts b/src/lib/jobs/job-outcome.ts index c7b7cf87b..1e8785ceb 100644 --- a/src/lib/jobs/job-outcome.ts +++ b/src/lib/jobs/job-outcome.ts @@ -128,6 +128,8 @@ export const JOB_FACT_ALLOWLIST: ReadonlySet = new Set([ "module_off", "mood_entries_migrated", "mood_pruned", + // v1.37.19 (A3-6) — ignored Coach reminders auto-dismissed at the nag cap. + "nag_dismissed", "notifications_dispatched", "notifications_failed", "notified", @@ -195,6 +197,8 @@ export const JOB_FACT_ALLOWLIST: ReadonlySet = new Set([ "stored", "subscription_repair_failed", "summaries_enqueued", + // v1.37.19 (A7-4) — stale-PENDING summary rows the hourly reaper healed. + "summary_pending_healed", "suppressed_client_managed", "suppressed_discreet", "thumbnails_enqueued", diff --git a/src/lib/jobs/measurement-reminder.ts b/src/lib/jobs/measurement-reminder.ts index 49a12d2dc..c53f6c365 100644 --- a/src/lib/jobs/measurement-reminder.ts +++ b/src/lib/jobs/measurement-reminder.ts @@ -34,6 +34,10 @@ import type { Locale } from "@/lib/i18n/config"; import { defaultLocale, locales } from "@/lib/i18n/config"; import { wallClockInTz } from "@/lib/tz/wall-clock"; import { dispatchNotification } from "@/lib/notifications/dispatcher"; +import { + claimNotificationEvent, + REMINDER_DEDUP_LOOKBACK_MS, +} from "@/lib/notifications/reminder-dedup"; import { getEvent } from "@/lib/logging/context"; import { isModuleEnabled } from "@/lib/modules/gate"; import type { ModuleKey } from "@/lib/modules/registry"; @@ -92,6 +96,8 @@ export interface MeasurementReminderSummary { skippedOutsideWindow: number; skippedModuleDisabled: number; skippedNoChannel: number; + /** v1.37.19 (A6-7) — slot already claimed for this local day (racing worker or crash-recovery replay). */ + skippedAlreadyClaimed: number; /** v1.18.1 — expired COACH course-window reminders soft-deleted this tick. */ expiredCleaned: number; failed: number; @@ -204,6 +210,7 @@ export async function runMeasurementReminderTick( skippedOutsideWindow: 0, skippedModuleDisabled: 0, skippedNoChannel: 0, + skippedAlreadyClaimed: 0, expiredCleaned: 0, failed: 0, }; @@ -325,6 +332,28 @@ export async function runMeasurementReminderTick( // user was filed as handled and vanished from the card and the digest. // Now, when nothing delivers, the no-channel branch below leaves the // reminder overdue and visible. + // v1.37.19 (A6-7) — claim the slot BEFORE provider egress, the + // documented claim-first pattern from `reminder-dedup.ts`. The old + // order (dispatch, then advance `nextDueAt`) double-sent when the + // worker crashed between the two; the claim is the durable record + // that this reminder's due cycle went out today. The trade is the + // same one the medication tick accepts: a crash mid-dispatch burns + // the slot for this local day rather than risking a repeat, and the + // next local day starts clean. + const localDate = new Date(now).toLocaleDateString("sv-SE", { + timeZone: timezone, + }); + const claimed = await claimNotificationEvent(prisma, { + recordUserId: reminder.user.id, + eventType: "MEASUREMENT_REMINDER", + dedupKey: `measurement:${reminder.id}:${localDate}`, + since: new Date(now.getTime() - REMINDER_DEDUP_LOOKBACK_MS), + }); + if (!claimed) { + summary.skippedAlreadyClaimed += 1; + continue; + } + const { title, body } = buildMeasurementReminderPayload( reminder.user.locale, reminder.label, @@ -359,9 +388,11 @@ export async function runMeasurementReminderTick( }, }); - // No channel succeeded — leave `nextDueAt` where it is so the next - // tick (or the user's next channel) retries. The reminder simply - // stays overdue, which the surface already shows as "überfällig". + // No channel succeeded — leave `nextDueAt` where it is. The claim + // above holds for the rest of the local day (claim-first burns the + // slot rather than re-flooding a failing channel — see + // reminder-dedup.ts); the reminder stays overdue and visible on the + // surface, and the next local day retries with a fresh key. if (!outcome.dispatched) { summary.skippedNoChannel += 1; continue; diff --git a/src/lib/jobs/medication-low-stock.ts b/src/lib/jobs/medication-low-stock.ts index 627ffdb99..493183833 100644 --- a/src/lib/jobs/medication-low-stock.ts +++ b/src/lib/jobs/medication-low-stock.ts @@ -57,6 +57,7 @@ import type { PrismaClient } from "@/generated/prisma/client"; import { classifyLowStockState, + effectiveUnitsPerDose, estimateDailyDoseCount, estimateRunwayDays, lowStockTriggerDays, @@ -125,7 +126,12 @@ export function evaluateMedicationRunway( unitsPerDose: number, schedules: RunwaySchedule[], ): LowStockEvaluation { - const supply = summariseSupply(items, unitsPerDose); + // v1.37.19 — divide the pool by the schedule-weighted average units per + // dose, so a per-slot medication's doses/runway match the wire figures. + const supply = summariseSupply( + items, + effectiveUnitsPerDose(schedules, unitsPerDose), + ); // v1.18.11 (#31) — server-side observability for the central stock clamp. // `summariseSupply` floors a corrupt/legacy negative pool to zero; this is // the request-scoped seam that records the underflow so the data defect @@ -411,6 +417,8 @@ export async function runMedicationLowStockTick( timesOfDay: true, rrule: true, rollingIntervalDays: true, + // v1.37.19 — per-slot units feed the slot-aware burn rate. + unitsPerDose: true, }, }, }, @@ -419,6 +427,16 @@ export async function runMedicationLowStockTick( for (const med of medications) { summary.medicationsEvaluated += 1; + // Decimal → number for the per-slot units so the runway math and + // the wire's `runwayDays` read the identical shape. + const runwaySchedules: RunwaySchedule[] = med.schedules.map( + (sched) => ({ + ...sched, + unitsPerDose: + sched.unitsPerDose === null ? null : Number(sched.unitsPerDose), + }), + ); + const evaluation = evaluateMedicationRunway( // v1.16.12 — Decimal columns → JS numbers for the runway math. med.inventoryItems.map((it) => ({ @@ -427,7 +445,7 @@ export async function runMedicationLowStockTick( unitsRemaining: Number(it.unitsRemaining), })), Number(med.unitsPerDose), - med.schedules, + runwaySchedules, ); // v1.17.0 — effective reorder lead (per-med override beats the @@ -441,7 +459,7 @@ export async function runMedicationLowStockTick( const triggerDays = lowStockTriggerDays({ lowStockRunwayDays: thresholdDays, leadDays, - schedules: med.schedules, + schedules: runwaySchedules, }); const decision = decideLowStockAction({ @@ -494,7 +512,7 @@ export async function runMedicationLowStockTick( expiredUnits: evaluation.expiredUnits, leadDays, triggerDays, - schedules: med.schedules, + schedules: runwaySchedules, today: now, }); const renderForRecipient = (locale: Locale) => { @@ -506,7 +524,7 @@ export async function runMedicationLowStockTick( expiredUnits: evaluation.expiredUnits, leadDays, triggerDays, - schedules: med.schedules, + schedules: runwaySchedules, today: now, }); return { title: rendered.title, message: rendered.body }; diff --git a/src/lib/jobs/reminder/register-maintenance.ts b/src/lib/jobs/reminder/register-maintenance.ts index b12a8207f..2f29ccc4c 100644 --- a/src/lib/jobs/reminder/register-maintenance.ts +++ b/src/lib/jobs/reminder/register-maintenance.ts @@ -196,6 +196,12 @@ import { handleDocumentPurge, type DocumentPurgePayload, } from "@/lib/jobs/document-purge"; +import { + DOCUMENT_SUMMARY_REAPER_QUEUE, + DOCUMENT_SUMMARY_REAPER_CRON, + handleDocumentSummaryReaper, + type DocumentSummaryReaperPayload, +} from "@/lib/jobs/document-summary-reaper"; import { CYCLE_PREDICTION_REFRESH_QUEUE, CYCLE_PREDICTION_REFRESH_CRON, @@ -423,6 +429,11 @@ const allQueues = [ // the daily schedule silently no-ops and "deleted" documents hold backup // weight forever. DOCUMENT_PURGE_QUEUE, + // Document vault — hourly reaper for summary rows stuck on PENDING (a + // job dead past its retry budget). Without this entry the schedule + // silently no-ops and a dead "wird generiert" claim never converges to + // the honest UNAVAILABLE state. + DOCUMENT_SUMMARY_REAPER_QUEUE, // Document vault P2 — on-demand content-search index backfill. Fired by the // "index all documents" action (no cron): indexes a user's not-yet-indexed // documents via one provider transcription each, consent + budget gated. @@ -569,6 +580,10 @@ const schedules: ScheduleEntry[] = [ // Document vault — daily 04:10 Europe/Berlin purge for tombstoned // documents past the 30-day undo grace. [DOCUMENT_PURGE_QUEUE, DOCUMENT_PURGE_CRON, cronIsTheRetry], + // Document vault — hourly heal of summary rows stuck on PENDING. The next + // tick is the retry: the updateMany is idempotent and the TTL predicate + // re-selects anything a failed tick left behind. + [DOCUMENT_SUMMARY_REAPER_QUEUE, DOCUMENT_SUMMARY_REAPER_CRON, cronIsTheRetry], // v1.32.1 (issue #588) — every-15-minute orphan-ImportJob sweep. Re-runs // the same reconcile the boot path uses, so a stuck "unpacking" row // whose worker crashed/restarted without the boot-time pass catching it @@ -834,6 +849,14 @@ export async function registerMaintenanceQueues( { localConcurrency: 1 }, handleDocumentPurge, ); + // Document vault — hourly stale-PENDING summary reaper. Single-flight; + // the underlying updateMany is idempotent so a duplicate tick is a no-op. + await createAndWork( + boss, + DOCUMENT_SUMMARY_REAPER_QUEUE, + { localConcurrency: 1 }, + handleDocumentSummaryReaper, + ); await createAndWork( boss, PR_DETECTION_QUEUE, diff --git a/src/lib/jobs/reminder/register-status.ts b/src/lib/jobs/reminder/register-status.ts index 22cff8e9b..8ea3a7a6f 100644 --- a/src/lib/jobs/reminder/register-status.ts +++ b/src/lib/jobs/reminder/register-status.ts @@ -845,6 +845,7 @@ export async function registerStatusQueues( reminders_due: summary.remindersDue, plan_reviews_minted: summary.planReviewsMinted, context_surfaced: summary.contextSurfaced, + nag_dismissed: summary.nagDismissed, errored: summary.errored, }, }); @@ -852,6 +853,7 @@ export async function registerStatusQueues( reminders_due: summary.remindersDue, plan_reviews_minted: summary.planReviewsMinted, context_surfaced: summary.contextSurfaced, + nag_dismissed: summary.nagDismissed, errored: summary.errored, }); } catch (err) { diff --git a/src/lib/jobs/subsystem-surface.ts b/src/lib/jobs/subsystem-surface.ts index e04088d14..cd9802d8e 100644 --- a/src/lib/jobs/subsystem-surface.ts +++ b/src/lib/jobs/subsystem-surface.ts @@ -127,6 +127,8 @@ const subsystemSurface = { "mcp-token-cleanup": { audience: "system" }, "med-notes-encryption-backfill": { audience: "system" }, "document-tombstone-purge": { audience: "system" }, + // v1.37.19 (A7-4) — hourly heal of summary rows stuck on PENDING. + "document-summary-reaper": { audience: "system" }, "document-content-index-backfill": { audience: "account" }, "document-index": { audience: "account" }, "document-thumbnail": { audience: "account" }, diff --git a/src/lib/medications/__tests__/schedule-units-dto.test.ts b/src/lib/medications/__tests__/schedule-units-dto.test.ts new file mode 100644 index 000000000..fa7b457bd --- /dev/null +++ b/src/lib/medications/__tests__/schedule-units-dto.test.ts @@ -0,0 +1,41 @@ +/** + * #219 / iOS #25 — the per-schedule units wire shape. + * + * Watched red: with `resolvedUnitsPerDose` falling back to the raw value + * alone (no medication-level inheritance) the NULL-slot case below fails — + * the pre-v1.37.19 wire shipped only the raw nullable column and told the + * client to derive the inheritance itself. + */ +import { describe, expect, it } from "vitest"; + +import { Prisma } from "@/generated/prisma/client"; + +import { serializeScheduleUnitsPerDose } from "../schedule-units-dto"; + +describe("serializeScheduleUnitsPerDose", () => { + it("keeps the raw column and adds the server-resolved effective value", () => { + const [explicit, inherited] = serializeScheduleUnitsPerDose( + [ + { id: "s1", unitsPerDose: new Prisma.Decimal("0.5") }, + { id: "s2", unitsPerDose: null }, + ], + new Prisma.Decimal("2"), + ); + // Explicit slot: raw stays, resolved equals it. + expect(explicit.unitsPerDose).toBe(0.5); + expect(explicit.resolvedUnitsPerDose).toBe(0.5); + // Inheriting slot: raw stays NULL (the edit surface needs the + // distinction), resolved carries the medication level. + expect(inherited.unitsPerDose).toBeNull(); + expect(inherited.resolvedUnitsPerDose).toBe(2); + }); + + it("serialises Decimals to plain JSON numbers", () => { + const [row] = serializeScheduleUnitsPerDose( + [{ unitsPerDose: new Prisma.Decimal("0.3333") }], + 1, + ); + expect(typeof row.unitsPerDose).toBe("number"); + expect(typeof row.resolvedUnitsPerDose).toBe("number"); + }); +}); diff --git a/src/lib/medications/list-read.ts b/src/lib/medications/list-read.ts index cbc190833..aff75744c 100644 --- a/src/lib/medications/list-read.ts +++ b/src/lib/medications/list-read.ts @@ -15,6 +15,10 @@ import { prisma } from "@/lib/db"; import { annotate, getEvent } from "@/lib/logging/context"; import { getMedicationCategories } from "@/lib/medication-category"; +import { + effectiveUnitsPerDose, + estimateUnitsRunwayDays, +} from "@/components/medications/detail/supply-runway"; import { serializeScheduleUnitsPerDose } from "@/lib/medications/schedule-units-dto"; import { computeDisplayDue, @@ -247,17 +251,42 @@ export async function buildMedicationsList( const stockUnitsRemaining = tracksInventory ? (usableUnitsByMedId.get(m.id) ?? 0) : null; + // #219 / iOS #25 — schedules on the wire carry both the raw nullable + // per-slot units AND the server-resolved effective value. + const schedulesDto = serializeScheduleUnitsPerDose( + m.schedules, + m.unitsPerDose, + ); + // v1.37.19 — slot-aware doses figure: divide the units pool by the + // schedule-weighted average units per dose, not the medication-level + // column alone (wrong for any per-slot medication). Falls back to the + // medication level when no schedule derives a consumption rate. const stockDosesRemaining = stockUnitsRemaining === null ? null - : Math.floor(stockUnitsRemaining / (Number(m.unitsPerDose) || 1)); + : Math.floor( + stockUnitsRemaining / + effectiveUnitsPerDose(schedulesDto, Number(m.unitsPerDose)), + ); + // v1.37.19 — projected runway in whole days on the wire (the low-stock + // engine's burn-rate math, slot-aware). NULL when inventory tracking is + // off or no schedule derives a consumption rate; 0 when the supply ran + // out. Published resolved so no client re-derives the cadence math. + const runwayDays = + stockUnitsRemaining === null + ? null + : estimateUnitsRunwayDays( + stockUnitsRemaining, + schedulesDto, + Number(m.unitsPerDose), + ); return { ...m, // v1.16.12 — Decimal → number so the wire stays a JSON number, not // the string Prisma would otherwise serialise a Decimal to. unitsPerDose: Number(m.unitsPerDose), // #219 — same Decimal → number unwrap for the per-schedule column. - schedules: serializeScheduleUnitsPerDose(m.schedules), + schedules: schedulesDto, category: categoryMap[m.id] ?? "OTHER", // v1.32.25 — provenance echo. Surfacing the mirror source lets the // web UI and an operator tell an externally-mirrored row (today only @@ -273,6 +302,7 @@ export async function buildMedicationsList( nextDueScheduleId: display?.scheduleId ?? null, stockUnitsRemaining, stockDosesRemaining, + runwayDays, }; }); } diff --git a/src/lib/medications/schedule-units-dto.ts b/src/lib/medications/schedule-units-dto.ts index 6aa8b694c..986bceeae 100644 --- a/src/lib/medications/schedule-units-dto.ts +++ b/src/lib/medications/schedule-units-dto.ts @@ -8,6 +8,14 @@ * column: convert to a number, keep NULL as NULL (NULL means "inherit the * medication level"). One helper so the five medication read/write responses * cannot drift on the shape. + * + * v1.37.19 (iOS #25 parity) — every schedule additionally carries + * `resolvedUnitsPerDose`: the EFFECTIVE units this slot consumes, resolved + * server-side (`schedule.unitsPerDose ?? medication.unitsPerDose`, matching + * the consumption resolver's >0 guard). Publish the resolved value; no + * client re-derives the inheritance rule. The raw nullable `unitsPerDose` + * stays beside it because the edit surface must distinguish "explicit" + * from "inherits". */ import type { Prisma } from "@/generated/prisma/client"; @@ -15,10 +23,21 @@ type ScheduleWithUnits = { unitsPerDose: Prisma.Decimal | null }; export function serializeScheduleUnitsPerDose( schedules: readonly T[], -): Array & { unitsPerDose: number | null }> { - return schedules.map((schedule) => ({ - ...schedule, - unitsPerDose: - schedule.unitsPerDose === null ? null : Number(schedule.unitsPerDose), - })); + medicationUnitsPerDose: Prisma.Decimal | number, +): Array< + Omit & { + unitsPerDose: number | null; + resolvedUnitsPerDose: number; + } +> { + const fallback = Number(medicationUnitsPerDose); + return schedules.map((schedule) => { + const raw = + schedule.unitsPerDose === null ? null : Number(schedule.unitsPerDose); + return { + ...schedule, + unitsPerDose: raw, + resolvedUnitsPerDose: raw !== null && raw > 0 ? raw : fallback, + }; + }); } diff --git a/src/lib/onboarding/__tests__/disclaimer.test.ts b/src/lib/onboarding/__tests__/disclaimer.test.ts new file mode 100644 index 000000000..497de02f5 --- /dev/null +++ b/src/lib/onboarding/__tests__/disclaimer.test.ts @@ -0,0 +1,47 @@ +/** + * The disclaimer-version gate (welcome step 0). + * + * Watched red: with `isDisclaimerAcknowledgmentCurrent` reduced to the + * pre-fix timestamp-only check (`acknowledgedAt != null`) the stale-version + * case below fails — the version column was written on acknowledgment but + * never compared, so bumping `DISCLAIMER_VERSION` could not re-prompt. + */ +import { describe, expect, it } from "vitest"; + +import { + DISCLAIMER_VERSION, + isDisclaimerAcknowledgmentCurrent, +} from "../disclaimer"; + +describe("isDisclaimerAcknowledgmentCurrent", () => { + it("holds for an acknowledgment of the current version", () => { + expect( + isDisclaimerAcknowledgmentCurrent( + "2026-06-20T08:00:00.000Z", + DISCLAIMER_VERSION, + ), + ).toBe(true); + }); + + it("re-prompts when the stored acknowledgment is for an older version", () => { + expect( + isDisclaimerAcknowledgmentCurrent( + "2026-06-20T08:00:00.000Z", + "2025-01-01", + ), + ).toBe(false); + }); + + it("re-prompts when the version was never recorded", () => { + expect( + isDisclaimerAcknowledgmentCurrent("2026-06-20T08:00:00.000Z", null), + ).toBe(false); + }); + + it("requires an acknowledgment timestamp at all", () => { + expect(isDisclaimerAcknowledgmentCurrent(null, DISCLAIMER_VERSION)).toBe( + false, + ); + expect(isDisclaimerAcknowledgmentCurrent(undefined, undefined)).toBe(false); + }); +}); diff --git a/src/lib/onboarding/disclaimer.ts b/src/lib/onboarding/disclaimer.ts index 02f37ef2d..df8d4c835 100644 --- a/src/lib/onboarding/disclaimer.ts +++ b/src/lib/onboarding/disclaimer.ts @@ -7,3 +7,18 @@ * not on a typo fix. */ export const DISCLAIMER_VERSION = "2026-06-18"; + +/** + * Whether a stored acknowledgment covers the CURRENT disclaimer text. + * + * The welcome gate pre-checks its checkbox (and skips the re-record) only + * when this holds. Until v1.37.19 the gate looked at the timestamp alone, + * which meant the version column was written but never compared — bumping + * `DISCLAIMER_VERSION` could not re-prompt anyone. + */ +export function isDisclaimerAcknowledgmentCurrent( + acknowledgedAt: string | Date | null | undefined, + acknowledgedVersion: string | null | undefined, +): boolean { + return acknowledgedAt != null && acknowledgedVersion === DISCLAIMER_VERSION; +} diff --git a/src/lib/openapi/__tests__/medication-auth-contract-drift.test.ts b/src/lib/openapi/__tests__/medication-auth-contract-drift.test.ts index f79981921..b6c4889b0 100644 --- a/src/lib/openapi/__tests__/medication-auth-contract-drift.test.ts +++ b/src/lib/openapi/__tests__/medication-auth-contract-drift.test.ts @@ -91,6 +91,8 @@ describe("medications — documented shapes match the handlers", () => { }); it("admits APPLE_HEALTH as an intake source (IntakeSource enum, v1.28)", () => { + // v1.37.19 (A4-8) — the resource declares the FULL row both intake + // responses return, so the fixture carries every field. const event = { id: "cm000000000000000000000", userId: "cm111111111111111111111", @@ -98,9 +100,18 @@ describe("medications — documented shapes match the handlers", () => { scheduledFor: "2026-07-19T08:00:00.000Z", takenAt: "2026-07-19T08:05:00.000Z", skipped: false, + autoMissed: false, + attributionSource: "AUTO", source: "APPLE_HEALTH", idempotencyKey: null, createdAt: "2026-07-19T08:05:00.000Z", + updatedAt: "2026-07-19T08:05:00.000Z", + injectionSite: null, + doseTaken: null, + inventoryConsumption: null, + externalId: "hk-intake-1", + syncVersion: 1, + deletedAt: null, }; const parsed = medicationIntakeEventResource.safeParse(event); expect(parsed.success, JSON.stringify(parsed.error?.issues)).toBe(true); diff --git a/src/lib/openapi/routes/medications/paths.ts b/src/lib/openapi/routes/medications/paths.ts index 58881a273..c33f8316b 100644 --- a/src/lib/openapi/routes/medications/paths.ts +++ b/src/lib/openapi/routes/medications/paths.ts @@ -9,6 +9,7 @@ import { createMedicationSchema, updateMedicationSchema, intakeSchema, + listIntakeEventsSchema, createInventoryItemSchema, updateInventoryItemSchema, injectionSiteEnum, @@ -452,6 +453,42 @@ export const medicationPaths: NonNullable = { ...stdResponses, }, }, + get: { + tags: ["Medications"], + summary: "List a medication's intake events", + description: + "Paged intake history for one medication (tombstoned rows excluded). `status` filters by action state: `all` (default, byte-stable pre-v1.4.37 contract), `taken`, `skipped`, or `completed` (taken OR skipped — hides the ambiguous never-confirmed rows). Sorting by `takenAt` pins NULLs last so skipped/planned rows do not float above real timestamps.", + requestParams: { + path: z.object({ id: z.string() }), + query: listIntakeEventsSchema, + }, + responses: { + ...recordRefusal(), + "200": { + description: "Intake event page.", + content: { + "application/json": { + schema: dataEnvelope( + z.object({ + events: z.array(medicationIntakeEventResource), + meta: z.object({ + total: z.number().int().nonnegative(), + limit: z.number().int().positive(), + offset: z.number().int().nonnegative(), + }), + }), + "ListMedicationIntakeEventsResponse", + ), + }, + }, + }, + "404": { + description: "Medication not found (or owned by another user).", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, }, "/api/medications/{id}/inventory": { get: { diff --git a/src/lib/openapi/routes/medications/schemas.ts b/src/lib/openapi/routes/medications/schemas.ts index 26dbf0f87..cb54ebddf 100644 --- a/src/lib/openapi/routes/medications/schemas.ts +++ b/src/lib/openapi/routes/medications/schemas.ts @@ -68,6 +68,17 @@ export const medicationScheduleResource = z .describe( "Per-schedule dose override. NULL means the schedule inherits `Medication.dose`.", ), + unitsPerDose: z + .number() + .nullable() + .describe( + "v1.37.10 (#219) — per-slot inventory-units override (may be a split-pill fraction). NULL means the schedule inherits the medication-level `unitsPerDose`. Kept raw so an edit surface can distinguish an explicit value from inheritance; consumers wanting the effective figure read `resolvedUnitsPerDose`.", + ), + resolvedUnitsPerDose: z + .number() + .describe( + "v1.37.19 — the EFFECTIVE units one dose of this slot consumes, resolved server-side (`schedule.unitsPerDose ?? medication.unitsPerDose`, matching the intake consumption resolver). Always present; clients never re-derive the inheritance rule.", + ), daysOfWeek: z .string() .nullable() @@ -260,7 +271,14 @@ export const medicationListEntry = medicationResource .int() .nullable() .describe( - "v1.16.10 — dose-derived stock: `floor(stockUnitsRemaining / unitsPerDose)`, where `unitsPerDose` may be a fraction (½ tablet ⇒ twice the doses). Stays a whole-dose count. NULL when inventory tracking is off. Drives the table view's Bestand column. Read-only — aggregated, not stored.", + "v1.16.10 — dose-derived stock as a whole-dose count. v1.37.19 — slot-aware: the divisor is the schedule-weighted average units per dose (each slot's `resolvedUnitsPerDose` weighted by its cadence share), falling back to the medication-level `unitsPerDose` when no schedule derives a consumption rate. `unitsPerDose` may be a fraction (½ tablet ⇒ twice the doses). NULL when inventory tracking is off. Drives the table view's Bestand column. Read-only — aggregated, not stored.", + ), + runwayDays: z + .number() + .int() + .nullable() + .describe( + "v1.37.19 — projected whole days the usable stock covers under the slot-aware burn rate (the same math the low-stock notification engine runs, so the wire and the push can never disagree). NULL = inventory tracking off or no consuming cadence derivable; 0 = tracking on, supply ran out. Read-only — computed, not stored.", ), }) .meta({ @@ -272,6 +290,26 @@ export const medicationListEntry = medicationResource export const medicationDetailEntry = medicationResource .extend({ category: medicationCategoryEnum, + stockUnitsRemaining: z + .number() + .nullable() + .describe( + "v1.37.19 — usable inventory units left (same semantics as the list entry's field): NULL = inventory tracking off; 0 = tracking on, supply ran out.", + ), + stockDosesRemaining: z + .number() + .int() + .nullable() + .describe( + "v1.37.19 — slot-aware dose-derived stock, mirroring the list entry's field.", + ), + runwayDays: z + .number() + .int() + .nullable() + .describe( + "v1.37.19 — projected whole days the usable stock covers under the slot-aware burn rate; NULL = tracking off or no consuming cadence.", + ), }) .meta({ id: "MedicationDetail", @@ -389,14 +427,70 @@ export const medicationIntakeEventResource = z scheduledFor: z.iso.datetime({ offset: true }), takenAt: z.iso.datetime({ offset: true }).nullable(), skipped: z.boolean(), + autoMissed: z + .boolean() + .describe( + "True when the nightly cron closed the slot as missed (no user action). Auto-missed rows count against adherence but never consume inventory.", + ), + attributionSource: z + .enum(["AUTO", "USER_PIN"]) + .describe( + "How the row bound to its schedule slot: AUTO = the write path's nearest-slot resolution; USER_PIN = the user explicitly pinned the slot (the dedup converge keeps the pinned row).", + ), source: z.enum(["WEB", "API", "REMINDER", "IMPORT", "APPLE_HEALTH"]), idempotencyKey: z.string().nullable(), createdAt: z.iso.datetime({ offset: true }), + updatedAt: z.iso.datetime({ offset: true }), + injectionSite: z + .enum([ + "ABDOMEN_LEFT", + "ABDOMEN_RIGHT", + "ABDOMEN_UPPER_LEFT", + "ABDOMEN_UPPER_RIGHT", + "THIGH_LEFT", + "THIGH_RIGHT", + "UPPER_ARM_LEFT", + "UPPER_ARM_RIGHT", + ]) + .nullable() + .describe( + "Recorded injection site for a site-tracked medication (GLP-1 rotation surface). NULL when the medication does not track sites or none was recorded.", + ), + doseTaken: z + .string() + .nullable() + .describe( + "Free-text dose actually taken when it differed from the scheduled dose (titration weeks, split doses). NULL = the scheduled dose.", + ), + inventoryConsumption: z + .unknown() + .nullable() + .describe( + "JSON ledger of the container decrements this intake caused (`[{itemId, units}]`), written by the consumption hook. NULL when nothing was consumed (skipped / no tracked inventory).", + ), + externalId: z + .string() + .nullable() + .describe( + "v1.28 — client-supplied stable id for externally-mirrored intakes (Apple Health sync); the dedup key for re-synced rows. NULL for native rows.", + ), + syncVersion: z + .number() + .int() + .describe( + "Monotonic per-row version for incremental sync readers; bumps on every mutation.", + ), + deletedAt: z.iso + .datetime({ offset: true }) + .nullable() + .describe( + "Soft-delete tombstone. Non-null rows are excluded from every list/aggregate read; sync readers use it to propagate deletions.", + ), }) .meta({ id: "MedicationIntakeEvent", description: - "Single dose log row. `takenAt` is non-null for confirmed intakes; `skipped:true` represents a deliberately-missed dose (no inventory consumption).", + "Single dose log row — the FULL row shape both the intake POST (201/200) and the intake list GET return. `takenAt` is non-null for confirmed intakes; `skipped:true` represents a deliberately-missed dose (no inventory consumption).", }); export const medicationCadenceTimelinePoint = z diff --git a/src/lib/openapi/routes/workouts.ts b/src/lib/openapi/routes/workouts.ts index 42b312b27..dd5cf5477 100644 --- a/src/lib/openapi/routes/workouts.ts +++ b/src/lib/openapi/routes/workouts.ts @@ -86,10 +86,14 @@ const workoutBatchResponse = z const workoutListEntry = z .object({ id: z.string(), - sportType: z.string(), - startedAt: z.iso.datetime({ offset: true }), - endedAt: z.iso.datetime({ offset: true }), - durationSec: z.number().int().nonnegative(), + // v1.37.19 — these four are nullable to match the DTO (list-read.ts + // passes them through from nullable columns): an externally-synced + // session can arrive without a sport type or timing. Declaring them + // non-null was a strict-decoder bomb for the Swift client. + sportType: z.string().nullable(), + startedAt: z.iso.datetime({ offset: true }).nullable(), + endedAt: z.iso.datetime({ offset: true }).nullable(), + durationSec: z.number().int().nonnegative().nullable(), distanceM: z.number().nullable(), activeEnergyKcal: z.number().nullable(), avgHr: z.number().int().nullable(), diff --git a/src/lib/record-settings/classification.ts b/src/lib/record-settings/classification.ts index 2c600d42d..0e532fd16 100644 --- a/src/lib/record-settings/classification.ts +++ b/src/lib/record-settings/classification.ts @@ -78,23 +78,15 @@ export const SETTINGS_DESTINATION_INVENTORY = { sources: UNAVAILABLE, notifications: MANAGED_GUARDIAN, layout: ADULT_SHARED_UNAVAILABLE, - dashboard: ADULT_SHARED_UNAVAILABLE, - insights: MANAGED_GUARDIAN, - medications: ADULT_SHARED_UNAVAILABLE, - mood: ADULT_SHARED_UNAVAILABLE, - labs: ADULT_SHARED_UNAVAILABLE, - illness: ADULT_SHARED_UNAVAILABLE, environment: UNAVAILABLE, anamnesis: MANAGE_WRITABLE, score: ADULT_SHARED_UNAVAILABLE, - vorsorge: ADULT_SHARED_UNAVAILABLE, thresholds: MANAGED_GUARDIAN, ai: UNAVAILABLE, coach: MANAGED_GUARDIAN, api: PERSONAL, mcp: PERSONAL, gesundheitsakte: PERSONAL, - sharing: UNAVAILABLE, export: PERSONAL, advanced: PERSONAL, privacy: PERSONAL, diff --git a/src/lib/rollups/__tests__/tiered-context.test.ts b/src/lib/rollups/__tests__/tiered-context.test.ts index 44c576f6f..4998abc97 100644 --- a/src/lib/rollups/__tests__/tiered-context.test.ts +++ b/src/lib/rollups/__tests__/tiered-context.test.ts @@ -20,6 +20,7 @@ const mocks = vi.hoisted(() => ({ ensureUserRollupsFresh: vi.fn(), probeRollupCoverage: vi.fn(), measurementFindMany: vi.fn(), + queryRaw: vi.fn(), })); vi.mock("../measurement-rollups", () => ({ @@ -34,6 +35,8 @@ vi.mock("../measurement-coverage", () => ({ vi.mock("@/lib/db", () => ({ prisma: { measurement: { findMany: mocks.measurementFindMany }, + // v1.37.19 (A6-11) — the live band fallback. + $queryRaw: mocks.queryRaw, }, })); @@ -70,6 +73,7 @@ describe("buildTieredSeries", () => { mocks.probeRollupCoverage.mockResolvedValue(new Map([["WEIGHT", true]])); mocks.measurementFindMany.mockResolvedValue([]); mocks.readRollupBuckets.mockResolvedValue([]); + mocks.queryRaw.mockResolvedValue([]); }); afterEach(() => { @@ -243,3 +247,97 @@ describe("buildTieredSeries", () => { }); }); }); + +// v1.37.19 (A6-11) — the live fallback the module doc promised from day +// one, and the far-from-UTC day-grain guard. +// +// Watched red: with the `readBandLive` fallback removed from `readBand` +// (the pre-fix reader returned the empty rollup result as-is), the +// coverage-miss case fails — a >5y account whose YEAR partition was never +// folded handed the AI empty MONTH/YEAR bands as if the history did not +// exist. With the `isNearUtc` guard removed, the far-tz case fails — the +// DAY band read UTC-keyed rollup buckets for a user whose local calendar +// disagrees with UTC for most of the day. +describe("buildTieredSeries — live fallback + near-UTC guard (A6-11)", () => { + beforeEach(() => { + mocks.ensureUserRollupsFresh.mockResolvedValue({ recomputed: false }); + mocks.probeRollupCoverage.mockResolvedValue(new Map([["WEIGHT", true]])); + mocks.measurementFindMany.mockResolvedValue([]); + mocks.readRollupBuckets.mockResolvedValue([]); + mocks.queryRaw.mockResolvedValue([]); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it("serves an empty rollup band from the live aggregate instead of silence", async () => { + mocks.readRollupBuckets.mockResolvedValue([]); + mocks.queryRaw.mockResolvedValue([ + { + bucket_start: new Date("2025-01-01T00:00:00.000Z"), + count: 40, + mean: 82.1, + min_value: 78, + max_value: 90, + sd: 2.5, + }, + ]); + + const series = await buildTieredSeries("u1", "WEIGHT", { + now: NOW, + tz: "Europe/Berlin", + }); + + // Every band read fell through to the live aggregate (4 bands). + expect(mocks.queryRaw).toHaveBeenCalled(); + expect(series.yearBand.length).toBeGreaterThan(0); + expect(series.yearBand[0]).toMatchObject({ mean: 82.1, min: 78, max: 90 }); + }); + + it("keeps the rollup result when the band has buckets (no live read)", async () => { + mocks.readRollupBuckets.mockResolvedValue([ + rollupRow("2026-05-01T00:00:00.000Z", 80, 79, 81, 10), + ]); + + await buildTieredSeries("u1", "WEIGHT", { + now: NOW, + tz: "Europe/Berlin", + }); + expect(mocks.queryRaw).not.toHaveBeenCalled(); + }); + + it("routes the DAY band straight to the tz-keyed live path for far-from-UTC users", async () => { + await buildTieredSeries("u1", "WEIGHT", { + now: NOW, + tz: "Pacific/Auckland", + }); + + // The DAY grain never touched the UTC-keyed rollup tier … + const dayReads = mocks.readRollupBuckets.mock.calls.filter( + (c) => c[2] === "DAY", + ); + expect(dayReads).toEqual([]); + // … while the coarser bands still did. + const coarseReads = mocks.readRollupBuckets.mock.calls.map( + (c) => c[2] as RollupGranularity, + ); + expect(coarseReads).toEqual( + expect.arrayContaining(["WEEK", "MONTH", "YEAR"]), + ); + }); + + it("still reads the DAY rollup inside the near-UTC band", async () => { + mocks.readRollupBuckets.mockResolvedValue([ + rollupRow("2026-06-01T00:00:00.000Z", 80, 79, 81, 10), + ]); + await buildTieredSeries("u1", "WEIGHT", { + now: NOW, + tz: "Europe/Berlin", + }); + const dayReads = mocks.readRollupBuckets.mock.calls.filter( + (c) => c[2] === "DAY", + ); + expect(dayReads.length).toBe(1); + }); +}); diff --git a/src/lib/rollups/tiered-context.ts b/src/lib/rollups/tiered-context.ts index 9d197f2b6..ac798e546 100644 --- a/src/lib/rollups/tiered-context.ts +++ b/src/lib/rollups/tiered-context.ts @@ -33,7 +33,7 @@ import type { MeasurementType, RollupGranularity, } from "@/generated/prisma/client"; -import { userDayKey } from "@/lib/tz/format"; +import { isNearUtc, userDayKey } from "@/lib/tz/format"; import { ensureUserRollupsFresh, readRollupBuckets, @@ -278,6 +278,116 @@ async function readRecentDaily( })); } +/** + * The `date_trunc` unit per band — a CLOSED map over the granularity enum, + * which is what makes splicing `unit` into the raw SQL below a + * whitelist-splice, not an injection surface. + */ +const BAND_TRUNC_UNIT: Record< + RollupGranularity, + "day" | "week" | "month" | "year" +> = { + DAY: "day", + WEEK: "week", + MONTH: "month", + YEAR: "year", +}; + +/** + * v1.37.19 (A6-11) — LIVE aggregate for one band, the fallback the module + * doc has promised since v1.18.7 but `readBand` never had: a coverage + * miss (or a >5y account whose YEAR partition was never folded) used to + * hand the AI an empty MONTH/YEAR band as if the history did not exist. + * + * One grouped aggregate over raw rows, shaped like a rollup bucket. Two + * documented approximations against the folded tier: no source-priority + * collapse (a dual-source day counts both readings into the mean), and + * no slope/r² (left null, exactly like a pre-0190 bucket). Both are + * acceptable for prompt context — the alternative was silence. + * + * `tzKey` (the far-from-UTC day-grain guard) buckets on the user's LOCAL + * calendar via `AT TIME ZONE`; parameter-bound, never spliced. + */ +async function readBandLive( + userId: string, + type: MeasurementType, + granularity: RollupGranularity, + from: Date, + to: Date, + tzKey?: string, +): Promise { + const unit = BAND_TRUNC_UNIT[granularity]; + try { + const rows = tzKey + ? await prisma.$queryRaw< + Array<{ + bucket_start: Date; + count: number; + mean: number; + min_value: number; + max_value: number; + sd: number | null; + }> + >` + SELECT + date_trunc(${unit}, m."measured_at" AT TIME ZONE ${tzKey}) AS bucket_start, + COUNT(*)::int AS count, + AVG(m."value")::double precision AS mean, + MIN(m."value")::double precision AS min_value, + MAX(m."value")::double precision AS max_value, + STDDEV_POP(m."value")::double precision AS sd + FROM measurements m + WHERE m."user_id" = ${userId} + AND m."type" = ${type}::"measurement_type" + AND m."deleted_at" IS NULL + AND m."measured_at" >= ${from} + AND m."measured_at" < ${to} + GROUP BY 1 + ORDER BY 1 + ` + : await prisma.$queryRaw< + Array<{ + bucket_start: Date; + count: number; + mean: number; + min_value: number; + max_value: number; + sd: number | null; + }> + >` + SELECT + date_trunc(${unit}, m."measured_at") AS bucket_start, + COUNT(*)::int AS count, + AVG(m."value")::double precision AS mean, + MIN(m."value")::double precision AS min_value, + MAX(m."value")::double precision AS max_value, + STDDEV_POP(m."value")::double precision AS sd + FROM measurements m + WHERE m."user_id" = ${userId} + AND m."type" = ${type}::"measurement_type" + AND m."deleted_at" IS NULL + AND m."measured_at" >= ${from} + AND m."measured_at" < ${to} + GROUP BY 1 + ORDER BY 1 + `; + return rows.map((r) => ({ + bucketStart: new Date(r.bucket_start), + count: Number(r.count), + mean: Number(r.mean), + minValue: Number(r.min_value), + maxValue: Number(r.max_value), + sd: r.sd === null ? null : Number(r.sd), + slope: null, + r2: null, + })); + } catch { + // The band builder never throws — an unreadable band degrades to + // empty exactly like the rollup path always has. + return []; + } +} + async function readBand( userId: string, type: MeasurementType, @@ -285,13 +395,28 @@ async function readBand( fromDaysAgo: number, toDaysAgo: number, now: number, + tz?: string, ): Promise { const from = new Date(now - fromDaysAgo * DAY_MS); const to = new Date(now - toDaysAgo * DAY_MS); + // v1.37.19 (A6-11) — the far-from-UTC guard the correlation readers + // carry (v1.4.38 W-A class): DAY rollup buckets group by UTC day, which + // diverges from the user's local calendar for most of the day outside + // the ±3 h near-UTC band. For those users the day-grain band goes + // straight to the live path keyed in their zone. The coarser bands stay + // on the UTC-anchored rollups — a few hours of offset moves bucket + // MEMBERSHIP at week/month/year grain by at most one edge reading. + if (granularity === "DAY" && tz && !isNearUtc(tz, new Date(now))) { + return readBandLive(userId, type, granularity, from, to, tz); + } // `readRollupBuckets` never throws — an empty / missing partition yields - // an empty array, which is the "fall back to nothing for this band" - // behaviour the coverage miss path relies on. - return readRollupBuckets(userId, type, granularity, from, to); + // an empty array. v1.37.19 — an empty band now falls back to the live + // aggregate instead of silently handing the AI nothing (the module doc + // promised replace-with-fallback from day one; only the "replace" half + // existed). + const buckets = await readRollupBuckets(userId, type, granularity, from, to); + if (buckets.length > 0) return buckets; + return readBandLive(userId, type, granularity, from, to); } /** @@ -331,6 +456,7 @@ export async function buildTieredSeries( TIERED_BANDS.dayUntil, TIERED_BANDS.rawDays, now, + options.tz, ), coarseOnly ? emptyBand diff --git a/src/lib/validations/__tests__/backup.test.ts b/src/lib/validations/__tests__/backup.test.ts index d65b46652..162251544 100644 --- a/src/lib/validations/__tests__/backup.test.ts +++ b/src/lib/validations/__tests__/backup.test.ts @@ -387,6 +387,51 @@ describe("backupPayloadSchema — v1.28 backup-completeness domains", () => { expect(summary.familyHistory).toBe(0); expect(summary.documents).toBe(0); }); + + // Watched red: with the v1.37.19 section counts removed from + // `summarizeBackup` (the pre-fix shape stopped at healthScoreRecords), + // every assertion below fails — a file carrying visits or an Impfpass + // was restored in full but reported as if those sections were empty. + it("summarizeBackup counts practitioners, encounters, links, and vaccinations", () => { + const ts = "2026-05-08T07:00:00.000Z"; + const parsed = backupPayloadSchema.parse({ + schemaVersion: "1", + exportedAt: ts, + userId: "u1", + practitioners: [ + { id: "pr-1", name: "Praxis", createdAt: ts, updatedAt: ts }, + ], + encounters: [ + { + id: "en-1", + occurredAt: ts, + status: "DONE", + kind: "ROUTINE", + createdAt: ts, + updatedAt: ts, + }, + ], + encounterDocumentLinks: [ + { encounterId: "en-1", targetId: "doc-1", createdAt: ts }, + ], + encounterLabLinks: [ + { encounterId: "en-1", targetId: "lab-1", createdAt: ts }, + ], + encounterConditionLinks: [], + vaccinations: [ + { id: "va-1", occurredAt: ts, createdAt: ts, updatedAt: ts }, + ], + vaccinationDocumentLinks: [ + { vaccinationId: "va-1", targetId: "doc-1", createdAt: ts }, + ], + }); + const summary = summarizeBackup(parsed); + expect(summary.practitioners).toBe(1); + expect(summary.encounters).toBe(1); + expect(summary.encounterLinks).toBe(2); + expect(summary.vaccinations).toBe(1); + expect(summary.vaccinationLinks).toBe(1); + }); }); describe("backupPayloadSchema — canonical v2 identity compatibility", () => { diff --git a/src/lib/validations/backup.ts b/src/lib/validations/backup.ts index 4b08b9ce0..2f1e696e1 100644 --- a/src/lib/validations/backup.ts +++ b/src/lib/validations/backup.ts @@ -1108,6 +1108,10 @@ export const backupPayloadSchema = z vaccinations: z.array(vaccinationBackupSchema).default([]), vaccinationDocumentLinks: z.array(vaccinationLinkBackupSchema).default([]), manifest: backupManifestSchema.nullable().default(null), + // v1.37.19 (A6-9) — field paths a PORTABLE export could not decrypt + // (fail-soft nulls). Disclosed in the file so a nulled field is + // distinguishable from one never written. Empty/absent on DR payloads. + decryptFailures: z.array(z.string()).default([]), }) .passthrough() .superRefine((payload, ctx) => { @@ -1175,6 +1179,16 @@ export interface BackupSummary { intradayProfiles: number; /** Local days whose health score was written down as it was shown. */ healthScoreRecords: number; + /** v1.37.19 (A6-8) — the visit address book. */ + practitioners: number; + /** v1.37.19 (A6-8) — doctor visits, planned and past. */ + encounters: number; + /** v1.37.19 (A6-8) — the three encounter link tables, summed. */ + encounterLinks: number; + /** v1.37.19 (A6-8) — immunization log entries. */ + vaccinations: number; + /** v1.37.19 (A6-8) — vaccination↔document links. */ + vaccinationLinks: number; } export function summarizeBackup(payload: BackupPayload): BackupSummary { @@ -1214,6 +1228,18 @@ export function summarizeBackup(payload: BackupPayload): BackupSummary { correlationPatterns: payload.correlationPatterns.length, intradayProfiles: payload.intradayProfiles.length, healthScoreRecords: payload.healthScoreRecords.length, + // v1.37.19 (A6-8) — the sections restored since 08-01 were written and + // restored but absent from this report, so the admin's "what did I just + // restore" answer silently under-counted a file that carried visits or + // an Impfpass. + practitioners: payload.practitioners.length, + encounters: payload.encounters.length, + encounterLinks: + payload.encounterDocumentLinks.length + + payload.encounterLabLinks.length + + payload.encounterConditionLinks.length, + vaccinations: payload.vaccinations.length, + vaccinationLinks: payload.vaccinationDocumentLinks.length, }; }