Merge pull request #1225 from MBombeck/release/v1.42.4

Release v1.42.4
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-10-15 18:14:30 +02:00
commit 1fe995245b
208 files changed
+18375 -5796

No files matched your search

+54
View File
@@ -1,5 +1,59 @@
# Changelog
## [1.42.4] — 2026-10-16
Reliability: one integration health model with a watchdog and a shared
card, a resumable Google Health backfill, no fixed list cut-offs.
### Added
- **Connection health.** Every integration status route (and
`/api/integrations/status`, `/api/integrations/healthkit`,
`/api/record-settings/integrations`) carries `health`: state
(`ok | limited | degraded | action_required`), reason, since, one action,
unavailable types, last success, last data and redacted diagnostics
(`integration_statuses.recent_calls`, migration 0400).
- **Watchdog.** Hourly and daily passes flag a continuous source that goes
silent (24 h; 48 h for Apple Health, Oura and WHOOP; never for scales, BP
cuffs or Strava) and pages the admin once per incident for action needed,
provider errors or silence, with one recovery note; provider-wide incidents
are aggregated; the first run records standing incidents without paging.
- **Integration cards.** One card for every connection and the managed
profile view, rendered from `health` only, with a three-dot menu and a
collapsed, copyable diagnostics section.
- **HealthKit arrivals.** Medication doses record arrivals as
`MEDICATION_DOSE`; every type carries `lastBackgroundAt` (migration 0401
turns `healthkit_type_syncs.type` into text).
- `GET /api/labs/overview`: one row per biomarker with its latest reading.
- The container logs pending migrations before `migrate deploy`.
### Fixed
- **Google Health backfill (#1220).** Progress is saved per page inside a
collection; slices of five minutes or 500 pages; server errors and
timeouts are retried; a long rate limit ends the slice and keeps the place;
a refused resume filter falls back to a client-side skip that keeps its
own place. Every admitted import runs in its own background event. The
hourly sync waits for a running slice and stays inside its expiry.
- **Fixed list limits (#1222).** Visit link pickers open on the record's date
with search and paging (`/api/labs` `q`; illness episodes `from`, `to`,
`offset`, `sortDir`, `q`; encounters `offset`); the lab list reads the
overview; side effects, GLP-1 medications, drug-level intakes and symptoms
page through all entries. A guard rejects new fixed client limits.
- **Extra Apple Health dose (#1221).** A dose on another local day than the
held take is stored unscheduled instead of overwriting it; a same-day
entry converges onto the slot; dose history lets the slot's own take claim
it first.
- **Sleep score.** A night's midpoint comes from its main session; Oura
`rest` and `deleted` periods are skipped and earlier imports retired.
- Recovery page empty state; aging test fixtures made relative with a guard.
### API
- `health` on integration status responses; `metricFreshness[]` gains
`MEDICATION_DOSE` and `lastBackgroundAt`; `POST /api/medications/intake/bulk`
accepts `syncTrigger`; GLP-1 timeline adds `medications[]`.
## [1.42.3] — 2026-10-15
Withings API plans, lab unit conversion, chronic conditions and rest mode,
+8 -2
View File
@@ -202,14 +202,20 @@ COPY --from=builder /app/src/generated ./src/generated
# `@node-rs/argon2` from the traced runtime tree. Scripts that import the
# application graph (`@/…`, the generated Prisma client) run from a source
# checkout instead. `src/__tests__/container-scripts-ship-guard.test.ts` holds
# the runbooks and this list in step. Root-owned on purpose: the app user can
# run them but not rewrite them.
# the runbooks and the entrypoint against this list. `pending-migrations.mjs`
# is the entrypoint's own: it logs the pending migration count before
# `migrate deploy`. Root-owned on purpose: the app user can run them but not
# rewrite them.
COPY --from=builder /app/scripts/reset-password.mjs ./scripts/reset-password.mjs
COPY --from=builder /app/scripts/pending-migrations.mjs ./scripts/pending-migrations.mjs
COPY --from=builder /app/src/lib/auth/argon2-params.mjs ./src/lib/auth/argon2-params.mjs
COPY --from=builder /app/scripts/repair-intake-anomalies.ts ./scripts/repair-intake-anomalies.ts
# Prove the reset CLI's imports resolve in this tree: with every import found
# it stops at its own usage line; a missing module throws before reaching it.
RUN node scripts/reset-password.mjs 2>&1 | grep -q "reset-password: usage:"
# The boot notice parses, and the `pg` it loads on a real boot resolves here.
RUN env -u DATABASE_URL node scripts/pending-migrations.mjs && \
node --input-type=module -e "await import('pg')"
# Install the migration CLI, its config dependency, and a pinned launcher for
# the maintenance scripts already shipped in the standalone tree. The Prisma
+3
View File
@@ -85,6 +85,9 @@ done
echo "HealthLog: Database is reachable."
# ── Run Prisma migrations ────────────────────────────────────
# Say how many migrations this image is about to apply, with a reminder to
# back up first. Reporting only: it never stops the boot.
node scripts/pending-migrations.mjs || true
echo "HealthLog: Running database migrations..."
NODE_PATH=/opt/prisma-cli/node_modules \
node /opt/prisma-cli/node_modules/prisma/build/index.js migrate deploy
+405 -15
View File
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: HealthLog API
version: 1.42.3
version: 1.42.4
description: >-
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
@@ -14833,6 +14833,12 @@ paths:
type: string
minLength: 1
maxLength: 120
- in: query
name: q
schema:
type: string
minLength: 1
maxLength: 120
- in: query
name: from
schema:
@@ -14918,6 +14924,26 @@ paths:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"429": *a12
/api/labs/overview:
get:
tags:
- Labs
summary: Per-marker overview of the caller's lab history
description: "One entry per biomarker over every live lab result, newest reading first: the newest reading, the reading
count and a short trend. Unlike `GET /api/labs` it is not paged, so a marker measured only long ago is never cut
off by a page size."
parameters: []
responses:
"200":
description: Lab overview.
content:
application/json:
schema:
$ref: "#/components/schemas/LabOverviewEnvelope"
"401": *a1
"403": *a11
"422": *a4
"429": *a5
/api/labs/{id}:
get:
tags:
@@ -15372,6 +15398,12 @@ paths:
type: integer
minimum: 1
maximum: 200
- in: query
name: offset
schema:
type: integer
minimum: 0
maximum: 100000
responses:
"200":
description: The caller's visits.
@@ -16315,6 +16347,37 @@ paths:
const: "true"
- type: string
const: "false"
- in: query
name: offset
schema:
type: integer
minimum: 0
maximum: 100000
- in: query
name: from
schema:
type: string
format: date-time
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
- in: query
name: to
schema:
type: string
format: date-time
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
- in: query
name: sortDir
schema:
type: string
enum:
- asc
- desc
- in: query
name: q
schema:
type: string
minLength: 1
maxLength: 120
responses:
"200":
description: The caller's illness episodes.
@@ -21989,10 +22052,11 @@ paths:
tags:
- Account sharing
summary: Read the managed record's integration connection states
description: "Disclosure only: whether each of the record's integrations is connected and when it last succeeded. There
is deliberately no control arm and no credential projection anywhere under this path — a Guardian cannot
connect, disconnect, probe or resume a managed record's provider, because an OAuth flow cannot safely carry a
record selector. Cookie session or Bearer, while acting on a managed record the caller guards."
description: "Disclosure only: whether each of the record's integrations is connected, when it last succeeded, and its
server-resolved health. There is deliberately no control arm and no credential projection anywhere under this
path — a Guardian cannot connect, disconnect, probe or resume a managed record's provider, because an OAuth flow
cannot safely carry a record selector. Cookie session or Bearer, while acting on a managed record the caller
guards."
responses:
"200":
description: Connection state per provider.
@@ -25528,8 +25592,9 @@ components:
account as `lastSyncTrigger` — plus `lastBackgroundSyncAt` for a `background` or `push` trigger — both
readable from `GET /api/integrations/healthkit`. Since v1.42 it is also recorded per measurement type the
batch carried (Apple Health rows only), served as `lastReceivedAt` / `lastTrigger` / `lastNewSampleAt` on
each `metricFreshness` entry of that read. It does not affect dedup, attribution, or how any sample is
stored. Optional and backward-compatible: pre-#66 clients omit it.'
each `metricFreshness` entry of that read, plus `lastBackgroundAt` (since v1.42.4) for a `background`
trigger. It does not affect dedup, attribution, or how any sample is stored. Optional and
backward-compatible: pre-#66 clients omit it.'
type: string
enum:
- foreground
@@ -26117,6 +26182,18 @@ components:
type: array
items:
$ref: "#/components/schemas/BulkMedicationIntakeEntry"
syncTrigger:
description: 'Diagnostic-only, the same field `POST /api/measurements/batch` takes (since v1.42.4): what woke the client
for this sync. When the batch carries entries with `source: "APPLE_HEALTH"` that land (`inserted`, `updated`
or `duplicate`), it is recorded on the `MEDICATION_DOSE` entry of `metricFreshness` in `GET
/api/integrations/healthkit` as `lastTrigger`, and a `background` value also dates its `lastBackgroundAt`.
It changes no dose row. Optional; omitted records a null trigger.'
type: string
enum:
- foreground
- background
- push
- manual
required:
- entries
description: iOS SyncMode bulk intake backfill. 1–500 entries per call; idempotent via `Idempotency-Key` and per-entry
@@ -61166,9 +61243,26 @@ components:
additionalProperties: false
description: "Newest first, capped at `limit`. A heterogeneous union: which optional keys are present depends on `kind`,
and no entry carries all of them."
medications:
type: array
items:
type: object
properties:
id:
type: string
name:
type: string
required:
- id
- name
additionalProperties: false
description: "Every medication classified GLP-1 in the record, whatever `limit` cut from `entries`. Read this, not the
entry names, to know which medications are GLP-1: a medication whose events are all older than the newest
`limit` entries has none in `entries`. Empty when `hasGlp1` is false."
required:
- hasGlp1
- entries
- medications
additionalProperties: false
description: "A chronological merge of the GLP-1-relevant events the record holds: titration steps, injections with
their sites, legacy stock-ledger movements, and side-effect-tagged mood days from the trailing 90 days.
@@ -67541,6 +67635,60 @@ components:
- data
- error
additionalProperties: false
LabOverviewEnvelope:
type: object
properties:
data:
$ref: "#/components/schemas/LabOverviewResponse"
error:
type: "null"
meta:
type: object
properties:
requestId:
type: string
additionalProperties: false
required:
- data
- error
additionalProperties: false
LabOverviewResponse:
type: object
properties:
markers:
type: array
items:
$ref: "#/components/schemas/LabOverviewMarker"
required:
- markers
additionalProperties: false
LabOverviewMarker:
type: object
properties:
key:
type: string
count:
type: integer
minimum: -9007199254740991
maximum: 9007199254740991
trend:
type: array
items:
anyOf:
- type: number
- type: "null"
latest:
$ref: "#/components/schemas/LabResult"
required:
- key
- count
- trend
- latest
additionalProperties: false
description: One biomarker in the caller's lab history. `key` is `bm:<biomarkerId>` for a catalog-linked marker or
`analyte:<lower-cased name>` for a legacy unlinked one. `count` is every live reading of the marker. `trend`
holds the values of its newest readings (at most 50), oldest first, null for a qualitative reading. `latest` is
the newest reading, serialised exactly like a `GET /api/labs` row.
GetLabResultResponse:
type: object
properties:
@@ -73461,6 +73609,12 @@ components:
anyOf:
- $ref: "#/components/schemas/AppleHealthSyncProgress"
- type: "null"
health:
description: The unified Apple Health connection state. Null when the phone has never delivered anything (or the read
failed). Present on the GET read; omitted from the PATCH echo.
anyOf:
- $ref: "#/components/schemas/ConnectionHealth"
- type: "null"
required:
- entries
- lastSyncedAt
@@ -73527,7 +73681,9 @@ components:
properties:
type:
type: string
description: The measurement type (e.g. `RESPIRATORY_RATE`), or `WORKOUTS` for the workout leg.
description: The measurement type (e.g. `RESPIRATORY_RATE`), `WORKOUTS` for the workout leg, or (Apple Health only,
since v1.42.4) `MEDICATION_DOSE` for medication doses imported from Apple Health. Clients should show or
skip a type they do not know rather than fail.
lastSeenAt:
type: string
format: date-time
@@ -73568,13 +73724,28 @@ components:
format: date-time
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
- type: "null"
lastBackgroundAt:
description: 'When a batch with `syncTrigger: "background"` last carried this type (since v1.42.4). A later
`foreground`, `push` or `manual` batch moves `lastReceivedAt` and `lastTrigger` but leaves this standing, so
opening the app does not hide an earlier background arrival. Null when the type has never arrived in the
background as far as the server recorded: before v1.42.4 only the latest arrival was kept, so a type whose
latest arrival then was a background one starts with that date and every other type starts null. Same
omission rules as `lastReceivedAt`.'
anyOf:
- type: string
format: date-time
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
- type: "null"
required:
- type
- lastSeenAt
- stale
additionalProperties: false
description: "Per-type Apple Health freshness: the newest sample's timestamp and staleness flag, plus the per-type
arrival facts recorded by `POST /api/measurements/batch` (v1.42)."
arrival facts recorded by `POST /api/measurements/batch` (v1.42). Since v1.42.4 the list can also hold a
`MEDICATION_DOSE` entry: `lastSeenAt` is the newest live dose imported from Apple Health (its `takenAt`, or its
slot for a skipped dose), and the arrival facts come from `POST /api/medications/intake/bulk` entries sent with
`source: \"APPLE_HEALTH\"`. A dose recorded in the app does not move them. Doses are never flagged `stale`."
AppleHealthSyncProgress:
type: object
properties:
@@ -73598,6 +73769,171 @@ components:
additionalProperties: false
description: "Server-side Apple Health backfill progress: how many rows have been accepted and how far back in time they
reach. Only what the server actually knows — no ETA, no device-side queue state."
ConnectionHealth:
type: object
properties:
state:
type: string
enum:
- ok
- limited
- degraded
- action_required
description: "`ok` data is arriving (or the first sync is still due). `limited` it works, but the types in `unavailable`
cannot be delivered (provider plan or missing permission). `degraded` temporary provider trouble or
unexpected silence; the server keeps retrying, `since` dates it. `action_required` the user has to do the
one thing in `action`."
reason:
type: string
enum:
- data_arriving
- first_sync_pending
- plan_limited
- permission_limited
- provider_errors
- sync_stalled
- no_new_data
- unexpected_silence
- reauth_required
- paused_after_errors
description: "Why the state is what it is. ok: `data_arriving`, `first_sync_pending`. limited: `plan_limited`,
`permission_limited`. degraded: `provider_errors`, `sync_stalled`, `no_new_data`, `unexpected_silence` (the
integration watchdog saw a source that normally delivers daily go quiet). action_required:
`reauth_required`, `paused_after_errors`. Pick the sentence from this code alone."
since:
anyOf:
- type: string
format: date-time
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
- type: "null"
description: When the current degraded / action_required condition started. Null for ok and limited.
action:
anyOf:
- type: string
enum:
- reconnect
- resume
- type: "null"
description: "The one action that resolves the state: `reconnect` (run the provider's connect flow again) or `resume`
(POST the provider's resume route). Also `reconnect` on a `limited` connection whose gap is a missing
permission. Null when nothing the user can do helps."
unavailable:
type: array
items:
type: object
properties:
item:
type: string
description: Provider data-type key, e.g. `spo2`, `vo2Max`, `activity`.
cause:
type: string
enum:
- plan
- permission
required:
- item
- cause
additionalProperties: false
description: Data types this connection cannot deliver. Present in every state, empty when nothing is withheld.
lastSuccessAt:
anyOf:
- type: string
format: date-time
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
- type: "null"
description: Last successful sync. For Apple Health, the last accepted batch.
lastDataAt:
anyOf:
- type: string
format: date-time
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
- type: "null"
description: "Newest data this source delivered: for polled providers when the newest reading or workout was taken, for
Apple Health when a sync last brought a new sample. Null when nothing has arrived."
diagnostics:
type: object
properties:
ledgerState:
anyOf:
- type: string
- type: "null"
description: The stored sync-ledger state. Null for Apple Health.
failingLegs:
type: array
items:
type: string
failingSince:
anyOf:
- type: string
format: date-time
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
- type: "null"
lastAttemptAt:
anyOf:
- type: string
format: date-time
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
- type: "null"
verdict:
type: string
description: The `SyncHealth` verdict the state was derived from.
recentCalls:
maxItems: 10
type: array
items:
type: object
properties:
endpoint:
type: string
description: Sync leg name (`sleep`, `measures`, `sync`, `token_refresh`), or `batch:<TYPE>` for Apple Health. Never a
URL.
status:
type: string
enum:
- ok
- transient
- persistent
- reauth_required
at:
type: string
format: date-time
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
errorCode:
anyOf:
- type: string
- type: "null"
description: Short error code (`401`, `invalid_grant`, `scope_missing`); anything not shaped like one is dropped.
required:
- endpoint
- status
- at
- errorCode
additionalProperties: false
description: The last ten sync attempts, newest first.
required:
- ledgerState
- failingLegs
- failingSince
- lastAttemptAt
- verdict
- recentCalls
additionalProperties: false
description: Redacted diagnostics for a bug report. Carries no token, no error message, no health value and no account
identifier, so a client may offer it for copying as is.
required:
- state
- reason
- since
- action
- unavailable
- lastSuccessAt
- lastDataAt
- diagnostics
additionalProperties: false
description: The one server-resolved health state of a connection (v1.42.4). It folds the ledger state, the liveness
verdict, plan and permission gaps, provider reauth flags and the integration watchdog's silence flag into four
states. Clients render it and never recompute it. The older health-shaped fields beside it stay for one release
after the clients read `health`, then go.
HealthKitConfigPatchEnvelope:
type: object
properties:
@@ -73933,6 +74269,12 @@ components:
- type: "null"
syncHealth:
$ref: "#/components/schemas/SyncHealth"
health:
description: The unified connection state. Present whenever the connection exists; null in the rare case it resolved to
none. Absent on the not-connected body.
anyOf:
- $ref: "#/components/schemas/ConnectionHealth"
- type: "null"
required:
- connected
- configured
@@ -74129,6 +74471,12 @@ components:
anyOf:
- type: string
- type: "null"
health:
description: The unified connection state. Present whenever the connection exists; null in the rare case it resolved to
none. Absent on the not-connected body.
anyOf:
- $ref: "#/components/schemas/ConnectionHealth"
- type: "null"
required:
- connected
- configured
@@ -74217,6 +74565,12 @@ components:
hasActivityScope:
description: Pre-computed convenience flag the reconnect banner reads. A null `scope` counts as missing activity.
type: boolean
health:
description: The unified connection state. Present whenever the connection exists; null in the rare case it resolved to
none. Absent on the not-connected body.
anyOf:
- $ref: "#/components/schemas/ConnectionHealth"
- type: "null"
required:
- connected
- configured
@@ -74531,6 +74885,12 @@ components:
anyOf:
- type: string
- type: "null"
health:
description: The unified connection state. Present whenever the connection exists; null in the rare case it resolved to
none. Absent on the not-connected body.
anyOf:
- $ref: "#/components/schemas/ConnectionHealth"
- type: "null"
required:
- connected
- configured
@@ -74694,6 +75054,12 @@ components:
anyOf:
- type: string
- type: "null"
health:
description: The unified connection state. Present whenever the connection exists; null in the rare case it resolved to
none. Absent on the not-connected body.
anyOf:
- $ref: "#/components/schemas/ConnectionHealth"
- type: "null"
needsReauth:
description: "The stored refresh token was rejected with `invalid_grant` — Google expires it after 7 days while the
OAuth client is in Testing publishing mode, and a user-revoked grant lands here too. Distinct from `parked`
@@ -74929,6 +75295,12 @@ components:
- type: "null"
syncHealth:
$ref: "#/components/schemas/SyncHealth"
health:
description: The unified connection state. Present whenever the connection exists; null in the rare case it resolved to
none. Absent on the not-connected body.
anyOf:
- $ref: "#/components/schemas/ConnectionHealth"
- type: "null"
required:
- connected
- configured
@@ -75468,6 +75840,14 @@ components:
allowPrivateHost:
description: Nightscout only.
type: boolean
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, which go once the settings surface renders `health`
(no other client reads this envelope).
required:
- integration
- state
@@ -75479,6 +75859,7 @@ components:
- metricFreshness
- connected
- configured
- health
additionalProperties: false
description: "One provider's entry in the consolidated envelope: the ledger core and the liveness verdict, which every
entry carries, plus the extras that provider has. The optional fields are not optional per request — each is
@@ -75489,7 +75870,9 @@ components:
properties:
type:
type: string
description: The measurement type (e.g. `RESPIRATORY_RATE`), or `WORKOUTS` for the workout leg.
description: The measurement type (e.g. `RESPIRATORY_RATE`), `WORKOUTS` for the workout leg, or (Apple Health only,
since v1.42.4) `MEDICATION_DOSE` for medication doses imported from Apple Health. Clients should show or
skip a type they do not know rather than fail.
lastSeenAt:
type: string
format: date-time
@@ -79237,11 +79620,18 @@ components:
format: date-time
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
- type: "null"
health:
anyOf:
- $ref: "#/components/schemas/ConnectionHealth"
- type: "null"
description: The same server-resolved connection state the owner's own envelope carries, null while the provider is not
connected. The web renders this; `state` stays for one transition release.
required:
- integration
- state
- lastSuccessAt
- lastAttemptAt
- health
additionalProperties: false
description: All eight providers, always, in the fixed order withings, whoop, fitbit, nightscout, polar, oura,
google-health, strava. Note the order differs from the owner's own `/api/integrations/status` envelope.
@@ -79249,11 +79639,11 @@ components:
- recordId
- integrations
additionalProperties: false
description: Whether the managed record's integrations are connected, and nothing else. Deliberately no control and no
credential projection — OAuth and provider state cannot safely carry a record selector, so a Guardian can see
that a pipe exists but cannot connect, disconnect, probe or resume it from here. A provider with no real
credential reads `disconnected` even when its ledger row says otherwise, because a synthesised healthy ledger is
not proof of a connection.
description: Whether the managed record's integrations are connected and how each is doing (`health`), and nothing else.
Deliberately no control and no credential projection — OAuth and provider state cannot safely carry a record
selector, so a Guardian can see that a pipe exists but cannot connect, disconnect, probe or resume it from here.
A provider with no real credential reads `disconnected` even when its ledger row says otherwise, because a
synthesised healthy ledger is not proof of a connection.
GetRecordSettingsFamilyEnvelope:
type: object
properties:
+101 -9
View File
@@ -122,22 +122,114 @@ The Google Cloud client id/secret are per-user (Settings), not env vars.
## Backfill, the hourly sync and Google's rate limit
Only one sync runs per account at a time. While the history backfill that
follows a connect is running, the hourly sync leaves that account alone, and
**Sync now** reports that a sync is already in progress and shows its progress
instead of starting a second one.
Only one sync runs per account at a time. **Sync now** reports that a sync is
already in progress while the history backfill that follows a connect is
running, and shows its progress instead of starting a second one.
The backfill reads the account's whole history, and for a watch that records
heart rate every minute that is a thousand-odd pages per year. It runs in
slices: a slice reads for up to five minutes or 500 pages, writes what it
read, notes how far it got and lets go of the account. The next slice starts
a minute later where the last one stopped. Two kinds of note are kept: a data
type read to the end is not read again, and a data type read page by page
(heart rate, weight, body fat, blood glucose, core temperature) keeps the
time of the oldest reading it has written, so the next slice asks Google only
for what is older. A restart or an update in the middle loses at most the page
that was being read.
Asking for "what is older" uses an upper bound on the sample time, which
Google's filter grammar allows but which a given account may still refuse. If
Google answers that first resumed request with `400`, the slice reads the data
type again without the bound, passes over the readings newer than its note
without writing them, and carries on writing from there. The refusal is noted
per data type, so later slices read without the bound straight away. The pages
passed over are not counted against the slice's 500 pages, but they do run on
its five minutes, and passing over stops after three minutes at most, so a
slice never holds the account longer than a slice. Wherever the walk stops, on
a limit or on a rate limit, it keeps Google's page token for the page it read
last, and the next slice reads that one page again and goes on from there
instead of passing over everything earlier slices wrote: the pages read stay
in proportion to the history. If Google no longer takes the token, or the page
it returns no longer reaches the noted reading, the slice starts again from the
newest page and passes over as before. A `400` that names something else (the
page size, or the page token of a walk that did not start from a kept one) is
still a failure.
The hourly sync waits for the slice that is running (up to eight minutes)
instead of skipping the account, so recent data keeps arriving while an old
history is still coming in. The hourly job stays inside its own fifteen-minute
limit: the wait is cut short when the job's time runs low, the sync after it
gets only what is left and leaves the rest of its window to the next hour, and
an account the job reaches after its time is up waits for the next hour.
Google limits how many requests one account may make per minute. When a sync
reaches that limit, Google answers `429`, and HealthLog waits and repeats the
request; the connection is never marked as needing a reconnect because of it.
Access tokens last an hour, and a long backfill renews its token as it goes.
A backfill that stops part-way (a restart, the job's time budget) picks up
with the data types it has not finished yet on its next attempt.
reaches that limit Google answers `429`, and HealthLog waits and repeats the
request. If Google asks for a longer wait than a minute, the slice ends there
and the next one starts five minutes later. A server error (`500`, `502`,
`503`, `504`), a request that times out and a dropped connection are repeated
three times before they count as a failure. None of these mark the connection
as needing a reconnect. Access tokens last an hour, and a long backfill renews
its token as it goes.
A slice that moved nothing forward (no data type finished, no reading older
than before) is a real failure: the job fails with the reason, pg-boss retries
it a few times, and after that the next restart offers the backfill again.
Everything already written stays.
If an older version left your Google Health card asking for a reconnect after
a backfill, the first sync after updating checks the stored grant and resumes
on its own when the grant is still valid.
### Reading the backfill in the logs
Every slice writes one log line when it ends, with
`"background":{"task_name":"job.integration_backfill"}`. The parts worth
reading:
- `meta.kind` is `google-health-backfill`, `meta.slice` the slice number
(0 for the first), `meta.job_id` the pg-boss job.
- `external_calls` lists the requests the slice made to Google, one entry per
request: `method` names the data type and page (`fetchHeartRate(page=212)`),
`status` the HTTP status, `duration_ms` how long it took. A request that was
repeated carries a `note` (`rate_limited_retrying_in_ms=4000`,
`server_error_retrying_in_ms=2000`, `no_response_retrying_in_ms=2000`,
`token_rejected_retrying_with_fresh_token`,
`edge_filter_rejected_reading_without_it`); one that failed for good
carries an `error`. Successful requests are listed up to 60 per line and
everything up to 200; `external_calls_omitted` counts the rest.
- `meta["googleHealth.backfill.slice"]` sums the slice up:
- `pages`, `elapsedMs` and `imported`: what the slice read, how long it
took, how many readings it wrote. `skippedPages` is the part of `pages`
read only to pass over readings already written, on a data type whose
upper-bound filter Google refused.
- `stopReason`: why it ended early. `time_budget` and `page_budget` are the
normal end of a slice, `rate_limited` means Google would not take more
requests for now, `job_budget` that the job itself ran out of time.
`null` means the slice read everything it started.
- `collectionsDone` of `collectionsTotal`: data types read to the end so
far.
- `collections`: one entry per data type the slice touched, with its
`status` (`done`, `stopped` short on a budget or a rate limit,
`deferred` to the next slice, or `failed`), its `pages`, and for the
page-by-page types
`edgeFrom` and `edgeTo`, the oldest reading written before and after the
slice.
- `edges`: where every unfinished page-by-page data type stands now.
- `meta["googleHealth.backfill.edgeFilterUnsupported"]` appears on the slice
where Google refused the upper-bound filter, listing the data types noted
as refusing it.
- `action.name` is `google_health.backfill.continued` for a slice that made
progress and queued the next one, `google_health.backfill.complete` for the
one that finished the history, and `google_health.backfill.stuck` for one
that moved nothing. A stuck slice also fails the job with a message that
names each unfinished data type and its status, for example
`no progress in slice 4, stopped on rate_limited; fetchHeartRate:stopped`.
While a slice runs, the usual `job.progress` line for the
`integration-backfill-admission` queue reports
`google_health_backfill_slice`, `google_health_backfill_pages` and
`google_health_backfill_collections_done` every two minutes.
## The 7-day re-consent caveat
While the OAuth consent screen stays in **Testing** publishing mode, Google
+6
View File
@@ -13,6 +13,12 @@ Migrations land **before** the new app container takes traffic. The
canonical command sequence on a single-instance host:
```sh
# 0. Dump the database first. A migration is not undone by going back to
# the previous image; the dump is the way back. See "Updates" in
# docs/self-hosting/getting-started.md.
docker compose exec -T db pg_dump -U healthlog healthlog \
| gzip > healthlog-before-update-$(date +%F).sql.gz
# 1. Pull the new image. If you pin a version with HEALTHLOG_IMAGE_REF,
# set it to the new one first.
docker compose pull app
+39
View File
@@ -361,3 +361,42 @@ For off-host encrypted backups to S3/R2/B2 see
[`docs/ops/backup-restore.md`](../ops/backup-restore.md); for rotating the
encryption key safely (keep the old key until zero rows remain on it) see
[`docs/ops/encryption-key-rotation.md`](../ops/encryption-key-rotation.md).
## Updates
A new image can bring database migrations, and `docker-entrypoint.sh`
applies them on the first start of that image. Most are additive, but some
rewrite data, and going back to the previous image does not undo them. The
only way back from a migration that went wrong is the dump you took before
it. So take one before every pull:
```bash
# 1. Dump the database (same command as under "Backup and restore").
docker compose exec -T db pg_dump -U healthlog healthlog \
| gzip > healthlog-before-update-$(date +%F).sql.gz
# 2. Pull the new image and recreate the app container.
docker compose pull app
docker compose up -d app
# 3. Confirm the running build.
curl -s http://localhost:3000/api/version
```
Keep the dump until the new version has run for a few days, and keep your
encryption key with it: a dump without the key cannot be read.
The start log says what is about to happen before the migrations run:
```text
HealthLog: 2 database migrations pending. Back up the database before updating (pg_dump, see the Updates section of docs/self-hosting/getting-started.md); a migration is not undone by going back to the previous image.
HealthLog: Running database migrations...
```
or `HealthLog: Database schema is up to date.` when the image brings none.
The line is a notice and does not pause the start. If you see a count and
have no dump yet, stop the container (`docker compose stop app`), take the
dump, and start it again; the migrations run on that start.
For the ordering on a host where you want the migration result before the
new container takes traffic, see [`docs/ops/deploy.md`](../ops/deploy.md).
+29
View File
@@ -605,6 +605,35 @@ async function installA11yMocks(page: Page) {
pdfSupported: true,
}),
);
// The lab list reads one row per marker from the overview route.
await page.route("**/api/labs/overview", (route) =>
fulfilJson(route, {
markers: [
{
key: "analyte:a11y ldl",
count: 1,
trend: [2.4],
latest: {
id: "a11y-lab",
biomarkerId: null,
panel: "A11y panel",
analyte: "A11y LDL",
value: 2.4,
valueText: null,
unit: "mmol/L",
referenceLow: 0,
referenceHigh: 3,
takenAt: "2026-07-19T12:00:00.000Z",
source: "MANUAL",
hasNote: false,
rangeStatus: "inRange",
createdAt: "2026-07-19T12:00:00.000Z",
updatedAt: "2026-07-19T12:00:00.000Z",
},
},
],
}),
);
await page.route("**/api/labs?*", (route) =>
fulfilJson(route, {
results: [
+220
View File
@@ -0,0 +1,220 @@
import AxeBuilder from "@axe-core/playwright";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import {
INTEGRATION_CARD_STATES,
mockIntegrationCards,
} from "./setup/integration-cards-fixture";
/**
* Settings → Integrations: the shared integration card, one card per state.
*
* Every provider is mocked into a different `health` (see the fixture), and
* the page is checked at desktop width and at 390 px. Assertions read data
* attributes only: the card's `data-state`, the badge's `data-state`, the one
* primary button's `data-action`. The wording is pinned by the component
* tests against the bundle itself.
*
* Runs in the desktop project and sizes the page itself, so one run covers
* both widths without a second project driving the same account.
*/
const VIEWPORTS = [
{ name: "desktop", width: 1280, height: 900 },
{ name: "390 px", width: 390, height: 844 },
] as const;
test.describe("Settings → Integrations: one card per health state", () => {
test.use({ storageState: STORAGE_STATE_PATH });
test.beforeEach(({}, testInfo) => {
test.skip(
testInfo.project.name !== "chromium-desktop",
"sizes the viewport itself",
);
});
for (const viewport of VIEWPORTS) {
test(`renders each state from health alone at ${viewport.name}`, async ({
page,
}) => {
await page.setViewportSize(viewport);
await mockIntegrationCards(page);
await page.goto("/settings/integrations");
for (const [provider, expected] of Object.entries(
INTEGRATION_CARD_STATES,
)) {
const card = page.locator(
`[data-slot="integration-card"][data-integration="${provider}"]`,
);
await expect(card, provider).toHaveAttribute(
"data-state",
expected.state,
{ timeout: 15_000 },
);
const badge = card.locator('[data-slot="integration-health-badge"]');
if (expected.state === "not_connected") {
await expect(badge, provider).toHaveCount(0);
} else {
await expect(badge, provider).toHaveAttribute(
"data-state",
expected.state,
);
// The word is the accessible name, not decoration.
await expect(badge, provider).not.toHaveText("");
}
// At most one primary, and exactly the one the state names.
const primary = card.locator(
'[data-slot="integration-primary-action"]',
);
if (expected.primary === null) {
await expect(primary, provider).toHaveCount(0);
} else {
await expect(primary, provider).toHaveCount(1);
await expect(primary, provider).toHaveAttribute(
"data-action",
expected.primary,
);
if (viewport.width < 640) {
const box = await primary.boundingBox();
expect(
box?.height ?? 0,
`${provider} tap target`,
).toBeGreaterThanOrEqual(44);
}
}
}
// Nothing on the page states a status the old way.
await expect(
page.locator('[data-testid="integration-status-pill"]'),
).toHaveCount(0);
const dims = await page.evaluate(() => ({
scrollWidth: document.documentElement.scrollWidth,
innerWidth: window.innerWidth,
}));
expect(
dims.scrollWidth,
`scrollWidth=${dims.scrollWidth}, innerWidth=${dims.innerWidth}`,
).toBeLessThanOrEqual(dims.innerWidth + 1);
});
}
test("the menu works from the keyboard and keeps its confirmations", async ({
page,
}) => {
await mockIntegrationCards(page);
await page.goto("/settings/integrations");
const card = page.locator(
'[data-slot="integration-card"][data-integration="withings"]',
);
const trigger = card.locator('[data-slot="integration-menu-trigger"]');
await expect(trigger).toBeVisible({ timeout: 15_000 });
await trigger.focus();
await page.keyboard.press("Enter");
const menu = page.locator('[data-slot="integration-menu"]');
await expect(menu).toBeVisible();
await expect(
menu.locator('[data-slot="integration-menu-item"]'),
).toHaveCount(7);
// Disconnect asks first; dismissing the dialog does nothing.
await menu.locator('[data-action="disconnect"]').focus();
await page.keyboard.press("Enter");
const dialog = page.getByRole("alertdialog");
await expect(dialog).toBeVisible();
await page.keyboard.press("Escape");
await expect(dialog).toHaveCount(0);
await expect(card).toHaveAttribute("data-state", "limited");
// Escape closes the menu and returns focus to its trigger.
await trigger.focus();
await page.keyboard.press("Enter");
await expect(menu).toBeVisible();
await page.keyboard.press("Escape");
await expect(menu).toHaveCount(0);
await expect(trigger).toBeFocused();
});
test("details open on request and Copy puts exactly the shown text on the clipboard", async ({
page,
context,
}) => {
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
await mockIntegrationCards(page);
await page.goto("/settings/integrations");
const card = page.locator(
'[data-slot="integration-card"][data-integration="polar"]',
);
const toggle = card.locator('[data-slot="integration-details-toggle"]');
const panel = card.locator('[data-slot="integration-details"]');
await expect(toggle).toHaveAttribute("aria-expanded", "false", {
timeout: 15_000,
});
await expect(panel).toBeHidden();
await toggle.click();
await expect(toggle).toHaveAttribute("aria-expanded", "true");
await expect(panel).toBeVisible();
const shown = await panel
.locator('[data-slot="integration-diagnostics"]')
.innerText();
expect(shown).toContain("degraded / provider_errors");
expect(shown).toContain("503");
await panel.locator('[data-slot="integration-diagnostics-copy"]').click();
const status = panel.locator(
'[data-slot="integration-diagnostics-copy-status"]',
);
await expect(status).toHaveAttribute("data-state", "copied");
await expect(status).toHaveAttribute("role", "status");
const copied = await page.evaluate(() => navigator.clipboard.readText());
expect(copied.trim()).toBe(shown.trim());
});
for (const theme of ["light", "dark"] as const) {
test(`every state reads accessibly in the ${theme} theme`, async ({
page,
}) => {
await page.emulateMedia({ colorScheme: theme, reducedMotion: "reduce" });
await page.addInitScript((explicit: string) => {
window.localStorage.setItem("healthlog-theme", explicit);
}, theme);
await mockIntegrationCards(page);
await page.goto("/settings/integrations");
const panel = page.locator('[data-slot="connections-panel"]');
await expect(
panel.locator(
'[data-slot="integration-card"][data-integration="whoop"]',
),
).toHaveAttribute("data-state", "action_required", { timeout: 15_000 });
// Scan with one details panel open, so the diagnostics block and its
// copy button are part of what is checked.
await panel
.locator(
'[data-integration="polar"] [data-slot="integration-details-toggle"]',
)
.click();
const results = await new AxeBuilder({ page })
.include('[data-slot="connections-panel"]')
.withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"])
.analyze();
const serious = results.violations
.filter((v) => v.impact === "serious" || v.impact === "critical")
.map(
(v) =>
`${v.id}: ${v.nodes.map((n) => n.target.join(" ")).join(", ")}`,
);
expect(serious).toEqual([]);
});
}
});
+96 -79
View File
@@ -3,22 +3,16 @@ import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
/**
* v1.4.19 phase A5 — `/settings/integrations` on a Pixel-5 viewport
* (393 CSS px) must:
* `/settings/integrations` on a Pixel-5 viewport (393 CSS px) must:
*
* 1. Not produce horizontal page overflow. The new
* IntegrationStatusPill is `whitespace-nowrap` so the chip
* itself can grow as wide as its longest possible label
* ("Verbunden · vor 12 min" / "Error — reconnect"); the parent
* header is a `flex-wrap` so the pill drops to its own line if
* the title and pill together exceed the card width — this spec
* verifies neither path leaks into a horizontal scrollbar on the
* <html> element.
* 1. Not produce horizontal page overflow. The badge in each card header is
* `whitespace-nowrap`; the header row wraps so the badge drops under the
* title when both do not fit. Neither path may leak into a horizontal
* scrollbar on the <html> element.
*
* 2. Render exactly one pill per integration card. Belt-and-braces
* guard for the card consolidation — the Vitest spec covers this
* on the SSR markup level, but the Pixel-5 layout is what matters
* in practice.
* 2. Render exactly one status badge per connected card, and none on a card
* that is not connected. The Vitest spec covers this on the SSR markup;
* the Pixel-5 layout is what matters in practice.
*
* Mobile-only spec — desktop project skips it.
*/
@@ -29,7 +23,7 @@ test.describe("/settings/integrations Pixel-5 layout", () => {
test.skip(testInfo.project.name !== "chromium-mobile", "mobile-only spec");
});
test("Pixel-5 viewport: no horizontal page scroll, one pill per card", async ({
test("Pixel-5 viewport: no horizontal page scroll, one badge per connected card", async ({
page,
}) => {
// Stub the three endpoints the Integrations section reads — both
@@ -66,42 +60,56 @@ test.describe("/settings/integrations Pixel-5 layout", () => {
integrations: [
{
integration: "withings",
state: "connected",
lastSuccessAt: recent,
lastAttemptAt: recent,
lastError: null,
consecutiveFailures: 0,
// v1.12.1 — the cards read connection state from this
// consolidated envelope (the per-provider routes were
// dropped from the section). The Withings pill keys on
// `connected`; a card-less pill keys on `configured`.
configured: true,
connected: true,
connectedAt: "2026-04-01T12:00:00Z",
legacyLastSyncedAt: recent,
tokenExpiresAt: "2026-12-01T12:00:00Z",
tokenExpired: false,
scope: "user.info,user.metrics,user.activity",
hasActivityScope: true,
syncHealth: { verdict: "fresh", since: null },
health: {
state: "ok",
reason: "data_arriving",
since: null,
action: null,
unavailable: [],
lastSuccessAt: recent,
lastDataAt: recent,
diagnostics: {
ledgerState: "connected",
failingLegs: [],
failingSince: null,
lastAttemptAt: recent,
verdict: "fresh",
recentCalls: [],
},
},
metricFreshness: [
{ type: "WEIGHT", lastSeenAt: recent, stale: false },
],
},
{
// Configured, card-less, and no longer attempting: the case
// the fallback row exists for. Synthetic since v1.32.33 —
// every shipped provider has a card again, and the row must
// stay proven before the next one is dropped.
// Configured, card-less, and no longer syncing: the case the
// fallback row exists for. Synthetic since v1.32.33 — every
// shipped provider has a card again, and the row must stay
// proven before the next one is dropped.
integration: "example-provider",
state: "connected",
lastSuccessAt: recent,
lastAttemptAt: recent,
lastError: null,
consecutiveFailures: 0,
configured: true,
legacyLastSyncedAt: monthAgo,
syncHealth: { verdict: "stalled", since: monthAgo },
connected: true,
health: {
state: "degraded",
reason: "sync_stalled",
since: monthAgo,
action: null,
unavailable: [],
lastSuccessAt: monthAgo,
lastDataAt: monthAgo,
diagnostics: {
ledgerState: "connected",
failingLegs: [],
failingSince: null,
lastAttemptAt: monthAgo,
verdict: "stalled",
recentCalls: [],
},
},
metricFreshness: [],
},
// v1.28.x — Strava OAuth card reads off this consolidated
@@ -109,30 +117,23 @@ test.describe("/settings/integrations Pixel-5 layout", () => {
// never resolves and the integrations page hangs.
{
integration: "strava",
state: "disconnected",
lastSuccessAt: null,
lastAttemptAt: null,
lastError: null,
connected: false,
configured: false,
available: false,
hasOwnCredentials: false,
syncHealth: { verdict: "disconnected", since: null },
health: null,
metricFreshness: [],
},
// Nightscout reads the envelope too since the card dropped its
// own status round-trip; without an entry its pill never resolves.
// own status round-trip; without an entry its card never settles.
{
integration: "nightscout",
state: "disconnected",
lastSuccessAt: null,
lastAttemptAt: null,
lastError: null,
connected: false,
configured: false,
hasToken: false,
allowPrivateHost: false,
syncHealth: { verdict: "disconnected", since: null },
health: null,
metricFreshness: [],
},
],
@@ -142,6 +143,19 @@ test.describe("/settings/integrations Pixel-5 layout", () => {
}),
);
// Apple Health has delivered nothing on this account: not connected, so
// no badge. Mocked so a seeded HealthKit row cannot add one.
await page.route("**/api/integrations/healthkit", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: { entries: [], lastSyncedAt: null, health: null },
error: null,
}),
}),
);
await page.route("**/api/withings/status", (route) =>
route.fulfill({
status: 200,
@@ -164,42 +178,45 @@ test.describe("/settings/integrations Pixel-5 layout", () => {
});
await page.waitForLoadState("networkidle");
// Wait for the pills to mount (queries resolve after first paint).
// One pill per card: Withings, WHOOP, Fitbit, Google Health, Polar,
// Oura, Strava, Nightscout — plus the fallback row for the configured,
// card-less entry. (The Garmin info note is not an OAuth card and
// carries no pill; the Apple Health card's pill keeps its own testid.)
const pills = page.locator('[data-testid="integration-status-pill"]');
await expect(pills).toHaveCount(9, { timeout: 10_000 });
// Wait for the cards to settle (queries resolve after first paint).
// Withings and the card-less fallback are connected: one badge each.
// Every provider without an envelope entry stays in its loading state,
// and the not-connected ones carry no badge at all.
const badges = page.locator('[data-slot="integration-health-badge"]');
await expect(badges).toHaveCount(2, { timeout: 10_000 });
// The acceptance case: a configured integration with no card of its own,
// which has stopped even attempting, is visible on the page.
// which has stopped syncing, is visible on the page, read-only.
const fallback = page.locator(
'[data-testid="integration-fallback-row"][data-integration="example-provider"]',
);
await expect(fallback).toHaveCount(1);
await expect(
fallback.locator('[data-testid="integration-status-pill"]'),
).toHaveAttribute("data-state", "stalled");
fallback.locator('[data-slot="integration-health-badge"]'),
).toHaveAttribute("data-state", "degraded");
await expect(
fallback.locator('[data-slot="integration-menu-trigger"]'),
).toHaveCount(0);
// Card anatomy is uniform because no integration card paints a rule under
// its header any more. The `<hr>` existed only in this folder — nine cards
// carrying a separator no other Settings card has — and the card's own gap
// does the separating now.
// its header any more.
await expect(
page.locator('[data-testid="integration-card-divider"]'),
).toHaveCount(0);
// Withings is connected in this fixture; WHOOP is a BYO-keys card that
// stays dormant until an operator adds credentials, so assert the one
// connected pill.
await expect(
page.locator(
'[data-testid="integration-status-pill"][data-state="connected"]',
'[data-slot="integration-card"][data-integration="withings"]',
),
).toHaveCount(1);
).toHaveAttribute("data-state", "ok");
await expect(
page.locator('[data-slot="integration-card"][data-integration="strava"]'),
).toHaveAttribute("data-state", "not_connected");
// The redundant v1.4.18 banner must be gone.
// The retired status surfaces stay gone.
await expect(
page.locator('[data-testid="integration-status-pill"]'),
).toHaveCount(0);
await expect(
page.locator('[data-testid="integration-status-banner"]'),
).toHaveCount(0);
@@ -215,12 +232,11 @@ test.describe("/settings/integrations Pixel-5 layout", () => {
`scrollWidth=${dims.scrollWidth}, innerWidth=${dims.innerWidth}`,
).toBeLessThanOrEqual(dims.innerWidth + 1);
// Pill text remains legible at this viewport — assert non-empty
// bounding rect so we know it's painted, not display:none-d by
// some surprising container query.
// Badge text remains legible at this viewport: painted, not hidden by a
// surprising container query.
for (let i = 0; i < 2; i++) {
const box = await pills.nth(i).boundingBox();
expect(box, `pill ${i} has no bounding box`).not.toBeNull();
const box = await badges.nth(i).boundingBox();
expect(box, `badge ${i} has no bounding box`).not.toBeNull();
expect(box!.width).toBeGreaterThan(0);
expect(box!.height).toBeGreaterThan(0);
}
@@ -241,7 +257,8 @@ test.describe("Apple Health guidance", () => {
data: {
entries: [],
lastSyncedAt: null,
syncHealth: { verdict: "pending_first_sync", since: null },
// Nothing delivered yet: no health, so the card is not connected.
health: null,
metricFreshness: [],
// #778 — nothing accepted yet, so the card must show no
// progress section for this pending-first-sync account.
@@ -268,10 +285,10 @@ test.describe("Apple Health guidance", () => {
// A raw key means the bundle lookup failed, which is the failure this
// assertion can actually see from a browser.
await expect(description).not.toContainText("settings.appleHealth");
await expect(page.getByTestId("apple-health-status")).toHaveAttribute(
"data-state",
"pending-setup",
);
await expect(card).toHaveAttribute("data-state", "not_connected");
await expect(
card.locator('[data-slot="integration-health-badge"]'),
).toHaveCount(0);
const importLink = page.getByTestId("apple-health-import-link");
await expect(importLink).toHaveAttribute(
+248
View File
@@ -0,0 +1,248 @@
import type { Page } from "@playwright/test";
/**
* Settings → Integrations with one card per health state, all mocked.
*
* Each provider carries the `health` the server would publish for one
* situation, so a single page shows every state the shared card can be in:
*
* withings limited / plan_limited (no button)
* oura limited / permission_limited (one Reconnect)
* fitbit ok / data_arriving (older history loading)
* polar degraded / provider_errors (no button)
* whoop action_required / paused (one Resume)
* google-health action_required / reauth (one Reconnect)
* strava not connected (Connect)
* nightscout not connected (address form)
* apple-health degraded / no_new_data
*
* Timestamps are relative to now, so the sentences read the same on any day.
*/
const HOUR = 60 * 60 * 1000;
function ago(hours: number): string {
return new Date(Date.now() - hours * HOUR).toISOString();
}
function health(overrides: Record<string, unknown> = {}) {
return {
state: "ok",
reason: "data_arriving",
since: null,
action: null,
unavailable: [],
lastSuccessAt: ago(1),
lastDataAt: ago(2),
diagnostics: {
ledgerState: "connected",
failingLegs: [],
failingSince: null,
lastAttemptAt: ago(1),
verdict: "fresh",
recentCalls: [
{ endpoint: "measures", status: "ok", at: ago(1), errorCode: null },
],
},
...overrides,
};
}
function connected(integration: string, h: Record<string, unknown>) {
return {
integration,
lastError: null,
connected: true,
configured: true,
available: true,
hasOwnCredentials: true,
health: h,
metricFreshness: [
{ type: "WEIGHT", lastSeenAt: ago(2), stale: false },
{ type: "PULSE", lastSeenAt: ago(80), stale: true },
],
};
}
function disconnected(integration: string) {
return {
integration,
lastError: null,
connected: false,
configured: false,
available: true,
hasOwnCredentials: false,
health: null,
metricFreshness: [],
};
}
export const INTEGRATION_CARD_STATES = {
withings: { state: "limited", primary: null },
oura: { state: "limited", primary: "reconnect" },
fitbit: { state: "ok", primary: null },
polar: { state: "degraded", primary: null },
whoop: { state: "action_required", primary: "resume" },
"google-health": { state: "action_required", primary: "reconnect" },
strava: { state: "not_connected", primary: "connect" },
nightscout: { state: "not_connected", primary: "connect" },
"apple-health": { state: "degraded", primary: null },
} as const;
export function integrationCardsEnvelope() {
return {
threshold: 3,
metricFreshnessDegraded: false,
integrations: [
connected(
"withings",
health({
state: "limited",
reason: "plan_limited",
unavailable: [
{ item: "spo2", cause: "plan" },
{ item: "vascularAge", cause: "plan" },
{ item: "pulseWaveVelocity", cause: "plan" },
{ item: "vo2Max", cause: "plan" },
{ item: "ecg", cause: "plan" },
],
}),
),
{
...connected("whoop", {
...health({
state: "action_required",
reason: "paused_after_errors",
since: ago(50),
action: "resume",
lastDataAt: ago(52),
}),
diagnostics: {
ledgerState: "parked",
failingLegs: ["recovery", "sleep"],
failingSince: ago(50),
lastAttemptAt: ago(3),
verdict: "parked",
recentCalls: [
{
endpoint: "recovery",
status: "persistent",
at: ago(3),
errorCode: "500",
},
{
endpoint: "sleep",
status: "persistent",
at: ago(3),
errorCode: "500",
},
],
},
}),
consecutiveFailuresByKind: {
transient: 0,
reauth_required: 0,
persistent: 4,
},
},
{
...connected("fitbit", health()),
backfillCompleted: false,
},
connected(
"google-health",
health({
state: "action_required",
reason: "reauth_required",
action: "reconnect",
since: ago(30),
lastDataAt: ago(31),
}),
),
connected(
"polar",
health({
state: "degraded",
reason: "provider_errors",
since: ago(9),
lastDataAt: ago(20),
diagnostics: {
ledgerState: "error_transient",
failingLegs: ["sleep"],
failingSince: ago(9),
lastAttemptAt: ago(1),
verdict: "failing",
recentCalls: [
{
endpoint: "sleep",
status: "transient",
at: ago(1),
errorCode: "503",
},
{
endpoint: "sleep",
status: "transient",
at: ago(2),
errorCode: "503",
},
{
endpoint: "activity",
status: "ok",
at: ago(2),
errorCode: null,
},
],
},
}),
),
connected(
"oura",
health({
state: "limited",
reason: "permission_limited",
action: "reconnect",
unavailable: [
{ item: "spo2", cause: "permission" },
{ item: "vo2max", cause: "permission" },
],
}),
),
disconnected("strava"),
{ ...disconnected("nightscout"), hasToken: false },
],
};
}
export function appleHealthStatus() {
return {
entries: [],
lastSyncedAt: ago(60),
lastSyncTrigger: "foreground",
lastBackgroundSyncAt: null,
metricFreshness: [],
syncProgress: { recordsAccepted: 48211, oldestMeasuredAt: ago(24 * 900) },
health: health({
state: "degraded",
reason: "no_new_data",
since: ago(60),
lastDataAt: ago(60),
}),
};
}
/** Route every read the Integrations page makes onto the fixture. */
export async function mockIntegrationCards(page: Page): Promise<void> {
await page.route("**/api/integrations/status", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ data: integrationCardsEnvelope(), error: null }),
}),
);
await page.route("**/api/integrations/healthkit", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ data: appleHealthStatus(), error: null }),
}),
);
}
+1 -1
View File
@@ -560,7 +560,7 @@ test("a chosen range lives in the URL: reload keeps it, Back returns to it", asy
// A parameter it cannot read opens the same default as no zoom: this
// record has entries in the last three months, so three months.
await page.goto("/timeline?zoom=range&from=nonsense&to=2026-01-01");
await page.goto(`/timeline?zoom=range&from=nonsense&to=${TODAY}`);
await expect(zoom("quarter")).toHaveAttribute("aria-checked", "true", {
timeout: 20_000,
});
+10 -2
View File
@@ -23,13 +23,21 @@ function correlation(r: number, strength: string, n: number) {
};
}
/**
* A calendar day this many days back, so the heatmap cells stay inside its
* 90-day window whenever the suite runs.
*/
function dayKeyDaysAgo(days: number): string {
return new Date(Date.now() - days * 86_400_000).toISOString().slice(0, 10);
}
const MOOD_INSIGHTS_PAYLOAD = {
summary: { totalEntries: 269, inTargetPct: 62 },
heatmap: {
windowDays: 90,
cells: [
{ date: "2026-07-01", score: 4, samples: 2 },
{ date: "2026-07-02", score: 3, samples: 1 },
{ date: dayKeyDaysAgo(14), score: 4, samples: 2 },
{ date: dayKeyDaysAgo(13), score: 3, samples: 1 },
],
},
distribution: [],
+15 -2
View File
@@ -16,7 +16,7 @@ import {
const DOMAIN_READS = {
measurements: "/api/measurements",
medications: "/api/medications",
labs: "/api/labs",
labs: "/api/labs/overview",
profile: "/api/profile/summary",
illness: "/api/illness/episodes",
mind: "/api/mood-entries",
@@ -628,8 +628,21 @@ test.describe.serial("scoped sharing browser journeys", () => {
);
await expect(integrations).toBeVisible();
await expect(integrations).toHaveAttribute("data-record-id", accountId);
// Read-only: the shared integration cards render, but nothing on them
// acts on a provider. The only buttons are each card's own "details for a
// bug report" toggle and its copy button.
await expect(
integrations.locator("button, input, select, textarea"),
integrations.locator('[data-slot="integration-card"]'),
).toHaveCount(8);
await expect(
integrations.locator(
'[data-slot="integration-primary-action"], [data-slot="integration-menu-trigger"], input, select, textarea',
),
).toHaveCount(0);
await expect(
integrations.locator(
'button:not([data-slot="integration-details-toggle"]):not([data-slot="integration-diagnostics-copy"])',
),
).toHaveCount(0);
await page.goBack();
+79 -26
View File
@@ -1386,6 +1386,7 @@
"emptyState": "Noch keine Nebenwirkungen erfasst. Tracking hilft dir und deiner Ärztin, Muster über die Zeit zu erkennen.",
"recentTitle": "Letzte 30 Tage",
"loading": "Wird geladen…",
"loadMore": "Weitere laden",
"deleteCta": "Löschen",
"deleteError": "Eintrag konnte nicht gelöscht werden",
"errorSeverity": "Schweregrad muss zwischen 1 und 5 liegen",
@@ -4569,7 +4570,7 @@
"description": "Wie gut sich dein Körper erholt hat: die Erholungs-, Belastungs- und Load-Signale, die dein Wearable über Nacht aufzeichnet.",
"scoreCrossLink": "Den zusammengesetzten Erholungs-Score ansehen",
"emptyTitle": "Noch keine Erholungsdaten",
"empty": "Noch keine Erholungsdaten. Verbinde ein WHOOP-, Polar- oder Oura-Konto, dann füllen sich diese Werte, sobald dein Gerät synchronisiert.",
"empty": "Hier ist noch nichts zu sehen. Diese Seite zeigt die Werte zu Aufladung, Belastung und Trainingslast, die WHOOP und Polar aufzeichnen, und Ouras Resilienz-Stufe. Ouras Readiness erscheint als dein Erholungs-Score, oben verlinkt.",
"block": {
"ansChargeExplainer": "Wie aufgeladen dein autonomes Nervensystem in den Tag startet, ein Recharge-Wert aus deinen nächtlichen Herz- und Atemsignalen.",
"dayStrainExplainer": "Die kardiovaskuläre Tagesbelastung auf der Belastungsskala deines Geräts. Lies sie im Verhältnis zu deiner Erholung, nicht als Ziel für sich.",
@@ -5315,7 +5316,8 @@
"unavailableTitle": "In diesem geteilten Datensatz nicht verfügbar",
"unavailableDescription": "Dieser Einstellungsbereich ist beim Anzeigen des Datensatzes einer anderen Person bewusst nicht verfügbar.",
"integrationStatusDescription": "Verbindungsstatus für diesen verwalteten Datensatz.",
"integrationStatusDetail": "Verbindungssteuerungen sind nicht verfügbar."
"integrationStatusDetail": "Ein betreutes Profil zeigt seine verbundenen Dienste nur als Status. Verbinden, neu verbinden, synchronisieren und trennen ist für Betreuende nicht möglich.",
"integrationStatusDocs": "So funktioniert das Teilen"
},
"sections": {
"account": {
@@ -6391,7 +6393,6 @@
"whoopDisconnectDescription": "Die Verbindung zu WHOOP wird getrennt. Bereits synchronisierte Daten bleiben erhalten.",
"whoopConnect": "Mit WHOOP verbinden",
"whoopNoCredentials": "Bitte gib oben deine API-Zugangsdaten ein, um WHOOP zu verbinden.",
"whoopBackfillInProgress": "Dein WHOOP-Verlauf wird im Hintergrund importiert…",
"whoopOauthConnected": "WHOOP verbunden. Die erste Synchronisierung startet im Hintergrund.",
"whoopOauthFailed": "WHOOP-Verbindung fehlgeschlagen",
"whoopOauthError": {
@@ -6427,7 +6428,6 @@
"fitbitDisconnectDescription": "Die Verbindung zu Fitbit wird getrennt. Zuvor synchronisierte Daten bleiben erhalten.",
"fitbitConnect": "Mit Fitbit verbinden",
"fitbitNoCredentials": "Bitte gib oben deine API-Zugangsdaten ein, um Fitbit zu verbinden.",
"fitbitBackfillInProgress": "Dein Fitbit-Verlauf wird im Hintergrund importiert…",
"fitbitOauthConnected": "Fitbit verbunden. Die erste Synchronisierung startet im Hintergrund.",
"fitbitOauthFailed": "Fitbit-Verbindung fehlgeschlagen",
"googleHealth": "Google Health",
@@ -6472,9 +6472,6 @@
"googleHealthDisconnectDescription": "Die Verbindung zu Google Health wird getrennt. Bereits synchronisierte Daten bleiben erhalten.",
"googleHealthConnect": "Mit Google verbinden",
"googleHealthNoCredentials": "Bitte gib oben deine API-Zugangsdaten ein, um Google Health zu verbinden.",
"googleHealthBackfillInProgress": "Dein Google-Health-Verlauf wird im Hintergrund importiert…",
"googleHealthReauthBanner": "Google hat die Verbindung ablaufen lassen. Das passiert regelmäßig, solange deine OAuth-App im Testmodus bleibt. Verbinde neu, um die Synchronisierung fortzusetzen.",
"googleHealthReconnect": "Neu verbinden",
"googleHealthOauthConnected": "Google Health verbunden. Die erste Synchronisierung startet im Hintergrund.",
"googleHealthOauthFailed": "Verbindung zu Google Health fehlgeschlagen",
"googleHealthOauthError": {
@@ -6499,16 +6496,7 @@
},
"integrations": {
"withings": {
"reconnect": {
"banner": {
"title": "Aktivitäts-Sync verfügbar",
"body": "Deine Withings-Verbindung wurde autorisiert, bevor der Aktivitäts-Sync hinzugefügt wurde. Verbinde einmal neu, um Schritte, aktive Energie, Geh- und Laufdistanz sowie Stockwerke aus deinen Withings-Geräten zu übernehmen.",
"action": "Withings neu verbinden"
}
},
"plan": {
"title": "Einige Werte brauchen den kostenpflichtigen API-Plan von Withings",
"body": "Withings bietet {list} jetzt nur noch im kostenpflichtigen API-Plan an, und deine Withings-Entwickler-App nutzt den kostenlosen Plan. Gewicht, Blutdruck, Körperzusammensetzung, Herzfrequenz und Schlaf werden weiter synchronisiert.",
"link": "Mehr zu den API-Plänen von Withings",
"items": {
"spo2": "SpO2",
@@ -6538,10 +6526,7 @@
},
"oura": {
"scope": {
"body": "Verbinde Oura einmal neu, um {list} freizugeben. Alles andere wird weiter synchronisiert.",
"action": "Oura neu verbinden",
"items": {
"spo2": "SpO2",
"vo2max": "VO2max",
"cardio_age": "kardiovaskuläres Alter",
"resilience": "Resilienz"
@@ -6560,7 +6545,6 @@
"minutesAgo": "vor {count} min",
"hoursAgo": "vor {count} Std.",
"daysAgo": "vor {count} d",
"resumeCta": "Wieder verbinden",
"resumeSuccess": "Integration wieder aktiv",
"resumeError": "Wieder verbinden fehlgeschlagen",
"stale": "Verbunden, keine neuen Daten",
@@ -6571,12 +6555,11 @@
"title": "Empfangene Daten",
"summary": "{count} Datentypen, neuestes {relative}",
"quiet": "{count} still",
"workouts": "Workouts"
"workouts": "Workouts",
"medicationDoses": "Medikamenteneinnahmen"
},
"integrationFallback": {
"description": "Für dieses Konto eingerichtet, hat aber keine eigene Einstellungskarte.",
"lastAttempt": "Letzter Versuch {relative}",
"noAttempt": "Kein Sync-Versuch aufgezeichnet"
"description": "Für dieses Konto eingerichtet, hat aber keine eigene Einstellungskarte."
},
"withingsViewData": "Messwerte ansehen",
"whoopViewData": "Erholung ansehen",
@@ -7124,7 +7107,6 @@
"backgroundStep": "Lass die Hintergrundaktualisierung aktiviert. Die App synchronisiert neue Daten automatisch im Vordergrund und wenn iOS Hintergrundzeit gewährt.",
"importNote": "Du brauchst ältere Daten oder kannst die iOS-App nicht nutzen? Der Apple-Health-XML/ZIP-Upload ist ein einmaliger Ersatzweg und aktiviert keine Live-Synchronisierung.",
"importAction": "Einmaligen Import öffnen",
"staleNote": "Seit über einer Woche sind keine Apple-Health-Daten angekommen. Öffne die HealthLog-App auf deinem iPhone und prüfe die Apple-Health-Berechtigungen.",
"progress": {
"title": "Sync-Fortschritt",
"receivedLabel": "Empfangene Datensätze",
@@ -7150,6 +7132,7 @@
"title": "Nach Datentyp",
"lastReceived": "Zuletzt empfangen {relative}",
"lastNewValue": "Letzter neuer Wert {relative}",
"lastBackground": "Zuletzt im Hintergrund {relative}",
"never": "Noch nicht empfangen"
}
},
@@ -7217,6 +7200,73 @@
"displayCard": {
"title": "Darstellung",
"description": "Sprache, Einheiten und Formate, gespeichert, sobald du sie änderst."
},
"integrationCard": {
"state": {
"ok": "Läuft",
"limited": "Eingeschränkt",
"degraded": "Verzögert",
"actionRequired": "Aktion nötig"
},
"reason": {
"dataArriving": "Daten kommen an.",
"dataArrivingHistory": "Daten kommen an. Der ältere Verlauf wird im Hintergrund geladen.",
"firstSyncPending": "Verbunden. Die erste Synchronisierung läuft in Kürze.",
"planLimited": "Daten kommen an. Dein {provider}-Tarif enthält {list} nicht.",
"permissionLimited": "Daten kommen an. Für {list} fehlt die Freigabe.",
"providerErrors": "{provider} antwortet seit {since} nicht zuverlässig. HealthLog versucht es weiter, du musst nichts tun.",
"providerErrorsNoSince": "{provider} antwortet gerade nicht zuverlässig. HealthLog versucht es weiter, du musst nichts tun.",
"syncStalled": "Seit {since} hat HealthLog nicht mehr synchronisiert. Das liegt am Server, nicht an deinem Gerät.",
"syncStalledNoSince": "HealthLog synchronisiert gerade nicht. Das liegt am Server, nicht an deinem Gerät.",
"noNewData": "Seit {since} sind keine neuen Daten gekommen.",
"noNewDataNoSince": "Seit einer Weile sind keine neuen Daten gekommen.",
"noNewDataApple": "Seit {since} sind keine neuen Daten gekommen. Öffne HealthLog einmal auf dem iPhone.",
"noNewDataAppleNoSince": "Seit einer Weile sind keine neuen Daten gekommen. Öffne HealthLog einmal auf dem iPhone.",
"unexpectedSilence": "Seit {since} kommen keine Daten, obwohl {provider} sonst täglich liefert.",
"unexpectedSilenceNoSince": "Es kommen keine Daten, obwohl {provider} sonst täglich liefert.",
"reauthRequired": "Die Verbindung zu {provider} ist abgelaufen. Verbinde neu, damit wieder Daten kommen.",
"pausedAfterErrors": "{provider} ist pausiert, weil es seit {since} nur Fehler gab.",
"pausedAfterErrorsNoSince": "{provider} ist pausiert, weil es zuletzt nur Fehler gab."
},
"when": {
"today": "heute, {time}",
"yesterday": "gestern, {time}",
"date": "{date}, {time}"
},
"items": {
"activity": "Aktivität",
"sleep": "Schlaf"
},
"moreValuesOne": "{count} weiteren Wert",
"moreValuesFew": "{count} weitere Werte",
"moreValuesOther": "{count} weitere Werte",
"notConnected": "Nicht verbunden.",
"lastData": "Letzte Daten: {when}",
"lastDataNone": "Letzte Daten: noch keine",
"reconnect": "Neu verbinden",
"resume": "Fortsetzen",
"menuLabel": "Weitere Aktionen für {provider}",
"menu": {
"credentials": "Zugangsdaten ändern",
"address": "Adresse und Token ändern"
},
"syncing": "Synchronisierung läuft…",
"details": {
"title": "Details für eine Fehlermeldung",
"none": "keine",
"state": "Zustand",
"since": "seit",
"lastSync": "Letzte Synchronisierung",
"lastData": "Letzte Daten",
"failingParts": "fehlende Teile",
"recentCalls": "Letzte Aufrufe",
"unavailable": "Nicht verfügbar",
"unavailableList": "Nicht verfügbar: {list}",
"causePlan": "Tarif",
"causePermission": "Freigabe",
"failureStreak": "Fehlerserie: {count} von {threshold}",
"copyFailed": "Kopieren hat nicht geklappt. Markiere den Text und kopiere ihn von Hand."
}
}
},
"admin": {
@@ -12174,7 +12224,10 @@
"suggested": "Um dieses Datum",
"loadError": "Die Liste konnte nicht geladen werden.",
"removeNamed": "{name} entfernen",
"removed": "Verknüpfung mit {name} gelöst"
"removed": "Verknüpfung mit {name} gelöst",
"linked": "Verknüpft",
"loadNewer": "Neuere laden",
"loadOlder": "Ältere laden"
}
},
"bodySites": {
+79 -26
View File
@@ -1386,6 +1386,7 @@
"emptyState": "No side effects logged yet. Tracking can help your doctor and surface patterns over time.",
"recentTitle": "Last 30 days",
"loading": "Loading…",
"loadMore": "Load more",
"deleteCta": "Delete",
"deleteError": "Could not delete the entry",
"errorSeverity": "Severity must be between 1 and 5",
@@ -4569,7 +4570,7 @@
"description": "How well your body has bounced back: the recovery, strain and load signals your wearable records overnight.",
"scoreCrossLink": "See the composite recovery score",
"emptyTitle": "No recovery data yet",
"empty": "No recovery data yet. Connect a WHOOP, Polar or Oura account and these scores fill in once your device syncs.",
"empty": "Nothing to chart here yet. This page shows the recharge, strain and load readings that WHOOP and Polar record, and Oura's resilience level. Oura's readiness appears as your recovery score, linked above.",
"block": {
"ansChargeExplainer": "How charged your autonomic nervous system is for the day ahead, a recharge reading drawn from your overnight heart and breathing signals.",
"dayStrainExplainer": "The day's whole-body cardiovascular load on your device's strain scale. Read it against your recovery, not as a goal on its own.",
@@ -5315,7 +5316,8 @@
"unavailableTitle": "Unavailable in this shared record",
"unavailableDescription": "This Settings area is intentionally unavailable while viewing another person's record.",
"integrationStatusDescription": "Connection status for this managed record.",
"integrationStatusDetail": "Connection controls are unavailable."
"integrationStatusDetail": "A managed profile shows its connected services as a status only. Connecting, reconnecting, syncing and disconnecting are not available to guardians.",
"integrationStatusDocs": "How sharing works"
},
"sections": {
"account": {
@@ -6391,7 +6393,6 @@
"whoopDisconnectDescription": "The connection to WHOOP will be disconnected. Previously synced data will be preserved.",
"whoopConnect": "Connect with WHOOP",
"whoopNoCredentials": "Please enter your API credentials above to connect WHOOP.",
"whoopBackfillInProgress": "Importing your WHOOP history in the background…",
"whoopOauthConnected": "WHOOP connected. The first sync starts in the background.",
"whoopOauthFailed": "WHOOP connection failed",
"whoopOauthError": {
@@ -6427,7 +6428,6 @@
"fitbitDisconnectDescription": "The connection to Fitbit will be disconnected. Previously synced data will be preserved.",
"fitbitConnect": "Connect with Fitbit",
"fitbitNoCredentials": "Please enter your API credentials above to connect Fitbit.",
"fitbitBackfillInProgress": "Importing your Fitbit history in the background…",
"fitbitOauthConnected": "Fitbit connected. The first sync starts in the background.",
"fitbitOauthFailed": "Fitbit connection failed",
"googleHealth": "Google Health",
@@ -6472,9 +6472,6 @@
"googleHealthDisconnectDescription": "The connection to Google Health will be disconnected. Previously synced data will be preserved.",
"googleHealthConnect": "Connect with Google",
"googleHealthNoCredentials": "Please enter your API credentials above to connect Google Health.",
"googleHealthBackfillInProgress": "Importing your Google Health history in the background…",
"googleHealthReauthBanner": "Google has expired the connection. This happens periodically while your OAuth app stays in Testing mode. Reconnect to resume syncing.",
"googleHealthReconnect": "Reconnect",
"googleHealthOauthConnected": "Google Health connected. The first sync starts in the background.",
"googleHealthOauthFailed": "Google Health connection failed",
"googleHealthOauthError": {
@@ -6499,16 +6496,7 @@
},
"integrations": {
"withings": {
"reconnect": {
"banner": {
"title": "Activity sync available",
"body": "Your Withings connection was authorised before activity sync was added. Reconnect once to enable steps, active energy, walking + running distance, and floors-climbed ingest from your Withings devices.",
"action": "Reconnect Withings"
}
},
"plan": {
"title": "Some readings need Withings' paid API plan",
"body": "Withings now offers {list} only in its paid API plan, and your Withings developer app is on the free plan. Weight, blood pressure, body composition, heart rate and sleep keep syncing.",
"link": "About the Withings API plans",
"items": {
"spo2": "SpO2",
@@ -6538,10 +6526,7 @@
},
"oura": {
"scope": {
"body": "Reconnect Oura once to allow {list}. Everything else keeps syncing.",
"action": "Reconnect Oura",
"items": {
"spo2": "SpO2",
"vo2max": "VO2 max",
"cardio_age": "cardiovascular age",
"resilience": "resilience"
@@ -6560,7 +6545,6 @@
"minutesAgo": "{count} min ago",
"hoursAgo": "{count} h ago",
"daysAgo": "{count} d ago",
"resumeCta": "Reconnect",
"resumeSuccess": "Integration resumed",
"resumeError": "Reconnect failed",
"stale": "Connected, no recent data",
@@ -6571,12 +6555,11 @@
"title": "Data received",
"summary": "{count} data types, newest {relative}",
"quiet": "{count} quiet",
"workouts": "Workouts"
"workouts": "Workouts",
"medicationDoses": "Medication doses"
},
"integrationFallback": {
"description": "Configured on this account without a settings card of its own.",
"lastAttempt": "Last attempt {relative}",
"noAttempt": "No sync attempt recorded"
"description": "Configured on this account without a settings card of its own."
},
"withingsViewData": "View your measurements",
"whoopViewData": "View your recovery",
@@ -7124,7 +7107,6 @@
"backgroundStep": "Keep Background App Refresh enabled. The app syncs new data automatically in the foreground and when iOS grants background time.",
"importNote": "Need older history or cannot use the iOS app? The Apple Health XML/ZIP upload is a one-shot fallback and does not enable live sync.",
"importAction": "Open one-shot import",
"staleNote": "No Apple Health data has arrived for over a week. Open the HealthLog app on your iPhone and check the Apple Health permissions.",
"progress": {
"title": "Sync progress",
"receivedLabel": "Records received",
@@ -7150,6 +7132,7 @@
"title": "By data type",
"lastReceived": "Last received {relative}",
"lastNewValue": "Last new value {relative}",
"lastBackground": "Last in background {relative}",
"never": "Not received yet"
}
},
@@ -7217,6 +7200,73 @@
"displayCard": {
"title": "Display",
"description": "Language, units and formats, saved as soon as you change them."
},
"integrationCard": {
"state": {
"ok": "Working",
"limited": "Limited",
"degraded": "Delayed",
"actionRequired": "Action needed"
},
"reason": {
"dataArriving": "Data is arriving.",
"dataArrivingHistory": "Data is arriving. Older history is loading in the background.",
"firstSyncPending": "Connected. The first sync runs shortly.",
"planLimited": "Data is arriving. Your {provider} plan does not include {list}.",
"permissionLimited": "Data is arriving. Permission is missing for {list}.",
"providerErrors": "{provider} has not answered reliably since {since}. HealthLog keeps trying; nothing to do on your side.",
"providerErrorsNoSince": "{provider} is not answering reliably right now. HealthLog keeps trying; nothing to do on your side.",
"syncStalled": "HealthLog has not synced since {since}. That is on the server, not your device.",
"syncStalledNoSince": "HealthLog is not syncing right now. That is on the server, not your device.",
"noNewData": "No new data since {since}.",
"noNewDataNoSince": "No new data for a while.",
"noNewDataApple": "No new data since {since}. Open HealthLog on your iPhone once.",
"noNewDataAppleNoSince": "No new data for a while. Open HealthLog on your iPhone once.",
"unexpectedSilence": "Nothing has arrived since {since}, although {provider} usually delivers daily.",
"unexpectedSilenceNoSince": "Nothing is arriving, although {provider} usually delivers daily.",
"reauthRequired": "The {provider} connection has expired. Reconnect to keep data coming.",
"pausedAfterErrors": "{provider} is paused after failing since {since}.",
"pausedAfterErrorsNoSince": "{provider} is paused after repeated failures."
},
"when": {
"today": "today, {time}",
"yesterday": "yesterday, {time}",
"date": "{date}, {time}"
},
"items": {
"activity": "activity",
"sleep": "sleep"
},
"moreValuesOne": "{count} more",
"moreValuesFew": "{count} more",
"moreValuesOther": "{count} more",
"notConnected": "Not connected.",
"lastData": "Last data: {when}",
"lastDataNone": "Last data: none yet",
"reconnect": "Reconnect",
"resume": "Resume",
"menuLabel": "More actions for {provider}",
"menu": {
"credentials": "Change credentials",
"address": "Change address and token"
},
"syncing": "Syncing…",
"details": {
"title": "Details for a bug report",
"none": "none",
"state": "State",
"since": "since",
"lastSync": "Last sync",
"lastData": "Last data",
"failingParts": "failing parts",
"recentCalls": "Recent calls",
"unavailable": "Not available",
"unavailableList": "Not available: {list}",
"causePlan": "plan",
"causePermission": "permission",
"failureStreak": "Failure streak: {count} of {threshold}",
"copyFailed": "Could not copy. Select the text and copy it by hand."
}
}
},
"admin": {
@@ -12174,7 +12224,10 @@
"suggested": "Around this date",
"loadError": "The list could not be loaded.",
"removeNamed": "Remove {name}",
"removed": "Unlinked {name}"
"removed": "Unlinked {name}",
"linked": "Linked",
"loadNewer": "Load newer",
"loadOlder": "Load older"
}
},
"bodySites": {
+79 -26
View File
@@ -1386,6 +1386,7 @@
"emptyState": "Aún no hay efectos secundarios registrados. Hacer seguimiento puede ayudar a tu médico y revelar patrones a lo largo del tiempo.",
"recentTitle": "Últimos 30 días",
"loading": "Cargando…",
"loadMore": "Cargar más",
"deleteCta": "Eliminar",
"deleteError": "No se pudo eliminar la entrada",
"errorSeverity": "La gravedad debe estar entre 1 y 5",
@@ -4569,7 +4570,7 @@
"description": "Cómo se ha recuperado tu cuerpo: las señales de recuperación, esfuerzo y carga que tu wearable registra durante la noche.",
"scoreCrossLink": "Ver la puntuación de recuperación combinada",
"emptyTitle": "Aún no hay datos de recuperación",
"empty": "Aún no hay datos de recuperación. Conecta una cuenta de WHOOP, Polar u Oura y estas puntuaciones se completarán cuando tu dispositivo sincronice.",
"empty": "Aún no hay nada que mostrar aquí. Esta página muestra las lecturas de recarga, esfuerzo y carga que registran WHOOP y Polar, y el nivel de resiliencia de Oura. La preparación (readiness) de Oura aparece como tu puntuación de recuperación, enlazada arriba.",
"block": {
"ansChargeExplainer": "Cuán cargado está tu sistema nervioso autónomo para el día: una lectura de recarga obtenida de tus señales nocturnas de corazón y respiración.",
"dayStrainExplainer": "La carga cardiovascular del día en la escala de esfuerzo de tu dispositivo. Léela en relación con tu recuperación, no como una meta en sí.",
@@ -5315,7 +5316,8 @@
"unavailableTitle": "No disponible en este registro compartido",
"unavailableDescription": "Esta sección de Ajustes no está disponible intencionadamente mientras consultas el registro de otra persona.",
"integrationStatusDescription": "Estado de conexión de este registro gestionado.",
"integrationStatusDetail": "Los controles de conexión no están disponibles."
"integrationStatusDetail": "Un perfil gestionado muestra sus servicios conectados solo como estado. Conectar, volver a conectar, sincronizar y desconectar no está disponible para quienes lo gestionan.",
"integrationStatusDocs": "Cómo funciona compartir"
},
"sections": {
"account": {
@@ -6391,7 +6393,6 @@
"whoopDisconnectDescription": "Se desconectará la conexión con WHOOP. Los datos ya sincronizados se conservarán.",
"whoopConnect": "Conectar con WHOOP",
"whoopNoCredentials": "Introduce tus credenciales de API arriba para conectar WHOOP.",
"whoopBackfillInProgress": "Importando tu historial de WHOOP en segundo plano…",
"whoopOauthConnected": "WHOOP conectado: la primera sincronización empieza en segundo plano.",
"whoopOauthFailed": "Error al conectar con WHOOP",
"whoopOauthError": {
@@ -6427,7 +6428,6 @@
"fitbitDisconnectDescription": "Se desconectará la conexión con Fitbit. Los datos sincronizados previamente se conservarán.",
"fitbitConnect": "Conectar con Fitbit",
"fitbitNoCredentials": "Introduce tus credenciales de API arriba para conectar Fitbit.",
"fitbitBackfillInProgress": "Importando tu historial de Fitbit en segundo plano…",
"fitbitOauthConnected": "Fitbit conectado: la primera sincronización empieza en segundo plano.",
"fitbitOauthFailed": "Error al conectar con Fitbit",
"googleHealth": "Google Health",
@@ -6472,9 +6472,6 @@
"googleHealthDisconnectDescription": "Se desconectará la conexión con Google Health. Los datos ya sincronizados se conservarán.",
"googleHealthConnect": "Conectar con Google",
"googleHealthNoCredentials": "Introduce tus credenciales de API arriba para conectar Google Health.",
"googleHealthBackfillInProgress": "Importando tu historial de Google Health en segundo plano…",
"googleHealthReauthBanner": "Google ha caducado la conexión: ocurre de forma periódica mientras tu app de OAuth permanece en modo de prueba. Vuelve a conectar para reanudar la sincronización.",
"googleHealthReconnect": "Volver a conectar",
"googleHealthOauthConnected": "Google Health conectado: la primera sincronización empieza en segundo plano.",
"googleHealthOauthFailed": "Error al conectar con Google Health",
"googleHealthOauthError": {
@@ -6499,16 +6496,7 @@
},
"integrations": {
"withings": {
"reconnect": {
"banner": {
"title": "Sincronización de actividad disponible",
"body": "Tu conexión con Withings se autorizó antes de añadir la sincronización de actividad. Vuelve a conectar una vez para habilitar la importación de pasos, energía activa, distancia caminando y corriendo, y pisos subidos desde tus dispositivos Withings.",
"action": "Reconectar Withings"
}
},
"plan": {
"title": "Algunos valores requieren el plan de API de pago de Withings",
"body": "Withings ahora ofrece {list} solo en su plan de API de pago, y tu app de desarrollador de Withings usa el plan gratuito. El peso, la presión arterial, la composición corporal, la frecuencia cardíaca y el sueño se siguen sincronizando.",
"link": "Sobre los planes de API de Withings",
"items": {
"spo2": "SpO2",
@@ -6538,10 +6526,7 @@
},
"oura": {
"scope": {
"body": "Vuelve a conectar Oura una vez para permitir {list}. Todo lo demás se sigue sincronizando.",
"action": "Volver a conectar Oura",
"items": {
"spo2": "SpO2",
"vo2max": "VO2 máx.",
"cardio_age": "edad cardiovascular",
"resilience": "resiliencia"
@@ -6560,7 +6545,6 @@
"minutesAgo": "hace {count} min",
"hoursAgo": "hace {count} h",
"daysAgo": "hace {count} d",
"resumeCta": "Reconectar",
"resumeSuccess": "Integración reanudada",
"resumeError": "Reconexión fallida",
"stale": "Conectado, sin datos recientes",
@@ -6571,12 +6555,11 @@
"title": "Datos recibidos",
"summary": "{count} tipos de datos, más reciente {relative}",
"quiet": "{count} en silencio",
"workouts": "Entrenamientos"
"workouts": "Entrenamientos",
"medicationDoses": "Tomas de medicación"
},
"integrationFallback": {
"description": "Configurado en esta cuenta pero sin tarjeta de ajustes propia.",
"lastAttempt": "Último intento {relative}",
"noAttempt": "Ningún intento de sincronización registrado"
"description": "Configurado en esta cuenta pero sin tarjeta de ajustes propia."
},
"withingsViewData": "Ver tus mediciones",
"whoopViewData": "Ver tu recuperación",
@@ -7124,7 +7107,6 @@
"backgroundStep": "Mantén activada la actualización en segundo plano. La app sincroniza datos nuevos automáticamente en primer plano y cuando iOS le concede tiempo en segundo plano.",
"importNote": "¿Necesitas un historial anterior o no puedes usar la app de iOS? La carga XML/ZIP de Apple Health es una alternativa puntual y no activa la sincronización en vivo.",
"importAction": "Abrir importación puntual",
"staleNote": "Hace más de una semana que no llegan datos de Apple Health. Abre la app HealthLog en tu iPhone y revisa los permisos de Apple Health.",
"progress": {
"title": "Progreso de sincronización",
"receivedLabel": "Registros recibidos",
@@ -7150,6 +7132,7 @@
"title": "Por tipo de dato",
"lastReceived": "Última recepción {relative}",
"lastNewValue": "Último valor nuevo {relative}",
"lastBackground": "Última vez en segundo plano {relative}",
"never": "Aún no recibido"
}
},
@@ -7217,6 +7200,73 @@
"displayCard": {
"title": "Visualización",
"description": "Idioma, unidades y formatos; se guardan en cuanto los cambias."
},
"integrationCard": {
"state": {
"ok": "Funciona",
"limited": "Limitada",
"degraded": "Con retraso",
"actionRequired": "Requiere acción"
},
"reason": {
"dataArriving": "Están llegando datos.",
"dataArrivingHistory": "Están llegando datos. El historial más antiguo se carga en segundo plano.",
"firstSyncPending": "Conectado. La primera sincronización se hará en breve.",
"planLimited": "Están llegando datos. Tu plan de {provider} no incluye {list}.",
"permissionLimited": "Están llegando datos. Falta el permiso para {list}.",
"providerErrors": "{provider} no responde de forma fiable desde {since}. HealthLog sigue intentándolo; no tienes que hacer nada.",
"providerErrorsNoSince": "{provider} no responde de forma fiable en este momento. HealthLog sigue intentándolo; no tienes que hacer nada.",
"syncStalled": "HealthLog no sincroniza desde {since}. Es cosa del servidor, no de tu dispositivo.",
"syncStalledNoSince": "HealthLog no está sincronizando en este momento. Es cosa del servidor, no de tu dispositivo.",
"noNewData": "No han llegado datos nuevos desde {since}.",
"noNewDataNoSince": "Hace tiempo que no llegan datos nuevos.",
"noNewDataApple": "No han llegado datos nuevos desde {since}. Abre HealthLog una vez en el iPhone.",
"noNewDataAppleNoSince": "Hace tiempo que no llegan datos nuevos. Abre HealthLog una vez en el iPhone.",
"unexpectedSilence": "No llega nada desde {since}, aunque {provider} suele enviar datos a diario.",
"unexpectedSilenceNoSince": "No llega nada, aunque {provider} suele enviar datos a diario.",
"reauthRequired": "La conexión con {provider} ha caducado. Vuelve a conectar para seguir recibiendo datos.",
"pausedAfterErrors": "{provider} está en pausa porque desde {since} solo ha habido errores.",
"pausedAfterErrorsNoSince": "{provider} está en pausa porque últimamente solo ha habido errores."
},
"when": {
"today": "hoy, {time}",
"yesterday": "ayer, {time}",
"date": "{date}, {time}"
},
"items": {
"activity": "actividad",
"sleep": "sueño"
},
"moreValuesOne": "{count} valor más",
"moreValuesFew": "{count} valores más",
"moreValuesOther": "{count} valores más",
"notConnected": "No conectado.",
"lastData": "Últimos datos: {when}",
"lastDataNone": "Últimos datos: todavía ninguno",
"reconnect": "Volver a conectar",
"resume": "Reanudar",
"menuLabel": "Más acciones para {provider}",
"menu": {
"credentials": "Cambiar credenciales",
"address": "Cambiar dirección y token"
},
"syncing": "Sincronizando…",
"details": {
"title": "Detalles para un informe de error",
"none": "ninguno",
"state": "Estado",
"since": "desde",
"lastSync": "Última sincronización",
"lastData": "Últimos datos",
"failingParts": "partes con fallos",
"recentCalls": "Últimas llamadas",
"unavailable": "No disponible",
"unavailableList": "No disponible: {list}",
"causePlan": "plan",
"causePermission": "permiso",
"failureStreak": "Racha de fallos: {count} de {threshold}",
"copyFailed": "No se pudo copiar. Selecciona el texto y cópialo a mano."
}
}
},
"admin": {
@@ -12174,7 +12224,10 @@
"suggested": "En torno a esta fecha",
"loadError": "No se pudo cargar la lista.",
"removeNamed": "Quitar {name}",
"removed": "Se desvinculó {name}"
"removed": "Se desvinculó {name}",
"linked": "Vinculados",
"loadNewer": "Cargar más recientes",
"loadOlder": "Cargar anteriores"
}
},
"bodySites": {
+79 -26
View File
@@ -1386,6 +1386,7 @@
"emptyState": "Aucun effet secondaire consigné. Le suivi peut aider votre médecin et révéler des tendances dans le temps.",
"recentTitle": "30 derniers jours",
"loading": "Chargement…",
"loadMore": "Charger plus",
"deleteCta": "Supprimer",
"deleteError": "Impossible de supprimer l'entrée",
"errorSeverity": "La sévérité doit être entre 1 et 5",
@@ -4569,7 +4570,7 @@
"description": "À quel point votre corps a récupéré : les signaux de récupération, de charge et d'effort que votre wearable enregistre la nuit.",
"scoreCrossLink": "Voir le score de récupération composite",
"emptyTitle": "Pas encore de données de récupération",
"empty": "Pas encore de données de récupération. Connectez un compte WHOOP, Polar ou Oura et ces scores se rempliront dès que votre appareil se synchronise.",
"empty": "Rien à afficher ici pour l'instant. Cette page montre les mesures de recharge, d'effort et de charge qu'enregistrent WHOOP et Polar, ainsi que le niveau de résilience d'Oura. La disponibilité (readiness) d'Oura apparaît comme votre score de récupération, en lien ci-dessus.",
"block": {
"ansChargeExplainer": "À quel point votre système nerveux autonome est rechargé pour la journée : une mesure de recharge issue de vos signaux nocturnes de cœur et de respiration.",
"dayStrainExplainer": "La charge cardiovasculaire de la journée sur l'échelle d'effort de votre appareil. Lisez-la au regard de votre récupération, pas comme un objectif en soi.",
@@ -5315,7 +5316,8 @@
"unavailableTitle": "Indisponible dans ce dossier partagé",
"unavailableDescription": "Cette section des réglages est volontairement indisponible lorsque vous consultez le dossier d’une autre personne.",
"integrationStatusDescription": "État de connexion pour ce dossier géré.",
"integrationStatusDetail": "Les commandes de connexion ne sont pas disponibles."
"integrationStatusDetail": "Un profil géré affiche ses services connectés uniquement sous forme d’état. Les personnes qui le gèrent ne peuvent ni connecter, ni reconnecter, ni synchroniser, ni déconnecter.",
"integrationStatusDocs": "Comment fonctionne le partage"
},
"sections": {
"account": {
@@ -6391,7 +6393,6 @@
"whoopDisconnectDescription": "La connexion à WHOOP sera déconnectée. Les données déjà synchronisées seront conservées.",
"whoopConnect": "Se connecter avec WHOOP",
"whoopNoCredentials": "Veuillez saisir vos identifiants API ci-dessus pour connecter WHOOP.",
"whoopBackfillInProgress": "Importation de votre historique WHOOP en arrière-plan…",
"whoopOauthConnected": "WHOOP connecté. La première synchronisation démarre en arrière-plan.",
"whoopOauthFailed": "Échec de la connexion à WHOOP",
"whoopOauthError": {
@@ -6427,7 +6428,6 @@
"fitbitDisconnectDescription": "La connexion à Fitbit sera déconnectée. Les données déjà synchronisées seront conservées.",
"fitbitConnect": "Se connecter à Fitbit",
"fitbitNoCredentials": "Veuillez saisir vos identifiants d'API ci-dessus pour connecter Fitbit.",
"fitbitBackfillInProgress": "Importation de votre historique Fitbit en arrière-plan…",
"fitbitOauthConnected": "Fitbit connecté. La première synchronisation démarre en arrière-plan.",
"fitbitOauthFailed": "Échec de la connexion à Fitbit",
"googleHealth": "Google Health",
@@ -6472,9 +6472,6 @@
"googleHealthDisconnectDescription": "La connexion à Google Health sera déconnectée. Les données déjà synchronisées seront conservées.",
"googleHealthConnect": "Se connecter avec Google",
"googleHealthNoCredentials": "Saisis tes identifiants d'API ci-dessus pour connecter Google Health.",
"googleHealthBackfillInProgress": "Importation de ton historique Google Health en arrière-plan…",
"googleHealthReauthBanner": "Google a expiré la connexion. Cela arrive régulièrement tant que ton application OAuth reste en mode Test. Reconnecte-toi pour reprendre la synchronisation.",
"googleHealthReconnect": "Se reconnecter",
"googleHealthOauthConnected": "Google Health connecté. La première synchronisation démarre en arrière-plan.",
"googleHealthOauthFailed": "Échec de la connexion à Google Health",
"googleHealthOauthError": {
@@ -6499,16 +6496,7 @@
},
"integrations": {
"withings": {
"reconnect": {
"banner": {
"title": "Synchronisation d’activité disponible",
"body": "Votre connexion Withings a été autorisée avant l’ajout de la synchronisation d’activité. Reconnectez-vous une fois pour activer l’import des pas, de l’énergie active, de la distance à pied et en course, et des étages gravis depuis vos appareils Withings.",
"action": "Reconnecter Withings"
}
},
"plan": {
"title": "Certaines mesures nécessitent l’offre API payante de Withings",
"body": "Withings ne propose désormais {list} que dans son offre API payante, et votre application développeur Withings utilise l’offre gratuite. Le poids, la tension artérielle, la composition corporelle, la fréquence cardiaque et le sommeil continuent d’être synchronisés.",
"link": "En savoir plus sur les offres API de Withings",
"items": {
"spo2": "la SpO2",
@@ -6538,10 +6526,7 @@
},
"oura": {
"scope": {
"body": "Reconnectez Oura une fois pour autoriser {list}. Tout le reste continue d’être synchronisé.",
"action": "Reconnecter Oura",
"items": {
"spo2": "la SpO2",
"vo2max": "la VO2 max",
"cardio_age": "l’âge cardiovasculaire",
"resilience": "la résilience"
@@ -6560,7 +6545,6 @@
"minutesAgo": "il y a {count} min",
"hoursAgo": "il y a {count} h",
"daysAgo": "il y a {count} j",
"resumeCta": "Reconnecter",
"resumeSuccess": "Intégration reprise",
"resumeError": "Échec de la reconnexion",
"stale": "Connecté, aucune donnée récente",
@@ -6571,12 +6555,11 @@
"title": "Données reçues",
"summary": "{count} types de données, plus récent {relative}",
"quiet": "{count} silencieux",
"workouts": "Séances"
"workouts": "Séances",
"medicationDoses": "Prises de médicaments"
},
"integrationFallback": {
"description": "Configuré sur ce compte sans carte de réglages dédiée.",
"lastAttempt": "Dernière tentative {relative}",
"noAttempt": "Aucune tentative de synchronisation enregistrée"
"description": "Configuré sur ce compte sans carte de réglages dédiée."
},
"withingsViewData": "Voir vos mesures",
"whoopViewData": "Voir votre récupération",
@@ -7124,7 +7107,6 @@
"backgroundStep": "Laisse l’actualisation en arrière-plan activée. L’app synchronise automatiquement les nouvelles données au premier plan et lorsque iOS lui accorde du temps en arrière-plan.",
"importNote": "Besoin d’un ancien historique ou impossible d’utiliser l’app iOS ? L’envoi XML/ZIP Apple Health est une solution ponctuelle et n’active pas la synchronisation en direct.",
"importAction": "Ouvrir l’import ponctuel",
"staleNote": "Aucune donnée Apple Health n'est arrivée depuis plus d'une semaine. Ouvrez l'app HealthLog sur votre iPhone et vérifiez les autorisations Apple Health.",
"progress": {
"title": "Progression de la synchronisation",
"receivedLabel": "Enregistrements reçus",
@@ -7150,6 +7132,7 @@
"title": "Par type de donnée",
"lastReceived": "Dernière réception {relative}",
"lastNewValue": "Dernière nouvelle valeur {relative}",
"lastBackground": "Dernière fois en arrière-plan {relative}",
"never": "Pas encore reçu"
}
},
@@ -7217,6 +7200,73 @@
"displayCard": {
"title": "Affichage",
"description": "Langue, unités et formats, enregistrés dès que vous les modifiez."
},
"integrationCard": {
"state": {
"ok": "Fonctionne",
"limited": "Limitée",
"degraded": "Retardée",
"actionRequired": "Action requise"
},
"reason": {
"dataArriving": "Les données arrivent.",
"dataArrivingHistory": "Les données arrivent. L’historique plus ancien se charge en arrière-plan.",
"firstSyncPending": "Connecté. La première synchronisation aura lieu sous peu.",
"planLimited": "Les données arrivent. Votre forfait {provider} n’inclut pas {list}.",
"permissionLimited": "Les données arrivent. L’autorisation manque pour {list}.",
"providerErrors": "{provider} ne répond plus de façon fiable depuis {since}. HealthLog continue d’essayer, vous n’avez rien à faire.",
"providerErrorsNoSince": "{provider} ne répond pas de façon fiable en ce moment. HealthLog continue d’essayer, vous n’avez rien à faire.",
"syncStalled": "HealthLog n’a plus synchronisé depuis {since}. Le problème vient du serveur, pas de votre appareil.",
"syncStalledNoSince": "HealthLog ne synchronise pas en ce moment. Le problème vient du serveur, pas de votre appareil.",
"noNewData": "Aucune nouvelle donnée depuis {since}.",
"noNewDataNoSince": "Aucune nouvelle donnée depuis un moment.",
"noNewDataApple": "Aucune nouvelle donnée depuis {since}. Ouvrez HealthLog une fois sur votre iPhone.",
"noNewDataAppleNoSince": "Aucune nouvelle donnée depuis un moment. Ouvrez HealthLog une fois sur votre iPhone.",
"unexpectedSilence": "Rien n’est arrivé depuis {since}, alors que {provider} envoie d’habitude des données chaque jour.",
"unexpectedSilenceNoSince": "Rien n’arrive, alors que {provider} envoie d’habitude des données chaque jour.",
"reauthRequired": "La connexion à {provider} a expiré. Reconnectez-vous pour continuer à recevoir des données.",
"pausedAfterErrors": "{provider} est en pause, car il n’y a eu que des erreurs depuis {since}.",
"pausedAfterErrorsNoSince": "{provider} est en pause, car il n’y a eu que des erreurs récemment."
},
"when": {
"today": "aujourd’hui, {time}",
"yesterday": "hier, {time}",
"date": "{date}, {time}"
},
"items": {
"activity": "l’activité",
"sleep": "le sommeil"
},
"moreValuesOne": "{count} autre valeur",
"moreValuesFew": "{count} autres valeurs",
"moreValuesOther": "{count} autres valeurs",
"notConnected": "Non connecté.",
"lastData": "Dernières données : {when}",
"lastDataNone": "Dernières données : aucune pour l’instant",
"reconnect": "Reconnecter",
"resume": "Reprendre",
"menuLabel": "Plus d’actions pour {provider}",
"menu": {
"credentials": "Modifier les identifiants",
"address": "Modifier l’adresse et le jeton"
},
"syncing": "Synchronisation en cours…",
"details": {
"title": "Détails pour un rapport de bug",
"none": "aucune",
"state": "État",
"since": "depuis",
"lastSync": "Dernière synchronisation",
"lastData": "Dernières données",
"failingParts": "parties en échec",
"recentCalls": "Derniers appels",
"unavailable": "Non disponible",
"unavailableList": "Non disponible : {list}",
"causePlan": "forfait",
"causePermission": "autorisation",
"failureStreak": "Série d’échecs : {count} sur {threshold}",
"copyFailed": "La copie a échoué. Sélectionnez le texte et copiez-le à la main."
}
}
},
"admin": {
@@ -12174,7 +12224,10 @@
"suggested": "Autour de cette date",
"loadError": "Impossible de charger la liste.",
"removeNamed": "Retirer {name}",
"removed": "{name} dissocié"
"removed": "{name} dissocié",
"linked": "Liés",
"loadNewer": "Charger les plus récents",
"loadOlder": "Charger les plus anciens"
}
},
"bodySites": {
+79 -26
View File
@@ -1386,6 +1386,7 @@
"emptyState": "Nessun effetto collaterale registrato. Tracciarli può aiutare il medico e far emergere schemi nel tempo.",
"recentTitle": "Ultimi 30 giorni",
"loading": "Caricamento…",
"loadMore": "Carica altri",
"deleteCta": "Elimina",
"deleteError": "Impossibile eliminare la voce",
"errorSeverity": "La gravità deve essere tra 1 e 5",
@@ -4569,7 +4570,7 @@
"description": "Quanto bene il tuo corpo si è ripreso: i segnali di recupero, sforzo e carico che il tuo wearable registra durante la notte.",
"scoreCrossLink": "Vedi il punteggio di recupero composito",
"emptyTitle": "Ancora nessun dato di recupero",
"empty": "Ancora nessun dato di recupero. Collega un account WHOOP, Polar o Oura e questi punteggi si popoleranno appena il dispositivo si sincronizza.",
"empty": "Qui non c'è ancora nulla da mostrare. Questa pagina mostra i valori di ricarica, sforzo e carico che registrano WHOOP e Polar, e il livello di resilienza di Oura. La prontezza (readiness) di Oura compare come punteggio di recupero, collegato qui sopra.",
"block": {
"ansChargeExplainer": "Quanto è carico il tuo sistema nervoso autonomo per la giornata: una lettura di ricarica ricavata dai segnali notturni di cuore e respiro.",
"dayStrainExplainer": "Il carico cardiovascolare della giornata sulla scala di sforzo del tuo dispositivo. Leggilo in rapporto al tuo recupero, non come obiettivo a sé.",
@@ -5315,7 +5316,8 @@
"unavailableTitle": "Non disponibile in questo record condiviso",
"unavailableDescription": "Questa area delle Impostazioni non è disponibile intenzionalmente mentre visualizzi il record di un’altra persona.",
"integrationStatusDescription": "Stato di connessione per questo record gestito.",
"integrationStatusDetail": "I controlli di connessione non sono disponibili."
"integrationStatusDetail": "Un profilo gestito mostra i servizi collegati solo come stato. Chi lo gestisce non può collegare, ricollegare, sincronizzare o scollegare.",
"integrationStatusDocs": "Come funziona la condivisione"
},
"sections": {
"account": {
@@ -6391,7 +6393,6 @@
"whoopDisconnectDescription": "La connessione a WHOOP verrà disconnessa. I dati già sincronizzati saranno conservati.",
"whoopConnect": "Connetti con WHOOP",
"whoopNoCredentials": "Inserisci le tue credenziali API qui sopra per connettere WHOOP.",
"whoopBackfillInProgress": "Importazione dello storico WHOOP in background…",
"whoopOauthConnected": "WHOOP collegato: la prima sincronizzazione parte in background.",
"whoopOauthFailed": "Connessione a WHOOP non riuscita",
"whoopOauthError": {
@@ -6427,7 +6428,6 @@
"fitbitDisconnectDescription": "La connessione a Fitbit verrà disconnessa. I dati già sincronizzati saranno conservati.",
"fitbitConnect": "Connetti con Fitbit",
"fitbitNoCredentials": "Inserisci le tue credenziali API qui sopra per connettere Fitbit.",
"fitbitBackfillInProgress": "Importazione dello storico Fitbit in background…",
"fitbitOauthConnected": "Fitbit collegato: la prima sincronizzazione parte in background.",
"fitbitOauthFailed": "Connessione a Fitbit non riuscita",
"googleHealth": "Google Health",
@@ -6472,9 +6472,6 @@
"googleHealthDisconnectDescription": "La connessione a Google Health verrà disconnessa. I dati già sincronizzati saranno conservati.",
"googleHealthConnect": "Connetti con Google",
"googleHealthNoCredentials": "Inserisci le tue credenziali API qui sopra per connettere Google Health.",
"googleHealthBackfillInProgress": "Importazione della cronologia di Google Health in background…",
"googleHealthReauthBanner": "Google ha fatto scadere la connessione. Accade periodicamente finché la tua app OAuth resta in modalità Test. Riconnettiti per riprendere la sincronizzazione.",
"googleHealthReconnect": "Riconnetti",
"googleHealthOauthConnected": "Google Health connesso: la prima sincronizzazione parte in background.",
"googleHealthOauthFailed": "Connessione a Google Health non riuscita",
"googleHealthOauthError": {
@@ -6499,16 +6496,7 @@
},
"integrations": {
"withings": {
"reconnect": {
"banner": {
"title": "Sincronizzazione attività disponibile",
"body": "La tua connessione Withings è stata autorizzata prima dell’aggiunta della sincronizzazione attività. Riconnettiti una volta per abilitare l’importazione di passi, energia attiva, distanza a piedi e di corsa, e piani saliti dai tuoi dispositivi Withings.",
"action": "Riconnetti Withings"
}
},
"plan": {
"title": "Alcuni valori richiedono il piano API a pagamento di Withings",
"body": "Withings ora offre {list} solo nel suo piano API a pagamento, e la tua app sviluppatore Withings usa il piano gratuito. Peso, pressione arteriosa, composizione corporea, frequenza cardiaca e sonno continuano a sincronizzarsi.",
"link": "Informazioni sui piani API di Withings",
"items": {
"spo2": "SpO2",
@@ -6538,10 +6526,7 @@
},
"oura": {
"scope": {
"body": "Riconnetti Oura una volta per consentire {list}. Tutto il resto continua a sincronizzarsi.",
"action": "Riconnetti Oura",
"items": {
"spo2": "SpO2",
"vo2max": "VO2 max",
"cardio_age": "età cardiovascolare",
"resilience": "resilienza"
@@ -6560,7 +6545,6 @@
"minutesAgo": "{count} min fa",
"hoursAgo": "{count} h fa",
"daysAgo": "{count} g fa",
"resumeCta": "Riconnetti",
"resumeSuccess": "Integrazione ripresa",
"resumeError": "Riconnessione fallita",
"stale": "Connesso, nessun dato recente",
@@ -6571,12 +6555,11 @@
"title": "Dati ricevuti",
"summary": "{count} tipi di dati, più recente {relative}",
"quiet": "{count} in silenzio",
"workouts": "Allenamenti"
"workouts": "Allenamenti",
"medicationDoses": "Assunzioni di farmaci"
},
"integrationFallback": {
"description": "Configurato su questo account ma senza una scheda di impostazioni propria.",
"lastAttempt": "Ultimo tentativo {relative}",
"noAttempt": "Nessun tentativo di sincronizzazione registrato"
"description": "Configurato su questo account ma senza una scheda di impostazioni propria."
},
"withingsViewData": "Vedi le tue misurazioni",
"whoopViewData": "Vedi il tuo recupero",
@@ -7124,7 +7107,6 @@
"backgroundStep": "Mantieni attivo l’aggiornamento in background. L’app sincronizza automaticamente i nuovi dati in primo piano e quando iOS concede tempo in background.",
"importNote": "Ti serve lo storico precedente o non puoi usare l’app iOS? Il caricamento XML/ZIP di Apple Health è un’alternativa una tantum e non attiva la sincronizzazione in tempo reale.",
"importAction": "Apri importazione una tantum",
"staleNote": "Da più di una settimana non arrivano dati da Apple Health. Apri l'app HealthLog sull'iPhone e controlla i permessi di Apple Health.",
"progress": {
"title": "Avanzamento della sincronizzazione",
"receivedLabel": "Record ricevuti",
@@ -7150,6 +7132,7 @@
"title": "Per tipo di dato",
"lastReceived": "Ultima ricezione {relative}",
"lastNewValue": "Ultimo nuovo valore {relative}",
"lastBackground": "Ultima volta in background {relative}",
"never": "Non ancora ricevuto"
}
},
@@ -7217,6 +7200,73 @@
"displayCard": {
"title": "Visualizzazione",
"description": "Lingua, unità e formati, salvati appena li modifichi."
},
"integrationCard": {
"state": {
"ok": "Funziona",
"limited": "Limitata",
"degraded": "In ritardo",
"actionRequired": "Azione richiesta"
},
"reason": {
"dataArriving": "I dati stanno arrivando.",
"dataArrivingHistory": "I dati stanno arrivando. Lo storico meno recente si carica in background.",
"firstSyncPending": "Connesso. La prima sincronizzazione partirà a breve.",
"planLimited": "I dati stanno arrivando. Il tuo piano {provider} non include {list}.",
"permissionLimited": "I dati stanno arrivando. Manca l’autorizzazione per {list}.",
"providerErrors": "{provider} non risponde in modo affidabile da {since}. HealthLog continua a riprovare, non devi fare nulla.",
"providerErrorsNoSince": "{provider} al momento non risponde in modo affidabile. HealthLog continua a riprovare, non devi fare nulla.",
"syncStalled": "HealthLog non sincronizza più da {since}. Dipende dal server, non dal tuo dispositivo.",
"syncStalledNoSince": "HealthLog al momento non sincronizza. Dipende dal server, non dal tuo dispositivo.",
"noNewData": "Nessun dato nuovo da {since}.",
"noNewDataNoSince": "Nessun dato nuovo da un po’ di tempo.",
"noNewDataApple": "Nessun dato nuovo da {since}. Apri HealthLog una volta sull’iPhone.",
"noNewDataAppleNoSince": "Nessun dato nuovo da un po’ di tempo. Apri HealthLog una volta sull’iPhone.",
"unexpectedSilence": "Non arriva nulla da {since}, anche se di solito {provider} invia dati ogni giorno.",
"unexpectedSilenceNoSince": "Non arriva nulla, anche se di solito {provider} invia dati ogni giorno.",
"reauthRequired": "La connessione a {provider} è scaduta. Riconnetti per continuare a ricevere dati.",
"pausedAfterErrors": "{provider} è in pausa perché da {since} ci sono stati solo errori.",
"pausedAfterErrorsNoSince": "{provider} è in pausa perché ultimamente ci sono stati solo errori."
},
"when": {
"today": "oggi, {time}",
"yesterday": "ieri, {time}",
"date": "{date}, {time}"
},
"items": {
"activity": "attività",
"sleep": "sonno"
},
"moreValuesOne": "{count} altro valore",
"moreValuesFew": "altri {count} valori",
"moreValuesOther": "altri {count} valori",
"notConnected": "Non connesso.",
"lastData": "Ultimi dati: {when}",
"lastDataNone": "Ultimi dati: ancora nessuno",
"reconnect": "Riconnetti",
"resume": "Riprendi",
"menuLabel": "Altre azioni per {provider}",
"menu": {
"credentials": "Modifica credenziali",
"address": "Modifica indirizzo e token"
},
"syncing": "Sincronizzazione in corso…",
"details": {
"title": "Dettagli per una segnalazione di errore",
"none": "nessuno",
"state": "Stato",
"since": "da",
"lastSync": "Ultima sincronizzazione",
"lastData": "Ultimi dati",
"failingParts": "parti con errori",
"recentCalls": "Ultime chiamate",
"unavailable": "Non disponibile",
"unavailableList": "Non disponibile: {list}",
"causePlan": "piano",
"causePermission": "autorizzazione",
"failureStreak": "Serie di errori: {count} su {threshold}",
"copyFailed": "Copia non riuscita. Seleziona il testo e copialo a mano."
}
}
},
"admin": {
@@ -12174,7 +12224,10 @@
"suggested": "Intorno a questa data",
"loadError": "Impossibile caricare l’elenco.",
"removeNamed": "Rimuovi {name}",
"removed": "Collegamento a {name} rimosso"
"removed": "Collegamento a {name} rimosso",
"linked": "Collegati",
"loadNewer": "Carica più recenti",
"loadOlder": "Carica meno recenti"
}
},
"bodySites": {
+79 -26
View File
@@ -1386,6 +1386,7 @@
"emptyState": "아직 기록한 부작용이 없어요. 기록해 두면 진료에 도움이 되고 패턴도 드러나요.",
"recentTitle": "최근 30일",
"loading": "불러오는 중…",
"loadMore": "더 불러오기",
"deleteCta": "삭제",
"deleteError": "기록을 삭제하지 못했어요",
"errorSeverity": "심각도는 1에서 5 사이여야 해요",
@@ -4569,7 +4570,7 @@
"description": "몸이 얼마나 회복했는지: 웨어러블이 밤사이 기록한 회복·부하 신호예요.",
"scoreCrossLink": "종합 회복 점수 보기",
"emptyTitle": "아직 회복 데이터가 없어요",
"empty": "아직 회복 데이터가 없어요. WHOOP, Polar, Oura 계정을 연결하면 기기가 동기화될 때 점수가 채워져요.",
"empty": "아직 여기에 표시할 내용이 없어요. 이 페이지는 WHOOP과 Polar가 기록하는 충전, 부하, 운동량 값과 Oura의 회복 탄력성 단계를 보여줘요. Oura의 준비도(Readiness)는 위에 연결된 회복 점수에 표시돼요.",
"block": {
"ansChargeExplainer": "오늘 하루를 맞을 자율신경계의 충전 상태예요. 밤사이 심장·호흡 신호에서 읽어낸 재충전 지표예요.",
"dayStrainExplainer": "기기의 부하 척도로 본 하루 전체의 심혈관 부하예요. 그 자체를 목표로 삼지 말고 회복과 견줘 보세요.",
@@ -5315,7 +5316,8 @@
"unavailableTitle": "이 공유 기록에서는 사용할 수 없어요",
"unavailableDescription": "다른 사람의 기록을 보는 동안에는 이 설정 영역을 사용할 수 없어요.",
"integrationStatusDescription": "이 관리 기록의 연결 상태예요.",
"integrationStatusDetail": "연결 설정은 사용할 수 없어요."
"integrationStatusDetail": "관리 프로필은 연결된 서비스를 상태로만 보여 줘요. 보호자는 연결, 다시 연결, 동기화, 연결 해제를 할 수 없어요.",
"integrationStatusDocs": "공유 방식 알아보기"
},
"sections": {
"account": {
@@ -6391,7 +6393,6 @@
"whoopDisconnectDescription": "WHOOP 연결이 해제돼요. 이미 동기화한 데이터는 그대로 남아요.",
"whoopConnect": "WHOOP으로 연결",
"whoopNoCredentials": "WHOOP을 연결하려면 위에 API 자격 증명을 입력해 주세요.",
"whoopBackfillInProgress": "WHOOP 기록을 백그라운드에서 가져오는 중이에요…",
"whoopOauthConnected": "WHOOP을 연결했어요. 첫 동기화가 백그라운드에서 시작돼요.",
"whoopOauthFailed": "WHOOP 연결에 실패했어요",
"whoopOauthError": {
@@ -6427,7 +6428,6 @@
"fitbitDisconnectDescription": "Fitbit 연결이 해제돼요. 이미 동기화한 데이터는 그대로 남아요.",
"fitbitConnect": "Fitbit으로 연결",
"fitbitNoCredentials": "Fitbit을 연결하려면 위에 API 자격 증명을 입력해 주세요.",
"fitbitBackfillInProgress": "Fitbit 기록을 백그라운드에서 가져오는 중이에요…",
"fitbitOauthConnected": "Fitbit을 연결했어요. 첫 동기화가 백그라운드에서 시작돼요.",
"fitbitOauthFailed": "Fitbit 연결에 실패했어요",
"googleHealth": "Google Health",
@@ -6472,9 +6472,6 @@
"googleHealthDisconnectDescription": "Google Health 연결이 해제돼요. 이미 동기화한 데이터는 그대로 남아요.",
"googleHealthConnect": "Google로 연결",
"googleHealthNoCredentials": "Google Health를 연결하려면 위에 API 자격 증명을 입력해 주세요.",
"googleHealthBackfillInProgress": "Google Health 기록을 백그라운드에서 가져오는 중이에요…",
"googleHealthReauthBanner": "Google이 연결을 만료시켰어요. OAuth 앱이 테스트 모드로 있는 동안 주기적으로 생기는 일이에요. 다시 연결하면 동기화가 이어져요.",
"googleHealthReconnect": "다시 연결",
"googleHealthOauthConnected": "Google Health를 연결했어요. 첫 동기화가 백그라운드에서 시작돼요.",
"googleHealthOauthFailed": "Google Health 연결에 실패했어요",
"googleHealthOauthError": {
@@ -6499,16 +6496,7 @@
},
"integrations": {
"withings": {
"reconnect": {
"banner": {
"title": "활동 동기화를 쓸 수 있어요",
"body": "활동 동기화가 추가되기 전에 Withings 연결을 승인했어요. 한 번만 다시 연결하면 Withings 기기에서 걸음 수, 활동 에너지, 걷기·달리기 거리, 오른 층수를 가져올 수 있어요.",
"action": "Withings 다시 연결"
}
},
"plan": {
"title": "일부 측정값은 Withings 유료 API 요금제가 필요해요",
"body": "Withings는 이제 {list} 데이터를 유료 API 요금제에서만 제공하고, 사용 중인 Withings 개발자 앱은 무료 요금제예요. 체중, 혈압, 체성분, 심박수, 수면은 계속 동기화돼요.",
"link": "Withings API 요금제 알아보기",
"items": {
"spo2": "SpO2",
@@ -6538,10 +6526,7 @@
},
"oura": {
"scope": {
"body": "{list} 데이터를 허용하려면 Oura를 한 번 다시 연결하세요. 나머지는 계속 동기화돼요.",
"action": "Oura 다시 연결",
"items": {
"spo2": "SpO2",
"vo2max": "VO2 max",
"cardio_age": "심혈관 나이",
"resilience": "회복탄력성"
@@ -6560,7 +6545,6 @@
"minutesAgo": "{count}분 전",
"hoursAgo": "{count}시간 전",
"daysAgo": "{count}일 전",
"resumeCta": "다시 연결",
"resumeSuccess": "연동을 다시 시작했어요",
"resumeError": "다시 연결하지 못했어요",
"stale": "연결됨, 최근 데이터 없음",
@@ -6571,12 +6555,11 @@
"title": "받은 데이터",
"summary": "데이터 유형 {count}개, 최신 {relative}",
"quiet": "{count}개 잠잠",
"workouts": "운동"
"workouts": "운동",
"medicationDoses": "약 복용"
},
"integrationFallback": {
"description": "이 계정에 설정돼 있지만 전용 설정 카드는 없어요.",
"lastAttempt": "마지막 시도 {relative}",
"noAttempt": "동기화 시도 기록이 없어요"
"description": "이 계정에 설정돼 있지만 전용 설정 카드는 없어요."
},
"withingsViewData": "측정값 보기",
"whoopViewData": "회복도 보기",
@@ -7124,7 +7107,6 @@
"backgroundStep": "백그라운드 앱 새로고침을 켠 채로 두세요. 앱은 화면에 떠 있을 때와 iOS가 백그라운드 시간을 줄 때 새 데이터를 자동으로 동기화해요.",
"importNote": "예전 기록이 필요하거나 iOS 앱을 쓸 수 없나요? Apple Health XML/ZIP 업로드는 한 번만 쓰는 대안이라 실시간 동기화는 켜지지 않아요.",
"importAction": "일회성 불러오기 열기",
"staleNote": "일주일 넘게 Apple Health 데이터가 들어오지 않았어요. iPhone에서 HealthLog 앱을 열고 Apple Health 권한을 확인해 주세요.",
"progress": {
"title": "동기화 진행 상황",
"receivedLabel": "받은 기록 수",
@@ -7150,6 +7132,7 @@
"title": "데이터 유형별",
"lastReceived": "마지막 수신 {relative}",
"lastNewValue": "마지막 새 값 {relative}",
"lastBackground": "마지막 백그라운드 수신 {relative}",
"never": "아직 수신되지 않음"
}
},
@@ -7217,6 +7200,73 @@
"displayCard": {
"title": "표시",
"description": "언어, 단위, 형식은 바꾸는 즉시 저장돼요."
},
"integrationCard": {
"state": {
"ok": "작동 중",
"limited": "제한됨",
"degraded": "지연됨",
"actionRequired": "조치 필요"
},
"reason": {
"dataArriving": "데이터가 들어오고 있어요.",
"dataArrivingHistory": "데이터가 들어오고 있어요. 이전 기록은 백그라운드에서 불러오는 중이에요.",
"firstSyncPending": "연결됐어요. 첫 동기화가 곧 시작돼요.",
"planLimited": "데이터가 들어오고 있어요. {provider} 요금제에는 {list} 항목이 포함되지 않아요.",
"permissionLimited": "데이터가 들어오고 있어요. {list} 항목에 대한 권한이 없어요.",
"providerErrors": "{since}부터 {provider}의 응답이 불안정해요. HealthLog가 계속 다시 시도하니 따로 하실 일은 없어요.",
"providerErrorsNoSince": "지금 {provider}의 응답이 불안정해요. HealthLog가 계속 다시 시도하니 따로 하실 일은 없어요.",
"syncStalled": "{since}부터 HealthLog가 동기화하지 못했어요. 기기가 아니라 서버 쪽 문제예요.",
"syncStalledNoSince": "지금 HealthLog가 동기화하지 못하고 있어요. 기기가 아니라 서버 쪽 문제예요.",
"noNewData": "{since}부터 새 데이터가 들어오지 않았어요.",
"noNewDataNoSince": "한동안 새 데이터가 들어오지 않았어요.",
"noNewDataApple": "{since}부터 새 데이터가 들어오지 않았어요. iPhone에서 HealthLog를 한 번 열어 주세요.",
"noNewDataAppleNoSince": "한동안 새 데이터가 들어오지 않았어요. iPhone에서 HealthLog를 한 번 열어 주세요.",
"unexpectedSilence": "{provider}는 보통 매일 데이터를 보내는데 {since}부터 아무것도 들어오지 않았어요.",
"unexpectedSilenceNoSince": "{provider}는 보통 매일 데이터를 보내는데 지금은 아무것도 들어오지 않아요.",
"reauthRequired": "{provider} 연결이 만료됐어요. 데이터를 계속 받으려면 다시 연결해 주세요.",
"pausedAfterErrors": "{since}부터 오류만 발생해서 {provider}를 일시 중지했어요.",
"pausedAfterErrorsNoSince": "최근 오류만 발생해서 {provider}를 일시 중지했어요."
},
"when": {
"today": "오늘 {time}",
"yesterday": "어제 {time}",
"date": "{date} {time}"
},
"items": {
"activity": "활동",
"sleep": "수면"
},
"moreValuesOne": "그 외 {count}개",
"moreValuesFew": "그 외 {count}개",
"moreValuesOther": "그 외 {count}개",
"notConnected": "연결되지 않음.",
"lastData": "마지막 데이터: {when}",
"lastDataNone": "마지막 데이터: 아직 없음",
"reconnect": "다시 연결",
"resume": "재개",
"menuLabel": "{provider} 추가 작업",
"menu": {
"credentials": "인증 정보 변경",
"address": "주소와 토큰 변경"
},
"syncing": "동기화 중…",
"details": {
"title": "오류 신고용 세부 정보",
"none": "없음",
"state": "상태",
"since": "시작",
"lastSync": "마지막 동기화",
"lastData": "마지막 데이터",
"failingParts": "실패한 부분",
"recentCalls": "최근 호출",
"unavailable": "사용 불가",
"unavailableList": "사용 불가: {list}",
"causePlan": "요금제",
"causePermission": "권한",
"failureStreak": "연속 실패: {threshold}회 중 {count}회",
"copyFailed": "복사하지 못했어요. 텍스트를 선택해서 직접 복사해 주세요."
}
}
},
"admin": {
@@ -12174,7 +12224,10 @@
"suggested": "이 날짜 전후",
"loadError": "목록을 불러오지 못했습니다.",
"removeNamed": "{name} 제거",
"removed": "{name} 연결 해제됨"
"removed": "{name} 연결 해제됨",
"linked": "연결됨",
"loadNewer": "최근 항목 더 보기",
"loadOlder": "이전 항목 더 보기"
}
},
"bodySites": {
+79 -26
View File
@@ -1386,6 +1386,7 @@
"emptyState": "Nie zarejestrowano jeszcze skutków ubocznych. Śledzenie może pomóc lekarzowi i ujawnić wzorce w czasie.",
"recentTitle": "Ostatnie 30 dni",
"loading": "Ładowanie…",
"loadMore": "Załaduj więcej",
"deleteCta": "Usuń",
"deleteError": "Nie udało się usunąć wpisu",
"errorSeverity": "Nasilenie musi mieścić się w zakresie 1–5",
@@ -4569,7 +4570,7 @@
"description": "Jak dobrze Twój organizm się zregenerował: sygnały regeneracji, obciążenia i load, które Twoje urządzenie rejestruje w nocy.",
"scoreCrossLink": "Zobacz łączny wynik regeneracji",
"emptyTitle": "Brak danych o regeneracji",
"empty": "Brak danych o regeneracji. Połącz konto WHOOP, Polar lub Oura, a te wyniki uzupełnią się po synchronizacji urządzenia.",
"empty": "Na razie nie ma tu nic do pokazania. Ta strona pokazuje odczyty ładowania, wysiłku i obciążenia, które rejestrują WHOOP i Polar, oraz poziom odporności z Oura. Gotowość (readiness) z Oura pojawia się jako wynik regeneracji, link powyżej.",
"block": {
"ansChargeExplainer": "Jak naładowany jest Twój autonomiczny układ nerwowy na dany dzień: odczyt regeneracji oparty na nocnych sygnałach serca i oddechu.",
"dayStrainExplainer": "Dzienne obciążenie sercowo-naczyniowe na skali wysiłku Twojego urządzenia. Odczytuj je w odniesieniu do regeneracji, nie jako cel sam w sobie.",
@@ -5315,7 +5316,8 @@
"unavailableTitle": "Niedostępne w tym udostępnionym rekordzie",
"unavailableDescription": "Ten obszar Ustawień jest celowo niedostępny podczas przeglądania rekordu innej osoby.",
"integrationStatusDescription": "Stan połączenia dla tego zarządzanego rekordu.",
"integrationStatusDetail": "Elementy sterujące połączeniem są niedostępne."
"integrationStatusDetail": "Profil zarządzany pokazuje połączone usługi tylko jako stan. Opiekunowie nie mogą łączyć, ponownie łączyć, synchronizować ani rozłączać.",
"integrationStatusDocs": "Jak działa udostępnianie"
},
"sections": {
"account": {
@@ -6391,7 +6393,6 @@
"whoopDisconnectDescription": "Połączenie z WHOOP zostanie zakończone. Wcześniej zsynchronizowane dane zostaną zachowane.",
"whoopConnect": "Połącz z WHOOP",
"whoopNoCredentials": "Wprowadź powyżej swoje dane API, aby połączyć WHOOP.",
"whoopBackfillInProgress": "Importowanie historii WHOOP w tle…",
"whoopOauthConnected": "WHOOP połączony. Pierwsza synchronizacja startuje w tle.",
"whoopOauthFailed": "Połączenie z WHOOP nie powiodło się",
"whoopOauthError": {
@@ -6427,7 +6428,6 @@
"fitbitDisconnectDescription": "Połączenie z Fitbit zostanie rozłączone. Wcześniej zsynchronizowane dane zostaną zachowane.",
"fitbitConnect": "Połącz z Fitbit",
"fitbitNoCredentials": "Wprowadź powyżej swoje dane uwierzytelniające API, aby połączyć Fitbit.",
"fitbitBackfillInProgress": "Importowanie historii Fitbit w tle…",
"fitbitOauthConnected": "Fitbit połączony. Pierwsza synchronizacja startuje w tle.",
"fitbitOauthFailed": "Połączenie z Fitbit nie powiodło się",
"googleHealth": "Google Health",
@@ -6472,9 +6472,6 @@
"googleHealthDisconnectDescription": "Połączenie z Google Health zostanie rozłączone. Wcześniej zsynchronizowane dane zostaną zachowane.",
"googleHealthConnect": "Połącz z Google",
"googleHealthNoCredentials": "Wprowadź powyżej swoje dane API, aby połączyć Google Health.",
"googleHealthBackfillInProgress": "Importowanie historii Google Health w tle…",
"googleHealthReauthBanner": "Google unieważnił połączenie. Dzieje się to okresowo, dopóki Twoja aplikacja OAuth pozostaje w trybie testowym. Połącz ponownie, aby wznowić synchronizację.",
"googleHealthReconnect": "Połącz ponownie",
"googleHealthOauthConnected": "Google Health połączony. Pierwsza synchronizacja startuje w tle.",
"googleHealthOauthFailed": "Połączenie z Google Health nie powiodło się",
"googleHealthOauthError": {
@@ -6499,16 +6496,7 @@
},
"integrations": {
"withings": {
"reconnect": {
"banner": {
"title": "Dostępna synchronizacja aktywności",
"body": "Twoje połączenie z Withings zostało autoryzowane przed dodaniem synchronizacji aktywności. Połącz ponownie jeden raz, aby włączyć import kroków, energii aktywnej, dystansu marszu i biegu oraz pokonanych pięter z urządzeń Withings.",
"action": "Połącz ponownie Withings"
}
},
"plan": {
"title": "Niektóre pomiary wymagają płatnego planu API Withings",
"body": "Withings udostępnia teraz te dane tylko w płatnym planie API: {list}. Twoja aplikacja deweloperska Withings korzysta z bezpłatnego planu. Waga, ciśnienie krwi, skład ciała, tętno i sen są nadal synchronizowane.",
"link": "Więcej o planach API Withings",
"items": {
"spo2": "SpO2",
@@ -6538,10 +6526,7 @@
},
"oura": {
"scope": {
"body": "Połącz ponownie Oura jeden raz, aby zezwolić na te dane: {list}. Wszystko inne jest nadal synchronizowane.",
"action": "Połącz ponownie Oura",
"items": {
"spo2": "SpO2",
"vo2max": "VO2 max",
"cardio_age": "wiek sercowo-naczyniowy",
"resilience": "odporność"
@@ -6560,7 +6545,6 @@
"minutesAgo": "{count} min temu",
"hoursAgo": "{count} godz. temu",
"daysAgo": "{count} dni temu",
"resumeCta": "Połącz ponownie",
"resumeSuccess": "Integracja wznowiona",
"resumeError": "Ponowne połączenie nieudane",
"stale": "Połączono, brak nowych danych",
@@ -6571,12 +6555,11 @@
"title": "Odebrane dane",
"summary": "{count} typów danych, najnowsze {relative}",
"quiet": "{count} bez danych",
"workouts": "Treningi"
"workouts": "Treningi",
"medicationDoses": "Dawki leków"
},
"integrationFallback": {
"description": "Skonfigurowane na tym koncie, ale bez własnej karty ustawień.",
"lastAttempt": "Ostatnia próba {relative}",
"noAttempt": "Brak zapisanej próby synchronizacji"
"description": "Skonfigurowane na tym koncie, ale bez własnej karty ustawień."
},
"withingsViewData": "Zobacz swoje pomiary",
"whoopViewData": "Zobacz swoją regenerację",
@@ -7124,7 +7107,6 @@
"backgroundStep": "Pozostaw włączone odświeżanie aplikacji w tle. Aplikacja synchronizuje nowe dane automatycznie na pierwszym planie oraz gdy iOS przydzieli jej czas w tle.",
"importNote": "Potrzebujesz starszej historii lub nie możesz użyć aplikacji iOS? Przesłanie pliku XML/ZIP z Apple Health jest jednorazową alternatywą i nie włącza synchronizacji na żywo.",
"importAction": "Otwórz jednorazowy import",
"staleNote": "Od ponad tygodnia nie napłynęły żadne dane z Apple Health. Otwórz aplikację HealthLog na iPhonie i sprawdź uprawnienia Apple Health.",
"progress": {
"title": "Postęp synchronizacji",
"receivedLabel": "Odebrane rekordy",
@@ -7150,6 +7132,7 @@
"title": "Według typu danych",
"lastReceived": "Ostatnio odebrano {relative}",
"lastNewValue": "Ostatnia nowa wartość {relative}",
"lastBackground": "Ostatnio w tle {relative}",
"never": "Jeszcze nie odebrano"
}
},
@@ -7217,6 +7200,73 @@
"displayCard": {
"title": "Wyświetlanie",
"description": "Język, jednostki i formaty zapisywane od razu po zmianie."
},
"integrationCard": {
"state": {
"ok": "Działa",
"limited": "Ograniczone",
"degraded": "Opóźnione",
"actionRequired": "Wymaga działania"
},
"reason": {
"dataArriving": "Dane napływają.",
"dataArrivingHistory": "Dane napływają. Starsza historia wczytuje się w tle.",
"firstSyncPending": "Połączono. Pierwsza synchronizacja ruszy za chwilę.",
"planLimited": "Dane napływają. Twój plan {provider} nie obejmuje tych danych: {list}.",
"permissionLimited": "Dane napływają. Brakuje uprawnień do tych danych: {list}.",
"providerErrors": "{provider} od {since} nie odpowiada niezawodnie. HealthLog próbuje dalej, nie musisz nic robić.",
"providerErrorsNoSince": "{provider} nie odpowiada teraz niezawodnie. HealthLog próbuje dalej, nie musisz nic robić.",
"syncStalled": "HealthLog nie synchronizuje od {since}. To problem po stronie serwera, nie twojego urządzenia.",
"syncStalledNoSince": "HealthLog teraz nie synchronizuje. To problem po stronie serwera, nie twojego urządzenia.",
"noNewData": "Od {since} nie napłynęły nowe dane.",
"noNewDataNoSince": "Od dłuższego czasu nie napłynęły nowe dane.",
"noNewDataApple": "Od {since} nie napłynęły nowe dane. Otwórz raz HealthLog na iPhonie.",
"noNewDataAppleNoSince": "Od dłuższego czasu nie napłynęły nowe dane. Otwórz raz HealthLog na iPhonie.",
"unexpectedSilence": "Od {since} nic nie napływa, chociaż {provider} zwykle dostarcza dane codziennie.",
"unexpectedSilenceNoSince": "Nic nie napływa, chociaż {provider} zwykle dostarcza dane codziennie.",
"reauthRequired": "Połączenie z {provider} wygasło. Połącz ponownie, aby dane znów napływały.",
"pausedAfterErrors": "{provider} jest wstrzymany, bo od {since} występowały same błędy.",
"pausedAfterErrorsNoSince": "{provider} jest wstrzymany, bo ostatnio występowały same błędy."
},
"when": {
"today": "dziś, {time}",
"yesterday": "wczoraj, {time}",
"date": "{date}, {time}"
},
"items": {
"activity": "aktywność",
"sleep": "sen"
},
"moreValuesOne": "{count} inna wartość",
"moreValuesFew": "{count} inne wartości",
"moreValuesOther": "{count} innych wartości",
"notConnected": "Nie połączono.",
"lastData": "Ostatnie dane: {when}",
"lastDataNone": "Ostatnie dane: jeszcze brak",
"reconnect": "Połącz ponownie",
"resume": "Wznów",
"menuLabel": "Więcej działań dla {provider}",
"menu": {
"credentials": "Zmień dane dostępowe",
"address": "Zmień adres i token"
},
"syncing": "Trwa synchronizacja…",
"details": {
"title": "Szczegóły do zgłoszenia błędu",
"none": "brak",
"state": "Stan",
"since": "od",
"lastSync": "Ostatnia synchronizacja",
"lastData": "Ostatnie dane",
"failingParts": "części z błędami",
"recentCalls": "Ostatnie wywołania",
"unavailable": "Niedostępne",
"unavailableList": "Niedostępne: {list}",
"causePlan": "plan",
"causePermission": "uprawnienie",
"failureStreak": "Seria błędów: {count} z {threshold}",
"copyFailed": "Nie udało się skopiować. Zaznacz tekst i skopiuj go ręcznie."
}
}
},
"admin": {
@@ -12174,7 +12224,10 @@
"suggested": "Wokół tej daty",
"loadError": "Nie udało się wczytać listy.",
"removeNamed": "Usuń {name}",
"removed": "Odłączono: {name}"
"removed": "Odłączono: {name}",
"linked": "Połączone",
"loadNewer": "Wczytaj nowsze",
"loadOlder": "Wczytaj starsze"
}
},
"bodySites": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "1.42.3",
"version": "1.42.4",
"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,40 @@
-- v1.42.4 — one health state per connection, and a watchdog behind it.
--
-- `integration_statuses.recent_calls` keeps the last ten sync attempts per
-- connection as `{ endpoint, status, at, errorCode }`: the leg name, the
-- outcome, the instant and a short error code. No message, token or value is
-- stored in it; it is the source of the redacted diagnostics a user can copy
-- into a bug report.
--
-- `integration_watches` holds what the integration watchdog observed per
-- connection: whether a source that normally delivers has gone silent, and
-- the one operator incident open for it, so an alert is sent once per
-- incident and a recovery note once when it closes.
--
-- Additive only: one nullable column, one new table, no rewrite.
ALTER TABLE "integration_statuses" ADD COLUMN IF NOT EXISTS "recent_calls" JSONB;
CREATE TABLE IF NOT EXISTS "integration_watches" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"integration" TEXT NOT NULL,
"silent_since" TIMESTAMP(3),
"incident_reason" TEXT,
"incident_opened_at" TIMESTAMP(3),
"alerted_at" TIMESTAMP(3),
"checked_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "integration_watches_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX IF NOT EXISTS "integration_watches_user_id_integration_key"
ON "integration_watches"("user_id", "integration");
DO $$ BEGIN
ALTER TABLE "integration_watches"
ADD CONSTRAINT "integration_watches_user_id_fkey"
FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
@@ -0,0 +1,23 @@
-- Per-type HealthKit arrival ledger: room for non-measurement types, and the
-- last background arrival per type.
--
-- `type` moves from the `measurement_type` enum to text so the ledger can
-- also record Apple Health medication doses (`MEDICATION_DOSE`), which are
-- not a measurement type. Every stored enum value converts to its own label
-- through the enum's text output, so no row changes meaning. The table holds
-- at most one row per (user, delivered type), a few dozen per account; the
-- rewrite and the primary-key rebuild it implies are cheap at any size this
-- table reaches, and the key keeps its name and columns.
ALTER TABLE "healthkit_type_syncs"
ALTER COLUMN "type" SET DATA TYPE TEXT USING "type"::text;
-- When a batch with `syncTrigger = background` last carried the type. A
-- later foreground or manual arrival leaves it standing.
ALTER TABLE "healthkit_type_syncs" ADD COLUMN "last_background_at" TIMESTAMP(3);
-- A row whose latest arrival was already a background one says exactly that,
-- so it starts with that date rather than null. Rows whose latest arrival
-- came another way cannot know an earlier background date and stay null.
UPDATE "healthkit_type_syncs"
SET "last_background_at" = "last_received_at"
WHERE "last_trigger" = 'background';
+65 -7
View File
@@ -804,6 +804,7 @@ model User {
idempotencyKeys IdempotencyKey[]
devices Device[]
integrationStatuses IntegrationStatus[]
integrationWatches IntegrationWatch[]
coachConversations CoachConversation[]
coachFacts CoachFact[]
coachPlans CoachPlan[]
@@ -4004,16 +4005,25 @@ model ManagedProfileHandover {
/// batch route; read by the Apple Health card's per-type freshness list.
/// Diagnostic state: rebuilt by the next sync, so it is not backed up.
model HealthKitTypeSync {
userId String @map("user_id")
type MeasurementType
userId String @map("user_id")
/// A `MeasurementType` name for rows the measurement batch writes, or
/// `MEDICATION_DOSE` for Apple Health doses the intake bulk route
/// receives. Text, not the enum, since v1.42.4 (migration 0401) because a
/// dose is not a measurement type.
type String
/// Last batch that carried this type.
lastReceivedAt DateTime @map("last_received_at")
lastReceivedAt DateTime @map("last_received_at")
/// `syncTrigger` of that batch
/// (`foreground` / `background` / `push` / `manual`), NULL when absent.
lastTrigger String? @map("last_trigger")
lastTrigger String? @map("last_trigger")
/// Last batch that inserted or updated a sample of
/// this type (as opposed to sending only duplicates).
lastNewSampleAt DateTime? @map("last_new_sample_at")
lastNewSampleAt DateTime? @map("last_new_sample_at")
/// Last batch with `syncTrigger = background` that carried this type. A
/// later foreground, push or manual arrival leaves it standing; NULL when
/// none has (or only before v1.42.4, except a row whose latest arrival was
/// a background one, which migration 0401 seeded).
lastBackgroundAt DateTime? @map("last_background_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@ -4410,8 +4420,10 @@ model GoogleHealthConnection {
/// pre-fix externalId (a re-scored night minted parallel copies). Null =
/// repair pending (or the connection predates the fix).
sleepRepairedAt DateTime? @map("sleep_repaired_at")
/// The collections the in-flight history backfill has already walked to
/// the end (`{ done: string[] }`), so a retry resumes with the rest
/// How far the in-flight history backfill has got
/// (`{ done: string[], cursors: { [collection]: { at, order, pages } } }`):
/// the collections it walked to the end, and for each collection read page
/// by page the edge it has written up to, so the next slice resumes there
/// instead of starting over. Null once the backfill completes, and reset
/// on every reconnect.
backfillProgress Json? @map("backfill_progress")
@@ -5047,6 +5059,15 @@ model IntegrationStatus {
// a failure recorded without a leg), under which any success clears the row.
failingLegs Json? @map("failing_legs")
alertedAt DateTime? @map("alerted_at") // last time admin Telegram alert fired (idempotency guard)
// The last ten sync attempts, newest first, as a JSON array of
// `{ endpoint, status, at, errorCode }`. `endpoint` is the sync leg name the
// code passes (never a URL), `status` is `ok` or the failure kind, and
// `errorCode` is a short closed-shape code ("401", "invalid_grant") or null.
// No message, no token, no value: this is what the redacted diagnostics on
// the connection health read publish, so it is shaped to be safe to copy
// into a bug report. Written by `appendRecentCall` in
// `src/lib/integrations/status.ts`, read by `resolveConnectionHealth`.
recentCalls Json? @map("recent_calls")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@ -5058,6 +5079,43 @@ model IntegrationStatus {
@@map("integration_statuses")
}
// What the integration watchdog (`src/lib/integrations/watchdog.ts`) knows
// about one connection: whether a source that normally delivers has gone
// silent, and whether an operator incident is open for it. One row per
// (user, connection) the watchdog has looked at. `integration` carries the
// connection key, which is the IntegrationStatus key set plus "apple-health",
// so it is a separate table rather than more columns on IntegrationStatus,
// whose readers assume every row is a polled provider.
//
// Operational, like IntegrationStatus: it records what this host observed, so
// it is excluded from backups and removed with the account's data.
model IntegrationWatch {
id String @id @default(cuid())
userId String @map("user_id")
integration String
/// Set while the source is unexpectedly silent: the newest data instant at
/// the moment the watchdog flagged it. Null when the source is not flagged.
/// The connection health read surfaces it as `degraded` /
/// `unexpected_silence` until data arrives again.
silentSince DateTime? @map("silent_since")
/// Reason code of the operator incident currently open for this connection,
/// null when none is. One incident at a time per connection.
incidentReason String? @map("incident_reason")
incidentOpenedAt DateTime? @map("incident_opened_at")
/// When the operator was told about the open incident, or when the watchdog
/// adopted an alert the sync ledger had already sent for the same streak.
/// Null while an incident is open but not yet announced.
alertedAt DateTime? @map("alerted_at")
checkedAt DateTime @default(now()) @map("checked_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([userId, integration])
@@map("integration_watches")
}
// ─── App Settings ─────────────────────────────────────────
model AppSettings {
+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.42.3";
/* @sw-version-fallback */ "v1.42.4";
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/*`
+95
View File
@@ -0,0 +1,95 @@
/**
* Boot-time notice before `prisma migrate deploy`: how many migrations this
* image is about to apply, and a reminder to have a backup first.
*
* Run by `docker-entrypoint.sh` as `node scripts/pending-migrations.mjs`.
* It only reports. A migration that changes data cannot be undone by pulling
* the previous image, so an operator who reads the count in the log still has
* a moment to stop the container and take a `pg_dump`; the entrypoint does
* not wait for that.
*
* Best effort by design: any failure to count (no `_prisma_migrations` table
* yet is not a failure, it means every migration is pending) prints one line
* saying so and exits 0, because a notice is never a reason to keep the app
* from starting. `migrate deploy` right after it is what decides.
*/
import { readdirSync, existsSync } from "node:fs";
import { join } from "node:path";
import { pathToFileURL } from "node:url";
/** Migration directory names that carry a `migration.sql`, sorted. */
export function shippedMigrations(migrationsDir) {
return readdirSync(migrationsDir, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
.filter((name) => existsSync(join(migrationsDir, name, "migration.sql")))
.sort();
}
/**
* The shipped migrations the database has not finished applying. Reads
* `_prisma_migrations` in the schema the connection string names (Prisma's
* `?schema=` parameter, `public` when absent); a database without that table
* has applied nothing.
*/
export async function pendingMigrations({ connectionString, migrationsDir }) {
const shipped = shippedMigrations(migrationsDir);
const { default: pg } = await import("pg");
const url = new URL(connectionString);
const schema = url.searchParams.get("schema") || "public";
url.searchParams.delete("schema");
const client = new pg.Client({
connectionString: url.toString(),
connectionTimeoutMillis: 5_000,
statement_timeout: 5_000,
});
await client.connect();
try {
const table = await client.query(
"SELECT to_regclass(format('%I._prisma_migrations', $1::text)) AS t",
[schema],
);
if (!table.rows[0]?.t) return shipped;
const applied = await client.query(
`SELECT migration_name FROM ${table.rows[0].t}
WHERE finished_at IS NOT NULL AND rolled_back_at IS NULL`,
);
const done = new Set(applied.rows.map((row) => row.migration_name));
return shipped.filter((name) => !done.has(name));
} finally {
await client.end().catch(() => {});
}
}
/** The log line for a count, as the entrypoint prints it. */
export function pendingNotice(count) {
if (count === 0) return "HealthLog: Database schema is up to date.";
const noun = count === 1 ? "migration" : "migrations";
return (
`HealthLog: ${count} database ${noun} pending. ` +
"Back up the database before updating (pg_dump, see the Updates section " +
"of docs/self-hosting/getting-started.md); a migration is not undone by " +
"going back to the previous image."
);
}
async function main() {
const connectionString = process.env.DATABASE_URL;
if (!connectionString) return;
try {
const pending = await pendingMigrations({
connectionString,
migrationsDir: join(process.cwd(), "prisma", "migrations"),
});
console.log(pendingNotice(pending.length));
} catch (error) {
const reason = error instanceof Error ? error.message : String(error);
console.log(
`HealthLog: Could not count pending migrations (${reason}); continuing.`,
);
}
}
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
await main();
}
+54 -28
View File
@@ -24,7 +24,10 @@
* - ~3-4 workouts/week (running, strength, cycling) with per-workout HR
* samples and per-workout strain
* - 3 medications with schedules and ~90 days of intake history at high
* compliance, with today scheduled on-track (taken or not-yet-due)
* compliance, with today scheduled on-track (taken or not-yet-due); each
* filed under a category, the evening supplement recording itself
* - Two lab readings the report printed in SI units, stored converted with
* the printed value kept beside them
* - 90 days of mood entries
* - Six mental-wellbeing screeners (WHO-5, PHQ-9, GAD-7) spread over the
* window, item answers encrypted the way the check-in route writes them
@@ -1298,12 +1301,15 @@ async function seed() {
[sched2Id, med2Id],
);
// Medication 3: Magnesium (evening)
// Medication 3: Magnesium (evening). A supplement taken reliably every
// night, so it records itself: `auto_mark_taken` is on from its first day
// and the intake-auto-take job fills each evening slot once 20:00 has
// passed. Its history below is written the way that job writes it.
const med3Id = cuid();
await client.query(
`INSERT INTO medications (id, user_id, name, dose, active, notifications_enabled, starts_on, created_at, updated_at)
VALUES ($1, $2, 'Magnesium', '400mg', true, true, $3, $4, $4)`,
[med3Id, userId, formatDate(daysAgo(60)), daysAgo(60)],
`INSERT INTO medications (id, user_id, name, dose, active, notifications_enabled, starts_on, auto_mark_taken, auto_mark_taken_since, created_at, updated_at)
VALUES ($1, $2, 'Magnesium', '400mg', true, true, $3, true, $4, $5, $5)`,
[med3Id, userId, formatDate(daysAgo(60)), daysAgo(60), daysAgo(60)],
);
const sched3Id = cuid();
await client.query(
@@ -1312,6 +1318,20 @@ async function seed() {
[sched3Id, med3Id],
);
// The category each medication is filed under, so the list groups the
// blood-pressure prescription apart from the two supplements.
for (const [medicationId, category] of [
[med1Id, "BLOOD_PRESSURE"],
[med2Id, "VITAMIN"],
[med3Id, "SUPPLEMENT"],
] as const) {
await client.query(
`INSERT INTO medication_categories (medication_id, category, updated_at)
VALUES ($1, $2, NOW())`,
[medicationId, category],
);
}
// ── Medication Intake Events ──────────────
// High compliance with no overdue doses. Historical days (i < days) carry
// a taken/skip row at ~95%+ adherence. TODAY is handled separately below
@@ -1371,26 +1391,14 @@ async function seed() {
);
}
// Magnesium: only last 60 days, 94% compliance
// Magnesium: only the last 60 days, recorded automatically at the
// slot instant, the shape the intake-auto-take job writes.
if (i >= 30) {
const takeMed3 = Math.random() > 0.06;
if (takeMed3) {
const takenTime = new Date(eveningSlotFor);
takenTime.setMinutes(
takenTime.getMinutes() + Math.floor(Math.random() * 60) + 1,
);
await client.query(
`INSERT INTO medication_intake_events (id, user_id, medication_id, scheduled_for, taken_at, skipped, source, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, false, 'WEB', $5, $5)`,
[cuid(), userId, med3Id, eveningSlotFor, takenTime],
);
} else {
await client.query(
`INSERT INTO medication_intake_events (id, user_id, medication_id, scheduled_for, taken_at, skipped, source, created_at, updated_at)
VALUES ($1, $2, $3, $4, NULL, true, 'WEB', $4, $4)`,
[cuid(), userId, med3Id, eveningSlotFor],
);
}
await client.query(
`INSERT INTO medication_intake_events (id, user_id, medication_id, scheduled_for, taken_at, skipped, auto_recorded, source, created_at, updated_at)
VALUES ($1, $2, $3, $4, $4, false, true, 'REMINDER', $4, $4)`,
[cuid(), userId, med3Id, eveningSlotFor],
);
}
}
@@ -1535,6 +1543,12 @@ async function seed() {
unit: string;
low: number;
high: number;
/**
* What the report printed when it used another unit than the marker's.
* The stored value is the conversion; the printed pair is kept beside
* it, as the lab write path does, so the list shows "converted from".
*/
printed?: { value: number; unit: string };
};
type QualLab = { analyte: string; valueText: string; unit: string };
@@ -1551,14 +1565,24 @@ async function seed() {
{ analyte: "HDL", value: 58, unit: "mg/dL", low: 40, high: 100 },
{ analyte: "Triglycerides", value: 92, unit: "mg/dL", low: 0, high: 150 },
{ analyte: "HbA1c", value: 5.2, unit: "%", low: 4.0, high: 5.6 },
// Both printed in SI units by the lab: 4.94 mmol/L is 89 mg/dL
// (factor 18.0156) and 104.83 nmol/L is 42 ng/mL (factor 2.496).
{
analyte: "Fasting Glucose",
value: 89,
value: 4.94 * 18.0156,
unit: "mg/dL",
low: 70,
high: 99,
printed: { value: 4.94, unit: "mmol/L" },
},
{
analyte: "Vitamin D",
value: 104.83 / 2.496,
unit: "ng/mL",
low: 30,
high: 100,
printed: { value: 104.83, unit: "nmol/L" },
},
{ analyte: "Vitamin D", value: 42, unit: "ng/mL", low: 30, high: 100 },
{ analyte: "TSH", value: 1.8, unit: "mIU/L", low: 0.4, high: 4.0 },
{ analyte: "Ferritin", value: 120, unit: "ng/mL", low: 30, high: 400 },
{ analyte: "CRP", value: 0.8, unit: "mg/L", low: 0, high: 3.0 },
@@ -1602,8 +1626,8 @@ async function seed() {
const insertQuant = async (panel: string, lab: QuantLab, takenAt: Date) => {
await client.query(
`INSERT INTO lab_results (id, user_id, panel, analyte, value, value_text, unit, reference_low, reference_high, taken_at, source, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, NULL, $6, $7, $8, $9, 'MANUAL', NOW(), NOW())`,
`INSERT INTO lab_results (id, user_id, panel, analyte, value, value_text, unit, reference_low, reference_high, source_value, source_unit, taken_at, source, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, NULL, $6, $7, $8, $9, $10, $11, 'MANUAL', NOW(), NOW())`,
[
cuid(),
userId,
@@ -1613,6 +1637,8 @@ async function seed() {
lab.unit,
lab.low,
lab.high,
lab.printed?.value ?? null,
lab.printed?.unit ?? null,
takenAt,
],
);
@@ -0,0 +1,203 @@
/**
* Guard: an integration or e2e fixture dated near "now" must not feed a time
* window.
*
* A fixture written today with an absolute date (`new Date("2026-07-15…")`,
* `?date=2026-07-02`) next to a window parameter passes on the day it is
* written and fails months later, when the date slides out of a window the
* route counts back from the clock. The suite then goes red on a release
* branch that changed nothing near it, and the fix is archaeology. That has
* happened more than once (chart tabs, the 90-day share-link window).
*
* The rule: an absolute ISO date within the last 400 days (or in the future)
* that sits within two lines of a window parameter (`days=`, `from=`, `to=`,
* `since`, `until`, `date=`, `range=`, `rangeDays`, `windowDays`,
* `lookbackDays`) is a
* finding. Fix it by dating the fixture relative to now
* (`new Date(Date.now() - 3 * DAY_MS)`, a `daysAgo(n)` helper), or, when the
* window is fixed on both ends and the route never measures it against the
* clock, allowlist the file below with that reason and the number of findings
* it may carry, so a new one in the same file still fails.
*
* What it cannot see: a fixture date and a relative window that are further
* apart than two lines (a seed helper at the top of the file, `days=90` in a
* request at the bottom). It is a textual net for the common shape, not a
* proof; the clock-shifted run described in the allowlist reasons is what
* proved the allowlisted files fixed.
*/
import fs from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
const ROOT = path.resolve(__dirname, "..", "..");
const SCAN_ROOTS = ["tests/integration", "e2e"];
const DAY_MS = 86_400_000;
/** How far back an absolute date still counts as "near now". */
const HORIZON_DAYS = 400;
/** Lines either side of a date searched for a window parameter. */
const NEIGHBOURHOOD = 2;
const ISO_DATE = /\b(20\d\d)-(\d\d)-(\d\d)/g;
const WINDOW_PARAM =
/[?&](?:days|from|to|since|until|date|range|rangeDays|windowDays)=|\b(?:since|until|rangeDays|windowDays|lookbackDays)\b\s*[:=]/;
/**
* Files whose flagged dates feed windows fixed on both ends, which the routes
* never compare with the clock. `max` is the number of findings the file may
* carry; one more fails.
*/
const ALLOWLIST: Record<string, { max: number; reason: string }> = {
// Each entry passed with the test clock moved a year forward, which is what
// an aging fixture fails under; the reason says why it cannot age.
"tests/integration/day-view.test.ts": {
max: 21,
reason:
"day index, timeline and notable reads are asked for explicit from/to windows; the reminder since/until params are fixture data, not a window counted from now",
},
"tests/integration/sharing-delegable-routes.test.ts": {
max: 5,
reason:
"day-log reads name one explicit calendar day (?date=), never a window counted from now",
},
"tests/integration/measurements-aggregate-daily.test.ts": {
max: 5,
reason: "every series read passes both from and to",
},
"tests/integration/cycle-routes.test.ts": {
max: 5,
reason:
"day-log reads name one explicit day and the calendar read passes both from and to",
},
"tests/integration/medication-auto-mark-taken.test.ts": {
max: 2,
reason: "the cases pin the clock with fake timers around the fixed dates",
},
"tests/integration/symptoms.test.ts": {
max: 1,
reason:
"the discovery matrix reads from a fixed since forward; the fixture days stay inside it as now moves on",
},
};
export interface AgingFinding {
file: string;
line: number;
text: string;
}
/** Every near-now absolute date within reach of a window parameter. */
export function findAgingFixtureDates(
files: ReadonlyArray<{ file: string; source: string }>,
now: number,
): AgingFinding[] {
const floor = now - HORIZON_DAYS * DAY_MS;
const findings: AgingFinding[] = [];
for (const { file, source } of files) {
const lines = source.split("\n");
lines.forEach((text, index) => {
for (const match of text.matchAll(ISO_DATE)) {
const at = Date.UTC(
Number(match[1]),
Number(match[2]) - 1,
Number(match[3]),
);
if (Number.isNaN(at) || at < floor) continue;
const around = lines
.slice(Math.max(0, index - NEIGHBOURHOOD), index + NEIGHBOURHOOD + 1)
.join("\n");
if (WINDOW_PARAM.test(around)) {
findings.push({ file, line: index + 1, text: text.trim() });
break;
}
}
});
}
return findings;
}
function walk(dir: string): string[] {
return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) return walk(full);
return /\.(ts|tsx|mts)$/.test(entry.name) ? [full] : [];
});
}
function scannedFiles() {
return SCAN_ROOTS.flatMap((root) => walk(path.join(ROOT, root))).map(
(full) => ({
file: path.relative(ROOT, full),
source: fs.readFileSync(full, "utf8"),
}),
);
}
describe("aging fixture dates", () => {
const files = scannedFiles();
it("scans the integration and e2e trees (never vacuously)", () => {
expect(files.length).toBeGreaterThan(300);
expect(files.some((f) => f.file.startsWith("e2e/"))).toBe(true);
});
it("no near-now absolute date feeds a window outside the allowlist", () => {
const findings = findAgingFixtureDates(files, Date.now());
const byFile = new Map<string, AgingFinding[]>();
for (const finding of findings) {
const list = byFile.get(finding.file) ?? [];
list.push(finding);
byFile.set(finding.file, list);
}
const offenders: string[] = [];
for (const [file, list] of byFile) {
const allowed = ALLOWLIST[file]?.max ?? 0;
if (list.length > allowed) {
offenders.push(
...list.map(
(f) =>
`${f.file}:${f.line} ${f.text}\n -> date it relative to now (Date.now() - n * DAY_MS), or allowlist a fixed window with its reason`,
),
);
}
}
expect(offenders, offenders.join("\n")).toEqual([]);
});
it("every allowlist entry still names a file with findings", () => {
// A stale entry would quietly allow the next finding in that file.
const findings = findAgingFixtureDates(files, Date.now());
for (const file of Object.keys(ALLOWLIST)) {
expect(
findings.some((f) => f.file === file),
`${file} has no findings left; drop its allowlist entry`,
).toBe(true);
}
});
it("flags a near-now fixture next to a window, and nothing older", () => {
const now = Date.UTC(2026, 9, 15);
const findings = findAgingFixtureDates(
[
{
file: "a.test.ts",
source: [
'const takenAt = new Date("2026-07-15T08:00:00Z");',
'await get("/api/series?kind=pulse&days=90");',
].join("\n"),
},
{ file: "b.test.ts", source: 'get("/api/day-logs?date=2026-07-02");' },
{
file: "c.test.ts",
source: 'get("/api/labs?from=2019-04-01T00:00:00Z&to=2019-05-01");',
},
{ file: "d.test.ts", source: 'const at = new Date("2026-07-15");' },
],
now,
);
expect(findings.map((f) => `${f.file}:${f.line}`)).toEqual([
"a.test.ts:1",
"b.test.ts:1",
]);
});
});
@@ -0,0 +1,207 @@
/**
* No client list or picker with a fixed row cap and nothing to reach past it.
*
* A client read that asks for "the newest N" and renders the answer as the
* whole list loses everything beyond N without a word: the Labs list asked
* for 500 raw rows and grouped them in the browser, so on a long history
* every marker measured only long ago had no row at all. The visit-form lab
* picker was the same class before it, and the GLP-1 curve, the side-effect
* log and the symptom timeline each had a quieter copy.
*
* The rule: every literal `limit=<number>` a client fetch sends either sits
* beside a pager (an `offset` / `cursor` / `pageParam` within a few lines, or
* a `useInfiniteQuery` in the file), or is listed below with the reason the
* cap cannot cut anything off.
*
* What it matches: `limit=<digits>` inside a URL string, and the two
* `URLSearchParams` spellings, `.set("limit", "<digits>")` and
* `limit: "<digits>"`. What it does not: a limit routed through a named
* constant (`limit=${PAGE_SIZE}`), which is a deliberate page size in every
* case today. It walks client code only (`components/`, `hooks/`, and `app/`
* outside `app/api/`); a server-side `take` is not a fetch.
*/
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(process.cwd(), "src");
interface Allowed {
file: string;
limit: number;
reason: string;
}
const ALLOWLIST: readonly Allowed[] = [
{
file: "app/labs/page.tsx",
limit: 1,
reason:
"Deep-link resolution: the newest reading under one analyte name, used only to find its marker page.",
},
{
file: "components/onboarding/first-result-screen.tsx",
limit: 1,
reason: "Asks only whether a type has a reading, and which is newest.",
},
{
file: "components/insights/glp1-substance-curve-summary.tsx",
limit: 1,
reason:
"Reads only `medications`, which lists every GLP-1 medication whatever the entry cap; the entries are not used.",
},
{
file: "components/charts/chart-series-request.ts",
limit: 5000,
reason:
"Daily rollup buckets: one row per day, and the widest window (All, 3,650 days, plus a 365-day comparison) is 4,015 days.",
},
{
file: "components/insights/trend-annotation.tsx",
limit: 5000,
reason: "Daily rollup buckets over a 30-day window: at most 31 rows.",
},
{
file: "components/insights/coach-panel/attachment-picker.tsx",
limit: 50,
reason:
"Search-backed picker: the query narrows the whole document vault server-side.",
},
{
file: "components/settings/share-document-picker.tsx",
limit: 50,
reason:
"Search-backed picker: the query narrows the whole document vault server-side.",
},
{
file: "components/documents/document-link-picker.tsx",
limit: 50,
reason:
"Search-backed picker: the query narrows the whole document vault server-side.",
},
{
file: "hooks/use-mood-day.ts",
limit: 50,
reason:
"One calendar day of mood entries; fifty check-ins in a day is beyond any cadence the app offers.",
},
];
const LIMIT_PATTERNS: readonly RegExp[] = [
/[?&]limit=(\d+)/g,
/\.set\(\s*["']limit["']\s*,\s*["'](\d+)["']\s*\)/g,
/\blimit:\s*["'](\d+)["']/g,
];
const PAGER_NEARBY = /\boffset\b|\bcursor\b|\bpageParam\b/;
const PAGER_LINES = 3;
function clientFiles(): string[] {
return walkSourceFiles(SRC, { floor: 3000 })
.filter((p) => !p.startsWith("generated/"))
.filter(
(p) =>
p.startsWith("components/") ||
p.startsWith("hooks/") ||
(p.startsWith("app/") && !p.startsWith("app/api/")),
)
.filter((p) => !p.includes("__tests__"))
.filter((p) => !/\.test\.tsx?$/.test(p))
.sort();
}
interface Hit {
file: string;
line: number;
limit: number;
paged: boolean;
}
/** Every literal limit in the given source, with whether a pager is beside it. */
function findLimitHits(file: string, source: string): Hit[] {
const lines = source.split("\n");
const infinite = /\buseInfiniteQuery\b/.test(source);
const hits: Hit[] = [];
lines.forEach((text, index) => {
// Comments describe old shapes; they send nothing.
const trimmed = text.trim();
if (trimmed.startsWith("//") || trimmed.startsWith("*")) return;
for (const pattern of LIMIT_PATTERNS) {
for (const match of text.matchAll(pattern)) {
const near = lines
.slice(Math.max(0, index - PAGER_LINES), index + PAGER_LINES + 1)
.join("\n");
hits.push({
file,
line: index + 1,
limit: Number(match[1]),
paged: infinite || PAGER_NEARBY.test(near),
});
}
}
});
return hits;
}
function allHits(): Hit[] {
return clientFiles().flatMap((file) =>
findLimitHits(file, readFileSync(join(SRC, file), "utf8")),
);
}
function isAllowed(hit: Hit): boolean {
return ALLOWLIST.some((a) => a.file === hit.file && a.limit === hit.limit);
}
describe("client fetch limits", () => {
it("finds the literal limits it is meant to police", () => {
// An empty sweep would agree with any allowlist. Every allowlisted cap is
// a live hit, so the hit set can never be smaller than the list.
expect(allHits().length).toBeGreaterThanOrEqual(ALLOWLIST.length);
});
it("puts every fixed limit beside a pager or on the allowlist", () => {
const offenders = allHits()
.filter((hit) => !hit.paged && !isAllowed(hit))
.map((hit) => `${hit.file}:${hit.line} limit=${hit.limit}`);
expect(
offenders,
"A client fetch with a fixed limit loses everything past it. Page it (offset / cursor / useInfiniteQuery), move the grouping server-side, or add an allowlist entry that says why the cap cannot cut anything off.",
).toEqual([]);
});
it("keeps no allowlist entry that no longer matches anything", () => {
const hits = allHits();
const stale = ALLOWLIST.filter(
(a) => !hits.some((h) => h.file === a.file && h.limit === a.limit),
).map((a) => `${a.file} limit=${a.limit}`);
expect(stale).toEqual([]);
});
it("gives every allowlist entry a reason", () => {
for (const entry of ALLOWLIST) {
expect(entry.reason.length, entry.file).toBeGreaterThan(20);
}
});
it("flags the shape that dropped markers from the Labs list", () => {
const hits = findLimitHits(
"components/labs/lab-list.tsx",
'apiGet("/api/labs?limit=500&sortDir=desc")',
);
expect(hits).toHaveLength(1);
expect(hits[0].paged).toBe(false);
expect(isAllowed(hits[0])).toBe(false);
});
it("accepts a limit that travels with an offset", () => {
const hits = findLimitHits(
"components/example.tsx",
"fetch(`/api/x?status=taken&limit=100&offset=${offset}`)",
);
expect(hits).toHaveLength(1);
expect(hits[0].paged).toBe(true);
});
});
@@ -69,6 +69,18 @@ function documentedContainerScripts(): string[] {
return [...found].sort();
}
/** Scripts `docker-entrypoint.sh` runs with `node scripts/…` on every boot. */
function entrypointScripts(): string[] {
const entrypoint = readFileSync(join(ROOT, "docker-entrypoint.sh"), "utf8");
return [
...new Set(
[...entrypoint.matchAll(/^\s*node\s+(scripts\/[\w.-]+)/gm)].map(
(m) => m[1],
),
),
].sort();
}
/** Relative import specifiers of a script, resolved against its location. */
function localImports(script: string): string[] {
const src = readFileSync(join(ROOT, script), "utf8");
@@ -109,3 +121,21 @@ describe("scripts the runbooks run inside the container ship in the image", () =
expect(missing).toEqual([]);
});
});
describe("scripts the entrypoint runs ship in the image", () => {
it("finds the boot scripts it is meant to check", () => {
// An empty match would agree with any Dockerfile.
expect(entrypointScripts()).toContain("scripts/pending-migrations.mjs");
});
it("copies every boot script and its local imports into the runner stage", () => {
const missing = entrypointScripts()
.flatMap((script) => [script, ...localImports(script)])
.filter((path) => !copiedIntoRunner(path));
expect(
missing,
"docker-entrypoint.sh runs these on every boot, but the Dockerfile " +
"runner stage does not COPY them; the container would fail to start.",
).toEqual([]);
});
});
@@ -126,12 +126,10 @@ const NO_REFETCH_ALLOWLIST = [
// Background integration-sync completion on the Settings surface — the user
// is configuring an integration, not acting on the hero; convergence via the
// daily reads' own poll/focus refetch is the intended behaviour.
"components/settings/integrations/fitbit-card.tsx",
"components/settings/integrations/google-health-card.tsx",
"components/settings/integrations/integration-actions.tsx",
"components/settings/integrations/nightscout-card.tsx",
"components/settings/integrations/oauth-provider-card.tsx",
"components/settings/integrations/whoop-card.tsx",
"components/settings/integrations/withings-card.tsx",
"components/settings/integrations/oauth-integration-card.tsx",
// Cycle writes: `cycleDependentKeys` carries no daily-read key and neither
// the dashboard snapshot nor the Today digest reads cycle rows today, so
// the forced refetch would be a no-op round-trip. The bundle is in the
+18 -14
View File
@@ -616,21 +616,25 @@ const REGISTRY: DestructiveEntry[] = [
},
// ── Integrations ────────────────────────────────────────────────────────
...(
[
["withings-card.tsx", "Withings"],
["fitbit-card.tsx", "Fitbit"],
["whoop-card.tsx", "WHOOP"],
["google-health-card.tsx", "Google Health"],
["nightscout-card.tsx", "Nightscout"],
["oauth-provider-card.tsx", "the generic OAuth providers"],
] as const
).map(([f, name]): DestructiveEntry => ({
file: `components/settings/integrations/${f}`,
destroys: `the stored ${name} credentials — reconnecting means authorising again`,
// Every connection renders the shared integration card, whose three-dot
// menu raises the confirmation for the destructive entry; the controllers
// below only hold the request.
{
file: "components/settings/integrations/oauth-integration-card.tsx",
destroys:
"the stored Withings, WHOOP, Fitbit, Google Health, Polar, Oura or Strava grant — reconnecting means authorising again",
triggers: ["components/settings/integrations/integration-card.tsx"],
recovery: "permanent",
confirm: ["AlertDialog"],
})),
confirm: ["ConfirmDialog"],
},
{
file: "components/settings/integrations/nightscout-card.tsx",
destroys:
"the stored Nightscout address and token — reconnecting means typing them again",
triggers: ["components/settings/integrations/integration-card.tsx"],
recovery: "permanent",
confirm: ["ConfirmDialog"],
},
{
file: "components/settings/integrations/document-sources-card.tsx",
destroys:
@@ -441,7 +441,7 @@ function assertBucketConsumerLineage(
const declaration = file.statements.find(
(statement): statement is ts.FunctionDeclaration =>
ts.isFunctionDeclaration(statement) &&
statement.name?.text === "pillFailurePropsFor",
statement.name?.text === "failureStreakFor",
);
expect(declaration?.body).toBeDefined();
let bucketInitializer: string | null = null;
@@ -473,7 +473,7 @@ function assertBucketConsumerLineage(
for (const kind of kinds) {
expect(
accesses,
`pillFailurePropsFor must read buckets.${kind} from that response value`,
`failureStreakFor must read buckets.${kind} from that response value`,
).toContain(`buckets.${kind}`);
}
}
@@ -51,6 +51,8 @@ const WRITE_SITES: Record<string, string> = {
"lib/measurements/create-from-telegram.ts": TAIL,
"lib/whoop/sync-body.ts": TAIL,
"lib/whoop/webhook-handler.ts": TAIL,
// Retiring the rows of Oura sleep periods the person rejected or deleted.
"lib/oura/sync.ts": TAIL,
// Integration syncs: each collects the (type, day) pairs it touched and
// runs both legs itself at the end of the sync.
"lib/fitbit/sync-core.ts":
+8 -1
View File
@@ -847,6 +847,10 @@ const DELEGABLE_ROUTES: Record<string, DelegableEntry> = {
domain: "labs",
why: "One lab result of the record, fetched by id then guarded against the resolved user before anything is serialised.",
},
"app/api/labs/overview/route.ts": {
domain: "labs",
why: "The Labs list's per-marker overview of the record: the newest reading of each biomarker, its count and a short trend. The same rows and the same biomarker include as `GET /api/labs`, grouped server-side; read only.",
},
"app/api/biomarkers/route.ts": {
domain: "labs",
why: "The record's biomarker catalogue: the analytes this account tracks and the ranges it tracks them against. Adding a marker is a delegable write; the PUT and DELETE on the sibling route are refused.",
@@ -2097,8 +2101,11 @@ const ACTOR_ROUTES: Record<string, string> = {
* v1.42 — the score-history read joins the record list and the manage
* literal (a score's daily course, MANAGE like the derived score itself).
* 252 -> 254.
*
* v1.42.4 -- the Labs list's per-marker overview joins the record list
* beside `GET /api/labs`, the rows it groups. 255 -> 256.
*/
const FROZEN_ENTRY_COUNT = 255;
const FROZEN_ENTRY_COUNT = 256;
/**
* The two surfaces that authenticate a Bearer token outside `requireAuth` —
+17 -29
View File
@@ -369,20 +369,26 @@ const PINNED_AFFORDANCES: Record<
"src/components/settings/integrations/connections-panel.tsx": {
"toast.success": 2,
},
"src/components/settings/integrations/fitbit-card.tsx": { "text-success": 2 },
"src/components/settings/integrations/google-health-card.tsx": {
// The saved-credentials confirmation line.
"src/components/settings/integrations/credentials-form.tsx": {
"text-success": 1,
},
// "Test connection" from the menu: the same ok line the button shows.
"src/components/settings/integrations/integration-actions.tsx": {
CheckCircle2: 2,
"text-success": 1,
},
// The "Working" badge tone, and "Copied" beside the diagnostics copy button.
"src/components/settings/integrations/integration-card.tsx": {
"text-success": 2,
},
"src/components/settings/integrations/nightscout-card.tsx": {
"text-success": 1,
},
"src/components/settings/integrations/oauth-provider-card.tsx": {
// A paused connection resumed.
"src/components/settings/integrations/oauth-integration-card.tsx": {
"text-success": 1,
},
"src/components/settings/integrations/whoop-card.tsx": { "text-success": 2 },
"src/components/settings/integrations/withings-card.tsx": {
"text-success": 2,
},
"src/components/settings/mcp-section.tsx": {
"text-success": 4,
"toast.success": 2,
@@ -466,9 +472,11 @@ const RESULT_SURFACES: ReadonlyArray<{
through: /IntakeImportSkipGroups/,
forbidsMarkers: true,
},
// The seven surfaces that were still gated on the transport.
// The provider sync surfaces that were still gated on the transport. Every
// integration card reports a sync through the shared action lines; Google
// Health keeps its own because a run can outlive its request.
{
path: "src/components/settings/integrations/fitbit-card.tsx",
path: "src/components/settings/integrations/integration-actions.tsx",
through: /WrittenOutcomeLine/,
forbidsMarkers: false,
},
@@ -477,26 +485,6 @@ const RESULT_SURFACES: ReadonlyArray<{
through: /WrittenOutcomeLine/,
forbidsMarkers: false,
},
{
path: "src/components/settings/integrations/nightscout-card.tsx",
through: /WrittenOutcomeLine/,
forbidsMarkers: false,
},
{
path: "src/components/settings/integrations/whoop-card.tsx",
through: /WrittenOutcomeLine/,
forbidsMarkers: false,
},
{
path: "src/components/settings/integrations/withings-card.tsx",
through: /WrittenOutcomeLine/,
forbidsMarkers: false,
},
{
path: "src/components/settings/integrations/oauth-provider-card.tsx",
through: /WrittenOutcomeLine/,
forbidsMarkers: false,
},
{
path: "src/components/labs/ocr-review-dialog.tsx",
through: /toastWrittenOutcome/,
+3 -1
View File
@@ -30,7 +30,9 @@ const REQUIRED_KEYS = [
"common.networkError",
"settings.googleHealthSyncResult",
"settings.googleHealthSyncFailed",
"settings.googleHealthBackfillInProgress",
// `settings.googleHealthBackfillInProgress` left this list in v1.42.4: the
// history-loading line became a sentence variant of the shared integration
// card (`settings.integrationCard.reason.dataArrivingHistory`).
"settings.syncOutcome.empty",
"settings.syncOutcome.partial",
"settings.syncOutcome.failed",
@@ -644,7 +644,7 @@ const SYNC_ROUTES: readonly SyncRouteSpec[] = [
id: "fitbit",
path: join(ROOT, "src/app/api/fitbit/sync/route.ts"),
responseClass: "resolved-with-full-sync-echo",
card: join(SYNC_CARD_ROOT, "fitbit-card.tsx"),
card: join(SYNC_CARD_ROOT, "integration-actions.tsx"),
exceptions: { fullSync: FULL_SYNC_EXCEPTION },
},
{
@@ -663,42 +663,42 @@ const SYNC_ROUTES: readonly SyncRouteSpec[] = [
id: "nightscout",
path: join(ROOT, "src/app/api/nightscout/sync/route.ts"),
responseClass: "resolved",
card: join(SYNC_CARD_ROOT, "nightscout-card.tsx"),
card: join(SYNC_CARD_ROOT, "integration-actions.tsx"),
exceptions: {},
},
{
id: "oura",
path: join(ROOT, "src/app/api/oura/sync/route.ts"),
responseClass: "resolved",
card: join(SYNC_CARD_ROOT, "oauth-provider-card.tsx"),
card: join(SYNC_CARD_ROOT, "integration-actions.tsx"),
exceptions: {},
},
{
id: "polar",
path: join(ROOT, "src/app/api/polar/sync/route.ts"),
responseClass: "resolved",
card: join(SYNC_CARD_ROOT, "oauth-provider-card.tsx"),
card: join(SYNC_CARD_ROOT, "integration-actions.tsx"),
exceptions: {},
},
{
id: "strava",
path: join(ROOT, "src/app/api/strava/sync/route.ts"),
responseClass: "resolved",
card: join(SYNC_CARD_ROOT, "oauth-provider-card.tsx"),
card: join(SYNC_CARD_ROOT, "integration-actions.tsx"),
exceptions: {},
},
{
id: "whoop",
path: join(ROOT, "src/app/api/whoop/sync/route.ts"),
responseClass: "resolved-with-full-sync-echo",
card: join(SYNC_CARD_ROOT, "whoop-card.tsx"),
card: join(SYNC_CARD_ROOT, "integration-actions.tsx"),
exceptions: { fullSync: FULL_SYNC_EXCEPTION },
},
{
id: "withings",
path: join(ROOT, "src/app/api/withings/sync/route.ts"),
responseClass: "resolved-with-full-sync-echo",
card: join(SYNC_CARD_ROOT, "withings-card.tsx"),
card: join(SYNC_CARD_ROOT, "integration-actions.tsx"),
exceptions: { fullSync: FULL_SYNC_EXCEPTION },
},
] as const;
@@ -908,7 +908,7 @@ function assertSyncCardConsumesFetchedResponse(
return;
}
const endpoint = responseInitializer.arguments[0];
const expectedEndpoint = path.endsWith("oauth-provider-card.tsx")
const expectedEndpoint = path.endsWith("integration-actions.tsx")
? "`/api/${provider}/sync`"
: `"/api/${routeId}/sync"`;
expect(
@@ -242,6 +242,8 @@ const EXEMPT_ROUTES: ReadonlyArray<string> = [
"src/app/api/workouts/batch/route.ts",
"src/app/api/labs/route.ts",
"src/app/api/labs/[id]/route.ts",
// The Labs list's per-marker overview reads the same row store.
"src/app/api/labs/overview/route.ts",
// v1.18.1 — the lab-result delete-Undo restore endpoint and the
// user-scoped Biomarker catalog CRUD share the LabResult data-layer
// reasoning: the labs module gates the SURFACES (the Labs page), not the
+6 -2
View File
@@ -63,6 +63,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
practitionerId: params.get("practitionerId") ?? undefined,
episodeId: params.get("episodeId") ?? undefined,
limit: params.get("limit") ?? undefined,
offset: params.get("offset") ?? undefined,
});
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422, {
@@ -72,6 +73,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
const query = parsed.data;
const take = query.limit ?? 100;
const skip = query.offset ?? 0;
const now = new Date();
const where = {
@@ -112,8 +114,9 @@ export const GET = apiHandler(async (request: NextRequest) => {
// `gt` and `gte` are different keys, so the caller's lower bound and this
// arm's floor both survive the spread; only the `lte` pair collided.
where: { ...where, occurredAt: { ...where.occurredAt, gt: now } },
orderBy: { occurredAt: "asc" },
orderBy: [{ occurredAt: "asc" }, { id: "asc" }],
take,
skip,
include: ENCOUNTER_INCLUDE,
}),
prisma.encounter.findMany({
@@ -121,8 +124,9 @@ export const GET = apiHandler(async (request: NextRequest) => {
...where,
occurredAt: { ...where.occurredAt, lte: pastCeiling },
},
orderBy: { occurredAt: "desc" },
orderBy: [{ occurredAt: "desc" }, { id: "desc" }],
take,
skip,
include: ENCOUNTER_INCLUDE,
}),
]);
+5
View File
@@ -2,6 +2,7 @@ import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { apiSuccess } from "@/lib/api-response";
import { loadOneConnectionHealth } from "@/lib/integrations/connection-health-load";
/**
* Get Fitbit / Google Health connection status for the current user (v1.12.0).
@@ -53,5 +54,9 @@ export const GET = apiHandler(async () => {
tokenExpiresAt: connection.tokenExpiresAt,
backfillCompleted: !!connection.backfillCompletedAt,
scope: connection.scope,
// The one server-resolved state (ok / limited / degraded /
// action_required) every client renders. The fields above stay for
// clients that still read them; see `ConnectionHealth` in the API docs.
health: await loadOneConnectionHealth(user.id, "fitbit"),
});
});
@@ -2,6 +2,7 @@ import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { apiSuccess } from "@/lib/api-response";
import { loadOneConnectionHealth } from "@/lib/integrations/connection-health-load";
/**
* Get Google Health connection status for the current user (v1.26.0).
@@ -58,5 +59,9 @@ export const GET = apiHandler(async () => {
backfillCompleted: !!connection.backfillCompletedAt,
needsReauth: connection.needsReauth,
scope: connection.scope,
// The one server-resolved state (ok / limited / degraded /
// action_required) every client renders. The fields above stay for
// clients that still read them; see `ConnectionHealth` in the API docs.
health: await loadOneConnectionHealth(user.id, "google-health"),
});
});
+19 -1
View File
@@ -43,6 +43,11 @@ export const GET = apiHandler(async (request: NextRequest) => {
const parsed = illnessEpisodeListQuerySchema.safeParse({
limit: params.get("limit") ?? undefined,
includeResolved: params.get("includeResolved") ?? undefined,
offset: params.get("offset") ?? undefined,
from: params.get("from") ?? undefined,
to: params.get("to") ?? undefined,
sortDir: params.get("sortDir") ?? undefined,
q: params.get("q") ?? undefined,
});
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422, {
@@ -50,15 +55,28 @@ export const GET = apiHandler(async (request: NextRequest) => {
});
}
const { from, to, q, offset, sortDir } = parsed.data;
const limit = parsed.data.limit ?? 50;
const order = sortDir ?? "desc";
const rows = await prisma.illnessEpisode.findMany({
where: {
userId: user.id,
deletedAt: null,
...(parsed.data.includeResolved === "false" ? { resolvedAt: null } : {}),
...(from || to
? {
onsetAt: {
...(from ? { gte: new Date(from) } : {}),
...(to ? { lte: new Date(to) } : {}),
},
}
: {}),
...(q ? { label: { contains: q, mode: "insensitive" as const } } : {}),
},
orderBy: { onsetAt: "desc" },
// `id` breaks ties so offset paging never repeats or drops a row.
orderBy: [{ onsetAt: order }, { id: order }],
take: limit,
skip: offset ?? 0,
});
annotate({
+9 -2
View File
@@ -68,7 +68,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
});
if (meds.length === 0) {
return apiSuccess({ hasGlp1: false, entries: [] });
return apiSuccess({ hasGlp1: false, entries: [], medications: [] });
}
const ninetyDaysAgo = new Date();
@@ -173,5 +173,12 @@ export const GET = apiHandler(async (request: NextRequest) => {
},
});
return apiSuccess({ hasGlp1: true, entries: entries.slice(0, limit) });
// `medications` is every GLP-1 medication, independent of `limit`: a
// consumer that needs the set (the substance-curve summary) must not
// derive it from a capped page of entries.
return apiSuccess({
hasGlp1: true,
entries: entries.slice(0, limit),
medications: meds.map((med) => ({ id: med.id, name: med.name })),
});
});
@@ -76,6 +76,19 @@ beforeEach(() => {
} as never);
});
/**
* The route issues two raw reads: the newest-sample probe per measurement type
* and (v1.42.4) the newest Apple Health dose. Answer each by its SQL.
*/
function mockProbes(rows: unknown, doseLastSeen: Date | null = null): void {
vi.mocked(prisma.$queryRaw).mockImplementation((async (
strings: TemplateStringsArray,
) =>
strings.join("?").includes("medication_intake_events")
? [{ last_seen: doseLastSeen }]
: rows) as never);
}
const callGet = GET as unknown as (req: NextRequest) => Promise<Response>;
function makeGetReq(): NextRequest {
return new NextRequest("http://localhost/api/integrations/healthkit");
@@ -313,9 +326,7 @@ describe("GET /api/integrations/healthkit — sync health", () => {
healthKitLastSyncedAt: null,
} as never);
vi.mocked(listLiveMeasurementTypes).mockResolvedValue(["PULSE"]);
vi.mocked(prisma.$queryRaw).mockResolvedValue([
{ type: "PULSE", last_seen: ago(DAY) },
] as never);
mockProbes([{ type: "PULSE", last_seen: ago(DAY) }] as never);
const body = await read();
expect(body.syncHealth.verdict).toBe("fresh");
expect(body.metricFreshness.map((e) => e.stale)).toEqual([false]);
@@ -330,7 +341,7 @@ describe("GET /api/integrations/healthkit — sync health", () => {
"RESPIRATORY_RATE",
"PULSE",
]);
vi.mocked(prisma.$queryRaw).mockResolvedValue([
mockProbes([
{ type: "RESPIRATORY_RATE", last_seen: ago(30 * DAY) },
{ type: "PULSE", last_seen: ago(60 * 60 * 1000) },
] as never);
@@ -441,7 +452,7 @@ describe("GET /api/integrations/healthkit — sync health", () => {
"RESPIRATORY_RATE",
"PULSE",
]);
vi.mocked(prisma.$queryRaw).mockResolvedValue([
mockProbes([
{ type: "RESPIRATORY_RATE", last_seen: ago(2 * DAY) },
{ type: "PULSE", last_seen: ago(60 * 60 * 1000) },
] as never);
@@ -476,15 +487,74 @@ describe("GET /api/integrations/healthkit — sync health", () => {
expect(byType.WORKOUTS).not.toHaveProperty("lastReceivedAt");
});
// v1.42.4 — Apple Health doses join the list with their own arrival facts,
// and every entry carries the last background arrival beside the last one.
it("serves a MEDICATION_DOSE entry and the last background arrival", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
healthKitConfigJson: null,
healthKitLastSyncedAt: ago(60 * 60 * 1000),
} as never);
vi.mocked(listLiveMeasurementTypes).mockResolvedValue(["PULSE"]);
const doseAt = ago(40 * DAY);
mockProbes([{ type: "PULSE", last_seen: ago(60 * 60 * 1000) }], doseAt);
const receivedAt = ago(60 * 60 * 1000);
const backgroundAt = ago(5 * 60 * 60 * 1000);
vi.mocked(prisma.healthKitTypeSync.findMany).mockResolvedValue([
{
type: "MEDICATION_DOSE",
lastReceivedAt: receivedAt,
lastTrigger: "foreground",
lastNewSampleAt: null,
lastBackgroundAt: backgroundAt,
},
{
type: "PULSE",
lastReceivedAt: receivedAt,
lastTrigger: "background",
lastNewSampleAt: receivedAt,
lastBackgroundAt: null,
},
] as never);
const body = await read();
expect(body.metricFreshness.map((e) => e.type)).toEqual([
"MEDICATION_DOSE",
"PULSE",
]);
const byType = Object.fromEntries(
body.metricFreshness.map((entry) => [entry.type, entry]),
);
expect(byType.MEDICATION_DOSE).toEqual({
type: "MEDICATION_DOSE",
lastSeenAt: doseAt.toISOString(),
// Forty days without a dose is not a dead pipe: as-needed medications.
stale: false,
lastReceivedAt: receivedAt.toISOString(),
lastTrigger: "foreground",
lastNewSampleAt: null,
lastBackgroundAt: backgroundAt.toISOString(),
});
expect(byType.PULSE).toMatchObject({ lastBackgroundAt: null });
});
it("leaves the dose entry out when there is no Apple Health dose", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
healthKitConfigJson: null,
healthKitLastSyncedAt: ago(DAY),
} as never);
vi.mocked(listLiveMeasurementTypes).mockResolvedValue(["PULSE"]);
mockProbes([{ type: "PULSE", last_seen: ago(DAY) }], null);
const body = await read();
expect(body.metricFreshness.map((e) => e.type)).toEqual(["PULSE"]);
});
it("leaves the arrival facts off when the ledger read fails", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
healthKitConfigJson: null,
healthKitLastSyncedAt: ago(DAY),
} as never);
vi.mocked(listLiveMeasurementTypes).mockResolvedValue(["PULSE"]);
vi.mocked(prisma.$queryRaw).mockResolvedValue([
{ type: "PULSE", last_seen: ago(DAY) },
] as never);
mockProbes([{ type: "PULSE", last_seen: ago(DAY) }] as never);
vi.mocked(prisma.healthKitTypeSync.findMany).mockRejectedValue(
new Error("ledger hiccup"),
);
+25 -6
View File
@@ -26,7 +26,11 @@ import {
newestAppleHealthDataAt,
withHealthKitArrivals,
} from "@/lib/integrations/healthkit-type-sync";
import { getSourceFreshness } from "@/lib/integrations/metric-freshness";
import {
getAppleHealthDoseFreshness,
getSourceFreshness,
} from "@/lib/integrations/metric-freshness";
import { loadOneConnectionHealth } from "@/lib/integrations/connection-health-load";
import {
classifyMetricFreshness,
PUSH_CADENCE,
@@ -173,11 +177,25 @@ export const GET = apiHandler(async () => {
// progress; the phone's queue and throttle state stay on the phone and are
// not guessed at. Fail-soft for the same reason the freshness read is —
// a progress line is never worth 500-ing the config the iOS client needs.
const [samples, arrivals, syncProgress] = await Promise.all([
getSourceFreshness(user.id, "APPLE_HEALTH").catch(() => []),
getHealthKitTypeArrivals(user.id).catch(() => null),
getAppleHealthSyncProgress(user.id).catch(() => null),
]);
// `health` is the unified connection state; fail-soft to null for the same
// reason as the reads beside it, so the config the phone needs never 500s
// over a diagnostic.
// v1.42.4 — Apple Health doses join the list as `MEDICATION_DOSE`, so the
// phone can read when doses last arrived from the same entries as every
// other type. Fail-soft on its own: a failed dose read drops that one entry.
const [measurementSamples, doseSample, arrivals, syncProgress, health] =
await Promise.all([
getSourceFreshness(user.id, "APPLE_HEALTH").catch(() => []),
getAppleHealthDoseFreshness(user.id).catch(() => null),
getHealthKitTypeArrivals(user.id).catch(() => null),
getAppleHealthSyncProgress(user.id).catch(() => null),
loadOneConnectionHealth(user.id, "apple-health").catch(() => null),
]);
const samples = doseSample
? [...measurementSamples, doseSample].sort((a, b) =>
a.type.localeCompare(b.type),
)
: measurementSamples;
// No `connected` hint: Apple Health has nothing to connect or disconnect, so
// "never delivered" resolves to `pending_first_sync`, not `disconnected`.
// "Delivered" is read off the data, not only off the user column: that
@@ -202,6 +220,7 @@ export const GET = apiHandler(async () => {
arrivals,
),
syncProgress,
health,
});
});
@@ -5,6 +5,9 @@ vi.mock("@/lib/api-handler", () => ({
requireAuth: vi.fn(async () => ({ user: { id: "u1" } })),
}));
vi.mock("@/lib/integrations/connection-health-load", () => ({
loadConnectionHealth: vi.fn().mockResolvedValue({}),
}));
vi.mock("@/lib/db", () => ({
prisma: {
user: { findUnique: vi.fn() },
@@ -444,3 +447,40 @@ describe("/api/integrations/status — what a provider withholds by contract", (
).toEqual([]);
});
});
describe("/api/integrations/status — the unified connection health", () => {
beforeEach(() => {
vi.clearAllMocks();
for (const key of Object.keys(perIntegrationLedger)) {
delete perIntegrationLedger[key];
}
});
it("puts each provider's resolved health on its own entry, null where not connected", async () => {
const { loadConnectionHealth } =
await import("@/lib/integrations/connection-health-load");
const ouraHealth = { state: "limited", reason: "permission_limited" };
vi.mocked(loadConnectionHealth).mockResolvedValueOnce({
withings: null,
whoop: null,
fitbit: null,
"google-health": null,
polar: null,
oura: ouraHealth,
strava: null,
nightscout: null,
"apple-health": null,
} as never);
userFind.mockResolvedValue({ ouraAccessTokenEncrypted: "tok" });
const entries = (await fetchEntries()) as Array<
Entry & { health?: unknown }
>;
expect(entries.find((e) => e.integration === "oura")?.health).toEqual(
ouraHealth,
);
for (const entry of entries.filter((e) => e.integration !== "oura")) {
expect(entry.health).toBeNull();
}
});
});
+34
View File
@@ -51,6 +51,8 @@ import {
import { getPolarClientCredentials } from "@/lib/polar/credentials";
import { getStravaClientCredentials } from "@/lib/strava/credentials";
import { hasActivityScope } from "@/lib/withings/client";
import { loadConnectionHealth } from "@/lib/integrations/connection-health-load";
import type { ConnectionHealth } from "@/lib/integrations/connection-health";
export const dynamic = "force-dynamic";
@@ -159,6 +161,20 @@ export const GET = apiHandler(async () => {
getStravaClientCredentials(user.id).then((c) => !!c),
]);
// The unified per-connection state. Resolved by the same loader the
// per-provider routes and the integration watchdog use, so the envelope and
// every other surface say the same thing about a connection.
const health = await loadConnectionHealth(user.id, [
"withings",
"whoop",
"fitbit",
"google-health",
"polar",
"oura",
"strava",
"nightscout",
]);
// F-SYNC-1 — per-metric-type last-value timestamps so the card can show that a
// single metric type has gone silent even while the integration reads green
// ("connected · 5 min ago"). Fail-soft: this is an honesty signal, never worth
@@ -301,6 +317,7 @@ export const GET = apiHandler(async () => {
parseWithingsApiPlanState(withingsConn.apiPlanState),
)
: [],
health: health["withings"],
...resolve("withings", withingsStatus, {
connected: !!withingsConn,
configured: withingsConfigured,
@@ -324,6 +341,7 @@ export const GET = apiHandler(async () => {
backfillCompleted: whoopConnected
? !!whoopConn?.backfillCompletedAt
: null,
health: health["whoop"],
...resolve("whoop", whoopStatus, {
connected: whoopConnected,
configured: whoopConfigured,
@@ -341,6 +359,7 @@ export const GET = apiHandler(async () => {
? fitbitConn.tokenExpiresAt.getTime() <= now
: null,
backfillCompleted: fitbitConn ? !!fitbitConn.backfillCompletedAt : null,
health: health["fitbit"],
...resolve("fitbit", fitbitStatus, {
connected: fitbitConnected,
configured: fitbitConfigured,
@@ -364,6 +383,7 @@ export const GET = apiHandler(async () => {
// user-revoked grant) surfaced from the connection row; the card reads it
// to raise a distinct "Reconnect" CTA separate from parked/disconnected.
needsReauth: googleHealthConn ? googleHealthConn.needsReauth : false,
health: health["google-health"],
...resolve("google-health", googleHealthStatus, {
connected: googleHealthConnected,
configured: googleHealthConfigured,
@@ -382,6 +402,7 @@ export const GET = apiHandler(async () => {
hasOwnCredentials:
!!dbUser?.polarClientIdEncrypted &&
!!dbUser?.polarClientSecretEncrypted,
health: health["polar"],
...resolve("polar", polarStatus, {
connected: polarConnected,
configured: polarConnected,
@@ -400,6 +421,7 @@ export const GET = apiHandler(async () => {
scopeReconnect: ouraConnected
? ouraScopeReconnectList(readOuraScopeDenied(dbUser?.ouraScopeDenied))
: [],
health: health["oura"],
...resolve("oura", ouraStatus, {
connected: ouraConnected,
configured: ouraConnected,
@@ -416,6 +438,7 @@ export const GET = apiHandler(async () => {
!!dbUser?.stravaClientSecretEncrypted,
// Strava writes Workout rows, not Measurements — its freshness list
// carries the `WORKOUTS` pseudo-entry alone.
health: health["strava"],
...resolve("strava", stravaStatus, {
connected: stravaConnected,
configured: stravaConnected,
@@ -431,6 +454,7 @@ export const GET = apiHandler(async () => {
configured: nightscoutConfigured,
hasToken: !!dbUser?.nightscoutTokenEncrypted,
allowPrivateHost: dbUser?.nightscoutAllowPrivateHost ?? false,
health: health["nightscout"],
...resolve("nightscout", nightscoutStatus, {
connected: nightscoutConfigured,
configured: nightscoutConfigured,
@@ -442,6 +466,16 @@ export const GET = apiHandler(async () => {
interface IntegrationViewModel {
integration: IntegrationKey;
/**
* The one server-resolved state per connection (ok / limited / degraded /
* action_required), null when the provider is not connected. Every other
* health-shaped field on this entry (`state`, `lastError`,
* `consecutiveFailuresByKind`, `syncHealth`, `tokenExpired`, `needsReauth`,
* `hasActivityScope`, `planUnavailable`, `scopeReconnect`) is folded into
* it and goes once the settings cards render `health`; no other client
* reads this envelope.
*/
health: ConnectionHealth | null;
state: string;
lastSuccessAt: string | null;
lastAttemptAt: string | null;
+36
View File
@@ -0,0 +1,36 @@
import { NextRequest } from "next/server";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { apiSuccess, returnAllZodIssues } from "@/lib/api-response";
import { loadLabOverview } from "@/lib/labs/overview";
import { annotate } from "@/lib/logging/context";
import { labOverviewQuerySchema } from "@/lib/validations/labs";
/**
* `GET /api/labs/overview` — one entry per biomarker over the caller's whole
* live lab history: the newest reading, the reading count and a short trend.
* The Labs list renders from this instead of grouping a capped page of raw
* rows, so a marker measured only long ago still has its row. Same read gate
* as `GET /api/labs` (`requireRecordAuth("read", "labs")`).
*/
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireRecordAuth("read", "labs");
const parsed = labOverviewQuerySchema.safeParse(
Object.fromEntries(request.nextUrl.searchParams),
);
if (!parsed.success) {
annotate({
action: { name: "labs.overview.validation-failed" },
meta: { issue_count: parsed.error.issues.length },
});
return returnAllZodIssues(parsed.error, 422);
}
const markers = await loadLabOverview(user.id);
annotate({
action: { name: "labs.overview" },
meta: { markers: markers.length },
});
return apiSuccess({ markers });
});
+7 -1
View File
@@ -85,7 +85,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
return returnAllZodIssues(parsed.error, 422);
}
const { biomarkerId, analyte, panel, from, to, limit, offset, sortDir } =
const { biomarkerId, analyte, panel, q, from, to, limit, offset, sortDir } =
parsed.data;
const where = {
@@ -94,6 +94,12 @@ export const GET = apiHandler(async (request: NextRequest) => {
...(biomarkerId && { biomarkerId }),
...(analyte && { analyte }),
...(panel && { panel }),
...(q && {
OR: [
{ analyte: { contains: q, mode: "insensitive" as const } },
{ panel: { contains: q, mode: "insensitive" as const } },
],
}),
...(from || to
? {
takenAt: {
+12 -9
View File
@@ -24,10 +24,12 @@
* · per-day cumulative totals — `stats:<HK>:<YYYY-MM-DD>` (Steps,
* Active Energy, Sleep Duration, Walking/Running Distance,
* Flights Climbed);
* · v1.19.0 (iOS #34) hourly heart-rate buckets —
* `stats:HKQuantityTypeIdentifierHeartRate:<bucket-start-hour>`
* carrying the hour's AVERAGE bpm as one PULSE row, so iOS stops
* uploading one row per raw HR sample.
* · v1.19.0 (iOS #34) heart-rate buckets, 10 minutes wide since
* v1.30.7 — `stats:HKQuantityTypeIdentifierHeartRate:<bucket-start>`
* where `<bucket-start>` is the ISO-8601 UTC 10-minute boundary
* (minutes 00/10/20/30/40/50), carrying the bucket's AVERAGE bpm
* as one PULSE row, so iOS stops uploading one row per raw HR
* sample.
* Both surface as `updated` (the row's value is overwritten) rather
* than `duplicate` (the new value dropped). Sample-class externalIds
* (every other prefix) keep the strict immutable `duplicate`
@@ -71,6 +73,7 @@ import {
findFoldedWindowDuplicates,
} from "@/lib/measurements/folded-window";
import {
HEALTHKIT_SYNC_TRIGGERS,
recordHealthKitTypeArrivals,
type HealthKitTypeArrival,
} from "@/lib/integrations/healthkit-type-sync";
@@ -153,11 +156,11 @@ const batchEntrySchema = z.object({
// device's own verdict / severity; resolved to a `rhythmClassification`
// by `mapAppleHealthEntry`. Ignored for every non-event identifier.
categoryValue: z.number().int().min(0).max(20).optional(),
// v1.19.2 (iOS #34 extension) — per-bucket spread for the hourly
// heart-rate bucket. `value` carries the hour's AVERAGE bpm; these carry
// the hour's MIN / MAX so a client renders the intra-hour band without
// v1.19.2 (iOS #34 extension) — per-bucket spread for the 10-minute
// heart-rate bucket. `value` carries the bucket's AVERAGE bpm; these carry
// its MIN / MAX so a client renders the band inside the bucket without
// re-uploading raw samples. Persisted ONLY on a well-formed
// `stats:HKQuantityTypeIdentifierHeartRate:<hour>` row; ignored (stored
// `stats:HKQuantityTypeIdentifierHeartRate:<bucket-start>` row; ignored (stored
// NULL) for every other entry. Optional + backward-compatible: a pre-W192
// iOS build omits them and the bucket keeps the avg-only contract.
valueMin: z.number().finite().optional(),
@@ -196,7 +199,7 @@ const batchEntrySchema = z.object({
// pre-#66 caller stays byte-for-byte unchanged.
// v1.42 (#1173) — `manual` names a user-initiated "Sync all" run, as opposed to
// the app simply being open (`foreground`).
const syncTriggerEnum = z.enum(["foreground", "background", "push", "manual"]);
const syncTriggerEnum = z.enum(HEALTHKIT_SYNC_TRIGGERS);
const batchPayloadSchema = z.object({
entries: z.array(batchEntrySchema).min(1),
@@ -0,0 +1,137 @@
/**
* The Apple Health dose arrival record (`MEDICATION_DOSE` in the per-type
* arrival table) describes the phone's Apple Health sync. A narrow,
* single-purpose credential is not that sync, so a batch it sends never
* writes the record, the same rule the measurement batch keeps.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
medication: {
findMany: vi.fn(),
findFirst: vi.fn(),
updateMany: vi.fn(),
},
medicationIntakeEvent: {
create: vi.fn(),
findUnique: vi.fn(),
findMany: vi.fn(),
update: vi.fn(),
findFirst: vi.fn(),
},
user: { update: vi.fn() },
auditLog: { create: vi.fn() },
},
}));
vi.mock("@/lib/api-handler", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/api-handler")>();
return { ...actual, requireAuth: vi.fn() };
});
vi.mock("@/lib/integrations/healthkit-type-sync", async (importOriginal) => {
const actual =
await importOriginal<
typeof import("@/lib/integrations/healthkit-type-sync")
>();
return { ...actual, recordHealthKitTypeArrivals: vi.fn() };
});
vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() }));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn(),
rateLimitHeaders: () => ({}),
}));
vi.mock("@/lib/idempotency", () => ({
withIdempotency:
<Args extends unknown[]>(fn: (...args: Args) => Promise<Response>) =>
(...args: Args) =>
fn(...args),
}));
vi.mock("@/lib/cache/invalidate", () => ({
invalidateUserMedications: vi.fn(),
}));
vi.mock("@/lib/notifications/medication-intake-sync", () => ({
queueMedicationIntakeSync: vi.fn(),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { POST } from "../route";
import { prisma } from "@/lib/db";
import { requireAuth } from "@/lib/api-handler";
import { recordHealthKitTypeArrivals } from "@/lib/integrations/healthkit-type-sync";
import { checkRateLimit } from "@/lib/rate-limit";
const USER = {
id: "user-1",
username: "tester",
role: "USER" as const,
timezone: "Europe/Berlin",
};
/** A replayed Apple dose: the cheapest batch that still counts as an arrival. */
async function postReplayedDose(): Promise<Response> {
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValueOnce([
{ id: "row-existing", externalId: "hk-dose-1" },
] as never);
return POST(
new NextRequest("http://localhost/api/medications/intake/bulk", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
syncTrigger: "background",
entries: [
{
medicationId: "med-apple",
takenAt: "2026-06-15T12:00:00.000Z",
source: "APPLE_HEALTH",
externalId: "hk-dose-1",
},
],
}),
}),
);
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
vi.mocked(prisma.medication.findMany).mockResolvedValue([
{ id: "med-apple", externalSource: "APPLE_HEALTH" },
] as never);
});
describe("the dose arrival record and the credential that sent the batch", () => {
it("is written for the phone's own session", async () => {
vi.mocked(requireAuth).mockResolvedValue({
session: { id: "s" },
user: USER,
} as never);
const res = await postReplayedDose();
expect(res.status).toBe(200);
expect(recordHealthKitTypeArrivals).toHaveBeenCalledTimes(1);
});
it("is not written for a narrow, single-purpose credential", async () => {
vi.mocked(requireAuth).mockResolvedValue({
session: { id: "t" },
user: USER,
bearerScope: "measurements:write",
} as never);
const res = await postReplayedDose();
expect(res.status).toBe(200);
expect(recordHealthKitTypeArrivals).not.toHaveBeenCalled();
});
});
@@ -1380,9 +1380,11 @@ describe("POST /api/medications/intake/bulk — v1.28 Apple Health dose events",
});
it("stamps the externalId onto the pending slot row when the Apple dose converges by band", async () => {
// Dedup pre-check first (empty), then the canonical-slot find returns
// the worker-minted pending REMINDER row for the 19:00 slot.
// Dedup pre-check first (empty), then the held-dose probe (no take on
// the slot yet), then the canonical-slot find returns the worker-minted
// pending REMINDER row for the 19:00 slot.
vi.mocked(prisma.medicationIntakeEvent.findMany)
.mockResolvedValueOnce([] as never)
.mockResolvedValueOnce([] as never)
.mockResolvedValueOnce([
{
+75 -3
View File
@@ -29,6 +29,10 @@
* per Apple dose)
* }
*
* Top level also takes `syncTrigger?` (foreground | background | push |
* manual, v1.42.4), diagnostic only: it dates the `MEDICATION_DOSE` entry of
* the Apple Health per-type arrival record and changes no row.
*
* Response (always 200): processed / inserted / duplicates / skipped /
* entries[] mirroring the measurements + workouts batch envelope.
*
@@ -39,7 +43,7 @@ import { NextRequest } from "next/server";
import { z } from "zod/v4";
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiHandler, isScopedCredential, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import {
@@ -53,6 +57,11 @@ import {
import { withIdempotency } from "@/lib/idempotency";
import { checkRateLimit } from "@/lib/rate-limit";
import { markSyncCheckpoint } from "@/lib/sync/checkpoint";
import {
HEALTHKIT_SYNC_TRIGGERS,
MEDICATION_DOSE_ARRIVAL_TYPE,
recordHealthKitTypeArrivals,
} from "@/lib/integrations/healthkit-type-sync";
import { invalidateUserMedications } from "@/lib/cache/invalidate";
import {
recomputeMedicationComplianceForDay,
@@ -65,6 +74,7 @@ import {
resolveForcedSlotForWrite,
resolveSlotForWriteByBand,
resolveSlotInstantForWrite,
slotHeldByAnotherDose,
} from "@/lib/medications/scheduling/slot-upsert";
import { resolveInjectionSiteForWrite } from "@/lib/medications/injection-site-write";
import {
@@ -163,6 +173,10 @@ const bulkEntrySchema = z
const bulkPayloadSchema = z.object({
entries: z.array(bulkEntrySchema).min(1).max(MAX_ENTRIES_PER_BATCH),
// v1.42.4 — what woke the phone for this sync, the same diagnostic-only
// field `POST /api/measurements/batch` takes. It changes no row; it only
// feeds the `MEDICATION_DOSE` arrival record below.
syncTrigger: z.enum(HEALTHKIT_SYNC_TRIGGERS).optional(),
});
// v1.8.2 — `updated` joins the per-entry status vocabulary: a write that
@@ -181,7 +195,13 @@ interface EntryResult {
export const POST = apiHandler(withIdempotency<[NextRequest]>(postBulk));
async function postBulk(request: NextRequest): Promise<Response> {
const { user } = await requireAuth();
const auth = await requireAuth();
const { user } = auth;
// A narrow, single-purpose credential (a bridge, an ingest token) is no
// Apple Health sync, so it never writes the dose arrival record below. The
// bare `requireAuth()` refuses such a credential today; the check keeps the
// record honest should this route ever declare a scope.
const scoped = isScopedCredential(auth);
const rl = await checkRateLimit(
`medications:intake:bulk:${user.id}`,
@@ -242,7 +262,7 @@ async function postBulk(request: NextRequest): Promise<Response> {
});
}
const { entries } = parsed.data;
const { entries, syncTrigger } = parsed.data;
// Up-front ownership check — the user can only POST intake events
// against medications they own. One read covers every medicationId
@@ -338,6 +358,9 @@ async function postBulk(request: NextRequest): Promise<Response> {
let inserted = 0;
let updated = 0;
let duplicates = 0;
// External dose records kept as their own unscheduled dose because their
// band slot already carried a different one.
let extraDosesUnscheduled = 0;
const skipped: Array<{ index: number; reason: string }> = [];
const unstableShapes: UnstableExternalIdShape[] = [];
// v1.4.39 W-MED — collect distinct `(medicationId, dayKey)` pairs
@@ -443,6 +466,9 @@ async function postBulk(request: NextRequest): Promise<Response> {
// a band decision. Pending echoes / skips carry no decision
// (undefined → the upsert leaves an existing USER_PIN untouched).
let attributionSource: "AUTO" | "USER_PIN" | undefined;
// Set when the band slot is held by a different dose and this take is
// recorded unscheduled; it must not converge onto any slot row below.
let heldByAnotherDose = false;
if (isExplicitTaken && entry.forceSlotInstant !== undefined) {
canonicalSlot = await resolveForcedSlotForWrite({
userId: user.id,
@@ -486,6 +512,28 @@ async function postBulk(request: NextRequest): Promise<Response> {
});
canonicalSlot = attribution.slotInstant;
attributionSource = "AUTO";
// An external dose record (a HealthKit dose event) whose band slot
// already carries a different recorded dose is an extra dose, not a
// correction of that one. Converging would overwrite the slot's
// take last-write-wins and the extra dose would exist nowhere; it is
// recorded as its own unscheduled dose instead.
if (
canonicalSlot !== null &&
entry.externalId !== undefined &&
(await slotHeldByAnotherDose({
userId: user.id,
medicationId: entry.medicationId,
canonicalSlot,
takenAt: entry.takenAt as Date,
externalId: entry.externalId,
scheduledFor: entry.scheduledFor ?? null,
userTz: user.timezone,
}))
) {
canonicalSlot = null;
heldByAnotherDose = true;
extraDosesUnscheduled += 1;
}
} else if (isExplicitSkip && entry.scheduledFor === undefined) {
// v1.16.9 — a slot-less deliberate skip resolves by band
// membership on the skip moment, exactly like a take does. The
@@ -666,6 +714,7 @@ async function postBulk(request: NextRequest): Promise<Response> {
// late-morning dose consuming the evening slot). It records
// standalone (ad-hoc) instead.
const existingSlotRow =
!heldByAnotherDose &&
entry.scheduledFor !== undefined &&
mayConvergeOntoSuppliedSlot({
skipped: entry.skipped,
@@ -878,6 +927,7 @@ async function postBulk(request: NextRequest): Promise<Response> {
updated,
duplicates,
skipped: skipped.length,
extra_doses_unscheduled: extraDosesUnscheduled,
...(unstableShapes.length > 0
? unstableExternalIdMeta("medication.intake.bulk", unstableShapes)
: {}),
@@ -963,6 +1013,28 @@ async function postBulk(request: NextRequest): Promise<Response> {
})();
}
// v1.42.4 — Apple Health doses get the per-type arrival record the
// measurement batch keeps for its types: when they last arrived, under which
// trigger, when a sync last brought a new or changed dose, and when a
// background sync last carried them. Entries sent with `source:
// "APPLE_HEALTH"` only; a dose taken in the app is not an Apple Health
// arrival. A replay answered `duplicate` still counts as an arrival, the
// same rule as the batch route.
const doseArrival = { accepted: 0, newSamples: 0 };
for (const result of results) {
if (entries[result.index]?.source !== "APPLE_HEALTH") continue;
if (result.status === "skipped") continue;
doseArrival.accepted += 1;
if (result.status !== "duplicate") doseArrival.newSamples += 1;
}
if (doseArrival.accepted > 0 && !scoped) {
await recordHealthKitTypeArrivals(prisma, {
userId: user.id,
counts: new Map([[MEDICATION_DOSE_ARRIVAL_TYPE, doseArrival]]),
trigger: syncTrigger ?? null,
});
}
// The SyncMode checkpoint belongs to the push that moved the data, not to
// the `/api/sync/state` handshake that reports it. This endpoint is one of
// the three domains that handshake summarises, so an intake drain advances
+5
View File
@@ -2,6 +2,7 @@ import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { apiSuccess } from "@/lib/api-response";
import { loadOneConnectionHealth } from "@/lib/integrations/connection-health-load";
import { getIntegrationStatus } from "@/lib/integrations/status";
import {
INTEGRATION_CADENCE,
@@ -62,5 +63,9 @@ export const GET = apiHandler(async () => {
failingSinceAt: status.failingSince,
cadence: INTEGRATION_CADENCE["nightscout"],
}),
// The one server-resolved state (ok / limited / degraded /
// action_required) every client renders. The fields above stay for
// clients that still read them; see `ConnectionHealth` in the API docs.
health: await loadOneConnectionHealth(user.id, "nightscout"),
});
});
@@ -5,6 +5,9 @@ vi.mock("@/lib/api-handler", () => ({
requireAuth: vi.fn(async () => ({ user: { id: "u1" } })),
}));
vi.mock("@/lib/integrations/connection-health-load", () => ({
loadOneConnectionHealth: vi.fn().mockResolvedValue(null),
}));
vi.mock("@/lib/db", () => ({
prisma: { user: { findUnique: vi.fn() } },
}));
+5
View File
@@ -2,6 +2,7 @@ import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { apiSuccess } from "@/lib/api-response";
import { loadOneConnectionHealth } from "@/lib/integrations/connection-health-load";
import { getOuraClientCredentials } from "@/lib/oura/credentials";
import { getIntegrationStatus } from "@/lib/integrations/status";
import {
@@ -70,5 +71,9 @@ export const GET = apiHandler(async () => {
failingSinceAt: status.failingSince,
cadence: INTEGRATION_CADENCE["oura"],
}),
// The one server-resolved state (ok / limited / degraded /
// action_required) every client renders. The fields above stay for
// clients that still read them; see `ConnectionHealth` in the API docs.
health: await loadOneConnectionHealth(user.id, "oura"),
});
});
@@ -5,6 +5,9 @@ vi.mock("@/lib/api-handler", () => ({
requireAuth: vi.fn(async () => ({ user: { id: "u1" } })),
}));
vi.mock("@/lib/integrations/connection-health-load", () => ({
loadOneConnectionHealth: vi.fn().mockResolvedValue(null),
}));
vi.mock("@/lib/db", () => ({
prisma: { user: { findUnique: vi.fn() } },
}));
+5
View File
@@ -2,6 +2,7 @@ import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { apiSuccess } from "@/lib/api-response";
import { loadOneConnectionHealth } from "@/lib/integrations/connection-health-load";
import { getPolarClientCredentials } from "@/lib/polar/credentials";
import { getIntegrationStatus } from "@/lib/integrations/status";
import {
@@ -71,5 +72,9 @@ export const GET = apiHandler(async () => {
failingSinceAt: status.failingSince,
cadence: INTEGRATION_CADENCE["polar"],
}),
// The one server-resolved state (ok / limited / degraded /
// action_required) every client renders. The fields above stay for
// clients that still read them; see `ConnectionHealth` in the API docs.
health: await loadOneConnectionHealth(user.id, "polar"),
});
});
@@ -9,6 +9,7 @@ import {
getIntegrationStatus,
type IntegrationKey,
} from "@/lib/integrations/status";
import { loadConnectionHealth } from "@/lib/integrations/connection-health-load";
import { annotate } from "@/lib/logging/context";
import {
resolveGuardianRecordSettingsAccess,
@@ -70,6 +71,13 @@ export const GET = apiHandler(async () => {
oura: context.user.ouraAccessTokenEncrypted !== null,
strava: context.user.stravaAccessTokenEncrypted !== null,
};
// The one server-resolved state per connection, from the same loader the
// owner's own envelope uses, so a guardian reads the record's connections in
// the words the record's owner would.
const health = await loadConnectionHealth(
access.recordId,
MANAGED_INTEGRATIONS,
);
const integrations = statuses.map((status) => ({
integration: status.integration,
state: resolveManagedIntegrationState(
@@ -79,6 +87,7 @@ export const GET = apiHandler(async () => {
),
lastSuccessAt: status.lastSuccessAt,
lastAttemptAt: status.lastAttemptAt,
health: health[status.integration],
}));
annotate({ action: { name: "record-settings.integrations.get" } });
+5
View File
@@ -2,6 +2,7 @@ import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { apiSuccess } from "@/lib/api-response";
import { loadOneConnectionHealth } from "@/lib/integrations/connection-health-load";
import { getStravaClientCredentials } from "@/lib/strava/credentials";
import { getIntegrationStatus } from "@/lib/integrations/status";
import {
@@ -70,5 +71,9 @@ export const GET = apiHandler(async () => {
failingSinceAt: status.failingSince,
cadence: INTEGRATION_CADENCE["strava"],
}),
// The one server-resolved state (ok / limited / degraded /
// action_required) every client renders. The fields above stay for
// clients that still read them; see `ConnectionHealth` in the API docs.
health: await loadOneConnectionHealth(user.id, "strava"),
});
});
@@ -5,6 +5,9 @@ vi.mock("@/lib/api-handler", () => ({
requireAuth: vi.fn(async () => ({ user: { id: "u1" } })),
}));
vi.mock("@/lib/integrations/connection-health-load", () => ({
loadOneConnectionHealth: vi.fn().mockResolvedValue(null),
}));
vi.mock("@/lib/db", () => ({
prisma: {
user: { findUnique: vi.fn() },
+5
View File
@@ -2,6 +2,7 @@ import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { apiSuccess } from "@/lib/api-response";
import { loadOneConnectionHealth } from "@/lib/integrations/connection-health-load";
/**
* Get WHOOP connection status for the current user (v1.11.0).
@@ -54,5 +55,9 @@ export const GET = apiHandler(async () => {
tokenExpiresAt: connection.tokenExpiresAt,
backfillCompleted: !!connection.backfillCompletedAt,
scope: connection.scope,
// The one server-resolved state (ok / limited / degraded /
// action_required) every client renders. The fields above stay for
// clients that still read them; see `ConnectionHealth` in the API docs.
health: await loadOneConnectionHealth(user.id, "whoop"),
});
});
+5
View File
@@ -2,6 +2,7 @@ import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate, getEvent } from "@/lib/logging/context";
import { apiSuccess } from "@/lib/api-response";
import { loadOneConnectionHealth } from "@/lib/integrations/connection-health-load";
import { decrypt, encrypt } from "@/lib/crypto";
import { getUserWithingsCredentials } from "@/lib/withings/credentials";
import { hasActivityScope, refreshAccessToken } from "@/lib/withings/client";
@@ -118,5 +119,9 @@ export const GET = apiHandler(async () => {
// never re-authed since v1.4.25 — it counts as "missing activity".
scope: connection.scope,
hasActivityScope: hasActivityScope(connection.scope),
// The one server-resolved state (ok / limited / degraded /
// action_required) every client renders. The fields above stay for
// clients that still read them; see `ConnectionHealth` in the API docs.
health: await loadOneConnectionHealth(user.id, "withings"),
});
});
@@ -4,12 +4,26 @@ import { renderToStaticMarkup } from "react-dom/server";
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({ user: { modules: { vaccinations: true } } }),
}));
vi.mock("@/hooks/use-encounters", () => ({
useEncounters: () => ({
data: { upcoming: [], past: [] },
isPending: false,
isError: false,
refetch: vi.fn(),
vi.mock("@/components/links/use-link-picker-pages", async (importOriginal) => ({
...(await importOriginal<
typeof import("@/components/links/use-link-picker-pages")
>()),
// The visit picker's pages: none loaded, nothing more to page.
useLinkPickerPages: () => ({
items: [],
pending: false,
error: false,
retry: vi.fn(),
term: "",
setTerm: vi.fn(),
searchPending: false,
filterLocally: true,
hasOlder: false,
hasNewer: false,
loadingOlder: false,
loadingNewer: false,
loadOlder: vi.fn(),
loadNewer: vi.fn(),
}),
}));
vi.mock("@/components/vaccinations/use-vaccinations", () => ({
@@ -23,18 +23,31 @@
* reach the doses (`vaccinationLinks: null`) shows no dose block rather than
* an empty one that would claim the page records none.
*/
import { useState } from "react";
import { useMemo, useState } from "react";
import Link from "next/link";
import { CalendarDays, Syringe } from "lucide-react";
import { EntityLinkPicker } from "@/components/links/entity-link-picker";
import {
EntityLinkPicker,
serverPagingFrom,
} from "@/components/links/entity-link-picker";
import type { EntityLinkOption } from "@/components/links/entity-link-picker";
import { isNearAnchor } from "@/components/links/vault-document-options";
import { encounterPickerUrl } from "@/components/links/picker-requests";
import {
PICKER_PAGE_SIZE,
pickerPivot,
useLinkPickerPages,
type PickerPage,
type PickerPageRequest,
} from "@/components/links/use-link-picker-pages";
import { encounterKindText } from "@/components/encounters/encounter-labels";
import { useVaccinations } from "@/components/vaccinations/use-vaccinations";
import { useEncounters } from "@/hooks/use-encounters";
import { useAuth } from "@/hooks/use-auth";
import { apiGet } from "@/lib/api/api-fetch";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
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";
@@ -75,6 +88,25 @@ export function recordOptions(
return [...near, ...rest];
}
/**
* One picker page of visits. The route answers in two arms, each paged by the
* same offset; another page exists while either arm came back full.
*/
export async function fetchEncounterPage(
request: PickerPageRequest,
): Promise<PickerPage<EncounterDTO>> {
const list = await apiGet<EncounterListDTO>(encounterPickerUrl(request));
const full =
list.upcoming.length >= PICKER_PAGE_SIZE ||
list.past.length >= PICKER_PAGE_SIZE;
return {
items: [...list.upcoming, ...list.past],
next: full
? String(Number(request.cursor ?? "0") + PICKER_PAGE_SIZE)
: null,
};
}
/** Where a visit opens: its sheet on the checkups page. */
function visitHref(encounterId: string): string {
return `/checkups?visit=${encodeURIComponent(encounterId)}`;
@@ -132,7 +164,16 @@ export function DocumentRecordLinks({
const vaccinationsOn = user?.modules?.vaccinations !== false;
const anchor = doc.documentDate ?? doc.reportDate;
const encounters = useEncounters(canManage);
// The visits around the document's date, paged older and newer from
// there. The visit route has no text search (its free text is ciphertext
// at rest), so the term filters what is loaded.
const encounters = useLinkPickerPages<EncounterDTO>({
enabled: canManage,
pivot: pickerPivot(anchor),
queryKey: queryKeys.encounterPicker,
fetchPage: fetchEncounterPage,
serverSearch: false,
});
const vaccinations = useVaccinations(
null,
canManage && vaccinationsOn && doc.vaccinationLinks !== null,
@@ -150,16 +191,16 @@ export function DocumentRecordLinks({
};
const visitOptions = recordOptions(
[
...(encounters.data?.upcoming ?? []),
...(encounters.data?.past ?? []),
].map((visit) => ({
id: visit.id,
label: encounterKindText(t, visit.kind as EncounterKind),
meta: visit.practitioner?.name ?? null,
occurredAt: visit.occurredAt,
href: visitHref(visit.id),
})),
[...encounters.items]
// Two arms per page arrive in two orders; the list reads newest first.
.sort((a, b) => b.occurredAt.localeCompare(a.occurredAt))
.map((visit) => ({
id: visit.id,
label: encounterKindText(t, visit.kind as EncounterKind),
meta: visit.practitioner?.name ?? null,
occurredAt: visit.occurredAt,
href: visitHref(visit.id),
})),
anchor,
labels,
);
@@ -176,6 +217,19 @@ export function DocumentRecordLinks({
const showDoses = vaccinationsOn && doc.vaccinationLinks !== null;
// The visits the document is filed against, as its own read names them,
// so their chips show whatever page of visits is loaded.
const knownVisits = useMemo(
() =>
doc.encounterLinks.map((link) => ({
id: link.encounterId,
label: encounterKindText(t, link.kind as EncounterKind),
dateLabel: link.occurredAt ? format.date(link.occurredAt) : null,
href: visitHref(link.encounterId),
})),
[doc.encounterLinks, t, format],
);
return (
<>
{canManage ? (
@@ -183,11 +237,11 @@ export function DocumentRecordLinks({
icon={CalendarDays}
title={t("documents.detail.visitsLabel")}
slot="document-visit-links"
pending={encounters.isPending || !seedFresh}
error={encounters.isError || seedError}
pending={encounters.pending || !seedFresh}
error={encounters.error || seedError}
errorLabel={t("links.picker.loadError")}
onRetry={() => {
if (encounters.isError) void encounters.refetch();
if (encounters.error) encounters.retry();
if (seedError) onRetrySeed?.();
}}
selected={visitIds ?? doc.encounterLinks.map((l) => l.encounterId)}
@@ -196,6 +250,8 @@ export function DocumentRecordLinks({
onChange({ encounterIds: ids });
}}
options={visitOptions}
server={serverPagingFrom(encounters)}
knownOptions={knownVisits}
searchPlaceholder={t("links.picker.searchPlaceholder")}
emptyLabel={t("documents.detail.noVisitsToLink")}
/>
@@ -1,32 +0,0 @@
import { describe, expect, it } from "vitest";
import { illnessEpisodeListQuerySchema } from "@/lib/validations/illness";
import { listLabResultsSchema } from "@/lib/validations/labs";
import {
EPISODE_FETCH_LIMIT,
PICKER_FETCH_LIMIT,
} from "../encounter-link-pickers";
/**
* Each picker's page size must be one its list route accepts. A refused size
* comes back as a 422, and a picker that treats a failed read as an empty
* list tells the person there is nothing to link. That is how the visit
* form's condition picker and both document pickers showed nothing at all.
*/
describe("visit link picker page sizes", () => {
it("the condition picker asks for a page the episode route accepts", () => {
expect(
illnessEpisodeListQuerySchema.safeParse({
limit: String(EPISODE_FETCH_LIMIT),
includeResolved: "true",
}).success,
).toBe(true);
});
it("the lab picker asks for a page the lab route accepts", () => {
expect(
listLabResultsSchema.safeParse({ limit: String(PICKER_FETCH_LIMIT) })
.success,
).toBe(true);
});
});
@@ -33,6 +33,7 @@ import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import type { Encounter, EncounterWriteBody } from "@/hooks/use-encounters";
import type { Practitioner } from "@/hooks/use-practitioners";
import type { EncounterLinksDTO } from "@/lib/encounters/dto";
import {
ENCOUNTER_KINDS,
ENCOUNTER_STATUSES,
@@ -57,6 +58,12 @@ export interface EncounterDraft {
documentIds: string[];
labResultIds: string[];
episodeIds: string[];
/**
* The links the visit carried when the sheet opened, as its read names
* them, so their chips show whatever page the pickers have loaded. Display
* only; the id arrays above are what a save writes.
*/
links: EncounterLinksDTO | null;
}
function localValue(instant: Date): string {
@@ -91,6 +98,7 @@ export function emptyDraft(
documentIds: [],
labResultIds: [],
episodeIds: [],
links: null,
...overrides,
};
}
@@ -108,6 +116,7 @@ export function draftFromEncounter(row: Encounter): EncounterDraft {
documentIds: row.links?.documents.map((link) => link.id) ?? [],
labResultIds: row.links?.labResults.map((link) => link.id) ?? [],
episodeIds: row.links?.conditions.map((link) => link.id) ?? [],
links: row.links ?? null,
};
}
@@ -303,6 +312,7 @@ export function EncounterForm({
<EncounterLinkPickers
modules={user?.modules}
anchor={draft.occurredAt || null}
links={draft.links}
documentIds={draft.documentIds}
labResultIds={draft.labResultIds}
episodeIds={draft.episodeIds}
@@ -16,10 +16,17 @@
* those things, which is why the gate lives here and not on the route.
*
* Nothing in this block can block a save. Every list starts empty and stays
* valid empty. The picker raises only the FETCH limit so grouping has enough
* to group; the link cap stays authoritative on the server.
* valid empty, and the link cap stays authoritative on the server.
*
* **Every family is reachable however long the history.** The lab and
* condition pickers used to load the newest 200 results and the newest 100
* conditions and offer only those, so on a long record a visit from a few
* years back could not reach its own lab panel. Each picker now opens on the
* records around the visit's date and pages older and newer from there, and
* its search asks the list route (`useLinkPickerPages`). What the visit
* already links always shows, from the labels its own read carries.
*/
import { useQuery } from "@tanstack/react-query";
import { useMemo } from "react";
import { FlaskConical, FolderOpen, Stethoscope } from "lucide-react";
import { apiGet } from "@/lib/api/api-fetch";
@@ -27,43 +34,92 @@ import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import type { ModuleKey } from "@/lib/modules/registry";
import type { IllnessEpisodeDTO } from "@/lib/illness/dto";
import type { EncounterLinksDTO } from "@/lib/encounters/dto";
import {
EntityLinkPicker,
serverPagingFrom,
type EntityLinkOption,
} from "@/components/links/entity-link-picker";
import { useVaultDocumentOptions } from "@/components/links/vault-document-options";
import {
linkedDocumentOption,
useVaultDocumentOptions,
} from "@/components/links/vault-document-options";
import {
episodePickerUrl,
labPickerUrl,
} from "@/components/links/picker-requests";
import {
nextOffset,
pickerPivot,
useLinkPickerPages,
type PickerPage,
type PickerPageRequest,
} from "@/components/links/use-link-picker-pages";
import { ImportFromSourceButton } from "@/components/documents/sources/import-from-source-button";
import { mergeIds } from "@/components/documents/sources/merge-ids";
/** Fetch cap for the picker — the grouped sheet needs enough rows to group. */
export const PICKER_FETCH_LIMIT = 200;
/**
* The episode list route caps `limit` at 100 and answers anything above it
* with a 422, which the picker used to read as "nothing here to link".
*/
export const EPISODE_FETCH_LIMIT = 100;
interface LabPickerResult {
id: string;
analyte: string;
panel: string | null;
takenAt: string;
}
interface LabListPage {
results: Array<{
id: string;
analyte: string;
panel: string | null;
takenAt: string;
}>;
results: LabPickerResult[];
}
/** One picker page of lab results. */
export async function fetchLabPage(
request: PickerPageRequest,
): Promise<PickerPage<LabPickerResult>> {
const page = await apiGet<LabListPage>(labPickerUrl(request));
return {
items: page.results,
next: nextOffset(request.cursor, page.results.length),
};
}
/** One picker page of condition episodes. */
export async function fetchEpisodePage(
request: PickerPageRequest,
): Promise<PickerPage<IllnessEpisodeDTO>> {
const rows = await apiGet<IllnessEpisodeDTO[]>(episodePickerUrl(request));
return { items: rows, next: nextOffset(request.cursor, rows.length) };
}
/** A visit's existing link as a chip option, or null when it is withheld. */
function linkedOption(
link: { id: string; label: string | null; date: string | null },
date: (iso: string) => string,
href?: string,
): EntityLinkOption | null {
if (link.label === null) return null;
return {
id: link.id,
label: link.label,
dateLabel: link.date ? date(link.date) : null,
...(href ? { href } : {}),
};
}
export function EncounterLinkPickers({
modules,
anchor,
links,
documentIds,
labResultIds,
episodeIds,
onChange,
}: {
modules: Partial<Record<ModuleKey, boolean>> | undefined;
/** The visit's own date, for the document suggestions on top. */
/** The visit's own date: where every picker opens. */
anchor: string | null;
/**
* What the visit links already, as its own read names it (an edit). Their
* chips show whether or not a loaded page holds them.
*/
links?: EncounterLinksDTO | null;
documentIds: string[];
labResultIds: string[];
episodeIds: string[];
@@ -79,64 +135,76 @@ export function EncounterLinkPickers({
const documentsOn = modules?.inboundDocuments === true;
const labsOn = modules?.labs === true;
const illnessOn = modules?.illness === true;
const pivot = pickerPivot(anchor);
// The whole vault, suggestions near the visit's date on top — see
// `useVaultDocumentOptions` for why this no longer asks for one oversized
// page.
// The vault around the visit's date, suggestions on top — see
// `useVaultDocumentOptions`.
const documents = useVaultDocumentOptions({
enabled: documentsOn,
anchor,
});
const labs = useQuery({
queryKey: queryKeys.labResultsPicker(PICKER_FETCH_LIMIT),
const labs = useLinkPickerPages<LabPickerResult>({
enabled: labsOn,
queryFn: () =>
apiGet<LabListPage>(
`/api/labs?limit=${PICKER_FETCH_LIMIT}&offset=0&sortDir=desc`,
),
pivot,
queryKey: queryKeys.labResultsPicker,
fetchPage: fetchLabPage,
});
const episodes = useQuery({
queryKey: queryKeys.illnessEpisodesPicker(EPISODE_FETCH_LIMIT),
const episodes = useLinkPickerPages<IllnessEpisodeDTO>({
enabled: illnessOn,
queryFn: () =>
apiGet<IllnessEpisodeDTO[]>(
`/api/illness/episodes?includeResolved=true&limit=${EPISODE_FETCH_LIMIT}`,
),
pivot,
queryKey: queryKeys.illnessEpisodesPicker,
fetchPage: fetchEpisodePage,
});
const known = useMemo(() => {
const date = (iso: string) => format.date(iso);
const keep = (
option: EntityLinkOption | null,
): option is EntityLinkOption => option !== null;
return {
documents: (links?.documents ?? [])
.map((link) => linkedDocumentOption(link, date))
.filter(keep),
labResults: (links?.labResults ?? [])
.map((link) => linkedOption(link, date))
.filter(keep),
conditions: (links?.conditions ?? [])
.map((link) =>
linkedOption(link, date, `/illness/${encodeURIComponent(link.id)}`),
)
.filter(keep),
};
}, [links, format]);
if (!documentsOn && !labsOn && !illnessOn) return null;
// Labs group by sample date + panel: "Blutbild · 12.05.2026", the analyte
// underneath. A visit links the whole panel from a day in one "select all".
const labOptions: EntityLinkOption[] = (labs.data?.results ?? []).map(
(result) => {
const dayLabel = format.date(result.takenAt);
const groupLabel = result.panel
? `${result.panel} · ${dayLabel}`
: dayLabel;
return {
id: result.id,
label: result.analyte,
dateLabel: dayLabel,
group: {
key: `${result.takenAt.slice(0, 10)}|${result.panel ?? ""}`,
label: groupLabel,
},
};
},
);
const labOptions: EntityLinkOption[] = labs.items.map((result) => {
const dayLabel = format.date(result.takenAt);
const groupLabel = result.panel
? `${result.panel} · ${dayLabel}`
: dayLabel;
return {
id: result.id,
label: result.analyte,
dateLabel: dayLabel,
group: {
key: `${result.takenAt.slice(0, 10)}|${result.panel ?? ""}`,
label: groupLabel,
},
};
});
// Episodes stay flat (a bounded list), newest onset first from the route.
const episodeOptions: EntityLinkOption[] = (episodes.data ?? []).map(
(episode) => ({
id: episode.id,
label: episode.label,
dateLabel: episode.onsetAt ? format.date(episode.onsetAt) : null,
href: `/illness/${encodeURIComponent(episode.id)}`,
}),
);
// Episodes stay flat, newest onset first.
const episodeOptions: EntityLinkOption[] = episodes.items.map((episode) => ({
id: episode.id,
label: episode.label,
dateLabel: episode.onsetAt ? format.date(episode.onsetAt) : null,
href: `/illness/${encodeURIComponent(episode.id)}`,
}));
return (
<div className="space-y-4 border-t pt-4" data-slot="encounter-link-pickers">
@@ -152,6 +220,8 @@ export function EncounterLinkPickers({
selected={documentIds}
onChange={(ids) => onChange({ documentIds: ids })}
options={documents.options}
server={documents.server}
knownOptions={known.documents}
searchPlaceholder={t("links.picker.searchPlaceholder")}
emptyLabel={t("encounters.form.linkNothingToOffer")}
extraAction={
@@ -173,13 +243,15 @@ export function EncounterLinkPickers({
icon={FlaskConical}
title={t("encounters.form.linkLabResults")}
slot="encounter-link-labs"
pending={labs.isPending}
error={labs.isError}
pending={labs.pending}
error={labs.error}
errorLabel={t("links.picker.loadError")}
onRetry={() => void labs.refetch()}
onRetry={labs.retry}
selected={labResultIds}
onChange={(ids) => onChange({ labResultIds: ids })}
options={labOptions}
server={serverPagingFrom(labs)}
knownOptions={known.labResults}
searchPlaceholder={t("links.picker.searchPlaceholder")}
emptyLabel={t("encounters.form.linkNothingToOffer")}
/>
@@ -190,13 +262,15 @@ export function EncounterLinkPickers({
icon={Stethoscope}
title={t("encounters.form.linkConditions")}
slot="encounter-link-conditions"
pending={episodes.isPending}
error={episodes.isError}
pending={episodes.pending}
error={episodes.error}
errorLabel={t("links.picker.loadError")}
onRetry={() => void episodes.refetch()}
onRetry={episodes.retry}
selected={episodeIds}
onChange={(ids) => onChange({ episodeIds: ids })}
options={episodeOptions}
server={serverPagingFrom(episodes)}
knownOptions={known.conditions}
searchPlaceholder={t("links.picker.searchPlaceholder")}
emptyLabel={t("encounters.form.linkNothingToOffer")}
/>
@@ -206,6 +206,12 @@ describe("<RecoverySection> data-gating", () => {
// old hand-rolled div — the empty title comes from the unified primitive.
expect(html).toContain("border-dashed");
expect(html).not.toContain('data-slot="recovery-block-DAY_STRAIN"');
// An Oura account sees this note too: it must say where Oura's readiness
// lives rather than tell a connected user to connect Oura.
expect(html).not.toContain("Connect a WHOOP, Polar or Oura account");
expect(html).toContain(
"Oura&#x27;s readiness appears as your recovery score",
);
});
it("renders a canonical chart block for the present signal", () => {
@@ -9,9 +9,12 @@
*
* Gating: reads `GET /api/insights/glp1-timeline` — the server-authoritative
* GLP-1 signal. When `hasGlp1` is false the block renders nothing. The set of
* GLP-1 medication names comes from the timeline's entries (every entry is
* from a `treatmentClass === "GLP1"` medication), and the passed medication
* list is filtered to that set so non-GLP-1 medications never render a curve.
* GLP-1 medications comes from the response's `medications` list, which the
* server fills from every `treatmentClass === "GLP1"` medication independent
* of the entry cap, and the passed medication list is filtered to that set so
* non-GLP-1 medications never render a curve. (It used to be derived from the
* names in a capped page of entries, which dropped a medication whose events
* were all older than that page.)
*
* The chart carries its own `MedicationDetailSection` card chrome + estimate
* disclaimer, so it reads as one system with the surrounding Insights cards
@@ -36,7 +39,7 @@ const DrugLevelChart = dynamic(
interface Glp1TimelineResponse {
hasGlp1: boolean;
entries: { medicationName?: string }[];
medications: { id: string; name: string }[];
}
export function Glp1SubstanceCurveSummary({
@@ -45,24 +48,17 @@ export function Glp1SubstanceCurveSummary({
medications: { id: string; name: string; dose: string }[];
}) {
const { data } = useQuery({
queryKey: queryKeys.insightsGlp1Timeline(200),
// Only `medications` is read, so the smallest page of entries will do.
queryKey: queryKeys.insightsGlp1Timeline(1),
queryFn: () =>
apiGet<Glp1TimelineResponse>("/api/insights/glp1-timeline?limit=200"),
apiGet<Glp1TimelineResponse>("/api/insights/glp1-timeline?limit=1"),
staleTime: 60_000,
});
if (!data?.hasGlp1) return null;
// Every timeline entry is from a GLP-1 medication (server filters on
// `treatmentClass: "GLP1"`). Medication names match the comprehensive
// list exactly — both derive from `medication.name` server-side.
const glp1Names = new Set(
data.entries
.map((e) => e.medicationName)
.filter((n): n is string => typeof n === "string" && n.length > 0),
);
const glp1Meds = medications.filter((m) => glp1Names.has(m.name));
const glp1Ids = new Set(data.medications.map((m) => m.id));
const glp1Meds = medications.filter((m) => glp1Ids.has(m.id));
if (glp1Meds.length === 0) return null;
const showNameLabel = glp1Meds.length > 1;
@@ -4,7 +4,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import type { LabResultDto, LabResultListResponse } from "../types";
import type { LabOverviewResponse, LabResultDto } from "../types";
/**
* Every row of the compact list is five grid cells from `lg` up. The range
@@ -55,22 +55,29 @@ function renderList(results: LabResultDto[]) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false, staleTime: Infinity } },
});
const data: LabResultListResponse = {
results,
meta: { total: results.length, limit: 500, offset: 0 },
};
queryClient.setQueryData(
queryKeys.labResultsList({
biomarkerId: undefined,
analyte: undefined,
panel: undefined,
from: undefined,
to: undefined,
page: 0,
sortDir: "desc",
// The overview the server would send for these readings: one marker per
// biomarker (or lower-cased analyte), newest reading as `latest`.
const byKey = new Map<string, LabResultDto[]>();
for (const r of results) {
const key = r.biomarkerId
? `bm:${r.biomarkerId}`
: `analyte:${r.analyte.toLowerCase()}`;
byKey.set(key, [...(byKey.get(key) ?? []), r]);
}
const data: LabOverviewResponse = {
markers: [...byKey.entries()].map(([key, readings]) => {
const ordered = [...readings].sort(
(a, b) => Date.parse(a.takenAt) - Date.parse(b.takenAt),
);
return {
key,
count: ordered.length,
trend: ordered.map((r) => r.value),
latest: ordered[ordered.length - 1],
};
}),
data,
);
};
queryClient.setQueryData(queryKeys.labResultsOverview(), data);
return renderToStaticMarkup(
<QueryClientProvider client={queryClient}>
<I18nProvider initialLocale="en">
+38 -79
View File
@@ -26,57 +26,44 @@ import { useLabDate, useLabNumber } from "./use-lab-format";
import { LabReferenceRangeBar } from "./lab-reference-range-bar";
import { ReferenceRangeBadge } from "./reference-range-badge";
import { SourceRangeNote } from "./source-range-note";
import type { LabResultDto, LabResultListResponse } from "./types";
import type {
LabOverviewMarkerDto,
LabOverviewResponse,
LabResultDto,
} from "./types";
interface MarkerGroup {
/** Stable group key: the biomarker id, or `analyte:<lower>` for legacy rows. */
/** Stable group key: `bm:<biomarkerId>`, or `analyte:<lower>` for legacy rows. */
key: string;
/** Link target when the group is catalog-linked; null for legacy rows. */
biomarkerId: string | null;
analyte: string;
panel: string | null;
unit: string;
readings: LabResultDto[];
/** Every live reading of the marker, counted by the server. */
count: number;
/** Values of the newest readings, oldest first, for the sparkline. */
trend: (number | null)[];
latest: LabResultDto;
}
/**
* Group readings by their linked biomarker (or, for legacy un-linked rows,
* case-insensitively by analyte). A catalog-linked group deep-links to its
* detail chart; legacy groups render inert until the backfill links them.
* One group per marker, as the server resolved it over the whole history. A
* catalog-linked group deep-links to its detail chart; legacy groups render
* inert until the backfill links them. The server already orders by the
* newest reading.
*/
function groupReadings(results: LabResultDto[]): MarkerGroup[] {
const byKey = new Map<string, LabResultDto[]>();
for (const r of results) {
const key = r.biomarkerId
? `bm:${r.biomarkerId}`
: `analyte:${r.analyte.toLowerCase()}`;
const bucket = byKey.get(key);
if (bucket) bucket.push(r);
else byKey.set(key, [r]);
}
const groups: MarkerGroup[] = [];
for (const [key, readings] of byKey.entries()) {
const ordered = [...readings].sort(
(a, b) => new Date(a.takenAt).getTime() - new Date(b.takenAt).getTime(),
);
const latest = ordered[ordered.length - 1];
groups.push({
key,
biomarkerId: latest.biomarkerId,
analyte: latest.analyte,
panel: latest.panel,
unit: latest.unit,
readings: ordered,
latest,
});
}
return groups.sort(
(a, b) =>
new Date(b.latest.takenAt).getTime() -
new Date(a.latest.takenAt).getTime(),
);
function toGroups(markers: LabOverviewMarkerDto[]): MarkerGroup[] {
return markers.map((marker) => ({
key: marker.key,
biomarkerId: marker.latest.biomarkerId,
analyte: marker.latest.analyte,
panel: marker.latest.panel,
unit: marker.latest.unit,
count: marker.count,
trend: marker.trend,
latest: marker.latest,
}));
}
/**
@@ -157,26 +144,17 @@ export function LabList({
// (no catalog match) carry no subtitle rather than a fabricated one.
// v1.24 — the per-marker description moved to the biomarker detail page
// (beneath the heading); the overview rows no longer carry it.
const listKey = queryKeys.labResultsList({
biomarkerId: undefined,
analyte: undefined,
panel: undefined,
from: undefined,
to: undefined,
page: 0,
sortDir: "desc",
});
// One entry per biomarker over the whole history, grouped server-side: a
// capped page of raw rows dropped every marker measured only long ago.
const { data, isLoading, isError, refetch } = useQuery({
queryKey: listKey,
queryFn: () =>
apiGet<LabResultListResponse>("/api/labs?limit=500&sortDir=desc"),
queryKey: queryKeys.labResultsOverview(),
queryFn: () => apiGet<LabOverviewResponse>("/api/labs/overview"),
});
const groups = useMemo(() => {
const base = groupReadings(data?.results ?? []);
// v1.18.6 (MOD-04) — honour the user's Labs sort choice. `groupReadings`
// already returns most-recent-first; `recentAsc` reverses, the alpha
const base = toGroups(data?.markers ?? []);
// v1.18.6 (MOD-04) — honour the user's Labs sort choice. The server
// order is most-recent-first; `recentAsc` reverses, the alpha
// options sort by analyte name (#43), and `manual` applies the persisted
// biomarker order (legacy un-linked groups, which carry no biomarkerId,
// sort after the ordered block).
@@ -195,14 +173,7 @@ export function LabList({
return applyOrder(base, prefs.order, (g) => g.biomarkerId ?? g.key);
}
return base;
}, [data?.results, prefs.sortDir, prefs.order]);
// The list caps at 500 rows server-side (the `limit` ceiling). Surface a
// calm "showing latest N of M" hint when the cap truncates so the count is
// never silently wrong — proper cursor paging is a later iteration.
const total = data?.meta?.total ?? 0;
const shown = data?.results?.length ?? 0;
const truncated = total > shown;
}, [data?.markers, prefs.sortDir, prefs.order]);
const listEmpty = !isLoading && !isError && groups.length === 0;
useEffect(() => {
@@ -266,11 +237,6 @@ export function LabList({
// branch above carries `lab-list-loading` instead, so the marker
// cannot stand for a silhouette.
<div data-slot="lab-list" className="space-y-3">
{truncated ? (
<p className="text-muted-foreground text-xs">
{t("labs.showingLatestOf", { shown, total })}
</p>
) : null}
{/* `@container` makes the columns follow the width the list has, not the
window's: below `@5xl` (64rem of card) the row is the `lg` table it
always was (the two tiers exclude each other, so the result does not
@@ -319,7 +285,7 @@ export function LabList({
<LabRangeBarSlot reading={group.latest} />
<div className={cn("w-[72px]", MOBILE_CELL.trend)}>
<LabTrendSparkline
values={group.readings.map((reading) => reading.value)}
values={group.trend}
referenceLow={group.latest.referenceLow}
referenceHigh={group.latest.referenceHigh}
/>
@@ -343,7 +309,7 @@ export function LabList({
<LabRangeBarSlot reading={group.latest} />
<div className={cn("w-[72px]", MOBILE_CELL.trend)}>
<LabTrendSparkline
values={group.readings.map((reading) => reading.value)}
values={group.trend}
referenceLow={group.latest.referenceLow}
referenceHigh={group.latest.referenceHigh}
/>
@@ -380,13 +346,6 @@ export function LabList({
groups.length > 1 && "sm:grid-cols-2",
)}
>
{truncated ? (
<li className="sm:col-span-2">
<p className="text-muted-foreground text-xs">
{t("labs.showingLatestOf", { shown, total })}
</p>
</li>
) : null}
{groups.map((group) => {
return (
<li key={group.key} className="contents">
@@ -432,10 +391,10 @@ export function LabList({
{labDate(group.latest.takenAt)}
</span>
<LabConvertedNote reading={group.latest} />
{group.readings.length > 1 ? (
{group.count > 1 ? (
<span className="text-xs">
{t("labs.readingsCount", {
count: group.readings.length,
count: group.count,
})}
</span>
) : null}
@@ -447,7 +406,7 @@ export function LabList({
/>
</div>
<LabTrendSparkline
values={group.readings.map((reading) => reading.value)}
values={group.trend}
referenceLow={group.latest.referenceLow}
referenceHigh={group.latest.referenceHigh}
/>
+15
View File
@@ -65,6 +65,21 @@ export interface LabResultListResponse {
meta: { total: number; limit: number; offset: number };
}
/** One biomarker in `GET /api/labs/overview`. */
export interface LabOverviewMarkerDto {
/** `bm:<biomarkerId>`, or `analyte:<lower-cased name>` for a legacy row. */
key: string;
/** Every live reading of the marker. */
count: number;
/** Values of the newest readings, oldest first; null for a qualitative one. */
trend: (number | null)[];
latest: LabResultDto;
}
export interface LabOverviewResponse {
markers: LabOverviewMarkerDto[];
}
/** A user-scoped Biomarker catalog entry as the API serialises it. */
export interface BiomarkerDto {
id: string;
@@ -0,0 +1,199 @@
/**
* The link picker over a list that pages on the server.
*
* A visit's link pickers used to offer one capped page and nothing past it.
* Paged, three things have to hold: what the record already links shows
* whether or not a loaded page holds it, the sheet offers the paging the
* server reports, and an empty first window is not read as "nothing to link".
*
* ResponsiveSheet portals its body, which static markup does not render, so it
* is mocked down to a wrapper that always shows its children.
*/
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { FlaskConical } from "lucide-react";
vi.mock("@/components/ui/responsive-sheet", () => ({
ResponsiveSheet: ({ children }: { children: React.ReactNode }) => (
<div data-slot="mock-sheet">{children}</div>
),
}));
import { I18nProvider } from "@/lib/i18n/context";
import {
EntityLinkPicker,
resolvableOptions,
type EntityLinkOption,
type EntityLinkServerPaging,
} from "../entity-link-picker";
import { mergeWindowPages, uniqueById } from "../use-link-picker-pages";
function render(node: React.ReactNode) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">{node}</I18nProvider>,
);
}
const server = (over: Partial<EntityLinkServerPaging> = {}) => ({
term: "",
onTermChange: () => undefined,
searchPending: false,
hasOlder: false,
hasNewer: false,
loadingOlder: false,
loadingNewer: false,
onOlder: () => undefined,
onNewer: () => undefined,
...over,
});
const base = {
icon: FlaskConical,
title: "Lab results",
slot: "labs",
searchPlaceholder: "Search",
emptyLabel: "Nothing to link",
pending: false,
onChange: () => undefined,
};
const loaded: EntityLinkOption[] = [
{ id: "recent", label: "Glucose", dateLabel: "12.05.2026" },
];
const linked2019: EntityLinkOption = {
id: "ferritin-2019",
label: "Ferritin",
dateLabel: "03.04.2019",
};
describe("paged link picker", () => {
it("a linked record outside every loaded page still shows as a chip and in the sheet", () => {
const html = render(
<EntityLinkPicker
{...base}
options={loaded}
selected={["ferritin-2019"]}
knownOptions={[linked2019]}
server={server({ hasOlder: true })}
/>,
);
expect(html).toContain('data-slot="labs-chip"');
expect(html).toContain("Ferritin");
expect(html).toContain("03.04.2019");
// In the sheet, on top under its own heading.
expect(html).toContain("Linked");
});
it("offers older and newer pages while the server reports them", () => {
const html = render(
<EntityLinkPicker
{...base}
options={loaded}
selected={[]}
server={server({ hasOlder: true, hasNewer: true })}
/>,
);
expect(html).toContain('data-slot="labs-load-older"');
expect(html).toContain('data-slot="labs-load-newer"');
expect(html).toContain("Load older");
expect(html).toContain("Load newer");
});
it("offers no paging once the server has no more", () => {
const html = render(
<EntityLinkPicker
{...base}
options={loaded}
selected={[]}
server={server()}
/>,
);
expect(html).not.toContain('data-slot="labs-load-older"');
expect(html).not.toContain('data-slot="labs-load-newer"');
});
it("an empty first window with more to page is not 'nothing to link'", () => {
const html = render(
<EntityLinkPicker
{...base}
options={[]}
selected={[]}
server={server({ hasNewer: true })}
/>,
);
expect(html).not.toContain("Nothing to link");
expect(html).toContain('data-slot="labs-add"');
});
it("does not filter a server answer again by the term", () => {
// The route matched on the panel name; the option does not render it.
const html = render(
<EntityLinkPicker
{...base}
options={loaded}
selected={[]}
server={server({ term: "metabolic" })}
/>,
);
expect(html).toContain('data-slot="labs-option"');
});
it("a route without search filters what is loaded", () => {
const html = render(
<EntityLinkPicker
{...base}
options={loaded}
selected={[]}
server={server({ term: "nomatch", filterLocally: true })}
/>,
);
expect(html).not.toContain('data-slot="labs-option"');
});
});
describe("resolvableOptions", () => {
it("prefers the loaded option, then the known one, then a remembered pick", () => {
const picked = new Map([
["a", { id: "a", label: "picked" }],
["b", { id: "b", label: "picked" }],
["c", { id: "c", label: "picked" }],
]);
const map = resolvableOptions(
[{ id: "a", label: "loaded" }],
[
{ id: "a", label: "known" },
{ id: "b", label: "known" },
],
picked,
);
expect(map.get("a")?.label).toBe("loaded");
expect(map.get("b")?.label).toBe("known");
expect(map.get("c")?.label).toBe("picked");
});
});
describe("mergeWindowPages", () => {
it("puts the newer window, newest first, above the older one", () => {
const merged = mergeWindowPages(
[
{ items: [{ id: "n1" }, { id: "n2" }], next: "2" },
{ items: [{ id: "n3" }], next: null },
],
[{ items: [{ id: "o1" }, { id: "o2" }], next: null }],
);
expect(merged.map((item) => item.id)).toEqual([
"n3",
"n2",
"n1",
"o1",
"o2",
]);
});
it("drops a record the two windows both returned", () => {
expect(uniqueById([{ id: "x" }, { id: "y" }, { id: "x" }])).toEqual([
{ id: "x" },
{ id: "y" },
]);
});
});
@@ -0,0 +1,122 @@
/**
* Every request a link picker can send must be one its list route accepts.
*
* A refused parameter comes back as a 422, and a picker that reads a failed
* read as an empty list tells the person there is nothing to link. That is how
* the visit form's condition picker and both document pickers once showed
* nothing at all. Each builder is checked in every direction, with and without
* a date, against the very schema its route parses with.
*/
import { describe, expect, it } from "vitest";
import { documentListQuerySchema } from "@/lib/validations/inbound-documents";
import { encounterListQuerySchema } from "@/lib/validations/encounters";
import { illnessEpisodeListQuerySchema } from "@/lib/validations/illness";
import { listLabResultsSchema } from "@/lib/validations/labs";
import {
documentPickerUrl,
encounterPickerUrl,
episodePickerUrl,
labPickerUrl,
} from "../picker-requests";
import {
PICKER_PAGE_SIZE,
nextOffset,
pickerPivot,
type PickerPageRequest,
} from "../use-link-picker-pages";
const pivot = pickerPivot("2019-05-10T10:00:00.000Z");
const REQUESTS: PickerPageRequest[] = [
{ direction: "older", pivot, term: "", cursor: null },
{ direction: "older", pivot, term: "", cursor: "200" },
{ direction: "older", pivot: null, term: "", cursor: null },
{ direction: "newer", pivot, term: "", cursor: null },
{ direction: "newer", pivot, term: "", cursor: "100" },
{ direction: "search", pivot: null, term: "ferritin", cursor: null },
{ direction: "search", pivot: null, term: "ferritin", cursor: "100" },
];
function params(url: string): Record<string, string> {
return Object.fromEntries(new URL(url, "http://local").searchParams);
}
describe.each([
["labs", labPickerUrl, listLabResultsSchema],
["episodes", episodePickerUrl, illnessEpisodeListQuerySchema],
["encounters", encounterPickerUrl, encounterListQuerySchema],
])("%s picker", (_name, build, schema) => {
it.each(REQUESTS)("$direction (cursor $cursor) parses", (request) => {
const result = schema.safeParse(params(build(request)));
expect(result.success, JSON.stringify(result.error?.issues)).toBe(true);
});
});
describe("documents picker", () => {
it.each(REQUESTS)("$direction parses", (request) => {
// The document cursor is a row id, not an offset.
const url = documentPickerUrl({
...request,
cursor: request.cursor ? "doc-id" : null,
});
const result = documentListQuerySchema.safeParse(params(url));
expect(result.success, JSON.stringify(result.error?.issues)).toBe(true);
});
it("splits the day-granular window at the pivot's day", () => {
const older = params(
documentPickerUrl({ direction: "older", pivot, term: "", cursor: null }),
);
const newer = params(
documentPickerUrl({ direction: "newer", pivot, term: "", cursor: null }),
);
expect(older.to).toBe("2019-05-17");
expect(newer.from).toBe("2019-05-18");
expect(older.order).toBe("desc");
expect(newer.order).toBe("asc");
});
});
describe("the windows around a record's date", () => {
it("splits a week after the date, at the end of that day", () => {
expect(pivot).toBe("2019-05-17T23:59:59.999Z");
expect(pickerPivot(null)).toBeNull();
expect(pickerPivot("not a date")).toBeNull();
});
it("older reads up to the split newest first, newer after it oldest first", () => {
const older = params(
labPickerUrl({ direction: "older", pivot, term: "", cursor: null }),
);
const newer = params(
labPickerUrl({ direction: "newer", pivot, term: "", cursor: null }),
);
expect(older).toMatchObject({ to: pivot, sortDir: "desc", offset: "0" });
expect(newer).toMatchObject({
from: "2019-05-18T00:00:00.000Z",
sortDir: "asc",
});
});
it("a search ignores the window and sends the term", () => {
const search = params(
labPickerUrl({
direction: "search",
pivot: null,
term: "ferritin",
cursor: null,
}),
);
expect(search.q).toBe("ferritin");
expect(search.from).toBeUndefined();
expect(search.to).toBeUndefined();
});
it("an offset page that came back full has a next page, a short one has none", () => {
expect(nextOffset(null, PICKER_PAGE_SIZE)).toBe(String(PICKER_PAGE_SIZE));
expect(nextOffset("100", PICKER_PAGE_SIZE)).toBe("200");
expect(nextOffset("100", 3)).toBeNull();
});
});
@@ -91,37 +91,36 @@ describe("documentOptionsWithSuggestions", () => {
});
});
describe("fetchWholeVault", () => {
it("asks only for pages the list route accepts, and walks every cursor", async () => {
describe("fetchVaultPage", () => {
it("asks for one page the list route accepts and hands back its cursor", async () => {
const { documentListQuerySchema } =
await import("@/lib/validations/inbound-documents");
const apiFetch = await import("@/lib/api/api-fetch");
const urls: string[] = [];
const pages = [
{ documents: [doc("a", "2026-05-01")], nextCursor: "a" },
{ documents: [doc("b", "1991-03-01")], nextCursor: null },
];
const spy = vi
.spyOn(apiFetch, "apiGet")
.mockImplementation(async (url: string) => {
urls.push(url);
return pages.shift() as never;
return {
documents: [doc("a", "2026-05-01")],
nextCursor: "a",
} as never;
});
try {
const { fetchWholeVault } = await import("../vault-document-options");
const documents = await fetchWholeVault();
expect(documents.map((d) => d.id)).toEqual(["a", "b"]);
expect(urls).toHaveLength(2);
for (const url of urls) {
// The route parses its query with this schema and answers a 422 to
// anything it refuses; the picker used to read that 422 as an empty
// vault.
const params = Object.fromEntries(
new URL(url, "http://local").searchParams,
);
expect(documentListQuerySchema.safeParse(params).success).toBe(true);
}
expect(urls[1]).toContain("cursor=a");
const { fetchVaultPage } = await import("../vault-document-options");
const page = await fetchVaultPage({
direction: "older",
pivot: "2026-05-17T23:59:59.999Z",
term: "",
cursor: "z",
});
expect(page.items.map((d) => d.id)).toEqual(["a"]);
expect(page.next).toBe("a");
const params = Object.fromEntries(
new URL(urls[0]!, "http://local").searchParams,
);
expect(documentListQuerySchema.safeParse(params).success).toBe(true);
expect(params).toMatchObject({ cursor: "z", to: "2026-05-17" });
} finally {
spy.mockRestore();
}
+203 -17
View File
@@ -30,10 +30,18 @@
* sheet where its button cannot be pressed while the sheet is open. The chip
* carries the option's date, so two doses of one vaccine read as two
* different records.
*
* **A long list pages on the server.** A caller over a list that can outgrow
* one page passes `server`: the search box then asks the list route instead
* of filtering what is loaded, and "Load newer" / "Load older" page through
* the windows around the record's date (see `useLinkPickerPages`). A record
* that is linked always shows as a chip, whether or not the page that holds
* it is loaded: the caller passes what it knows about the existing links as
* `knownOptions`, and every option picked in this session is remembered.
*/
import { useMemo, useState, type ReactNode } from "react";
import Link from "next/link";
import { Check, Plus, Search, X } from "lucide-react";
import { Check, Loader2, Plus, Search, X } from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { Badge } from "@/components/ui/badge";
@@ -82,6 +90,74 @@ export function filterOptions(
return options.filter((option) => haystack(option).includes(needle));
}
/**
* Server-side search and paging for a list that can outgrow one page. The
* caller owns the term (it is part of the list route's query) and the pages.
*/
export interface EntityLinkServerPaging {
term: string;
onTermChange: (term: string) => void;
/** The typed term has not been answered yet. */
searchPending: boolean;
hasOlder: boolean;
hasNewer: boolean;
loadingOlder: boolean;
loadingNewer: boolean;
onOlder: () => void;
onNewer: () => void;
/**
* The list route has no text search: the term filters the loaded options
* here instead of being sent.
*/
filterLocally?: boolean;
}
/**
* The paging props from a `useLinkPickerPages` result, so every caller
* wires the same fields the same way.
*/
export function serverPagingFrom(pages: {
term: string;
setTerm: (term: string) => void;
searchPending: boolean;
hasOlder: boolean;
hasNewer: boolean;
loadingOlder: boolean;
loadingNewer: boolean;
loadOlder: () => void;
loadNewer: () => void;
filterLocally: boolean;
}): EntityLinkServerPaging {
return {
term: pages.term,
onTermChange: pages.setTerm,
searchPending: pages.searchPending,
hasOlder: pages.hasOlder,
hasNewer: pages.hasNewer,
loadingOlder: pages.loadingOlder,
loadingNewer: pages.loadingNewer,
onOlder: pages.loadOlder,
onNewer: pages.loadNewer,
filterLocally: pages.filterLocally,
};
}
/**
* The options a chip can be resolved from: what is loaded, what the caller
* knows about the existing links, and what was picked in this session. The
* loaded option wins, then the known one, then the picked one.
*/
export function resolvableOptions(
options: readonly EntityLinkOption[],
knownOptions: readonly EntityLinkOption[],
picked: ReadonlyMap<string, EntityLinkOption>,
): Map<string, EntityLinkOption> {
const map = new Map<string, EntityLinkOption>(picked);
for (const option of knownOptions) map.set(option.id, option);
for (const option of options) map.set(option.id, option);
return map;
}
export interface EntityLinkGroup {
/** Group key, or null for the flat (ungrouped) bucket. */
key: string | null;
@@ -165,6 +241,8 @@ export function EntityLinkPicker({
errorLabel,
onRetry,
extraAction,
server,
knownOptions,
}: {
icon: LucideIcon;
title: string;
@@ -191,27 +269,68 @@ export function EntityLinkPicker({
* the way forward. The element renders nothing when it has nothing to offer.
*/
extraAction?: ReactNode;
/** Server-side search and paging; omit for a list that fits one read. */
server?: EntityLinkServerPaging;
/**
* What the caller knows about records already linked (label and date from
* the record's own read), so their chips show even when no loaded page
* holds them.
*/
knownOptions?: EntityLinkOption[];
}) {
const { t } = useTranslations();
const [open, setOpen] = useState(false);
const [term, setTerm] = useState("");
const [localTerm, setLocalTerm] = useState("");
const term = server ? server.term : localTerm;
const setTerm = server ? server.onTermChange : setLocalTerm;
const optionById = useMemo(() => {
const map = new Map<string, EntityLinkOption>();
for (const option of options) map.set(option.id, option);
return map;
}, [options]);
const groups = useMemo(
() => groupOptions(filterOptions(options, term)),
[options, term],
// Every option picked in this session, so its chip survives a search or a
// page change that takes it out of the loaded list. Written from the event
// handlers, never during render.
const [picked, setPicked] = useState<ReadonlyMap<string, EntityLinkOption>>(
() => new Map(),
);
const remember = (more: readonly EntityLinkOption[]) => {
if (more.length === 0) return;
setPicked((prev) => {
const next = new Map(prev);
for (const option of more) next.set(option.id, option);
return next;
});
};
const optionById = useMemo(
() => resolvableOptions(options, knownOptions ?? [], picked),
[options, knownOptions, picked],
);
// A server-paged list is already the answer to the term; filtering it
// again here would drop a match on a field the option does not render.
const groups = useMemo(() => {
const visible =
server && !server.filterLocally ? options : filterOptions(options, term);
// Linked records the loaded pages do not hold stay reachable in the
// sheet, on top, so they can be unticked where they are listed.
const loaded = new Set(options.map((option) => option.id));
const linkedElsewhere = term.trim()
? []
: selected
.filter((id) => !loaded.has(id))
.map((id) => optionById.get(id))
.filter((option): option is EntityLinkOption => option != null)
.map((option) => ({
...option,
group: { key: "__linked__", label: t("links.picker.linked") },
}));
return groupOptions([...linkedElsewhere, ...visible]);
}, [options, term, server, selected, optionById, t]);
// The link the last X removed, offered back until the next change.
const [lastRemoved, setLastRemoved] = useState<EntityLinkOption | null>(null);
const change = (ids: string[]) => {
const change = (ids: string[], touched: readonly EntityLinkOption[] = []) => {
setLastRemoved(null);
remember(touched);
onChange(ids);
};
@@ -245,7 +364,9 @@ export function EntityLinkPicker({
onRetry={onRetry}
slot={`${slot}-error`}
/>
) : options.length === 0 ? (
) : options.length === 0 &&
selected.length === 0 &&
!(server && (server.hasOlder || server.hasNewer)) ? (
<div className="space-y-2">
<p className="text-muted-foreground text-xs">{emptyLabel}</p>
{extraAction}
@@ -315,7 +436,9 @@ export function EntityLinkPicker({
<button
type="button"
data-slot={`${slot}-undo-button`}
onClick={() => change(restoreLink(selected, lastRemoved.id))}
onClick={() =>
change(restoreLink(selected, lastRemoved.id), [lastRemoved])
}
className="text-foreground focus-visible:ring-ring/50 min-h-8 rounded-sm font-medium underline underline-offset-2 focus-visible:ring-[3px] focus-visible:outline-none"
>
{t("common.undo")}
@@ -363,12 +486,31 @@ export function EntityLinkPicker({
/>
</div>
{groups.length === 0 ? (
{server?.searchPending && groups.length === 0 ? (
<div
className="flex justify-center py-6"
data-slot={`${slot}-searching`}
>
<Loader2
className="text-muted-foreground size-4 animate-spin motion-reduce:animate-none"
aria-hidden
/>
</div>
) : groups.length === 0 &&
!(server && (server.hasOlder || server.hasNewer)) ? (
<p className="text-muted-foreground px-1 py-6 text-center text-sm">
{t("links.picker.searchNoMatch")}
</p>
) : (
<div className="max-h-[50vh] space-y-4 overflow-y-auto overscroll-contain">
{server?.hasNewer ? (
<PageButton
slot={`${slot}-load-newer`}
loading={server.loadingNewer}
onClick={server.onNewer}
label={t("links.picker.loadNewer")}
/>
) : null}
{groups.map((group) => {
const groupIds = group.options.map((option) => option.id);
return (
@@ -384,7 +526,9 @@ export function EntityLinkPicker({
<button
type="button"
data-slot={`${slot}-group-select`}
onClick={() => change(toggleAll(selected, groupIds))}
onClick={() =>
change(toggleAll(selected, groupIds), group.options)
}
className="text-muted-foreground hover:text-foreground text-xs underline-offset-2 hover:underline"
>
{t("links.picker.selectAll")}
@@ -401,7 +545,7 @@ export function EntityLinkPicker({
aria-pressed={on}
data-slot={`${slot}-option`}
onClick={() =>
change(toggleOne(selected, option.id))
change(toggleOne(selected, option.id), [option])
}
className={cn(
"border-border hover:bg-muted/50 focus-visible:ring-ring/50 flex min-h-11 w-full items-center gap-2 rounded-md border px-3 text-left focus-visible:ring-[3px] focus-visible:outline-none",
@@ -438,6 +582,14 @@ export function EntityLinkPicker({
</div>
);
})}
{server?.hasOlder ? (
<PageButton
slot={`${slot}-load-older`}
loading={server.loadingOlder}
onClick={server.onOlder}
label={t("links.picker.loadOlder")}
/>
) : null}
</div>
)}
@@ -451,3 +603,37 @@ export function EntityLinkPicker({
</div>
);
}
/** "Load newer" / "Load older": a full-width row inside the scrolling list. */
function PageButton({
slot,
loading,
onClick,
label,
}: {
slot: string;
loading: boolean;
onClick: () => void;
label: string;
}) {
return (
<Button
type="button"
variant="ghost"
size="sm"
className="min-h-11 w-full"
data-slot={slot}
disabled={loading}
aria-busy={loading}
onClick={onClick}
>
{loading ? (
<Loader2
className="size-4 animate-spin motion-reduce:animate-none"
aria-hidden
/>
) : null}
{label}
</Button>
);
}
+96
View File
@@ -0,0 +1,96 @@
/**
* The list-route requests behind each link picker, one builder per family.
*
* Pure, so a test can parse every URL a picker can send against the route's
* own query schema: a page size or a parameter the route refuses answers 422,
* and a picker that reads a 422 as an empty list hides the record it exists to
* offer. That is exactly how a vault once read as empty.
*
* Every builder speaks one {@link PickerPageRequest}: `older` is the window up
* to the split newest first, `newer` the window after it oldest first,
* `search` the term across the whole history. The cursor is the family's own:
* an offset for the routes that page by offset, the last row's id for the
* document route's keyset.
*/
import {
PICKER_PAGE_SIZE,
afterPivot,
type PickerPageRequest,
} from "./use-link-picker-pages";
function withParams(path: string, params: URLSearchParams): string {
return `${path}?${params.toString()}`;
}
/** `GET /api/labs` — offset paging, `from` / `to` on the draw instant. */
export function labPickerUrl(request: PickerPageRequest): string {
const params = new URLSearchParams({
limit: String(PICKER_PAGE_SIZE),
offset: request.cursor ?? "0",
sortDir: request.direction === "newer" ? "asc" : "desc",
});
if (request.direction === "search") params.set("q", request.term);
else if (request.pivot !== null) {
if (request.direction === "older") params.set("to", request.pivot);
else params.set("from", afterPivot(request.pivot));
}
return withParams("/api/labs", params);
}
/** `GET /api/illness/episodes` — offset paging, the window on the onset. */
export function episodePickerUrl(request: PickerPageRequest): string {
const params = new URLSearchParams({
includeResolved: "true",
limit: String(PICKER_PAGE_SIZE),
offset: request.cursor ?? "0",
sortDir: request.direction === "newer" ? "asc" : "desc",
});
if (request.direction === "search") params.set("q", request.term);
else if (request.pivot !== null) {
if (request.direction === "older") params.set("to", request.pivot);
else params.set("from", afterPivot(request.pivot));
}
return withParams("/api/illness/episodes", params);
}
/**
* `GET /api/documents/inbound` — keyset paging on the filing date. The route's
* window is day-granular and inclusive, so the newer window starts the day
* after the split's day.
*/
export function documentPickerUrl(request: PickerPageRequest): string {
const params = new URLSearchParams({
sort: "documentDate",
order: request.direction === "newer" ? "asc" : "desc",
limit: String(PICKER_PAGE_SIZE),
});
if (request.cursor) params.set("cursor", request.cursor);
if (request.direction === "search") params.set("q", request.term);
else if (request.pivot !== null) {
if (request.direction === "older") {
params.set("to", request.pivot.slice(0, 10));
} else {
params.set("from", afterPivot(request.pivot).slice(0, 10));
}
}
return withParams("/api/documents/inbound", params);
}
/**
* `GET /api/encounters` — offset paging inside a window on the visit instant.
* The route answers in two arms (upcoming soonest first, past newest first),
* each paged by the same offset; the picker merges and orders them itself.
* The visit route has no text search, so `search` reads the whole history
* and the picker filters what it has loaded.
*/
export function encounterPickerUrl(request: PickerPageRequest): string {
const params = new URLSearchParams({
limit: String(PICKER_PAGE_SIZE),
offset: request.cursor ?? "0",
});
if (request.direction !== "search" && request.pivot !== null) {
if (request.direction === "older") params.set("to", request.pivot);
else params.set("from", afterPivot(request.pivot));
}
return withParams("/api/encounters", params);
}
@@ -0,0 +1,231 @@
"use client";
/**
* The data behind a link picker over a list that can be long: the records
* around one date, paged older and newer from there, or a search across the
* whole history.
*
* The pickers used to fetch one capped page, newest first (200 lab results,
* 100 conditions, ten pages of documents, the last 100 visits), and offered
* only that. On a long record everything older than the cap could not be
* linked at all: a visit from 2019 could not reach its own 2019 lab panel.
*
* Now a picker opens on the records around the date of the thing being linked
* (the visit's, the dose's, the document's). The list is split a few days
* after that date (the same window the "belongs to a visit?" suggestion uses,
* so a result drawn two days after the visit is on the first page) into two
* server-side windows:
*
* - `older`: up to the split, newest first, so the record's own days lead
* the list, paged further back on request;
* - `newer`: after the split, oldest first, fetched only when asked for and
* paged further forward from there;
*
* and a typed term switches to `search`, which asks the list route itself
* (`q`) so a match is found wherever it sits in the history. With no date
* yet the picker opens on the newest records and pages back.
*
* Every page comes from the family's own list route through `fetchPage`, so
* paging, the filter and the ownership check stay server-side.
*/
import { useMemo, useState } from "react";
import { useInfiniteQuery } from "@tanstack/react-query";
import { useDebouncedValue } from "@/hooks/use-debounced-value";
import { ENCOUNTER_SUGGEST_WINDOW_DAYS } from "@/lib/encounters/suggest-window";
export type PickerDirection = "older" | "newer" | "search";
/** One page of a picker list, and the cursor for the next one (or null). */
export interface PickerPage<T> {
items: T[];
next: string | null;
}
export interface PickerPageRequest {
direction: PickerDirection;
/** The split instant (ISO), or null when the record has no date yet. */
pivot: string | null;
/** The trimmed search term; empty outside `search`. */
term: string;
/** The family's own cursor (an offset or a row id), null for page one. */
cursor: string | null;
}
/** Records per page, the same for every family. */
export const PICKER_PAGE_SIZE = 100;
/** How long typing settles before the search asks the server. */
const SEARCH_DEBOUNCE_MS = 250;
/** Drop repeats by id, keeping the first occurrence's position. */
export function uniqueById<T extends { id: string }>(items: readonly T[]): T[] {
const seen = new Set<string>();
const out: T[] = [];
for (const item of items) {
if (seen.has(item.id)) continue;
seen.add(item.id);
out.push(item);
}
return out;
}
/**
* The visible list, newest first: the newer pages (fetched oldest first, so
* reversed) above the older ones.
*/
export function mergeWindowPages<T extends { id: string }>(
newerPages: ReadonlyArray<PickerPage<T>>,
olderPages: ReadonlyArray<PickerPage<T>>,
): T[] {
const newer = newerPages.flatMap((page) => page.items).reverse();
const older = olderPages.flatMap((page) => page.items);
return uniqueById([...newer, ...older]);
}
export function useLinkPickerPages<T extends { id: string }>({
enabled,
pivot,
queryKey,
fetchPage,
serverSearch = true,
}: {
enabled: boolean;
pivot: string | null;
/** The family's factory key for one direction, pivot and term. */
queryKey: (
direction: PickerDirection,
pivot: string | null,
term: string,
) => readonly unknown[];
fetchPage: (request: PickerPageRequest) => Promise<PickerPage<T>>;
/**
* Whether the list route can search. A route without a text filter (the
* visit list: its free text is ciphertext at rest) leaves the term to the
* picker, which filters what is loaded.
*/
serverSearch?: boolean;
}) {
const [rawTerm, setTerm] = useState("");
const debounced = useDebouncedValue(rawTerm.trim(), SEARCH_DEBOUNCE_MS);
const term = serverSearch ? debounced : "";
const searching = term.length > 0;
const older = useInfiniteQuery({
queryKey: queryKey("older", pivot, ""),
enabled: enabled && !searching,
initialPageParam: null as string | null,
queryFn: ({ pageParam }) =>
fetchPage({ direction: "older", pivot, term: "", cursor: pageParam }),
getNextPageParam: (last) => last.next ?? undefined,
});
// The newer window is read only once asked for, and only for the split it
// was asked for: a changed date starts over on the older window.
const [newerFor, setNewerFor] = useState<string | null>(null);
const newerRequested = pivot !== null && newerFor === pivot;
const newer = useInfiniteQuery({
queryKey: queryKey("newer", pivot, ""),
// No date, no "after": the older window already starts at the newest.
enabled: enabled && !searching && newerRequested,
initialPageParam: null as string | null,
queryFn: ({ pageParam }) =>
fetchPage({ direction: "newer", pivot, term: "", cursor: pageParam }),
getNextPageParam: (last) => last.next ?? undefined,
});
const search = useInfiniteQuery({
queryKey: queryKey("search", null, term),
enabled: enabled && searching,
initialPageParam: null as string | null,
queryFn: ({ pageParam }) =>
fetchPage({ direction: "search", pivot: null, term, cursor: pageParam }),
getNextPageParam: (last) => last.next ?? undefined,
});
const items = useMemo(() => {
if (searching) {
return uniqueById((search.data?.pages ?? []).flatMap((p) => p.items));
}
return mergeWindowPages(newer.data?.pages ?? [], older.data?.pages ?? []);
}, [searching, search.data, newer.data, older.data]);
const activeError = searching
? search.isError
: older.isError || (newerRequested && newer.isError);
return {
items,
/** The first window is still loading; the inline block shows a skeleton. */
pending: older.isPending && !searching,
error: activeError,
retry: () => {
if (searching) void search.refetch();
else {
void older.refetch();
if (newerRequested) void newer.refetch();
}
},
term: rawTerm,
setTerm,
/** A search is in flight (typed, or its first page not back yet). */
searchPending:
serverSearch &&
(rawTerm.trim() !== term || (searching && search.isPending)),
/** The picker filters the loaded options by the term itself. */
filterLocally: !serverSearch,
hasOlder: searching ? search.hasNextPage : older.hasNextPage,
// Unknown until asked: offered once for every dated record, then only
// while the server reports another page.
hasNewer:
!searching &&
pivot !== null &&
(!newerRequested || newer.isFetching || newer.hasNextPage),
loadingOlder: searching
? search.isFetchingNextPage
: older.isFetchingNextPage,
loadingNewer: !searching && newerRequested && newer.isFetching,
loadOlder: () => {
if (searching) void search.fetchNextPage();
else void older.fetchNextPage();
},
loadNewer: () => {
if (searching || pivot === null) return;
if (!newerRequested) setNewerFor(pivot);
else void newer.fetchNextPage();
},
};
}
/**
* Where a record's date splits the list: the end of the UTC day
* {@link ENCOUNTER_SUGGEST_WINDOW_DAYS} days after it, so everything the
* suggestion window would offer is on the first page. Day-granular so typing
* the time of a visit does not refetch. Null when there is no date (or it does
* not parse).
*/
export function pickerPivot(anchor: string | null): string | null {
if (!anchor) return null;
const parsed = new Date(anchor);
if (Number.isNaN(parsed.getTime())) return null;
const split = new Date(
parsed.getTime() + ENCOUNTER_SUGGEST_WINDOW_DAYS * 86_400_000,
);
split.setUTCHours(23, 59, 59, 999);
return split.toISOString();
}
/** The instant just after the split, where the newer window starts. */
export function afterPivot(pivot: string): string {
return new Date(new Date(pivot).getTime() + 1).toISOString();
}
/** Offset cursors, for the list routes that page by `offset`. */
export function nextOffset(
cursor: string | null,
received: number,
): string | null {
if (received < PICKER_PAGE_SIZE) return null;
return String(Number(cursor ?? "0") + received);
}
+58 -51
View File
@@ -7,38 +7,41 @@
* The visit and vaccination forms used to fetch their document options with a
* page size the list route refuses (`limit=200` against a ceiling of 100). The
* 422 read as an empty list, so the picker said "No documents yet." to a
* person with a full vault, and the only way a scan ever got attached was the
* ±7-day suggestion offered at upload. A childhood record covering eight doses
* from one sheet of paper could therefore be filed against exactly one of
* them.
* person with a full vault. The next version walked the whole vault, ten
* pages deep, and anything past the thousandth document was out of reach.
*
* Now the whole vault is offered, paged through at the route's own ceiling,
* and a read failure says so instead of pretending the vault is empty. The
* documents dated within the same ±{@link ENCOUNTER_SUGGEST_WINDOW_DAYS}-day
* window the upload suggestion uses stay on top under their own heading, so
* the common case is still one tap; everything else follows, grouped by month
* and searchable.
* Now the picker opens on the documents filed around the record's date and
* pages older and newer from there, and the search box asks the list route
* itself, so a document is reachable wherever it sits in a vault of any size
* (`useLinkPickerPages`). A read failure says so instead of pretending the
* vault is empty. The documents dated within the same
* ±{@link ENCOUNTER_SUGGEST_WINDOW_DAYS}-day window the upload suggestion uses
* stay on top under their own heading, so the common case is still one tap;
* everything else follows, grouped by month.
*
* A document whose filing date was cleared falls outside every date window;
* the search still finds it by title, file name or content.
*/
import { useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import type { EntityLinkOption } from "@/components/links/entity-link-picker";
import {
serverPagingFrom,
type EntityLinkOption,
} from "@/components/links/entity-link-picker";
import { documentPickerUrl } from "@/components/links/picker-requests";
import {
pickerPivot,
useLinkPickerPages,
type PickerPage,
type PickerPageRequest,
} from "@/components/links/use-link-picker-pages";
import { apiGet } from "@/lib/api/api-fetch";
import { ENCOUNTER_SUGGEST_WINDOW_DAYS } from "@/lib/encounters/suggest-window";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import {
DOCUMENT_LIST_MAX_LIMIT,
type InboundDocumentDto,
} from "@/lib/validations/inbound-documents";
import type { InboundDocumentDto } from "@/lib/validations/inbound-documents";
import { daysBetweenDateKeys } from "@/lib/tz/format";
/**
* How many pages the picker walks. At the route's ceiling of 100 that is a
* thousand documents, past which the vault's own search is the better tool.
*/
const VAULT_PICKER_MAX_PAGES = 10;
/** The fields the option list reads off a document. */
export type VaultDocument = Pick<
InboundDocumentDto,
@@ -50,25 +53,12 @@ interface ListPage {
nextCursor: string | null;
}
/** Walk the vault newest filing date first, at the route's own page ceiling. */
export async function fetchWholeVault(): Promise<VaultDocument[]> {
const documents: VaultDocument[] = [];
let cursor: string | null = null;
for (let page = 0; page < VAULT_PICKER_MAX_PAGES; page++) {
const params = new URLSearchParams({
sort: "documentDate",
order: "desc",
limit: String(DOCUMENT_LIST_MAX_LIMIT),
});
if (cursor) params.set("cursor", cursor);
const result: ListPage = await apiGet<ListPage>(
`/api/documents/inbound?${params.toString()}`,
);
documents.push(...result.documents);
cursor = result.nextCursor;
if (!cursor) break;
}
return documents;
/** One picker page of the vault. */
export async function fetchVaultPage(
request: PickerPageRequest,
): Promise<PickerPage<VaultDocument>> {
const result = await apiGet<ListPage>(documentPickerUrl(request));
return { items: result.documents, next: result.nextCursor };
}
/**
@@ -135,8 +125,9 @@ export function documentOptionsWithSuggestions(
}
/**
* The picker's data: the whole vault as options, the suggestion group on top
* when the record has a date, and the query state an error row needs.
* The picker's data: the vault around the record's date as options, the
* suggestion group on top when the record has a date, the paging the sheet
* offers, and the query state an error row needs.
*/
export function useVaultDocumentOptions({
enabled,
@@ -148,26 +139,42 @@ export function useVaultDocumentOptions({
}) {
const { t } = useTranslations();
const format = useFormatters();
const query = useQuery({
queryKey: queryKeys.inboundDocumentVaultPicker(),
const pages = useLinkPickerPages<VaultDocument>({
enabled,
queryFn: fetchWholeVault,
pivot: pickerPivot(anchor),
queryKey: queryKeys.inboundDocumentVaultPicker,
fetchPage: fetchVaultPage,
});
const options = useMemo(
() =>
documentOptionsWithSuggestions(query.data ?? [], anchor, {
documentOptionsWithSuggestions(pages.items, anchor, {
suggested: t("links.picker.suggested"),
month: (iso) => `${format.monthShort(iso)} ${iso.slice(0, 4)}`,
date: (iso) => format.date(iso),
}),
[query.data, anchor, t, format],
[pages.items, anchor, t, format],
);
return {
options,
pending: query.isPending,
error: query.isError,
retry: () => void query.refetch(),
pending: pages.pending,
error: pages.error,
retry: pages.retry,
server: serverPagingFrom(pages),
};
}
/** A linked document as the record's own read names it, for its chip. */
export function linkedDocumentOption(
link: { id: string; label: string | null; date: string | null },
date: (iso: string) => string,
): EntityLinkOption | null {
if (link.label === null) return null;
return {
id: link.id,
label: link.label,
dateLabel: link.date ? date(link.date) : null,
href: `/documents?doc=${encodeURIComponent(link.id)}`,
};
}
@@ -81,6 +81,7 @@ vi.mock("@/lib/medications/glp1-pk", async () => {
import {
DrugLevelChart,
loadTakenIntakesSince,
parseDoseMg,
resolveDoseMg,
} from "../drug-level-chart";
@@ -362,3 +363,54 @@ describe("parseDoseMg / resolveDoseMg helpers", () => {
expect(resolveDoseMg("not-an-iso", [], 42)).toBe(42);
});
});
describe("loadTakenIntakesSince", () => {
const DAY = 24 * 60 * 60 * 1000;
// 400 daily taken doses, newest first, the newest one a day ago.
const now = Date.parse("2026-06-01T08:00:00.000Z");
const all = Array.from({ length: 400 }, (_, i) => ({
id: `i-${i}`,
takenAt: new Date(now - (i + 1) * DAY).toISOString(),
skipped: false,
scheduledFor: new Date(now - (i + 1) * DAY).toISOString(),
}));
function pager() {
const urls: string[] = [];
const fetchPage = async (url: string) => {
urls.push(url);
const params = new URL(url, "http://localhost").searchParams;
const limit = Number(params.get("limit"));
const offset = Number(params.get("offset"));
return {
events: all.slice(offset, offset + limit),
meta: { total: all.length },
};
};
return { urls, fetchPage };
}
it("walks back past a single page to the horizon, and no further", async () => {
const { urls, fetchPage } = pager();
// 21-day window + 5 half-lives of 7 days = 56 days, then 150 to cross a page.
const since = new Date(now - 150.5 * DAY);
const { events } = await loadTakenIntakesSince("med-1", since, fetchPage);
expect(events).toHaveLength(150);
expect(urls).toHaveLength(2);
for (const url of urls) {
expect(url).toContain("status=taken");
expect(url).toContain("sortBy=takenAt");
}
});
it("stops at the end of the history", async () => {
const { urls, fetchPage } = pager();
const { events } = await loadTakenIntakesSince(
"med-1",
new Date(now - 10_000 * DAY),
fetchPage,
);
expect(events).toHaveLength(400);
expect(urls).toHaveLength(4);
});
});
@@ -87,8 +87,14 @@ function seedSideEffects(
}>,
) {
client.setQueryData(queryKeys.medicationSideEffects(medId), {
items: rows,
meta: { total: rows.length },
pages: [
{
items: rows,
meta: { total: rows.length },
from: "2026-01-01T00:00:00.000Z",
},
],
pageParams: [null],
});
}
@@ -131,13 +137,24 @@ async function executeSideEffectsQuery(
medicationId: string,
): Promise<void> {
const queryKey = queryKeys.medicationSideEffects(medicationId);
const queryFn = client.getQueryCache().find({ queryKey, exact: true })
?.options.queryFn;
if (typeof queryFn !== "function") {
const options = client.getQueryCache().find({ queryKey, exact: true })
?.options as
| {
queryFn?: unknown;
initialPageParam?: unknown;
getNextPageParam?: unknown;
}
| undefined;
if (typeof options?.queryFn !== "function") {
throw new Error("Side-effects query function was not registered");
}
try {
await client.fetchQuery({ queryKey, queryFn });
await client.fetchInfiniteQuery({
queryKey,
queryFn: options.queryFn as never,
initialPageParam: options.initialPageParam as never,
getNextPageParam: options.getNextPageParam as never,
});
} catch {
// The settled error is the state under test.
}
@@ -420,3 +437,88 @@ describe("<SideEffectsSection> — timeline rows", () => {
expect(html).toContain("Mittel");
});
});
describe("<SideEffectsSection> — paging", () => {
function sideEffectsResponse(
items: Array<{ id: string; occurredAt: string }>,
): Response {
return new Response(
JSON.stringify({
data: {
items: items.map((row) => ({
...row,
category: "GI",
entry: "NAUSEA",
severity: 2,
notes: null,
})),
meta: { total: items.length },
},
error: null,
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
}
it("offers a next page after a full one and asks for the entries before it", async () => {
const base = Date.parse("2026-05-20T12:00:00.000Z");
const firstPage = Array.from({ length: 50 }, (_, i) => ({
id: `se-${i}`,
occurredAt: new Date(base - i * 60_000).toISOString(),
}));
const oldest = firstPage[firstPage.length - 1];
const fetchMock = vi
.fn()
.mockResolvedValueOnce(sideEffectsResponse(firstPage))
// The boundary entry comes back once more and is shown once.
.mockResolvedValueOnce(
sideEffectsResponse([
oldest,
{ id: "se-older", occurredAt: "2026-05-01T08:00:00.000Z" },
]),
);
vi.stubGlobal("fetch", fetchMock);
const client = makeClient();
render(<SideEffectsSection medicationId="med-paged" />, client);
await executeSideEffectsQuery(client, "med-paged");
await waitForQueryStatus(client, "med-paged", "success");
const firstHtml = render(
<SideEffectsSection medicationId="med-paged" />,
client,
);
expect(firstHtml).toContain(en.medications.sideEffects.loadMore);
await client
.getQueryCache()
.find({
queryKey: queryKeys.medicationSideEffects("med-paged"),
exact: true,
})
?.fetch(undefined, { meta: { fetchMore: { direction: "forward" } } });
const secondUrl = new URL(
String(fetchMock.mock.calls[1]?.[0]),
"http://localhost",
);
expect(secondUrl.searchParams.get("to")).toBe(
new Date(Date.parse(oldest.occurredAt) + 1).toISOString(),
);
const firstUrl = new URL(
String(fetchMock.mock.calls[0]?.[0]),
"http://localhost",
);
expect(secondUrl.searchParams.get("from")).toBe(
firstUrl.searchParams.get("from"),
);
const secondHtml = render(
<SideEffectsSection medicationId="med-paged" />,
client,
);
// 50 + 1 new, the repeated boundary entry dropped; a short page ends it.
expect(secondHtml).toContain("(51)");
expect(secondHtml).not.toContain(en.medications.sideEffects.loadMore);
});
});
@@ -56,6 +56,7 @@ import {
} from "@/lib/medications/glp1-pk";
import {
findDrugIdByBrand,
GLP1_DRUGS,
type Glp1DrugId,
} from "@/lib/medications/glp1-knowledge";
import { parseDoseMg } from "@/lib/medications/dose-string";
@@ -74,6 +75,53 @@ const HOURS_PER_DAY = 24;
const WINDOW_HOURS_BEFORE = 21 * HOURS_PER_DAY;
const WINDOW_HOURS_AFTER = 0;
/** Taken intakes per request while walking back to the model's horizon. */
const INTAKE_PAGE_SIZE = 100;
/**
* Half-lives before the chart's left edge whose doses still count. After five
* half-lives a dose holds about 3% of its peak, below what the chart can show.
*/
const HORIZON_HALF_LIVES = 5;
/**
* Every taken intake from `since` on, newest first, walked page by page. The
* curve is a sum over every dose still in the body, so a fixed count of
* recent intakes was wrong in both directions: twenty daily doses of a
* long-acting drug stop well short of steady state, and planned or skipped
* rows used up the count without being doses at all.
*/
export async function loadTakenIntakesSince(
medicationId: string,
since: Date,
fetchPage: (url: string) => Promise<{
events: IntakeEvent[];
meta: { total: number };
}> = (url) => apiGet(url),
): Promise<{ events: IntakeEvent[] }> {
const events: IntakeEvent[] = [];
for (let offset = 0; ; offset += INTAKE_PAGE_SIZE) {
const page = await fetchPage(
`/api/medications/${medicationId}/intake?status=taken&sortBy=takenAt&sortDir=desc&limit=${INTAKE_PAGE_SIZE}&offset=${offset}`,
);
events.push(...page.events);
const oldest = page.events[page.events.length - 1];
const reachedHorizon =
!oldest?.takenAt || Date.parse(oldest.takenAt) < since.getTime();
if (
reachedHorizon ||
page.events.length < INTAKE_PAGE_SIZE ||
offset + INTAKE_PAGE_SIZE >= page.meta.total
) {
break;
}
}
return {
events: events.filter(
(ev) => ev.takenAt && Date.parse(ev.takenAt) >= since.getTime(),
),
};
}
interface IntakeEvent {
id: string;
takenAt: string | null;
@@ -147,9 +195,16 @@ export function DrugLevelChart({ medication, asOf }: DrugLevelChartProps) {
queryKey: queryKeys.medicationIntakeDrugLevelChart(medication.id),
queryFn: async () => {
try {
return await apiGet<{ events: IntakeEvent[] }>(
`/api/medications/${medication.id}/intake?limit=20&sortBy=takenAt&sortDir=desc`,
const halfLifeHours =
GLP1_DRUGS[drugId!].pharmacology.halfLifeDays * HOURS_PER_DAY;
const since = new Date(
Date.now() -
(WINDOW_HOURS_BEFORE + HORIZON_HALF_LIVES * halfLifeHours) *
60 *
60 *
1000,
);
return await loadTakenIntakesSince(medication.id, since);
} catch {
return null;
}
@@ -3,7 +3,11 @@
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { useId, useMemo, useState } from "react";
import { createPortal } from "react-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
useInfiniteQuery,
useMutation,
useQueryClient,
} from "@tanstack/react-query";
import { toast } from "sonner";
import { Loader2, Plus, Trash2 } from "lucide-react";
@@ -110,6 +114,16 @@ function entryI18nKey(entry: MedicationSideEffectEntry): string {
.replace(/_([a-z])/g, (_, c: string) => c.toUpperCase());
}
/** Entries per page of the side-effect list. */
const SIDE_EFFECTS_PAGE_SIZE = 50;
interface SideEffectPageParam {
/** The window start, fixed when the first page loaded. */
from: string;
/** Exclusive upper bound: just after the oldest entry already shown. */
to: string;
}
export function SideEffectsSection({ medicationId }: SideEffectsSectionProps) {
const { t } = useTranslations();
const { canWriteDomain, canManageDomain } = useRecordCapabilities();
@@ -133,18 +147,40 @@ export function SideEffectsSection({ medicationId }: SideEffectsSectionProps) {
const listKey = queryKeys.medicationSideEffects(medicationId);
const { data, isLoading, isError, refetch } = useQuery({
// The last 30 days, newest first, one page at a time. The window is fixed
// when the first page loads; each further page asks for the entries before
// the oldest one shown (`to` is exclusive, so the boundary instant is
// re-asked one millisecond later and the overlap is dropped by id).
const {
data,
isLoading,
isError,
refetch,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfiniteQuery({
queryKey: listKey,
queryFn: async () => {
// Last 30 days, newest-first.
const to = new Date();
const from = new Date(to.getTime() - 30 * 24 * 60 * 60 * 1000);
const url = `/api/medications/${medicationId}/side-effects?from=${encodeURIComponent(from.toISOString())}&to=${encodeURIComponent(to.toISOString())}&limit=100`;
initialPageParam: null as SideEffectPageParam | null,
queryFn: async ({ pageParam }) => {
const to = pageParam ? new Date(pageParam.to) : new Date();
const from = pageParam
? new Date(pageParam.from)
: new Date(to.getTime() - 30 * 24 * 60 * 60 * 1000);
const url = `/api/medications/${medicationId}/side-effects?from=${encodeURIComponent(from.toISOString())}&to=${encodeURIComponent(to.toISOString())}&limit=${SIDE_EFFECTS_PAGE_SIZE}`;
const { data: payload } = await apiFetchEnvelope<{
items: SideEffectRow[];
meta: { total: number };
}>(url);
return payload;
return { ...payload, from: from.toISOString() };
},
getNextPageParam: (lastPage): SideEffectPageParam | undefined => {
if (lastPage.items.length < SIDE_EFFECTS_PAGE_SIZE) return undefined;
const oldest = lastPage.items[lastPage.items.length - 1];
return {
from: lastPage.from,
to: new Date(Date.parse(oldest.occurredAt) + 1).toISOString(),
};
},
staleTime: 30 * 1000,
});
@@ -184,7 +220,18 @@ export function SideEffectsSection({ medicationId }: SideEffectsSectionProps) {
},
});
const items = data?.items ?? [];
const items = useMemo(() => {
const seen = new Set<string>();
const out: SideEffectRow[] = [];
for (const page of data?.pages ?? []) {
for (const row of page.items) {
if (seen.has(row.id)) continue;
seen.add(row.id);
out.push(row);
}
}
return out;
}, [data]);
const filteredEntries = useMemo(
() => entriesByCategory(category),
@@ -363,6 +410,20 @@ export function SideEffectsSection({ medicationId }: SideEffectsSectionProps) {
</li>
))}
</ul>
{hasNextPage ? (
<div className="flex justify-center">
<Button
variant="outline"
size="sm"
disabled={isFetchingNextPage}
onClick={() => void fetchNextPage()}
>
{isFetchingNextPage
? t("common.loading")
: t("medications.sideEffects.loadMore")}
</Button>
</div>
) : null}
</div>
)}
@@ -1,28 +1,17 @@
/**
* v1.4.19 Phase A5 — Settings → Integrations status consolidation.
* Settings → Integrations: one status display per card.
*
* The maintainer's verbatim concern (paraphrased):
* - Withings card had 3 status displays — top-right "connected"
* badge, mid-card "connected / last successful / last attempt"
* trio container, and the badge inside that container.
* - Mood Log card had 4 — same as Withings PLUS a "letzter Sync"
* line at the very bottom.
* v1.4.19 consolidated three and four status surfaces per card into one pill.
* v1.42.4 replaces the pill with the shared card's badge, rendered from the
* server's `health`, and removes what had grown back around it: the raw
* provider error line, the failure counter, the per-provider banners.
*
* After A5: each card has EXACTLY ONE status display (the pill, top
* right). Error context (the failure message itself, when actionable)
* is allowed to surface below the pill since it carries information
* the pill can't fit. Timestamp repetition is gone.
*
* The tests pin:
* 1. Healthy Withings + Mood Log: ONE pill per card, no banner.
* 2. Withings transient error: ONE pill per card + error message,
* NO redundant "{n}/{threshold} consecutive failures" badge,
* NO "Last successful sync / Last attempt" trio.
* 3. Withings reauth: pill carries the "Error — reconnect" label.
* 4. Mood Log disconnected: pill says "Not connected", no other
* status display surfaces.
* 5. no card paints a divider under its header
* — consistency the maintainer explicitly called out.
* The tests pin, on the whole section:
* 1. one badge per connected card, and nothing else that states a status;
* 2. the stored provider error text never reaches the page;
* 3. a paused connection offers exactly one Resume;
* 4. no card paints a divider under its header;
* 5. every not-connected card links its setup guide.
*/
import { describe, expect, it, vi, beforeEach } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
@@ -104,227 +93,162 @@ function count(html: string, needle: string): number {
return n;
}
describe("IntegrationsSection — single-status-display contract (A5)", () => {
const PROVIDERS = [
"withings",
"whoop",
"fitbit",
"google-health",
"polar",
"oura",
"strava",
"nightscout",
] as const;
function health(overrides: Record<string, unknown> = {}) {
return {
state: "ok",
reason: "data_arriving",
since: null,
action: null,
unavailable: [],
lastSuccessAt: "2026-05-09T18:00:00.000Z",
lastDataAt: "2026-05-09T17:00:00.000Z",
diagnostics: {
ledgerState: "connected",
failingLegs: [],
failingSince: null,
lastAttemptAt: "2026-05-09T18:00:00.000Z",
verdict: "fresh",
recentCalls: [],
},
...overrides,
};
}
function disconnected(integration: string) {
return {
integration,
lastError: null,
connected: false,
configured: false,
available: false,
health: null,
};
}
/** The Withings card's markup, up to the next card. */
function withingsCard(html: string): string {
const start = html.indexOf('data-testid="withings-card"');
const next = html.indexOf('data-slot="integration-card"', start + 1);
return html.slice(start, next === -1 ? undefined : next);
}
/** Withings as given, every other provider present and not connected. */
function envelopeWith(withings: Record<string, unknown>) {
return {
threshold: 3,
integrations: [
{
integration: "withings",
lastError: null,
connected: true,
configured: true,
...withings,
},
...PROVIDERS.filter((p) => p !== "withings").map(disconnected),
],
};
}
describe("IntegrationsSection — one status display per card", () => {
beforeEach(() => {
integrationStatusPayload = null;
});
it("renders exactly ONE status pill per card when both integrations are healthy", () => {
setIntegrationStatus({
threshold: 3,
integrations: [
{
integration: "withings",
state: "connected",
lastSuccessAt: "2026-05-09T18:00:00.000Z",
lastAttemptAt: "2026-05-09T18:00:00.000Z",
lastError: null,
connected: true,
configured: true,
legacyLastSyncedAt: "2026-05-09T18:00:00.000Z",
hasActivityScope: true,
syncHealth: { verdict: "fresh", since: null },
},
],
});
it("renders exactly one badge for the connected card and no other status surface", () => {
setIntegrationStatus(envelopeWith({ health: health() }));
const html = render();
// Exactly one pill per card → 8 (Withings, WHOOP, Fitbit, Google Health,
// Polar, Oura, Strava, Nightscout). The Garmin info note carries no pill;
// the Apple Health card's pill keeps its own testid.
expect(count(html, 'data-testid="integration-status-pill"')).toBe(8);
// The redundant banner from v1.4.15 is gone.
expect(count(html, 'data-slot="integration-health-badge"')).toBe(1);
expect(html).not.toContain('data-testid="integration-status-pill"');
expect(html).not.toContain('data-testid="integration-status-banner"');
// Card-body "letzter Sync" repetition is gone — no
// "Last sync:" label outside the pill.
expect(html).not.toContain("Last sync:");
// No "Last successful sync" / "Last attempt" trio.
expect(html).not.toContain("Last successful sync");
expect(html).not.toContain("Last attempt");
// The seven other providers and Apple Health are not connected and say so
// once each.
expect(count(html, ">Not connected.<")).toBe(8);
});
it("Withings transient error keeps the pill + error text but drops the {n}/{threshold} duplicate badge", () => {
setIntegrationStatus({
threshold: 3,
integrations: [
{
integration: "withings",
state: "error_transient",
lastSuccessAt: "2026-05-09T08:00:00.000Z",
lastAttemptAt: "2026-05-09T18:00:00.000Z",
lastError: "Withings refresh error: 503 - upstream",
connected: true,
configured: true,
legacyLastSyncedAt: "2026-05-09T08:00:00.000Z",
hasActivityScope: true,
syncHealth: { verdict: "failing", since: "2026-05-09T08:00:00.000Z" },
it("never shows the stored provider error or a failure counter", () => {
setIntegrationStatus(
envelopeWith({
lastError: "Withings refresh error: 503 - upstream",
consecutiveFailuresByKind: {
transient: 2,
reauth_required: 0,
persistent: 0,
},
],
});
health: health({
state: "degraded",
reason: "provider_errors",
since: "2026-05-09T08:00:00.000Z",
}),
}),
);
const html = render();
// A 503 is not the user's to fix, so the pill carries the warning tone —
// red is reserved for "reconnect actually helps".
expect(html).toContain('data-state="warning"');
expect(html).not.toContain('data-state="error"');
// Actionable error message still surfaces (we kept it because
// the pill can't fit "Withings refresh error: 503 - upstream").
expect(html).toContain("Withings refresh error: 503 - upstream");
// …but the consecutive-failure badge that doubled the pill's
// signal is gone.
expect(html).toMatch(
/data-slot="integration-health-badge"[^>]*data-state="degraded"/,
);
expect(html).not.toContain("Withings refresh error");
expect(html).not.toContain("2/3 consecutive failures");
expect(html).not.toContain("Last successful sync");
expect(html).not.toContain("Last attempt");
// Not the user's to fix: the Withings card offers no button.
expect(withingsCard(html)).toContain('data-testid="withings-menu"');
expect(withingsCard(html)).not.toContain(
'data-slot="integration-primary-action"',
);
});
it("Withings reauth state surfaces 'Error — reconnect' on the pill (the maintainer's wording)", () => {
setIntegrationStatus({
threshold: 3,
integrations: [
{
integration: "withings",
state: "error_reauth",
lastSuccessAt: "2026-05-08T12:00:00.000Z",
lastAttemptAt: "2026-05-09T18:00:00.000Z",
lastError: "Withings refresh error: 100 - invalid_grant",
connected: true,
configured: true,
legacyLastSyncedAt: "2026-05-08T12:00:00.000Z",
tokenExpired: true,
hasActivityScope: true,
syncHealth: {
verdict: "reauth_required",
since: "2026-05-09T18:00:00.000Z",
},
},
],
});
it("a paused connection offers exactly one Resume", () => {
setIntegrationStatus(
envelopeWith({
lastError: "Withings activity error: 293",
health: health({
state: "action_required",
reason: "paused_after_errors",
since: "2026-05-09T18:00:00.000Z",
action: "resume",
}),
}),
);
const html = render();
expect(html).toContain('data-state="error"');
// Old "Reconnect required" banner is gone — the pill is now
// the canonical surface.
expect(html).not.toContain('data-testid="integration-status-banner"');
});
it("disconnected state shows 'Not connected' on the pill, no extra status surfaces", () => {
setIntegrationStatus({
threshold: 3,
integrations: [
{
integration: "withings",
state: "disconnected",
lastSuccessAt: "2026-05-08T10:00:00.000Z",
lastAttemptAt: "2026-05-08T10:00:00.000Z",
lastError: null,
syncHealth: { verdict: "disconnected", since: null },
},
],
});
const html = render();
expect(html).toContain("Not connected");
expect(html).not.toContain('data-testid="integration-status-banner"');
// No trailing "letzter Sync" line outside the pill.
expect(html).not.toContain("Last sync:");
});
// v1.4.43 W14 — parked-state surface check.
// The pill flips to `data-state="parked"` and the resume banner
// renders with the reconnect CTA. The legacy `error` reconnect text
// (red) must NOT appear because the user-facing copy is the lighter
// "Paused: reconnect" string, not "Error — reconnect".
it("Withings parked state surfaces the parked pill + resume CTA", () => {
setIntegrationStatus({
threshold: 3,
integrations: [
{
integration: "withings",
state: "parked",
lastSuccessAt: "2026-05-08T12:00:00.000Z",
lastAttemptAt: "2026-05-09T18:00:00.000Z",
lastError: "Withings activity error: 293",
connected: true,
configured: true,
legacyLastSyncedAt: "2026-05-08T12:00:00.000Z",
hasActivityScope: true,
syncHealth: { verdict: "parked", since: "2026-05-09T18:00:00.000Z" },
},
],
});
const html = render();
expect(html).toContain('data-state="parked"');
expect(html).toContain("Paused");
expect(html).toContain("Paused: reconnect");
expect(html).toContain('data-testid="withings-parked-banner"');
expect(html).toMatch(
/data-slot="integration-health-badge"[^>]*data-state="action_required"/,
);
expect(html).toContain('data-testid="withings-resume-button"');
// The lastError is surfaced under the pill so the operator sees
// the contract-mismatch reason without opening Audit Log.
expect(html).toContain("Withings activity error: 293");
expect(count(html, 'data-action="resume"')).toBe(1);
expect(html).not.toContain('data-testid="withings-parked-banner"');
expect(html).not.toContain("Withings activity error: 293");
});
it("no integration card paints a divider under its header", () => {
setIntegrationStatus({
threshold: 3,
integrations: [
{
integration: "withings",
state: "connected",
lastSuccessAt: "2026-05-09T18:00:00.000Z",
lastAttemptAt: "2026-05-09T18:00:00.000Z",
lastError: null,
connected: true,
configured: true,
legacyLastSyncedAt: "2026-05-09T18:00:00.000Z",
hasActivityScope: true,
syncHealth: { verdict: "fresh", since: null },
},
],
});
const html = render();
// The `<hr>` under the header existed only in `integrations/` — nine
// cards carrying a rule no other Settings card has. The card's own gap
// does the separating now, identically here and everywhere else.
expect(count(html, 'data-testid="integration-card-divider"')).toBe(0);
setIntegrationStatus(envelopeWith({ health: health() }));
expect(count(render(), 'data-testid="integration-card-divider"')).toBe(0);
});
// v1.17.1 — every integration card carries the same discreet "Setup guide"
// doc-link pointing at its runbook under the single shared docs base. The
// affordance is one family across all six providers.
it("every integration card carries the shared Setup-guide doc-link", () => {
// Every card that is not connected yet carries the same discreet "Setup
// guide" link to its runbook under the single shared docs base. Once
// connected, the link moves into the card's menu.
it("every not-connected integration card carries the shared Setup-guide link", () => {
setIntegrationStatus({
threshold: 3,
integrations: [
{
integration: "withings",
state: "connected",
lastSuccessAt: "2026-05-09T18:00:00.000Z",
lastAttemptAt: "2026-05-09T18:00:00.000Z",
lastError: null,
connected: true,
configured: true,
legacyLastSyncedAt: "2026-05-09T18:00:00.000Z",
hasActivityScope: true,
},
],
integrations: PROVIDERS.map(disconnected),
});
const html = render();
// One setup-guide link per card → eight providers (the Garmin info note is
// not an OAuth card and carries its own docs link, not a setup-guide).
expect(count(html, 'data-slot="integration-setup-guide"')).toBe(8);
for (const provider of [
"withings",
"whoop",
"fitbit",
"google-health",
"polar",
"oura",
"strava",
"nightscout",
]) {
for (const provider of PROVIDERS) {
expect(html).toContain(`data-testid="${provider}-setup-guide"`);
expect(html).toContain(
`href="https://docs.healthlog.dev/integrations/${provider}"`,
@@ -0,0 +1,133 @@
/**
* A managed record's connections on the shared integration card, read-only.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import type { ConnectionHealth } from "@/lib/integrations/connection-health";
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: {
id: "guardian",
accountAccess: {
active: {
accountId: "record-1",
recordKind: "managed",
displayName: "Managed record",
},
},
},
isAuthenticated: true,
isLoading: false,
}),
}));
let payload: unknown;
let loading = false;
vi.mock("@tanstack/react-query", () => ({
useQuery: () => ({
data: payload,
isLoading: loading,
isError: false,
refetch: vi.fn(),
}),
}));
import { I18nProvider } from "@/lib/i18n/context";
import { ManagedIntegrationStatus } from "../managed-integration-status";
import { healthFixture } from "../integrations/__tests__/health-fixture";
const KEYS = [
"withings",
"whoop",
"fitbit",
"nightscout",
"polar",
"oura",
"google-health",
"strava",
];
function render() {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<ManagedIntegrationStatus />
</I18nProvider>,
);
}
function withHealth(map: Record<string, ConnectionHealth | null>) {
return {
recordId: "record-1",
integrations: KEYS.map((integration) => ({
integration,
// The legacy fields still ride the wire; the view must not use them.
state: "connected",
lastSuccessAt: null,
lastAttemptAt: null,
health: map[integration] ?? null,
})),
};
}
describe("<ManagedIntegrationStatus>", () => {
beforeEach(() => {
payload = undefined;
loading = false;
});
it("renders one shared card per provider from health, with no control on any", () => {
payload = withHealth({
oura: healthFixture({
state: "action_required",
reason: "reauth_required",
action: "reconnect",
}),
withings: healthFixture(),
});
const html = render();
expect(html).toContain('data-record-settings-family="integrations"');
expect(html).toContain(
'aria-labelledby="managed-integration-status-title"',
);
expect(html).toContain('data-record-id="record-1"');
expect(html.match(/data-slot="integration-card"/g)).toHaveLength(8);
for (const key of KEYS) {
expect(html).toContain(`data-testid="managed-integration-${key}"`);
}
expect(html).toMatch(
/data-testid="managed-integration-oura"[\s\S]*?data-slot="integration-health-badge"[^>]*data-state="action_required"/,
);
// Read-only even where the state asks for an action.
expect(html).not.toContain('data-slot="integration-primary-action"');
expect(html).not.toContain('data-slot="integration-menu-trigger"');
// The legacy `state: "connected"` on every row does not make a card
// connected; only `health` does.
expect(html.match(/>Not connected\.</g)).toHaveLength(6);
});
it("says once why nothing here can be changed, and links the explanation", () => {
payload = withHealth({});
const html = render();
expect(html).toContain('data-slot="managed-integration-read-only"');
expect(html).toContain(
"A managed profile shows its connected services as a status only.",
);
expect(html).toContain(
'href="https://docs.healthlog.dev/self-hosting/account-sharing#records-with-no-login"',
);
});
it("paints the cards while loading without claiming a state", () => {
loading = true;
const html = render();
expect(html).toContain('aria-busy="true"');
expect(html).not.toContain(
'aria-labelledby="managed-integration-status-title"',
);
expect(html).not.toContain('data-slot="integration-health-badge"');
expect(html).not.toContain("Not connected.");
});
});
@@ -14,11 +14,15 @@ type Payload = {
lastSyncedAt: string | null;
lastSyncTrigger?: "foreground" | "background" | "push" | null;
lastBackgroundSyncAt?: string | null;
syncHealth?: { verdict: string; since: string | null };
health?: ConnectionHealth | null;
metricFreshness?: Array<{
type: string;
lastSeenAt: string;
stale: boolean;
lastReceivedAt?: string | null;
lastTrigger?: "foreground" | "background" | "push" | "manual" | null;
lastNewSampleAt?: string | null;
lastBackgroundAt?: string | null;
}>;
syncProgress?: {
recordsAccepted: number;
@@ -38,7 +42,10 @@ vi.mock("@tanstack/react-query", () => ({
}),
}));
import type { ConnectionHealth } from "@/lib/integrations/connection-health";
import { AppleHealthCard, ArrivalDetail } from "../apple-health-card";
import { healthFixture } from "./health-fixture";
function render(locale: Locale = "en") {
return renderToStaticMarkup(
@@ -56,76 +63,62 @@ describe("<AppleHealthCard>", () => {
});
it("explains that live sync uses the iOS app rather than OAuth", () => {
statusPayload = {
lastSyncedAt: null,
syncHealth: { verdict: "pending_first_sync", since: null },
};
statusPayload = { lastSyncedAt: null, health: null };
const html = render();
expect(html).toContain('data-testid="apple-health-card"');
expect(html).toContain('data-state="not_connected"');
expect(html).toContain("HealthLog iOS app");
expect(html).toContain("not from a web or OAuth connection");
expect(html).toContain("Settings → Apple Health");
expect(html).toContain("when iOS grants background time");
expect(html).not.toContain(">Connected<");
expect(html).toContain("Not connected.");
expect(html).not.toContain('data-slot="integration-health-badge"');
});
/**
* The defect this card carried: any `lastSyncedAt` at all painted the green
* "data received" chip, so a pipe dead for three weeks looked exactly like
* one that delivered this morning. The verdict now decides.
*/
it("shows a week-old delivery as stale, not as data received", () => {
it("says when data stopped and what to do, from the server's health", () => {
const eightDaysAgo = new Date(
Date.now() - 8 * 24 * 60 * 60 * 1000,
).toISOString();
statusPayload = {
lastSyncedAt: eightDaysAgo,
syncHealth: { verdict: "stale", since: eightDaysAgo },
health: healthFixture({
state: "degraded",
reason: "no_new_data",
since: eightDaysAgo,
lastDataAt: eightDaysAgo,
}),
};
const html = render();
expect(html).toContain('data-testid="apple-health-status"');
expect(html).toContain('data-state="stale"');
expect(html).toContain("no recent data");
// And it says what to do about it.
expect(html).toContain('data-testid="apple-health-stale"');
expect(html).not.toContain('data-state="connected"');
expect(html).toMatch(
/data-slot="integration-health-badge"[^>]*data-state="degraded"/,
);
expect(html).toContain("Delayed");
// Apple Health's own variant: the iPhone app is what moves the data.
expect(html).toContain("Open HealthLog on your iPhone once.");
});
it("shows a recent delivery as connected with its relative time", () => {
it("reads a recent delivery as working", () => {
const anHourAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString();
statusPayload = {
lastSyncedAt: anHourAgo,
syncHealth: { verdict: "fresh", since: null },
health: healthFixture({ lastDataAt: anHourAgo }),
};
const html = render();
expect(html).toContain('data-state="connected"');
expect(html).toMatch(/1\s+h\s+ago/);
expect(html).not.toContain('data-testid="apple-health-stale"');
expect(html).toContain('data-state="ok"');
expect(html).toContain("Data is arriving.");
expect(html).toContain("Last data: today,");
});
it("waits for the first delivery rather than claiming a connection", () => {
statusPayload = {
lastSyncedAt: null,
syncHealth: { verdict: "pending_first_sync", since: null },
};
const html = render();
expect(html).toContain('data-state="pending-setup"');
expect(html).toContain("Waiting for first data");
expect(html).not.toContain('data-state="connected"');
});
it("renders per-metric freshness so one dead permission is visible", () => {
it("keeps per-metric freshness in the details so one dead permission stays findable", () => {
statusPayload = {
lastSyncedAt: new Date(Date.now() - 60 * 60 * 1000).toISOString(),
syncHealth: { verdict: "fresh", since: null },
health: healthFixture(),
metricFreshness: [
{
type: "RESPIRATORY_RATE",
@@ -143,26 +136,14 @@ describe("<AppleHealthCard>", () => {
};
const html = render();
const details = html.slice(html.indexOf('data-slot="integration-details"'));
expect(html).toContain('data-slot="metric-freshness"');
// The collapsed summary already names the problem — the signal must not
// hide behind a click.
expect(html).toContain("quiet");
});
it("carries no card divider — the card gap does that job", () => {
statusPayload = {
lastSyncedAt: null,
syncHealth: { verdict: "pending_first_sync", since: null },
};
expect(render()).not.toContain('data-testid="integration-card-divider"');
expect(details).toContain('data-slot="metric-freshness"');
expect(details).toContain("1 quiet");
});
it("links to the existing one-shot Apple Health import fallback", () => {
statusPayload = {
lastSyncedAt: null,
syncHealth: { verdict: "pending_first_sync", since: null },
};
statusPayload = { lastSyncedAt: null, health: null };
const html = render();
@@ -173,14 +154,15 @@ describe("<AppleHealthCard>", () => {
expect(html).toContain("Open one-shot import");
});
it("claims no status at all while the read is in flight", () => {
it("paints the card and its header while the read is in flight, and no state", () => {
statusPayload = undefined;
statusLoading = true;
const html = render();
expect(html).not.toContain('data-testid="apple-health-status"');
expect(html).not.toContain("Connected");
expect(html).toContain('data-state="loading"');
expect(html).not.toContain('data-slot="integration-health-badge"');
expect(html).not.toContain("Not connected.");
});
it("treats a failed status read as an error line, not a status", () => {
@@ -189,9 +171,8 @@ describe("<AppleHealthCard>", () => {
const html = render();
expect(html).toContain("Apple Health status unavailable");
expect(html).toContain('data-testid="integration-error-message"');
expect(html).not.toContain('data-testid="apple-health-status"');
expect(html).not.toContain("Connected");
expect(html).not.toContain('data-slot="integration-health-badge"');
expect(html).not.toContain("Not connected.");
});
it("defines every card key in all six locale catalogs", () => {
@@ -203,7 +184,6 @@ describe("<AppleHealthCard>", () => {
"backgroundStep",
"importNote",
"importAction",
"staleNote",
] as const;
for (const catalog of [en, de, es, fr, itMessages, pl]) {
@@ -212,8 +192,6 @@ describe("<AppleHealthCard>", () => {
expect(appleHealth[key]).toBeTypeOf("string");
expect(appleHealth[key].length).toBeGreaterThan(0);
}
// The status copy the pill now owns is gone from the catalog; only the
// failed-read line survives here.
expect(Object.keys(appleHealth.status).sort()).toEqual(["unavailable"]);
}
});
@@ -221,10 +199,7 @@ describe("<AppleHealthCard>", () => {
it.each(["en", "de", "es", "fr", "it", "pl", "ko"] as const)(
"renders localized Apple Health copy for %s without leaking keys",
(locale) => {
statusPayload = {
lastSyncedAt: null,
syncHealth: { verdict: "pending_first_sync", since: null },
};
statusPayload = { lastSyncedAt: null, health: null };
const html = render(locale);
@@ -246,7 +221,7 @@ describe("<AppleHealthCard> — delivery diagnostic (#586)", () => {
lastSyncedAt: new Date(Date.now() - 60 * 60 * 1000).toISOString(),
lastSyncTrigger: "background",
lastBackgroundSyncAt: new Date(Date.now() - 60 * 60 * 1000).toISOString(),
syncHealth: { verdict: "fresh", since: null },
health: healthFixture(),
};
const html = render();
@@ -261,7 +236,7 @@ describe("<AppleHealthCard> — delivery diagnostic (#586)", () => {
lastSyncedAt: new Date(Date.now() - 60 * 60 * 1000).toISOString(),
lastSyncTrigger: "foreground",
lastBackgroundSyncAt: null,
syncHealth: { verdict: "fresh", since: null },
health: healthFixture(),
};
const html = render();
@@ -277,7 +252,7 @@ describe("<AppleHealthCard> — delivery diagnostic (#586)", () => {
lastSyncedAt: new Date(Date.now() - 60 * 60 * 1000).toISOString(),
lastSyncTrigger: null,
lastBackgroundSyncAt: null,
syncHealth: { verdict: "fresh", since: null },
health: healthFixture(),
};
const html = render();
@@ -287,15 +262,16 @@ describe("<AppleHealthCard> — delivery diagnostic (#586)", () => {
);
});
it("says there is nothing to report when no sync has ever arrived", () => {
it("shows the setup steps instead of an empty delivery report before any sync", () => {
statusPayload = {
lastSyncedAt: null,
syncHealth: { verdict: "pending_first_sync", since: null },
health: null,
};
const html = render();
expect(html).toContain("No sync has arrived yet");
expect(html).toContain("Turn on live sync");
expect(html).not.toContain('data-testid="apple-health-delivery"');
expect(html).not.toContain("Last background sync");
});
});
@@ -312,7 +288,7 @@ describe("<AppleHealthCard> — sync progress (#778)", () => {
it("shows the accepted-row count and the oldest reading reached", () => {
statusPayload = {
lastSyncedAt: new Date(Date.now() - 5 * 60 * 1000).toISOString(),
syncHealth: { verdict: "fresh", since: null },
health: healthFixture(),
syncProgress: {
recordsAccepted: 12345,
oldestMeasuredAt: "2019-05-04T06:00:00.000Z",
@@ -334,7 +310,7 @@ describe("<AppleHealthCard> — sync progress (#778)", () => {
it("reads as flowing while batches keep arriving", () => {
statusPayload = {
lastSyncedAt: new Date(Date.now() - 5 * 60 * 1000).toISOString(),
syncHealth: { verdict: "fresh", since: null },
health: healthFixture(),
syncProgress: { recordsAccepted: 100, oldestMeasuredAt: null },
};
@@ -348,7 +324,7 @@ describe("<AppleHealthCard> — sync progress (#778)", () => {
it("waits honestly instead of inventing throttle state when batches pause", () => {
statusPayload = {
lastSyncedAt: new Date(Date.now() - 3 * 60 * 60 * 1000).toISOString(),
syncHealth: { verdict: "fresh", since: null },
health: healthFixture(),
syncProgress: { recordsAccepted: 100, oldestMeasuredAt: null },
};
@@ -364,7 +340,7 @@ describe("<AppleHealthCard> — sync progress (#778)", () => {
it("renders no progress section before anything has arrived", () => {
statusPayload = {
lastSyncedAt: null,
syncHealth: { verdict: "pending_first_sync", since: null },
health: null,
syncProgress: { recordsAccepted: 0, oldestMeasuredAt: null },
};
@@ -377,7 +353,7 @@ describe("<AppleHealthCard> — sync progress (#778)", () => {
it("renders no progress section when the server read failed", () => {
statusPayload = {
lastSyncedAt: new Date(Date.now() - 5 * 60 * 1000).toISOString(),
syncHealth: { verdict: "fresh", since: null },
health: healthFixture(),
syncProgress: null,
};
@@ -454,6 +430,52 @@ describe("<AppleHealthCard> — per-type arrivals (#1173)", () => {
expect(out).toContain("Last new value 3 d ago");
});
// v1.42.4 — opening the app to look starts a foreground sync; the earlier
// background arrival must stay visible beside it.
it("keeps the last background arrival visible after a later foreground one", () => {
const out = html({
...base,
lastReceivedAt: hoursAgo(1),
lastTrigger: "foreground",
lastNewSampleAt: hoursAgo(1),
lastBackgroundAt: hoursAgo(5),
});
expect(out).toContain('data-trigger="foreground"');
expect(out).toContain("Last in background 5 h ago");
});
it("does not repeat the background date when the last arrival was it", () => {
const out = html({
...base,
lastReceivedAt: hoursAgo(1),
lastTrigger: "background",
lastNewSampleAt: hoursAgo(1),
lastBackgroundAt: hoursAgo(1),
});
expect(out).not.toContain("Last in background");
});
it("names the medication dose row in the freshness list", () => {
statusPayload = {
lastSyncedAt: hoursAgo(1),
health: healthFixture(),
metricFreshness: [
{
type: "MEDICATION_DOSE",
lastSeenAt: hoursAgo(3),
stale: false,
lastReceivedAt: hoursAgo(1),
lastTrigger: "foreground",
lastNewSampleAt: hoursAgo(1),
lastBackgroundAt: hoursAgo(6),
},
],
};
const out = render();
expect(out).toContain("Medication doses");
expect(out).toContain("Last in background");
});
it("says a type has not arrived through live sync yet", () => {
const out = html({
...base,
@@ -472,7 +494,7 @@ describe("<AppleHealthCard> — per-type arrivals (#1173)", () => {
it("titles the list by data type", () => {
statusPayload = {
lastSyncedAt: hoursAgo(1),
syncHealth: { verdict: "fresh", since: null },
health: healthFixture(),
metricFreshness: [{ ...base }],
};
expect(render()).toContain("By data type");
@@ -0,0 +1,160 @@
/**
* The integration cards render the server's `health` and nothing else.
*
* Before v1.42.4 each card combined the ledger state, the liveness verdict,
* token expiry, plan and scope lists and a reauth flag in its own way, so one
* fact could read as a red error on one card and a footnote on the next. The
* server now folds all of it into `health`, and this pins that no card under
* this folder starts reading the old fields again, which is the one way a
* second status dialect could come back. The managed-profile status view is
* held to the same rule.
*/
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import ts from "typescript";
import { walkSourceFiles } from "@/__tests__/helpers/source-files";
import { failureStreakFor, pickStatus } from "../shared";
describe("failureStreakFor", () => {
it("pairs the largest per-kind streak with the envelope threshold", () => {
const status = pickStatus(
{
threshold: 3,
integrations: [
{
integration: "withings",
consecutiveFailuresByKind: {
transient: 1,
reauth_required: 2,
persistent: 0,
},
},
],
},
"withings",
);
expect(failureStreakFor(status)).toEqual({ count: 2, threshold: 3 });
});
it("reports nothing for a healthy zero-count status", () => {
const status = pickStatus(
{
threshold: 3,
integrations: [
{
integration: "withings",
consecutiveFailuresByKind: {
transient: 0,
reauth_required: 0,
persistent: 0,
},
},
],
},
"withings",
);
expect(failureStreakFor(status)).toBeNull();
});
});
describe("structural guard: cards read health, not the retired fields", () => {
const SRC = join(process.cwd(), "src");
const CARDS = "components/settings/integrations";
const MANAGED = "components/settings/managed-integration-status.tsx";
/**
* Fields the server keeps on the wire for one transition release and that
* `health` replaces. Matched as property reads in the parsed source, so a
* comment or an i18n key that happens to contain the word is not a read.
*/
const RETIRED = new Set([
"lastError",
"state",
"needsReauth",
"planUnavailable",
"scopeReconnect",
"syncHealth",
"tokenExpired",
"tokenExpiresAt",
"hasActivityScope",
"legacyLastSyncedAt",
"consecutiveFailures",
]);
/**
* `.state` is also the name of `health.state` and of a Google Health sync
* run's progress state. Those bases are the allowed readers; any other base
* reading `.state` is a ledger state finding its way back onto a card.
*/
const STATE_READERS = /(^|\.)(health|progress|syncProgress|record)$/;
const files = [
...walkSourceFiles(join(SRC, CARDS), { floor: 15 })
.filter((p) => !p.includes("__tests__") && !p.endsWith(".test.ts"))
.map((p) => `${CARDS}/${p}`),
MANAGED,
];
function retiredReads(rel: string): string[] {
const path = join(SRC, rel);
const file = ts.createSourceFile(
path,
readFileSync(path, "utf8"),
ts.ScriptTarget.Latest,
true,
path.endsWith("x") ? ts.ScriptKind.TSX : ts.ScriptKind.TS,
);
const found: string[] = [];
function visit(node: ts.Node): void {
if (ts.isPropertyAccessExpression(node)) {
const name = node.name.text;
const base = node.expression.getText(file).replace(/\?$/, "");
if (
RETIRED.has(name) &&
!(name === "state" && STATE_READERS.test(base))
) {
found.push(`${rel}: ${base}.${name}`);
}
}
ts.forEachChild(node, visit);
}
visit(file);
return found;
}
it("walks the cards and the managed view", () => {
expect(files.length).toBeGreaterThan(10);
expect(files).toContain(`${CARDS}/integration-card.tsx`);
expect(files).toContain(`${CARDS}/oauth-integration-card.tsx`);
expect(files).toContain(MANAGED);
});
it("no card reads a retired health-shaped field", () => {
expect(files.flatMap(retiredReads)).toEqual([]);
});
it("the card data types do not declare them either", () => {
// The compiler is the stronger half of this guard: a field the view model
// does not declare cannot be read without a cast.
const shared = readFileSync(join(SRC, CARDS, "shared.tsx"), "utf8");
const viewModel = shared.slice(
shared.indexOf("export interface IntegrationStatusViewModel"),
shared.indexOf("export interface IntegrationStatusEnvelope"),
);
expect(viewModel.length).toBeGreaterThan(100);
for (const field of RETIRED) {
expect(viewModel, field).not.toMatch(new RegExp(`\\b${field}\\??:`));
}
});
it("no card paints the old status pill", () => {
const offenders = files.filter((rel) =>
/<IntegrationStatusPill\b/.test(readFileSync(join(SRC, rel), "utf8")),
);
expect(offenders).toEqual([]);
});
});
@@ -49,6 +49,7 @@ vi.mock("@tanstack/react-query", () => ({
import { I18nProvider } from "@/lib/i18n/context";
import { ConnectionsPanel } from "../connections-panel";
import { INTEGRATION_DISPLAY_NAMES } from "../integration-fallback-row";
import { healthFixture } from "./health-fixture";
const MONTH_AGO = new Date(Date.now() - 28 * 24 * 60 * 60 * 1000).toISOString();
@@ -64,14 +65,16 @@ const CARD_LESS_KEY = "example-provider";
function entry(integration: string) {
return {
integration,
state: "error_transient",
lastSuccessAt: MONTH_AGO,
lastAttemptAt: MONTH_AGO,
lastError: null,
configured: true,
connected: true,
available: true,
syncHealth: { verdict: "stalled", since: MONTH_AGO },
health: healthFixture({
state: "degraded",
reason: "sync_stalled",
since: MONTH_AGO,
lastDataAt: MONTH_AGO,
}),
metricFreshness: [],
};
}
@@ -117,7 +120,7 @@ describe("ConnectionsPanel — every envelope entry surfaces", () => {
}
});
it("gives a card-less provider a fallback row with the stalled pill", () => {
it("gives a card-less provider the shared card, read-only, with its server state", () => {
// The shape the mechanism exists for. With no display name registered the
// row falls back to the raw key, which is still an honest surface — far
// better than the provider rendering nowhere at all.
@@ -130,9 +133,15 @@ describe("ConnectionsPanel — every envelope entry surfaces", () => {
expect(html).toContain('data-testid="integration-fallback-row"');
expect(html).toContain(`data-integration="${CARD_LESS_KEY}"`);
expect(html).toContain(CARD_LESS_KEY);
expect(html).toContain('data-state="stalled"');
expect(html).toContain("Sync stopped");
expect(html).toContain("Last attempt");
const row = html.slice(
html.indexOf('data-testid="integration-fallback-row"'),
);
expect(row).toMatch(
/data-slot="integration-health-badge"[^>]*data-state="degraded"/,
);
expect(row).toContain("HealthLog has not synced since");
// Read-only: no menu and no button for a dropped integration.
expect(html).not.toContain(`data-testid="${CARD_LESS_KEY}-menu"`);
});
it("renders no fallback row when every entry is card-backed", () => {
@@ -149,12 +158,9 @@ describe("ConnectionsPanel — every envelope entry surfaces", () => {
integrations: [
{
...entry(CARD_LESS_KEY),
state: "disconnected",
lastSuccessAt: null,
lastAttemptAt: null,
configured: false,
connected: false,
syncHealth: { verdict: "disconnected", since: null },
health: null,
},
],
};
@@ -0,0 +1,25 @@
import type { ConnectionHealth } from "@/lib/integrations/connection-health";
/** A `ConnectionHealth` as the server would publish it, with overrides. */
export function healthFixture(
overrides: Partial<ConnectionHealth> = {},
): ConnectionHealth {
return {
state: "ok",
reason: "data_arriving",
since: null,
action: null,
unavailable: [],
lastSuccessAt: "2026-10-15T08:12:00.000Z",
lastDataAt: "2026-10-15T07:12:00.000Z",
diagnostics: {
ledgerState: "connected",
failingLegs: [],
failingSince: null,
lastAttemptAt: "2026-10-15T08:12:00.000Z",
verdict: "fresh",
recentCalls: [],
},
...overrides,
};
}
@@ -0,0 +1,403 @@
/**
* The shared integration card, one test per state the server can publish.
*
* Every assertion reads the markup's data attributes or the bundle's own
* sentence, never a computed state: the card renders `health` and nothing
* else, so what is checked here is that each `health` lands on the right
* badge, sentence and (at most one) button.
*/
import { describe, expect, it, vi } from "vitest";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
vi.mock("@tanstack/react-query", () => ({
useMutation: () => ({ mutate: vi.fn(), isPending: false }),
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
}));
let lastCardProps: IntegrationCardProps | null = null;
vi.mock("../integration-card", async (importOriginal) => {
const actual = await importOriginal<typeof import("../integration-card")>();
return {
...actual,
IntegrationCard: (props: IntegrationCardProps) => {
lastCardProps = props;
return createElement(actual.IntegrationCard, props);
},
};
});
import type { Locale } from "@/lib/i18n/config";
import { I18nProvider } from "@/lib/i18n/context";
import type { ConnectionHealth } from "@/lib/integrations/connection-health";
import type { IntegrationCardProps } from "../integration-card";
import {
OAUTH_INTEGRATIONS,
OAuthIntegrationCard,
type OAuthIntegrationKey,
} from "../oauth-integration-card";
import type { IntegrationStatusViewModel } from "../shared";
import { healthFixture } from "./health-fixture";
const NOW = Date.now();
const hoursAgo = (h: number) => new Date(NOW - h * 3_600_000).toISOString();
function render(
provider: OAuthIntegrationKey,
viewModel: Partial<IntegrationStatusViewModel> | undefined,
locale: Locale = "en",
) {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>
<OAuthIntegrationCard
config={OAUTH_INTEGRATIONS[provider]}
viewModel={
viewModel
? ({
integration: provider,
lastError: null,
...viewModel,
} as IntegrationStatusViewModel)
: undefined
}
callbackUrl={`https://app.example/api/${provider}/callback`}
/>
</I18nProvider>,
);
}
function connected(health: ConnectionHealth, extra = {}) {
return {
connected: true,
configured: true,
available: true,
health,
...extra,
};
}
function badgeState(html: string): string | null {
return (
html.match(
/data-slot="integration-health-badge"[^>]*data-state="(\w+)"/,
)?.[1] ?? null
);
}
function primaryActions(html: string): string[] {
return [
...html.matchAll(
/data-slot="integration-primary-action"[^>]*data-action="(\w+)"/g,
),
].map((m) => m[1]);
}
describe("<IntegrationCard> per state", () => {
it("ok: Working, one sentence, last data, no button", () => {
const html = render(
"oura",
connected(healthFixture({ lastDataAt: hoursAgo(1) })),
);
expect(html).toMatch(/data-slot="integration-card"[^>]*data-state="ok"/);
expect(badgeState(html)).toBe("ok");
expect(html).toContain(">Working<");
expect(html).toContain("Data is arriving.");
expect(html).toContain('data-slot="integration-last-data"');
expect(html).toContain("Last data: today,");
expect(primaryActions(html)).toEqual([]);
// Details are always there, collapsed.
expect(html).toContain('data-slot="integration-details-toggle"');
expect(html).toMatch(
/data-slot="integration-details"[^>]*data-state="closed"/,
);
expect(html).toMatch(
/<div[^>]*hidden=""[^>]*data-slot="integration-details"/,
);
});
it("ok while older history loads: the sentence says so", () => {
const html = render(
"whoop",
connected(healthFixture(), { backfillCompleted: false }),
);
expect(html).toContain(
"Data is arriving. Older history is loading in the background.",
);
});
it("limited by plan: the sentence names up to three, counts the rest, no button", () => {
const html = render(
"withings",
connected(
healthFixture({
state: "limited",
reason: "plan_limited",
unavailable: [
{ item: "spo2", cause: "plan" },
{ item: "vascularAge", cause: "plan" },
{ item: "pulseWaveVelocity", cause: "plan" },
{ item: "vo2Max", cause: "plan" },
{ item: "ecg", cause: "plan" },
],
}),
),
);
expect(badgeState(html)).toBe("limited");
expect(html).toContain(">Limited<");
expect(html).toContain(
"Data is arriving. Your Withings plan does not include SpO2, vascular age, and 3 more.",
);
expect(primaryActions(html)).toEqual([]);
// The full list lives in the details, with the plans page one tap away.
const details = html.slice(html.indexOf('data-slot="integration-details"'));
expect(details).toContain("ECG and AFib");
expect(details).toContain('data-testid="withings-plan-link"');
});
it("limited by permission: the sentence names it and offers the one reconnect", () => {
const html = render(
"oura",
connected(
healthFixture({
state: "limited",
reason: "permission_limited",
action: "reconnect",
unavailable: [
{ item: "spo2", cause: "permission" },
{ item: "vo2max", cause: "permission" },
],
}),
),
);
expect(badgeState(html)).toBe("limited");
expect(html).toContain(
"Data is arriving. Permission is missing for SpO2 and VO2 max.",
);
expect(primaryActions(html)).toEqual(["reconnect"]);
expect(html).toContain(">Reconnect<");
});
it("degraded: Delayed, dated, reassuring, no button", () => {
const html = render(
"withings",
connected(
healthFixture({
state: "degraded",
reason: "provider_errors",
since: hoursAgo(2),
}),
),
);
expect(badgeState(html)).toBe("degraded");
expect(html).toContain(">Delayed<");
expect(html).toMatch(
/Withings has not answered reliably since (today|yesterday), [^.]+\. HealthLog keeps trying; nothing to do on your side\./,
);
expect(primaryActions(html)).toEqual([]);
});
it("action required, reauth: Action needed and exactly one Reconnect", () => {
const html = render(
"google-health",
connected(
healthFixture({
state: "action_required",
reason: "reauth_required",
action: "reconnect",
}),
),
);
expect(badgeState(html)).toBe("action_required");
expect(html).toContain(">Action needed<");
expect(html).toContain(
"The Google Health connection has expired. Reconnect to keep data coming.",
);
expect(primaryActions(html)).toEqual(["reconnect"]);
// The re-consent banner it replaces is gone.
expect(html).not.toContain("google-health-reauth-banner");
});
it("action required, paused: exactly one Resume, wired to the resume endpoint", () => {
const html = render(
"whoop",
connected(
healthFixture({
state: "action_required",
reason: "paused_after_errors",
since: hoursAgo(30),
action: "resume",
}),
),
);
expect(badgeState(html)).toBe("action_required");
expect(html).toContain("WHOOP is paused after failing since");
expect(primaryActions(html)).toEqual(["resume"]);
expect(html).toContain(">Resume<");
expect(html).toContain('data-testid="whoop-resume-button"');
expect(html).not.toContain("whoop-parked-banner");
});
it("not connected: no badge, one sentence, the BYO form and Connect", () => {
const html = render("withings", {
connected: false,
configured: false,
health: null,
});
expect(html).toMatch(
/data-slot="integration-card"[^>]*data-state="not_connected"/,
);
expect(badgeState(html)).toBeNull();
expect(html).toContain("Not connected.");
expect(html).toContain('data-testid="withings-setup-guide"');
expect(html).toContain('data-testid="withings-redirect-guide"');
expect(html).toContain('id="withings-clientid"');
expect(html).toContain('data-testid="withings-connect"');
// Connecting needs stored credentials first, and the card says so.
expect(html).toMatch(
/data-testid="withings-connect"[^>]*disabled=""|disabled=""[^>]*data-testid="withings-connect"/,
);
expect(html).toContain('data-testid="integration-unavailable"');
expect(html).not.toContain('data-slot="integration-details-toggle"');
});
it("loading: paints the card and its header, says nothing about state", () => {
const html = render("polar", undefined);
expect(html).toMatch(
/data-slot="integration-card"[^>]*data-state="loading"/,
);
expect(badgeState(html)).toBeNull();
expect(html).not.toContain("Not connected.");
});
it("speaks German with the maintainer's words", () => {
const html = render(
"oura",
connected(
healthFixture({
state: "degraded",
reason: "unexpected_silence",
since: hoursAgo(50),
}),
),
"de",
);
expect(html).toContain(">Verzögert<");
expect(html).toContain("obwohl Oura sonst täglich liefert.");
expect(html).toContain("Details für eine Fehlermeldung");
});
});
describe("<IntegrationCard> menu and actions", () => {
it("moves sync, full reload, test, data, credentials, guide and disconnect into the menu", () => {
render("withings", connected(healthFixture()));
const menu = lastCardProps?.menu ?? [];
expect(menu.map((i) => i.id)).toEqual([
"sync",
"full-sync",
"test",
"view-data",
"credentials",
"setup-guide",
"disconnect",
]);
// The heavy and the destructive entries keep their confirmation.
expect(menu.find((i) => i.id === "full-sync")?.confirm).toBeTruthy();
const disconnect = menu.find((i) => i.id === "disconnect");
expect(disconnect?.destructive).toBe(true);
expect(disconnect?.confirm?.confirmLabel).toBe("Disconnect");
});
it("offers no full reload where the provider has none", () => {
render("strava", connected(healthFixture()));
expect((lastCardProps?.menu ?? []).map((i) => i.id)).not.toContain(
"full-sync",
);
});
it("names the menu trigger for screen readers and keeps the 44 px floor", () => {
const html = render("oura", connected(healthFixture()));
expect(html).toContain('aria-label="More actions for Oura"');
const trigger = html.match(
/<button[^>]*data-slot="integration-menu-trigger"[^>]*>/,
)?.[0];
expect(trigger).toBeDefined();
expect(trigger).toMatch(/\bmin-h-11\b/);
expect(trigger).toMatch(/\bmin-w-11\b/);
expect(trigger).toContain('aria-haspopup="menu"');
});
it("shows the badge word as readable text, not an icon or a hidden label", () => {
const html = render(
"oura",
connected(healthFixture({ state: "degraded", reason: "no_new_data" })),
);
const badge = html.match(
/<span[^>]*data-slot="integration-health-badge"[^>]*>([\s\S]*?)<\/span>(?=<\/div>)/,
)?.[1];
expect(badge).toContain("Delayed");
expect(html).not.toMatch(
/data-slot="integration-health-badge"[^>]*aria-hidden="true"/,
);
});
});
describe("<IntegrationCard> details for a bug report", () => {
it("shows exactly the redacted diagnostics the copy button copies", () => {
const secret = "Oura error: token=abc.def.ghi for user user-42";
const html = render(
"oura",
connected(
healthFixture({
state: "degraded",
reason: "provider_errors",
since: "2026-10-14T18:40:00.000Z",
lastSuccessAt: "2026-10-14T18:12:00.000Z",
lastDataAt: "2026-10-14T07:55:00.000Z",
diagnostics: {
ledgerState: "error_transient",
failingLegs: ["sleep"],
failingSince: "2026-10-14T18:40:00.000Z",
lastAttemptAt: "2026-10-15T09:15:00.000Z",
verdict: "failing",
recentCalls: [
{
endpoint: "sleep",
status: "transient",
at: "2026-10-15T09:15:00.000Z",
errorCode: "503",
},
],
},
}),
{
lastError: secret,
consecutiveFailuresByKind: {
transient: 2,
reauth_required: 0,
persistent: 0,
},
failureThreshold: 3,
},
),
);
const pre = html.match(
/<pre[^>]*data-slot="integration-diagnostics"[^>]*>([\s\S]*?)<\/pre>/,
)?.[1];
expect(pre).toBeDefined();
expect(pre).toContain("HealthLog");
expect(pre).toContain("Oura");
expect(pre).toContain(
"State: degraded / provider_errors since 2026-10-14 18:40 UTC",
);
expect(pre).toContain("Last sync: 2026-10-14 18:12 UTC");
expect(pre).toContain("Ledger: error_transient, failing parts: sleep");
expect(pre).toContain("Failure streak: 2 of 3");
expect(pre).toMatch(/2026-10-15 09:15 {2}sleep {2}transient +503/);
// The stored provider message, its token and the account never appear,
// neither in the block nor anywhere else on the card.
expect(html).not.toContain("token=abc");
expect(html).not.toContain("user-42");
expect(html).not.toContain("Oura error");
});
});
@@ -0,0 +1,275 @@
import { describe, expect, it } from "vitest";
import { locales, type Locale } from "@/lib/i18n/config";
import { makeFormatters } from "@/lib/format-locale";
import {
CONNECTION_HEALTH_REASONS,
CONNECTION_HEALTH_STATES,
} from "@/lib/integrations/connection-health";
import de from "../../../../../messages/de.json";
import en from "../../../../../messages/en.json";
import es from "../../../../../messages/es.json";
import fr from "../../../../../messages/fr.json";
import itMessages from "../../../../../messages/it.json";
import ko from "../../../../../messages/ko.json";
import pl from "../../../../../messages/pl.json";
import {
diagnosticsText,
formatHealthInstant,
healthBadgeLabel,
healthSentence,
namedItemList,
} from "../integration-health-copy";
import { healthFixture } from "./health-fixture";
const CATALOGS: Record<Locale, unknown> = {
de,
en,
es,
fr,
it: itMessages,
pl,
ko,
};
function translator(locale: Locale) {
return (key: string, params?: Record<string, string | number>) => {
const value = key
.split(".")
.reduce<unknown>(
(node, part) => (node as Record<string, unknown> | undefined)?.[part],
CATALOGS[locale],
);
if (typeof value !== "string") return key;
return value.replace(/\{(\w+)\}/g, (_, name: string) =>
String(params?.[name] ?? `{${name}}`),
);
};
}
const NOW = new Date("2026-10-15T12:00:00.000Z");
const fmt = makeFormatters("de", "Europe/Berlin", "AUTO", "AUTO");
const t = translator("de");
describe("formatHealthInstant", () => {
it("reads today and yesterday with the time, older instants with the date", () => {
expect(formatHealthInstant("2026-10-15T05:12:00.000Z", t, fmt, NOW)).toBe(
"heute, 07:12",
);
expect(formatHealthInstant("2026-10-14T16:40:00.000Z", t, fmt, NOW)).toBe(
"gestern, 18:40",
);
expect(formatHealthInstant("2026-10-12T12:00:00.000Z", t, fmt, NOW)).toBe(
"12.10., 14:00",
);
});
it("buckets the day in the reader's zone, not UTC", () => {
// 23:30 UTC on the 14th is already the 15th in Berlin.
expect(formatHealthInstant("2026-10-14T23:30:00.000Z", t, fmt, NOW)).toBe(
"heute, 01:30",
);
});
});
describe("namedItemList", () => {
it("names up to three types", () => {
expect(
namedItemList(
[
{ item: "spo2", cause: "plan" },
{ item: "vascularAge", cause: "plan" },
{ item: "ecg", cause: "plan" },
],
t,
"de",
),
).toBe("SpO2, Gefäßalter und EKG und Vorhofflimmern");
});
it("names two and counts the rest beyond three", () => {
expect(
namedItemList(
["spo2", "vascularAge", "ecg", "vo2Max", "weight"].map((item) => ({
item,
cause: "plan" as const,
})),
t,
"de",
),
).toBe("SpO2, Gefäßalter und 3 weitere Werte");
});
it("names the Withings activity scope gap", () => {
expect(
namedItemList(
[
{ item: "activity", cause: "permission" },
{ item: "sleep", cause: "permission" },
],
t,
"de",
),
).toBe("Aktivität und Schlaf");
});
});
describe("healthSentence", () => {
const opts = { provider: "Oura", locale: "de" as const, fmt, now: NOW };
it("uses the dated sentence when the server dates the condition", () => {
expect(
healthSentence(
healthFixture({
state: "degraded",
reason: "no_new_data",
since: "2026-10-14T16:40:00.000Z",
}),
t,
opts,
),
).toBe("Seit gestern, 18:40 sind keine neuen Daten gekommen.");
});
it("falls back to an undated sentence rather than printing nothing", () => {
expect(
healthSentence(
healthFixture({ state: "degraded", reason: "provider_errors" }),
t,
opts,
),
).toBe(
"Oura antwortet gerade nicht zuverlässig. HealthLog versucht es weiter, du musst nichts tun.",
);
});
it("tells an Apple Health user to open the app", () => {
expect(
healthSentence(
healthFixture({
state: "degraded",
reason: "no_new_data",
since: "2026-10-12T12:00:00.000Z",
}),
t,
{ ...opts, provider: "Apple Health", appleHealth: true },
),
).toBe(
"Seit 12.10., 14:00 sind keine neuen Daten gekommen. Öffne HealthLog einmal auf dem iPhone.",
);
});
it.each(locales)(
"has a sentence and a badge word for every state and reason in %s",
(locale) => {
const tl = translator(locale);
const f = makeFormatters(locale, "Europe/Berlin", "AUTO", "AUTO");
for (const state of CONNECTION_HEALTH_STATES) {
const word = healthBadgeLabel(state, tl);
expect(word).not.toContain("settings.");
}
for (const reason of CONNECTION_HEALTH_REASONS) {
for (const since of [null, "2026-10-14T16:40:00.000Z"]) {
const sentence = healthSentence(
healthFixture({
reason,
since,
unavailable: [
{ item: "spo2", cause: "plan" },
{ item: "activity", cause: "permission" },
],
}),
tl,
{ provider: "Oura", locale, fmt: f, now: NOW },
);
expect(sentence, `${locale} ${reason}`).not.toMatch(
/settings\.|\{\w+\}| · | — | – /,
);
expect(sentence.length).toBeGreaterThan(5);
}
}
},
);
});
describe("diagnosticsText", () => {
it("prints the draft's block from health alone", () => {
const text = diagnosticsText(
{
version: "1.42.4",
provider: "Oura",
health: healthFixture({
state: "degraded",
reason: "provider_errors",
since: "2026-10-14T18:40:00.000Z",
lastSuccessAt: "2026-10-14T18:12:00.000Z",
lastDataAt: "2026-10-14T07:55:00.000Z",
unavailable: [{ item: "spo2", cause: "permission" }],
diagnostics: {
ledgerState: "error_transient",
failingLegs: ["sleep"],
failingSince: "2026-10-14T18:40:00.000Z",
lastAttemptAt: "2026-10-15T09:15:00.000Z",
verdict: "failing",
recentCalls: [
{
endpoint: "sleep",
status: "transient",
at: "2026-10-15T09:15:00.000Z",
errorCode: "503",
},
{
endpoint: "readiness",
status: "ok",
at: "2026-10-15T08:15:00.000Z",
errorCode: null,
},
],
},
}),
},
t,
);
expect(text.split("\n")).toEqual([
"HealthLog 1.42.4 Oura",
"Zustand: degraded / provider_errors seit 2026-10-14 18:40 UTC",
"Letzte Synchronisierung: 2026-10-14 18:12 UTC",
"Letzte Daten: 2026-10-14 07:55 UTC",
"Ledger: error_transient, fehlende Teile: sleep",
"Letzte Aufrufe:",
" 2026-10-15 09:15 sleep transient 503",
" 2026-10-15 08:15 readiness ok",
"Nicht verfügbar: spo2 (Freigabe)",
]);
});
it("leaves out what the server does not know instead of inventing it", () => {
const text = diagnosticsText(
{
version: "",
provider: "Apple Health",
health: healthFixture({
lastSuccessAt: null,
lastDataAt: null,
diagnostics: {
ledgerState: null,
failingLegs: [],
failingSince: null,
lastAttemptAt: null,
verdict: "fresh",
recentCalls: [],
},
}),
},
t,
);
expect(text.split("\n")).toEqual([
"HealthLog Apple Health",
"Zustand: ok / data_arriving",
"Letzte Synchronisierung: keine",
"Letzte Daten: keine",
]);
});
});
@@ -1,23 +1,23 @@
/**
* Nightscout parity + correctness.
* Nightscout on the shared integration card.
*
* 1. A `parked` verdict renders the warning banner + reconnect button
* (matching the WHOOP card's parked treatment, byte-for-byte classes).
* 2. A healthy connection renders the shared TestConnectionButton AND the
* connect→data link to /insights/blood-glucose.
* 3. The card reads the consolidated envelope — it was the last card firing
* its own status round-trip — so a disconnect invalidates that one key.
* 1. A paused connection offers "Resume", which opens the address form (there
* is no OAuth round-trip to re-run).
* 2. A healthy connection keeps sync, test, the data link and disconnect in
* the menu, and disconnect asks first.
* 3. The card reads the consolidated envelope, so a disconnect invalidates
* that one key.
* 4. An unapproved private origin is named for what it is, an operator
* action, without echoing the stored error text.
*/
import { describe, expect, it, vi } from "vitest";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { queryKeys } from "@/lib/query-keys";
// Capture the disconnect mutation's onSuccess so the test can assert which
// query keys it invalidates.
const invalidateSpy = vi.fn();
type OnSuccess = () => void;
const capturedDisconnectOnSuccess: { fn: OnSuccess | null } = { fn: null };
const capturedDisconnectOnSuccess: { fn: (() => void) | null } = { fn: null };
vi.mock("@tanstack/react-query", () => ({
useMutation: ({ onSuccess }: { onSuccess?: () => void }) => {
@@ -28,11 +28,30 @@ vi.mock("@tanstack/react-query", () => ({
useQueryClient: () => ({ invalidateQueries: invalidateSpy }),
}));
let lastCardProps: IntegrationCardProps | null = null;
vi.mock("../integration-card", async (importOriginal) => {
const actual = await importOriginal<typeof import("../integration-card")>();
return {
...actual,
IntegrationCard: (props: IntegrationCardProps) => {
lastCardProps = props;
return createElement(actual.IntegrationCard, props);
},
};
});
import { I18nProvider } from "@/lib/i18n/context";
import type { IntegrationCardProps } from "../integration-card";
import { NightscoutCard } from "../nightscout-card";
import type { IntegrationStatusViewModel } from "../shared";
import { healthFixture } from "./health-fixture";
function render(viewModel?: Partial<IntegrationStatusViewModel>) {
/** The envelope carries more than the card declares; the card must ignore it. */
type Envelope = Partial<IntegrationStatusViewModel> & {
lastError?: string | null;
};
function render(viewModel?: Envelope) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<NightscoutCard
@@ -40,9 +59,6 @@ function render(viewModel?: Partial<IntegrationStatusViewModel>) {
viewModel
? ({
integration: "nightscout",
state: "connected",
lastSuccessAt: null,
lastAttemptAt: null,
lastError: null,
...viewModel,
} as IntegrationStatusViewModel)
@@ -53,121 +69,126 @@ function render(viewModel?: Partial<IntegrationStatusViewModel>) {
);
}
describe("NightscoutCard — parked + test + data-link + invalidation", () => {
it("renders the parked banner + reconnect button when the verdict is parked", () => {
function menuIds(): string[] {
return (lastCardProps?.menu ?? []).map((item) => item.id);
}
describe("NightscoutCard on the shared card", () => {
it("offers Resume on a paused connection, and Resume opens the address form", () => {
const html = render({
connected: true,
configured: true,
state: "parked",
lastError: "Nightscout unreachable",
syncHealth: { verdict: "parked", since: null },
health: healthFixture({
state: "action_required",
reason: "paused_after_errors",
since: "2026-10-12T14:00:00.000Z",
action: "resume",
}),
});
expect(html).toContain('data-state="parked"');
expect(html).toContain('data-testid="nightscout-parked-banner"');
expect(html).toContain('data-testid="nightscout-resume-button"');
expect(html).toContain("border-warning/30 bg-warning/10");
expect(html).toMatch(
/data-slot="integration-health-badge"[^>]*data-state="action_required"/,
);
expect(html).toContain('data-action="resume"');
expect(html).toContain("Nightscout is paused after failing since");
expect(lastCardProps?.primary?.onResume).toBeTypeOf("function");
// The form is closed until the button asks for it.
expect(html).not.toContain('id="nightscout-url"');
});
it("renders the test-connection button + data link when connected", () => {
const html = render({
it("keeps sync, test, the data link and disconnect in the menu", () => {
render({
connected: true,
configured: true,
lastSuccessAt: "2026-06-01T00:00:00.000Z",
syncHealth: { verdict: "fresh", since: null },
health: healthFixture(),
metricFreshness: [],
});
expect(html).toContain("Test connection");
expect(html).toContain('data-testid="nightscout-data-link"');
expect(html).toContain('href="/insights/blood-glucose"');
expect(menuIds()).toEqual([
"sync",
"test",
"view-data",
"credentials",
"setup-guide",
"disconnect",
]);
const viewData = lastCardProps?.menu?.find((i) => i.id === "view-data");
expect(viewData?.href).toBe("/insights/blood-glucose");
const disconnect = lastCardProps?.menu?.find((i) => i.id === "disconnect");
expect(disconnect?.destructive).toBe(true);
expect(disconnect?.confirm?.title).toBeTruthy();
});
it("carries no card divider — the card gap does that job", () => {
it("puts the per-metric list in the details, stale rows included", () => {
const html = render({
connected: true,
configured: true,
syncHealth: { verdict: "fresh", since: null },
});
expect(html).not.toContain('data-testid="integration-card-divider"');
});
it("renders the per-metric freshness disclosure with a stale row", () => {
const html = render({
connected: true,
configured: true,
syncHealth: { verdict: "fresh", since: null },
health: healthFixture(),
metricFreshness: [
{
type: "BLOOD_GLUCOSE",
lastSeenAt: "2026-06-01T00:00:00.000Z",
lastSeenAt: new Date(Date.now() - 3 * 86_400_000).toISOString(),
stale: true,
},
],
});
expect(html).toContain('data-slot="metric-freshness"');
expect(html).toContain("quiet");
const details = html.slice(html.indexOf('data-slot="integration-details"'));
expect(details).toContain('data-metric="BLOOD_GLUCOSE"');
expect(details).toContain('data-state="stale"');
});
it("disconnect invalidates the envelope the card actually reads", () => {
render({ connected: true, configured: true, health: healthFixture() });
invalidateSpy.mockClear();
capturedDisconnectOnSuccess.fn = null;
render({
connected: true,
configured: true,
syncHealth: { verdict: "fresh", since: null },
capturedDisconnectOnSuccess.fn?.();
expect(invalidateSpy).toHaveBeenCalledWith({
queryKey: queryKeys.integrationsStatus(),
});
const onSuccess = capturedDisconnectOnSuccess.fn as (() => void) | null;
expect(typeof onSuccess).toBe("function");
onSuccess?.();
const invalidatedKeys = invalidateSpy.mock.calls.map((c) => c[0]?.queryKey);
expect(invalidatedKeys).toContainEqual(queryKeys.integrationsStatus());
});
});
describe("NightscoutCard — sync-now action", () => {
it("offers the sync action on a connected instance", () => {
const html = render({
connected: true,
configured: true,
syncHealth: { verdict: "fresh", since: null },
});
expect(html).toContain('data-testid="nightscout-sync"');
expect(html).toContain("Sync now");
});
it("does not offer it before an instance is configured", () => {
expect(
render({
connected: false,
configured: false,
state: "disconnected",
syncHealth: { verdict: "disconnected", since: null },
}),
).not.toContain('data-testid="nightscout-sync"');
it("shows the address form and Connect while not connected", () => {
const html = render({ connected: false, configured: false, health: null });
expect(html).toContain('data-state="not_connected"');
expect(html).toContain('id="nightscout-url"');
expect(html).toContain('data-testid="nightscout-connect"');
expect(html).toContain('data-testid="nightscout-setup-guide"');
});
});
describe("NightscoutCard — operator-owned private access", () => {
it("does not offer an ordinary user a private-host enforcement bypass", () => {
const html = render({
connected: false,
configured: false,
state: "disconnected",
syncHealth: { verdict: "disconnected", since: null },
});
const html = render({ connected: false, configured: false, health: null });
expect(html).not.toContain('id="nightscout-private"');
expect(html).not.toContain("This site runs on a private network");
expect(html).not.toContain("Enable only if your Nightscout instance");
});
it("shows a redacted operator-action state for an unapproved legacy private connection", () => {
it("names the operator action for an unapproved private origin without echoing the error", () => {
const html = render({
connected: true,
configured: true,
state: "error_reauth",
// Still on the wire for one release; the card must not show it.
lastError:
"private_origin_not_approved: http://10.0.0.4:1337/api/v1/entries.json?token=ns-secret",
syncHealth: { verdict: "failing", since: null },
allowPrivateHost: true,
health: healthFixture({
state: "degraded",
reason: "provider_errors",
since: "2026-10-14T18:40:00.000Z",
diagnostics: {
ledgerState: "error_transient",
failingLegs: ["sync"],
failingSince: "2026-10-14T18:40:00.000Z",
lastAttemptAt: "2026-10-15T09:15:00.000Z",
verdict: "failing",
recentCalls: [
{
endpoint: "sync",
status: "persistent",
at: "2026-10-15T09:15:00.000Z",
errorCode: "private_origin_not_approved",
},
],
},
}),
});
expect(html).toContain(
@@ -176,35 +197,27 @@ describe("NightscoutCard — operator-owned private access", () => {
expect(html).toContain(
"Private Nightscout access requires server operator approval.",
);
expect(html).toContain(
"Ask your server operator to approve this exact Nightscout origin.",
);
expect(html).not.toContain("private_origin_not_approved");
// The short code appears in the diagnostics block, the stored message
// nowhere: no address, no token.
expect(html).not.toContain("private_origin_not_approved:");
expect(html).not.toContain("10.0.0.4");
expect(html).not.toContain("ns-secret");
expect(html).not.toContain('data-testid="nightscout-sync"');
expect(html).not.toContain("Test connection");
expect(html).toContain("Disconnect");
// Sync and test cannot help until the operator approves the origin.
expect(menuIds()).not.toContain("sync");
expect(menuIds()).not.toContain("test");
expect(menuIds()).toContain("disconnect");
});
it.each([
["public", false],
["operator-approved private", true],
])("keeps a healthy %s connection usable", (_label, allowPrivateHost) => {
it("keeps a healthy connection usable", () => {
const html = render({
connected: true,
configured: true,
state: "connected",
lastError: null,
syncHealth: { verdict: "fresh", since: null },
// Compatibility/display metadata only; it is not the authority asserted
// by this positive flow.
allowPrivateHost,
health: healthFixture(),
});
expect(html).toContain('data-testid="nightscout-sync"');
expect(html).toContain("Test connection");
expect(html).toContain("Disconnect");
expect(menuIds()).toContain("sync");
expect(menuIds()).toContain("test");
expect(html).not.toContain(
'data-testid="nightscout-private-operator-required"',
);
@@ -1,274 +0,0 @@
/**
* v1.17.0 — interaction-parity check for the shared OAuth integration card
* (Polar / Oura). The new cards must match the existing WHOOP card's
* parked-state + test-connection + connect→data treatment:
*
* 1. A `parked` status renders the warning banner + reconnect button.
* 2. A `connected` status renders the shared TestConnectionButton (its
* "Test connection" affordance) AND the connect→data link.
* 3. The data link resolves to the provider's insight surface.
*/
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { Watch } from "lucide-react";
// The card reads the consolidated envelope only — its per-card status fetch is
// gone, so every fixture is a view-model.
vi.mock("@tanstack/react-query", () => ({
useMutation: () => ({ mutate: vi.fn(), isPending: false }),
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
}));
import { I18nProvider } from "@/lib/i18n/context";
import { OAuthProviderCard } from "../oauth-provider-card";
type ViewModel = Parameters<typeof OAuthProviderCard>[0]["viewModel"];
function render({
credentials = false,
viewModel,
}: {
credentials?: boolean;
viewModel?: ViewModel;
} = {}) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<OAuthProviderCard
provider="polar"
statusQueryKey={["polar"]}
i18nPrefix="settings.polar"
icon={Watch}
dataHref="/insights/sleep"
credentials={credentials}
viewModel={viewModel}
callbackUrl="https://app.example/api/polar/callback"
/>
</I18nProvider>,
);
}
describe("OAuthProviderCard — parked + test + data-link parity", () => {
it("renders the parked banner + reconnect button when state is parked", () => {
const html = render({
viewModel: {
connected: true,
configured: true,
available: true,
state: "parked",
lastSuccessAt: null,
lastError: "Polar grant expired",
syncHealth: { verdict: "parked", since: null },
},
});
expect(html).toContain('data-state="parked"');
expect(html).toContain('data-testid="polar-parked-banner"');
expect(html).toContain('data-testid="polar-resume-button"');
expect(html).toContain("Paused: reconnect");
// The parked banner uses the same warning treatment as the WHOOP card.
expect(html).toContain("border-warning/30 bg-warning/10");
});
it("renders the test-connection button + data link when connected", () => {
const html = render({
viewModel: {
connected: true,
configured: true,
available: true,
state: "connected",
lastSuccessAt: "2026-06-01T00:00:00.000Z",
lastError: null,
syncHealth: { verdict: "fresh", since: null },
},
});
// The shared TestConnectionButton surfaces its "Test connection" label.
expect(html).toContain("Test connection");
// connect→data link points at the provider's insight surface.
expect(html).toContain('data-testid="polar-data-link"');
expect(html).toContain('href="/insights/sleep"');
});
it("reads off the passed view-model — the per-card fetch is gone", () => {
// There is no `useQuery` left in this component: the envelope is the only
// source. A per-provider status response carries no verdict, so falling
// back to it would have painted a status the server never resolved.
const html = render({
viewModel: {
connected: true,
configured: true,
available: true,
state: "connected",
lastSuccessAt: "2026-06-01T00:00:00.000Z",
lastError: null,
syncHealth: { verdict: "fresh", since: null },
},
});
expect(html).toContain('data-testid="polar-data-link"');
expect(html).toContain("Test connection");
});
it("does not render the data link or test button when disconnected", () => {
const html = render({
viewModel: {
connected: false,
configured: false,
available: true,
syncHealth: { verdict: "disconnected", since: null },
},
});
expect(html).not.toContain('data-testid="polar-data-link"');
expect(html).not.toContain("Test connection");
// The connect CTA stands in instead.
expect(html).toContain('data-testid="polar-connect"');
});
});
describe("OAuthProviderCard — per-user BYO credentials form (v1.17.1)", () => {
it("renders the credentials form only when the `credentials` prop is set", () => {
const viewModel: ViewModel = {
connected: false,
configured: false,
available: true,
hasOwnCredentials: false,
syncHealth: { verdict: "disconnected", since: null },
};
// Opt-in: the BYO client-id/secret form + save button appear.
const withForm = render({ credentials: true, viewModel });
expect(withForm).toContain('data-testid="polar-credentials"');
expect(withForm).toContain('id="polar-clientid"');
expect(withForm).toContain('id="polar-secret"');
// Default: no credential inputs (env-only behaviour preserved).
const withoutForm = render({ viewModel });
expect(withoutForm).not.toContain('data-testid="polar-credentials"');
expect(withoutForm).not.toContain('id="polar-clientid"');
});
it("shows the saved-placeholder once the user has stored their own pair", () => {
const html = render({
credentials: true,
viewModel: {
connected: true,
configured: true,
available: true,
hasOwnCredentials: true,
state: "connected",
lastSuccessAt: null,
lastError: null,
syncHealth: { verdict: "pending_first_sync", since: null },
},
});
expect(html).toContain("Saved. Enter new to replace");
});
});
describe("OAuthProviderCard — redirect-URI mini-guide (v1.29.x, UX audit H2)", () => {
it("shows the callback URL guide before the user has BYO credentials", () => {
const html = render({
credentials: true,
viewModel: {
connected: false,
configured: false,
available: true,
hasOwnCredentials: false,
syncHealth: { verdict: "disconnected", since: null },
},
});
expect(html).toContain('data-testid="polar-redirect-guide"');
expect(html).toContain('data-testid="polar-redirect-uri"');
expect(html).toContain("https://app.example/api/polar/callback");
});
it("hides the guide once the user has stored their own credentials", () => {
const html = render({
credentials: true,
viewModel: {
connected: true,
configured: true,
available: true,
hasOwnCredentials: true,
state: "connected",
lastSuccessAt: null,
lastError: null,
syncHealth: { verdict: "fresh", since: null },
},
});
expect(html).not.toContain('data-testid="polar-redirect-guide"');
});
});
/**
* v1.32.28 — sync now. The route without the button would be an endpoint with
* no caller; the button is what makes the manual sync a feature rather than an
* API detail. Rendered only once the provider is connected, in the same action
* row as Test connection.
*/
describe("OAuthProviderCard — sync-now action", () => {
it("offers the sync action on a connected provider", () => {
const html = render({
viewModel: {
connected: true,
configured: true,
available: true,
state: "connected",
lastSuccessAt: null,
lastError: null,
syncHealth: { verdict: "fresh", since: null },
},
});
expect(html).toContain('data-testid="polar-sync"');
expect(html).toContain("Sync now");
});
it("does not offer it before the provider is connected", () => {
expect(
render({
viewModel: {
connected: false,
configured: true,
available: true,
syncHealth: { verdict: "disconnected", since: null },
},
}),
).not.toContain('data-testid="polar-sync"');
});
});
describe("OAuthProviderCard — Oura scope note", () => {
const scopeGap = {
connected: true,
configured: true,
available: true,
state: "connected" as const,
lastSuccessAt: "2026-10-14T00:00:00.000Z",
lastError: null,
syncHealth: { verdict: "fresh" as const, since: null },
scopeReconnect: ["spo2" as const, "vo2max" as const],
};
function renderOura(viewModel: ViewModel) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<OAuthProviderCard
provider="oura"
statusQueryKey={["oura"]}
i18nPrefix="settings.oura"
icon={Watch}
dataHref="/insights/sleep"
viewModel={viewModel}
callbackUrl="https://app.example/api/oura/callback"
/>
</I18nProvider>,
);
}
it("asks for one reconnect beside a healthy pill, not an error", () => {
const html = renderOura(scopeGap);
expect(html).toContain('data-testid="oura-scope-note"');
expect(html).toContain("Reconnect Oura once to allow SpO2 and VO2 max.");
expect(html).not.toContain('data-testid="oura-error"');
});
it("stays away from the other OAuth providers", () => {
expect(render({ viewModel: scopeGap })).not.toContain("scope-note");
});
});
@@ -1,138 +0,0 @@
/**
* One verdict, one pill mapping.
*
* There used to be three `pillStateFor` implementations — one per card family —
* and they disagreed with each other. The same ledger state painted red on the
* Withings card and orange on the Polar card, on the same page. The server now
* resolves the verdict once; this pins that the client projects it once, and
* that a fourth dialect cannot be minted without someone editing this file.
*/
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { walkSourceFiles } from "@/__tests__/helpers/source-files";
import type { SyncVerdict } from "@/lib/integrations/sync-verdict";
import {
pickStatus,
pillFailurePropsFor,
pillStateForVerdict,
} from "../shared";
describe("pillStateForVerdict — the verdict→pill table", () => {
const table: Array<[SyncVerdict | undefined, string]> = [
["fresh", "connected"],
["stale", "stale"],
["stalled", "stalled"],
["failing", "warning"],
["reauth_required", "error"],
["parked", "parked"],
["pending_first_sync", "pending-setup"],
["disconnected", "disconnected"],
// Still loading: the card renders its neutral default rather than
// guessing at a status it has not been told.
[undefined, "disconnected"],
];
it.each(table)("maps %s to the %s pill", (verdict, expected) => {
expect(pillStateForVerdict(verdict)).toBe(expected);
});
/**
* The decision this release makes and must not silently lose: **red means
* "your action fixes this"**. Reconnecting cannot repair an upstream 503 or
* a cron that stopped running, so neither of those may reach the destructive
* pill.
*/
it("reserves the error pill for the states a reconnect actually fixes", () => {
const red = table
.filter(([, state]) => state === "error")
.map(([verdict]) => verdict);
expect(red).toEqual(["reauth_required"]);
expect(pillStateForVerdict("failing")).not.toBe("error");
expect(pillStateForVerdict("stalled")).not.toBe("error");
});
});
describe("pillFailurePropsFor", () => {
it("projects the active bucket and envelope threshold onto the pill", () => {
const status = pickStatus(
{
threshold: 3,
integrations: [
{
integration: "withings",
state: "error_transient",
lastSuccessAt: null,
lastAttemptAt: "2026-07-28T12:00:00.000Z",
lastError: "upstream unavailable",
consecutiveFailuresByKind: {
transient: 2,
reauth_required: 0,
persistent: 1,
},
},
],
},
"withings",
);
expect(pillFailurePropsFor(status)).toEqual({
failureCount: 2,
failureThreshold: 3,
});
});
it("returns no ratio for a healthy zero-count status", () => {
const status = pickStatus(
{
threshold: 3,
integrations: [
{
integration: "withings",
state: "connected",
lastSuccessAt: "2026-07-28T12:00:00.000Z",
lastAttemptAt: "2026-07-28T12:00:00.000Z",
lastError: null,
consecutiveFailuresByKind: {
transient: 0,
reauth_required: 0,
persistent: 0,
},
},
],
},
"withings",
);
expect(pillFailurePropsFor(status)).toEqual({});
});
});
describe("structural guard — no second pill dialect", () => {
const SRC = join(process.cwd(), "src");
function sourceFiles(): string[] {
return walkSourceFiles(SRC, { floor: 3000 })
.filter((p) => !p.startsWith("generated/"))
.filter((p) => !p.includes("__tests__"))
.filter((p) => !p.endsWith(".test.ts") && !p.endsWith(".test.tsx"));
}
/**
* The one module allowed to define a status→pill mapping. A tripwire, not a
* proof: it cannot show the mapping is right, only that nobody added a
* second one without editing this file.
*/
const MAPPER_HOME = "components/settings/integrations/shared.tsx";
it("defines the mapping in exactly one module", () => {
const definers = sourceFiles().filter((rel) =>
/function\s+pillStateFor\w*\s*\(/.test(
readFileSync(join(SRC, rel), "utf8"),
),
);
expect(definers).toEqual([MAPPER_HOME]);
});
});
@@ -1,65 +0,0 @@
/**
* The two "withheld by contract" notes: the Withings card names what the
* account's API plan refuses, the Oura card asks for one reconnect when the
* grant lacks a scope. Both render nothing when nothing is withheld, and both
* render the server-resolved list rather than deriving one.
*/
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import { WithingsPlanNote } from "../withings-plan-note";
import { OuraScopeNote } from "../oura-scope-note";
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
);
}
describe("<WithingsPlanNote>", () => {
it("lists the withheld readings and links the Withings plans page", () => {
const html = render(
<WithingsPlanNote features={["spo2", "vascularAge", "ecg"]} />,
);
expect(html).toContain('data-testid="withings-plan-note"');
expect(html).toContain("SpO2, vascular age, and ECG and AFib");
expect(html).toContain("Weight, blood pressure, body composition");
expect(html).toContain(
"https://static.withings.com/content/api/plans/plans.html",
);
});
it("speaks the reader's language", () => {
const html = render(<WithingsPlanNote features={["vascularAge"]} />, "de");
expect(html).toContain("Gefäßalter");
expect(html).toContain("kostenpflichtigen API-Plan");
});
it("renders nothing when the plan withholds nothing", () => {
expect(render(<WithingsPlanNote features={[]} />)).toBe("");
expect(render(<WithingsPlanNote features={undefined} />)).toBe("");
});
});
describe("<OuraScopeNote>", () => {
it("asks for one reconnect naming the collections the grant lacks", () => {
const html = render(
<OuraScopeNote
collections={["spo2", "vo2max", "cardio_age", "resilience"]}
onReconnect={vi.fn()}
/>,
);
expect(html).toContain('data-testid="oura-scope-note"');
expect(html).toContain(
"Reconnect Oura once to allow SpO2, VO2 max, cardiovascular age, and resilience.",
);
expect(html).toContain('data-testid="oura-scope-reconnect"');
});
it("renders nothing when the grant covers everything", () => {
expect(
render(<OuraScopeNote collections={[]} onReconnect={vi.fn()} />),
).toBe("");
});
});
@@ -1,51 +0,0 @@
/**
* v1.29.x — UX audit H2: the WHOOP card shows a compact callback-URL guide
* while the account has no BYO credentials saved, and steps out of the way
* once configured. Mirrors the OAuthProviderCard coverage for Polar/Oura.
*/
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
vi.mock("@tanstack/react-query", () => ({
useMutation: () => ({ mutate: vi.fn(), isPending: false }),
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
}));
import { I18nProvider } from "@/lib/i18n/context";
import { WhoopCard } from "../whoop-card";
import type { IntegrationStatusViewModel } from "../shared";
function render(
viewModel?: Partial<IntegrationStatusViewModel>,
callbackUrl: string | null = "https://app.example/api/whoop/callback",
) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<WhoopCard
viewModel={viewModel as IntegrationStatusViewModel | undefined}
callbackUrl={callbackUrl}
/>
</I18nProvider>,
);
}
describe("<WhoopCard> redirect-URI mini-guide", () => {
it("shows the callback URL guide before credentials are configured", () => {
const html = render({ connected: false, configured: false });
expect(html).toContain('data-testid="whoop-redirect-guide"');
expect(html).toContain('data-testid="whoop-redirect-uri"');
expect(html).toContain("https://app.example/api/whoop/callback");
});
it("falls back to the fixed path when the server has no redirect URI", () => {
const html = render({ connected: false, configured: false }, null);
expect(html).toContain('data-testid="whoop-redirect-uri"');
expect(html).toContain("/api/whoop/callback");
expect(html).not.toContain("undefined/api/whoop/callback");
});
it("hides the guide once credentials are configured", () => {
const html = render({ connected: false, configured: true });
expect(html).not.toContain('data-testid="whoop-redirect-guide"');
});
});
@@ -1,36 +1,29 @@
"use client";
/**
* Apple Health — the one integration with no ledger row and no cron.
* Apple Health: the one integration with no ledger row and no cron.
*
* The iOS app pushes; nothing here polls. The card used to derive its status
* from the mere presence of `lastSyncedAt`, so any timestamp at all painted the
* green "data received" chip — a pipe dead for three weeks looked exactly like
* one that delivered this morning. It now reads the server's verdict, which
* applies a seven-day threshold to the same timestamp, and renders the shared
* pill instead of a bespoke badge so the flagship integration speaks the same
* status language as its siblings.
* The iOS app pushes; nothing here polls. The card renders the shared
* integration card from the server's `health`, so the flagship integration
* speaks the same status language as its siblings. What only Apple Health
* knows (how the last batch arrived, how far the first backfill reached, when
* each data type last came in) lives in the card's details.
*/
import Link from "next/link";
import { useQuery } from "@tanstack/react-query";
import { HeartPulse, Upload } from "lucide-react";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { SettingsCard } from "@/components/settings/settings-card";
import {
formatRelative,
IntegrationStatusPill,
} from "@/components/settings/integration-status-pill";
import { formatRelative } from "@/components/settings/integration-status-pill";
import { Button } from "@/components/ui/button";
import { apiGet } from "@/lib/api/api-fetch";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import type { ConnectionHealth } from "@/lib/integrations/connection-health";
import type { HealthKitFreshnessEntry } from "@/lib/integrations/healthkit-type-sync";
import type { SyncHealth } from "@/lib/integrations/sync-verdict";
import { queryKeys } from "@/lib/query-keys";
import { MetricFreshnessDisclosure } from "./metric-freshness-disclosure";
import { IntegrationErrorMessage, pillStateForVerdict } from "./shared";
import { IntegrationCard } from "./integration-card";
import { MetricFreshnessList } from "./metric-freshness-list";
type SyncTrigger = "foreground" | "background" | "push" | "manual";
@@ -40,7 +33,8 @@ interface HealthKitStatus {
lastSyncTrigger?: SyncTrigger | null;
/** When data last arrived without the app being opened. */
lastBackgroundSyncAt?: string | null;
syncHealth?: SyncHealth;
/** The unified connection state; null until the phone has delivered. */
health?: ConnectionHealth | null;
/** Per type: newest sample, plus when it last arrived and how (#1173). */
metricFreshness?: HealthKitFreshnessEntry[];
/** Server-side backfill progress (#778); null when the read failed. */
@@ -139,7 +133,8 @@ function DeliveryDiagnostic({
* under which trigger, and when one last brought a new value. The newest
* sample's time on the right of the row says when the reading was TAKEN; this
* says when the server RECEIVED it, which is what tells a type that only
* arrives on "Sync all" from one the phone delivers by itself (#1173). Nothing
* arrives on "Sync all" from one the phone delivers by itself (#1173), plus
* the last background arrival when a later one came another way. Nothing
* renders when the server sent no arrival facts (the workouts row, or a failed
* ledger read), so absence is never painted as "not received".
*/
@@ -182,13 +177,25 @@ export function ArrivalDetail({
}),
);
}
// The last background arrival, when a later one came another way: opening
// the app to look would otherwise hide that the phone delivers by itself.
if (
entry.lastBackgroundAt &&
entry.lastBackgroundAt !== entry.lastReceivedAt
) {
parts.push(
t("settings.appleHealth.freshness.lastBackground", {
relative: relative(entry.lastBackgroundAt),
}),
);
}
return (
<span
data-testid="apple-health-arrival"
data-state="received"
data-trigger={trigger ?? "unknown"}
>
{parts.join(" · ")}
{parts.join(", ")}
</span>
);
}
@@ -279,88 +286,84 @@ export function AppleHealthCard({ enabled }: { enabled: boolean }) {
refetchOnWindowFocus: true,
});
const verdict = status?.syncHealth?.verdict;
const pillState = pillStateForVerdict(verdict);
// A failed status read is an error line, not a status state — the card says
// nothing about the pipe it could not ask about.
const showPill = !isLoading && !isError && !!status;
const health = status?.health ?? null;
// Apple Health has nothing to connect: it counts as connected once the
// phone has delivered anything, which is exactly when the server resolves a
// health for it. A failed read is neither, so the card says it could not
// ask instead of claiming "not connected".
const connected = isError || health !== null;
const importLink = (
<Button
asChild
variant="outline"
size="sm"
className="min-h-11 w-full sm:min-h-9 sm:w-auto"
>
<Link
href="/settings/export#settings-section-import-title"
data-testid="apple-health-import-link"
>
<Upload className="size-3.5" aria-hidden="true" />
{t("settings.appleHealth.importAction")}
</Link>
</Button>
);
return (
<SettingsCard
as="section"
aria-labelledby="apple-health-card-title"
data-testid="apple-health-card"
>
<SettingsCardHeader
icon={HeartPulse}
titleId="apple-health-card-title"
title={t("settings.appleHealth.title")}
description={t("settings.appleHealth.description")}
status={
showPill ? (
<IntegrationStatusPill
testId="apple-health-status"
state={pillState}
lastSyncAt={status?.lastSyncedAt ?? null}
/>
) : null
}
/>
<div className="space-y-4">
{isError && (
<IntegrationErrorMessage
message={t("settings.appleHealth.status.unavailable")}
/>
)}
{verdict === "stale" && (
<p className="text-warning text-sm" data-testid="apple-health-stale">
{t("settings.appleHealth.staleNote")}
</p>
)}
{showPill && <DeliveryDiagnostic status={status} t={t} />}
{showPill && <SyncProgress status={status} t={t} />}
<MetricFreshnessDisclosure
entries={status?.metricFreshness}
idPrefix="apple-health"
title={t("settings.appleHealth.freshness.title")}
detail={(entry) => <ArrivalDetail entry={entry} t={t} />}
/>
<div className="space-y-2">
<h3 className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
{t("settings.appleHealth.setupTitle")}
</h3>
<ol className="text-muted-foreground list-decimal space-y-2 pl-5 text-sm">
<li>{t("settings.appleHealth.permissionStep")}</li>
<li>{t("settings.appleHealth.backgroundStep")}</li>
</ol>
</div>
<div className="border-border bg-muted/30 flex flex-col gap-3 rounded-lg border p-3 sm:flex-row sm:items-center sm:justify-between">
<p className="text-muted-foreground text-xs sm:max-w-xl">
<IntegrationCard
provider="apple-health"
providerName={t("settings.appleHealth.title")}
icon={HeartPulse}
description={t("settings.appleHealth.description")}
loading={enabled && isLoading}
connected={connected}
health={health}
appleHealth
setup={
<div className="space-y-3">
<div className="space-y-2">
<h3 className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
{t("settings.appleHealth.setupTitle")}
</h3>
<ol className="list-decimal space-y-2 pl-5 text-sm">
<li>{t("settings.appleHealth.permissionStep")}</li>
<li>{t("settings.appleHealth.backgroundStep")}</li>
</ol>
</div>
<p className="text-muted-foreground text-xs">
{t("settings.appleHealth.importNote")}
</p>
<Button
asChild
variant="outline"
size="sm"
className="min-h-11 shrink-0 sm:min-h-9"
>
<Link
href="/settings/export#settings-section-import-title"
data-testid="apple-health-import-link"
>
<Upload className="h-3.5 w-3.5" aria-hidden="true" />
{t("settings.appleHealth.importAction")}
</Link>
</Button>
</div>
</div>
</SettingsCard>
}
connectAction={importLink}
menu={[
{
id: "import",
label: t("settings.appleHealth.importAction"),
icon: Upload,
href: "/settings/export#settings-section-import-title",
},
]}
details={
status && !isError ? (
<>
<DeliveryDiagnostic status={status} t={t} />
<SyncProgress status={status} t={t} />
<MetricFreshnessList
entries={status.metricFreshness}
title={t("settings.appleHealth.freshness.title")}
detail={(entry) => <ArrivalDetail entry={entry} t={t} />}
/>
</>
) : undefined
}
>
{isError ? (
<p role="alert" className="text-destructive text-sm">
{t("settings.appleHealth.status.unavailable")}
</p>
) : null}
</IntegrationCard>
);
}
Loaded 100 of 208 files, more files were not shown because too many files have changed in this diff. Show more