diff --git a/CHANGELOG.md b/CHANGELOG.md index 7850a2335..ff9c8570e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,83 @@ # Changelog +## [1.43.11] — 2026-10-21 + +### Fixed + +- **Long lab reports scan completely.** A report is now read page by page + instead of in one request, so a hospital report with several panels comes + back whole, up to 20 pages and 400 readings. Where the AI provider stops + before the end of a page, the readings it already returned are kept and the + review screen names the pages to check. A scan that hits your daily AI + budget stops there and says so, and a reading whose name or unit is too + long is marked on its row instead of blocking the save. Models that think + before answering no longer use up the room meant for the readings. When a + PDF carries a text layer, it helps the model tell similar characters apart, + but the page image always wins. (#1257) +- **An upcoming appointment opens itself.** Tapping it on the dashboard, in + Today or in its reminder now opens that visit instead of the check-ups + list. The visit preparation and the doctor report offered for an + appointment start at your last visit with the same practice, otherwise the + same specialty or kind of visit, and say which; with no match they cover + the last 90 days. (#1258) +- **Withings sleep stages arrive the next morning**, not weeks later. One + failing night no longer loses the other nights of a sleep sync. Steps that + Health Mate copies in from another app are skipped only when that other + app already sends them to HealthLog for the same day. +- **Deleting a WHOOP recovery no longer removes that night's sleep.** +- **Polar** no longer asks you to reconnect when your device simply lacks a + data type, and **Strava** keeps calories and progress when its rate limit + hits. +- **Fitbit and Google Health HRV is filed as RMSSD**, apart from the SDNN + that Apple Watch and Withings report, and takes part in correlations and + illness views. Readiness, HRV balance and stress keep using SDNN sources + only; the dashboard tile, the briefing and the doctor report show RMSSD, + labelled, when you have no SDNN. Restoring a backup from before this + release keeps that split. +- **The notification settings show the mood reminder as it is delivered.** +- **Settings behave the same everywhere:** delete, revoke and remove + confirmations are red again, ending a session's kept access asks first, + cards no longer stay stuck saving after a network failure, and quiet + actions (passkeys, security keys, disconnecting an integration, share + links, injection sites, local OCR, check-up reminders) tell you how they + went. +- **Readings entered through Telegram, the assistant API or Health Connect** + now rank properly against device estimates in source priority. + +### Changed + +- **Fitbit stops on 30 October 2026**, when Google turns its old Fitbit API + off. From then HealthLog no longer polls Fitbit, keeps everything already + imported, and the Fitbit card explains how to move to the Google Health + integration, which reads Fitbit and Pixel data. A day recorded by both + counts once. Accounts that never used Fitbit no longer see the card after + that date. +- **Saved AI keys can be removed**, the display name can be changed in your + profile, and emergency fields can go back to "Not recorded". Setting or + removing an AI key is recorded in the audit log. + +### Security + +- **Hardened the markers that keep document and user text apart from + instructions** sent to an AI model. Nested markers could re-form after + cleaning; cleaning now repeats until nothing changes. This covers lab + scans, the Coach and the MCP server. + +### Upgrade notes + +- Migrations 0420 and 0421 run on start. 0420 refiles Fitbit and Google + Health HRV as RMSSD and clears their rollups, which are rebuilt in the + background on the first start; expect a few minutes of extra work then. + 0421 adds one column for Withings sleep bookkeeping. +- If you use Fitbit, connect Google Health before 30 October. Tokens cannot + be carried over, so it is a fresh sign-in. + +### Thanks + +Thanks to @asmala for pinning down both limits the lab scan ran into (#1257), +and to @mills1975 for the appointment link and the report window idea +(#1258). + ## [1.43.10] — 2026-10-21 ### Changed diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index d38b30a9f..9b4f13c5b 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.43.10 + version: 1.43.11 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. @@ -20339,6 +20339,13 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 + "410": + description: Google turned the legacy Fitbit Web API off on 2026-10-30; nothing is fetched. `meta.errorCode` = + `provider_retired`. The data already imported stays; Google Health reads the same devices. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" "413": description: Body exceeds 64 KiB. content: @@ -20953,6 +20960,13 @@ paths: schema: $ref: "#/components/schemas/FitbitIntegrationTestEnvelope" "401": *a1 + "410": + description: Google turned the legacy Fitbit Web API off on 2026-10-30; nothing is probed. `meta.errorCode` = + `provider_retired`. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" "422": *a49 "429": *a50 "502": *a51 @@ -24166,10 +24180,11 @@ paths: description: "What stood out and what changed in a window, for the preparation of the next visit: deterministic observations (a value highest or lowest for at least three months, a first reading, a regular reading that stopped for two weeks or more) and context changes (dose changes, medication and course starts and ends, pauses, - illness onsets and recoveries, vaccinations, procedures, lab days). Without `from` the window starts at the last - completed visit the caller may see (else 90 days back); without `to` it ends today; at most 1096 days. Keys and - the record's own names only, no judgement. Days are cut in the record's own time zone. Sections the caller's - grant does not cover are left out." + illness onsets and recoveries, vaccinations, procedures, lab days). Without `from` the window is the one the + next planned visit prepares (its `preparation.from` on `GET /api/encounters`, `anchor: nextVisit`); with nothing + booked it starts at the last completed visit the caller may see (else 90 days back); without `to` it ends today; + at most 1096 days. Keys and the record's own names only, no judgement. Days are cut in the record's own time + zone. Sections the caller's grant does not cover are left out." parameters: - in: query name: from @@ -26637,9 +26652,10 @@ components: - OTHER - type: "null" displayName: + description: Trimmed before its bound is checked. Null, an empty string or only spaces clears it; an omitted field is + left untouched. anyOf: - type: string - minLength: 1 maxLength: 80 - type: "null" locale: @@ -31776,7 +31792,7 @@ components: properties: rows: minItems: 1 - maxItems: 100 + maxItems: 400 type: array items: type: object @@ -31838,10 +31854,10 @@ components: description: The rows a human confirmed on the Lab-OCR review screen. Each row is EITHER numeric (`value` + `unit`, optional reference bounds) OR qualitative (`valueText`) — exactly one. `analyte` drives a resolve-or-mint of the user-scoped biomarker; `takenAt` is a backdatable ISO instant. No `userId` field — it is narrowed from the - session. 1..100 rows. The route skips a row that duplicates a live reading (same analyte + day + value). The - optional `encounterId` files the panel against a visit the caller owns — ONE link per written result row, so a - marker re-run on a different day belongs to its own visit; always optional, and a link that could not be made - never fails the commit. + session. 1..400 rows (`analyte` and `valueText` up to 120 characters, `unit` up to 40). The route skips a row + that duplicates a live reading (same analyte + day + value). The optional `encounterId` files the panel against + a visit the caller owns — ONE link per written result row, so a marker re-run on a different day belongs to its + own visit; always optional, and a link that could not be made never fails the commit. CreateBiomarkerRequest: type: object properties: @@ -33746,8 +33762,7 @@ components: maxLength: 64 displayName: type: string - minLength: 1 - maxLength: 50 + maxLength: 80 heightCm: type: number minimum: 50 @@ -36231,10 +36246,10 @@ components: labs.biomarker.merge.invalid, labs.biomarker.merge.unitMismatch, labs.biomarker.notFound, labs.biomarker.update.invalid, labs.biomarker.update.referenceRangeInvalid, labs.create.invalid, labs.link.invalid, labs.ocr.budgetExceeded, labs.ocr.extractFailed, labs.ocr.fileTooLarge, - labs.ocr.fileType, labs.ocr.localOcrDisabled, labs.ocr.pdfNeedsAnthropic, labs.ocr.providerUnsupported, - labs.ocr.rateLimited, labs.restore.invalid, labs.result.notFound, labs.unit.mismatch, - labs.update.invalid, labs.update.linkedFieldsImmutable, labs.update.numericExpected, - labs.update.qualitativeExpected, labs.update.referenceRangeInvalid, + labs.ocr.fileType, labs.ocr.localOcrDisabled, labs.ocr.outputLimit, labs.ocr.pdfNeedsAnthropic, + labs.ocr.providerUnsupported, labs.ocr.rateLimited, labs.ocr.tooLarge, labs.restore.invalid, + labs.result.notFound, labs.unit.mismatch, labs.update.invalid, labs.update.linkedFieldsImmutable, + labs.update.numericExpected, labs.update.qualitativeExpected, labs.update.referenceRangeInvalid, labs.update.sourceReferenceRangeInvalid; life_event — life_event.invalid, life_event.not_found; managed_profile — managed_profile.guardian.duplicate, managed_profile.guardian.managed_invitee, managed_profile.guardian.required, managed_profile.handover.no_pending, @@ -36298,8 +36313,8 @@ components: health_score_config_conflict, insights_layout_conflict, invalid_base_updated_at, json_carries_no_intake_time, json_not_an_array, medication_layout_conflict, missing_required_columns, modules_conflict, mood_tag_layout_conflict, not_configured, notification_prefs_conflict, oidc_only, - private_origin_not_approved, private_origin_not_grantable, rate_limited_self, sync_in_progress, - too_many_rows, unreadable_json, upstream_invalid_json, vapid_not_configured" + private_origin_not_approved, private_origin_not_grantable, provider_retired, rate_limited_self, + sync_in_progress, too_many_rows, unreadable_json, upstream_invalid_json, vapid_not_configured" type: string capability: description: "On an AI refusal: the capability that is unavailable, one of the keys of `AiCapabilities` (`coach`, @@ -71279,8 +71294,8 @@ components: items: $ref: "#/components/schemas/OcrExtractedRow" pageCoverage: - description: "Present only when a PDF ran past the pages a scan reads: the rows come from the first `read` of `total` - pages. Absent when the whole document was read." + description: "Present only when a PDF ran past the pages a scan reads (20): the rows come from the first `read` of + `total` pages. Absent when the whole document was read." type: object properties: read: @@ -71295,13 +71310,75 @@ components: - read - total additionalProperties: false + truncated: + description: Present only when a read stopped at the provider's output limit. The rows that read produced before the cut + are kept; readings after the cut may be missing. `pages` names the pages whose read was cut, or is empty + when the report went to the provider as one read (a photo, a one-page PDF, OCR'd text). + type: object + properties: + pages: + type: array + items: + type: integer + exclusiveMinimum: 0 + maximum: 9007199254740991 + required: + - pages + additionalProperties: false + unreadPages: + description: "Present only when part of a multi-part read produced nothing: the provider failed partway through, or a + part's reply could not be read. The rows of the other parts are kept; these pages are not in `rows`. Empty + when the parts were not pages (OCR'd text)." + type: array + items: + type: integer + exclusiveMinimum: 0 + maximum: 9007199254740991 + stoppedBy: + description: "Present only when the scan stopped before its last read for a reason other than the provider: `budget`, + the day's AI budget ran out (every read after the first is reserved on its own); `unavailable`, AI was + turned off for the record while it ran (the wire is re-checked before every read); `error`, that check or + the reservation failed itself, so the scan stopped rather than read on unchecked. The pages it did not reach + are in `unreadPages`." + type: string + enum: + - budget + - unavailable + - error + rowsCapped: + description: "Present only when the report held more readings than one scan carries (400): `rows` holds the first `kept` + of the `found` readings." + type: object + properties: + kept: + type: integer + exclusiveMinimum: 0 + maximum: 9007199254740991 + found: + type: integer + exclusiveMinimum: 0 + maximum: 9007199254740991 + required: + - kept + - found + additionalProperties: false required: - reportDate - providerType - rows additionalProperties: false - description: The proposed rows for the human review screen, served by `GET /api/ai-runs/{id}` for a `LABS_OCR_EXTRACT` - run. Never written to the person's record; the commit route is the only write. + description: "The proposed rows for the human review screen, served by `GET /api/ai-runs/{id}` for a `LABS_OCR_EXTRACT` + run. A PDF of more than one page is read a page at a time, each rendered page sent with its text layer as a hint + the image overrules, and the pages merged in order, a reading an earlier page already listed listed once. Never + written to the person's record; the commit route is the only write. A run in which no read produced a single + complete row fails with 422 `labs.ocr.outputLimit` when the reads stopped at the provider's output limit, else + `labs.ocr.extractFailed`; OCR'd text too long for the reads the deadline allows is refused up front with 422 + `labs.ocr.tooLarge`. A scan stopped before any read produced a row answers with the stop: 429 + `labs.ocr.budgetExceeded` when the day's budget refused a further read; the wire's own refusal (its status and + code, e.g. 403 `consent.ai.required`, `assistant.disabled.*`, `module.disabled`) when AI was turned off for the + record; 500 `aiRuns.failed` when the check before a read failed. A scan stopped after some rows succeeds with + them and names the stop in `stoppedBy` (`budget`, `unavailable`, `error`) and the pages it did not reach in + `unreadPages`." OcrExtractedRow: type: object properties: @@ -71911,6 +71988,12 @@ components: $ref: "#/components/schemas/EncounterLinks" skipped: $ref: "#/components/schemas/EncounterSkipReport" + preparation: + description: "On the list read only (`GET /api/encounters`): the window before this visit when it is booked (PLANNED, + upcoming), null on every other row. Absent on the detail and on write responses." + anyOf: + - $ref: "#/components/schemas/EncounterPreparation" + - type: "null" createdAt: type: string updatedAt: @@ -72062,6 +72145,78 @@ components: checkup, which is a measurements-domain write a health-background delegate cannot perform. The visit still saves; the checkup stays due and the response says so, because somebody who believes a checkup was closed will not look at it again." + EncounterPreparation: + type: object + properties: + from: + type: string + description: "First day of the window, `YYYY-MM-DD` on the record's own calendar: the day of `since`, or 90 days before + today on `default`." + to: + type: string + description: The booked visit's own day, `YYYY-MM-DD` on the same calendar as `from` (the record's, not the reader's). A + report window offered for the appointment ends here. + basis: + type: string + enum: + - practitioner + - specialty + - kind + - default + description: "The rule that chose `from`, first match wins over the visits that took place (DONE) before this one: + `practitioner` (the last visit with the same practitioner), `specialty` (the last visit with a practitioner + of the same specialty, compared without case or extra spaces), `kind` (the last visit of the same kind, for + ROUTINE, PREVENTIVE and THERAPY only), `default` (none matched: the last 90 days)." + since: + anyOf: + - $ref: "#/components/schemas/EncounterPreparationAnchor" + - type: "null" + required: + - from + - to + - basis + - since + additionalProperties: false + description: "The window before a booked visit, resolved server-side: where the visit preparation and the doctor report + offered for the appointment start, and why there. Never the last visit of any kind, which is how a dermatology + appointment was offered a week of data from an internist visit. A client shows `from` with its reason and lets + the person change the dates; it does not recompute the rule." + EncounterPreparationAnchor: + type: object + properties: + id: + type: string + occurredAt: + type: string + kind: + type: string + enum: + - ROUTINE + - ACUTE + - SPECIALIST + - PREVENTIVE + - EMERGENCY + - HOSPITAL + - THERAPY + - OTHER + - PROCEDURE + practitionerName: + type: + - string + - "null" + specialty: + type: + - string + - "null" + required: + - id + - occurredAt + - kind + - practitionerName + - specialty + additionalProperties: false + description: "The earlier visit the window starts at: its id, instant and kind, and the practitioner's name and + specialty as they read now." CreateEncounterEnvelope: type: object properties: @@ -79322,13 +79477,42 @@ components: allowPrivateHost: description: Nightscout only. type: boolean + retirement: + description: Fitbit only. The end of the legacy Fitbit Web API. + type: object + properties: + endsOn: + type: string + pattern: ^\d{4}-\d{2}-\d{2}$ + description: The day Google turns the legacy Fitbit Web API off. + ended: + type: boolean + description: "The day has come: the connection no longer syncs, its imported data stays, and `health` is null." + successor: + type: string + enum: + - google-health + description: The integration that reads the same Fitbit and Pixel devices. Moving needs a fresh consent; the Fitbit + grant cannot be carried over. + offered: + type: boolean + description: "Whether the Fitbit card is shown. False once `ended` for an account that never connected Fitbit, has no + Fitbit app of its own and holds no Fitbit data: there is nothing to explain the move for. True before + the turndown and for every account that had Fitbit." + required: + - endsOn + - ended + - successor + - offered + additionalProperties: false health: anyOf: - $ref: "#/components/schemas/ConnectionHealth" - type: "null" - description: The unified connection state, null when the provider is not connected. It supersedes `state`, `lastError`, - `consecutiveFailuresByKind`, `syncHealth`, `tokenExpired`, `needsReauth`, `hasActivityScope`, - `planUnavailable` and `scopeReconnect` on this entry; those nine are deprecated and removed in v1.44. + description: The unified connection state, null when the provider is not connected (and for Fitbit once + `retirement.ended`). It supersedes `state`, `lastError`, `consecutiveFailuresByKind`, `syncHealth`, + `tokenExpired`, `needsReauth`, `hasActivityScope`, `planUnavailable` and `scopeReconnect` on this entry; + those nine are deprecated and removed in v1.44. required: - integration - state @@ -82724,10 +82908,58 @@ components: items: type: string description: Every event category the server knows, in declaration order — the list a client renders rows for. + eventDefaults: + type: object + propertyNames: + type: string + enum: + - MEDICATION_REMINDER + - MEASUREMENT_ANOMALY + - COMPLIANCE_LOW + - WITHINGS_SYNC_FAILED + - SYSTEM_ALERT + - PERSONAL_RECORD + - MOOD_REMINDER + - CYCLE_PERIOD_SOON + - CYCLE_PERIOD_CONFIRM + - CYCLE_FERTILE_SOON + - COACH_NUDGE + - MEDICATION_LOW_STOCK + - MEASUREMENT_REMINDER + - MEDICATION_INTAKE_SYNC + - SECURITY_ALERT + - DAILY_BRIEFING + - SHARED_RECORD_INTAKE + description: Notification category a per-channel preference can toggle. + additionalProperties: + type: boolean + required: + - MEDICATION_REMINDER + - MEASUREMENT_ANOMALY + - COMPLIANCE_LOW + - WITHINGS_SYNC_FAILED + - SYSTEM_ALERT + - PERSONAL_RECORD + - MOOD_REMINDER + - CYCLE_PERIOD_SOON + - CYCLE_PERIOD_CONFIRM + - CYCLE_FERTILE_SOON + - COACH_NUDGE + - MEDICATION_LOW_STOCK + - MEASUREMENT_REMINDER + - MEDICATION_INTAKE_SYNC + - SECURITY_ALERT + - DAILY_BRIEFING + - SHARED_RECORD_INTAKE + description: What each event does on a channel with no stored row, resolved for this account by the function delivery + uses. Most are constant per category; the mood reminder follows the account's mood-reminder setting + (`moodReminderEnabled` on `/api/user/profile`). Render a switch without a row from this map rather than from + a copy of the defaults. Since v1.43.11. required: - channels - preferences - eventTypes + - eventDefaults additionalProperties: false description: The caller's notification channels, their stored per-event overrides, and the full event vocabulary. NotificationChannelSummary: @@ -82797,9 +83029,8 @@ components: - eventType - enabled additionalProperties: false - description: An explicit per-(channel, event) override. Only STORED rows appear — an event with no row here follows the - server-side default for that category, which is ON for most and OFF for the opt-in ones (personal records, mood - and cycle reminders). Absence is therefore not 'disabled'. + description: An explicit per-(channel, event) override. Only STORED rows appear — an event with no row here follows + `eventDefaults` on the same response. Absence is therefore not 'disabled'. PutNotificationPreferenceResponse: type: object properties: @@ -85655,11 +85886,15 @@ components: anchor: type: string enum: + - nextVisit - lastVisit - requested - fallback - description: "Where `from` came from: the last completed visit, the request, or the fallback of 90 days when the record - holds no visit the caller may see." + description: "Where `from` came from. `requested`: the request named it. `nextVisit`: no `from` was sent and a visit is + booked; `from` is that visit's `preparation.from` on `GET /api/encounters` (the soonest PLANNED visit + ahead), so a client that omits `from` reads the window the web shows for it. `lastVisit`: nothing is booked; + the day of the last completed visit. `fallback`: 90 days, when the record holds no visit the caller may see. + Clients should treat an unknown value like `fallback`." observations: type: array items: diff --git a/docs/integrations/fitbit.md b/docs/integrations/fitbit.md index 7e4310c39..4c5684c21 100644 --- a/docs/integrations/fitbit.md +++ b/docs/integrations/fitbit.md @@ -16,14 +16,24 @@ Settings. The internal integration key is `fitbit`. > consent screen with `unauthorized_client — Invalid client_id`, because Fitbit > and Google Cloud are separate client registries. -> **Heads-up — new registrations are closed.** Fitbit has **discontinued -> self-serve app registration**: the form at `dev.fitbit.com/apps/new` is -> disabled for new applications, and the classic Fitbit Web API sunsets in -> **September 2026**. If you already hold a registered Fitbit app, it keeps -> working until the sunset and the setup below still applies. If you are -> connecting Fitbit data for the first time, use the -> [Google Health integration](google-health.md) instead — Fitbit devices sync -> into Google's health stack, which HealthLog reads directly. +> **The classic Fitbit Web API is turned off on 30 October 2026.** Google +> ended support for it on 30 September 2026 and turns it off on +> **30 October 2026**, after which it "will no longer function or sync data" +> ([Google's migration guide](https://developers.google.com/health/migration)). +> New app registrations at `dev.fitbit.com/apps/new` are already closed. +> +> From that day HealthLog stops calling the Web API for every Fitbit +> connection: no hourly sync, no backfill, no token refresh, and no new +> connection. Nothing is deleted. The connection and every reading it imported +> stay, and the Fitbit card explains the move instead of asking for a +> reconnect. To keep Fitbit and Pixel data coming, connect the +> [Google Health integration](google-health.md). It reads the same devices, +> but Google asks for consent once more: Fitbit tokens cannot be carried over. +> +> Both integrations can run side by side while you move. Where both deliver the +> same day, the source-priority ladder keeps one of them per day (Fitbit ranks +> just above Google Health by default), so steps, sleep and the other daily +> values are not counted twice. After the turndown only Google Health delivers. ## 1. Use an existing Fitbit developer app diff --git a/docs/integrations/google-health.md b/docs/integrations/google-health.md index 554f1ed1f..069d09e83 100644 --- a/docs/integrations/google-health.md +++ b/docs/integrations/google-health.md @@ -2,9 +2,12 @@ HealthLog reads Fitbit, Pixel Watch and Fitbit Air data through the **Google Health API** (`health.googleapis.com/v4`), the successor to the classic Fitbit -Web API. Google retires the classic Fitbit Web API in **September 2026**, and -Fitbit access/refresh tokens do not carry over — every account re-consents -through Google OAuth. This integration is that path. +Web API. Google ended support for the classic Fitbit Web API on 30 September +2026 and turns it off on **30 October 2026** +([migration guide](https://developers.google.com/health/migration)); from that +day HealthLog's `fitbit` connection stops syncing and keeps what it imported. +Fitbit access/refresh tokens do not carry over, so every account consents once +more through Google OAuth. This integration is that path. The OAuth app is a **Google Cloud** OAuth client, not a Fitbit developer-console app. Credentials are brought **per user**: each user registers their own Google diff --git a/e2e/setup-flow-visit.spec.ts b/e2e/setup-flow-visit.spec.ts index 42e53648b..1e3231bce 100644 --- a/e2e/setup-flow-visit.spec.ts +++ b/e2e/setup-flow-visit.spec.ts @@ -77,7 +77,9 @@ test.describe("setup flow — a visit within a month, with labs", () => { await expect(checklist.locator("#getting-started-body")).toBeVisible(); const visitRow = checklist.locator('li[data-item-id="visit"]'); await expect(visitRow).toHaveAttribute("data-done", "false"); - await expect(visitRow.locator('a[href="/checkups"]')).toBeVisible(); + await expect( + visitRow.locator('a[href="/checkups?view=visits"]'), + ).toBeVisible(); // Book the visit; the row reads the visits list's own body and flips. const booked = await page.request.post("/api/encounters", { diff --git a/messages/de.json b/messages/de.json index 9a2c80392..b8afe311c 100644 --- a/messages/de.json +++ b/messages/de.json @@ -6206,7 +6206,7 @@ "resetConfirmBody": "Die Kette wird auf Codex → OpenAI → Anthropic → Lokal → Admin-OpenAI zurückgesetzt. Gespeicherte Zugangsdaten bleiben erhalten." }, "activeProviderHeading": "Aktiver Provider", - "activeProviderLabel": "Primärer Provider", + "activeProviderLabel": "Anbieter einrichten", "providerSelect": { "codex": "ChatGPT-Account (Codex)", "openai": "OpenAI (eigener API-Key)", @@ -6413,7 +6413,7 @@ }, "coachNote": "Im Coach gilt die Denktiefe aus den Coach-Einstellungen." }, - "providerCardDescription": "Der Anbieter, den HealthLog zuerst fragt, mit Schlüssel und Modell.", + "providerCardDescription": "Richte Schlüssel und Modell je Anbieter ein; die Reihenfolge darunter bestimmt, welchen HealthLog zuerst fragt.", "runtimeTitle": "Prüfen und aktualisieren", "usage": { "title": "KI-Einsatz", @@ -6440,6 +6440,14 @@ "followsDefault": "Du folgst dem Standard des Servers ({level}).", "useDefault": "Standard des Servers verwenden ({level})", "saveError": "Die Stufe konnte nicht gespeichert werden. Bitte versuche es erneut." + }, + "removeKey": { + "label": "API-Schlüssel entfernen", + "title": "Gespeicherten API-Schlüssel entfernen?", + "body": "HealthLog löscht den gespeicherten Schlüssel und kann ihn nicht wiederherstellen. Gib ihn erneut ein, um ihn weiter zu nutzen.", + "confirm": "API-Schlüssel entfernen", + "done": "API-Schlüssel entfernt.", + "failed": "Der API-Schlüssel konnte nicht entfernt werden." } }, "dashboard": { @@ -6619,7 +6627,8 @@ "nocode": "Fitbit hat keinen Autorisierungscode zurückgegeben. Versuche es erneut und erlaube den Zugriff im Fitbit-Dialog.", "nocreds": "Fitbit ist nicht konfiguriert. Hinterlege oben deine Fitbit-OAuth-Zugangsdaten und versuche es erneut.", "token": "Der Token-Austausch mit Fitbit ist fehlgeschlagen. Bitte starte die Verbindung erneut.", - "generic": "Unbekannter Fehler bei der Verbindung. Bitte starte die Verbindung erneut." + "generic": "Unbekannter Fehler bei der Verbindung. Bitte starte die Verbindung erneut.", + "retired": "Google hat die Fitbit-Web-API abgeschaltet, deshalb lässt sich keine neue Fitbit-Verbindung mehr herstellen. Verbinde stattdessen Google Health, das dieselben Geräte liest." }, "integrations": { "withings": { @@ -6851,7 +6860,7 @@ "errorRateLimit": "Das Exportlimit für diese Stunde ist erreicht.", "errorModuleDisabled": "Der Gesundheitsakten-Export ist für dieses Konto ausgeschaltet.", "visitOffer": "Am {when} steht ein Termin an: {what}.", - "visitOfferApply": "Bericht bis dahin" + "visitOfferApplyWindow": "Zeitraum übernehmen" }, "globalExcludedInjectionSitesLabel": "Global ausgeschlossene Injektionsstellen", "globalExcludedInjectionSitesHint": "Nie angeboten und beim Erfassen abgelehnt, auch wenn eine Medikation sie bevorzugt.", @@ -6909,7 +6918,9 @@ "reselectReason": "Dieser Link lieferte einen Umfang, den niemand gewählt hatte, und wurde deshalb geschlossen. Lege einen neuen an und wähle den Inhalt.", "reselectAction": "Ersatz anlegen", "createDetail": "Der Token wird nur einmal bei der Erstellung angezeigt. Sichere ihn dann; er lässt sich nicht wiederherstellen.", - "activeDescription": "Deine geteilten Links, wann sie ablaufen und wie oft sie geöffnet wurden." + "activeDescription": "Deine geteilten Links, wann sie ablaufen und wie oft sie geöffnet wurden.", + "revokedToast": "Link widerrufen.", + "revokeFailed": "Der Link konnte nicht widerrufen werden." }, "genderOther": "Divers", "nightscout": "Nightscout", @@ -7096,7 +7107,9 @@ "lastUsed": "Zuletzt verwendet {date}", "neverUsed": "Noch nie verwendet", "added": "hinzugefügt {date}", - "nameLabel": "Name des Sicherheitsschlüssels" + "nameLabel": "Name des Sicherheitsschlüssels", + "addedToast": "Sicherheitsschlüssel hinzugefügt.", + "removedToast": "Sicherheitsschlüssel entfernt." }, "passkeyNudge": { "title": "Füge einen Passkey für schnellere Anmeldung hinzu", @@ -7439,7 +7452,20 @@ "homePlaceUnknown": "Diesen Ort kennt die Dawarich-Instanz nicht mehr.", "notConnected": "Dawarich ist nicht verbunden.", "generic": "Das hat nicht geklappt. Versuche es erneut." - } + }, + "fitbitRetirement": { + "ending": "Google schaltet die Fitbit-Web-API am {date} ab. Ab dann synchronisiert diese Verbindung nicht mehr.", + "ended": "Google hat die Fitbit-Web-API am {date} abgeschaltet. Diese Verbindung synchronisiert nicht mehr.", + "move": "Google Health liest dieselben Fitbit- und Pixel-Geräte. Verbinde es unten, damit deine Daten weiter ankommen. Google fragt dich noch einmal nach deiner Zustimmung, weil sich die Fitbit-Verbindung nicht übertragen lässt. Alles bereits Importierte bleibt erhalten.", + "toCard": "Zu Google Health", + "guide": "So verbindest du Google Health", + "badgeEnding": "Wird abgeschaltet", + "badgeEnded": "Abgeschaltet" + }, + "passkeyAddedToast": "Passkey hinzugefügt.", + "passkeyRemovedToast": "Passkey entfernt.", + "integrationDisconnected": "{provider} getrennt.", + "integrationDisconnectFailed": "{provider} konnte nicht getrennt werden." }, "admin": { "title": "Administration", @@ -9878,6 +9904,17 @@ "reviewHint": "Prüfe jeden Wert und speichere die gewünschten Einträge.", "foundCount": "{count} Werte gefunden", "pagesCapped": "Nur die ersten {read} von {total} Seiten wurden gelesen. Werte auf späteren Seiten fehlen in dieser Liste.", + "stoppedBudget": "Dein KI-Budget für heute war mitten im Befund aufgebraucht, deshalb wurden die übrigen Seiten nicht gelesen.", + "stoppedUnavailable": "KI wurde abgeschaltet, während der Befund gelesen wurde, deshalb wurden die übrigen Seiten nicht gelesen.", + "stoppedError": "Der Scan konnte wegen eines Serverfehlers nicht weiterlaufen, deshalb wurden die übrigen Seiten nicht gelesen. Versuche es später noch einmal.", + "tooLarge": "Dieser Text ist für einen Scan zu lang. Scanne den Befund Seite für Seite.", + "truncated": "Der KI-Anbieter hat sein Ausgabelimit vor dem Ende des Befunds erreicht. Werte danach können fehlen, vergleiche die Liste deshalb mit dem Befund.", + "truncatedPages": "Der KI-Anbieter hat auf diesen Seiten sein Ausgabelimit erreicht: {pages}. Werte weiter unten auf diesen Seiten können fehlen, vergleiche sie deshalb mit dem Befund.", + "unreadPages": "Diese Seiten konnten nicht gelesen werden, ihre Werte fehlen in dieser Liste: {pages}.", + "unreadParts": "Ein Teil des Befunds konnte nicht gelesen werden, seine Werte fehlen in dieser Liste.", + "rowsCapped": "Der Befund enthält {found} Werte. Ein Scan übernimmt {kept}, deshalb siehst du nur die ersten {kept}.", + "outputLimit": "Der KI-Anbieter hat sein Ausgabelimit erreicht, bevor er einen einzigen Wert geliefert hat. Versuche ein Foto einer einzelnen Seite oder ein Modell, das längere Antworten erlaubt.", + "errorTooLong": "Bitte auf {max} Zeichen kürzen.", "confirmRow": "Diesen Wert übernehmen", "analyteLabel": "Bezeichnung", "resultLabel": "Ergebnis", @@ -10392,6 +10429,7 @@ "vital": { "RESTING_HEART_RATE": "Ruheherzfrequenz", "HEART_RATE_VARIABILITY": "Herzfrequenzvariabilität", + "HRV_RMSSD": "HRV (RMSSD)", "RECOVERY_SCORE": "Recovery", "OXYGEN_SATURATION": "Sauerstoffsättigung", "BODY_TEMPERATURE": "Körpertemperatur", @@ -11997,6 +12035,8 @@ "logDose": "Dosis erfassen", "reconnect": "Neu verbinden", "viewCheckups": "Vorsorge ansehen", + "viewAppointment": "Termin ansehen", + "viewAppointments": "Termine ansehen", "checkinKeep": "Behalten", "checkinAdjust": "Anpassen", "checkinLetGo": "Loslassen", @@ -12721,6 +12761,13 @@ "noMatchTitle": "Nichts passt zu diesem Filter", "clearFilter": "Filter zurücksetzen", "loadError": "Die Eingriffe konnten nicht geladen werden." + }, + "preparation": { + "practitioner": "Seit deinem letzten Termin bei {name} am {date}", + "specialty": "Seit deinem letzten Termin ({specialty}) am {date}", + "kind": "Seit deinem letzten Termin der gleichen Art ({kind}) am {date}", + "default": "Die letzten {days} Tage", + "defaultReason": "Kein früherer Termin bei derselben Praxis, in derselben Fachrichtung oder derselben Art." } }, "checkups": { @@ -12970,7 +13017,6 @@ "meanOfReadingsFew": "Schnitt aus {count} Messungen", "meanOfReadingsOther": "Schnitt aus {count} Messungen", "sinceLastVisit": { - "title": "Seit dem letzten Besuch am {date}", "countOne": "1 Eintrag", "countFew": "{count} Einträge", "countOther": "{count} Einträge", diff --git a/messages/en.json b/messages/en.json index 8d6ab2cd4..8ec8c0e61 100644 --- a/messages/en.json +++ b/messages/en.json @@ -6206,7 +6206,7 @@ "resetConfirmBody": "The chain reverts to Codex → OpenAI → Anthropic → Local → Admin OpenAI. Your saved credentials are not touched." }, "activeProviderHeading": "Active provider", - "activeProviderLabel": "Primary provider", + "activeProviderLabel": "Set up a provider", "providerSelect": { "codex": "ChatGPT account (Codex)", "openai": "OpenAI (your API key)", @@ -6413,7 +6413,7 @@ }, "coachNote": "In the Coach, the thinking depth from the Coach settings applies." }, - "providerCardDescription": "The provider HealthLog asks first, and its key and model.", + "providerCardDescription": "Set up each provider's key and model; the order below decides which one HealthLog asks first.", "runtimeTitle": "Check and refresh", "usage": { "title": "AI usage", @@ -6440,6 +6440,14 @@ "followsDefault": "You follow the server default ({level}).", "useDefault": "Use the server default ({level})", "saveError": "The level could not be saved. Please try again." + }, + "removeKey": { + "label": "Remove API key", + "title": "Remove the saved API key?", + "body": "HealthLog deletes the stored key and cannot recover it. Enter it again to keep using it.", + "confirm": "Remove API key", + "done": "API key removed.", + "failed": "Could not remove the API key." } }, "dashboard": { @@ -6619,7 +6627,8 @@ "nocode": "Fitbit did not return an authorization code. Try again and allow access in the Fitbit dialog.", "nocreds": "Fitbit is not configured. Add your Fitbit OAuth credentials above and try again.", "token": "The token exchange with Fitbit failed. Please start the connection again.", - "generic": "Unknown error while connecting. Please start the connection again." + "generic": "Unknown error while connecting. Please start the connection again.", + "retired": "Google has turned the Fitbit Web API off, so no new Fitbit connection can be made. Connect Google Health instead, which reads the same devices." }, "integrations": { "withings": { @@ -6851,7 +6860,7 @@ "errorRateLimit": "You have reached the export limit for this hour.", "errorModuleDisabled": "The health record export is switched off for this account.", "visitOffer": "An appointment is coming up on {when}: {what}.", - "visitOfferApply": "Report up to then" + "visitOfferApplyWindow": "Use this window" }, "globalExcludedInjectionSitesLabel": "Globally excluded injection sites", "globalExcludedInjectionSitesHint": "Never offered and rejected when logging a dose, even if a medication prefers them.", @@ -6909,7 +6918,9 @@ "reselectReason": "This link served a scope that was never chosen, so it was closed. Create a new one and pick what it should contain.", "reselectAction": "Create a replacement", "createDetail": "The token is shown once on creation. Store it then; it cannot be recovered.", - "activeDescription": "Links you have shared, when they expire and how often they were opened." + "activeDescription": "Links you have shared, when they expire and how often they were opened.", + "revokedToast": "Link revoked.", + "revokeFailed": "Could not revoke the link." }, "genderOther": "Other", "nightscout": "Nightscout", @@ -7096,7 +7107,9 @@ "lastUsed": "Last used {date}", "neverUsed": "Never used", "added": "added {date}", - "nameLabel": "Security key name" + "nameLabel": "Security key name", + "addedToast": "Security key added.", + "removedToast": "Security key removed." }, "passkeyNudge": { "title": "Add a passkey for faster sign-in", @@ -7439,7 +7452,20 @@ "homePlaceUnknown": "The Dawarich instance no longer knows this place.", "notConnected": "Dawarich is not connected.", "generic": "That did not work. Try again." - } + }, + "fitbitRetirement": { + "ending": "Google turns the Fitbit Web API off on {date}. From then on this connection stops syncing.", + "ended": "Google turned the Fitbit Web API off on {date}. This connection no longer syncs.", + "move": "Google Health reads the same Fitbit and Pixel devices. Connect it below to keep your data coming. Google asks for your consent once more, because the Fitbit connection cannot be carried over. Everything already imported stays.", + "toCard": "Go to Google Health", + "guide": "How to connect Google Health", + "badgeEnding": "Shutting down", + "badgeEnded": "Retired" + }, + "passkeyAddedToast": "Passkey added.", + "passkeyRemovedToast": "Passkey removed.", + "integrationDisconnected": "{provider} disconnected.", + "integrationDisconnectFailed": "Could not disconnect {provider}." }, "admin": { "title": "Administration", @@ -9878,6 +9904,17 @@ "reviewHint": "Check each reading, then save the ones you want to keep.", "foundCount": "{count} readings found", "pagesCapped": "Only the first {read} of {total} pages were read. Values on later pages are not in this list.", + "stoppedBudget": "Your AI budget for today ran out partway through the report, so the remaining pages were not read.", + "stoppedUnavailable": "AI was turned off while the report was being read, so the remaining pages were not read.", + "stoppedError": "The scan could not continue because of a server error, so the remaining pages were not read. Try again later.", + "tooLarge": "This text is too long for one scan. Scan the report a page at a time.", + "truncated": "The AI provider reached its output limit before the end of the report. Readings after that point may be missing, so check the list against the report.", + "truncatedPages": "The AI provider reached its output limit partway through these pages: {pages}. Readings further down them may be missing, so check them against the report.", + "unreadPages": "These pages could not be read, and their readings are not in this list: {pages}.", + "unreadParts": "Part of the report could not be read, and its readings are not in this list.", + "rowsCapped": "The report lists {found} readings. One scan takes {kept}, so only the first {kept} are shown.", + "outputLimit": "The AI provider reached its output limit before it returned a single reading. Try a photo of one page, or a model that allows longer answers.", + "errorTooLong": "Shorten this to {max} characters.", "confirmRow": "Include this reading", "analyteLabel": "Test name", "resultLabel": "Result", @@ -10392,6 +10429,7 @@ "vital": { "RESTING_HEART_RATE": "Resting heart rate", "HEART_RATE_VARIABILITY": "Heart-rate variability", + "HRV_RMSSD": "HRV (RMSSD)", "RECOVERY_SCORE": "Recovery", "OXYGEN_SATURATION": "Oxygen saturation", "BODY_TEMPERATURE": "Body temperature", @@ -11997,6 +12035,8 @@ "logDose": "Log dose", "reconnect": "Reconnect", "viewCheckups": "View check-ups", + "viewAppointment": "View appointment", + "viewAppointments": "View appointments", "checkinKeep": "Keep it", "checkinAdjust": "Adjust", "checkinLetGo": "Let it go", @@ -12721,6 +12761,13 @@ "noMatchTitle": "Nothing matches this filter", "clearFilter": "Clear filter", "loadError": "Procedures could not be loaded." + }, + "preparation": { + "practitioner": "Since your last visit with {name} on {date}", + "specialty": "Since your last {specialty} visit on {date}", + "kind": "Since your last visit of the same kind ({kind}) on {date}", + "default": "The last {days} days", + "defaultReason": "No earlier visit with the same practice, specialty or kind." } }, "checkups": { @@ -12970,7 +13017,6 @@ "meanOfReadingsFew": "Average of {count} readings", "meanOfReadingsOther": "Average of {count} readings", "sinceLastVisit": { - "title": "Since the last visit on {date}", "countOne": "1 entry", "countFew": "{count} entries", "countOther": "{count} entries", diff --git a/messages/es.json b/messages/es.json index f86b42a52..80928dc2d 100644 --- a/messages/es.json +++ b/messages/es.json @@ -6206,7 +6206,7 @@ "resetConfirmBody": "La cadena vuelve a Codex → OpenAI → Anthropic → Local → OpenAI de administrador. Tus credenciales guardadas no se modifican." }, "activeProviderHeading": "Proveedor activo", - "activeProviderLabel": "Proveedor principal", + "activeProviderLabel": "Configurar un proveedor", "providerSelect": { "codex": "Cuenta de ChatGPT (Codex)", "openai": "OpenAI (tu clave de API)", @@ -6413,7 +6413,7 @@ }, "coachNote": "En el Coach se aplica la profundidad de pensamiento de los ajustes del Coach." }, - "providerCardDescription": "El proveedor al que HealthLog pregunta primero, con su clave y su modelo.", + "providerCardDescription": "Configura la clave y el modelo de cada proveedor; el orden de abajo decide a cuál pregunta HealthLog primero.", "runtimeTitle": "Comprobar y actualizar", "usage": { "title": "Uso de la IA", @@ -6440,6 +6440,14 @@ "followsDefault": "Sigues el valor predeterminado del servidor ({level}).", "useDefault": "Usar el valor predeterminado del servidor ({level})", "saveError": "No se pudo guardar el nivel. Inténtalo de nuevo." + }, + "removeKey": { + "label": "Quitar clave de API", + "title": "¿Quitar la clave de API guardada?", + "body": "HealthLog borra la clave guardada y no puede recuperarla. Vuelve a introducirla para seguir usándola.", + "confirm": "Quitar clave de API", + "done": "Clave de API quitada.", + "failed": "No se pudo quitar la clave de API." } }, "dashboard": { @@ -6619,7 +6627,8 @@ "nocode": "Fitbit no devolvió un código de autorización. Inténtalo de nuevo y permite el acceso en el diálogo de Fitbit.", "nocreds": "Fitbit no está configurado. Añade tus credenciales OAuth de Fitbit arriba e inténtalo de nuevo.", "token": "El intercambio de token con Fitbit falló. Inicia la conexión de nuevo.", - "generic": "Error desconocido al conectar. Inicia la conexión de nuevo." + "generic": "Error desconocido al conectar. Inicia la conexión de nuevo.", + "retired": "Google desactivó la API web de Fitbit, así que ya no se puede crear una nueva conexión de Fitbit. Conecta Google Health, que lee los mismos dispositivos." }, "integrations": { "withings": { @@ -6851,7 +6860,7 @@ "errorRateLimit": "Ha alcanzado el límite de exportaciones de esta hora.", "errorModuleDisabled": "La exportación del expediente está desactivada para esta cuenta.", "visitOffer": "El {when} hay una cita: {what}.", - "visitOfferApply": "Informe hasta esa fecha" + "visitOfferApplyWindow": "Usar este periodo" }, "globalExcludedInjectionSitesLabel": "Sitios de inyección excluidos globalmente", "globalExcludedInjectionSitesHint": "Nunca se ofrecen y se rechazan al registrar una dosis, aunque un medicamento los prefiera.", @@ -6909,7 +6918,9 @@ "reselectReason": "Este enlace mostraba un alcance que nadie eligió, por eso se cerró. Crea uno nuevo y elige su contenido.", "reselectAction": "Crear un reemplazo", "createDetail": "El token se muestra una sola vez al crearlo. Guárdalo entonces; no se puede recuperar.", - "activeDescription": "Los enlaces que has compartido, cuándo caducan y cuántas veces se abrieron." + "activeDescription": "Los enlaces que has compartido, cuándo caducan y cuántas veces se abrieron.", + "revokedToast": "Enlace revocado.", + "revokeFailed": "No se pudo revocar el enlace." }, "genderOther": "Otro", "nightscout": "Nightscout", @@ -7096,7 +7107,9 @@ "lastUsed": "Usada por última vez el {date}", "neverUsed": "Nunca usada", "added": "añadida el {date}", - "nameLabel": "Nombre de la llave de seguridad" + "nameLabel": "Nombre de la llave de seguridad", + "addedToast": "Llave de seguridad añadida.", + "removedToast": "Llave de seguridad eliminada." }, "passkeyNudge": { "title": "Añade una clave de acceso para iniciar sesión más rápido", @@ -7439,7 +7452,20 @@ "homePlaceUnknown": "La instancia de Dawarich ya no conoce este lugar.", "notConnected": "Dawarich no está conectado.", "generic": "No ha funcionado. Inténtalo de nuevo." - } + }, + "fitbitRetirement": { + "ending": "Google desactivará la API web de Fitbit el {date}. A partir de entonces, esta conexión dejará de sincronizar.", + "ended": "Google desactivó la API web de Fitbit el {date}. Esta conexión ya no sincroniza.", + "move": "Google Health lee los mismos dispositivos Fitbit y Pixel. Conéctalo abajo para que tus datos sigan llegando. Google te pedirá tu consentimiento otra vez, porque la conexión de Fitbit no se puede transferir. Todo lo ya importado se conserva.", + "toCard": "Ir a Google Health", + "guide": "Cómo conectar Google Health", + "badgeEnding": "Se desactiva pronto", + "badgeEnded": "Desactivado" + }, + "passkeyAddedToast": "Passkey añadida.", + "passkeyRemovedToast": "Passkey eliminada.", + "integrationDisconnected": "{provider} desconectado.", + "integrationDisconnectFailed": "No se pudo desconectar {provider}." }, "admin": { "title": "Administration", @@ -9878,6 +9904,17 @@ "reviewHint": "Revisa cada valor y guarda los que quieras conservar.", "foundCount": "{count} valores encontrados", "pagesCapped": "Solo se leyeron las primeras {read} de {total} páginas. Los valores de las páginas siguientes no están en esta lista.", + "stoppedBudget": "Tu presupuesto de IA de hoy se agotó a mitad del informe, así que las páginas restantes no se leyeron.", + "stoppedUnavailable": "La IA se desactivó mientras se leía el informe, así que las páginas restantes no se leyeron.", + "stoppedError": "El escaneo no pudo continuar por un error del servidor, así que las páginas restantes no se leyeron. Inténtalo de nuevo más tarde.", + "tooLarge": "Este texto es demasiado largo para un escaneo. Escanea el informe página por página.", + "truncated": "El proveedor de IA alcanzó su límite de salida antes del final del informe. Pueden faltar valores a partir de ese punto, así que compara la lista con el informe.", + "truncatedPages": "El proveedor de IA alcanzó su límite de salida en estas páginas: {pages}. Pueden faltar valores más abajo en ellas, así que compáralos con el informe.", + "unreadPages": "No se pudieron leer estas páginas y sus valores no están en esta lista: {pages}.", + "unreadParts": "No se pudo leer una parte del informe y sus valores no están en esta lista.", + "rowsCapped": "El informe contiene {found} valores. Un escaneo admite {kept}, así que solo se muestran los primeros {kept}.", + "outputLimit": "El proveedor de IA alcanzó su límite de salida antes de devolver un solo valor. Prueba con una foto de una sola página o con un modelo que permita respuestas más largas.", + "errorTooLong": "Acórtalo a {max} caracteres.", "confirmRow": "Incluir este valor", "analyteLabel": "Nombre de la prueba", "resultLabel": "Resultado", @@ -10392,6 +10429,7 @@ "vital": { "RESTING_HEART_RATE": "Frecuencia cardíaca en reposo", "HEART_RATE_VARIABILITY": "Variabilidad de la frecuencia cardíaca", + "HRV_RMSSD": "VFC (RMSSD)", "RECOVERY_SCORE": "Recovery", "OXYGEN_SATURATION": "Saturación de oxígeno", "BODY_TEMPERATURE": "Temperatura corporal", @@ -11997,6 +12035,8 @@ "logDose": "Registrar dosis", "reconnect": "Reconectar", "viewCheckups": "Ver revisiones", + "viewAppointment": "Ver cita", + "viewAppointments": "Ver citas", "checkinKeep": "Conservar", "checkinAdjust": "Ajustar", "checkinLetGo": "Dejarlo ir", @@ -12721,6 +12761,13 @@ "noMatchTitle": "Nada coincide con este filtro", "clearFilter": "Quitar filtro", "loadError": "No se pudieron cargar los procedimientos." + }, + "preparation": { + "practitioner": "Desde tu última visita con {name} el {date}", + "specialty": "Desde tu última visita ({specialty}) el {date}", + "kind": "Desde tu última visita del mismo tipo ({kind}) el {date}", + "default": "Los últimos {days} días", + "defaultReason": "No hay ninguna visita anterior con la misma consulta, especialidad o tipo." } }, "checkups": { @@ -12970,7 +13017,6 @@ "meanOfReadingsFew": "Media de {count} mediciones", "meanOfReadingsOther": "Media de {count} mediciones", "sinceLastVisit": { - "title": "Desde la última visita el {date}", "countOne": "1 entrada", "countFew": "{count} entradas", "countOther": "{count} entradas", diff --git a/messages/fr.json b/messages/fr.json index 3b3a92f2f..4f7058a57 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -6206,7 +6206,7 @@ "resetConfirmBody": "La chaîne revient à Codex → OpenAI → Anthropic → Local → OpenAI administrateur. Vos identifiants enregistrés ne sont pas affectés." }, "activeProviderHeading": "Fournisseur actif", - "activeProviderLabel": "Fournisseur principal", + "activeProviderLabel": "Configurer un fournisseur", "providerSelect": { "codex": "Compte ChatGPT (Codex)", "openai": "OpenAI (votre clé API)", @@ -6413,7 +6413,7 @@ }, "coachNote": "Dans le Coach, c'est la profondeur de réflexion des réglages du Coach qui s'applique." }, - "providerCardDescription": "Le fournisseur que HealthLog sollicite en premier, avec sa clé et son modèle.", + "providerCardDescription": "Configurez la clé et le modèle de chaque fournisseur ; l’ordre ci-dessous décide lequel HealthLog sollicite en premier.", "runtimeTitle": "Vérifier et actualiser", "usage": { "title": "Usage de l'IA", @@ -6440,6 +6440,14 @@ "followsDefault": "Vous suivez le réglage par défaut du serveur ({level}).", "useDefault": "Utiliser le réglage par défaut du serveur ({level})", "saveError": "Le niveau n'a pas pu être enregistré. Réessaie." + }, + "removeKey": { + "label": "Supprimer la clé API", + "title": "Supprimer la clé API enregistrée ?", + "body": "HealthLog efface la clé enregistrée et ne peut pas la récupérer. Saisissez-la de nouveau pour continuer à l’utiliser.", + "confirm": "Supprimer la clé API", + "done": "Clé API supprimée.", + "failed": "Impossible de supprimer la clé API." } }, "dashboard": { @@ -6619,7 +6627,8 @@ "nocode": "Fitbit n'a pas renvoyé de code d'autorisation. Réessayez et autorisez l'accès dans la fenêtre Fitbit.", "nocreds": "Fitbit n'est pas configuré. Ajoutez vos identifiants OAuth Fitbit ci-dessus et réessayez.", "token": "L'échange de jeton avec Fitbit a échoué. Veuillez relancer la connexion.", - "generic": "Erreur inconnue lors de la connexion. Veuillez relancer la connexion." + "generic": "Erreur inconnue lors de la connexion. Veuillez relancer la connexion.", + "retired": "Google a désactivé l'API Web Fitbit : il n'est plus possible de créer une nouvelle connexion Fitbit. Connectez plutôt Google Health, qui lit les mêmes appareils." }, "integrations": { "withings": { @@ -6851,7 +6860,7 @@ "errorRateLimit": "Vous avez atteint la limite d'exports pour cette heure.", "errorModuleDisabled": "L'export du dossier est désactivé pour ce compte.", "visitOffer": "Un rendez-vous est prévu le {when} : {what}.", - "visitOfferApply": "Rapport jusque-là" + "visitOfferApplyWindow": "Utiliser cette période" }, "globalExcludedInjectionSitesLabel": "Sites d'injection exclus globalement", "globalExcludedInjectionSitesHint": "Jamais proposés et refusés à l'enregistrement, même si un médicament les privilégie.", @@ -6909,7 +6918,9 @@ "reselectReason": "Ce lien diffusait un périmètre que personne n'avait choisi ; il a donc été fermé. Créez-en un nouveau et choisissez son contenu.", "reselectAction": "Créer un remplacement", "createDetail": "Le jeton n'est affiché qu'une fois à la création. Conservez-le alors ; il ne peut pas être récupéré.", - "activeDescription": "Les liens que vous avez partagés, leur expiration et le nombre d'ouvertures." + "activeDescription": "Les liens que vous avez partagés, leur expiration et le nombre d'ouvertures.", + "revokedToast": "Lien révoqué.", + "revokeFailed": "Impossible de révoquer le lien." }, "genderOther": "Autre", "nightscout": "Nightscout", @@ -7096,7 +7107,9 @@ "lastUsed": "Dernière utilisation le {date}", "neverUsed": "Jamais utilisée", "added": "ajoutée le {date}", - "nameLabel": "Nom de la clé de sécurité" + "nameLabel": "Nom de la clé de sécurité", + "addedToast": "Clé de sécurité ajoutée.", + "removedToast": "Clé de sécurité supprimée." }, "passkeyNudge": { "title": "Ajoutez une clé d'accès pour une connexion plus rapide", @@ -7439,7 +7452,20 @@ "homePlaceUnknown": "L’instance Dawarich ne connaît plus ce lieu.", "notConnected": "Dawarich n’est pas connecté.", "generic": "Ça n’a pas marché. Réessaie." - } + }, + "fitbitRetirement": { + "ending": "Google désactive l'API Web Fitbit le {date}. Ensuite, cette connexion ne se synchronisera plus.", + "ended": "Google a désactivé l'API Web Fitbit le {date}. Cette connexion ne se synchronise plus.", + "move": "Google Health lit les mêmes appareils Fitbit et Pixel. Connectez-le ci-dessous pour continuer à recevoir vos données. Google vous demandera de nouveau votre consentement, car la connexion Fitbit ne peut pas être transférée. Tout ce qui a déjà été importé est conservé.", + "toCard": "Aller à Google Health", + "guide": "Comment connecter Google Health", + "badgeEnding": "Bientôt désactivé", + "badgeEnded": "Désactivé" + }, + "passkeyAddedToast": "Passkey ajoutée.", + "passkeyRemovedToast": "Passkey supprimée.", + "integrationDisconnected": "{provider} déconnecté.", + "integrationDisconnectFailed": "Impossible de déconnecter {provider}." }, "admin": { "title": "Administration", @@ -9878,6 +9904,17 @@ "reviewHint": "Vérifiez chaque valeur, puis enregistrez celles à conserver.", "foundCount": "{count} valeurs trouvées", "pagesCapped": "Seules les {read} premières pages sur {total} ont été lues. Les valeurs des pages suivantes ne figurent pas dans cette liste.", + "stoppedBudget": "Votre budget d'IA du jour a été épuisé en cours de lecture, les pages restantes n'ont donc pas été lues.", + "stoppedUnavailable": "L'IA a été désactivée pendant la lecture du compte rendu, les pages restantes n'ont donc pas été lues.", + "stoppedError": "Le scan n'a pas pu continuer à cause d'une erreur du serveur, les pages restantes n'ont donc pas été lues. Réessayez plus tard.", + "tooLarge": "Ce texte est trop long pour un seul scan. Scannez le compte rendu page par page.", + "truncated": "Le fournisseur d'IA a atteint sa limite de sortie avant la fin du compte rendu. Des valeurs peuvent manquer après ce point : comparez la liste avec le compte rendu.", + "truncatedPages": "Le fournisseur d'IA a atteint sa limite de sortie sur ces pages : {pages}. Des valeurs plus bas sur ces pages peuvent manquer : comparez-les avec le compte rendu.", + "unreadPages": "Ces pages n'ont pas pu être lues et leurs valeurs ne figurent pas dans cette liste : {pages}.", + "unreadParts": "Une partie du compte rendu n'a pas pu être lue et ses valeurs ne figurent pas dans cette liste.", + "rowsCapped": "Le compte rendu contient {found} valeurs. Un scan en prend {kept}, seules les {kept} premières sont donc affichées.", + "outputLimit": "Le fournisseur d'IA a atteint sa limite de sortie avant de renvoyer la moindre valeur. Essayez une photo d'une seule page, ou un modèle qui accepte des réponses plus longues.", + "errorTooLong": "Raccourcissez à {max} caractères.", "confirmRow": "Inclure cette valeur", "analyteLabel": "Nom de l'analyse", "resultLabel": "Résultat", @@ -10392,6 +10429,7 @@ "vital": { "RESTING_HEART_RATE": "Fréquence cardiaque au repos", "HEART_RATE_VARIABILITY": "Variabilité de la fréquence cardiaque", + "HRV_RMSSD": "VFC (RMSSD)", "RECOVERY_SCORE": "Recovery", "OXYGEN_SATURATION": "Saturation en oxygène", "BODY_TEMPERATURE": "Température corporelle", @@ -11997,6 +12035,8 @@ "logDose": "Enregistrer la dose", "reconnect": "Reconnecter", "viewCheckups": "Voir les examens", + "viewAppointment": "Voir le rendez-vous", + "viewAppointments": "Voir les rendez-vous", "checkinKeep": "Garder", "checkinAdjust": "Ajuster", "checkinLetGo": "Laisser tomber", @@ -12721,6 +12761,13 @@ "noMatchTitle": "Rien ne correspond à ce filtre", "clearFilter": "Effacer le filtre", "loadError": "Les interventions n’ont pas pu être chargées." + }, + "preparation": { + "practitioner": "Depuis votre dernière visite chez {name} le {date}", + "specialty": "Depuis votre dernière visite ({specialty}) le {date}", + "kind": "Depuis votre dernière visite du même type ({kind}) le {date}", + "default": "Les {days} derniers jours", + "defaultReason": "Aucune visite antérieure au même cabinet, dans la même spécialité ou du même type." } }, "checkups": { @@ -12970,7 +13017,6 @@ "meanOfReadingsFew": "Moyenne de {count} mesures", "meanOfReadingsOther": "Moyenne de {count} mesures", "sinceLastVisit": { - "title": "Depuis la dernière visite le {date}", "countOne": "1 entrée", "countFew": "{count} entrées", "countOther": "{count} entrées", diff --git a/messages/it.json b/messages/it.json index 85e7d2cf8..a1dc08274 100644 --- a/messages/it.json +++ b/messages/it.json @@ -6206,7 +6206,7 @@ "resetConfirmBody": "La catena torna a Codex → OpenAI → Anthropic → Locale → OpenAI amministratore. Le credenziali salvate non vengono toccate." }, "activeProviderHeading": "Provider attivo", - "activeProviderLabel": "Provider principale", + "activeProviderLabel": "Configura un fornitore", "providerSelect": { "codex": "Account ChatGPT (Codex)", "openai": "OpenAI (la tua chiave API)", @@ -6413,7 +6413,7 @@ }, "coachNote": "Nel Coach vale la profondità di ragionamento delle impostazioni del Coach." }, - "providerCardDescription": "Il fornitore che HealthLog interpella per primo, con chiave e modello.", + "providerCardDescription": "Configura chiave e modello di ogni fornitore; l’ordine qui sotto decide quale HealthLog interpella per primo.", "runtimeTitle": "Verifica e aggiorna", "usage": { "title": "Uso dell'IA", @@ -6440,6 +6440,14 @@ "followsDefault": "Segui l'impostazione predefinita del server ({level}).", "useDefault": "Usa l'impostazione predefinita del server ({level})", "saveError": "Non è stato possibile salvare il livello. Riprova." + }, + "removeKey": { + "label": "Rimuovi chiave API", + "title": "Rimuovere la chiave API salvata?", + "body": "HealthLog elimina la chiave salvata e non può recuperarla. Inseriscila di nuovo per continuare a usarla.", + "confirm": "Rimuovi chiave API", + "done": "Chiave API rimossa.", + "failed": "Impossibile rimuovere la chiave API." } }, "dashboard": { @@ -6619,7 +6627,8 @@ "nocode": "Fitbit non ha restituito un codice di autorizzazione. Riprova e concedi l'accesso nella finestra di Fitbit.", "nocreds": "Fitbit non è configurato. Aggiungi le tue credenziali OAuth di Fitbit qui sopra e riprova.", "token": "Lo scambio del token con Fitbit è fallito. Riavvia la connessione.", - "generic": "Errore sconosciuto durante la connessione. Riavvia la connessione." + "generic": "Errore sconosciuto durante la connessione. Riavvia la connessione.", + "retired": "Google ha disattivato l'API web di Fitbit, quindi non è più possibile creare una nuova connessione Fitbit. Collega invece Google Health, che legge gli stessi dispositivi." }, "integrations": { "withings": { @@ -6851,7 +6860,7 @@ "errorRateLimit": "Hai raggiunto il limite di esportazioni per questa ora.", "errorModuleDisabled": "L'esportazione della cartella è disattivata per questo account.", "visitOffer": "Il {when} c'è un appuntamento: {what}.", - "visitOfferApply": "Referto fino a quella data" + "visitOfferApplyWindow": "Usa questo periodo" }, "globalExcludedInjectionSitesLabel": "Siti di iniezione esclusi globalmente", "globalExcludedInjectionSitesHint": "Mai proposti e rifiutati alla registrazione, anche se un farmaco li preferisce.", @@ -6909,7 +6918,9 @@ "reselectReason": "Questo link mostrava un ambito che nessuno aveva scelto, perciò è stato chiuso. Creane uno nuovo e scegli cosa deve contenere.", "reselectAction": "Crea un sostituto", "createDetail": "Il token viene mostrato una sola volta alla creazione: salvalo subito, non può essere recuperato.", - "activeDescription": "I link che hai condiviso, quando scadono e quante volte sono stati aperti." + "activeDescription": "I link che hai condiviso, quando scadono e quante volte sono stati aperti.", + "revokedToast": "Link revocato.", + "revokeFailed": "Impossibile revocare il link." }, "genderOther": "Altro", "nightscout": "Nightscout", @@ -7096,7 +7107,9 @@ "lastUsed": "Usata l'ultima volta il {date}", "neverUsed": "Mai usata", "added": "aggiunta il {date}", - "nameLabel": "Nome della chiave di sicurezza" + "nameLabel": "Nome della chiave di sicurezza", + "addedToast": "Chiave di sicurezza aggiunta.", + "removedToast": "Chiave di sicurezza rimossa." }, "passkeyNudge": { "title": "Aggiungi una passkey per un accesso più rapido", @@ -7439,7 +7452,20 @@ "homePlaceUnknown": "L’istanza Dawarich non conosce più questo luogo.", "notConnected": "Dawarich non è collegato.", "generic": "Non ha funzionato. Riprova." - } + }, + "fitbitRetirement": { + "ending": "Google disattiva l'API web di Fitbit il {date}. Da quel momento questa connessione smette di sincronizzarsi.", + "ended": "Google ha disattivato l'API web di Fitbit il {date}. Questa connessione non si sincronizza più.", + "move": "Google Health legge gli stessi dispositivi Fitbit e Pixel. Collegalo qui sotto per continuare a ricevere i tuoi dati. Google ti chiederà di nuovo il consenso, perché la connessione Fitbit non può essere trasferita. Tutto ciò che è già stato importato resta.", + "toCard": "Vai a Google Health", + "guide": "Come collegare Google Health", + "badgeEnding": "In dismissione", + "badgeEnded": "Dismesso" + }, + "passkeyAddedToast": "Passkey aggiunta.", + "passkeyRemovedToast": "Passkey rimossa.", + "integrationDisconnected": "{provider} disconnesso.", + "integrationDisconnectFailed": "Impossibile disconnettere {provider}." }, "admin": { "title": "Administration", @@ -9878,6 +9904,17 @@ "reviewHint": "Controlla ogni valore, poi salva quelli da conservare.", "foundCount": "{count} valori trovati", "pagesCapped": "Sono state lette solo le prime {read} pagine su {total}. I valori delle pagine successive non sono in questo elenco.", + "stoppedBudget": "Il budget di IA di oggi si è esaurito a metà del referto, quindi le pagine rimanenti non sono state lette.", + "stoppedUnavailable": "L'IA è stata disattivata durante la lettura del referto, quindi le pagine rimanenti non sono state lette.", + "stoppedError": "La scansione non è potuta proseguire per un errore del server, quindi le pagine rimanenti non sono state lette. Riprova più tardi.", + "tooLarge": "Questo testo è troppo lungo per una scansione. Scansiona il referto una pagina alla volta.", + "truncated": "Il fornitore di IA ha raggiunto il suo limite di output prima della fine del referto. Dopo quel punto possono mancare dei valori, quindi confronta l'elenco con il referto.", + "truncatedPages": "Il fornitore di IA ha raggiunto il suo limite di output in queste pagine: {pages}. Possono mancare dei valori più in basso, quindi confrontali con il referto.", + "unreadPages": "Queste pagine non sono state lette e i loro valori non sono in questo elenco: {pages}.", + "unreadParts": "Una parte del referto non è stata letta e i suoi valori non sono in questo elenco.", + "rowsCapped": "Il referto contiene {found} valori. Una scansione ne accetta {kept}, quindi vengono mostrati solo i primi {kept}.", + "outputLimit": "Il fornitore di IA ha raggiunto il suo limite di output prima di restituire un solo valore. Prova con la foto di una sola pagina o con un modello che consente risposte più lunghe.", + "errorTooLong": "Accorcia a {max} caratteri.", "confirmRow": "Includi questo valore", "analyteLabel": "Nome dell'esame", "resultLabel": "Risultato", @@ -10392,6 +10429,7 @@ "vital": { "RESTING_HEART_RATE": "Frequenza cardiaca a riposo", "HEART_RATE_VARIABILITY": "Variabilità della frequenza cardiaca", + "HRV_RMSSD": "HRV (RMSSD)", "RECOVERY_SCORE": "Recovery", "OXYGEN_SATURATION": "Saturazione di ossigeno", "BODY_TEMPERATURE": "Temperatura corporea", @@ -11997,6 +12035,8 @@ "logDose": "Registra dose", "reconnect": "Riconnetti", "viewCheckups": "Vedi controlli", + "viewAppointment": "Vedi appuntamento", + "viewAppointments": "Vedi appuntamenti", "checkinKeep": "Mantieni", "checkinAdjust": "Modifica", "checkinLetGo": "Lascia andare", @@ -12721,6 +12761,13 @@ "noMatchTitle": "Nessun risultato per questo filtro", "clearFilter": "Rimuovi filtro", "loadError": "Non è stato possibile caricare gli interventi." + }, + "preparation": { + "practitioner": "Dalla tua ultima visita con {name} del {date}", + "specialty": "Dalla tua ultima visita ({specialty}) del {date}", + "kind": "Dalla tua ultima visita dello stesso tipo ({kind}) del {date}", + "default": "Gli ultimi {days} giorni", + "defaultReason": "Nessuna visita precedente presso lo stesso studio, nella stessa specialità o dello stesso tipo." } }, "checkups": { @@ -12970,7 +13017,6 @@ "meanOfReadingsFew": "Media di {count} misurazioni", "meanOfReadingsOther": "Media di {count} misurazioni", "sinceLastVisit": { - "title": "Dall’ultima visita del {date}", "countOne": "1 voce", "countFew": "{count} voci", "countOther": "{count} voci", diff --git a/messages/ko.json b/messages/ko.json index cc848901c..8712f3f1a 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -6206,7 +6206,7 @@ "resetConfirmBody": "순서가 Codex → OpenAI → Anthropic → 로컬 → 관리자 OpenAI로 돌아가요. 저장한 인증 정보는 그대로예요." }, "activeProviderHeading": "사용 중인 제공자", - "activeProviderLabel": "기본 제공자", + "activeProviderLabel": "설정할 제공자", "providerSelect": { "codex": "ChatGPT 계정 (Codex)", "openai": "OpenAI (내 API 키)", @@ -6413,7 +6413,7 @@ }, "coachNote": "코치에서는 코치 설정의 사고 깊이가 적용됩니다." }, - "providerCardDescription": "HealthLog가 가장 먼저 쓰는 제공자와 그 키, 모델이에요.", + "providerCardDescription": "제공자마다 키와 모델을 설정하고, 아래 순서로 HealthLog가 먼저 쓸 제공자를 정해요.", "runtimeTitle": "확인 및 새로고침", "usage": { "title": "AI 사용량", @@ -6440,6 +6440,14 @@ "followsDefault": "서버 기본값({level})을 따르고 있어요.", "useDefault": "서버 기본값 사용({level})", "saveError": "단계를 저장하지 못했어요. 다시 시도해 주세요." + }, + "removeKey": { + "label": "API 키 삭제", + "title": "저장된 API 키를 삭제할까요?", + "body": "HealthLog가 저장된 키를 삭제하며 되돌릴 수 없어요. 계속 쓰려면 키를 다시 입력하세요.", + "confirm": "API 키 삭제", + "done": "API 키를 삭제했어요.", + "failed": "API 키를 삭제하지 못했어요." } }, "dashboard": { @@ -6619,7 +6627,8 @@ "nocode": "Fitbit이 인증 코드를 보내지 않았어요. 다시 시도하고 Fitbit 창에서 접근을 허용해 주세요.", "nocreds": "Fitbit이 설정되지 않았어요. 위에 Fitbit OAuth 자격 증명을 추가하고 다시 시도해 주세요.", "token": "Fitbit과의 토큰 교환에 실패했어요. 연결을 다시 시작해 주세요.", - "generic": "연결 중 알 수 없는 오류가 생겼어요. 연결을 다시 시작해 주세요." + "generic": "연결 중 알 수 없는 오류가 생겼어요. 연결을 다시 시작해 주세요.", + "retired": "Google이 Fitbit Web API를 종료해서 새 Fitbit 연결을 만들 수 없어요. 같은 기기의 데이터를 읽는 Google Health를 대신 연결해 주세요." }, "integrations": { "withings": { @@ -6851,7 +6860,7 @@ "errorRateLimit": "이번 시간의 내보내기 한도에 도달했어요.", "errorModuleDisabled": "이 계정에서는 건강 기록 내보내기가 꺼져 있어요.", "visitOffer": "{when}에 예정된 진료가 있어요: {what}.", - "visitOfferApply": "그날까지로 리포트 만들기" + "visitOfferApplyWindow": "이 기간 사용하기" }, "globalExcludedInjectionSitesLabel": "전체 제외 주사 부위", "globalExcludedInjectionSitesHint": "약이 선호하더라도 용량을 기록할 때 추천하지 않고 선택도 막아요.", @@ -6909,7 +6918,9 @@ "reselectReason": "이 링크는 선택한 적 없는 범위를 제공하고 있어서 닫았어요. 새로 만들고 담을 내용을 골라 주세요.", "reselectAction": "새 링크 만들기", "createDetail": "토큰은 만들 때 한 번만 보여줘요. 그때 저장해 두세요. 복구할 수 없어요.", - "activeDescription": "공유한 링크와 만료 시점, 열람 횟수를 보여줘요." + "activeDescription": "공유한 링크와 만료 시점, 열람 횟수를 보여줘요.", + "revokedToast": "링크를 해지했어요.", + "revokeFailed": "링크를 해지하지 못했어요." }, "genderOther": "기타", "nightscout": "Nightscout", @@ -7096,7 +7107,9 @@ "lastUsed": "마지막 사용 {date}", "neverUsed": "사용한 적 없음", "added": "{date} 추가됨", - "nameLabel": "보안 키 이름" + "nameLabel": "보안 키 이름", + "addedToast": "보안 키를 추가했어요.", + "removedToast": "보안 키를 삭제했어요." }, "passkeyNudge": { "title": "패스키를 추가하면 더 빠르게 로그인해요", @@ -7439,7 +7452,20 @@ "homePlaceUnknown": "Dawarich 인스턴스에 이 장소가 더 이상 없습니다.", "notConnected": "Dawarich가 연결되어 있지 않습니다.", "generic": "실패했습니다. 다시 시도하세요." - } + }, + "fitbitRetirement": { + "ending": "Google이 {date}에 Fitbit Web API를 종료해요. 그 이후로 이 연결은 더 이상 동기화되지 않아요.", + "ended": "Google이 {date}에 Fitbit Web API를 종료했어요. 이 연결은 더 이상 동기화되지 않아요.", + "move": "Google Health는 같은 Fitbit 및 Pixel 기기의 데이터를 읽어요. 데이터를 계속 받으려면 아래에서 연결해 주세요. Fitbit 연결은 옮길 수 없어서 Google이 동의를 한 번 더 요청해요. 이미 가져온 데이터는 모두 그대로 남아요.", + "toCard": "Google Health로 이동", + "guide": "Google Health 연결 방법", + "badgeEnding": "종료 예정", + "badgeEnded": "종료됨" + }, + "passkeyAddedToast": "패스키를 추가했어요.", + "passkeyRemovedToast": "패스키를 삭제했어요.", + "integrationDisconnected": "{provider} 연결을 해제했어요.", + "integrationDisconnectFailed": "{provider} 연결을 해제하지 못했어요." }, "admin": { "title": "관리자", @@ -9878,6 +9904,17 @@ "reviewHint": "측정값을 하나씩 확인하고, 남길 항목만 저장하세요.", "foundCount": "측정값 {count}개를 찾았어요", "pagesCapped": "전체 {total}쪽 중 처음 {read}쪽만 읽었습니다. 이후 쪽의 값은 이 목록에 없습니다.", + "stoppedBudget": "결과지를 읽는 도중 오늘의 AI 사용량이 소진되어 나머지 페이지는 읽지 못했어요.", + "stoppedUnavailable": "결과지를 읽는 도중 AI가 꺼져서 나머지 페이지는 읽지 못했어요.", + "stoppedError": "서버 오류로 스캔을 계속할 수 없어서 나머지 페이지는 읽지 못했어요. 잠시 후 다시 시도해 주세요.", + "tooLarge": "한 번에 스캔하기에는 텍스트가 너무 길어요. 결과지를 한 페이지씩 스캔해 주세요.", + "truncated": "AI 제공자가 결과지 끝에 닿기 전에 출력 한도에 도달했어요. 그 뒤의 수치가 빠졌을 수 있으니 목록을 결과지와 비교해 주세요.", + "truncatedPages": "AI 제공자가 다음 페이지에서 출력 한도에 도달했어요: {pages}. 해당 페이지 아래쪽 수치가 빠졌을 수 있으니 결과지와 비교해 주세요.", + "unreadPages": "다음 페이지를 읽지 못해서 해당 수치가 이 목록에 없어요: {pages}.", + "unreadParts": "결과지 일부를 읽지 못해서 해당 수치가 이 목록에 없어요.", + "rowsCapped": "결과지에 수치가 {found}개 있어요. 한 번 스캔할 때 {kept}개까지 담을 수 있어서 처음 {kept}개만 보여 드려요.", + "outputLimit": "AI 제공자가 수치를 하나도 돌려주기 전에 출력 한도에 도달했어요. 한 페이지만 찍은 사진이나 더 긴 답변을 허용하는 모델로 다시 시도해 주세요.", + "errorTooLong": "{max}자 이내로 줄여 주세요.", "confirmRow": "이 측정값 포함", "analyteLabel": "검사 항목", "resultLabel": "결과", @@ -10392,6 +10429,7 @@ "vital": { "RESTING_HEART_RATE": "안정 시 심박수", "HEART_RATE_VARIABILITY": "심박변이도", + "HRV_RMSSD": "심박변이도 (RMSSD)", "RECOVERY_SCORE": "회복", "OXYGEN_SATURATION": "산소포화도", "BODY_TEMPERATURE": "체온", @@ -11997,6 +12035,8 @@ "logDose": "복용 기록", "reconnect": "다시 연결", "viewCheckups": "건강검진 보기", + "viewAppointment": "예약 보기", + "viewAppointments": "예약 목록 보기", "checkinKeep": "계속하기", "checkinAdjust": "조정", "checkinLetGo": "놓아주기", @@ -12721,6 +12761,13 @@ "noMatchTitle": "이 필터에 맞는 항목이 없습니다", "clearFilter": "필터 지우기", "loadError": "시술 목록을 불러올 수 없습니다." + }, + "preparation": { + "practitioner": "{date} {name} 마지막 방문 이후", + "specialty": "{date} 마지막 {specialty} 방문 이후", + "kind": "{date} 같은 유형({kind})의 마지막 방문 이후", + "default": "최근 {days}일", + "defaultReason": "같은 병원, 진료과 또는 유형의 이전 방문이 없어요." } }, "checkups": { @@ -12970,7 +13017,6 @@ "meanOfReadingsFew": "측정 {count}회 평균", "meanOfReadingsOther": "측정 {count}회 평균", "sinceLastVisit": { - "title": "{date} 마지막 방문 이후", "countOne": "기록 1개", "countFew": "기록 {count}개", "countOther": "기록 {count}개", diff --git a/messages/nl.json b/messages/nl.json index 745094620..dc0c5e5f0 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -6206,7 +6206,7 @@ "resetConfirmBody": "De keten wordt teruggezet naar Codex → OpenAI → Anthropic → Lokaal → OpenAI van beheerder. Je opgeslagen inloggegevens blijven onaangeroerd." }, "activeProviderHeading": "Actieve provider", - "activeProviderLabel": "Primaire provider", + "activeProviderLabel": "Provider instellen", "providerSelect": { "codex": "ChatGPT-account (Codex)", "openai": "OpenAI (je eigen API-sleutel)", @@ -6413,7 +6413,7 @@ }, "coachNote": "In de Coach geldt de denkdiepte uit de Coach-instellingen." }, - "providerCardDescription": "De provider die HealthLog als eerste raadpleegt, met de bijbehorende sleutel en het model.", + "providerCardDescription": "Stel per provider de sleutel en het model in; de volgorde hieronder bepaalt welke HealthLog als eerste raadpleegt.", "runtimeTitle": "Controleren en vernieuwen", "usage": { "title": "AI-gebruik", @@ -6440,6 +6440,14 @@ "followsDefault": "Je volgt de standaardinstelling van de server ({level}).", "useDefault": "Standaardinstelling van de server gebruiken ({level})", "saveError": "Het niveau kon niet worden opgeslagen. Probeer het opnieuw." + }, + "removeKey": { + "label": "API-sleutel verwijderen", + "title": "Opgeslagen API-sleutel verwijderen?", + "body": "HealthLog wist de opgeslagen sleutel en kan hem niet herstellen. Voer hem opnieuw in om hem te blijven gebruiken.", + "confirm": "API-sleutel verwijderen", + "done": "API-sleutel verwijderd.", + "failed": "De API-sleutel kon niet worden verwijderd." } }, "dashboard": { @@ -6619,7 +6627,8 @@ "nocode": "Fitbit heeft geen autorisatiecode teruggestuurd. Probeer het opnieuw en sta toegang toe in het Fitbit-venster.", "nocreds": "Fitbit is niet geconfigureerd. Voeg hierboven je Fitbit-OAuth-inloggegevens toe en probeer het opnieuw.", "token": "De tokenuitwisseling met Fitbit is mislukt. Start de koppeling opnieuw.", - "generic": "Onbekende fout bij het koppelen. Start de koppeling opnieuw." + "generic": "Onbekende fout bij het koppelen. Start de koppeling opnieuw.", + "retired": "Google heeft de Fitbit Web API uitgeschakeld, dus er kan geen nieuwe Fitbit-koppeling meer worden gemaakt. Koppel in plaats daarvan Google Health, dat dezelfde apparaten leest." }, "integrations": { "withings": { @@ -6851,7 +6860,7 @@ "errorRateLimit": "Je hebt de exportlimiet voor dit uur bereikt.", "errorModuleDisabled": "De export van het gezondheidsdossier is uitgeschakeld voor dit account.", "visitOffer": "Er staat een afspraak gepland op {when}: {what}.", - "visitOfferApply": "Rapport tot die datum" + "visitOfferApplyWindow": "Deze periode gebruiken" }, "globalExcludedInjectionSitesLabel": "Overal uitgesloten injectieplaatsen", "globalExcludedInjectionSitesHint": "Worden nooit aangeboden en geweigerd bij het vastleggen van een dosis, ook als een medicijn er de voorkeur aan geeft.", @@ -6909,7 +6918,9 @@ "reselectReason": "Deze link gaf toegang tot een selectie die nooit was gekozen, daarom is hij gesloten. Maak een nieuwe link aan en kies wat erin moet staan.", "reselectAction": "Vervanging aanmaken", "createDetail": "Het token wordt één keer getoond bij het aanmaken. Bewaar het dan; het kan niet worden hersteld.", - "activeDescription": "Links die je hebt gedeeld, wanneer ze verlopen en hoe vaak ze zijn geopend." + "activeDescription": "Links die je hebt gedeeld, wanneer ze verlopen en hoe vaak ze zijn geopend.", + "revokedToast": "Link ingetrokken.", + "revokeFailed": "De link kon niet worden ingetrokken." }, "genderOther": "Anders", "nightscout": "Nightscout", @@ -7096,7 +7107,9 @@ "lastUsed": "Laatst gebruikt op {date}", "neverUsed": "Nooit gebruikt", "added": "toegevoegd op {date}", - "nameLabel": "Naam van de beveiligingssleutel" + "nameLabel": "Naam van de beveiligingssleutel", + "addedToast": "Beveiligingssleutel toegevoegd.", + "removedToast": "Beveiligingssleutel verwijderd." }, "passkeyNudge": { "title": "Voeg een passkey toe om sneller in te loggen", @@ -7439,7 +7452,20 @@ "homePlaceUnknown": "De Dawarich-instantie kent deze plaats niet meer.", "notConnected": "Dawarich is niet gekoppeld.", "generic": "Dat is niet gelukt. Probeer het opnieuw." - } + }, + "fitbitRetirement": { + "ending": "Google schakelt de Fitbit Web API uit op {date}. Vanaf dan synchroniseert deze koppeling niet meer.", + "ended": "Google heeft de Fitbit Web API uitgeschakeld op {date}. Deze koppeling synchroniseert niet meer.", + "move": "Google Health leest dezelfde Fitbit- en Pixel-apparaten. Koppel het hieronder om je gegevens te blijven ontvangen. Google vraagt nog een keer om je toestemming, omdat de Fitbit-koppeling niet kan worden overgezet. Alles wat al is geïmporteerd blijft bewaard.", + "toCard": "Naar Google Health", + "guide": "Zo koppel je Google Health", + "badgeEnding": "Wordt uitgeschakeld", + "badgeEnded": "Uitgeschakeld" + }, + "passkeyAddedToast": "Passkey toegevoegd.", + "passkeyRemovedToast": "Passkey verwijderd.", + "integrationDisconnected": "{provider} ontkoppeld.", + "integrationDisconnectFailed": "{provider} kon niet worden ontkoppeld." }, "admin": { "title": "Beheer", @@ -9878,6 +9904,17 @@ "reviewHint": "Controleer elke meting en sla daarna de metingen op die je wilt bewaren.", "foundCount": "{count} metingen gevonden", "pagesCapped": "Alleen de eerste {read} van {total} pagina's zijn gelezen. Waarden op latere pagina's staan niet in deze lijst.", + "stoppedBudget": "Je AI-budget voor vandaag raakte halverwege het rapport op, dus de overige pagina's zijn niet gelezen.", + "stoppedUnavailable": "AI werd uitgeschakeld terwijl het rapport werd gelezen, dus de overige pagina's zijn niet gelezen.", + "stoppedError": "De scan kon door een serverfout niet verder, dus de overige pagina's zijn niet gelezen. Probeer het later opnieuw.", + "tooLarge": "Deze tekst is te lang voor één scan. Scan het rapport pagina voor pagina.", + "truncated": "De AI-provider bereikte zijn uitvoerlimiet vóór het einde van het rapport. Waarden daarna kunnen ontbreken, dus vergelijk de lijst met het rapport.", + "truncatedPages": "De AI-provider bereikte zijn uitvoerlimiet op deze pagina's: {pages}. Waarden verderop op die pagina's kunnen ontbreken, dus vergelijk ze met het rapport.", + "unreadPages": "Deze pagina's konden niet worden gelezen en hun waarden staan niet in deze lijst: {pages}.", + "unreadParts": "Een deel van het rapport kon niet worden gelezen en de waarden ervan staan niet in deze lijst.", + "rowsCapped": "Het rapport bevat {found} waarden. Eén scan neemt er {kept}, dus alleen de eerste {kept} worden getoond.", + "outputLimit": "De AI-provider bereikte zijn uitvoerlimiet voordat hij één waarde teruggaf. Probeer een foto van één pagina, of een model dat langere antwoorden toestaat.", + "errorTooLong": "Kort dit in tot {max} tekens.", "confirmRow": "Deze meting opnemen", "analyteLabel": "Testnaam", "resultLabel": "Uitslag", @@ -10392,6 +10429,7 @@ "vital": { "RESTING_HEART_RATE": "Rusthartslag", "HEART_RATE_VARIABILITY": "Hartslagvariabiliteit", + "HRV_RMSSD": "HRV (RMSSD)", "RECOVERY_SCORE": "Herstel", "OXYGEN_SATURATION": "Zuurstofverzadiging", "BODY_TEMPERATURE": "Lichaamstemperatuur", @@ -11997,6 +12035,8 @@ "logDose": "Dosis registreren", "reconnect": "Opnieuw verbinden", "viewCheckups": "Controles bekijken", + "viewAppointment": "Afspraak bekijken", + "viewAppointments": "Afspraken bekijken", "checkinKeep": "Aanhouden", "checkinAdjust": "Aanpassen", "checkinLetGo": "Loslaten", @@ -12721,6 +12761,13 @@ "noMatchTitle": "Niets komt overeen met dit filter", "clearFilter": "Filter wissen", "loadError": "Ingrepen konden niet worden geladen." + }, + "preparation": { + "practitioner": "Sinds je laatste bezoek bij {name} op {date}", + "specialty": "Sinds je laatste bezoek ({specialty}) op {date}", + "kind": "Sinds je laatste bezoek van hetzelfde soort ({kind}) op {date}", + "default": "De afgelopen {days} dagen", + "defaultReason": "Geen eerder bezoek bij dezelfde praktijk, in hetzelfde specialisme of van hetzelfde soort." } }, "checkups": { @@ -12970,7 +13017,6 @@ "meanOfReadingsFew": "Gemiddelde van {count} metingen", "meanOfReadingsOther": "Gemiddelde van {count} metingen", "sinceLastVisit": { - "title": "Sinds het laatste bezoek op {date}", "countOne": "1 registratie", "countFew": "{count} registraties", "countOther": "{count} registraties", diff --git a/messages/pl.json b/messages/pl.json index d87da90db..625abe2a4 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -6206,7 +6206,7 @@ "resetConfirmBody": "Łańcuch wraca do Codex → OpenAI → Anthropic → Lokalny → OpenAI administratora. Zapisane dane logowania pozostają nienaruszone." }, "activeProviderHeading": "Aktywny dostawca", - "activeProviderLabel": "Główny dostawca", + "activeProviderLabel": "Skonfiguruj dostawcę", "providerSelect": { "codex": "Konto ChatGPT (Codex)", "openai": "OpenAI (Twój klucz API)", @@ -6413,7 +6413,7 @@ }, "coachNote": "W Coachu obowiązuje głębokość myślenia z ustawień Coacha." }, - "providerCardDescription": "Dostawca, którego HealthLog pyta najpierw, wraz z kluczem i modelem.", + "providerCardDescription": "Skonfiguruj klucz i model każdego dostawcy; kolejność poniżej decyduje, którego HealthLog pyta najpierw.", "runtimeTitle": "Sprawdź i odśwież", "usage": { "title": "Użycie AI", @@ -6440,6 +6440,14 @@ "followsDefault": "Korzystasz z ustawienia domyślnego serwera ({level}).", "useDefault": "Użyj ustawienia domyślnego serwera ({level})", "saveError": "Nie udało się zapisać poziomu. Spróbuj ponownie." + }, + "removeKey": { + "label": "Usuń klucz API", + "title": "Usunąć zapisany klucz API?", + "body": "HealthLog usuwa zapisany klucz i nie może go odzyskać. Wpisz go ponownie, aby dalej z niego korzystać.", + "confirm": "Usuń klucz API", + "done": "Usunięto klucz API.", + "failed": "Nie udało się usunąć klucza API." } }, "dashboard": { @@ -6619,7 +6627,8 @@ "nocode": "Fitbit nie zwrócił kodu autoryzacji. Spróbuj ponownie i zezwól na dostęp w oknie Fitbit.", "nocreds": "Fitbit nie jest skonfigurowany. Dodaj powyżej swoje dane OAuth Fitbit i spróbuj ponownie.", "token": "Wymiana tokena z Fitbit nie powiodła się. Rozpocznij połączenie ponownie.", - "generic": "Nieznany błąd podczas łączenia. Rozpocznij połączenie ponownie." + "generic": "Nieznany błąd podczas łączenia. Rozpocznij połączenie ponownie.", + "retired": "Google wyłączył Fitbit Web API, więc nie można już utworzyć nowego połączenia Fitbit. Połącz zamiast tego Google Health, które odczytuje dane z tych samych urządzeń." }, "integrations": { "withings": { @@ -6851,7 +6860,7 @@ "errorRateLimit": "Osiągnięto limit eksportów w tej godzinie.", "errorModuleDisabled": "Eksport dokumentacji jest wyłączony dla tego konta.", "visitOffer": "{when} zaplanowano wizytę: {what}.", - "visitOfferApply": "Raport do tego dnia" + "visitOfferApplyWindow": "Użyj tego okresu" }, "globalExcludedInjectionSitesLabel": "Globalnie wykluczone miejsca iniekcji", "globalExcludedInjectionSitesHint": "Nigdy nieproponowane i odrzucane przy rejestracji, nawet jeśli lek je preferuje.", @@ -6909,7 +6918,9 @@ "reselectReason": "Ten link udostępniał zakres, którego nikt nie wybrał, więc został zamknięty. Utwórz nowy i wybierz jego zawartość.", "reselectAction": "Utwórz zamiennik", "createDetail": "Token jest pokazywany tylko raz przy tworzeniu. Zapisz go wtedy; nie można go odzyskać.", - "activeDescription": "Udostępnione linki, termin ich wygaśnięcia i liczba otwarć." + "activeDescription": "Udostępnione linki, termin ich wygaśnięcia i liczba otwarć.", + "revokedToast": "Unieważniono link.", + "revokeFailed": "Nie udało się unieważnić linku." }, "genderOther": "Inna", "nightscout": "Nightscout", @@ -7096,7 +7107,9 @@ "lastUsed": "Ostatnio użyty {date}", "neverUsed": "Nigdy nieużywany", "added": "dodany {date}", - "nameLabel": "Nazwa klucza bezpieczeństwa" + "nameLabel": "Nazwa klucza bezpieczeństwa", + "addedToast": "Dodano klucz bezpieczeństwa.", + "removedToast": "Usunięto klucz bezpieczeństwa." }, "passkeyNudge": { "title": "Dodaj klucz dostępu, aby logować się szybciej", @@ -7439,7 +7452,20 @@ "homePlaceUnknown": "Instancja Dawarich nie zna już tego miejsca.", "notConnected": "Dawarich nie jest połączony.", "generic": "Nie udało się. Spróbuj ponownie." - } + }, + "fitbitRetirement": { + "ending": "Google wyłączy Fitbit Web API dnia {date}. Od tego dnia to połączenie przestanie się synchronizować.", + "ended": "Google wyłączył Fitbit Web API dnia {date}. To połączenie już się nie synchronizuje.", + "move": "Google Health odczytuje dane z tych samych urządzeń Fitbit i Pixel. Połącz je poniżej, aby dane nadal napływały. Google poprosi o zgodę jeszcze raz, ponieważ połączenia Fitbit nie da się przenieść. Wszystko, co już zaimportowano, zostaje.", + "toCard": "Przejdź do Google Health", + "guide": "Jak połączyć Google Health", + "badgeEnding": "Wkrótce wyłączone", + "badgeEnded": "Wyłączone" + }, + "passkeyAddedToast": "Dodano passkey.", + "passkeyRemovedToast": "Usunięto passkey.", + "integrationDisconnected": "Rozłączono {provider}.", + "integrationDisconnectFailed": "Nie udało się rozłączyć {provider}." }, "admin": { "title": "Administracja", @@ -9878,6 +9904,17 @@ "reviewHint": "Sprawdź każdy wynik, a następnie zapisz te, które chcesz zachować.", "foundCount": "Znaleziono {count} wyników", "pagesCapped": "Odczytano tylko pierwsze {read} z {total} stron. Wartości z dalszych stron nie ma na tej liście.", + "stoppedBudget": "Dzisiejszy budżet AI wyczerpał się w trakcie odczytu wyniku, więc pozostałe strony nie zostały odczytane.", + "stoppedUnavailable": "AI zostało wyłączone podczas odczytu wyniku, więc pozostałe strony nie zostały odczytane.", + "stoppedError": "Skan nie mógł być kontynuowany z powodu błędu serwera, więc pozostałe strony nie zostały odczytane. Spróbuj ponownie później.", + "tooLarge": "Ten tekst jest za długi na jeden skan. Zeskanuj wynik strona po stronie.", + "truncated": "Dostawca AI osiągnął limit odpowiedzi przed końcem wyniku. Wartości po tym miejscu mogą być pominięte, więc porównaj listę z wynikiem.", + "truncatedPages": "Dostawca AI osiągnął limit odpowiedzi na tych stronach: {pages}. Wartości niżej na tych stronach mogą być pominięte, więc porównaj je z wynikiem.", + "unreadPages": "Tych stron nie udało się odczytać, a ich wartości nie ma na tej liście: {pages}.", + "unreadParts": "Części wyniku nie udało się odczytać, a jej wartości nie ma na tej liście.", + "rowsCapped": "Wynik zawiera {found} wartości. Jeden skan obejmuje {kept}, więc widać tylko pierwsze {kept}.", + "outputLimit": "Dostawca AI osiągnął limit odpowiedzi, zanim zwrócił choć jedną wartość. Spróbuj zdjęcia jednej strony albo modelu, który pozwala na dłuższe odpowiedzi.", + "errorTooLong": "Skróć do {max} znaków.", "confirmRow": "Uwzględnij ten wynik", "analyteLabel": "Nazwa badania", "resultLabel": "Wynik", @@ -10392,6 +10429,7 @@ "vital": { "RESTING_HEART_RATE": "Spoczynkowe tętno", "HEART_RATE_VARIABILITY": "Zmienność rytmu serca", + "HRV_RMSSD": "HRV (RMSSD)", "RECOVERY_SCORE": "Recovery", "OXYGEN_SATURATION": "Saturacja tlenem", "BODY_TEMPERATURE": "Temperatura ciała", @@ -11997,6 +12035,8 @@ "logDose": "Zapisz dawkę", "reconnect": "Połącz ponownie", "viewCheckups": "Zobacz badania", + "viewAppointment": "Zobacz wizytę", + "viewAppointments": "Zobacz wizyty", "checkinKeep": "Zachowaj", "checkinAdjust": "Dostosuj", "checkinLetGo": "Odpuść", @@ -12721,6 +12761,13 @@ "noMatchTitle": "Nic nie pasuje do tego filtra", "clearFilter": "Wyczyść filtr", "loadError": "Nie udało się wczytać zabiegów." + }, + "preparation": { + "practitioner": "Od ostatniej wizyty ({name}) {date}", + "specialty": "Od ostatniej wizyty ({specialty}) {date}", + "kind": "Od ostatniej wizyty tego samego rodzaju ({kind}) {date}", + "default": "Ostatnie {days} dni", + "defaultReason": "Brak wcześniejszej wizyty w tym samym gabinecie, tej samej specjalizacji lub tego samego rodzaju." } }, "checkups": { @@ -12970,7 +13017,6 @@ "meanOfReadingsFew": "Średnia z {count} pomiarów", "meanOfReadingsOther": "Średnia z {count} pomiarów", "sinceLastVisit": { - "title": "Od ostatniej wizyty {date}", "countOne": "1 wpis", "countFew": "{count} wpisy", "countOther": "{count} wpisów", diff --git a/package.json b/package.json index ddf92f179..761b2393a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.43.10", + "version": "1.43.11", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "PolyForm-Noncommercial-1.0.0", "homepage": "https://healthlog.dev", diff --git a/prisma/migrations/0420_hrv_rmssd_from_fitbit_and_google_health/migration.sql b/prisma/migrations/0420_hrv_rmssd_from_fitbit_and_google_health/migration.sql new file mode 100644 index 000000000..dc1652c2d --- /dev/null +++ b/prisma/migrations/0420_hrv_rmssd_from_fitbit_and_google_health/migration.sql @@ -0,0 +1,130 @@ +-- Fitbit and Google Health HRV is RMSSD; file it as such. +-- +-- Both providers report a nightly RMSSD (`dailyRmssd`; Google documents its +-- daily average as RMSSD too), but their mappers wrote it as +-- `HEART_RATE_VARIABILITY`, the SDNN series. The `hrv` ladder ranks Fitbit and +-- Google Health above Apple Health, so an account with both had a series that +-- switched estimator from day to day, and every baseline, trend and +-- correlation over it measured the switch. The mappers now write `HRV_RMSSD`; +-- this moves the rows they wrote before. +-- +-- Every `HEART_RATE_VARIABILITY` row under source FITBIT or GOOGLE_HEALTH came +-- from that one daily-HRV mapper per provider (their externalIds end in +-- `:hrv`), so source and type identify them. Tombstones move too, so the sync +-- feed and a restore see the same type. A row whose RMSSD twin already exists +-- under either unique identity, `(user, type, source, externalId)` or +-- `(user, type, measuredAt, source, sleepStage)`, cannot move without +-- breaking that key and aborting the block; it is tombstoned instead, since +-- the twin holds the night. The second identity matters for a row with no +-- externalId, or a different one, on the same night as an RMSSD row. +-- +-- Derived state that would otherwise keep the old filing: +-- - the FITBIT / GOOGLE_HEALTH `HEART_RATE_VARIABILITY` rollup buckets are +-- dropped (no rows remain behind them); +-- - every `HRV_RMSSD` rollup bucket of an account whose rows moved is +-- dropped, so the per-type coverage probe reads that series live and the +-- boot-time rollup discovery (`enqueueBootTimeRollupBackfill`, which flags +-- any (user, type, day) with no DAY bucket) folds the account again on the +-- next worker start. Dropping only the moved days would not do: a day that +-- another source already covers would keep its bucket and never be +-- flagged, and the moved rows would be missing from it; +-- - personal records filed under `HEART_RATE_VARIABILITY` from those sources +-- are deleted. They ranked an RMSSD value as an SDNN best; `HRV_RMSSD` keeps +-- no records, and the detector re-derives the SDNN ones from what remains. +-- +-- One `DO` block, so the move and its cleanup commit or roll back together +-- (`migrate deploy` commits each top-level statement on its own). Running it +-- again finds nothing to move and drops nothing a fold has not rebuilt. +-- +-- Reversibility: the moved rows carry source FITBIT / GOOGLE_HEALTH and type +-- `HRV_RMSSD`, which nothing else writes for those sources; setting the type +-- back and dropping their rollups reverses it. + +DO $migration$ +BEGIN + -- An RMSSD tombstone on the night of a live legacy row with no live RMSSD + -- twin would make that row look twinned and lose the night. It holds + -- nothing, so it goes and the live row moves into its place. + DELETE FROM "measurements" dead + WHERE dead."type" = 'HRV_RMSSD' + AND dead."source" IN ('FITBIT', 'GOOGLE_HEALTH') + AND dead."deleted_at" IS NOT NULL + AND EXISTS ( + SELECT 1 + FROM "measurements" m + WHERE m."user_id" = dead."user_id" + AND m."type" = 'HEART_RATE_VARIABILITY' + AND m."source" = dead."source" + AND m."deleted_at" IS NULL + AND ( + m."external_id" = dead."external_id" + OR ( + m."measured_at" = dead."measured_at" + AND m."sleep_stage" IS NOT DISTINCT FROM dead."sleep_stage" + ) + ) + AND NOT EXISTS ( + SELECT 1 + FROM "measurements" live + WHERE live."user_id" = m."user_id" + AND live."type" = 'HRV_RMSSD' + AND live."source" = m."source" + AND live."deleted_at" IS NULL + AND ( + live."external_id" = m."external_id" + OR ( + live."measured_at" = m."measured_at" + AND live."sleep_stage" IS NOT DISTINCT FROM m."sleep_stage" + ) + ) + ) + ); + + WITH moved AS ( + UPDATE "measurements" m + SET "type" = 'HRV_RMSSD', + "updated_at" = CURRENT_TIMESTAMP, + "sync_version" = m."sync_version" + 1 + WHERE m."type" = 'HEART_RATE_VARIABILITY' + AND m."source" IN ('FITBIT', 'GOOGLE_HEALTH') + AND NOT EXISTS ( + SELECT 1 + FROM "measurements" twin + WHERE twin."user_id" = m."user_id" + AND twin."type" = 'HRV_RMSSD' + AND twin."source" = m."source" + AND ( + twin."external_id" = m."external_id" + OR ( + twin."measured_at" = m."measured_at" + AND twin."sleep_stage" IS NOT DISTINCT FROM m."sleep_stage" + ) + ) + ) + RETURNING m."user_id" + ) + DELETE FROM "measurement_rollups" r + WHERE r."type" = 'HRV_RMSSD' + AND r."user_id" IN (SELECT DISTINCT "user_id" FROM moved); + + -- What did not move had an RMSSD twin under one of the two identities. + -- The twin holds the night; the row left behind would keep an RMSSD value + -- in the SDNN series, so it becomes a tombstone (the sync feed then tells + -- every client to drop it). + UPDATE "measurements" + SET "deleted_at" = CURRENT_TIMESTAMP, + "updated_at" = CURRENT_TIMESTAMP, + "sync_version" = "sync_version" + 1 + WHERE "type" = 'HEART_RATE_VARIABILITY' + AND "source" IN ('FITBIT', 'GOOGLE_HEALTH') + AND "deleted_at" IS NULL; + + DELETE FROM "measurement_rollups" + WHERE "type" = 'HEART_RATE_VARIABILITY' + AND "source" IN ('FITBIT', 'GOOGLE_HEALTH'); + + DELETE FROM "personal_records" + WHERE "metric_type" = 'HEART_RATE_VARIABILITY' + AND "source" IN ('FITBIT', 'GOOGLE_HEALTH'); +END +$migration$; diff --git a/prisma/migrations/0421_withings_sleep_stage_reads/migration.sql b/prisma/migrations/0421_withings_sleep_stage_reads/migration.sql new file mode 100644 index 000000000..58ed41ab3 --- /dev/null +++ b/prisma/migrations/0421_withings_sleep_stage_reads/migration.sql @@ -0,0 +1,17 @@ +-- Per-night stage-read bookkeeping for the Withings sleep sync. +-- +-- The sync reads each night's stage timeline on its own. A night whose read +-- failed was lost with every night read before it, and a night that never +-- carries stages (a tracker without them) was read again on every run for as +-- long as it stayed in the 30-day window. The column records, per night, how +-- many reads came back without a stage and whether the last one failed, so a +-- failed night is read first next time and an unstaged one is given up after +-- a few tries. +-- +-- Nullable, no default, no backfill: a connection without it reads every +-- night as before and starts keeping the record on its next sync. +-- +-- Reversibility: dropping the column loses only the record; the sync then +-- reads every night as before. + +ALTER TABLE "withings_connections" ADD COLUMN "sleep_stage_reads" JSONB; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 5509b6aeb..1d79d97b1 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -4251,6 +4251,15 @@ model WithingsConnection { /// Withings card name what the plan withholds. Versioned and validated on /// read (`src/lib/withings/api-plan.ts`); reset on reconnect. apiPlanState Json? @map("api_plan_state") + /// v1.43.11 — what the sleep sync learned reading each night's stage + /// timeline: `{ v: 1, nights: { ":": { empty, failed } } }`, + /// keyed by the night's unix span. `empty` counts reads that answered with + /// no stage, so a night a tracker never stages stops being read after a + /// few tries; `failed` marks a night whose read errored, so it is read + /// first on the next run. Pruned to the nights of the summary window. + /// Validated on read (`src/lib/withings/sleep-stage-reads.ts`); reset on + /// reconnect. + sleepStageReads Json? @map("sleep_stage_reads") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") diff --git a/public/sw.js b/public/sw.js index 9bbd22085..7132c296a 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.43.10"; + /* @sw-version-fallback */ "v1.43.11"; 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__/ai-egress-capability-guard.test.ts b/src/__tests__/ai-egress-capability-guard.test.ts index 95591050e..ed12342c5 100644 --- a/src/__tests__/ai-egress-capability-guard.test.ts +++ b/src/__tests__/ai-egress-capability-guard.test.ts @@ -121,9 +121,9 @@ const EGRESS_SITES: Record = { "Supplement label read (v1.43); reached from the background run worker (`lib/jobs/document-ai-run.ts`), which re-checks `supplementLabel` for the picked provider, for a label the ingredients extract route queued under that capability.", }, "lib/labs/ocr-extract.ts": { - recheck: false, + recheck: true, reason: - "Lab report scan; reached from the background run worker (`lib/jobs/document-ai-run.ts`) for a scan the labs OCR route queued under `labsOcr`.", + "Lab report scan; reached from the background run worker (`lib/jobs/document-ai-run.ts`), which checks `labsOcr` for the picked provider before the first read. A long report's reads run for many minutes after that, so every further read re-checks the wire (`aiEgressRefusal`) and stops when AI was turned off meanwhile.", }, "app/api/medications/extract/route.ts": { recheck: false, diff --git a/src/__tests__/confirm-destructive-style-guard.test.ts b/src/__tests__/confirm-destructive-style-guard.test.ts new file mode 100644 index 000000000..44ae1df62 --- /dev/null +++ b/src/__tests__/confirm-destructive-style-guard.test.ts @@ -0,0 +1,104 @@ +/** + * A confirmation whose act takes something away wears the destructive style. + * + * `ConfirmButton` accepted a `destructive` prop and dropped it on the way to + * its dialog, so every delete, revoke and remove built on it asked its + * question over a neutral primary button — while the documents vault, which + * uses `ConfirmDialog`, asked the same question in red. The primitive forwards + * it now (`confirm-button.test.tsx` pins the wiring); this file pins that the + * call sites ask for it. + * + * The judgement "does this act destroy" is read off the `slot` the call site + * already names: a slot that says delete, remove, revoke, renounce, forget, + * clear or disconnect is that act. A reset is deliberately not in the list: + * it puts back a default the person can change again, and stays neutral. + * A slot without one of those words is not checked, which is the honest + * limit of a name-based rule; the slot is the one label every call site + * carries, and it is what the destructive-control registry is keyed on too. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import ts from "typescript"; +import { describe, expect, it } from "vitest"; + +import { walkSourceFiles } from "./helpers/source-files"; + +const SRC = join(process.cwd(), "src"); +const DESTROYING = /(delete|remove|revoke|renounce|forget|clear|disconnect)/i; +const TAGS = new Set(["ConfirmButton", "ConfirmDialog"]); + +interface Site { + file: string; + slot: string; + destructive: boolean; +} + +function slotText(attr: ts.JsxAttribute): string | null { + const init = attr.initializer; + if (!init) return null; + if (ts.isStringLiteral(init)) return init.text; + if (ts.isJsxExpression(init) && init.expression) { + const expr = init.expression; + if (ts.isStringLiteral(expr) || ts.isNoSubstitutionTemplateLiteral(expr)) { + return expr.text; + } + if (ts.isTemplateExpression(expr)) return expr.head.text; + } + return null; +} + +function collect(): Site[] { + const sites: Site[] = []; + for (const rel of walkSourceFiles(SRC, { + floor: 1200, + extensions: [".tsx"], + })) { + if (rel.includes("__tests__/") || rel.startsWith("generated/")) continue; + const text = readFileSync(join(SRC, rel), "utf8"); + if (!text.includes(" { + const opening = ts.isJsxSelfClosingElement(node) + ? node + : ts.isJsxElement(node) + ? node.openingElement + : null; + if (opening && TAGS.has(opening.tagName.getText(sf))) { + let slot: string | null = null; + let destructive = false; + for (const prop of opening.attributes.properties) { + if (!ts.isJsxAttribute(prop)) continue; + const name = prop.name.getText(sf); + if (name === "slot") slot = slotText(prop); + if (name === "destructive") destructive = true; + } + if (slot !== null) sites.push({ file: rel, slot, destructive }); + } + ts.forEachChild(node, visit); + }; + visit(sf); + } + return sites; +} + +describe("destroying confirmations wear the destructive style", () => { + const sites = collect(); + + it("finds the confirmations it is meant to check", () => { + // A matcher that found nothing would agree with any tree. + expect(sites.filter((s) => DESTROYING.test(s.slot)).length).toBeGreaterThan( + 15, + ); + }); + + it("every one whose slot names a destroying act passes `destructive`", () => { + const missing = sites + .filter((s) => DESTROYING.test(s.slot) && !s.destructive) + .map((s) => `${s.file}: ${s.slot}`); + expect( + missing, + "Pass `destructive` so the confirm button is red, or rename the slot if the act does not take anything away.", + ).toEqual([]); + }); +}); diff --git a/src/__tests__/destructive-control-guard.test.ts b/src/__tests__/destructive-control-guard.test.ts index 0d25ccca4..6fe80d772 100644 --- a/src/__tests__/destructive-control-guard.test.ts +++ b/src/__tests__/destructive-control-guard.test.ts @@ -114,7 +114,8 @@ const REGISTRY: DestructiveEntry[] = [ }, { file: "components/settings/security-sessions-card.tsx", - destroys: "one login session, or every session other than this one", + destroys: + "one login session, or every session other than this one; and, from the list sign-out-everywhere leaves behind, another account's access grant (the same revoke the access card confirms)", recovery: "permanent", confirm: ["ConfirmButton"], }, @@ -136,6 +137,21 @@ const REGISTRY: DestructiveEntry[] = [ recovery: "permanent", confirm: ["AlertDialog"], }, + { + file: "components/settings/ai/remove-saved-key-button.tsx", + destroys: + "one stored personal AI provider key (OpenAI, Anthropic, the local model or the gateway); the provider choice and model stay", + triggers: [ + "components/settings/ai/openai-provider-form.tsx", + "components/settings/ai/anthropic-provider-form.tsx", + "components/settings/ai/local-provider-form.tsx", + "components/settings/ai/compat-provider-form.tsx", + ], + recovery: "permanent", + confirm: ["ConfirmButton"], + undetected: + "No detector sees it. The body comes from `removeSavedKeyBody` with a computed key (`{ [field]: null }`), which the D3 literal pattern does not match, and `/api/user/ai-provider` names no act. Registered by hand.", + }, { file: "components/settings/api-section.tsx", destroys: "an API token — revoked, and the secret is never shown again", diff --git a/src/__tests__/i18n-english-leak-guard.test.ts b/src/__tests__/i18n-english-leak-guard.test.ts index 451ab270f..b091256da 100644 --- a/src/__tests__/i18n-english-leak-guard.test.ts +++ b/src/__tests__/i18n-english-leak-guard.test.ts @@ -98,6 +98,7 @@ const LEGIT_IDENTICAL = new Set([ "insights.derived.composite.READINESS.component.hrv", "labs.catalog.hs-crp", "measurements.typeHrvRmssd", + "illness.vital.HRV_RMSSD", "settings.sections.sources.metrics.hrvRmssd", "measurements.typeVo2Max", // "{hours} h {minutes} min": the SI symbols for hour and minute. diff --git a/src/__tests__/visit-link-guard.test.ts b/src/__tests__/visit-link-guard.test.ts new file mode 100644 index 000000000..bf1c20e51 --- /dev/null +++ b/src/__tests__/visit-link-guard.test.ts @@ -0,0 +1,169 @@ +/** + * A link to a visit is built by `src/lib/encounters/visit-links.ts`, and a + * bare `/checkups` is a link to the checkups, never to a visit. + * + * Visits are the second tab of `/checkups`. The bare path opens the first + * one, the preventive-care list, which never lists an appointment. Four + * surfaces showed an upcoming appointment and linked it to the bare path — + * the dashboard's next-appointment line, the Today rail item, the Today + * overview fact and the appointment's push — so a tap on the appointment + * landed on the list it is not on. Each had spelled the path by hand. + * + * Two rules, both over code lines (comments are skipped): + * + * 1. The visit and tab query forms (`/checkups?visit=`, `/checkups?view=`) + * appear only in the builder. + * 2. The bare `/checkups` literal appears only where the target IS the + * checkups list, counted per file. A new occurrence fails the run until + * someone decides whether it names a checkup (add it here, with the + * reason) or a visit (use the builder). + * + * Not matched, stated rather than hidden: a path assembled from parts + * (`"/check" + "ups"`), and a `/*` inside a string on the same line as the + * literal, which the comment skipper reads as a comment. + */ +import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { walkSourceFiles } from "./helpers/source-files"; + +const SRC = join(__dirname, ".."); +const BUILDER = "lib/encounters/visit-links.ts"; + +/** + * Where a bare `/checkups` is right, and how often: each names the + * preventive-care list itself, or lists routes rather than linking one. + */ +const BARE_CHECKUPS: Record = { + "components/layout/nav-model.ts": { count: 1, why: "the page's nav entry" }, + "components/measurement-reminders/vorsorge-dashboard-card.tsx": { + count: 1, + why: "the checkups card's empty state opens the checkups", + }, + "components/illness/illness-episode-detail.tsx": { + count: 2, + why: "the body-site view (`?site=`); the bare fallback never renders", + }, + "lib/navigation/shared-record.ts": { + count: 2, + why: "route lists for domain visibility, not links", + }, + "lib/daily/digest.ts": { count: 1, why: "the due check-ups rail item" }, + "lib/day/records.ts": { count: 1, why: "a check-up satisfied that day" }, + "lib/onboarding/tour-state.ts": { count: 1, why: "the checkups tour step" }, + "lib/notifications/tap-target.ts": { + count: 1, + why: "a checkup push; an appointment's names its visit as override", + }, +}; + +/** The code on each line, with line and block comments removed. */ +function codeLines(source: string): string[] { + const out: string[] = []; + let inBlock = false; + for (const raw of source.split("\n")) { + let line = raw; + let code = ""; + while (line.length > 0) { + if (inBlock) { + const end = line.indexOf("*/"); + if (end === -1) { + line = ""; + break; + } + inBlock = false; + line = line.slice(end + 2); + continue; + } + const start = line.indexOf("/*"); + const lineComment = line.search(/(^|[^:])\/\//); + if (lineComment !== -1 && (start === -1 || lineComment < start)) { + code += line.slice( + 0, + lineComment + (line[lineComment] === "/" ? 0 : 1), + ); + line = ""; + break; + } + if (start === -1) { + code += line; + line = ""; + break; + } + code += line.slice(0, start); + inBlock = true; + line = line.slice(start + 2); + } + out.push(code); + } + return out; +} + +const QUERY_FORM = /\/checkups\?(visit|view)=/; +// The bare page, or the page with a query the builder does not own. +const BARE = /["'`]\/checkups(?:["'`]|\?(?!visit=|view=))/g; + +function scan(files: { path: string; source: string }[]) { + const queryForms: string[] = []; + const bare: Record = {}; + for (const { path, source } of files) { + for (const line of codeLines(source)) { + if (QUERY_FORM.test(line)) queryForms.push(path); + const hits = line.match(BARE)?.length ?? 0; + if (hits > 0) bare[path] = (bare[path] ?? 0) + hits; + } + } + return { queryForms: [...new Set(queryForms)].sort(), bare }; +} + +describe("links to a visit go through the visit-links builder", () => { + const files = walkSourceFiles(SRC, { floor: 2500 }) + .filter((p) => !p.includes("__tests__/") && !p.startsWith("generated/")) + .map((path) => ({ path, source: readFileSync(join(SRC, path), "utf8") })); + + it("walks the tree", () => { + expect(files.length).toBeGreaterThan(2000); + }); + + it("spells the visit and tab paths only in the builder", () => { + // The builder itself must match, or the matcher proves nothing. + expect( + scan(files).queryForms, + "Build a link to a visit with visitPath() / visitsPath() / VISITS_PATH from src/lib/encounters/visit-links.ts.", + ).toEqual([BUILDER]); + }); + + it("links the bare checkups page only where the target is the checkups", () => { + const { bare } = scan(files); + delete bare[BUILDER]; + const expected = Object.fromEntries( + Object.entries(BARE_CHECKUPS).map(([path, { count }]) => [path, count]), + ); + expect( + bare, + "A new bare `/checkups`: if it leads to a visit, use the visit-links builder; if it really is the checkups list, add it to BARE_CHECKUPS with the reason.", + ).toEqual(expected); + }); + + it("skips comments and keeps code", () => { + const { queryForms, bare } = scan([ + { + path: "a.tsx", + source: [ + "/**", + " * `/checkups?visit=` in a docblock", + " */", + '// "/checkups" in a line comment', + '{/* a JSX comment naming "/checkups"', + ' over two lines "/checkups" */}', + 'const url = "https://example.test/x"; const bad = "/checkups";', + ].join("\n"), + }, + { path: "b.ts", source: "const p = `/checkups?visit=${id}`;" }, + { path: "c.ts", source: "const q = `/checkups?${params}`;" }, + ]); + expect(queryForms).toEqual(["b.ts"]); + expect(bare).toEqual({ "a.tsx": 1, "c.ts": 1 }); + }); +}); diff --git a/src/app/__tests__/dashboard-hrv-rmssd-fallback.test.tsx b/src/app/__tests__/dashboard-hrv-rmssd-fallback.test.tsx index 2c8790363..c063d13c1 100644 --- a/src/app/__tests__/dashboard-hrv-rmssd-fallback.test.tsx +++ b/src/app/__tests__/dashboard-hrv-rmssd-fallback.test.tsx @@ -24,9 +24,9 @@ import type { DataSummary } from "@/lib/analytics/trends"; * The dashboard HRV tile must accept either HRV type. * * HRV is stored under two measurement types that are not the same measure: - * SDNN (`HEART_RATE_VARIABILITY`, Apple / Fitbit / Google Health) and nightly - * RMSSD (`HRV_RMSSD`, Oura / Polar / WHOOP, and a bridge pushing a Garmin-style - * feed). Every other HRV surface already unions them — `/insights/hrv` via + * SDNN (`HEART_RATE_VARIABILITY`, Apple Health / Health Connect) and nightly + * RMSSD (`HRV_RMSSD`, Oura / Polar / WHOOP / Fitbit / Google Health, and a + * bridge pushing a Garmin-style feed). Every other HRV surface already unions them — `/insights/hrv` via * `fallbackMeasurementType`, the Coach via `COACH_SOURCE_MEASUREMENT_TYPES.hrv`, * the MCP reads via `withHrvFallback` — and `sub-page-metric.ts` states that * listing both is what keeps "the tab, pill, and dashboard tile lit whichever @@ -141,16 +141,16 @@ describe("pickHrvSummary", () => { }); describe("the RMSSD fallback cannot leak recovery-module data", () => { - it("drops HRV_RMSSD from the snapshot summaries when recovery is off", () => { + it("lists HRV_RMSSD among the recovery-off summary types", () => { // The half of this fix that is easy to miss. `HRV_RMSSD` is recovery-owned // in `METRIC_STATUS_MODULE_OWNERS`, which governs the metric-status and // MCP reads — but the dashboard snapshot gates off `SUMMARY_TYPE_MODULE`, // a DIFFERENT map, and that one did not list it. Inert while the tile read - // SDNN alone; the moment the tile falls back to RMSSD it would show a - // recovery-off account a tile built from recovery-owned rows. + // SDNN alone. The one exception, the tile's fallback for an account with + // no SDNN, is made in `gateSummariesByModules` (tested below). expect(disabledSummaryTypes({ recovery: false })).toContain("HRV_RMSSD"); // SDNN is a plain vital and stays ungated — turning recovery off must not - // take the ordinary HRV tile away from an Apple / Fitbit account. + // take the ordinary HRV tile away from an Apple Health account. expect(disabledSummaryTypes({ recovery: false })).not.toContain( "HEART_RATE_VARIABILITY", ); @@ -290,35 +290,74 @@ describe("the gate reaches every feed, not just the default one", () => { expect(on.summaries.HRV_RMSSD).toBeDefined(); expect(summaries.HRV_RMSSD).toBeDefined(); }); + + it("keeps the RMSSD fallback for an account with no SDNN", () => { + // A Fitbit or Google Health account files its nightly HRV as RMSSD since + // migration 0420. On this tile RMSSD stands in for SDNN, a plain vital + // that paints with Recovery off, so the fallback must not vanish for the + // estimator the device reports. + const summaries = { + HEART_RATE_VARIABILITY: summary(0, 0), + HRV_RMSSD: summary(30, 42), + }; + const lastSeen = { + HRV_RMSSD: { lastSeenAt: "2026-09-02T00:00:00.000Z" }, + }; + const off = gateSummariesByModules(summaries, lastSeen, { + recovery: false, + }); + expect(off.summaries.HRV_RMSSD).toBeDefined(); + expect(off.lastSeenByType.HRV_RMSSD).toBeDefined(); + expect(pickHrvSummary(off.summaries).type).toBe("HRV_RMSSD"); + // Another recovery-owned series is still stripped. + const withResting = gateSummariesByModules( + { ...summaries, RESTING_HEART_RATE: summary(5, 58) }, + lastSeen, + { recovery: false }, + ); + expect( + (withResting.summaries as Record).RESTING_HEART_RATE, + ).toBeUndefined(); + }); }); describe("a switch that cannot do anything is not offered", () => { - // The symptom one step on from the original bug: with recovery off, a - // ring / strap account's only HRV series is withheld, so the tile can - // never paint — and before this, the toggle was still offered, still - // turned on, and still explained nothing. Same experience as the defect - // this change exists to remove, so it does not get to survive here. - it("drops the HRV toggle for a ring account with recovery off", () => { + it("drops the HRV toggle for an account with recovery off and no HRV", () => { expect( - unavailableWidgetIds({ recovery: false }, { hasSdnn: false }), + unavailableWidgetIds( + { recovery: false }, + { hasSdnn: false, hasRmssd: false }, + ), ).toEqual(["hrv"]); }); - it("keeps the toggle for an account that has SDNN", () => { - // SDNN is a plain vital that no module owns, so the tile still works - // with recovery off. Taking this row away would be a new bug. + it("keeps the toggle for an account that has either estimator", () => { expect( - unavailableWidgetIds({ recovery: false }, { hasSdnn: true }), + unavailableWidgetIds( + { recovery: false }, + { hasSdnn: true, hasRmssd: false }, + ), + ).toEqual([]); + expect( + unavailableWidgetIds( + { recovery: false }, + { hasSdnn: false, hasRmssd: true }, + ), ).toEqual([]); }); it("keeps the toggle whenever recovery is on", () => { expect( - unavailableWidgetIds({ recovery: true }, { hasSdnn: false }), + unavailableWidgetIds( + { recovery: true }, + { hasSdnn: false, hasRmssd: false }, + ), ).toEqual([]); // Fail-open on an unresolved map, like the module gate beside it: only // an explicit `false` takes a row away. - expect(unavailableWidgetIds({}, { hasSdnn: false })).toEqual([]); + expect( + unavailableWidgetIds({}, { hasSdnn: false, hasRmssd: false }), + ).toEqual([]); }); }); diff --git a/src/app/api/auth/me/route.ts b/src/app/api/auth/me/route.ts index 09cebfdc0..ef1167630 100644 --- a/src/app/api/auth/me/route.ts +++ b/src/app/api/auth/me/route.ts @@ -391,6 +391,10 @@ export const GET = apiHandler(async () => { // resolved `ai.capabilities.coach` and `modules.coach` below, which fold // in the record's own opt-out, the operator's switches and the grant. disableCoach: user.disableCoach ?? false, + // The name the app addresses the account by (dashboard greeting, + // reports). Set in the setup flow and edited on the profile card, which + // seeds its field from here. + displayName: user.displayName ?? null, // v1.7.0 — health-record export identity fields. All optional. fullName: user.fullName ?? null, insurerName: user.insurerName ?? null, diff --git a/src/app/api/dashboard/widgets/route.ts b/src/app/api/dashboard/widgets/route.ts index 470c6362a..3c394cebf 100644 --- a/src/app/api/dashboard/widgets/route.ts +++ b/src/app/api/dashboard/widgets/route.ts @@ -235,7 +235,7 @@ export const GET = apiHandler(async () => { // linger for minutes after Recovery went off, which is the confusion this // is removing. // - // The SDNN probe is an index-only existence check + // The HRV probes are index-only existence checks // (`@@index([userId, type, measuredAt])`), not a summaries read; it runs // only when Recovery is off, so the common account pays nothing. // One flag, read the way this codebase reads one flag — not the whole @@ -243,20 +243,22 @@ export const GET = apiHandler(async () => { // is the only module that decides it. const recoveryEnabled = await isModuleEnabled(user.id, "recovery"); let hasSdnn = true; + let hasRmssd = true; if (!recoveryEnabled) { - const sdnnRow = await prisma.measurement.findFirst({ - where: { - userId: user.id, - type: "HEART_RATE_VARIABILITY", - deletedAt: null, - }, - select: { id: true }, - }); + const [sdnnRow, rmssdRow] = await Promise.all( + (["HEART_RATE_VARIABILITY", "HRV_RMSSD"] as const).map((type) => + prisma.measurement.findFirst({ + where: { userId: user.id, type, deletedAt: null }, + select: { id: true }, + }), + ), + ); hasSdnn = sdnnRow !== null; + hasRmssd = rmssdRow !== null; } const unavailable = unavailableWidgetIds( { recovery: recoveryEnabled }, - { hasSdnn }, + { hasSdnn, hasRmssd }, ); return apiSuccess( unavailable.length > 0 diff --git a/src/app/api/day/notable/route.ts b/src/app/api/day/notable/route.ts index 74cc8ec9f..685eddcc3 100644 --- a/src/app/api/day/notable/route.ts +++ b/src/app/api/day/notable/route.ts @@ -1,8 +1,9 @@ /** * `GET /api/day/notable?from=&to=` — what changed and what stood out in a * window, for the preparation of the next visit (v1.42, #613). Without - * `from` the window starts at the last completed visit the caller may see; - * without `to` it ends today. The shape is `DayNotableWindowResponse` in + * `from` the window is the one the next planned visit prepares (its + * `preparation.from`), else it starts at the last completed visit the caller + * may see; without `to` it ends today. The shape is `DayNotableWindowResponse` in * `src/lib/day/contract.ts`. * * Observations and changes only, as keys and the record's own names: no diff --git a/src/app/api/encounters/route.ts b/src/app/api/encounters/route.ts index 5686620b2..5c73973e6 100644 --- a/src/app/api/encounters/route.ts +++ b/src/app/api/encounters/route.ts @@ -35,6 +35,7 @@ import { } from "@/lib/validations/encounters"; import { encounterKindLabel } from "@/lib/encounters/kind-label"; import { toEncounterDTO, type EncounterListDTO } from "@/lib/encounters/dto"; +import { loadVisitPreparations } from "@/lib/encounters/preparation-read"; import { ENCOUNTER_INCLUDE, applyEncounterLinks, @@ -143,12 +144,17 @@ export const GET = apiHandler(async (request: NextRequest) => { // them makes the first read zero and the second unfile everything on save. // Three grouped queries for the page, not three per row. const visible = await actingDomainVisibility(prisma, grantId); - const links = await loadEncounterLinksForMany( - prisma, - user.id, - [...upcoming, ...past].map((row) => row.id), - visible, - ); + // The window before each booked visit reads the whole history, not this + // page of it: the visit it starts at may sit outside the caller's filter. + const [links, preparations] = await Promise.all([ + loadEncounterLinksForMany( + prisma, + user.id, + [...upcoming, ...past].map((row) => row.id), + visible, + ), + loadVisitPreparations(prisma, user.id, upcoming, now), + ]); annotate({ action: { name: "encounter.visit.list", entity_type: "encounter" }, @@ -156,8 +162,14 @@ export const GET = apiHandler(async (request: NextRequest) => { }); const body: EncounterListDTO = { - upcoming: upcoming.map((row) => toEncounterDTO(row, links.get(row.id))), - past: past.map((row) => toEncounterDTO(row, links.get(row.id))), + upcoming: upcoming.map((row) => ({ + ...toEncounterDTO(row, links.get(row.id)), + preparation: preparations.get(row.id) ?? null, + })), + past: past.map((row) => ({ + ...toEncounterDTO(row, links.get(row.id)), + preparation: null, + })), }; return apiListSuccess(body, combinedCap(upcomingCap, pastCap)); }); diff --git a/src/app/api/fitbit/connect/__tests__/route.test.ts b/src/app/api/fitbit/connect/__tests__/route.test.ts index aa71ee8a4..003b50857 100644 --- a/src/app/api/fitbit/connect/__tests__/route.test.ts +++ b/src/app/api/fitbit/connect/__tests__/route.test.ts @@ -56,6 +56,7 @@ import { GET } from "../route"; import { prisma } from "@/lib/db"; import { checkRateLimit } from "@/lib/rate-limit"; import { getUserFitbitCredentials } from "@/lib/fitbit/credentials"; +import { isFitbitRetired } from "@/lib/fitbit/retirement"; function connectRequest(): NextRequest { return new NextRequest("http://localhost/api/fitbit/connect", { @@ -112,4 +113,16 @@ describe("GET /api/fitbit/connect", () => { expect(location).toContain("reason=nocreds"); expect(prisma.fitbitOAuthState.create).not.toHaveBeenCalled(); }); + it("redirects to reason=retired once Google has turned the Web API off", async () => { + vi.mocked(isFitbitRetired).mockReturnValue(true); + try { + const response = await GET(connectRequest()); + const location = response.headers.get("location") ?? ""; + expect(location).toContain("fitbit=error"); + expect(location).toContain("reason=retired"); + expect(prisma.fitbitOAuthState.create).not.toHaveBeenCalled(); + } finally { + vi.mocked(isFitbitRetired).mockReturnValue(false); + } + }); }); diff --git a/src/app/api/fitbit/connect/route.ts b/src/app/api/fitbit/connect/route.ts index e9380b63f..11823963b 100644 --- a/src/app/api/fitbit/connect/route.ts +++ b/src/app/api/fitbit/connect/route.ts @@ -12,6 +12,7 @@ import { import { NextRequest, NextResponse } from "next/server"; import { relativeRedirect } from "@/lib/http/relative-redirect"; import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie"; +import { isFitbitRetired } from "@/lib/fitbit/retirement"; /** * Redirect the user to the Fitbit OAuth consent page (classic @@ -57,6 +58,15 @@ export const GET = apiHandler(async (req: NextRequest) => { ); } + // A grant issued now would never sync anything: Google has turned the Web + // API off. The card explains the move to Google Health. + if (isFitbitRetired()) { + annotate({ action: { name: "fitbit.connect.retired" } }); + return relativeRedirect( + "/settings/integrations?fitbit=error&reason=retired", + ); + } + const creds = await getUserFitbitCredentials(user.id); if (!creds) { annotate({ action: { name: "fitbit.connect.no_credentials" } }); diff --git a/src/app/api/fitbit/sync/__tests__/route.test.ts b/src/app/api/fitbit/sync/__tests__/route.test.ts new file mode 100644 index 000000000..b96e44345 --- /dev/null +++ b/src/app/api/fitbit/sync/__tests__/route.test.ts @@ -0,0 +1,68 @@ +/** + * POST /api/fitbit/sync once Google has turned the Fitbit Web API off: a + * clear 410 naming the cause, and no sync attempt behind it. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; +import type * as ApiHandlerModule from "@/lib/api-handler"; + +const { syncUserFitbitMock } = vi.hoisted(() => ({ + syncUserFitbitMock: vi.fn(), +})); + +vi.mock("@/lib/api-handler", async () => { + const actual = + await vi.importActual("@/lib/api-handler"); + return { + ...actual, + requireAuth: vi.fn(async () => ({ user: { id: "u1" } })), + }; +}); +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: vi.fn(async () => ({ allowed: true })), +})); +vi.mock("@/lib/fitbit/sync", () => ({ syncUserFitbit: syncUserFitbitMock })); + +import { POST } from "../route"; +import { isFitbitRetired } from "@/lib/fitbit/retirement"; + +const post = POST as unknown as (r: NextRequest) => Promise; + +function request(): NextRequest { + return new NextRequest("http://localhost/api/fitbit/sync", { + method: "POST", + }); +} + +beforeEach(() => { + vi.clearAllMocks(); + syncUserFitbitMock.mockResolvedValue({ imported: 2, failed: false }); +}); + +describe("POST /api/fitbit/sync", () => { + it("syncs while the Web API is still up", async () => { + const response = await post(request()); + expect(response.status).toBe(200); + expect(syncUserFitbitMock).toHaveBeenCalledWith("u1", { + fullSync: false, + }); + }); + + it("answers 410 provider_retired after the turndown and syncs nothing", async () => { + vi.mocked(isFitbitRetired).mockReturnValue(true); + try { + const response = await post(request()); + expect(response.status).toBe(410); + const body = (await response.json()) as { + error: string; + meta?: { errorCode?: string }; + }; + expect(body.meta?.errorCode).toBe("provider_retired"); + expect(body.error).toMatch(/Google Health/); + expect(syncUserFitbitMock).not.toHaveBeenCalled(); + } finally { + vi.mocked(isFitbitRetired).mockReturnValue(false); + } + }); +}); diff --git a/src/app/api/fitbit/sync/route.ts b/src/app/api/fitbit/sync/route.ts index be981267e..0c70598d6 100644 --- a/src/app/api/fitbit/sync/route.ts +++ b/src/app/api/fitbit/sync/route.ts @@ -4,6 +4,7 @@ import { checkRateLimit } from "@/lib/rate-limit"; import { annotate } from "@/lib/logging/context"; import { readSyncTriggerBody } from "@/lib/integrations/sync-request"; import { syncUserFitbit } from "@/lib/fitbit/sync"; +import { isFitbitRetired } from "@/lib/fitbit/retirement"; import { resolveSyncOutcome } from "@/lib/outcome/written-outcome"; import { NextRequest } from "next/server"; @@ -31,6 +32,14 @@ export const POST = apiHandler(async (request: NextRequest) => { }); } + if (isFitbitRetired()) { + return apiError( + "Google has turned the Fitbit Web API off. Connect Google Health instead.", + 410, + { errorCode: "provider_retired" }, + ); + } + const body = await readSyncTriggerBody(request); if (body.error) return body.error; const { fullSync } = body; diff --git a/src/app/api/integrations/fitbit/test/__tests__/route.test.ts b/src/app/api/integrations/fitbit/test/__tests__/route.test.ts index 31481035d..58a55d5b1 100644 --- a/src/app/api/integrations/fitbit/test/__tests__/route.test.ts +++ b/src/app/api/integrations/fitbit/test/__tests__/route.test.ts @@ -36,6 +36,7 @@ import { POST } from "../route"; import { prisma } from "@/lib/db"; import { checkRateLimit } from "@/lib/rate-limit"; import { getValidToken } from "@/lib/fitbit/sync-core"; +import { isFitbitRetired } from "@/lib/fitbit/retirement"; interface ApiErrorEnvelope { data: null; @@ -109,6 +110,22 @@ describe("POST /api/integrations/fitbit/test", () => { expect(global.fetch).not.toHaveBeenCalled(); }); + it("answers 410 provider_retired once Google has turned the Web API off", async () => { + vi.mocked(isFitbitRetired).mockReturnValue(true); + try { + const response = await POST(emptyRequest() as never); + expect(response.status).toBe(410); + const body = (await response.json()) as ApiErrorEnvelope & { + meta?: { errorCode?: string }; + }; + expect(body.meta?.errorCode).toBe("provider_retired"); + expect(getValidToken).not.toHaveBeenCalled(); + expect(global.fetch).not.toHaveBeenCalled(); + } finally { + vi.mocked(isFitbitRetired).mockReturnValue(false); + } + }); + it("returns 422 when no Fitbit connection exists", async () => { vi.mocked(getValidToken).mockResolvedValueOnce(null); const response = await POST(emptyRequest() as never); diff --git a/src/app/api/integrations/fitbit/test/route.ts b/src/app/api/integrations/fitbit/test/route.ts index 509e617bc..c9be8f9b1 100644 --- a/src/app/api/integrations/fitbit/test/route.ts +++ b/src/app/api/integrations/fitbit/test/route.ts @@ -7,6 +7,7 @@ import { prisma } from "@/lib/db"; import { getValidToken } from "@/lib/fitbit/sync-core"; import { FITBIT_API_BASE } from "@/lib/fitbit/client"; import { safeFetch, SafeFetchError } from "@/lib/safe-fetch"; +import { isFitbitRetired } from "@/lib/fitbit/retirement"; export const dynamic = "force-dynamic"; @@ -55,6 +56,14 @@ export const POST = apiHandler(async (request: NextRequest) => { }); } + if (isFitbitRetired()) { + return apiError( + "Google has turned the Fitbit Web API off. Connect Google Health instead.", + 410, + { errorCode: "provider_retired" }, + ); + } + const tokenInfo = await getValidToken(user.id); if (!tokenInfo) { return apiError("Fitbit not connected", 422, { diff --git a/src/app/api/integrations/status/__tests__/route.test.ts b/src/app/api/integrations/status/__tests__/route.test.ts index 43596e00d..907f210cc 100644 --- a/src/app/api/integrations/status/__tests__/route.test.ts +++ b/src/app/api/integrations/status/__tests__/route.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; +import { afterEach, describe, it, expect, vi, beforeEach } from "vitest"; vi.mock("@/lib/api-handler", () => ({ apiHandler: unknown>(fn: T) => fn, @@ -16,6 +16,7 @@ vi.mock("@/lib/db", () => ({ fitbitConnection: { findUnique: vi.fn(async () => null) }, googleHealthConnection: { findUnique: vi.fn(async () => null) }, moodEntry: { count: vi.fn(async () => 0) }, + measurement: { findFirst: vi.fn(async () => null) }, $queryRaw: vi.fn(async () => []), workout: { groupBy: vi.fn(async () => []) }, }, @@ -65,6 +66,7 @@ vi.mock("@/lib/oura/credentials", async () => ({ })); import { GET } from "../route"; +import { isFitbitRetired } from "@/lib/fitbit/retirement"; import { prisma } from "@/lib/db"; import { listLiveMeasurementTypes } from "@/lib/measurements/live-types"; import { ATTEMPT_STALE_AFTER_MS } from "@/lib/integrations/sync-verdict"; @@ -508,3 +510,61 @@ describe("/api/integrations/status — one round of reads", () => { expect(envelope.integrations.length).toBeGreaterThan(0); }); }); + +describe("/api/integrations/status — the Fitbit card after the turndown", () => { + const fitbitFind = prisma.fitbitConnection.findUnique as ReturnType< + typeof vi.fn + >; + const fitbitDataFind = prisma.measurement.findFirst as ReturnType< + typeof vi.fn + >; + const offered = async () => + ( + (await fetchEntries()).find((e) => e.integration === "fitbit") as { + retirement?: { ended: boolean; offered: boolean }; + } + ).retirement; + + beforeEach(() => { + vi.clearAllMocks(); + userFind.mockResolvedValue({}); + fitbitFind.mockResolvedValue(null); + fitbitDataFind.mockResolvedValue(null); + // The suites run before the turndown by default (`vitest.setup.ts`). + vi.mocked(isFitbitRetired).mockReturnValue(true); + }); + afterEach(() => { + vi.mocked(isFitbitRetired).mockReturnValue(false); + }); + + it("is not offered to an account that never had Fitbit", async () => { + expect(await offered()).toMatchObject({ ended: true, offered: false }); + expect(fitbitDataFind).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ userId: "u1", source: "FITBIT" }), + }), + ); + }); + + it("stays, with its notice, for an account holding Fitbit data", async () => { + fitbitDataFind.mockResolvedValue({ id: "m1" }); + expect(await offered()).toMatchObject({ ended: true, offered: true }); + }); + + it("stays for an account still holding its Fitbit connection", async () => { + fitbitFind.mockResolvedValue({ + createdAt: new Date("2026-01-01T00:00:00.000Z"), + tokenExpiresAt: new Date("2026-11-01T00:00:00.000Z"), + lastSyncedAt: null, + backfillCompletedAt: null, + }); + expect(await offered()).toMatchObject({ ended: true, offered: true }); + expect(fitbitDataFind).not.toHaveBeenCalled(); + }); + + it("is offered to everyone before the turndown", async () => { + vi.mocked(isFitbitRetired).mockReturnValue(false); + expect(await offered()).toMatchObject({ ended: false, offered: true }); + expect(fitbitDataFind).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/integrations/status/route.ts b/src/app/api/integrations/status/route.ts index 73396103d..1fe7cf99c 100644 --- a/src/app/api/integrations/status/route.ts +++ b/src/app/api/integrations/status/route.ts @@ -57,6 +57,10 @@ import { type GoogleHealthBackfillEndedEntry, } from "@/lib/google-health/backfill-progress"; import type { ConnectionHealth } from "@/lib/integrations/connection-health"; +import { + fitbitRetirement, + type PublishedFitbitRetirement, +} from "@/lib/fitbit/retirement"; export const dynamic = "force-dynamic"; @@ -291,6 +295,19 @@ export const GET = apiHandler(async () => { !!dbUser?.fitbitClientIdEncrypted && !!dbUser?.fitbitClientSecretEncrypted; const fitbitLegacyLastSyncedAt = fitbitConn?.lastSyncedAt?.toISOString() ?? null; + // Once the Web API is gone the Fitbit card only explains the move to an + // account that had something to move: a connection, its own Fitbit app, or + // imported Fitbit data. Anyone else is shown no dead card. The data probe + // runs only after the turndown, for an account with neither of the others. + const retirementNow = fitbitRetirement(); + const fitbitOffered = + !retirementNow.ended || + fitbitConnected || + fitbitConfigured || + (await prisma.measurement.findFirst({ + where: { userId: user.id, source: "FITBIT", deletedAt: null }, + select: { id: true }, + })) !== null; const googleHealthConnected = !!googleHealthConn; const googleHealthConfigured = !!dbUser?.googleHealthClientIdEncrypted && @@ -378,6 +395,7 @@ export const GET = apiHandler(async () => { ? fitbitConn.tokenExpiresAt.getTime() <= now : null, backfillCompleted: fitbitConn ? !!fitbitConn.backfillCompletedAt : null, + retirement: { ...retirementNow, offered: fitbitOffered }, health: health["fitbit"], ...resolve("fitbit", fitbitStatus, { connected: fitbitConnected, @@ -558,6 +576,8 @@ interface FitbitExtras { tokenExpiresAt: string | null; tokenExpired: boolean | null; backfillCompleted: boolean | null; + /** The end of the legacy Fitbit Web API (`@/lib/fitbit/retirement`). */ + retirement: PublishedFitbitRetirement; } // v1.27.0 — Google Health mirrors the Fitbit shape plus `needsReauth`: Google diff --git a/src/app/api/labs/ocr/commit/route.ts b/src/app/api/labs/ocr/commit/route.ts index b9bd332f0..4b49874ea 100644 --- a/src/app/api/labs/ocr/commit/route.ts +++ b/src/app/api/labs/ocr/commit/route.ts @@ -157,8 +157,10 @@ function inBatchKey( async function commitOcrRows(request: NextRequest) { const { user } = await requireAuth(); + // OCR_MAX_ROWS rows at every field's longest bound run past 256 KB; 512 + // keeps a full report in one body with room to spare. const { data: body, error: jsonError } = await safeJson(request, { - maxBytes: 256 * 1024, + maxBytes: 512 * 1024, }); if (jsonError) return jsonError; diff --git a/src/app/api/notifications/preferences/route.ts b/src/app/api/notifications/preferences/route.ts index 0fbe2fa4d..ad39a9675 100644 --- a/src/app/api/notifications/preferences/route.ts +++ b/src/app/api/notifications/preferences/route.ts @@ -12,6 +12,7 @@ import { notificationPreferenceSchema } from "@/lib/validations/notifications"; import { EVENT_TYPES, CHANNEL_TYPE_LABELS } from "@/lib/notifications/types"; import type { ChannelType } from "@/lib/notifications/types"; import { decrypt, encrypt } from "@/lib/crypto"; +import { resolveEventDefaults } from "@/lib/notifications/event-defaults"; export const GET = apiHandler(async () => { const { user } = await requireAuth(); @@ -92,6 +93,12 @@ export const GET = apiHandler(async () => { channels: channelData, preferences, eventTypes: EVENT_TYPES as unknown as string[], + // What an event does on a channel with no stored row, resolved for this + // account by the same function the dispatcher asks. A client renders a + // switch without a row from this map, never from its own copy of the + // defaults: the mood reminder's follows the reminder card, which a + // constant table cannot know. + eventDefaults: await resolveEventDefaults(user.id), }); }); diff --git a/src/app/api/polar/sync/__tests__/route.test.ts b/src/app/api/polar/sync/__tests__/route.test.ts index a3f870aed..654fa82f6 100644 --- a/src/app/api/polar/sync/__tests__/route.test.ts +++ b/src/app/api/polar/sync/__tests__/route.test.ts @@ -67,13 +67,21 @@ beforeEach(() => { vi.clearAllMocks(); vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never); vi.mocked(requireAuth).mockResolvedValue({ user: { id: "u1" } } as never); - syncUserPolarMock.mockResolvedValue({ imported: 0, failed: false }); + syncUserPolarMock.mockResolvedValue({ + imported: 0, + failed: false, + tokenProven: true, + }); syncUserPolarWorkoutsMock.mockResolvedValue(0); }); describe("POST /api/polar/sync", () => { it("runs both Polar legs and totals what they imported", async () => { - syncUserPolarMock.mockResolvedValue({ imported: 5, failed: false }); + syncUserPolarMock.mockResolvedValue({ + imported: 5, + failed: false, + tokenProven: true, + }); syncUserPolarWorkoutsMock.mockResolvedValue(2); const response = await post(request()); @@ -84,7 +92,10 @@ describe("POST /api/polar/sync", () => { error: null, }); expect(syncUserPolarMock).toHaveBeenCalledWith("u1"); - expect(syncUserPolarWorkoutsMock).toHaveBeenCalledWith("u1"); + // The vitals leg tells the workouts leg the token just worked. + expect(syncUserPolarWorkoutsMock).toHaveBeenCalledWith("u1", { + tokenProven: true, + }); }); it("does not call a run whose vitals collections all failed a success", async () => { diff --git a/src/app/api/user/ai-provider/__tests__/route.test.ts b/src/app/api/user/ai-provider/__tests__/route.test.ts index 0eb415494..d6de4a654 100644 --- a/src/app/api/user/ai-provider/__tests__/route.test.ts +++ b/src/app/api/user/ai-provider/__tests__/route.test.ts @@ -63,6 +63,10 @@ vi.mock("@/lib/ai/consent-guard", () => ({ hasActiveConsentForSurface: vi.fn(), })); +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn(), +})); + import { GET, PATCH } from "../route"; import { prisma } from "@/lib/db"; import { resolveProviderAvailability } from "@/lib/ai/provider"; @@ -72,6 +76,7 @@ import { readServerProviderHealth } from "@/lib/ai/server-provider-health"; import { getAssistantFlags } from "@/lib/feature-flags"; import { providerCredentialPolicy } from "@/lib/sharing/provider-work-authority"; import { hasActiveConsentForSurface } from "@/lib/ai/consent-guard"; +import { auditLog } from "@/lib/auth/audit"; function patchRequest(body: unknown): Request { return new Request("http://localhost/api/user/ai-provider", { @@ -243,6 +248,53 @@ describe("PATCH /api/user/ai-provider — v1.22 fields", () => { }); }); +describe("PATCH /api/user/ai-provider — key changes are audited", () => { + const patch = PATCH as (req: Request) => Promise; + + it("audits a removed key, naming its slot and never a value", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + aiAnthropicKeyEncrypted: "enc:old", + } as never); + const res = await patch(patchRequest({ anthropicKey: null })); + expect(res.status).toBe(200); + expect(vi.mocked(auditLog)).toHaveBeenCalledTimes(1); + expect(vi.mocked(auditLog)).toHaveBeenCalledWith( + "user.ai-provider.key.remove", + expect.objectContaining({ + userId: "u-1", + details: { slot: "anthropic" }, + }), + ); + }); + + it("audits a stored key without its value", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + aiCompatKeyEncrypted: "enc:old", + } as never); + await patch(patchRequest({ openaiKey: "sk-test-123", compatKey: "" })); + const calls = vi.mocked(auditLog).mock.calls; + expect(calls.map(([action, opts]) => [action, opts?.details])).toEqual([ + ["user.ai-provider.key.set", { slot: "openai" }], + ["user.ai-provider.key.remove", { slot: "openai-compatible" }], + ]); + expect(JSON.stringify(calls)).not.toContain("sk-test-123"); + }); + + it("writes no remove audit for a slot that held no key", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + aiAnthropicKeyEncrypted: null, + } as never); + const res = await patch(patchRequest({ anthropicKey: null })); + expect(res.status).toBe(200); + expect(vi.mocked(auditLog)).not.toHaveBeenCalled(); + }); + + it("writes no key audit for a change that touches no key", async () => { + await patch(patchRequest({ responseTimeoutSeconds: 120 })); + expect(vi.mocked(auditLog)).not.toHaveBeenCalled(); + }); +}); + // v1.33.1 (#470) — the OpenAI-compatible gateway writes three columns of its // own. What matters at this layer: the SSRF floor applies to the gateway's // base URL exactly as it does to the Local one, and the column is nobody diff --git a/src/app/api/user/ai-provider/route.ts b/src/app/api/user/ai-provider/route.ts index 1b976d5ec..842e8e41b 100644 --- a/src/app/api/user/ai-provider/route.ts +++ b/src/app/api/user/ai-provider/route.ts @@ -4,9 +4,11 @@ import { prisma } from "@/lib/db"; import { apiSuccess, apiError, + getClientIp, returnAllZodIssues, safeJson, } from "@/lib/api-response"; +import { auditLog } from "@/lib/auth/audit"; import { isPublicUrl } from "@/lib/validations/notifications"; import { isLocalAiHostAllowed } from "@/lib/ai/local-host-allowlist"; import { encrypt, decrypt } from "@/lib/crypto"; @@ -370,9 +372,49 @@ export const PATCH = apiHandler(async (request: NextRequest) => { updates.aiBaseUrl = null; } + // A stored provider key is a credential: setting or removing one is + // audited like every other credential change, with the slot it went into + // and never the value. A removal used to leave no trace at all. Clearing a + // slot that held no key removes nothing and is not audited, so the slots a + // removal names are read before the write. + const keySlots = [ + ["anthropic", body.anthropicKey, "aiAnthropicKeyEncrypted"], + ["openai", body.openaiKey, "aiOpenaiKeyEncrypted"], + ["local", body.localKey, "aiLocalKeyEncrypted"], + ["openai-compatible", body.compatKey, "aiCompatKeyEncrypted"], + ] as const; + const isRemoval = (value: string | null | undefined) => + value === null || value === ""; + const storedKeys = keySlots.some(([, value]) => isRemoval(value)) + ? await prisma.user.findUnique({ + where: { id: user.id }, + select: { + aiAnthropicKeyEncrypted: true, + aiOpenaiKeyEncrypted: true, + aiLocalKeyEncrypted: true, + aiCompatKeyEncrypted: true, + }, + }) + : null; + if (Object.keys(updates).length > 0) { await prisma.user.update({ where: { id: user.id }, data: updates }); } + for (const [slot, value, column] of keySlots) { + if (value === undefined) continue; + if (isRemoval(value) && storedKeys?.[column] == null) continue; + await auditLog( + isRemoval(value) + ? "user.ai-provider.key.remove" + : "user.ai-provider.key.set", + { + userId: user.id, + ipAddress: getClientIp(request), + details: { slot }, + }, + ); + } + return apiSuccess({ updated: true }); }); diff --git a/src/app/api/whoop/webhook/[token]/__tests__/route.test.ts b/src/app/api/whoop/webhook/[token]/__tests__/route.test.ts index ddf3950fe..93f212a60 100644 --- a/src/app/api/whoop/webhook/[token]/__tests__/route.test.ts +++ b/src/app/api/whoop/webhook/[token]/__tests__/route.test.ts @@ -191,14 +191,16 @@ describe("POST /api/whoop/webhook/[token]", () => { expect(getGlobalBoss).not.toHaveBeenCalled(); }); - it("soft-deletes matching rows on a `*.deleted` event", async () => { + it("soft-deletes the recovery's own rows on a `recovery.deleted` event", async () => { vi.mocked(prisma.whoopConnection.findUnique).mockResolvedValue({ userId: "user-1", } as never); + // The v2 recovery webhook id is the associated sleep's UUID, which also + // prefixes the night's sleep rows — the match names the recovery's ids. const req = signedRequest({ user_id: 42, - id: "rec-uuid", + id: "sleep-uuid", type: "recovery.deleted", }); const res = await POST(req, ctx(SECRET)); @@ -208,7 +210,15 @@ describe("POST /api/whoop/webhook/[token]", () => { where: { userId: "user-1", source: "WHOOP", - externalId: { startsWith: "rec-uuid:" }, + externalId: { + in: [ + "sleep-uuid:recovery", + "sleep-uuid:hrv_rmssd", + "sleep-uuid:rhr", + "sleep-uuid:spo2", + "sleep-uuid:skin_temp", + ], + }, deletedAt: null, }, data: { deletedAt: expect.any(Date), syncVersion: { increment: 1 } }, diff --git a/src/app/api/withings/callback/route.ts b/src/app/api/withings/callback/route.ts index faed894ae..f1c02e33c 100644 --- a/src/app/api/withings/callback/route.ts +++ b/src/app/api/withings/callback/route.ts @@ -216,6 +216,9 @@ export const GET = apiHandler(async (request: NextRequest) => { // A reconnect may come with another developer app (another plan), so // what the old grant's plan refused is asked for again. apiPlanState: Prisma.DbNull, + // A reconnect may be another account or device, so nights it gave + // up on are tried again. + sleepStageReads: Prisma.DbNull, }, create: { userId: user.id, diff --git a/src/app/api/workouts/__tests__/batch-scope.test.ts b/src/app/api/workouts/__tests__/batch-scope.test.ts index 61a063093..091b0ad8f 100644 --- a/src/app/api/workouts/__tests__/batch-scope.test.ts +++ b/src/app/api/workouts/__tests__/batch-scope.test.ts @@ -246,8 +246,18 @@ describe("POST /api/workouts/batch — rate limit buckets", () => { describe("POST /api/workouts/batch — what a scoped caller writes", () => { it("attributes its rows to EXTERNAL", async () => { armToken([WORKOUTS_WRITE_SCOPE]); + // Two sessions of the day: EXTERNAL ranks on the workout ladder, so two + // rows of one session would land once. const res = await POST( - makeRequest({ workouts: [workout("s-1"), workout("s-2")] }), + makeRequest({ + workouts: [ + workout("s-1"), + workout("s-2", { + startedAt: "2026-09-20T18:00:00.000Z", + endedAt: "2026-09-20T18:45:00.000Z", + }), + ], + }), ); expect(res.status).toBe(200); const rows = insertedRows(); diff --git a/src/app/checkups/__tests__/page-client-view.test.tsx b/src/app/checkups/__tests__/page-client-view.test.tsx new file mode 100644 index 000000000..7e65a4a93 --- /dev/null +++ b/src/app/checkups/__tests__/page-client-view.test.tsx @@ -0,0 +1,75 @@ +/** + * Which tab a link to `/checkups` opens. + * + * Visits are the page's second tab. `?visit=` opens one, `?view=visits` + * the list, and the bare path keeps opening the checkups, which is what the + * nav entry and the checkup surfaces mean by it. + */ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +let search = new URLSearchParams(); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), + usePathname: () => "/checkups", + useSearchParams: () => search, +})); +vi.mock("@tanstack/react-query", async (importOriginal) => ({ + ...(await importOriginal()), + useQueryClient: () => ({}), +})); +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ isAuthenticated: true, isLoading: false }), +})); +vi.mock("@/hooks/use-mounted", () => ({ useMounted: () => true })); +vi.mock("@/hooks/use-pull-to-refresh", () => ({ + usePullToRefresh: () => ({}), +})); +vi.mock("@/components/ui/pull-to-refresh-indicator", () => ({ + PullToRefreshIndicator: () => null, +})); +vi.mock("@/components/measurement-reminders/vorsorge-section", () => ({ + VorsorgeSection: () =>
, +})); +vi.mock("@/components/encounters/visits-section", () => ({ + VisitsSection: () =>
, +})); +vi.mock("@/components/encounters/procedures-section", () => ({ + ProceduresSection: () =>
, +})); +vi.mock("@/components/body-sites/body-sites-section", () => ({ + BodySitesSection: () =>
, +})); + +import { I18nProvider } from "@/lib/i18n/context"; +import CheckupsPageClient from "../page-client"; + +function openTab(query: string): string | null { + search = new URLSearchParams(query); + const html = renderToStaticMarkup( + + + , + ); + return html.match(/data-section="([^"]+)"/)?.[1] ?? null; +} + +describe("/checkups — the tab a link opens", () => { + it("opens the checkups on the bare path", () => { + expect(openTab("")).toBe("vorsorge"); + }); + + it("opens the visits for a link to one visit", () => { + expect(openTab("visit=v1")).toBe("visits"); + }); + + it("opens the tab `view` names", () => { + expect(openTab("view=visits")).toBe("visits"); + expect(openTab("view=procedures")).toBe("procedures"); + }); + + it("ignores a `view` it does not know", () => { + expect(openTab("view=elsewhere")).toBe("vorsorge"); + }); +}); diff --git a/src/app/checkups/page-client.tsx b/src/app/checkups/page-client.tsx index e97672d7c..b4bc35a05 100644 --- a/src/app/checkups/page-client.tsx +++ b/src/app/checkups/page-client.tsx @@ -15,6 +15,7 @@ import { VisitsSection } from "@/components/encounters/visits-section"; import { ProceduresSection } from "@/components/encounters/procedures-section"; import { BodySitesSection } from "@/components/body-sites/body-sites-section"; import type { BodySiteSide } from "@/hooks/use-body-sites"; +import { parseCheckupsView } from "@/lib/encounters/visit-links"; import { useTranslations } from "@/lib/i18n/context"; import { invalidateKeys, queryKeys } from "@/lib/query-keys"; @@ -66,17 +67,29 @@ export default function CheckupsPageClient() { linkedSideParam === "BOTH" ? linkedSideParam : null; - const [view, setView] = useState( - linkedVisit ? "visits" : linkedSite ? "body-sites" : "vorsorge", + // A link to one tab (`?view=visits`, from a line naming several + // appointments). Built by `src/lib/encounters/visit-links.ts`, never by hand. + const linkedView = parseCheckupsView(searchParams?.get("view")); + const [view, setView] = useState( + linkedVisit + ? "visits" + : linkedSite + ? "body-sites" + : (linkedView ?? "vorsorge"), ); // A link to a visit can arrive while the page is already open (the search // opens one from wherever the person is): it moves to the visits tab then - // too, rather than only on the first render. + // too, rather than only on the first render. A link to a tab does the same. const [observedVisit, setObservedVisit] = useState(linkedVisit); if (linkedVisit !== observedVisit) { setObservedVisit(linkedVisit); if (linkedVisit) setView("visits"); } + const [observedView, setObservedView] = useState(linkedView); + if (linkedView !== observedView) { + setObservedView(linkedView); + if (linkedView && !linkedVisit) setView(linkedView); + } const refresh = useCallback( () => diff --git a/src/app/coach/plans/page.tsx b/src/app/coach/plans/page.tsx index f60a3cfc4..cd646d1e5 100644 --- a/src/app/coach/plans/page.tsx +++ b/src/app/coach/plans/page.tsx @@ -191,6 +191,7 @@ function CoachPlansBody({ readOnly }: { readOnly: boolean }) { {(readOnly || group === "past") && ( { const html = render(); expect(html).toContain("won’t receive notifications"); }); + + it("a switch without a stored row shows the server's default for this account", () => { + // The mood reminder is delivered when the reminder card is on, with no + // per-channel row. The constant table says OFF for it, so a matrix that + // read the table showed the switch off while the reminder kept arriving. + // The page renders what the server resolved, row-less events included. + queryState.data = { + channels: [ + { + id: "c1", + type: "TELEGRAM", + label: "Telegram", + enabled: true, + globallyEnabled: true, + }, + ], + preferences: [], + eventTypes: ["MOOD_REMINDER", "PERSONAL_RECORD"], + eventDefaults: { MOOD_REMINDER: true, PERSONAL_RECORD: false }, + }; + const html = render(); + const state = (label: string) => + new RegExp( + `aria-label="${label} - Telegram"[^>]*data-state="(checked|unchecked)"|data-state="(checked|unchecked)"[^>]*aria-label="${label} - Telegram"`, + ).exec(html); + const mood = state("Mood reminder"); + const record = state("Personal record"); + expect(mood, "mood switch rendered").not.toBeNull(); + expect(mood?.[1] ?? mood?.[2]).toBe("checked"); + expect(record?.[1] ?? record?.[2]).toBe("unchecked"); + }); + + it("a stored row still wins over the default", () => { + queryState.data = { + channels: [ + { + id: "c1", + type: "TELEGRAM", + label: "Telegram", + enabled: true, + globallyEnabled: true, + }, + ], + preferences: [ + { channelId: "c1", eventType: "MOOD_REMINDER", enabled: false }, + ], + eventTypes: ["MOOD_REMINDER"], + eventDefaults: { MOOD_REMINDER: true }, + }; + const html = render(); + expect(html).toMatch( + /aria-label="Mood reminder - Telegram"[^>]*data-state="unchecked"|data-state="unchecked"[^>]*aria-label="Mood reminder - Telegram"/, + ); + }); }); diff --git a/src/app/notifications/page.tsx b/src/app/notifications/page.tsx index 4c14bd9e3..5ff3c6766 100644 --- a/src/app/notifications/page.tsx +++ b/src/app/notifications/page.tsx @@ -12,15 +12,12 @@ import { EmptyState } from "@/components/ui/empty-state"; import { QueryErrorCard } from "@/components/ui/query-error-card"; import Link from "next/link"; import { Bell, Settings, AlertCircle } from "lucide-react"; -import { - EVENT_DEFAULT_ENABLED, - type EventType, -} from "@/lib/notifications/types"; import { eventDescriptionTranslationKey, eventTranslationKey, } from "@/lib/notifications/event-labels"; import { queryKeys } from "@/lib/query-keys"; +import { preferenceCellEnabled } from "@/lib/notifications/event-default-rule"; import { apiGet, apiPut } from "@/lib/api/api-fetch"; import { Card, @@ -48,6 +45,12 @@ interface PreferencesData { channels: ChannelInfo[]; preferences: Preference[]; eventTypes: string[]; + /** + * What each event does on a channel with no stored row, resolved by the + * server for this account with the function the dispatcher uses. + */ + /** Absent on a response cached by a build before the field. */ + eventDefaults?: Record; } export default function NotificationsPage() { @@ -112,14 +115,11 @@ export default function NotificationsPage() { }); function isEnabled(channelId: string, eventType: string): boolean { - const pref = data?.preferences?.find( - (p) => p.channelId === channelId && p.eventType === eventType, - ); - if (pref) return pref.enabled; - // v1.4.25 — per-event default. Most events stay opt-out - // (no row = ON); PERSONAL_RECORD flips to opt-in (no row = OFF) - // so a backfill doesn't fire hundreds of pushes on first sync. - return EVENT_DEFAULT_ENABLED[eventType as EventType] ?? true; + // No row: the server's resolved default for this account. Read, never + // recomputed here: the mood reminder's follows the reminder card, and a + // copy of the constant table showed it off while it was being delivered. + // A cached payload without the map falls back to the shared rule. + return preferenceCellEnabled(data, channelId, eventType); } // Cross-links to the other two notification surfaces (reminder types + diff --git a/src/app/page-client.tsx b/src/app/page-client.tsx index b72534282..02c00fe5d 100644 --- a/src/app/page-client.tsx +++ b/src/app/page-client.tsx @@ -491,8 +491,9 @@ export default function DashboardPageClient({ // measurement-type enum), so no backend change was needed; the strip // tiles below self-gate on `count > 0`. // HRV is stored under two types and the tile has to accept either: SDNN - // (`HEART_RATE_VARIABILITY`, Apple / Fitbit / Google Health) or nightly - // RMSSD (`HRV_RMSSD`, Oura / Polar / WHOOP and bridge pushes). Reading + // (`HEART_RATE_VARIABILITY`, Apple Health / Health Connect) or nightly + // RMSSD (`HRV_RMSSD`, Oura / Polar / WHOOP / Fitbit / Google Health and + // bridge pushes). Reading // SDNN alone left a ring / strap account with a Settings toggle that // rendered nothing while `/insights/hrv` charted the same data. The // helper keeps the union in one place and hands back which type won, so diff --git a/src/components/admin/ai-server-key-section.tsx b/src/components/admin/ai-server-key-section.tsx index c28f88035..ee90687e8 100644 --- a/src/components/admin/ai-server-key-section.tsx +++ b/src/components/admin/ai-server-key-section.tsx @@ -187,6 +187,7 @@ export function AiServerKeySection() { {configured && ( } @@ -1471,6 +1472,7 @@ export function SymptomRow({ ", () => { expect(html).toContain("View check-ups"); }); + // An appointment keeps the check-up label on the wire for the shipped iOS + // app; the web names what the link opens. The check-up item above is the + // negative control. + it("names an appointment's action for the visit it opens", () => { + const action = (href: string) => ({ + labelKey: "daily.action.viewCheckups", + intent: "checkup.view", + href, + }); + const one = render( + , + ); + expect(one).toContain("View appointment"); + expect(one).not.toContain("View check-ups"); + expect(one).toContain('href="/checkups?visit=v1"'); + const several = render( + , + ); + expect(several).toContain("View appointments"); + }); + describe("dismiss affordance — observational kinds only", () => { const DISMISSIBLE: PriorityItemKind[] = [ "milestone", diff --git a/src/components/daily/priority-card.tsx b/src/components/daily/priority-card.tsx index 6f47ec2c1..5d1d5962d 100644 --- a/src/components/daily/priority-card.tsx +++ b/src/components/daily/priority-card.tsx @@ -36,6 +36,7 @@ import { Card, CardContent } from "@/components/ui/card"; import { TileHeader } from "@/components/insights/tile-header"; import { ProseBlocks } from "@/components/insights/prose-blocks"; import { Button, buttonVariants } from "@/components/ui/button"; +import { VISITS_PATH } from "@/lib/encounters/visit-links"; import { useTranslations } from "@/lib/i18n/context"; import { cn } from "@/lib/utils"; import { @@ -205,7 +206,7 @@ export function PriorityCard({ ) : onAction ? ( , + action: Pick, +): string { + if (item.kind !== "upcoming_visit" || !action.href) return action.labelKey; + return action.href === VISITS_PATH + ? "daily.action.viewAppointments" + : "daily.action.viewAppointment"; +} + function ActionLink({ href, label }: { href: string; label: string }) { return ( ` to its existing destination * by construction; S2 invents no new backend action. * diff --git a/src/components/dashboard/dashboard-gates.ts b/src/components/dashboard/dashboard-gates.ts index d219ffa9a..c26154b33 100644 --- a/src/components/dashboard/dashboard-gates.ts +++ b/src/components/dashboard/dashboard-gates.ts @@ -164,9 +164,9 @@ export const HRV_LABEL_KEY: Record = { * Which HRV series backs the dashboard tile. * * HRV is stored under two types and they are not the same measure: Apple - * Health / Fitbit / Google Health write SDNN as `HEART_RATE_VARIABILITY`, - * while Oura / Polar / WHOOP — and a Garmin-style feed pushed in through a - * bridge — write nightly RMSSD as `HRV_RMSSD`. Every other HRV surface + * Health and Health Connect write SDNN as `HEART_RATE_VARIABILITY`, while + * Oura / Polar / WHOOP / Fitbit / Google Health — and a Garmin-style feed + * pushed in through a bridge — write nightly RMSSD as `HRV_RMSSD`. Every other HRV surface * already unions the two: `/insights/hrv` passes `fallbackMeasurementType` * (`app/insights/hrv/page.tsx`), the Coach resolves both * (`COACH_SOURCE_MEASUREMENT_TYPES.hrv`), and the MCP reads swap through diff --git a/src/components/day/since-last-visit.tsx b/src/components/day/since-last-visit.tsx index 08f0cf5b6..488781784 100644 --- a/src/components/day/since-last-visit.tsx +++ b/src/components/day/since-last-visit.tsx @@ -17,10 +17,17 @@ import { } from "lucide-react"; import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { + Card, + CardContent, + CardDescription, + CardHeader, +} from "@/components/ui/card"; import { TileHeader } from "@/components/insights/tile-header"; import { QueryErrorRow } from "@/components/ui/query-error-row"; +import { preparationText } from "@/components/encounters/preparation-text"; import type { EncounterDTO } from "@/lib/encounters/dto"; +import type { EncounterPreparationDTO } from "@/lib/encounters/preparation"; import { apiGet } from "@/lib/api/api-fetch"; import type { DateKey, @@ -46,14 +53,20 @@ import { useTodayKey } from "./use-today-key"; /** * "Since the last visit": the preparation the next planned appointment * otherwise lacks. Dose changes, episodes that began, new results, extreme - * readings and gaps between the last visit and today, each dated, each date - * opening its day. Compiled from the record without any judgement; the - * wording is the day view's own. + * readings and gaps between the start of the window and today, each dated, + * each date opening its day. Compiled from the record without any judgement; + * the wording is the day view's own. * - * One read, `GET /api/day/notable` for the window from the last visit to - * today: the server lists every context change in it (a dose change always, - * however quiet the day around it) and every notable observation. Nothing is - * recomputed here; this only spells the rows. + * The window starts where the server says (`EncounterDTO.preparation`): the + * last visit with the same practitioner, else the same specialty, else the + * same kind, else the last 90 days — never simply the last visit of any kind, + * which handed a dermatology appointment a week of an internist's data. The + * card's title says which, so the start date explains itself. + * + * One read, `GET /api/day/notable` for that window up to today: the server + * lists every context change in it (a dose change always, however quiet the + * day around it) and every notable observation. Nothing is recomputed here; + * this only spells the rows. * * The component owns its card and draws nothing until it has a row to show: * not while the window loads, not when it holds nothing worth listing. A card @@ -132,12 +145,12 @@ export function preparationRows( export function SinceLastVisit({ next, - last, + preparation, }: { /** The next planned visit, the one this prepares. */ next: EncounterDTO; - /** The last visit that took place. */ - last: EncounterDTO; + /** The window before it, as the server resolved it. */ + preparation: EncounterPreparationDTO; }) { const { t, tCount } = useTranslations(); const fmt = useFormatters(); @@ -147,16 +160,17 @@ export function SinceLastVisit({ const notableText = useNotableText(); const [showAll, setShowAll] = useState(false); - const lastDay = dateKeyOfInstant(last.occurredAt, timeZone); - // From the last visit's own day (a dose changed at the visit belongs to - // what the next one should know) to today. The server caps the span. + // From the server's start day (the matched visit's own day: a dose changed + // at that visit belongs to what the next one should know) to today. The + // notable read caps the span. + const fromDay: DateKey = preparation.from; const window = useQuery({ - queryKey: queryKeys.dayNotable(lastDay, today), + queryKey: queryKeys.dayNotable(fromDay, today), queryFn: () => { - const params = new URLSearchParams({ from: lastDay, to: today }); + const params = new URLSearchParams({ from: fromDay, to: today }); return apiGet(`/api/day/notable?${params}`); }, - enabled: lastDay <= today, + enabled: fromDay <= today, staleTime: 5 * 60_000, }); @@ -194,7 +208,15 @@ export function SinceLastVisit({ }) : []; - const lastLabel = fmt.dateShortSmartCalendar(lastDay); + const fromLabel = fmt.dateShortSmartCalendar(fromDay); + // Where the window starts and why, in one spelling shared with the report + // panel's offer. + const heading = preparationText(t, fmt.dateShortSmartCalendar, preparation); + const reason = heading.reason ? ( + + {heading.reason} + + ) : null; // A failed read keeps the card and its title, so the page does not jump // and the reader still sees what could not be loaded. @@ -206,8 +228,9 @@ export function SinceLastVisit({ size="sm" icon={Stethoscope} titleAs="h3" - title={t("day.sinceLastVisit.title", { date: lastLabel })} + title={heading.title} /> + {reason} {tCount("day.sinceLastVisit.count", rows.length)} } /> + {reason}
diff --git a/src/components/documents/document-record-links.tsx b/src/components/documents/document-record-links.tsx index 93b4c4509..c3cbd6827 100644 --- a/src/components/documents/document-record-links.tsx +++ b/src/components/documents/document-record-links.tsx @@ -50,6 +50,7 @@ import { queryKeys } from "@/lib/query-keys"; import type { EncounterDTO, EncounterListDTO } from "@/lib/encounters/dto"; import type { EncounterKind } from "@/generated/prisma/client"; import type { InboundDocumentDetailDto } from "@/lib/validations/inbound-documents"; +import { visitPath } from "@/lib/encounters/visit-links"; type Translate = ReturnType["t"]; @@ -109,7 +110,7 @@ export async function fetchEncounterPage( /** Where a visit opens: its sheet on the checkups page. */ function visitHref(encounterId: string): string { - return `/checkups?visit=${encodeURIComponent(encounterId)}`; + return visitPath(encounterId); } /** Where a dose opens: its sheet on the vaccinations page. */ diff --git a/src/components/encounters/__tests__/visits-section.test.tsx b/src/components/encounters/__tests__/visits-section.test.tsx index 31f95b25f..03c909419 100644 --- a/src/components/encounters/__tests__/visits-section.test.tsx +++ b/src/components/encounters/__tests__/visits-section.test.tsx @@ -47,7 +47,7 @@ vi.mock("@/lib/api/api-fetch", async () => { }; }); -import { dateKeyOfInstant, todayKeyInZone } from "@/components/day/day-url"; +import { todayKeyInZone } from "@/components/day/day-url"; import { ApiError } from "@/lib/api/api-fetch"; import type { DayNotableWindowResponse } from "@/lib/day/contract"; import { I18nProvider } from "@/lib/i18n/context"; @@ -174,13 +174,33 @@ describe("", () => { * to wrap it in a card, so whenever it had nothing to list (still loading, a * quiet window, a failed read) an empty bordered card sat under the visit. * It now draws its own card, and only once it has a row. + * + * The window is the server's (`preparation` on the booked visit). The section + * used to start it at the last visit of any kind it found in the past list; + * the fixtures carry such a visit, newer than the window's start, and the + * read must not move to it. */ describe(" since the last visit", () => { + const sameDoctor = { + id: "derm-before", + occurredAt: "2026-03-02T09:00:00.000Z", + kind: "SPECIALIST" as const, + practitionerName: "Skin practice", + specialty: "Dermatology", + }; const next = visit({ id: "next", status: "PLANNED", occurredAt: "2026-09-20T09:00:00.000Z", + preparation: { + from: "2026-03-02", + to: "2026-12-01", + basis: "practitioner", + since: sameDoctor, + }, }); + const unprepared = visit({ ...next, preparation: null }); + // A newer visit elsewhere: what the old client rule anchored on. const last = visit({ id: "last", occurredAt: "2026-08-01T09:00:00.000Z" }); /** Cards painted inside the upcoming list. */ @@ -194,12 +214,8 @@ describe(" since the last visit", () => { function seedWindow(window: DayNotableWindowResponse | null) { return (queryClient: QueryClient) => { if (!window) return; - const tz = DEFAULT_TIMEZONE; queryClient.setQueryData( - queryKeys.dayNotable( - dateKeyOfInstant(last.occurredAt, tz), - todayKeyInZone(tz), - ), + queryKeys.dayNotable("2026-03-02", todayKeyInZone(DEFAULT_TIMEZONE)), window, ); }; @@ -219,10 +235,10 @@ describe(" since the last visit", () => { }); it("draws no card while the window is still loading", () => { - const withLast = render({ upcoming: [next], past: [last] }); - const withoutLast = render({ upcoming: [next], past: [] }); - expect(withLast).not.toContain('data-slot="since-last-visit"'); - expect(upcomingCards(withLast)).toBe(upcomingCards(withoutLast)); + const loading = render({ upcoming: [next], past: [last] }); + const without = render({ upcoming: [unprepared], past: [last] }); + expect(loading).not.toContain('data-slot="since-last-visit"'); + expect(upcomingCards(loading)).toBe(upcomingCards(without)); }); it("draws no card when the window holds nothing worth listing", () => { @@ -230,17 +246,16 @@ describe(" since the last visit", () => { { upcoming: [next], past: [last] }, seedWindow(emptyWindow), ); - const baseline = render({ upcoming: [next], past: [] }); + const baseline = render({ upcoming: [unprepared], past: [last] }); expect(html).not.toContain('data-slot="since-last-visit"'); expect(upcomingCards(html)).toBe(upcomingCards(baseline)); }); function seedFailure(error: Error) { return (queryClient: QueryClient) => { - const tz = DEFAULT_TIMEZONE; const queryKey = queryKeys.dayNotable( - dateKeyOfInstant(last.occurredAt, tz), - todayKeyInZone(tz), + "2026-03-02", + todayKeyInZone(DEFAULT_TIMEZONE), ); // The state the section sees once the real read has given up: a mount // does not retry it. @@ -257,14 +272,14 @@ describe(" since the last visit", () => { { upcoming: [next], past: [last] }, seedFailure(new ApiError("window read failed", 500)), ); - const baseline = render({ upcoming: [next], past: [] }); + const baseline = render({ upcoming: [unprepared], past: [last] }); expect(html).toContain('data-slot="since-last-visit-error"'); expect(html).toContain('role="alert"'); expect(html).not.toContain('data-slot="since-last-visit"'); // The failed window keeps its own card and title, so the page does not // jump; the visit cards beside it stay as they were. expect(upcomingCards(html)).toBe(upcomingCards(baseline) + 1); - expect(html).toContain("Since the last visit on"); + expect(html).toContain("Since your last visit with Skin practice on"); }); // Visits need the profile section, the window reads measurements, and a @@ -296,10 +311,15 @@ describe(" since the last visit", () => { ], } as unknown as DayNotableWindowResponse), ); - const baseline = render({ upcoming: [next], past: [] }); + const baseline = render({ upcoming: [unprepared], past: [last] }); expect(html).toContain('data-slot="since-last-visit"'); expect(html).toContain('data-slot="since-last-visit-row"'); expect(upcomingCards(html)).toBe(upcomingCards(baseline) + 1); + // The title says where the window starts and why; the report starts + // there too, not at the newer visit elsewhere. + expect(html).toContain("Since your last visit with Skin practice on"); + expect(html).toContain("reportFrom=2026-03-02&"); + expect(html).not.toContain('data-slot="since-last-visit-reason"'); // The card heads like every compact card: TileHeader at the small size, // a real heading under the section's own, at the compact density. const card = html.slice( @@ -314,4 +334,42 @@ describe(" since the last visit", () => { /

{ + const html = render( + { + upcoming: [ + visit({ + ...next, + preparation: { + from: "2026-03-02", + to: "2026-12-01", + basis: "default", + since: null, + }, + }), + ], + past: [last], + }, + seedWindow({ + ...emptyWindow, + changes: [ + { + date: "2026-08-03", + kind: "doseChange", + section: "medications", + id: "d1", + title: "Example 5 mg", + count: 1, + href: null, + }, + ], + } as unknown as DayNotableWindowResponse), + ); + expect(html).toContain("The last 90 days"); + expect(html).toContain('data-slot="since-last-visit-reason"'); + expect(html).toContain( + "No earlier visit with the same practice, specialty or kind.", + ); + }); }); diff --git a/src/components/encounters/preparation-text.ts b/src/components/encounters/preparation-text.ts new file mode 100644 index 000000000..3d5211228 --- /dev/null +++ b/src/components/encounters/preparation-text.ts @@ -0,0 +1,69 @@ +/** + * The window before a booked visit, in words: the line that says where the + * visit preparation and the offered doctor report start, and why there. + * + * Spells only what the server resolved (`EncounterDTO.preparation`); nothing + * here chooses a date. One spelling for both surfaces that show it, so the + * visits tab and the report panel cannot give two reasons for one window. + */ +import type { EncounterKind } from "@/generated/prisma/client"; +import { + PREPARATION_DEFAULT_DAYS, + type EncounterPreparationDTO, +} from "@/lib/encounters/preparation"; + +import { encounterKindText } from "./encounter-labels"; + +type Translate = ( + key: string, + params?: Record, +) => string; + +export interface PreparationText { + /** Where the window starts: "Since your last visit with … on …". */ + title: string; + /** Why, when the title alone does not say: only on the default window. */ + reason: string | null; +} + +export function preparationText( + t: Translate, + formatDay: (day: string) => string, + preparation: EncounterPreparationDTO, +): PreparationText { + const date = formatDay(preparation.from); + const since = preparation.since; + switch (preparation.basis) { + case "practitioner": + return { + title: t("encounters.preparation.practitioner", { + name: since?.practitionerName ?? "", + date, + }), + reason: null, + }; + case "specialty": + return { + title: t("encounters.preparation.specialty", { + specialty: since?.specialty ?? "", + date, + }), + reason: null, + }; + case "kind": + return { + title: t("encounters.preparation.kind", { + kind: since ? encounterKindText(t, since.kind as EncounterKind) : "", + date, + }), + reason: null, + }; + case "default": + return { + title: t("encounters.preparation.default", { + days: PREPARATION_DEFAULT_DAYS, + }), + reason: t("encounters.preparation.defaultReason"), + }; + } +} diff --git a/src/components/encounters/visits-section.tsx b/src/components/encounters/visits-section.tsx index dde6fea21..015e6c008 100644 --- a/src/components/encounters/visits-section.tsx +++ b/src/components/encounters/visits-section.tsx @@ -72,8 +72,11 @@ export function VisitsSection({ enabled = true }: { enabled?: boolean }) { resolve: fetchVisit, }); const empty = upcoming.length === 0 && past.length === 0; - // The last visit that took place: the start of the visit preparation. - const lastDone = past.find((encounter) => encounter.status === "DONE"); + // The next booked visit carries the window before it, resolved by the + // server (`EncounterDTO.preparation`): which earlier visit it starts at and + // why. Never picked here from the past list, which would be the last visit + // of any kind — the rule the server replaced. + const prepared = upcoming.find((encounter) => encounter.preparation); const addButton = canAddVisit ? (

) : ( <> @@ -284,10 +299,17 @@ export function OcrRowEditor({ value={row.unit ?? ""} onChange={(e) => onChange({ ...row, unit: e.target.value })} {...invalidProps( - errors?.unitMismatch ? "unitMismatch" : "unit", + errors?.unitMismatch + ? "unitMismatch" + : errors?.unitTooLong + ? "unitTooLong" + : "unit", )} /> {fieldError("unit", "labs.ocr.errorUnit")} + {fieldError("unitTooLong", "labs.ocr.errorTooLong", { + max: String(OCR_UNIT_MAX), + })} {fieldError("unitMismatch", "labs.ocr.errorUnitMismatch", { markerUnit: row.markerUnit ?? "", })} diff --git a/src/components/links/related-records.tsx b/src/components/links/related-records.tsx index aadcfd923..85ff071f4 100644 --- a/src/components/links/related-records.tsx +++ b/src/components/links/related-records.tsx @@ -64,6 +64,7 @@ import type { IllnessEpisodeDTO } from "@/lib/illness/dto"; import type { RelatedLink } from "@/lib/links/related"; import type { EncounterKind } from "@/generated/prisma/client"; import { translateOrNull } from "@/lib/i18n/translate-code"; +import { visitPath } from "@/lib/encounters/visit-links"; type Translate = ReturnType["t"]; type RelatedKind = RelatedLink["kind"]; @@ -311,7 +312,7 @@ function VisitPicker({ selected, onChange, anchor, known }: PickerProps) { label: encounterKindText(t, visit.kind as EncounterKind), meta: visit.practitioner?.name ?? null, occurredAt: visit.occurredAt, - href: `/checkups?visit=${encodeURIComponent(visit.id)}`, + href: visitPath(visit.id), })), anchor, { diff --git a/src/components/measurement-reminders/__tests__/vorsorge-dashboard-card-next-visit.test.tsx b/src/components/measurement-reminders/__tests__/vorsorge-dashboard-card-next-visit.test.tsx new file mode 100644 index 000000000..bddb52399 --- /dev/null +++ b/src/components/measurement-reminders/__tests__/vorsorge-dashboard-card-next-visit.test.tsx @@ -0,0 +1,72 @@ +/** + * The dashboard's next-appointment line opens the appointment, on the visits + * tab, and not the checkups list the card otherwise summarises. + * + * The line linked the bare `/checkups`, which opens the preventive-care tab — + * the one list an appointment is never on. The negative control is the card's + * own empty state, whose link is to the checkups and stays there. + */ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +const visit = { + id: "visit-42", + status: "PLANNED", + kind: "SPECIALIST", + // Inside the card's two-week horizon whenever the suite runs. + occurredAt: new Date(Date.now() + 2 * 24 * 60 * 60 * 1000).toISOString(), + practitioner: { name: "Practice Example" }, +}; + +vi.mock("@/hooks/use-measurement-reminders", () => ({ + useMeasurementReminders: () => ({ + data: [], + isError: false, + refetch: vi.fn(), + }), + useMeasurementReminderMutations: () => ({ + satisfy: { mutate: vi.fn(), isPending: false }, + }), +})); + +vi.mock("@/hooks/use-encounters", () => ({ + useEncounters: () => ({ data: { upcoming: [visit], past: [] } }), +})); + +vi.mock("@/hooks/use-record-capabilities", () => ({ + useRecordCapabilities: () => ({ + canManage: true, + canWriteDomain: () => true, + canManageDomain: () => true, + }), +})); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), + usePathname: () => "/", + useSearchParams: () => new URLSearchParams(), +})); + +import { I18nProvider } from "@/lib/i18n/context"; +import { VorsorgeDashboardCard } from "../vorsorge-dashboard-card"; + +function linkHref(html: string, slot: string): string | null { + const tag = html.match(new RegExp(`]*data-slot="${slot}"[^>]*>`)); + return tag?.[0].match(/href="([^"]*)"/)?.[1] ?? null; +} + +describe(" — the next appointment", () => { + it("opens the visit itself", () => { + const html = renderToStaticMarkup( + + + , + ); + expect(html).toContain("Practice Example"); + expect(linkHref(html, "vorsorge-dashboard-next-visit")).toBe( + "/checkups?visit=visit-42", + ); + // The empty state names the checkups and still opens them. + expect(html).toContain('href="/checkups"'); + }); +}); diff --git a/src/components/measurement-reminders/vorsorge-dashboard-card.tsx b/src/components/measurement-reminders/vorsorge-dashboard-card.tsx index 7fbe3287a..ff724b5d5 100644 --- a/src/components/measurement-reminders/vorsorge-dashboard-card.tsx +++ b/src/components/measurement-reminders/vorsorge-dashboard-card.tsx @@ -22,6 +22,7 @@ import { CalendarClock, CheckCircle2, Plus } from "lucide-react"; import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; import { encounterKindText } from "@/components/encounters/encounter-labels"; import { useEncounters } from "@/hooks/use-encounters"; +import { visitPath } from "@/lib/encounters/visit-links"; import { useFormatters, useTranslations, @@ -240,10 +241,12 @@ export function VorsorgeDashboardCard() { {/* Visually distinct from a due checkup: a quiet line under the block rather than a fourth row with an action, because there is nothing - to do about an appointment except keep it. */} + to do about an appointment except keep it. It opens the visit + itself: a bare `/checkups` lands on the checkups tab, which is + exactly where the appointment is not. */} {nextAppointment ? ( diff --git a/src/components/measurement-reminders/vorsorge-settings.tsx b/src/components/measurement-reminders/vorsorge-settings.tsx index 3fe69268b..a4df6ea47 100644 --- a/src/components/measurement-reminders/vorsorge-settings.tsx +++ b/src/components/measurement-reminders/vorsorge-settings.tsx @@ -11,6 +11,7 @@ * reminder PATCH route. */ import { ArrowUpDown, LayoutGrid, ListChecks } from "lucide-react"; +import { toastWrittenOutcome } from "@/components/outcome/outcome-toast"; import { Card, CardContent } from "@/components/ui/card"; import { Switch } from "@/components/ui/switch"; @@ -31,6 +32,7 @@ import { } from "@/hooks/use-measurement-reminders"; import { translateOrNull } from "@/lib/i18n/translate-code"; import { EmptyState } from "@/components/ui/empty-state"; +import { showErrorToast } from "@/lib/errors/error-toast"; function resolveLabel( reminder: MeasurementReminder, @@ -127,10 +129,20 @@ export function VorsorgeSettings() { - update.mutate({ - id: reminder.id, - body: { enabled: next }, - }) + update.mutate( + { id: reminder.id, body: { enabled: next } }, + { + onSuccess: () => + toastWrittenOutcome( + "success", + t("settings.saved"), + ), + onError: (failure) => + showErrorToast(t("settings.savingError"), { + cause: failure, + }), + }, + ) } disabled={update.isPending} // Label each row's Switch by its own name span so SR diff --git a/src/components/onboarding/baseline-fields.tsx b/src/components/onboarding/baseline-fields.tsx index bd83c1e77..7d042aee9 100644 --- a/src/components/onboarding/baseline-fields.tsx +++ b/src/components/onboarding/baseline-fields.tsx @@ -1,5 +1,6 @@ "use client"; +import { DISPLAY_NAME_MAX_LENGTH } from "@/lib/validations/display-name"; import { Input } from "@/components/ui/input"; import { DateField } from "@/components/ui/date-field"; import { FieldGroup } from "@/components/ui/field-group"; @@ -98,7 +99,7 @@ export function BaselineFields({ onChange={(e) => onChange("displayName", e.target.value)} disabled={readOnlyDisplayName} autoComplete="nickname" - maxLength={50} + maxLength={DISPLAY_NAME_MAX_LENGTH} placeholder={t("onboarding.baseline.displayNamePlaceholder")} aria-invalid={errors.displayName ? true : undefined} aria-describedby={ diff --git a/src/components/practitioners/practitioner-list.tsx b/src/components/practitioners/practitioner-list.tsx index 43f9ec817..b305ed15a 100644 --- a/src/components/practitioners/practitioner-list.tsx +++ b/src/components/practitioners/practitioner-list.tsx @@ -39,6 +39,7 @@ import { usePractitioners, type Practitioner, } from "@/hooks/use-practitioners"; +import { VISITS_PATH } from "@/lib/encounters/visit-links"; import { useTranslations } from "@/lib/i18n/context"; import { PractitionerSheet } from "./practitioner-sheet"; @@ -107,7 +108,7 @@ export function PractitionerList({ enabled = true }: { enabled?: boolean }) { diff --git a/src/components/records/__tests__/emergency-profile-save.test.tsx b/src/components/records/__tests__/emergency-profile-save.test.tsx index 6640644ce..845175c56 100644 --- a/src/components/records/__tests__/emergency-profile-save.test.tsx +++ b/src/components/records/__tests__/emergency-profile-save.test.tsx @@ -1,3 +1,6 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + import { describe, expect, it, vi } from "vitest"; import { renderToStaticMarkup } from "react-dom/server"; @@ -30,7 +33,14 @@ vi.mock("@/lib/api/api-fetch", () => ({ apiGet: vi.fn(), apiPatch: vi.fn() })); vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } })); import { I18nProvider } from "@/lib/i18n/context"; -import { EmergencyProfileManager } from "../emergency-profile-manager"; +import { + emergencyProfileUpdateSchema, + type EmergencyProfileDto, +} from "@/lib/validations/emergency-profile"; +import { + EmergencyProfileManager, + emergencyPatchBody, +} from "../emergency-profile-manager"; describe(" Save", () => { it("stays disabled until a field differs from the stored profile", () => { @@ -43,3 +53,71 @@ describe(" Save", () => { expect(row).toMatch(/]*disabled=""[^>]*>[^]*?Save<\/button>/); }); }); + +describe("emergency selects can be taken back to not recorded", () => { + // A blood type, donor status or directive, once chosen, could be changed + // to another value but never cleared: the select offered no empty option, + // and the save left out an empty select, so even a cleared draft would not + // have reached the column. + const stored: EmergencyProfileDto = { + bloodType: "A_POS", + organDonor: "YES", + advanceDirective: null, + contacts: "A contact", + contactsUnreadable: false, + implants: null, + implantsUnreadable: false, + note: null, + noteUnreadable: false, + }; + const draft = { + bloodType: "A_POS", + organDonor: "YES", + advanceDirective: "", + contacts: "A contact", + implants: "", + note: "", + }; + + it("sends a cleared select as null, which clears the column", () => { + expect( + emergencyPatchBody({ ...draft, bloodType: "" }, stored), + ).toMatchObject({ bloodType: null }); + }); + + it("leaves an untouched select out of the save", () => { + const body = emergencyPatchBody(draft, stored); + expect(body).not.toHaveProperty("bloodType"); + expect(body).not.toHaveProperty("organDonor"); + expect(body).not.toHaveProperty("advanceDirective"); + }); + + it("sends a changed select's new value", () => { + expect( + emergencyPatchBody({ ...draft, advanceDirective: "YES" }, stored), + ).toMatchObject({ advanceDirective: "YES" }); + }); + + it("the server accepts what the card sends", () => { + const body = emergencyPatchBody( + { ...draft, bloodType: "", organDonor: "NO" }, + stored, + ); + const parsed = emergencyProfileUpdateSchema.safeParse(body); + expect(parsed.success).toBe(true); + expect(parsed.data).toMatchObject({ bloodType: null, organDonor: "NO" }); + }); + + it("offers a not-recorded option in every select", () => { + // The options live in the popover, which a static render never opens; + // read from the source that each select carries the option. + const source = readFileSync( + resolve(__dirname, "../emergency-profile-manager.tsx"), + "utf8", + ); + expect(source).toMatch( + /\s*\{t\("records\.emergency\.notRecorded"\)\}/, + ); + expect(source).toMatch(/next === NOT_RECORDED \? "" : next/); + }); +}); diff --git a/src/components/records/about-me-note-manager.tsx b/src/components/records/about-me-note-manager.tsx index ebbc8232c..422fd6001 100644 --- a/src/components/records/about-me-note-manager.tsx +++ b/src/components/records/about-me-note-manager.tsx @@ -194,6 +194,7 @@ export function AboutMeNoteManager() { {hasNote && ( } variant="outline" diff --git a/src/components/records/audit-c-panel.tsx b/src/components/records/audit-c-panel.tsx index 6f040d9b4..c986cff7d 100644 --- a/src/components/records/audit-c-panel.tsx +++ b/src/components/records/audit-c-panel.tsx @@ -176,6 +176,7 @@ export function AuditCPanel({ className="text-destructive size-11 shrink-0 sm:size-9" icon={} slot={`audit-c-remove-${entry.id}`} + destructive /> ))} @@ -214,6 +215,7 @@ export function AuditCPanel({ className="text-destructive size-11 shrink-0 sm:size-9" icon={} slot="audit-c-legacy-remove" + destructive />
) : null} diff --git a/src/components/records/emergency-profile-manager.tsx b/src/components/records/emergency-profile-manager.tsx index 920fe4b40..4210900d3 100644 --- a/src/components/records/emergency-profile-manager.tsx +++ b/src/components/records/emergency-profile-manager.tsx @@ -31,7 +31,7 @@ import { type EmergencyProfileDto, } from "@/lib/validations/emergency-profile"; -interface Draft { +export interface Draft { bloodType: string; organDonor: string; advanceDirective: string; @@ -40,6 +40,45 @@ interface Draft { note: string; } +/** + * The select value that stands for "not recorded". Radix refuses an empty + * string as an item value, so the cleared state needs a sentinel of its own; + * the draft keeps "" and only the select sees this. + */ +const NOT_RECORDED = "__none__"; + +type EnumKey = "bloodType" | "organDonor" | "advanceDirective"; +const ENUM_KEYS: readonly EnumKey[] = [ + "bloodType", + "organDonor", + "advanceDirective", +]; + +/** + * The PATCH body for a save. A select rides only when it changed, so a save + * leaves a column it did not touch alone; a changed select that is now empty + * rides as `null`, which is what clears it. The free-text fields always ride, + * an emptied one clearing its column server-side. + * + * An empty select used to be omitted always, and the select had no empty + * option to begin with: a blood type, once chosen, could be changed to + * another one but never taken back to "not recorded". + */ +export function emergencyPatchBody( + draft: Draft, + stored: EmergencyProfileDto, +): Record { + const body: Record = {}; + for (const key of ENUM_KEYS) { + const next = draft[key] || null; + if (next !== (stored[key] ?? null)) body[key] = next; + } + body.contacts = draft.contacts; + body.implants = draft.implants; + body.note = draft.note; + return body; +} + function draftFromDto(dto: EmergencyProfileDto): Draft { return { bloodType: dto.bloodType ?? "", @@ -66,19 +105,10 @@ export function EmergencyProfileManager() { const save = useMutation({ mutationKey: queryKeys.emergencyProfile(), mutationFn: (input: Draft) => - apiPatch("/api/anamnesis/emergency", { - // A select left at "" is "not recorded" and is omitted so a partial - // save leaves the column untouched; the free-text fields always ride, - // with an emptied field clearing its column server-side. - ...(input.bloodType ? { bloodType: input.bloodType } : {}), - ...(input.organDonor ? { organDonor: input.organDonor } : {}), - ...(input.advanceDirective - ? { advanceDirective: input.advanceDirective } - : {}), - contacts: input.contacts, - implants: input.implants, - note: input.note, - }), + apiPatch( + "/api/anamnesis/emergency", + emergencyPatchBody(input, query.data!), + ), onSuccess: (data) => { setDraft(draftFromDto(data)); queryClient.setQueryData(queryKeys.emergencyProfile(), data); @@ -110,7 +140,7 @@ export function EmergencyProfileManager() { setDraft((prev) => ({ ...(prev ?? draftFromDto(query.data!)), ...patch })); const enumField = ( - key: "bloodType" | "organDonor" | "advanceDirective", + key: EnumKey, values: readonly string[], valuePrefix: string, ) => ( @@ -119,14 +149,21 @@ export function EmergencyProfileManager() { {t(`records.emergency.${key}`)} ` tag, or null. */ +function displayNameInput(html: string): string | null { + return /]*id="display-name"[^>]*>/.exec(html)?.[0] ?? null; +} + +describe("display name on the profile card", () => { + it("renders the field seeded with the stored name", () => { + displayName = "Sam"; + const input = displayNameInput(render()); + expect(input).not.toBeNull(); + expect(input).toContain('value="Sam"'); + }); + + it("renders it empty for an account that never set one", () => { + displayName = null; + const input = displayNameInput(render()); + expect(input).not.toBeNull(); + expect(input).toContain('value=""'); + }); + + it("sends the field with the profile save", () => { + const source = readFileSync(resolve(__dirname, "../index.tsx"), "utf8"); + expect(source).toMatch( + /displayName:\s*savedProfile\.displayName\s*\|\|\s*null/, + ); + }); + + it("is published on the account payload the card seeds from", () => { + const route = readFileSync( + join(process.cwd(), "src/app/api/auth/me/route.ts"), + "utf8", + ); + expect(route).toMatch(/\bdisplayName:\s*user\.displayName\s*\?\?\s*null/); + }); +}); diff --git a/src/components/settings/account-section/avatar-section.tsx b/src/components/settings/account-section/avatar-section.tsx index 583fb2ff9..bff3eb9e5 100644 --- a/src/components/settings/account-section/avatar-section.tsx +++ b/src/components/settings/account-section/avatar-section.tsx @@ -154,6 +154,7 @@ export function AvatarSection() { {avatarUrl && ( } diff --git a/src/components/settings/account-section/index.tsx b/src/components/settings/account-section/index.tsx index 860586e40..5ad991a0f 100644 --- a/src/components/settings/account-section/index.tsx +++ b/src/components/settings/account-section/index.tsx @@ -1,5 +1,6 @@ "use client"; +import { DISPLAY_NAME_MAX_LENGTH } from "@/lib/validations/display-name"; import { useCallback, useEffect, useState } from "react"; import { useRouter } from "next/navigation"; import { Languages, Loader2, Save, User } from "lucide-react"; @@ -95,12 +96,16 @@ export function AccountSection() { const [dateOfBirth, setDateOfBirth] = useState(""); const [gender, setGender] = useState(""); const [timezone, setTimezone] = useState(DEFAULT_TIMEZONE); + // The name the app addresses the account by; set in the setup flow, and + // until now not editable anywhere after it. + const [displayName, setDisplayName] = useState(""); // v1.7.0 — optional patient-identity fields for the health-record export. const [fullName, setFullName] = useState(""); const [insurerName, setInsurerName] = useState(""); const [insuranceNumber, setInsuranceNumber] = useState(""); const [profileSeed, setProfileSeed] = useState({ email: "", + displayName: "", height: EMPTY_HEIGHT_DRAFT, dateOfBirth: "", gender: "", @@ -149,6 +154,7 @@ export function AccountSection() { if (user && user.id !== seededUserId) { const seed = { email: user.email ?? "", + displayName: user.displayName ?? "", height: heightAdapter.toDraft(user.heightCm), dateOfBirth: user.dateOfBirth ? dateOnlyKey(new Date(user.dateOfBirth)) @@ -161,6 +167,7 @@ export function AccountSection() { }; setSeededUserId(user.id); setEmail(seed.email); + setDisplayName(seed.displayName); setHeight(seed.height); setDateOfBirth(seed.dateOfBirth); setGender(seed.gender); @@ -192,6 +199,7 @@ export function AccountSection() { seed: profileSeed, value: { email, + displayName, height, dateOfBirth, gender, @@ -253,132 +261,148 @@ export function AccountSection() { setSaveMsg(null); setSaveMsgType(null); setFieldErrors({}); - const savedProfile = { - email: email.trim(), - height, - dateOfBirth, - gender, - timezone, - fullName: fullName.trim(), - insurerName: insurerName.trim(), - insuranceNumber: insuranceNumber.trim(), - }; + try { + const savedProfile = { + email: email.trim(), + displayName: displayName.trim(), + height, + dateOfBirth, + gender, + timezone, + fullName: fullName.trim(), + insurerName: insurerName.trim(), + insuranceNumber: insuranceNumber.trim(), + }; - // The timezone is owned by a dedicated route (v1.4.25 W7) so the - // resolver cache can be invalidated without piping the flag - // through the bigger profile patch path. Run the two PUTs in - // parallel — they're independent. - const [profileRes, tzRes] = await Promise.all([ - apiFetchRaw("/api/auth/profile", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - email: savedProfile.email || null, - ...(emailChanged && emailPassword - ? { currentPassword: emailPassword } - : {}), - heightCm: heightAdapter.toCanonicalCm(savedProfile.height), - dateOfBirth: savedProfile.dateOfBirth || null, - gender: savedProfile.gender || null, - fullName: savedProfile.fullName || null, - insurerName: savedProfile.insurerName || null, - insuranceNumber: savedProfile.insuranceNumber || null, + // The timezone is owned by a dedicated route (v1.4.25 W7) so the + // resolver cache can be invalidated without piping the flag + // through the bigger profile patch path. Run the two PUTs in + // parallel — they're independent. + const [profileRes, tzRes] = await Promise.all([ + apiFetchRaw("/api/auth/profile", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + email: savedProfile.email || null, + // An emptied field clears the name; the greeting falls back to + // the username, as it does for an account that never set one. + displayName: savedProfile.displayName || null, + ...(emailChanged && emailPassword + ? { currentPassword: emailPassword } + : {}), + heightCm: heightAdapter.toCanonicalCm(savedProfile.height), + dateOfBirth: savedProfile.dateOfBirth || null, + gender: savedProfile.gender || null, + fullName: savedProfile.fullName || null, + insurerName: savedProfile.insurerName || null, + insuranceNumber: savedProfile.insuranceNumber || null, + }), }), - }), - user && timezone && timezone !== user.timezone - ? apiFetchRaw("/api/auth/me/timezone", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ timezone }), - }) - : Promise.resolve({ ok: true } as Response), - ]); + user && timezone && timezone !== user.timezone + ? apiFetchRaw("/api/auth/me/timezone", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ timezone }), + }) + : Promise.resolve({ ok: true } as Response), + ]); - setEmailPassword(""); + setEmailPassword(""); - if (profileRes.ok && tzRes.ok) { - // Instant same-tab flip of every rendered timestamp (issue #490): - // mirror the accepted zone before the `/me` refetch confirms it — - // the same synchronous-update pattern the time/date-format selects - // use. `refetch()` → `fetchMe` re-writes the mirror authoritatively. - if (user && timezone && timezone !== user.timezone) { - storeTimezone(timezone); - } - const profileJson = (await profileRes.json().catch(() => null)) as { - data?: { rejectedFields?: RejectedProfileField[] }; - } | null; - const rejected = profileJson?.data?.rejectedFields; + if (profileRes.ok && tzRes.ok) { + // Instant same-tab flip of every rendered timestamp (issue #490): + // mirror the accepted zone before the `/me` refetch confirms it — + // the same synchronous-update pattern the time/date-format selects + // use. `refetch()` → `fetchMe` re-writes the mirror authoritatively. + if (user && timezone && timezone !== user.timezone) { + storeTimezone(timezone); + } + const profileJson = (await profileRes.json().catch(() => null)) as { + data?: { rejectedFields?: RejectedProfileField[] }; + } | null; + const rejected = profileJson?.data?.rejectedFields; - if (!afterFactor && needsSecondFactor(rejected, undefined)) { - if (await proveFactorThenSave()) return; - } + if (!afterFactor && needsSecondFactor(rejected, undefined)) { + if (await proveFactorThenSave()) return; + } - if (rejected && rejected.length > 0) { - // The write is field-independent: everything but the rejected - // field(s) landed. Name the field instead of implying full - // success, and reseed the form from what the server actually - // persisted — the client's optimistic value for the rejected - // field was never written, so it must not read as saved. - setSaveMsg({ - key: "settings.profilePartiallySaved", - params: { field: describeRejectedProfileField(rejected, t) ?? "" }, - }); - setSaveMsgType("warning"); - setFieldErrors(describeRejectedProfileFields(rejected, t)); - await refetch(); - setSeededUserId(null); + if (rejected && rejected.length > 0) { + // The write is field-independent: everything but the rejected + // field(s) landed. Name the field instead of implying full + // success, and reseed the form from what the server actually + // persisted — the client's optimistic value for the rejected + // field was never written, so it must not read as saved. + setSaveMsg({ + key: "settings.profilePartiallySaved", + params: { field: describeRejectedProfileField(rejected, t) ?? "" }, + }); + setSaveMsgType("warning"); + setFieldErrors(describeRejectedProfileFields(rejected, t)); + await refetch(); + setSeededUserId(null); + } else { + setSaveMsg({ key: "settings.profileSaved" }); + setSaveMsgType("success"); + setEmail(savedProfile.email); + setDisplayName(savedProfile.displayName); + setFullName(savedProfile.fullName); + setInsurerName(savedProfile.insurerName); + setInsuranceNumber(savedProfile.insuranceNumber); + setProfileSeed(savedProfile); + await refetch(); + } + } else if (!tzRes.ok) { + // The dedicated tz endpoint owns the IANA validation error + // text. Surface its message verbatim so the user sees + // "Not a valid IANA timezone." instead of the generic save + // failure copy. + try { + const json = (await (tzRes as Response).json()) as { error?: string }; + setSaveMsg( + json.error + ? { text: json.error } + : { key: "settings.timezoneInvalid" }, + ); + } catch { + setSaveMsg({ key: "settings.timezoneInvalid" }); + } + setSaveMsgType("error"); } else { - setSaveMsg({ key: "settings.profileSaved" }); - setSaveMsgType("success"); - setEmail(savedProfile.email); - setFullName(savedProfile.fullName); - setInsurerName(savedProfile.insurerName); - setInsuranceNumber(savedProfile.insuranceNumber); - setProfileSeed(savedProfile); - await refetch(); - } - } else if (!tzRes.ok) { - // The dedicated tz endpoint owns the IANA validation error - // text. Surface its message verbatim so the user sees - // "Not a valid IANA timezone." instead of the generic save - // failure copy. - try { - const json = (await (tzRes as Response).json()) as { error?: string }; - setSaveMsg( - json.error - ? { text: json.error } - : { key: "settings.timezoneInvalid" }, - ); - } catch { - setSaveMsg({ key: "settings.timezoneInvalid" }); - } - setSaveMsgType("error"); - } else { - // The server's top-level `error` string is intentionally generic - // (never a raw validator message); resolve the specific, localized - // reason through `meta.errorCode` instead of echoing it verbatim. - const json = (await profileRes.json().catch(() => null)) as { - meta?: { errorCode?: string }; - details?: { issues?: RejectedProfileField[] }; - } | null; - const errorCode = json?.meta?.errorCode; - if (!afterFactor && needsSecondFactor(undefined, errorCode)) { - if (await proveFactorThenSave()) return; - } - const field = describeRejectedProfileField(json?.details?.issues, t); - setFieldErrors(describeRejectedProfileFields(json?.details?.issues, t)); - if (errorCode === "profile.update.nothingSaved" && field) { - setSaveMsg({ key: "settings.profileNothingSaved", params: { field } }); - } else if (translateCodeOrNull(t, "apiErrors", errorCode) !== null) { - // Only a code with a sentence of its own: every refusal carries a - // code now, and the generic `http.*` family has no translation. - setSaveMsg({ key: `apiErrors.${errorCode}` }); - } else { - setSaveMsg({ key: "settings.savingError" }); + // The server's top-level `error` string is intentionally generic + // (never a raw validator message); resolve the specific, localized + // reason through `meta.errorCode` instead of echoing it verbatim. + const json = (await profileRes.json().catch(() => null)) as { + meta?: { errorCode?: string }; + details?: { issues?: RejectedProfileField[] }; + } | null; + const errorCode = json?.meta?.errorCode; + if (!afterFactor && needsSecondFactor(undefined, errorCode)) { + if (await proveFactorThenSave()) return; + } + const field = describeRejectedProfileField(json?.details?.issues, t); + setFieldErrors(describeRejectedProfileFields(json?.details?.issues, t)); + if (errorCode === "profile.update.nothingSaved" && field) { + setSaveMsg({ + key: "settings.profileNothingSaved", + params: { field }, + }); + } else if (translateCodeOrNull(t, "apiErrors", errorCode) !== null) { + // Only a code with a sentence of its own: every refusal carries a + // code now, and the generic `http.*` family has no translation. + setSaveMsg({ key: `apiErrors.${errorCode}` }); + } else { + setSaveMsg({ key: "settings.savingError" }); + } + setSaveMsgType("error"); } + } catch { + // The request never reached the server (offline, a dropped + // connection): a failed save, said as one, never a card left saving. + setSaveMsg({ key: "settings.savingError" }); setSaveMsgType("error"); + } finally { + setSaving(false); } - setSaving(false); } if (!mounted || isLoading) { @@ -438,45 +462,81 @@ export function AccountSection() { autoComplete="username" />
+ {/* The name the greeting and the reports use. The setup flow + asks for it once; this is where it changes afterwards. */}
- + { - setEmail(e.target.value); - clearFieldError("email"); + setDisplayName(e.target.value); + clearFieldError("displayName"); }} - placeholder={t("auth.emailPlaceholder")} - maxLength={320} - autoComplete="email" + placeholder={t("onboarding.baseline.displayNamePlaceholder")} + maxLength={DISPLAY_NAME_MAX_LENGTH} + autoComplete="nickname" enterKeyHint="next" - aria-invalid={fieldErrors.email ? true : undefined} - aria-describedby={fieldErrors.email ? "email-error" : undefined} + aria-invalid={fieldErrors.displayName ? true : undefined} + aria-describedby={ + fieldErrors.displayName + ? "display-name-hint display-name-error" + : "display-name-hint" + } + /> +

+ {t("onboarding.baseline.displayNameHint")} +

+ - - {emailChanged && !emailNeedsFactor && ( -
- - { - setEmailPassword(e.target.value); - clearFieldError("email"); - }} - autoComplete="current-password" - maxLength={512} - /> -
- )}
+
+ + { + setEmail(e.target.value); + clearFieldError("email"); + }} + placeholder={t("auth.emailPlaceholder")} + maxLength={320} + autoComplete="email" + enterKeyHint="next" + aria-invalid={fieldErrors.email ? true : undefined} + aria-describedby={fieldErrors.email ? "email-error" : undefined} + /> + + {emailChanged && !emailNeedsFactor && ( +
+ + { + setEmailPassword(e.target.value); + clearFieldError("email"); + }} + autoComplete="current-password" + maxLength={512} + /> +
+ )} +
+
diff --git a/src/components/settings/ai/__tests__/remove-saved-key.test.tsx b/src/components/settings/ai/__tests__/remove-saved-key.test.tsx new file mode 100644 index 000000000..bda1376bb --- /dev/null +++ b/src/components/settings/ai/__tests__/remove-saved-key.test.tsx @@ -0,0 +1,121 @@ +/** + * A saved API key can be removed from every provider form that holds one. + * + * The forms showed "saved" beside the key field and nothing to take the key + * back: an empty field on Save keeps the stored key by design, so a key once + * entered stayed. The remove control renders on each of the four personal-key + * forms exactly when that form's key is stored, and the body it sends clears + * that one key and nothing beside it. The route's side (the column is cleared, + * the other keys and the provider choice survive) is pinned against Postgres + * in `tests/integration/ai-provider-key-removal.test.ts`. + */ +import { describe, expect, it } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { ComponentType } from "react"; + +import { I18nProvider } from "@/lib/i18n/context"; +import { aiProviderPatchSchema } from "@/lib/validations/ai-provider"; + +import { AnthropicProviderForm } from "../anthropic-provider-form"; +import { CompatProviderForm } from "../compat-provider-form"; +import { LocalProviderForm } from "../local-provider-form"; +import { OpenAIProviderForm } from "../openai-provider-form"; +import { + removeSavedKeyBody, + type SavedKeyField, +} from "../remove-saved-key-button"; +import type { UserAIProvider } from "../shared"; + +function provider(overrides: Partial = {}): UserAIProvider { + return { + provider: null, + model: null, + baseUrl: null, + hasAnthropicKey: false, + anthropicKeyPreview: null, + hasLocalKey: false, + hasOpenaiKey: false, + openaiKeyPreview: null, + compatBaseUrl: null, + compatModel: null, + hasCompatKey: false, + responseTimeoutSeconds: null, + ...overrides, + }; +} + +type Form = ComponentType<{ userProvider: UserAIProvider | null | undefined }>; + +const FORMS: { + name: string; + Form: Form; + field: SavedKeyField; + saved: Partial; +}[] = [ + { + name: "OpenAI", + Form: OpenAIProviderForm, + field: "openaiKey", + saved: { hasOpenaiKey: true, openaiKeyPreview: "...abcd" }, + }, + { + name: "Anthropic", + Form: AnthropicProviderForm, + field: "anthropicKey", + saved: { hasAnthropicKey: true, anthropicKeyPreview: "...abcd" }, + }, + { + name: "Local", + Form: LocalProviderForm, + field: "localKey", + saved: { hasLocalKey: true }, + }, + { + name: "OpenAI-compatible gateway", + Form: CompatProviderForm, + field: "compatKey", + saved: { hasCompatKey: true }, + }, +]; + +function render(Form: Form, userProvider: UserAIProvider) { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return renderToStaticMarkup( + + +
+ + , + ); +} + +describe("remove a saved API key", () => { + for (const { name, Form, field, saved } of FORMS) { + describe(name, () => { + const slot = `data-slot="settings-ai-remove-key-${field}"`; + + it("offers the remove control while a key is stored", () => { + const html = render(Form, provider(saved)); + expect(html).toContain(slot); + expect(html).toContain("Remove API key"); + }); + + it("offers nothing to remove when no key is stored", () => { + expect(render(Form, provider())).not.toContain(slot); + }); + + it("sends a body that clears that key and nothing else", () => { + const body = removeSavedKeyBody(field); + expect(body).toEqual({ [field]: null }); + // The route reads the body through this schema; a field it did not + // know would be dropped and the request answered "No valid fields". + const parsed = aiProviderPatchSchema.safeParse(body); + expect(parsed.success).toBe(true); + expect(parsed.data).toEqual({ [field]: null }); + }); + }); + } +}); diff --git a/src/components/settings/ai/anthropic-provider-form.tsx b/src/components/settings/ai/anthropic-provider-form.tsx index 33ded234f..7aa31d06b 100644 --- a/src/components/settings/ai/anthropic-provider-form.tsx +++ b/src/components/settings/ai/anthropic-provider-form.tsx @@ -18,6 +18,8 @@ import { apiPatch } from "@/lib/api/api-fetch"; import { useTranslations } from "@/lib/i18n/context"; import { aiInputDependentKeys, invalidateKeys } from "@/lib/query-keys"; +import { RemoveSavedKeyButton } from "./remove-saved-key-button"; + import { ANTHROPIC_MODEL_PRESETS, CUSTOM_MODEL_SENTINEL, @@ -169,6 +171,9 @@ export function AnthropicProviderForm({ )} + {userProvider?.hasAnthropicKey && ( + + )} @@ -718,7 +718,7 @@ function ApiTokensCard() { variant="destructive" onClick={() => handleRevoke(tok.id)} > - {t("settings.tokenRevoked")} + {t("settings.tokenRevokeAction")} diff --git a/src/components/settings/coach-memory-section.tsx b/src/components/settings/coach-memory-section.tsx index c5e34a9e2..c5fa86302 100644 --- a/src/components/settings/coach-memory-section.tsx +++ b/src/components/settings/coach-memory-section.tsx @@ -228,6 +228,7 @@ function FactRow({ accessible name and the confirmation carry the words. */} null); - if (res.ok) { - if (toast) { - setMsg(toast); - setMsgType("success"); + if (res?.ok) { + if (toast) { + setMsg(toast); + setMsgType("success"); + scheduleClear(); + } + await queryClient.invalidateQueries({ + queryKey: queryKeys.authNotificationPrefs(), + }); + setOptimistic({}); + } else if (res?.status === 409) { + // The prefs advanced since they were loaded. Revert the optimistic knob + // and refetch (the active query reseeds a fresh token), then nudge — the + // change did not persist, so ask the user to re-apply it. + setOptimistic({}); + await queryClient.invalidateQueries({ + queryKey: queryKeys.authNotificationPrefs(), + }); + setMsg(t("common.conflictReloaded")); + setMsgType("error"); + scheduleClear(); + } else { + setOptimistic({}); + setMsg(t("notifications.coachNudge.saveError")); + setMsgType("error"); scheduleClear(); } - await queryClient.invalidateQueries({ - queryKey: queryKeys.authNotificationPrefs(), - }); - setOptimistic({}); - } else if (res.status === 409) { - // The prefs advanced since they were loaded. Revert the optimistic knob - // and refetch (the active query reseeds a fresh token), then nudge — the - // change did not persist, so ask the user to re-apply it. - setOptimistic({}); - await queryClient.invalidateQueries({ - queryKey: queryKeys.authNotificationPrefs(), - }); - setMsg(t("common.conflictReloaded")); - setMsgType("error"); - scheduleClear(); - } else { - setOptimistic({}); - setMsg(t("notifications.coachNudge.saveError")); - setMsgType("error"); - scheduleClear(); + } finally { + setSaving(false); } - setSaving(false); } async function handleToggle(next: boolean) { diff --git a/src/components/settings/coach-reminders-section.tsx b/src/components/settings/coach-reminders-section.tsx index 3c040e3ba..1da7a16e4 100644 --- a/src/components/settings/coach-reminders-section.tsx +++ b/src/components/settings/coach-reminders-section.tsx @@ -147,6 +147,7 @@ export function CoachRemindersSection({ )} + showErrorToast(t("settings.savingError"), { cause: failure }), }); function toggle(site: InjectionSiteKey, checked: boolean) { diff --git a/src/components/settings/integrations/__tests__/connections-panel-coverage.test.tsx b/src/components/settings/integrations/__tests__/connections-panel-coverage.test.tsx index 0c3e448a6..26b828490 100644 --- a/src/components/settings/integrations/__tests__/connections-panel-coverage.test.tsx +++ b/src/components/settings/integrations/__tests__/connections-panel-coverage.test.tsx @@ -181,3 +181,38 @@ describe("ConnectionsPanel — every envelope entry surfaces", () => { expect(render()).not.toContain('data-testid="integration-fallback-row"'); }); }); + +describe("ConnectionsPanel — the Fitbit card after the turndown", () => { + const withFitbit = (retirement: Record) => ({ + threshold: 3, + integrations: fullEnvelope().integrations.map((e) => + e.integration === "fitbit" + ? { ...e, connected: false, configured: false, retirement } + : e, + ), + }); + + it("shows no Fitbit card when the server does not offer it", () => { + envelope = withFitbit({ + endsOn: "2026-10-30", + ended: true, + successor: "google-health", + offered: false, + }); + const html = render(); + expect(html).not.toContain('id="fitbit"'); + expect(html).not.toContain('data-testid="fitbit-retirement-notice"'); + }); + + it("keeps the card and its notice for an account that had Fitbit", () => { + envelope = withFitbit({ + endsOn: "2026-10-30", + ended: true, + successor: "google-health", + offered: true, + }); + const html = render(); + expect(html).toContain('id="fitbit"'); + expect(html).toContain('data-testid="fitbit-retirement-notice"'); + }); +}); diff --git a/src/components/settings/integrations/__tests__/fitbit-retirement-notice.test.tsx b/src/components/settings/integrations/__tests__/fitbit-retirement-notice.test.tsx new file mode 100644 index 000000000..21fd77d23 --- /dev/null +++ b/src/components/settings/integrations/__tests__/fitbit-retirement-notice.test.tsx @@ -0,0 +1,47 @@ +/** + * The Fitbit card's notice about the end of the legacy Web API: before the + * day it says when the connection stops, from the day on that it has, and + * both times it points at Google Health and says the data stays. + */ +import { describe, expect, it } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; +import { FitbitRetirementNotice } from "../fitbit-retirement-notice"; + +function render(ended: boolean | null) { + return renderToStaticMarkup( + + + , + ); +} + +describe("", () => { + it("names the day the connection stops, and the way on", () => { + const html = render(false); + expect(html).toContain('data-ended="false"'); + expect(html).toContain("Google turns the Fitbit Web API off on"); + expect(html).toContain("Google Health reads the same Fitbit and Pixel"); + expect(html).toContain("Everything already imported stays."); + expect(html).toContain('href="#google-health"'); + expect(html).toContain("/google-health"); + }); + + it("says the connection has stopped once the day has come", () => { + const html = render(true); + expect(html).toContain('data-ended="true"'); + expect(html).toContain("Google turned the Fitbit Web API off on"); + expect(html).not.toContain("Google turns the Fitbit Web API off on"); + }); + + it("renders nothing without a retirement on the status entry", () => { + expect(render(null)).toBe(""); + }); +}); diff --git a/src/components/settings/integrations/connections-panel.tsx b/src/components/settings/integrations/connections-panel.tsx index bb4fa54cd..b79f1e442 100644 --- a/src/components/settings/integrations/connections-panel.tsx +++ b/src/components/settings/integrations/connections-panel.tsx @@ -19,6 +19,7 @@ import { showErrorToast } from "@/lib/errors/error-toast"; import { AppleHealthCard } from "@/components/settings/integrations/apple-health-card"; import { DawarichCard } from "@/components/settings/integrations/dawarich-card"; +import { FitbitRetirementNotice } from "@/components/settings/integrations/fitbit-retirement-notice"; import { GarminInfoNote } from "@/components/settings/integrations/garmin-info-note"; import { GoogleHealthCard } from "@/components/settings/integrations/google-health-card"; import { IntegrationFallbackRow } from "@/components/settings/integrations/integration-fallback-row"; @@ -136,6 +137,9 @@ const PROVIDER_ONLY_REASONS: Partial< Record> > = { oura: new Set(["issuer"]), + // Fitbit's `retired`: Google has turned the Web API off, so the connect + // route refuses a new grant. + fitbit: new Set(["retired"]), }; type OAuthOutcome = @@ -216,6 +220,8 @@ export function ConnectionsPanel({ const queryClient = useQueryClient(); const { data: integrationStatus } = useIntegrationStatuses(isAuthenticated); + const fitbitStatus = pickStatus(integrationStatus, "fitbit"); + const fitbitRetired = fitbitStatus?.retirement?.ended === true; // OAuth callback handler — reads `?withings=connected|error&reason=…` // from the URL (lazy initialiser, same shape as the Codex handler in @@ -331,25 +337,44 @@ export function ConnectionsPanel({ callbackUrl={callbackUrls.whoop} />
-
- - {t("settings.fitbitTag")} - - {t("settings.fitbitExperimentalBadge")} - - - } - /> -
+ {/* After the turndown the card is offered only to an account that had + Fitbit; the server decides (`retirement.offered`). */} + {fitbitStatus?.retirement?.offered !== false && ( +
+ + + {t("settings.fitbitTag")} + {fitbitStatus?.retirement ? ( + + {fitbitRetired + ? t("settings.fitbitRetirement.badgeEnded") + : t("settings.fitbitRetirement.badgeEnding")} + + ) : ( + + {t("settings.fitbitExperimentalBadge")} + + )} + + } + /> +
+ )}
+

+ {retirement.ended + ? t("settings.fitbitRetirement.ended", { date }) + : t("settings.fitbitRetirement.ending", { date })} +

+

{t("settings.fitbitRetirement.move")}

+ +
+ ); +} diff --git a/src/components/settings/integrations/oauth-integration-card.tsx b/src/components/settings/integrations/oauth-integration-card.tsx index 9af68d69e..ae4636334 100644 --- a/src/components/settings/integrations/oauth-integration-card.tsx +++ b/src/components/settings/integrations/oauth-integration-card.tsx @@ -15,6 +15,7 @@ import { useState, type ReactNode } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { toastWrittenOutcome } from "@/components/outcome/outcome-toast"; import { Activity, ArrowRight, @@ -34,6 +35,7 @@ import { import { Button } from "@/components/ui/button"; import { apiPost } from "@/lib/api/api-fetch"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { useTranslations } from "@/lib/i18n/context"; import type { IntegrationCallbackUrls } from "@/lib/integrations/callback-urls"; import { loadDocument } from "@/lib/navigation/load-document"; @@ -220,6 +222,10 @@ export function OAuthIntegrationCard(props: { callbackUrl: IntegrationCallbackUrls[OAuthIntegrationKey]; icon?: LucideIcon; titleAccessory?: ReactNode; + /** The provider cannot sync any more (Fitbit after its API turndown). */ + syncDisabled?: boolean; + /** …and cannot be connected either. */ + connectDisabled?: boolean; }) { const { config } = props; const sync = useStandardSync({ @@ -229,7 +235,13 @@ export function OAuthIntegrationCard(props: { fullSyncResult: config.fullSyncResult, statusQueryKey: config.statusQueryKey(), }); - return ; + return ( + + ); } /** @@ -248,6 +260,7 @@ export function OAuthIntegrationCardView({ fullSyncDisabled, syncResults, details, + connectDisabled, }: { config: OAuthIntegrationConfig; viewModel: IntegrationStatusViewModel | undefined; @@ -263,6 +276,8 @@ export function OAuthIntegrationCardView({ syncResults?: ReactNode; /** Provider extras for the details section. */ details?: ReactNode; + /** Greys out "Connect" whatever the credentials say. */ + connectDisabled?: boolean; }) { const { t } = useTranslations(); const queryClient = useQueryClient(); @@ -298,7 +313,20 @@ export function OAuthIntegrationCardView({ mutationFn: async () => { await apiPost(`/api/${provider}/disconnect`); }, - onSuccess: invalidateStatus, + onSuccess: () => { + invalidateStatus(); + toastWrittenOutcome( + "success", + t("settings.integrationDisconnected", { provider: providerName }), + ); + }, + // The confirmation dialog has closed by now; without this a refused + // disconnect left the card connected and said nothing. + onError: (failure) => + showErrorToast( + t("settings.integrationDisconnectFailed", { provider: providerName }), + { cause: failure }, + ), }); const resume = useMutation({ @@ -374,7 +402,7 @@ export function OAuthIntegrationCardView({ type="button" size="sm" className="min-h-11 w-full sm:min-h-9 sm:w-auto" - disabled={!configured} + disabled={!configured || connectDisabled} onClick={connect} data-slot="integration-primary-action" data-action="connect" diff --git a/src/components/settings/integrations/shared.tsx b/src/components/settings/integrations/shared.tsx index c5c558ef3..ae125c1d1 100644 --- a/src/components/settings/integrations/shared.tsx +++ b/src/components/settings/integrations/shared.tsx @@ -14,6 +14,7 @@ import type { GoogleHealthBackfillEndedEntry } from "@/lib/google-health/backfil import type { ConnectionHealth } from "@/lib/integrations/connection-health"; import type { MetricFreshnessEntry } from "@/lib/integrations/sync-verdict"; import { queryKeys } from "@/lib/query-keys"; +import type { PublishedFitbitRetirement } from "@/lib/fitbit/retirement"; export type IntegrationKey = | "withings" @@ -71,6 +72,8 @@ export interface IntegrationStatusViewModel { hasToken?: boolean; /** Per-metric last-seen timestamps with the server-computed stale flag. */ metricFreshness?: MetricFreshnessEntry[]; + /** Fitbit: the end of the legacy Web API, resolved on the server. */ + retirement?: PublishedFitbitRetirement; } export interface IntegrationStatusEnvelope { diff --git a/src/components/settings/low-stock-card.tsx b/src/components/settings/low-stock-card.tsx index caa967fb7..7619c6dea 100644 --- a/src/components/settings/low-stock-card.tsx +++ b/src/components/settings/low-stock-card.tsx @@ -124,44 +124,52 @@ export function LowStockCard({ // v1.32.22 — echo the base token so a concurrent notification-prefs // write (the iOS client, another web card) 409s instead of reverting a // sibling category through the server-side deep-merge. - const res = await apiFetchRaw("/api/auth/me/notification-prefs", { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify( - withBaseToken( - body, - readUpdatedAtToken(queryClient, queryKeys.authNotificationPrefs()), + let saved = false; + try { + // A request that never reaches the server (offline, a dropped + // connection) is a failed save like any other, not a stuck one: it + // falls through to the error branch, and `finally` lowers the flag. + const res = await apiFetchRaw("/api/auth/me/notification-prefs", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify( + withBaseToken( + body, + readUpdatedAtToken(queryClient, queryKeys.authNotificationPrefs()), + ), ), - ), - }); + }).catch(() => null); - if (res.ok) { - setMsg(successMsg); - setMsgType("success"); - await Promise.all([ - queryClient.invalidateQueries({ + saved = res?.ok === true; + if (res?.ok) { + setMsg(successMsg); + setMsgType("success"); + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: queryKeys.authNotificationPrefs(), + }), + queryClient.invalidateQueries({ + queryKey: queryKeys.settingsReminderThresholds(), + }), + ]); + } else if (res?.status === 409) { + // The prefs advanced since they were loaded. Refetch (the active query + // reseeds a fresh token) and nudge — the caller reverts its optimistic + // value, so the card snaps back to server truth for a re-apply. + await queryClient.invalidateQueries({ queryKey: queryKeys.authNotificationPrefs(), - }), - queryClient.invalidateQueries({ - queryKey: queryKeys.settingsReminderThresholds(), - }), - ]); - } else if (res.status === 409) { - // The prefs advanced since they were loaded. Refetch (the active query - // reseeds a fresh token) and nudge — the caller reverts its optimistic - // value, so the card snaps back to server truth for a re-apply. - await queryClient.invalidateQueries({ - queryKey: queryKeys.authNotificationPrefs(), - }); - setMsg(t("common.conflictReloaded")); - setMsgType("error"); - } else { - setMsg(t("notifications.lowStock.saveError")); - setMsgType("error"); + }); + setMsg(t("common.conflictReloaded")); + setMsgType("error"); + } else { + setMsg(t("notifications.lowStock.saveError")); + setMsgType("error"); + } + scheduleClear(); + } finally { + setSaving(false); } - scheduleClear(); - setSaving(false); - return res.ok; + return saved; } async function persist(next: number | null, successMsg: string) { diff --git a/src/components/settings/mcp-section.tsx b/src/components/settings/mcp-section.tsx index bc21fcb4f..6b448a5d6 100644 --- a/src/components/settings/mcp-section.tsx +++ b/src/components/settings/mcp-section.tsx @@ -516,7 +516,7 @@ function McpTokensCard() { className="min-h-11 w-full" > - {t("settings.tokenRevoke")} + {t("settings.tokenRevokeAction")} @@ -536,7 +536,7 @@ function McpTokensCard() { variant="destructive" onClick={() => handleRevoke(tok.id)} > - {t("settings.tokenRevoked")} + {t("settings.tokenRevokeAction")} diff --git a/src/components/settings/mood-reminder-card.tsx b/src/components/settings/mood-reminder-card.tsx index 4b1f65528..04a9844fd 100644 --- a/src/components/settings/mood-reminder-card.tsx +++ b/src/components/settings/mood-reminder-card.tsx @@ -99,31 +99,44 @@ export function MoodReminderCard({ clearTimerRef.current = null; } - const res = await apiFetchRaw("/api/user/profile", { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ moodReminderEnabled: next }), - }); + try { + // A request that never reaches the server (offline, a dropped + // connection) is a failed save like any other, not a stuck one: it + // falls through to the error branch, and `finally` lowers the flag. + const res = await apiFetchRaw("/api/user/profile", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ moodReminderEnabled: next }), + }).catch(() => null); - if (res.ok) { - setMsg( - next - ? t("notifications.moodReminder.enabledToast") - : t("notifications.moodReminder.disabledToast"), - ); - setMsgType("success"); - await queryClient.invalidateQueries({ - queryKey: queryKeys.userProfile(), - }); - setOptimistic(null); - scheduleClear(); - } else { - setOptimistic(null); - setMsg(t("notifications.moodReminder.saveError")); - setMsgType("error"); - scheduleClear(); + if (res?.ok) { + setMsg( + next + ? t("notifications.moodReminder.enabledToast") + : t("notifications.moodReminder.disabledToast"), + ); + setMsgType("success"); + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: queryKeys.userProfile(), + }), + // The /notifications matrix shows the reminder's channel switches + // from the server's `eventDefaults`, which follow this card. + queryClient.invalidateQueries({ + queryKey: queryKeys.notificationsPreferences(), + }), + ]); + setOptimistic(null); + scheduleClear(); + } else { + setOptimistic(null); + setMsg(t("notifications.moodReminder.saveError")); + setMsgType("error"); + scheduleClear(); + } + } finally { + setSaving(false); } - setSaving(false); } async function handleHourChange(next: number) { @@ -139,43 +152,49 @@ export function MoodReminderCard({ // v1.32.22 — echo the base token so a concurrent notification-prefs // write (the iOS client's `clientManaged` flag, another web card) 409s // instead of silently reverting a sibling category through the deep-merge. - const res = await apiFetchRaw("/api/auth/me/notification-prefs", { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify( - withBaseToken( - { mood: { reminderHour: next } }, - readUpdatedAtToken(queryClient, queryKeys.authNotificationPrefs()), + try { + // A request that never reaches the server (offline, a dropped + // connection) is a failed save like any other, not a stuck one: it + // falls through to the error branch, and `finally` lowers the flag. + const res = await apiFetchRaw("/api/auth/me/notification-prefs", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify( + withBaseToken( + { mood: { reminderHour: next } }, + readUpdatedAtToken(queryClient, queryKeys.authNotificationPrefs()), + ), ), - ), - }); + }).catch(() => null); - if (res.ok) { - setMsg(t("notifications.moodReminder.hourSavedToast")); - setMsgType("success"); - await queryClient.invalidateQueries({ - queryKey: queryKeys.authNotificationPrefs(), - }); - setOptimisticHour(null); - scheduleClear(); - } else if (res.status === 409) { - // The prefs advanced since they were loaded. Revert the optimistic hour - // and refetch (the active query reseeds a fresh token), then nudge — the - // change did not persist, so ask the user to re-apply it. - setOptimisticHour(null); - await queryClient.invalidateQueries({ - queryKey: queryKeys.authNotificationPrefs(), - }); - setMsg(t("common.conflictReloaded")); - setMsgType("error"); - scheduleClear(); - } else { - setOptimisticHour(null); - setMsg(t("notifications.moodReminder.saveError")); - setMsgType("error"); - scheduleClear(); + if (res?.ok) { + setMsg(t("notifications.moodReminder.hourSavedToast")); + setMsgType("success"); + await queryClient.invalidateQueries({ + queryKey: queryKeys.authNotificationPrefs(), + }); + setOptimisticHour(null); + scheduleClear(); + } else if (res?.status === 409) { + // The prefs advanced since they were loaded. Revert the optimistic hour + // and refetch (the active query reseeds a fresh token), then nudge — the + // change did not persist, so ask the user to re-apply it. + setOptimisticHour(null); + await queryClient.invalidateQueries({ + queryKey: queryKeys.authNotificationPrefs(), + }); + setMsg(t("common.conflictReloaded")); + setMsgType("error"); + scheduleClear(); + } else { + setOptimisticHour(null); + setMsg(t("notifications.moodReminder.saveError")); + setMsgType("error"); + scheduleClear(); + } + } finally { + setSaving(false); } - setSaving(false); } return ( diff --git a/src/components/settings/notification-content-card.tsx b/src/components/settings/notification-content-card.tsx index 91dfc2f2b..f64b06256 100644 --- a/src/components/settings/notification-content-card.tsx +++ b/src/components/settings/notification-content-card.tsx @@ -69,37 +69,43 @@ export function NotificationContentCard({ setMsg(null); setMsgType(null); - const res = await apiFetchRaw("/api/auth/me/notification-prefs", { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify( - withBaseToken( - { content: { detail: next ? "detailed" : "generic" } }, - readUpdatedAtToken(queryClient, queryKeys.authNotificationPrefs()), + try { + // A request that never reaches the server (offline, a dropped + // connection) is a failed save like any other, not a stuck one: it + // falls through to the error branch, and `finally` lowers the flag. + const res = await apiFetchRaw("/api/auth/me/notification-prefs", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify( + withBaseToken( + { content: { detail: next ? "detailed" : "generic" } }, + readUpdatedAtToken(queryClient, queryKeys.authNotificationPrefs()), + ), ), - ), - }); + }).catch(() => null); - await queryClient.invalidateQueries({ - queryKey: queryKeys.authNotificationPrefs(), - }); - setOptimistic(null); - if (res.ok) { - setMsg( - next - ? t("notifications.content.detailedToast") - : t("notifications.content.genericToast"), - ); - setMsgType("success"); - } else if (res.status === 409) { - setMsg(t("common.conflictReloaded")); - setMsgType("error"); - } else { - setMsg(t("notifications.content.saveError")); - setMsgType("error"); + await queryClient.invalidateQueries({ + queryKey: queryKeys.authNotificationPrefs(), + }); + setOptimistic(null); + if (res?.ok) { + setMsg( + next + ? t("notifications.content.detailedToast") + : t("notifications.content.genericToast"), + ); + setMsgType("success"); + } else if (res?.status === 409) { + setMsg(t("common.conflictReloaded")); + setMsgType("error"); + } else { + setMsg(t("notifications.content.saveError")); + setMsgType("error"); + } + scheduleClear(); + } finally { + setSaving(false); } - scheduleClear(); - setSaving(false); } return ( diff --git a/src/components/settings/report-selection/__tests__/report-visit-offer.test.tsx b/src/components/settings/report-selection/__tests__/report-visit-offer.test.tsx index 2f555dcc5..d61fd0847 100644 --- a/src/components/settings/report-selection/__tests__/report-visit-offer.test.tsx +++ b/src/components/settings/report-selection/__tests__/report-visit-offer.test.tsx @@ -32,7 +32,7 @@ vi.mock("@/lib/api/api-fetch", async () => { import { I18nProvider } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import type { EncounterDTO } from "@/lib/encounters/dto"; -import { ReportVisitOffer } from "../report-visit-offer"; +import { ReportVisitOffer, visitReportWindow } from "../report-visit-offer"; const DAY_MS = 24 * 60 * 60 * 1000; @@ -67,7 +67,7 @@ function render(upcoming: EncounterDTO[], onUse = () => {}) { return renderToStaticMarkup( - + , ); @@ -108,4 +108,97 @@ describe("", () => { expect(html).toContain('data-slot="report-visit-offer-apply"'); expect(onUse).not.toHaveBeenCalled(); }); + + // The issue behind this: an internist a week ago, a dermatologist next. The + // start used to be whatever the panel held, typically the last visit of any + // kind; it is now the server's window before this visit, and says why. + it("names where the report would start, and why there", () => { + const html = render([ + visit({ + id: "derm", + preparation: { + from: "2026-03-02", + to: "2026-12-01", + basis: "specialty", + since: { + id: "old-derm", + occurredAt: "2026-03-02T09:00:00.000Z", + kind: "SPECIALIST", + practitionerName: "Former skin practice", + specialty: "Dermatology", + }, + }, + }), + ]); + expect(html).toContain('data-slot="report-visit-offer-window"'); + expect(html).toContain("Since your last Dermatology visit on"); + expect(html).toContain("Use this window"); + }); + + it("says why on the default window", () => { + const html = render([ + visit({ + id: "first", + preparation: { + from: "2026-07-23", + to: "2026-12-01", + basis: "default", + since: null, + }, + }), + ]); + expect(html).toContain("The last 90 days"); + expect(html).toContain( + "No earlier visit with the same practice, specialty or kind.", + ); + }); + + it("applies the server's start and the appointment's day on a press", () => { + const at = new Date(Date.now() + 5 * DAY_MS); + const window = visitReportWindow( + visit({ + id: "derm", + occurredAt: at.toISOString(), + preparation: { + from: "2026-03-02", + to: "2026-12-01", + basis: "default", + since: null, + }, + }), + "Europe/Berlin", + ); + expect(window.from).toBe("2026-03-02"); + expect(window.to).toMatch(/^\d{4}-\d{2}-\d{2}$/); + // Without a window from the server, only the end moves. + expect( + visitReportWindow(visit({ id: "plain" }), "Europe/Berlin").from, + ).toBeNull(); + }); + + it("ends on the record's day the server cut, not the reader's", () => { + // 04:30 on the 12th in Berlin is still the evening of the 11th in New + // York. A guardian in New York viewing a Berlin record gets the record's + // day: both ends come from the server, on the record's calendar. + const early = visit({ + id: "early", + occurredAt: "2026-11-12T03:30:00.000Z", + preparation: { + from: "2026-08-12", + to: "2026-11-12", + basis: "default", + since: null, + }, + }); + expect(visitReportWindow(early, "America/New_York")).toEqual({ + from: "2026-08-12", + to: "2026-11-12", + }); + // Without a preparation the zone handed in decides. + const plain = visit({ + id: "plain", + occurredAt: "2026-11-12T03:30:00.000Z", + }); + expect(visitReportWindow(plain, "America/New_York").to).toBe("2026-11-11"); + }); }); diff --git a/src/components/settings/report-selection/report-visit-offer.tsx b/src/components/settings/report-selection/report-visit-offer.tsx index d9f70ce5f..156a8d4d7 100644 --- a/src/components/settings/report-selection/report-visit-offer.tsx +++ b/src/components/settings/report-selection/report-visit-offer.tsx @@ -14,18 +14,27 @@ * Termin ansteht") and does nothing until pressed. A control that moves on its * own is the thing that makes an export panel untrustworthy. * - * Nothing about the stored selection changes: this sets the custom-range end - * date and nothing else. + * Pressing it sets both ends of the custom range: the end on the appointment's + * day, the start where the server put the window before that visit + * (`EncounterDTO.preparation`: the last visit with the same practitioner, else + * the same specialty, else the same kind, else the last 90 days). The offer + * says which, so the start date explains itself, and both dates stay editable. + * Nothing about the stored selection changes. */ import { CalendarClock, X } from "lucide-react"; import { useState } from "react"; import { encounterKindText } from "@/components/encounters/encounter-labels"; +import { preparationText } from "@/components/encounters/preparation-text"; import { Button } from "@/components/ui/button"; -import { useEncounters } from "@/hooks/use-encounters"; -import { useFormatters, useTranslations } from "@/lib/i18n/context"; +import { useEncounters, type Encounter } from "@/hooks/use-encounters"; +import { + useDisplayTimezone, + useFormatters, + useTranslations, +} from "@/lib/i18n/context"; import type { EncounterKind } from "@/generated/prisma/client"; -import { detectBrowserTimezone, userDayKey } from "@/lib/tz/format"; +import { userDayKey } from "@/lib/tz/format"; /** * How far ahead a booked visit is treated as the reason for this report. @@ -35,19 +44,42 @@ import { detectBrowserTimezone, userDayKey } from "@/lib/tz/format"; */ export const REPORT_VISIT_HORIZON_DAYS = 14; -/** The local `yyyy-MM-dd` a `DateField` takes, in the reader's own day. */ -function localDay(iso: string): string { - return userDayKey(new Date(iso), detectBrowserTimezone()); +/** The report window an appointment offers, as `yyyy-MM-dd` days. */ +export interface VisitReportWindow { + /** The server's start of the window before the visit; null if it has none. */ + from: string | null; + /** The appointment's own day. */ + to: string; +} + +/** + * What pressing the offer for `visit` applies. Both ends are days of the + * viewed record's own calendar, as the server cut them (`preparation.from` + * and `preparation.to`). Cutting the end in the browser's zone moved it a day + * against the start for a late-evening or early-morning visit, and a + * guardian viewing a managed profile would have cut it in their own zone. + * `timeZone` serves only a visit without a preparation. + */ +export function visitReportWindow( + visit: Encounter, + timeZone: string, +): VisitReportWindow { + return { + from: visit.preparation?.from ?? null, + to: + visit.preparation?.to ?? userDayKey(new Date(visit.occurredAt), timeZone), + }; } export function ReportVisitOffer({ - onUseVisitDate, + onUseVisitWindow, }: { - /** Sets the custom window's END date. Called only on an explicit press. */ - onUseVisitDate: (endDay: string) => void; + /** Sets the custom window. Called only on an explicit press. */ + onUseVisitWindow: (window: VisitReportWindow) => void; }) { const { t } = useTranslations(); const format = useFormatters(); + const timeZone = useDisplayTimezone(); const [dismissed, setDismissed] = useState(false); const visits = useEncounters(); @@ -68,6 +100,9 @@ export function ReportVisitOffer({ const what = next.practitioner?.name ?? encounterKindText(t, next.kind as EncounterKind); + const windowText = next.preparation + ? preparationText(t, format.dateShortSmartCalendar, next.preparation) + : null; return (
- - - {t("settings.healthRecord.visitOffer", { - what, - when: format.date(next.occurredAt), - })} - -

+
+ +
+

+ {t("settings.healthRecord.visitOffer", { + what, + when: format.date(next.occurredAt), + })} +

+ {/* Where the report would start, and why there: meta, so muted. */} + {windowText ? ( +

+ {windowText.title} +

+ ) : null} + {windowText?.reason ? ( +

{windowText.reason}

+ ) : null} +
+
- - ))} - -
- )} + + setKeptGrants((current) => current.filter((g) => g.id !== id)) + } + onRevokeFailed={() => + setStatus(t("recordSharing.actionError.revokeFailed")) + } + />
); } + +/** + * The grants "sign out everywhere" left standing, each with its "end access" + * button. The button asks the access card's question, whose disclosure names + * the audit window off the grant list; until that list has loaded the button + * is shown disabled rather than left out, and a failed read says so with a + * retry instead of leaving the row without its action. + */ +export function KeptGrantsList({ + grants, + grantList, + revokeGrant, + onRevoked, + onRevokeFailed, +}: { + grants: readonly KeptGrant[]; + grantList: { + data?: { retentionDays: number }; + isError: boolean; + error: unknown; + refetch: () => unknown; + }; + /** The revoke mutation; called only from the confirmation's action. */ + revokeGrant: { + mutate: ( + grantId: string, + callbacks: { onSuccess: () => void; onError: () => void }, + ) => void; + isPending: boolean; + }; + onRevoked: (grantId: string) => void; + onRevokeFailed: () => void; +}) { + const { t } = useTranslations(); + if (grants.length === 0) return null; + const retentionDays = grantList.data?.retentionDays; + return ( +
+

+ {t("settings.security.grantsKeptTitle")} +

+

+ {t("settings.security.grantsKeptBody")} +

+ {grantList.isError && ( + void grantList.refetch()} + /> + )} +
    + {grants.map((grant) => ( +
  • +
    +

    {accountLabel(grant.account)}

    +

    + {t(ACCESS_LABEL_KEY[grant.access])} +

    +
    + + revokeGrant.mutate(grant.id, { + onSuccess: () => onRevoked(grant.id), + onError: onRevokeFailed, + }) + } + /> +
  • + ))} +
+
+ ); +} diff --git a/src/components/settings/sharing-section.tsx b/src/components/settings/sharing-section.tsx index 7b3574d06..1c7b70340 100644 --- a/src/components/settings/sharing-section.tsx +++ b/src/components/settings/sharing-section.tsx @@ -23,6 +23,7 @@ */ import { useMemo, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { toastWrittenOutcome } from "@/components/outcome/outcome-toast"; import { FileText, KeyRound, Link2, List, Trash2 } from "lucide-react"; import { @@ -53,6 +54,7 @@ import { formatDate, formatDateTime } from "@/lib/format"; import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import { apiDelete, apiGet } from "@/lib/api/api-fetch"; +import { showErrorToast } from "@/lib/errors/error-toast"; export function SharingSection() { // v1.18.7 — the visible heading + subtitle come from the shared settings @@ -127,7 +129,12 @@ function ShareLinksCard() { mutationFn: (id: string) => apiDelete(`/api/share-links/${id}`), onSuccess: () => { queryClient.invalidateQueries({ queryKey: queryKeys.shareLinks() }); + toastWrittenOutcome("success", t("settings.sharing.revokedToast")); }, + // A refused revoke leaves the link working; that has to be said, since + // the dialog that asked has already closed. + onError: (failure) => + showErrorToast(t("settings.sharing.revokeFailed"), { cause: failure }), }); const activeLinks = useMemo( diff --git a/src/components/settings/telegram-card.tsx b/src/components/settings/telegram-card.tsx index a0870d6ff..3c685ce6b 100644 --- a/src/components/settings/telegram-card.tsx +++ b/src/components/settings/telegram-card.tsx @@ -116,23 +116,30 @@ export function TelegramCard({ if (botToken.trim()) body.botToken = botToken.trim(); if (chatId !== (settings?.chatId ?? "")) body.chatId = chatId; - const res = await apiFetchRaw("/api/settings/telegram", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body), - }); + try { + // A request that never reaches the server (offline, a dropped + // connection) is a failed save like any other, not a stuck one. + const res = await apiFetchRaw("/api/settings/telegram", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }).catch(() => null); - if (res.ok) { - setMsg(t("settings.saved")); - setMsgType("success"); - setBotToken(""); - queryClient.invalidateQueries({ queryKey: queryKeys.telegram() }); - } else { - const json = await res.json(); - setMsg(json.error || t("settings.savingError")); - setMsgType("error"); + if (res?.ok) { + setMsg(t("settings.saved")); + setMsgType("success"); + setBotToken(""); + queryClient.invalidateQueries({ queryKey: queryKeys.telegram() }); + } else { + const json = (await res?.json().catch(() => null)) as { + error?: string; + } | null; + setMsg(json?.error || t("settings.savingError")); + setMsgType("error"); + } + } finally { + setSaving(false); } - setSaving(false); } return ( diff --git a/src/components/settings/trusted-devices-card.tsx b/src/components/settings/trusted-devices-card.tsx index 20fa7b45f..70c1d074c 100644 --- a/src/components/settings/trusted-devices-card.tsx +++ b/src/components/settings/trusted-devices-card.tsx @@ -137,6 +137,7 @@ export function TrustedDevicesCard({