Merge pull request #1260 from MBombeck/release/v1.43.11

Release v1.43.11
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-10-21 17:54:10 +02:00
commit 45da8ae971
278 files changed
+13072 -1415

No files matched your search

+78
View File
@@ -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
+266 -31
View File
@@ -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:
+18 -8
View File
@@ -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
+6 -3
View File
@@ -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
+3 -1
View File
@@ -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", {
+54 -8
View File
@@ -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",
+54 -8
View File
@@ -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",
+54 -8
View File
@@ -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",
+54 -8
View File
@@ -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",
+54 -8
View File
@@ -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",
+54 -8
View File
@@ -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}개",
+54 -8
View File
@@ -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",
+54 -8
View File
@@ -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",
+1 -1
View File
@@ -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",
@@ -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$;
@@ -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;
+9
View File
@@ -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: { "<start>:<end>": { 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")
+1 -1
View File
@@ -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/*`
@@ -121,9 +121,9 @@ const EGRESS_SITES: Record<string, EgressSite> = {
"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,
@@ -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("<Confirm")) continue;
const sf = ts.createSourceFile(rel, text, ts.ScriptTarget.Latest, true);
const visit = (node: ts.Node) => {
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([]);
});
});
@@ -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",
@@ -98,6 +98,7 @@ const LEGIT_IDENTICAL = new Set<string>([
"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.
+169
View File
@@ -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<string, { count: number; why: string }> = {
"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<string, number> = {};
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=<id>` 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 });
});
});
@@ -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<string, unknown>).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([]);
});
});
+4
View File
@@ -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,
+12 -10
View File
@@ -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
+3 -2
View File
@@ -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
+20 -8
View File
@@ -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));
});
@@ -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);
}
});
});
+10
View File
@@ -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" } });
@@ -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<typeof ApiHandlerModule>("@/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<Response>;
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);
}
});
});
+9
View File
@@ -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;
@@ -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);
@@ -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, {
@@ -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: <T extends (...args: unknown[]) => 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();
});
});
+20
View File
@@ -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
+3 -1
View File
@@ -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;
@@ -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),
});
});
+14 -3
View File
@@ -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 () => {
@@ -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<Response>;
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
+42
View File
@@ -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 });
});
@@ -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 } },
+3
View File
@@ -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,
@@ -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();
@@ -0,0 +1,75 @@
/**
* Which tab a link to `/checkups` opens.
*
* Visits are the page's second tab. `?visit=<id>` 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<typeof import("@tanstack/react-query")>()),
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: () => <div data-section="vorsorge" />,
}));
vi.mock("@/components/encounters/visits-section", () => ({
VisitsSection: () => <div data-section="visits" />,
}));
vi.mock("@/components/encounters/procedures-section", () => ({
ProceduresSection: () => <div data-section="procedures" />,
}));
vi.mock("@/components/body-sites/body-sites-section", () => ({
BodySitesSection: () => <div data-section="body-sites" />,
}));
import { I18nProvider } from "@/lib/i18n/context";
import CheckupsPageClient from "../page-client";
function openTab(query: string): string | null {
search = new URLSearchParams(query);
const html = renderToStaticMarkup(
<I18nProvider initialLocale="en">
<CheckupsPageClient />
</I18nProvider>,
);
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");
});
});
+16 -3
View File
@@ -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<string>(
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(
() =>
+1
View File
@@ -191,6 +191,7 @@ function CoachPlansBody({ readOnly }: { readOnly: boolean }) {
{(readOnly || group === "past") && (
<ConfirmButton
slot="coach-plan-delete"
destructive
variant="ghost"
size="sm"
className="min-h-9"
+3 -3
View File
@@ -11,9 +11,9 @@ import { EcgCrossLink } from "@/components/insights/ecg-cross-link";
* Heart-rate variability sub-page. Pairs with the iOS HKQuantityType
* `heartRateVariabilitySDNN` ingest path; the user typically logs one
* sample per night during sleep, so the chart is sparse but the
* weekly trend is the load-bearing signal. Apple Health / Fitbit store
* SDNN as millisecond `HEART_RATE_VARIABILITY` rows; Oura / Polar / WHOOP
* store nightly RMSSD as `HRV_RMSSD`. The page renders SDNN when present
* weekly trend is the load-bearing signal. Apple Health and Health Connect
* store SDNN as millisecond `HEART_RATE_VARIABILITY` rows; Oura / Polar /
* WHOOP / Fitbit / Google Health store nightly RMSSD as `HRV_RMSSD`. The page renders SDNN when present
* and falls back to the RMSSD series (labelled "RMSSD") otherwise, so a
* ring / strap user's stored HRV always surfaces. Since iOS 27 an Apple
* Watch writes both (#1110); with rows of both the page charts each on its
@@ -126,4 +126,58 @@ describe("NotificationsPage", () => {
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"/,
);
});
});
+12 -12
View File
@@ -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<string, boolean>;
}
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 +
+3 -2
View File
@@ -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
@@ -187,6 +187,7 @@ export function AiServerKeySection() {
{configured && (
<ConfirmButton
slot="admin-ai-server-key-remove"
destructive
variant="outline"
size="sm"
label={t("admin.aiServerKey.remove")}
@@ -196,6 +196,7 @@ export function CentralCodexSection() {
<SettingsCardActions>
<ConfirmButton
slot="admin-central-codex-disconnect"
destructive
variant="destructive"
size="sm"
className="min-h-11 shrink-0 sm:min-h-9"
+2
View File
@@ -880,6 +880,7 @@ export function LogDaySheet({
{currentRowId ? (
<ConfirmButton
slot="cycle-day-delete"
destructive
variant="outline"
className="text-destructive hover:text-destructive mr-auto"
icon={<Trash2 className="h-4 w-4" />}
@@ -1471,6 +1472,7 @@ export function SymptomRow({
</button>
<ConfirmDialog
slot="cycle-custom-symptom-remove"
destructive
open={confirmRemove}
onOpenChange={setConfirmRemove}
title={t("cycle.symptom.custom.removeTitle")}
@@ -161,6 +161,37 @@ describe("<PriorityCard>", () => {
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(
<PriorityCard
item={item({
kind: "upcoming_visit",
actions: [action("/checkups?visit=v1")],
})}
/>,
);
expect(one).toContain("View appointment");
expect(one).not.toContain("View check-ups");
expect(one).toContain('href="/checkups?visit=v1"');
const several = render(
<PriorityCard
item={item({
kind: "upcoming_visit",
actions: [action("/checkups?view=visits")],
})}
/>,
);
expect(several).toContain("View appointments");
});
describe("dismiss affordance — observational kinds only", () => {
const DISMISSIBLE: PriorityItemKind[] = [
"milestone",
+20 -1
View File
@@ -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({
<ActionLink
key={action.intent}
href={action.href}
label={t(action.labelKey)}
label={t(actionLabelKey(item, action))}
/>
) : onAction ? (
<ActionButton
@@ -225,6 +226,24 @@ export function PriorityCard({
}
/** A navigation action. Always offered — reading is never delegated away. */
/**
* The label key a web card shows for an action.
*
* The server's `labelKey`, except on an appointment: that item still carries
* the check-up item's label on the wire ("View check-ups"), because the
* shipped iOS app resolves the label and routes the intent it already knows.
* Its `href` already opens the visit, so the web names what it opens.
*/
export function actionLabelKey(
item: Pick<PriorityItem, "kind">,
action: Pick<PriorityItemAction, "labelKey" | "href">,
): 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 (
<Link
+2 -1
View File
@@ -30,7 +30,8 @@
* stands in its place — not an alarming empty card.
*
* Every `PriorityItem` action carries an `href` (dose.log → /medications,
* sync.reconnect → /settings/integrations, checkup.view → /checkups), so
* sync.reconnect → /settings/integrations, checkup.view → /checkups, or the
* visit itself on an upcoming_visit item), so
* `PriorityCard` wires each tap as a `<Link>` to its existing destination
* by construction; S2 invents no new backend action.
*
+3 -3
View File
@@ -164,9 +164,9 @@ export const HRV_LABEL_KEY: Record<HrvSummaryType, string> = {
* 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
+46 -22
View File
@@ -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<DayNotableWindowResponse>(`/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 ? (
<CardDescription className="text-xs" data-slot="since-last-visit-reason">
{heading.reason}
</CardDescription>
) : 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}
</CardHeader>
<CardContent>
<QueryErrorRow
@@ -236,13 +259,14 @@ export function SinceLastVisit({
icon={Stethoscope}
id={`since-last-visit-${next.id}`}
titleAs="h3"
title={t("day.sinceLastVisit.title", { date: lastLabel })}
title={heading.title}
right={
<span className="text-muted-foreground text-xs">
{tCount("day.sinceLastVisit.count", rows.length)}
</span>
}
/>
{reason}
</CardHeader>
<CardContent>
<section
@@ -297,11 +321,11 @@ export function SinceLastVisit({
className="min-h-11 sm:min-h-9"
>
<Link
href={`/settings/gesundheitsakte?reportFrom=${lastDay}&reportTo=${nextDay < today ? nextDay : today}`}
href={`/settings/gesundheitsakte?reportFrom=${fromDay}&reportTo=${nextDay < today ? nextDay : today}`}
data-slot="since-last-visit-report"
>
<FileText className="size-4" aria-hidden="true" />
{t("day.sinceLastVisit.report", { date: lastLabel })}
{t("day.sinceLastVisit.report", { date: fromLabel })}
</Link>
</Button>
</section>
@@ -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<typeof useTranslations>["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. */
@@ -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("<VisitsSection>", () => {
* 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("<VisitsSection> 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("<VisitsSection> 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("<VisitsSection> 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("<VisitsSection> 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("<VisitsSection> 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("<VisitsSection> 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&amp;");
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("<VisitsSection> since the last visit", () => {
/<h3 id="since-last-visit-[^"]+" data-slot="card-title" class="[^"]*text-sm/,
);
});
it("says why on the default window, where the title alone cannot", () => {
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.",
);
});
});
@@ -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, string | number>,
) => 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"),
};
}
}
+15 -9
View File
@@ -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 ? (
<Button
@@ -135,18 +138,21 @@ export function VisitsSection({ enabled = true }: { enabled?: boolean }) {
icon={CalendarClock}
title={t("encounters.upcomingHeading")}
/>
{upcoming.map((encounter, i) => (
{upcoming.map((encounter) => (
<Fragment key={encounter.id}>
<VisitCard
encounter={encounter}
onOpen={canManageVisits ? openSheet : undefined}
/>
{/* v1.42 — the next appointment carries what happened
since the last one, each date opening its day. The
component draws its own card, and none when it has
nothing to list. */}
{i === 0 && lastDone ? (
<SinceLastVisit next={encounter} last={lastDone} />
{/* v1.42 — the next booked appointment carries what
happened in the window before it, each date opening its
day. The component draws its own card, and none when it
has nothing to list. */}
{encounter.id === prepared?.id && prepared.preparation ? (
<SinceLastVisit
next={encounter}
preparation={prepared.preparation}
/>
) : null}
</Fragment>
))}
@@ -29,6 +29,7 @@ import { Skeleton } from "@/components/ui/skeleton";
import { useAuth } from "@/hooks/use-auth";
import { useEncounters } from "@/hooks/use-encounters";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { visitPath } from "@/lib/encounters/visit-links";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import type { EncounterKind } from "@/generated/prisma/client";
@@ -95,7 +96,7 @@ export function EpisodeVisitsCard({ episodeId }: { episodeId: string }) {
<li key={visit.id}>
<ListRow asChild>
<Link
href="/checkups"
href={visitPath(visit.id)}
data-encounter-id={visit.id}
className="hover:bg-muted/50 focus-visible:ring-ring/50 flex items-center gap-3 focus-visible:ring-[3px] focus-visible:outline-none"
>
@@ -59,9 +59,9 @@ export interface HealthKitMetricPageProps {
/**
* v1.17.0 — optional secondary MeasurementType the page falls back to
* when the primary type has no rows but the fallback does. Used by HRV:
* the primary `HEART_RATE_VARIABILITY` (SDNN, Apple / Fitbit) is empty
* for a ring / strap user whose nightly HRV is stored as `HRV_RMSSD`
* (Oura / Polar / WHOOP). When the swap is active the chart, stat strip,
* the primary `HEART_RATE_VARIABILITY` (SDNN, Apple Health / Health
* Connect) is empty for a ring / strap user whose nightly HRV is stored as
* `HRV_RMSSD` (Oura / Polar / WHOOP / Fitbit / Google Health). When the swap is active the chart, stat strip,
* "all values" list, and diversity nudge key off the fallback type and
* `fallbackMeasureLabel` names the measure so the two are never silently
* merged. Omit it and every other page renders byte-identically.
@@ -12,6 +12,7 @@ import {
extractErrorMessage,
handleFilePickerChange,
OcrPageCoverageNote,
OcrReadNotes,
OcrReviewDialog,
unitSkippedAnalytes,
} from "../ocr-review-dialog";
@@ -237,6 +238,15 @@ describe("extractErrorMessage", () => {
);
});
it("names a read cut at the output limit instead of blaming the photo", () => {
expect(extractErrorMessage(refusal("labs.ocr.outputLimit", 422), t)).toBe(
"labs.ocr.outputLimit",
);
expect(extractErrorMessage(refusal("labs.ocr.tooLarge", 422), t)).toBe(
"labs.ocr.tooLarge",
);
});
it("never calls an unrelated 403 a missing consent", () => {
expect(extractErrorMessage(new ApiError("forbidden", 403), t)).toBe(
"labs.ocr.extractFailed",
@@ -265,6 +275,53 @@ describe("<OcrPageCoverageNote> — a long PDF read from its first pages", () =>
});
});
describe("<OcrReadNotes> — what a read could not give", () => {
const render = (notes: Parameters<typeof OcrReadNotes>[0]["notes"]) =>
renderToStaticMarkup(
<I18nProvider initialLocale="en">
<OcrReadNotes notes={notes} />
</I18nProvider>,
);
it("names the pages a read was cut on, or the report when it was one read", () => {
expect(render({ truncated: { pages: [2, 5] } })).toContain(
"partway through these pages: 2, 5.",
);
expect(render({ truncated: { pages: [] } })).toContain(
"before the end of the report",
);
});
it("names the pages that were not read", () => {
expect(render({ unreadPages: [3, 4] })).toContain(
"not in this list: 3, 4.",
);
expect(render({ unreadPages: [] })).toContain("Part of the report");
});
it("says why a scan stopped early when it was not the provider", () => {
expect(render({ unreadPages: [4], stoppedBy: "budget" })).toContain(
"AI budget for today ran out",
);
expect(render({ unreadPages: [4], stoppedBy: "unavailable" })).toContain(
"AI was turned off while the report was being read",
);
const failed = render({ unreadPages: [4], stoppedBy: "error" });
expect(failed).toContain("because of a server error");
expect(failed).not.toContain("AI was turned off");
});
it("says how many readings the report had when it ran past the cap", () => {
expect(render({ rowsCapped: { kept: 400, found: 512 } })).toContain(
"The report lists 512 readings. One scan takes 400",
);
});
it("renders nothing for a clean read", () => {
expect(render({})).toBe("");
});
});
describe("unitSkippedAnalytes", () => {
const unit = (analyte: string) => ({
analyte,
@@ -57,6 +57,23 @@ describe("validateReviewRow", () => {
{ value: null, valueText: " ", unit: null },
{ valueText: true },
],
// The commit's own bounds, so one over-long field marks its row instead
// of failing the whole save with a 422.
[
"an analyte longer than a reading holds",
{ analyte: "a".repeat(121) },
{ analyteTooLong: true },
],
[
"a unit longer than a reading holds",
{ unit: "u".repeat(41), markerUnit: null },
{ unitTooLong: true },
],
[
"a qualitative result longer than a reading holds",
{ value: null, valueText: "n".repeat(121), unit: null },
{ valueTextTooLong: true },
],
[
"several problems at once",
{ takenAt: null, unit: "", analyte: "" },
@@ -83,6 +100,10 @@ describe("toCommitRow agrees with validateReviewRow", () => {
{ unit: "" },
{ value: null, valueText: "negative", unit: null },
{ value: null, valueText: "", unit: null },
{ analyte: "a".repeat(121) },
{ analyte: "a".repeat(120) },
{ unit: "u".repeat(41) },
{ value: null, valueText: "n".repeat(121), unit: null },
];
it.each(cases)("row %j", (overrides) => {
+12 -1
View File
@@ -14,6 +14,7 @@
* View / sort / order persist client-side via `useModuleListPrefs("labs")`.
*/
import { useQuery } from "@tanstack/react-query";
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
import {
ArrowDownUp,
FlaskConical,
@@ -29,6 +30,7 @@ import { Label } from "@/components/ui/label";
import { Skeleton } from "@/components/ui/skeleton";
import { Switch } from "@/components/ui/switch";
import { apiGet } from "@/lib/api/api-fetch";
import { showErrorToast } from "@/lib/errors/error-toast";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { ModuleViewToggle } from "@/components/module-list/module-view-toggle";
@@ -139,7 +141,16 @@ export function LabsSettings() {
<Switch
checked={localOcr.data?.labsLocalOcrEnabled ?? false}
disabled={localOcr.isLoading || updateLocalOcr.isPending}
onCheckedChange={(checked) => updateLocalOcr.mutate(checked)}
onCheckedChange={(checked) =>
updateLocalOcr.mutate(checked, {
onSuccess: () =>
toastWrittenOutcome("success", t("settings.saved")),
onError: (failure) =>
showErrorToast(t("settings.savingError"), {
cause: failure,
}),
})
}
aria-label={t("labs.localOcr.heading")}
/>
}
+74 -1
View File
@@ -22,7 +22,10 @@ import { useQuery } from "@tanstack/react-query";
import { ApiError, apiGet } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
import type { OcrSkippedRowDto } from "@/lib/validations/labs-ocr";
import type {
OcrExtractResponseDto,
OcrSkippedRowDto,
} from "@/lib/validations/labs-ocr";
import { EncounterSuggestionField } from "@/components/encounters/encounter-suggestion-field";
import { useTranslations } from "@/lib/i18n/context";
@@ -102,6 +105,10 @@ export function extractErrorMessage(
return t("labs.ocr.fileType");
case "labs.ocr.pdfNeedsAnthropic":
return t("labs.ocr.pdfNeedsAnthropic");
case "labs.ocr.outputLimit":
return t("labs.ocr.outputLimit");
case "labs.ocr.tooLarge":
return t("labs.ocr.tooLarge");
case "consent.ai.required":
return t("labs.ocr.consentRequired");
case "ai.provider.none":
@@ -171,6 +178,9 @@ export function OcrReviewDialog({
read: number;
total: number;
} | null>(null);
// Set when a read stopped short: cut at the output limit, pages the
// provider never answered for, or more readings than one scan carries.
const [readNotes, setReadNotes] = useState<OcrReadNotesValue>({});
const [focusRequest, setFocusRequest] = useState(0);
const reviewListRef = useRef<HTMLDivElement>(null);
@@ -236,6 +246,7 @@ export function OcrReviewDialog({
setStage("pick");
setShowErrors(false);
setPageCoverage(null);
setReadNotes({});
setRows([]);
setPickedFile(null);
setVisitId(null);
@@ -260,6 +271,12 @@ export function OcrReviewDialog({
}
setRows(seeded);
setPageCoverage(data.pageCoverage ?? null);
setReadNotes({
truncated: data.truncated,
unreadPages: data.unreadPages,
stoppedBy: data.stoppedBy,
rowsCapped: data.rowsCapped,
});
setStage("review");
},
onError: (err) =>
@@ -437,6 +454,7 @@ export function OcrReviewDialog({
</p>
<OcrPageCoverageNote coverage={pageCoverage} />
<OcrReadNotes notes={readNotes} />
{showErrors && rowErrors.size > 0 ? (
<p
@@ -498,3 +516,58 @@ export function OcrPageCoverageNote({
</p>
);
}
type OcrReadNotesValue = Pick<
OcrExtractResponseDto,
"truncated" | "unreadPages" | "stoppedBy" | "rowsCapped"
>;
/**
* What the read could not give the person, said beside the rows for the same
* reason as the page note: a list that looks complete is one nobody checks.
* A read cut at the provider's output limit (rows up to the cut are kept),
* pages the provider never answered for, and a report with more readings
* than one scan carries. Renders nothing for a clean read.
*/
export function OcrReadNotes({ notes }: { notes: OcrReadNotesValue }) {
const { t } = useTranslations();
const { truncated, unreadPages, stoppedBy, rowsCapped } = notes;
if (!truncated && !unreadPages && !rowsCapped) return null;
return (
<div data-slot="ocr-read-notes" className="text-warning space-y-1 text-sm">
{truncated ? (
<p>
{truncated.pages.length > 0
? t("labs.ocr.truncatedPages", {
pages: truncated.pages.join(", "),
})
: t("labs.ocr.truncated")}
</p>
) : null}
{stoppedBy ? (
<p>
{stoppedBy === "budget"
? t("labs.ocr.stoppedBudget")
: stoppedBy === "error"
? t("labs.ocr.stoppedError")
: t("labs.ocr.stoppedUnavailable")}
</p>
) : null}
{unreadPages ? (
<p>
{unreadPages.length > 0
? t("labs.ocr.unreadPages", { pages: unreadPages.join(", ") })
: t("labs.ocr.unreadParts")}
</p>
) : null}
{rowsCapped ? (
<p>
{t("labs.ocr.rowsCapped", {
kept: rowsCapped.kept,
found: rowsCapped.found,
})}
</p>
) : null}
</div>
);
}
+28 -4
View File
@@ -9,12 +9,25 @@
*/
import { convertLabValue } from "@/lib/labs/unit-conversion/convert";
import { sameLabUnit } from "@/lib/labs/unit-normalise";
import {
OCR_ANALYTE_MAX,
OCR_UNIT_MAX,
OCR_VALUE_TEXT_MAX,
} from "@/lib/validations/labs-ocr";
import type { OcrReviewRow } from "./ocr-review-types";
import type { OcrCommitRowInput } from "./use-ocr-extract";
export type OcrRowField =
"analyte" | "value" | "valueText" | "unit" | "unitMismatch" | "date";
| "analyte"
| "analyteTooLong"
| "value"
| "valueText"
| "valueTextTooLong"
| "unit"
| "unitTooLong"
| "unitMismatch"
| "date";
/** The fields of one row that block saving; empty when the row is writable. */
export type OcrRowErrors = Partial<Record<OcrRowField, true>>;
@@ -103,14 +116,24 @@ export function readingUnitBlocked(row: {
export function validateReviewRow(row: OcrReviewRow): OcrRowErrors {
const errors: OcrRowErrors = {};
if (!row.analyte.trim()) errors.analyte = true;
const analyte = row.analyte.trim();
if (!analyte) errors.analyte = true;
// The commit's own bounds: a scan can read a longer name or result than a
// reading may hold, and one such row would otherwise fail the whole save.
else if (analyte.length > OCR_ANALYTE_MAX) errors.analyteTooLong = true;
if (!dayAtNoonUtc(row.takenAt)) errors.date = true;
if (isQualitativeRow(row)) {
if (!(row.valueText ?? "").trim()) errors.valueText = true;
const valueText = (row.valueText ?? "").trim();
if (!valueText) errors.valueText = true;
else if (valueText.length > OCR_VALUE_TEXT_MAX) {
errors.valueTextTooLong = true;
}
} else {
if (row.value === null || !Number.isFinite(row.value)) errors.value = true;
if (!(row.unit ?? "").trim()) errors.unit = true;
const unit = (row.unit ?? "").trim();
if (!unit) errors.unit = true;
else if (unit.length > OCR_UNIT_MAX) errors.unitTooLong = true;
if (readingUnitBlocked(row)) errors.unitMismatch = true;
}
return errors;
@@ -122,6 +145,7 @@ export function hasRowErrors(errors: OcrRowErrors): boolean {
/** Map a confirmed review row to the commit payload, or null when invalid. */
export function toCommitRow(row: OcrReviewRow): OcrCommitRowInput | null {
if (hasRowErrors(validateReviewRow(row))) return null;
const analyte = row.analyte.trim();
const takenAt = dayAtNoonUtc(row.takenAt);
if (!analyte || !takenAt) return null;
+25 -3
View File
@@ -24,6 +24,11 @@ import { DateField } from "@/components/ui/date-field";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useTranslations } from "@/lib/i18n/context";
import {
OCR_ANALYTE_MAX,
OCR_UNIT_MAX,
OCR_VALUE_TEXT_MAX,
} from "@/lib/validations/labs-ocr";
import { MarkerMatchField } from "./marker-match-field";
import { chooseMarker, type OcrReviewRow } from "./ocr-review-types";
@@ -151,10 +156,15 @@ export function OcrRowEditor({
value={row.analyte}
onChange={(e) => onChange({ ...row, analyte: e.target.value })}
aria-label={t("labs.ocr.analyteLabel")}
{...invalidProps("analyte")}
{...invalidProps(
errors?.analyteTooLong ? "analyteTooLong" : "analyte",
)}
className="font-medium"
/>
{fieldError("analyte", "labs.ocr.errorAnalyte")}
{fieldError("analyteTooLong", "labs.ocr.errorTooLong", {
max: String(OCR_ANALYTE_MAX),
})}
{/* v1.43 — the marker this value goes to: the server's
match, or a choice between its suggestions and a new one. */}
<MarkerMatchField
@@ -244,9 +254,14 @@ export function OcrRowEditor({
onChange={(e) =>
onChange({ ...row, valueText: e.target.value })
}
{...invalidProps("valueText")}
{...invalidProps(
errors?.valueTextTooLong ? "valueTextTooLong" : "valueText",
)}
/>
{fieldError("valueText", "labs.ocr.errorResult")}
{fieldError("valueTextTooLong", "labs.ocr.errorTooLong", {
max: String(OCR_VALUE_TEXT_MAX),
})}
</div>
) : (
<>
@@ -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 ?? "",
})}
+2 -1
View File
@@ -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<typeof useTranslations>["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,
{
@@ -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(`<a[^>]*data-slot="${slot}"[^>]*>`));
return tag?.[0].match(/href="([^"]*)"/)?.[1] ?? null;
}
describe("<VorsorgeDashboardCard> — the next appointment", () => {
it("opens the visit itself", () => {
const html = renderToStaticMarkup(
<I18nProvider initialLocale="en">
<VorsorgeDashboardCard />
</I18nProvider>,
);
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"');
});
});
@@ -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 ? (
<Link
href="/checkups"
href={visitPath(nextAppointment.id)}
data-slot="vorsorge-dashboard-next-visit"
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 flex items-center gap-2 rounded-md border-t pt-2 text-xs transition-colors focus-visible:ring-[3px] focus-visible:outline-none"
>
@@ -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() {
<Switch
checked={reminder.enabled}
onCheckedChange={(next) =>
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
@@ -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={
@@ -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 }) {
<PageHeader
title={t("practitioners.title")}
description={t("practitioners.description")}
backLink={{ href: "/checkups", label: t("encounters.sectionTitle") }}
backLink={{ href: VISITS_PATH, label: t("encounters.sectionTitle") }}
actions={addButton}
/>
@@ -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("<EmergencyProfileManager> Save", () => {
it("stays disabled until a field differs from the stored profile", () => {
@@ -43,3 +53,71 @@ describe("<EmergencyProfileManager> Save", () => {
expect(row).toMatch(/<button[^>]*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(
/<SelectItem value=\{NOT_RECORDED\}>\s*\{t\("records\.emergency\.notRecorded"\)\}/,
);
expect(source).toMatch(/next === NOT_RECORDED \? "" : next/);
});
});
@@ -194,6 +194,7 @@ export function AboutMeNoteManager() {
{hasNote && (
<ConfirmButton
slot="settings-about-me-clear"
destructive
label={t("settings.ai.aboutMe.clear")}
icon={<Trash2 className="size-4" aria-hidden />}
variant="outline"
+2
View File
@@ -176,6 +176,7 @@ export function AuditCPanel({
className="text-destructive size-11 shrink-0 sm:size-9"
icon={<Trash2 className="size-4" aria-hidden />}
slot={`audit-c-remove-${entry.id}`}
destructive
/>
</li>
))}
@@ -214,6 +215,7 @@ export function AuditCPanel({
className="text-destructive size-11 shrink-0 sm:size-9"
icon={<Trash2 className="size-4" aria-hidden />}
slot="audit-c-legacy-remove"
destructive
/>
</div>
) : null}
@@ -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<string, string | null> {
const body: Record<string, string | null> = {};
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<EmergencyProfileDto>("/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<EmergencyProfileDto>(
"/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}`)}
</Label>
<Select
value={current?.[key] ?? ""}
value={current?.[key] || NOT_RECORDED}
disabled={disabled}
onValueChange={(next) => set({ [key]: next } as Partial<Draft>)}
onValueChange={(next) =>
set({ [key]: next === NOT_RECORDED ? "" : next } as Partial<Draft>)
}
>
<SelectTrigger id={`emergency-${key}`} className="w-full">
<SelectValue placeholder={t("records.emergency.notRecorded")} />
</SelectTrigger>
<SelectContent>
{/* The way back to "not recorded", as every optional select in the
records offers one. */}
<SelectItem value={NOT_RECORDED}>
{t("records.emergency.notRecorded")}
</SelectItem>
{values.map((option) => (
<SelectItem key={option} value={option}>
{t(`records.emergency.${valuePrefix}.${option}`)}
@@ -0,0 +1,152 @@
/**
* Settings actions say how they went.
*
* Each action below finished in silence on at least one side. Adding or
* removing a passkey or a security key only redrew the list; disconnecting an
* integration and revoking a share link fired from a dialog that had already
* closed, so a refused request left everything as it was and said nothing;
* the injection-site exclusions, the local-OCR switch and the preventive-care
* reminder switches saved, or did not, without a word.
*
* They use the pair the rest of the app reports a write with —
* `toastWrittenOutcome("success", …)`, the outcome module's one door to the
* success toast, and `showErrorToast` (which carries the error id of a server
* failure). The passkey and security-key cards keep their inline error line,
* which also carries the step-up dialog's outcome, and gain the success half.
*
* Mutation callbacks only run after an interaction, so the wiring is read
* from the source: per action, the success callback raises the success
* outcome and, where the card has no inline error line, the error callback raises
* `showErrorToast`.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import ts from "typescript";
import { describe, expect, it } from "vitest";
interface Site {
file: string;
/** `const <name> = useMutation({…})`, or `<name>.mutate(…, {…})`. */
name: string;
shape: "definition" | "call";
needsErrorToast: boolean;
}
const SITES: Site[] = [
{
file: "src/components/settings/security-section/passkey-list-section.tsx",
name: "add",
shape: "definition",
needsErrorToast: false,
},
{
file: "src/components/settings/security-section/passkey-list-section.tsx",
name: "remove",
shape: "definition",
needsErrorToast: false,
},
{
file: "src/components/settings/security-section/security-keys-card.tsx",
name: "add",
shape: "definition",
needsErrorToast: false,
},
{
file: "src/components/settings/security-section/security-keys-card.tsx",
name: "remove",
shape: "definition",
needsErrorToast: false,
},
{
file: "src/components/settings/integrations/oauth-integration-card.tsx",
name: "disconnect",
shape: "definition",
needsErrorToast: true,
},
{
file: "src/components/settings/sharing-section.tsx",
name: "revokeMutation",
shape: "definition",
needsErrorToast: true,
},
{
file: "src/components/settings/injection-sites-card.tsx",
name: "mutation",
shape: "definition",
needsErrorToast: true,
},
{
file: "src/components/labs/labs-settings.tsx",
name: "updateLocalOcr",
shape: "call",
needsErrorToast: true,
},
{
file: "src/components/measurement-reminders/vorsorge-settings.tsx",
name: "update",
shape: "call",
needsErrorToast: true,
},
];
/** The `{ onSuccess, onError, … }` literal for a site, or null. */
function callbacks(site: Site): ts.ObjectLiteralExpression | null {
const text = readFileSync(join(process.cwd(), site.file), "utf8");
const sf = ts.createSourceFile(site.file, text, ts.ScriptTarget.Latest, true);
let found: ts.ObjectLiteralExpression | null = null;
const visit = (node: ts.Node) => {
if (found) return;
if (
site.shape === "definition" &&
ts.isVariableDeclaration(node) &&
node.name.getText(sf) === site.name &&
node.initializer &&
ts.isCallExpression(node.initializer) &&
node.initializer.expression.getText(sf) === "useMutation"
) {
const arg = node.initializer.arguments[0];
if (arg && ts.isObjectLiteralExpression(arg)) found = arg;
}
if (
site.shape === "call" &&
ts.isCallExpression(node) &&
node.expression.getText(sf) === `${site.name}.mutate`
) {
const arg = node.arguments[1];
if (arg && ts.isObjectLiteralExpression(arg)) found = arg;
}
ts.forEachChild(node, visit);
};
visit(sf);
return found;
}
function property(obj: ts.ObjectLiteralExpression, name: string): string {
const prop = obj.properties.find((p) => p.name && p.name.getText() === name);
return prop ? prop.getText() : "";
}
describe("settings actions report their outcome", () => {
for (const site of SITES) {
describe(`${site.file} → ${site.name}`, () => {
const obj = callbacks(site);
it("is found", () => {
expect(obj, "the mutation options were not found").not.toBeNull();
});
it("raises a success toast", () => {
expect(property(obj!, "onSuccess")).toMatch(
/toastWrittenOutcome\(\s*"success"/,
);
});
if (site.needsErrorToast) {
it("raises an error toast through showErrorToast", () => {
expect(property(obj!, "onError")).toContain("showErrorToast(");
});
}
});
}
});
@@ -216,12 +216,25 @@ describe("<AiSection> — dropdown-driven UX (B2)", () => {
expect(html).toContain("ChatGPT-Account (Codex)");
// v1.4.33 — the active-provider heading and the parent section
// both ship without the "KI"/"AI" prefix per the project-voice rule
// ("Aktiver KI-Provider" → "Aktiver Provider"). The heading and the
// chain summary now share the same phrase, so we assert against the
// explicit `activeProviderLabel` slot below it ("Primärer Provider")
// to keep the test pinned to the right surface.
// ("Aktiver KI-Provider" → "Aktiver Provider"). The picker below it is
// labelled for what it does: it chooses the form, not the order.
expect(html).toContain("Aktiver Provider");
expect(html).toContain("Primärer Provider");
expect(html).toContain("Anbieter einrichten");
});
it("labels the picker for what it does, not as the primary provider", () => {
// The select picks which provider's key and model the form below edits.
// It never wrote anything, yet read "Primary provider" under a card
// that said it held "the provider HealthLog asks first": a person who
// picked one expected it to answer first, and the chain order, which is
// what decides that, stayed as it was. The copy now points at the chain.
activeProviderQuery = "codex";
const html = render();
expect(html).not.toContain("Primary provider");
expect(html).toContain("Set up a provider");
expect(html).toContain(
"the order below decides which one HealthLog asks first",
);
});
// v1.18.0 — the Coach preference cards (disable toggle, preferences,
@@ -0,0 +1,57 @@
/**
* Ending a grant from the sessions card asks first.
*
* "Sign out everywhere" leaves accepted grants standing and lists them with a
* one-click end. That end is the same act as the access card's revoke — it
* takes another account's standing access to the record, permanently — but it
* fired on the tap, while the access card asks with a dialog naming who loses
* what. The row is state set after a mutation, out of reach of a static
* render, so the wiring is read from the source: the revoke call may only sit
* inside a `ConfirmButton`'s `onConfirm`.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import ts from "typescript";
import { describe, expect, it } from "vitest";
const FILE = "src/components/settings/security-sessions-card.tsx";
function revokeCallSites(): { attr: string | null; element: string | null }[] {
const text = readFileSync(join(process.cwd(), FILE), "utf8");
const sf = ts.createSourceFile(FILE, text, ts.ScriptTarget.Latest, true);
const sites: { attr: string | null; element: string | null }[] = [];
const visit = (node: ts.Node) => {
if (
ts.isCallExpression(node) &&
node.expression.getText(sf) === "revokeGrant.mutate"
) {
let attr: string | null = null;
let element: string | null = null;
for (let p: ts.Node | undefined = node.parent; p; p = p.parent) {
if (ts.isJsxAttribute(p) && attr === null) attr = p.name.getText(sf);
if (
(ts.isJsxSelfClosingElement(p) || ts.isJsxOpeningElement(p)) &&
attr !== null
) {
element = p.tagName.getText(sf);
break;
}
}
sites.push({ attr, element });
}
ts.forEachChild(node, visit);
};
visit(sf);
return sites;
}
describe("kept-grant revoke on the sessions card", () => {
it("fires only from a ConfirmButton's onConfirm", () => {
const sites = revokeCallSites();
expect(sites.length).toBeGreaterThan(0);
for (const site of sites) {
expect(site).toEqual({ attr: "onConfirm", element: "ConfirmButton" });
}
});
});
@@ -0,0 +1,70 @@
/**
* The "end access" button of a grant sign-out-everywhere left standing.
*
* Its confirmation names the audit window, which rides the grant list. The
* row used to render the button only once that list had loaded, so while it
* loaded, and for good when the read failed, the row offered no way to end
* the access it had just explained. Now the button is there, disabled, until
* the list arrives, and a failed read draws the standard error row.
*/
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import { KeptGrantsList, type KeptGrant } from "../security-sessions-card";
const GRANTS: KeptGrant[] = [
{
id: "g1",
account: { id: "a1", username: "carer", displayName: null },
access: "WRITE",
},
];
function render(grantList: Parameters<typeof KeptGrantsList>[0]["grantList"]) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<KeptGrantsList
grants={GRANTS}
grantList={grantList}
revokeGrant={{ mutate: () => {}, isPending: false }}
onRevoked={() => {}}
onRevokeFailed={() => {}}
/>
</I18nProvider>,
);
}
function revokeButton(html: string): string {
const at = html.indexOf('data-slot="kept-grant-revoke"');
expect(at).toBeGreaterThan(-1);
return html.slice(html.lastIndexOf("<button", at), html.indexOf(">", at));
}
describe("KeptGrantsList", () => {
it("shows the button disabled while the grant list loads", () => {
const html = render({ isError: false, error: null, refetch: () => {} });
expect(revokeButton(html)).toMatch(/\sdisabled=""/);
expect(html).not.toContain('data-slot="query-error-row"');
});
it("keeps the button and draws the error row when the read fails", () => {
const html = render({
isError: true,
error: new Error("boom"),
refetch: () => {},
});
expect(revokeButton(html)).toMatch(/\sdisabled=""/);
expect(html).toContain("query-error-row");
});
it("enables the button once the audit window is known", () => {
const html = render({
data: { retentionDays: 90 },
isError: false,
error: null,
refetch: () => {},
});
expect(revokeButton(html)).not.toMatch(/\sdisabled=""/);
});
});
@@ -71,6 +71,15 @@ describe("oauthReasonKey", () => {
);
});
it("names the Fitbit turndown refusal with its own copy, and only for Fitbit", () => {
expect(oauthReasonKey("fitbit", "retired")).toBe(
"settings.fitbitOauthError.retired",
);
expect(oauthReasonKey("whoop", "retired")).toBe(
"settings.whoopOauthError.generic",
);
});
it("falls back to generic for an unknown tag", () => {
expect(oauthReasonKey("whoop", "totally_unknown")).toBe(
"settings.whoopOauthError.generic",
@@ -0,0 +1,163 @@
/**
* A settings save that raises its own pending flag lowers it in a `finally`.
*
* The cards that hand-roll a `saving` flag around a raw `fetch` set it, await
* the request, and cleared it on the line after. A request that never reached
* the server (offline, a dropped connection, a timeout) rejects instead of
* returning a response, the line after never ran, and the card stayed in its
* saving state for good: the Telegram form, the mood reminder, the low-stock
* threshold, the lock-screen detail switch, the Coach nudges, the profile
* form. Every one of them disabled its own control while "saving", so the
* only way out was a reload.
*
* `useMutation` owns its `isPending` and cannot get stuck this way; this
* guard covers the hand-rolled ones. For every async function under
* `src/components/settings` that sets one of the pending flags below to
* `true` and then awaits, it requires a `try` whose `finally` sets the same
* flag back to `false` — or, the older shape that is equally safe, every
* await inside a `try` that catches, so nothing can skip the reset after it.
* What it cannot see: a flag under another name, or a function that hands
* the await to a helper it does not await itself.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import ts from "typescript";
import { describe, expect, it } from "vitest";
import { walkSourceFiles } from "@/__tests__/helpers/source-files";
const ROOT = join(process.cwd(), "src/components/settings");
const PENDING_SETTER =
/^set(Saving|Pending|Busy|Loading|Submitting|ActionLoading)$/;
interface Finding {
where: string;
setter: string;
/** The flag is lowered in a `finally`, or no await can throw past the reset. */
safe: boolean;
}
/** Is this await inside the try block of a `try` that has a `catch`? */
function caught(node: ts.Node, fn: ts.Node): boolean {
for (let p = node.parent; p && p !== fn; p = p.parent) {
if (
ts.isBlock(p) &&
ts.isTryStatement(p.parent) &&
p.parent.tryBlock === p &&
p.parent.catchClause
) {
return true;
}
}
return false;
}
type FunctionLike =
| ts.FunctionDeclaration
| ts.FunctionExpression
| ts.ArrowFunction
| ts.MethodDeclaration;
function isFunctionLike(node: ts.Node): node is FunctionLike {
return (
ts.isFunctionDeclaration(node) ||
ts.isFunctionExpression(node) ||
ts.isArrowFunction(node) ||
ts.isMethodDeclaration(node)
);
}
/** Walk a function body without descending into nested functions. */
function ownNodes(body: ts.Node, visit: (node: ts.Node) => void): void {
const step = (node: ts.Node) => {
visit(node);
if (isFunctionLike(node)) return;
ts.forEachChild(node, step);
};
ts.forEachChild(body, step);
}
function setterCall(node: ts.Node, sf: ts.SourceFile, value: string) {
return (
ts.isCallExpression(node) &&
ts.isIdentifier(node.expression) &&
PENDING_SETTER.test(node.expression.text) &&
node.arguments.length === 1 &&
node.arguments[0].getText(sf) === value
);
}
function scan(): Finding[] {
const findings: Finding[] = [];
for (const rel of walkSourceFiles(ROOT, { floor: 150 })) {
if (rel.includes("__tests__/")) continue;
const text = readFileSync(join(ROOT, rel), "utf8");
const sf = ts.createSourceFile(rel, text, ts.ScriptTarget.Latest, true);
const visitFn = (node: ts.Node) => {
if (
isFunctionLike(node) &&
node.body &&
node.modifiers?.some((m) => m.kind === ts.SyntaxKind.AsyncKeyword)
) {
const raised = new Set<string>();
const awaits: ts.Node[] = [];
ownNodes(node.body, (n) => {
if (setterCall(n, sf, "true")) {
raised.add(
((n as ts.CallExpression).expression as ts.Identifier).text,
);
}
if (ts.isAwaitExpression(n)) awaits.push(n);
});
const allCaught = awaits.every((a) => caught(a, node));
if (awaits.length > 0) {
for (const setter of raised) {
let resetInFinally = false;
ownNodes(node.body, (n) => {
if (!ts.isTryStatement(n) || !n.finallyBlock) return;
ownNodes(n.finallyBlock, (m) => {
if (
setterCall(m, sf, "false") &&
((m as ts.CallExpression).expression as ts.Identifier)
.text === setter
) {
resetInFinally = true;
}
});
});
const line =
sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1;
findings.push({
where: `${rel}:${line}`,
setter,
safe: resetInFinally || allCaught,
});
}
}
}
ts.forEachChild(node, visitFn);
};
visitFn(sf);
}
return findings;
}
describe("settings saves cannot stay pending after a failed request", () => {
const findings = scan();
it("finds the hand-rolled saves it is meant to check", () => {
// A matcher that found nothing would pass on any tree.
expect(findings.length).toBeGreaterThan(10);
});
it("every one lowers its flag in a finally", () => {
const stuck = findings
.filter((f) => !f.safe)
.map((f) => `${f.where} (${f.setter})`);
expect(
stuck,
"Move the reset into `finally`, so a request that rejects (offline, timeout) cannot leave the card saving.",
).toEqual([]);
});
});
@@ -0,0 +1,52 @@
/**
* The token revoke confirmation names the act.
*
* Both token lists (API tokens and MCP connections) labelled the dialog's
* confirm button with `settings.tokenRevoked` — "Revoked", the status word the
* admin overview shows for a token that is already gone — so the button that
* performs the revoke read as if it had happened. The phone layout's trigger
* carried the dialog title instead, question mark included.
*
* Read from the source because the dialog content only mounts while open, and
* an SSR render never sees it. What is pinned: every `AlertDialogAction` that
* calls `handleRevoke` is labelled with the action key, and no trigger or
* action on these two surfaces uses the status word.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
const FILES = [
"src/components/settings/api-section.tsx",
"src/components/settings/mcp-section.tsx",
];
function read(rel: string): string {
return readFileSync(join(process.cwd(), rel), "utf8");
}
/** The body of every `<AlertDialogAction …>…</AlertDialogAction>` block. */
function actions(source: string): string[] {
return [
...source.matchAll(/<AlertDialogAction\b[\s\S]*?<\/AlertDialogAction>/g),
].map((m) => m[0]);
}
describe("token revoke labels", () => {
for (const rel of FILES) {
it(`${rel}: the revoke confirm says "Revoke token"`, () => {
const revokes = actions(read(rel)).filter((a) =>
a.includes("handleRevoke(tok.id)"),
);
expect(revokes.length).toBeGreaterThan(0);
for (const action of revokes) {
expect(action).toContain('t("settings.tokenRevokeAction")');
}
});
it(`${rel}: never labels a control with the status word`, () => {
expect(read(rel)).not.toContain('t("settings.tokenRevoked")');
});
}
});
@@ -127,6 +127,7 @@ export function GrantsGivenCard() {
return (
<ConfirmButton
slot="grant-revoke"
destructive
size="sm"
label={
grant.state === "PENDING"
@@ -229,6 +229,7 @@ export function GrantsReceivedCard() {
</Button>
<ConfirmButton
slot="grant-renounce"
destructive
size="sm"
label={t("recordSharing.received.renounce")}
title={t("recordSharing.received.renounceTitle", { name })}
@@ -188,6 +188,7 @@ function ManagedProfileRow({
{guardians.data && (
<ConfirmButton
slot="managed-profile-delete"
destructive
size="sm"
className="min-h-11 sm:min-h-9"
label={t("recordSharing.managed.delete")}
@@ -1,5 +1,6 @@
"use client";
import { DISPLAY_NAME_MAX_LENGTH } from "@/lib/validations/display-name";
import { useState } from "react";
import { Button } from "@/components/ui/button";
@@ -176,7 +177,7 @@ export function ManagedProfileCreateForm({
}}
placeholder={t("recordSharing.managed.namePlaceholder")}
autoComplete="off"
maxLength={80}
maxLength={DISPLAY_NAME_MAX_LENGTH}
aria-invalid={
displayName.length > 0 && nameIssue !== null ? "true" : undefined
}
@@ -1,5 +1,6 @@
"use client";
import { DISPLAY_NAME_MAX_LENGTH } from "@/lib/validations/display-name";
import { useState, type FormEvent } from "react";
import { Loader2 } from "lucide-react";
@@ -213,7 +214,7 @@ function ManagedProfileEditFields({
setError(null);
}}
autoComplete="off"
maxLength={80}
maxLength={DISPLAY_NAME_MAX_LENGTH}
aria-invalid={nameIssue !== null ? "true" : undefined}
/>
<FieldGuidance
@@ -172,6 +172,7 @@ export function ManagedProfileGuardians({
<div className="flex shrink-0 items-center">
<ConfirmButton
slot="managed-profile-guardian-remove"
destructive
size="sm"
className="min-h-11 sm:min-h-9"
label={t("recordSharing.managed.guardianRemove")}
@@ -0,0 +1,100 @@
/**
* The display name can be changed after the setup flow.
*
* The setup flow asks for the name the greeting and the reports use, and the
* profile route has always accepted `displayName` — but no settings surface
* offered it, and `/api/auth/me` did not even publish it, so a name chosen on
* the first day was the name for good. The profile card now carries the
* field, seeded from the account payload, and sends it with the save.
*
* Both ends are pinned: the route publishes the field (read from its response
* literal, the way `account-payload-consumer-guard` reads it), and the card
* renders it seeded and posts it.
*/
import { readFileSync } from "node:fs";
import { join, resolve } from "node:path";
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
let displayName: string | null = "Sam";
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
usePathname: () => "/settings/account",
useSearchParams: () => new URLSearchParams(),
}));
vi.mock("@tanstack/react-query", () => ({
useQuery: () => ({ data: null, isLoading: false, refetch: vi.fn() }),
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
useMutation: () => ({ mutate: vi.fn(), isPending: false }),
}));
vi.mock("@/hooks/use-mounted", () => ({ useMounted: () => true }));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: {
id: "u1",
username: "testuser",
displayName,
email: "user@example.com",
heightCm: null,
dateOfBirth: null,
gender: null,
role: "USER",
unitPreference: "metric",
},
isAuthenticated: true,
isLoading: false,
refetch: vi.fn(),
}),
}));
import { I18nProvider } from "@/lib/i18n/context";
import { AccountSection } from "@/components/settings/account-section";
function render(): string {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<AccountSection />
</I18nProvider>,
);
}
/** The `<input id="display-name" …>` tag, or null. */
function displayNameInput(html: string): string | null {
return /<input[^>]*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/);
});
});
@@ -154,6 +154,7 @@ export function AvatarSection() {
{avatarUrl && (
<ConfirmButton
slot="settings-avatar-remove"
destructive
variant="ghost"
className="text-destructive min-h-11 sm:min-h-9"
icon={<Trash2 className="h-4 w-4" />}
+206 -146
View File
@@ -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<string>("");
const [timezone, setTimezone] = useState<string>(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"
/>
</div>
{/* The name the greeting and the reports use. The setup flow
asks for it once; this is where it changes afterwards. */}
<div className="space-y-2">
<Label htmlFor="email">{t("auth.email")}</Label>
<Label htmlFor="display-name">
{t("onboarding.baseline.displayNameLabel")}
</Label>
<Input
id="email"
type="email"
value={email}
id="display-name"
value={displayName}
onChange={(e) => {
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"
}
/>
<p
id="display-name-hint"
className="text-muted-foreground text-xs"
>
{t("onboarding.baseline.displayNameHint")}
</p>
<FieldError
id="display-name-error"
message={fieldErrors.displayName}
/>
<FieldError id="email-error" message={fieldErrors.email} />
{emailChanged && !emailNeedsFactor && (
<div className="space-y-2 pt-1">
<Label htmlFor="email-current-password">
{t("settings.emailChangePassword")}
</Label>
<Input
id="email-current-password"
type="password"
value={emailPassword}
onChange={(e) => {
setEmailPassword(e.target.value);
clearFieldError("email");
}}
autoComplete="current-password"
maxLength={512}
/>
</div>
)}
</div>
</div>
<div className="space-y-2">
<Label htmlFor="email">{t("auth.email")}</Label>
<Input
id="email"
type="email"
value={email}
onChange={(e) => {
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}
/>
<FieldError id="email-error" message={fieldErrors.email} />
{emailChanged && !emailNeedsFactor && (
<div className="space-y-2 pt-1">
<Label htmlFor="email-current-password">
{t("settings.emailChangePassword")}
</Label>
<Input
id="email-current-password"
type="password"
value={emailPassword}
onChange={(e) => {
setEmailPassword(e.target.value);
clearFieldError("email");
}}
autoComplete="current-password"
maxLength={512}
/>
</div>
)}
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="gender">{t("settings.gender")}</Label>
@@ -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> = {}): 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<UserAIProvider>;
}[] = [
{
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(
<QueryClientProvider client={client}>
<I18nProvider initialLocale="en">
<Form userProvider={userProvider} />
</I18nProvider>
</QueryClientProvider>,
);
}
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 });
});
});
}
});
@@ -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({
)}
<SettingsCardActions>
{userProvider?.hasAnthropicKey && (
<RemoveSavedKeyButton field="anthropicKey" />
)}
<Button
type="submit"
size="sm"
@@ -210,6 +210,7 @@ export function CodexProviderForm({
) : isConnected ? (
<ConfirmButton
slot="settings-codex-disconnect"
destructive
variant="outline"
size="sm"
className="text-destructive min-h-11 shrink-0 sm:min-h-9"
Loaded 100 of 278 files, more files were not shown because too many files have changed in this diff. Show more