diff --git a/CHANGELOG.md b/CHANGELOG.md index 220bc88ec..8ee610daf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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, diff --git a/Dockerfile b/Dockerfile index 475e51861..a06be1896 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 3386a8879..8eff71a6b 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -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 diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index c1dbd36c3..c45c0efaf 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.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:` for a catalog-linked marker or + `analyte:` 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:` 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: diff --git a/docs/integrations/google-health.md b/docs/integrations/google-health.md index 7fc61e208..600a52fc7 100644 --- a/docs/integrations/google-health.md +++ b/docs/integrations/google-health.md @@ -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 diff --git a/docs/ops/deploy.md b/docs/ops/deploy.md index 94f0c047d..1bf418cdc 100644 --- a/docs/ops/deploy.md +++ b/docs/ops/deploy.md @@ -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 diff --git a/docs/self-hosting/getting-started.md b/docs/self-hosting/getting-started.md index 9967227e6..983a8267f 100644 --- a/docs/self-hosting/getting-started.md +++ b/docs/self-hosting/getting-started.md @@ -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). diff --git a/e2e/a11y.spec.ts b/e2e/a11y.spec.ts index abc0c487d..e6ac14953 100644 --- a/e2e/a11y.spec.ts +++ b/e2e/a11y.spec.ts @@ -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: [ diff --git a/e2e/settings-integration-cards.spec.ts b/e2e/settings-integration-cards.spec.ts new file mode 100644 index 000000000..1663a1668 --- /dev/null +++ b/e2e/settings-integration-cards.spec.ts @@ -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([]); + }); + } +}); diff --git a/e2e/settings-integrations-mobile.spec.ts b/e2e/settings-integrations-mobile.spec.ts index f827442c8..ffc6d2b8b 100644 --- a/e2e/settings-integrations-mobile.spec.ts +++ b/e2e/settings-integrations-mobile.spec.ts @@ -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 - * 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 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 `
` 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( diff --git a/e2e/setup/integration-cards-fixture.ts b/e2e/setup/integration-cards-fixture.ts new file mode 100644 index 000000000..501861174 --- /dev/null +++ b/e2e/setup/integration-cards-fixture.ts @@ -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 = {}) { + 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) { + 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 { + 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 }), + }), + ); +} diff --git a/e2e/timeline.spec.ts b/e2e/timeline.spec.ts index 39b4d40fb..37a03977c 100644 --- a/e2e/timeline.spec.ts +++ b/e2e/timeline.spec.ts @@ -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, }); diff --git a/e2e/utils/mock-mood-insights.ts b/e2e/utils/mock-mood-insights.ts index 36fe5c144..a85ceb457 100644 --- a/e2e/utils/mock-mood-insights.ts +++ b/e2e/utils/mock-mood-insights.ts @@ -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: [], diff --git a/e2e/v137-sharing-managed-profiles.spec.ts b/e2e/v137-sharing-managed-profiles.spec.ts index f4e225f74..59928b471 100644 --- a/e2e/v137-sharing-managed-profiles.spec.ts +++ b/e2e/v137-sharing-managed-profiles.spec.ts @@ -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(); diff --git a/messages/de.json b/messages/de.json index 6a814002e..5e17d1bf6 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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": { diff --git a/messages/en.json b/messages/en.json index 356521583..b08ee23b9 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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": { diff --git a/messages/es.json b/messages/es.json index dfa682e77..9bf9001d7 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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": { diff --git a/messages/fr.json b/messages/fr.json index 587297fd3..878a0384b 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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": { diff --git a/messages/it.json b/messages/it.json index 5302c2727..2c3c7df29 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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": { diff --git a/messages/ko.json b/messages/ko.json index fe1370a61..1d65cae91 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -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": { diff --git a/messages/pl.json b/messages/pl.json index 7a3e8b972..6093866f9 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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": { diff --git a/package.json b/package.json index f9cf712b5..b27b8a46b 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/prisma/migrations/0400_integration_health_watchdog/migration.sql b/prisma/migrations/0400_integration_health_watchdog/migration.sql new file mode 100644 index 000000000..6f13cefe8 --- /dev/null +++ b/prisma/migrations/0400_integration_health_watchdog/migration.sql @@ -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 $$; diff --git a/prisma/migrations/0401_healthkit_type_sync_text_and_background/migration.sql b/prisma/migrations/0401_healthkit_type_sync_text_and_background/migration.sql new file mode 100644 index 000000000..0056be00e --- /dev/null +++ b/prisma/migrations/0401_healthkit_type_sync_text_and_background/migration.sql @@ -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'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 9834872e9..f78946ab2 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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 { diff --git a/public/sw.js b/public/sw.js index 6515573e4..a34d26be9 100644 --- a/public/sw.js +++ b/public/sw.js @@ -36,7 +36,7 @@ try { // v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild. const CACHE_VERSION = (typeof self !== "undefined" && self.__APP_VERSION__) || - /* @sw-version-fallback */ "v1.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/*` diff --git a/scripts/pending-migrations.mjs b/scripts/pending-migrations.mjs new file mode 100644 index 000000000..387e062dc --- /dev/null +++ b/scripts/pending-migrations.mjs @@ -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(); +} diff --git a/scripts/seed-demo.ts b/scripts/seed-demo.ts index 7c606cb91..c37cf6089 100644 --- a/scripts/seed-demo.ts +++ b/scripts/seed-demo.ts @@ -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, ], ); diff --git a/src/__tests__/aging-fixture-dates-guard.test.ts b/src/__tests__/aging-fixture-dates-guard.test.ts new file mode 100644 index 000000000..b4db923dd --- /dev/null +++ b/src/__tests__/aging-fixture-dates-guard.test.ts @@ -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 = { + // 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(); + 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", + ]); + }); +}); diff --git a/src/__tests__/client-fetch-limit-guard.test.ts b/src/__tests__/client-fetch-limit-guard.test.ts new file mode 100644 index 000000000..fcd8cffa3 --- /dev/null +++ b/src/__tests__/client-fetch-limit-guard.test.ts @@ -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=` 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=` inside a URL string, and the two + * `URLSearchParams` spellings, `.set("limit", "")` and + * `limit: ""`. 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); + }); +}); diff --git a/src/__tests__/container-scripts-ship-guard.test.ts b/src/__tests__/container-scripts-ship-guard.test.ts index 83e2e928f..629a62f48 100644 --- a/src/__tests__/container-scripts-ship-guard.test.ts +++ b/src/__tests__/container-scripts-ship-guard.test.ts @@ -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([]); + }); +}); diff --git a/src/__tests__/daily-reads-refetch-guard.test.ts b/src/__tests__/daily-reads-refetch-guard.test.ts index 3dda6b7c3..201759d15 100644 --- a/src/__tests__/daily-reads-refetch-guard.test.ts +++ b/src/__tests__/daily-reads-refetch-guard.test.ts @@ -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 diff --git a/src/__tests__/destructive-control-guard.test.ts b/src/__tests__/destructive-control-guard.test.ts index 46c0d9655..a6284a586 100644 --- a/src/__tests__/destructive-control-guard.test.ts +++ b/src/__tests__/destructive-control-guard.test.ts @@ -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: diff --git a/src/__tests__/integration-failure-response-consumer-guard.test.ts b/src/__tests__/integration-failure-response-consumer-guard.test.ts index 9d5438e17..cb86c7212 100644 --- a/src/__tests__/integration-failure-response-consumer-guard.test.ts +++ b/src/__tests__/integration-failure-response-consumer-guard.test.ts @@ -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}`); } } diff --git a/src/__tests__/measurement-write-tail-guard.test.ts b/src/__tests__/measurement-write-tail-guard.test.ts index 061f49718..4deacda2d 100644 --- a/src/__tests__/measurement-write-tail-guard.test.ts +++ b/src/__tests__/measurement-write-tail-guard.test.ts @@ -51,6 +51,8 @@ const WRITE_SITES: Record = { "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": diff --git a/src/__tests__/sharing-surface-guard.test.ts b/src/__tests__/sharing-surface-guard.test.ts index 50035b43b..6d627ac32 100644 --- a/src/__tests__/sharing-surface-guard.test.ts +++ b/src/__tests__/sharing-surface-guard.test.ts @@ -847,6 +847,10 @@ const DELEGABLE_ROUTES: Record = { 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 = { * 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` — diff --git a/src/__tests__/success-affordance-guard.test.ts b/src/__tests__/success-affordance-guard.test.ts index c6023429a..86828e5e6 100644 --- a/src/__tests__/success-affordance-guard.test.ts +++ b/src/__tests__/success-affordance-guard.test.ts @@ -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/, diff --git a/src/__tests__/v1341-locale-parity.test.ts b/src/__tests__/v1341-locale-parity.test.ts index bdd0570da..f6f0b5ec8 100644 --- a/src/__tests__/v1341-locale-parity.test.ts +++ b/src/__tests__/v1341-locale-parity.test.ts @@ -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", diff --git a/src/__tests__/written-outcome-response-consumer-guard.test.ts b/src/__tests__/written-outcome-response-consumer-guard.test.ts index bc9948329..8f96081bb 100644 --- a/src/__tests__/written-outcome-response-consumer-guard.test.ts +++ b/src/__tests__/written-outcome-response-consumer-guard.test.ts @@ -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( diff --git a/src/app/api/__tests__/module-route-gate-inventory.test.ts b/src/app/api/__tests__/module-route-gate-inventory.test.ts index 64d8ba407..431e119ca 100644 --- a/src/app/api/__tests__/module-route-gate-inventory.test.ts +++ b/src/app/api/__tests__/module-route-gate-inventory.test.ts @@ -242,6 +242,8 @@ const EXEMPT_ROUTES: ReadonlyArray = [ "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 diff --git a/src/app/api/encounters/route.ts b/src/app/api/encounters/route.ts index 43151980d..ef90cc80b 100644 --- a/src/app/api/encounters/route.ts +++ b/src/app/api/encounters/route.ts @@ -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, }), ]); diff --git a/src/app/api/fitbit/status/route.ts b/src/app/api/fitbit/status/route.ts index ac51d14f1..60a695256 100644 --- a/src/app/api/fitbit/status/route.ts +++ b/src/app/api/fitbit/status/route.ts @@ -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"), }); }); diff --git a/src/app/api/google-health/status/route.ts b/src/app/api/google-health/status/route.ts index 38b002f82..d63ea0c12 100644 --- a/src/app/api/google-health/status/route.ts +++ b/src/app/api/google-health/status/route.ts @@ -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"), }); }); diff --git a/src/app/api/illness/episodes/route.ts b/src/app/api/illness/episodes/route.ts index 15425425f..255e0f8c0 100644 --- a/src/app/api/illness/episodes/route.ts +++ b/src/app/api/illness/episodes/route.ts @@ -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({ diff --git a/src/app/api/insights/glp1-timeline/route.ts b/src/app/api/insights/glp1-timeline/route.ts index 67af7ff9c..2b59c53c9 100644 --- a/src/app/api/insights/glp1-timeline/route.ts +++ b/src/app/api/insights/glp1-timeline/route.ts @@ -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 })), + }); }); diff --git a/src/app/api/integrations/healthkit/__tests__/route.test.ts b/src/app/api/integrations/healthkit/__tests__/route.test.ts index f029d6a7f..deaedee17 100644 --- a/src/app/api/integrations/healthkit/__tests__/route.test.ts +++ b/src/app/api/integrations/healthkit/__tests__/route.test.ts @@ -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; 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"), ); diff --git a/src/app/api/integrations/healthkit/route.ts b/src/app/api/integrations/healthkit/route.ts index 886e489ea..bde6b3da4 100644 --- a/src/app/api/integrations/healthkit/route.ts +++ b/src/app/api/integrations/healthkit/route.ts @@ -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, }); }); diff --git a/src/app/api/integrations/status/__tests__/route.test.ts b/src/app/api/integrations/status/__tests__/route.test.ts index 25b08cc78..67daaed0a 100644 --- a/src/app/api/integrations/status/__tests__/route.test.ts +++ b/src/app/api/integrations/status/__tests__/route.test.ts @@ -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(); + } + }); +}); diff --git a/src/app/api/integrations/status/route.ts b/src/app/api/integrations/status/route.ts index fe97a54a2..f7a38cf98 100644 --- a/src/app/api/integrations/status/route.ts +++ b/src/app/api/integrations/status/route.ts @@ -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; diff --git a/src/app/api/labs/overview/route.ts b/src/app/api/labs/overview/route.ts new file mode 100644 index 000000000..c25a41ec5 --- /dev/null +++ b/src/app/api/labs/overview/route.ts @@ -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 }); +}); diff --git a/src/app/api/labs/route.ts b/src/app/api/labs/route.ts index e18282a76..7d1b8857e 100644 --- a/src/app/api/labs/route.ts +++ b/src/app/api/labs/route.ts @@ -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: { diff --git a/src/app/api/measurements/batch/route.ts b/src/app/api/measurements/batch/route.ts index fa0c55400..299b0ff1b 100644 --- a/src/app/api/measurements/batch/route.ts +++ b/src/app/api/measurements/batch/route.ts @@ -24,10 +24,12 @@ * · per-day cumulative totals — `stats::` (Steps, * Active Energy, Sleep Duration, Walking/Running Distance, * Flights Climbed); - * · v1.19.0 (iOS #34) hourly heart-rate buckets — - * `stats:HKQuantityTypeIdentifierHeartRate:` - * 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:` + * where `` 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:` row; ignored (stored + // `stats:HKQuantityTypeIdentifierHeartRate:` 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), diff --git a/src/app/api/medications/intake/bulk/__tests__/dose-arrival-scope.test.ts b/src/app/api/medications/intake/bulk/__tests__/dose-arrival-scope.test.ts new file mode 100644 index 000000000..3c8e9c0aa --- /dev/null +++ b/src/app/api/medications/intake/bulk/__tests__/dose-arrival-scope.test.ts @@ -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(); + 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: + (fn: (...args: Args) => Promise) => + (...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 { + 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(); + }); +}); diff --git a/src/app/api/medications/intake/bulk/__tests__/route.test.ts b/src/app/api/medications/intake/bulk/__tests__/route.test.ts index 464d57691..c3ac9d2a3 100644 --- a/src/app/api/medications/intake/bulk/__tests__/route.test.ts +++ b/src/app/api/medications/intake/bulk/__tests__/route.test.ts @@ -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([ { diff --git a/src/app/api/medications/intake/bulk/route.ts b/src/app/api/medications/intake/bulk/route.ts index 69fd01a1f..00bfc9d5d 100644 --- a/src/app/api/medications/intake/bulk/route.ts +++ b/src/app/api/medications/intake/bulk/route.ts @@ -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 { - 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 { }); } - 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 { 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 { // 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 { }); 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 { // 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 { 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 { })(); } + // 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 diff --git a/src/app/api/nightscout/status/route.ts b/src/app/api/nightscout/status/route.ts index f2cda6aa0..dafd647ce 100644 --- a/src/app/api/nightscout/status/route.ts +++ b/src/app/api/nightscout/status/route.ts @@ -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"), }); }); diff --git a/src/app/api/oura/status/__tests__/route.test.ts b/src/app/api/oura/status/__tests__/route.test.ts index dc73142d0..6dc845f78 100644 --- a/src/app/api/oura/status/__tests__/route.test.ts +++ b/src/app/api/oura/status/__tests__/route.test.ts @@ -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() } }, })); diff --git a/src/app/api/oura/status/route.ts b/src/app/api/oura/status/route.ts index ba3abf574..20924f365 100644 --- a/src/app/api/oura/status/route.ts +++ b/src/app/api/oura/status/route.ts @@ -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"), }); }); diff --git a/src/app/api/polar/status/__tests__/route.test.ts b/src/app/api/polar/status/__tests__/route.test.ts index 7a7d30162..f9b4adbfd 100644 --- a/src/app/api/polar/status/__tests__/route.test.ts +++ b/src/app/api/polar/status/__tests__/route.test.ts @@ -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() } }, })); diff --git a/src/app/api/polar/status/route.ts b/src/app/api/polar/status/route.ts index 92cd26d79..1f11d7f0e 100644 --- a/src/app/api/polar/status/route.ts +++ b/src/app/api/polar/status/route.ts @@ -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"), }); }); diff --git a/src/app/api/record-settings/integrations/route.ts b/src/app/api/record-settings/integrations/route.ts index 295e24733..f649858eb 100644 --- a/src/app/api/record-settings/integrations/route.ts +++ b/src/app/api/record-settings/integrations/route.ts @@ -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" } }); diff --git a/src/app/api/strava/status/route.ts b/src/app/api/strava/status/route.ts index c80c14a71..9906c250b 100644 --- a/src/app/api/strava/status/route.ts +++ b/src/app/api/strava/status/route.ts @@ -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"), }); }); diff --git a/src/app/api/whoop/status/__tests__/route.test.ts b/src/app/api/whoop/status/__tests__/route.test.ts index 3744084de..80d07e6cf 100644 --- a/src/app/api/whoop/status/__tests__/route.test.ts +++ b/src/app/api/whoop/status/__tests__/route.test.ts @@ -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() }, diff --git a/src/app/api/whoop/status/route.ts b/src/app/api/whoop/status/route.ts index ae8ca1221..bfe462376 100644 --- a/src/app/api/whoop/status/route.ts +++ b/src/app/api/whoop/status/route.ts @@ -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"), }); }); diff --git a/src/app/api/withings/status/route.ts b/src/app/api/withings/status/route.ts index 7e4311481..baeb2d754 100644 --- a/src/app/api/withings/status/route.ts +++ b/src/app/api/withings/status/route.ts @@ -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"), }); }); diff --git a/src/components/documents/__tests__/document-record-links.test.tsx b/src/components/documents/__tests__/document-record-links.test.tsx index e83374fcf..209e10413 100644 --- a/src/components/documents/__tests__/document-record-links.test.tsx +++ b/src/components/documents/__tests__/document-record-links.test.tsx @@ -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", () => ({ diff --git a/src/components/documents/document-record-links.tsx b/src/components/documents/document-record-links.tsx index b91b7fb0d..a80281ef9 100644 --- a/src/components/documents/document-record-links.tsx +++ b/src/components/documents/document-record-links.tsx @@ -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> { + const list = await apiGet(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({ + 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")} /> diff --git a/src/components/encounters/__tests__/encounter-link-picker-limits.test.ts b/src/components/encounters/__tests__/encounter-link-picker-limits.test.ts deleted file mode 100644 index 9cae124b8..000000000 --- a/src/components/encounters/__tests__/encounter-link-picker-limits.test.ts +++ /dev/null @@ -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); - }); -}); diff --git a/src/components/encounters/encounter-form.tsx b/src/components/encounters/encounter-form.tsx index 3b65668d5..9183aa7c9 100644 --- a/src/components/encounters/encounter-form.tsx +++ b/src/components/encounters/encounter-form.tsx @@ -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({ ; + results: LabPickerResult[]; +} + +/** One picker page of lab results. */ +export async function fetchLabPage( + request: PickerPageRequest, +): Promise> { + const page = await apiGet(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> { + const rows = await apiGet(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> | 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({ enabled: labsOn, - queryFn: () => - apiGet( - `/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({ enabled: illnessOn, - queryFn: () => - apiGet( - `/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 (
@@ -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")} /> diff --git a/src/components/insights/__tests__/device-score-surfaces.test.tsx b/src/components/insights/__tests__/device-score-surfaces.test.tsx index b97208e08..5b26ba04a 100644 --- a/src/components/insights/__tests__/device-score-surfaces.test.tsx +++ b/src/components/insights/__tests__/device-score-surfaces.test.tsx @@ -206,6 +206,12 @@ describe(" 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's readiness appears as your recovery score", + ); }); it("renders a canonical chart block for the present signal", () => { diff --git a/src/components/insights/glp1-substance-curve-summary.tsx b/src/components/insights/glp1-substance-curve-summary.tsx index 9fc178a08..80c6a7044 100644 --- a/src/components/insights/glp1-substance-curve-summary.tsx +++ b/src/components/insights/glp1-substance-curve-summary.tsx @@ -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("/api/insights/glp1-timeline?limit=200"), + apiGet("/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; diff --git a/src/components/labs/__tests__/lab-list-columns.test.tsx b/src/components/labs/__tests__/lab-list-columns.test.tsx index 44c7505b6..4b570c544 100644 --- a/src/components/labs/__tests__/lab-list-columns.test.tsx +++ b/src/components/labs/__tests__/lab-list-columns.test.tsx @@ -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(); + 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( diff --git a/src/components/labs/lab-list.tsx b/src/components/labs/lab-list.tsx index 77d7e72cc..e98e04ffa 100644 --- a/src/components/labs/lab-list.tsx +++ b/src/components/labs/lab-list.tsx @@ -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:` for legacy rows. */ + /** Stable group key: `bm:`, or `analyte:` 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(); - 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("/api/labs?limit=500&sortDir=desc"), + queryKey: queryKeys.labResultsOverview(), + queryFn: () => apiGet("/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.
- {truncated ? ( -

- {t("labs.showingLatestOf", { shown, total })} -

- ) : 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({
reading.value)} + values={group.trend} referenceLow={group.latest.referenceLow} referenceHigh={group.latest.referenceHigh} /> @@ -343,7 +309,7 @@ export function LabList({
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 ? ( -
  • -

    - {t("labs.showingLatestOf", { shown, total })} -

    -
  • - ) : null} {groups.map((group) => { return (
  • @@ -432,10 +391,10 @@ export function LabList({ {labDate(group.latest.takenAt)} - {group.readings.length > 1 ? ( + {group.count > 1 ? ( {t("labs.readingsCount", { - count: group.readings.length, + count: group.count, })} ) : null} @@ -447,7 +406,7 @@ export function LabList({ />
  • reading.value)} + values={group.trend} referenceLow={group.latest.referenceLow} referenceHigh={group.latest.referenceHigh} /> diff --git a/src/components/labs/types.ts b/src/components/labs/types.ts index 90e7f60fb..512038fc1 100644 --- a/src/components/labs/types.ts +++ b/src/components/labs/types.ts @@ -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:`, or `analyte:` 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; diff --git a/src/components/links/__tests__/entity-link-picker-paging.test.tsx b/src/components/links/__tests__/entity-link-picker-paging.test.tsx new file mode 100644 index 000000000..8c7e67de3 --- /dev/null +++ b/src/components/links/__tests__/entity-link-picker-paging.test.tsx @@ -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 }) => ( +
    {children}
    + ), +})); + +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( + {node}, + ); +} + +const server = (over: Partial = {}) => ({ + 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( + , + ); + 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( + , + ); + 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( + , + ); + 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( + , + ); + 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( + , + ); + expect(html).toContain('data-slot="labs-option"'); + }); + + it("a route without search filters what is loaded", () => { + const html = render( + , + ); + 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" }, + ]); + }); +}); diff --git a/src/components/links/__tests__/picker-requests.test.ts b/src/components/links/__tests__/picker-requests.test.ts new file mode 100644 index 000000000..125939e4b --- /dev/null +++ b/src/components/links/__tests__/picker-requests.test.ts @@ -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 { + 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(); + }); +}); diff --git a/src/components/links/__tests__/vault-document-options.test.ts b/src/components/links/__tests__/vault-document-options.test.ts index e0dbbace6..049c915d8 100644 --- a/src/components/links/__tests__/vault-document-options.test.ts +++ b/src/components/links/__tests__/vault-document-options.test.ts @@ -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(); } diff --git a/src/components/links/entity-link-picker.tsx b/src/components/links/entity-link-picker.tsx index 8d70d10b0..919dfd9e7 100644 --- a/src/components/links/entity-link-picker.tsx +++ b/src/components/links/entity-link-picker.tsx @@ -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, +): Map { + const map = new Map(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(); - 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>( + () => 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(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)) ? (

    {emptyLabel}

    {extraAction} @@ -315,7 +436,9 @@ export function EntityLinkPicker({
    - {groups.length === 0 ? ( + {server?.searchPending && groups.length === 0 ? ( +
    + +
    + ) : groups.length === 0 && + !(server && (server.hasOlder || server.hasNewer)) ? (

    {t("links.picker.searchNoMatch")}

    ) : (
    + {server?.hasNewer ? ( + + ) : null} {groups.map((group) => { const groupIds = group.options.map((option) => option.id); return ( @@ -384,7 +526,9 @@ export function EntityLinkPicker({
    ); })} + {server?.hasOlder ? ( + + ) : null}
    )} @@ -451,3 +603,37 @@ export function EntityLinkPicker({
    ); } + +/** "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 ( + + ); +} diff --git a/src/components/links/picker-requests.ts b/src/components/links/picker-requests.ts new file mode 100644 index 000000000..2d4be8381 --- /dev/null +++ b/src/components/links/picker-requests.ts @@ -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); +} diff --git a/src/components/links/use-link-picker-pages.ts b/src/components/links/use-link-picker-pages.ts new file mode 100644 index 000000000..4a0b13f7e --- /dev/null +++ b/src/components/links/use-link-picker-pages.ts @@ -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 { + 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(items: readonly T[]): T[] { + const seen = new Set(); + 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( + newerPages: ReadonlyArray>, + olderPages: ReadonlyArray>, +): T[] { + const newer = newerPages.flatMap((page) => page.items).reverse(); + const older = olderPages.flatMap((page) => page.items); + return uniqueById([...newer, ...older]); +} + +export function useLinkPickerPages({ + 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>; + /** + * 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(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); +} diff --git a/src/components/links/vault-document-options.ts b/src/components/links/vault-document-options.ts index 91aee8604..3ac090efd 100644 --- a/src/components/links/vault-document-options.ts +++ b/src/components/links/vault-document-options.ts @@ -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 { - 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( - `/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> { + const result = await apiGet(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({ 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)}`, }; } diff --git a/src/components/medications/__tests__/drug-level-chart.test.tsx b/src/components/medications/__tests__/drug-level-chart.test.tsx index a509dbcd3..9e9b0b673 100644 --- a/src/components/medications/__tests__/drug-level-chart.test.tsx +++ b/src/components/medications/__tests__/drug-level-chart.test.tsx @@ -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); + }); +}); diff --git a/src/components/medications/__tests__/side-effects-section.test.tsx b/src/components/medications/__tests__/side-effects-section.test.tsx index 4555b07c6..7737bd290 100644 --- a/src/components/medications/__tests__/side-effects-section.test.tsx +++ b/src/components/medications/__tests__/side-effects-section.test.tsx @@ -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 { 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(" — timeline rows", () => { expect(html).toContain("Mittel"); }); }); + +describe(" — 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(, client); + await executeSideEffectsQuery(client, "med-paged"); + await waitForQueryStatus(client, "med-paged", "success"); + + const firstHtml = render( + , + 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( + , + 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); + }); +}); diff --git a/src/components/medications/drug-level-chart.tsx b/src/components/medications/drug-level-chart.tsx index 481b65748..86e2fdcd7 100644 --- a/src/components/medications/drug-level-chart.tsx +++ b/src/components/medications/drug-level-chart.tsx @@ -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; } diff --git a/src/components/medications/side-effects-section.tsx b/src/components/medications/side-effects-section.tsx index 7df4d2477..140fb8edb 100644 --- a/src/components/medications/side-effects-section.tsx +++ b/src/components/medications/side-effects-section.tsx @@ -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(); + 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) { ))} + {hasNextPage ? ( +
    + +
    + ) : null}
    )} diff --git a/src/components/settings/__tests__/integrations-section.test.tsx b/src/components/settings/__tests__/integrations-section.test.tsx index dfc482093..1979ab0d6 100644 --- a/src/components/settings/__tests__/integrations-section.test.tsx +++ b/src/components/settings/__tests__/integrations-section.test.tsx @@ -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 = {}) { + 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) { + 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 `
    ` 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}"`, diff --git a/src/components/settings/__tests__/managed-integration-status.test.tsx b/src/components/settings/__tests__/managed-integration-status.test.tsx new file mode 100644 index 000000000..8cc091ea6 --- /dev/null +++ b/src/components/settings/__tests__/managed-integration-status.test.tsx @@ -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( + + + , + ); +} + +function withHealth(map: Record) { + 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("", () => { + 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\. { + 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."); + }); +}); diff --git a/src/components/settings/integrations/__tests__/apple-health-card.test.tsx b/src/components/settings/integrations/__tests__/apple-health-card.test.tsx index 1a483c1d5..2cee22c27 100644 --- a/src/components/settings/integrations/__tests__/apple-health-card.test.tsx +++ b/src/components/settings/integrations/__tests__/apple-health-card.test.tsx @@ -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("", () => { }); 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("", () => { }; 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("", () => { 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("", () => { 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("", () => { "backgroundStep", "importNote", "importAction", - "staleNote", ] as const; for (const catalog of [en, de, es, fr, itMessages, pl]) { @@ -212,8 +192,6 @@ describe("", () => { 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("", () => { 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(" — 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(" — 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(" — 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(" — 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(" — 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(" — 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(" — 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(" — 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(" — 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(" — 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(" — 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"); diff --git a/src/components/settings/integrations/__tests__/cards-render-health.test.ts b/src/components/settings/integrations/__tests__/cards-render-health.test.ts new file mode 100644 index 000000000..5ae749d7e --- /dev/null +++ b/src/components/settings/integrations/__tests__/cards-render-health.test.ts @@ -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) => + / ({ 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, }, ], }; diff --git a/src/components/settings/integrations/__tests__/health-fixture.ts b/src/components/settings/integrations/__tests__/health-fixture.ts new file mode 100644 index 000000000..7337b2505 --- /dev/null +++ b/src/components/settings/integrations/__tests__/health-fixture.ts @@ -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 { + 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, + }; +} diff --git a/src/components/settings/integrations/__tests__/integration-card.test.tsx b/src/components/settings/integrations/__tests__/integration-card.test.tsx new file mode 100644 index 000000000..feedd6ee3 --- /dev/null +++ b/src/components/settings/integrations/__tests__/integration-card.test.tsx @@ -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(); + 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 | undefined, + locale: Locale = "en", +) { + return renderToStaticMarkup( + + + , + ); +} + +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(" 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( + /]*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(" 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( + /]*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( + /]*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(" 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( + /]*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"); + }); +}); diff --git a/src/components/settings/integrations/__tests__/integration-health-copy.test.ts b/src/components/settings/integrations/__tests__/integration-health-copy.test.ts new file mode 100644 index 000000000..abdef8e95 --- /dev/null +++ b/src/components/settings/integrations/__tests__/integration-health-copy.test.ts @@ -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 = { + de, + en, + es, + fr, + it: itMessages, + pl, + ko, +}; + +function translator(locale: Locale) { + return (key: string, params?: Record) => { + const value = key + .split(".") + .reduce( + (node, part) => (node as Record | 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", + ]); + }); +}); diff --git a/src/components/settings/integrations/__tests__/nightscout-card.test.tsx b/src/components/settings/integrations/__tests__/nightscout-card.test.tsx index 21668bf1d..a30661df3 100644 --- a/src/components/settings/integrations/__tests__/nightscout-card.test.tsx +++ b/src/components/settings/integrations/__tests__/nightscout-card.test.tsx @@ -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(); + 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) { +/** The envelope carries more than the card declares; the card must ignore it. */ +type Envelope = Partial & { + lastError?: string | null; +}; + +function render(viewModel?: Envelope) { return renderToStaticMarkup( ) { viewModel ? ({ integration: "nightscout", - state: "connected", - lastSuccessAt: null, - lastAttemptAt: null, lastError: null, ...viewModel, } as IntegrationStatusViewModel) @@ -53,121 +69,126 @@ function render(viewModel?: Partial) { ); } -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"', ); diff --git a/src/components/settings/integrations/__tests__/oauth-provider-card.test.tsx b/src/components/settings/integrations/__tests__/oauth-provider-card.test.tsx deleted file mode 100644 index 12462e77d..000000000 --- a/src/components/settings/integrations/__tests__/oauth-provider-card.test.tsx +++ /dev/null @@ -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[0]["viewModel"]; - -function render({ - credentials = false, - viewModel, -}: { - credentials?: boolean; - viewModel?: ViewModel; -} = {}) { - return renderToStaticMarkup( - - - , - ); -} - -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( - - - , - ); - } - - 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"); - }); -}); diff --git a/src/components/settings/integrations/__tests__/pill-mapper.test.tsx b/src/components/settings/integrations/__tests__/pill-mapper.test.tsx deleted file mode 100644 index f67be81d0..000000000 --- a/src/components/settings/integrations/__tests__/pill-mapper.test.tsx +++ /dev/null @@ -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]); - }); -}); diff --git a/src/components/settings/integrations/__tests__/plan-and-scope-notes.test.tsx b/src/components/settings/integrations/__tests__/plan-and-scope-notes.test.tsx deleted file mode 100644 index 6a109ab19..000000000 --- a/src/components/settings/integrations/__tests__/plan-and-scope-notes.test.tsx +++ /dev/null @@ -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( - {node}, - ); -} - -describe("", () => { - it("lists the withheld readings and links the Withings plans page", () => { - const html = render( - , - ); - 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(, "de"); - expect(html).toContain("Gefäßalter"); - expect(html).toContain("kostenpflichtigen API-Plan"); - }); - - it("renders nothing when the plan withholds nothing", () => { - expect(render()).toBe(""); - expect(render()).toBe(""); - }); -}); - -describe("", () => { - it("asks for one reconnect naming the collections the grant lacks", () => { - const html = render( - , - ); - 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(), - ).toBe(""); - }); -}); diff --git a/src/components/settings/integrations/__tests__/whoop-card.test.tsx b/src/components/settings/integrations/__tests__/whoop-card.test.tsx deleted file mode 100644 index 6d5bc5388..000000000 --- a/src/components/settings/integrations/__tests__/whoop-card.test.tsx +++ /dev/null @@ -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, - callbackUrl: string | null = "https://app.example/api/whoop/callback", -) { - return renderToStaticMarkup( - - - , - ); -} - -describe(" 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"'); - }); -}); diff --git a/src/components/settings/integrations/apple-health-card.tsx b/src/components/settings/integrations/apple-health-card.tsx index 403594b6f..ee0431664 100644 --- a/src/components/settings/integrations/apple-health-card.tsx +++ b/src/components/settings/integrations/apple-health-card.tsx @@ -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 ( - {parts.join(" · ")} + {parts.join(", ")} ); } @@ -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 = ( + + ); return ( - - - ) : null - } - /> - -
    - {isError && ( - - )} - - {verdict === "stale" && ( -

    - {t("settings.appleHealth.staleNote")} -

    - )} - - {showPill && } - - {showPill && } - - } - /> - -
    -

    - {t("settings.appleHealth.setupTitle")} -

    -
      -
    1. {t("settings.appleHealth.permissionStep")}
    2. -
    3. {t("settings.appleHealth.backgroundStep")}
    4. -
    -
    - -
    -

    + +

    +

    + {t("settings.appleHealth.setupTitle")} +

    +
      +
    1. {t("settings.appleHealth.permissionStep")}
    2. +
    3. {t("settings.appleHealth.backgroundStep")}
    4. +
    +
    +

    {t("settings.appleHealth.importNote")}

    -
    -
    -
    + } + connectAction={importLink} + menu={[ + { + id: "import", + label: t("settings.appleHealth.importAction"), + icon: Upload, + href: "/settings/export#settings-section-import-title", + }, + ]} + details={ + status && !isError ? ( + <> + + + } + /> + + ) : undefined + } + > + {isError ? ( +

    + {t("settings.appleHealth.status.unavailable")} +

    + ) : null} +
    ); } diff --git a/src/components/settings/integrations/connections-panel.tsx b/src/components/settings/integrations/connections-panel.tsx index 52877436f..bc89bb947 100644 --- a/src/components/settings/integrations/connections-panel.tsx +++ b/src/components/settings/integrations/connections-panel.tsx @@ -17,23 +17,21 @@ import { useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { AppleHealthCard } from "@/components/settings/integrations/apple-health-card"; -import { FitbitCard } from "@/components/settings/integrations/fitbit-card"; import { GarminInfoNote } from "@/components/settings/integrations/garmin-info-note"; import { GoogleHealthCard } from "@/components/settings/integrations/google-health-card"; import { IntegrationFallbackRow } from "@/components/settings/integrations/integration-fallback-row"; import { NightscoutCard } from "@/components/settings/integrations/nightscout-card"; -import type { OAuthProviderStatus } from "@/components/settings/integrations/oauth-provider-card"; -import { OuraCard } from "@/components/settings/integrations/oura-card"; -import { PolarCard } from "@/components/settings/integrations/polar-card"; -import { StravaCard } from "@/components/settings/integrations/strava-card"; +import { + OAUTH_INTEGRATIONS, + OAuthIntegrationCard, +} from "@/components/settings/integrations/oauth-integration-card"; import { pickStatus, useIntegrationStatuses, type IntegrationKey, - type IntegrationStatusViewModel, } from "@/components/settings/integrations/shared"; -import { WhoopCard } from "@/components/settings/integrations/whoop-card"; -import { WithingsCard } from "@/components/settings/integrations/withings-card"; +import { Badge } from "@/components/ui/badge"; +import { TagChip } from "@/components/ui/tag-chip"; import { useAuth } from "@/hooks/use-auth"; import type { IntegrationCallbackUrls } from "@/lib/integrations/callback-urls"; import { useTranslations } from "@/lib/i18n/context"; @@ -179,32 +177,6 @@ export function oauthReasonKey( : `${base}.generic`; } -/** - * Adapt the consolidated-envelope view-model into the OAuth card's status - * shape. Returns `undefined` when the envelope hasn't loaded so the card - * renders its loading/disconnected default rather than a half-populated state. - */ -function toOAuthStatus( - vm: IntegrationStatusViewModel | undefined, -): OAuthProviderStatus | undefined { - if (!vm) return undefined; - return { - connected: vm.connected ?? false, - configured: vm.configured ?? false, - available: vm.available ?? false, - hasOwnCredentials: vm.hasOwnCredentials, - state: vm.state, - lastSuccessAt: vm.lastSuccessAt, - lastAttemptAt: vm.lastAttemptAt, - lastError: vm.lastError, - legacyLastSyncedAt: vm.legacyLastSyncedAt, - consecutiveFailuresByKind: vm.consecutiveFailuresByKind, - failureThreshold: vm.failureThreshold, - syncHealth: vm.syncHealth, - metricFreshness: vm.metricFreshness, - }; -} - /** * The keys a bespoke card on this panel renders. Every OTHER configured * envelope entry falls through to `IntegrationFallbackRow` — the panel's unit @@ -320,21 +292,6 @@ export function ConnectionsPanel({ } }, [oauthOutcome, router, queryClient, t]); - const withingsViewModel = pickStatus(integrationStatus, "withings"); - const whoopViewModel = pickStatus(integrationStatus, "whoop"); - const fitbitViewModel = pickStatus(integrationStatus, "fitbit"); - // v1.27.0 — Google Health reads off the same consolidated envelope; the - // ledger integration key is the hyphenated `google-health`. - const googleHealthViewModel = pickStatus(integrationStatus, "google-health"); - // v1.17.1 — Polar/Oura now read off the same consolidated envelope; the cards - // no longer fire their own /api//status round-trip. - const polarViewModel = toOAuthStatus(pickStatus(integrationStatus, "polar")); - const ouraViewModel = toOAuthStatus(pickStatus(integrationStatus, "oura")); - // v1.28.x — Strava reads off the same consolidated envelope. - const stravaViewModel = toOAuthStatus( - pickStatus(integrationStatus, "strava"), - ); - const nightscoutViewModel = pickStatus(integrationStatus, "nightscout"); const unclaimedIntegrations = (integrationStatus?.integrations ?? []) .filter( (entry) => @@ -351,26 +308,41 @@ export function ConnectionsPanel({ {/* Per-provider anchors so deep-links (onboarding "more sources" chips, `/settings/integrations#oura`) scroll straight to the right card. */}
    -
    -
    - + {t("settings.fitbitTag")} + + {t("settings.fitbitExperimentalBadge")} + + + } />
    @@ -378,22 +350,30 @@ export function ConnectionsPanel({
    -
    - +
    -
    - +
    {/* Every configured envelope entry no card above claims. Normally this renders nothing; on an account carrying a dropped-but-still-running diff --git a/src/components/settings/integrations/credentials-form.tsx b/src/components/settings/integrations/credentials-form.tsx new file mode 100644 index 000000000..c3ecb5be9 --- /dev/null +++ b/src/components/settings/integrations/credentials-form.tsx @@ -0,0 +1,175 @@ +"use client"; + +/** + * The bring-your-own OAuth app credentials form, one copy for every provider + * that takes a client id and secret (Withings, WHOOP, Fitbit, Google Health, + * Polar, Oura, Strava). It sits in the not-connected card before "Connect", + * and behind "Change credentials" in the menu once connected. + * + * Config fields, so no password manager is offered them (`autoComplete="off"` + * on both, the secret through `PasswordInput`). + */ + +import { useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { Loader2, Save } from "lucide-react"; + +import { SettingsCardActions } from "@/components/settings/_card-actions"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { PasswordInput } from "@/components/ui/password-input"; +import { apiFetchRaw } from "@/lib/api/api-fetch"; +import { useTranslations } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; + +export function CredentialsForm({ + provider, + i18nPrefix, + saved, + secretPlaceholder, + statusQueryKey, +}: { + /** Route segment: PUT `/api//credentials`; also the field ids. */ + provider: string; + /** i18n base, e.g. `settings.withings`. */ + i18nPrefix: string; + /** A pair is stored already: the fields show the saved placeholder. */ + saved: boolean; + /** + * The provider ships a secret-specific saved placeholder + * (`…CredentialsSavedPlaceholderSecret`); the others reuse the id's. + */ + secretPlaceholder: boolean; + statusQueryKey: readonly unknown[]; +}) { + const { t } = useTranslations(); + const queryClient = useQueryClient(); + const [clientId, setClientId] = useState(""); + const [clientSecret, setClientSecret] = useState(""); + const [saving, setSaving] = useState(false); + const [msg, setMsg] = useState<{ + kind: "success" | "error"; + text: string; + } | null>(null); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setSaving(true); + setMsg(null); + try { + const res = await apiFetchRaw(`/api/${provider}/credentials`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + clientId: clientId.trim(), + clientSecret: clientSecret.trim(), + }), + }); + if (res.ok) { + setMsg({ kind: "success", text: t(`${i18nPrefix}CredentialsSaved`) }); + setClientId(""); + setClientSecret(""); + queryClient.invalidateQueries({ queryKey: statusQueryKey }); + // The cards read the consolidated envelope; repaint it as well. + queryClient.invalidateQueries({ + queryKey: queryKeys.integrationsStatus(), + }); + } else { + let text = t("settings.savingError"); + try { + const json = await res.json(); + if (json.error) text = json.error; + } catch { + // keep the generic message + } + setMsg({ kind: "error", text }); + } + } catch { + setMsg({ kind: "error", text: t("common.networkError") }); + } + setSaving(false); + } + + return ( +
    +

    + {t(`${i18nPrefix}Credentials`)} +

    +
    +
    + + setClientId(e.target.value)} + placeholder={ + saved + ? t(`${i18nPrefix}CredentialsSavedPlaceholder`) + : t(`${i18nPrefix}ClientId`) + } + maxLength={200} + autoComplete="off" + inputMode="text" + spellCheck={false} + autoCapitalize="none" + enterKeyHint="next" + /> +
    +
    + + setClientSecret(e.target.value)} + placeholder={ + !saved + ? t(`${i18nPrefix}ClientSecret`) + : secretPlaceholder + ? t(`${i18nPrefix}CredentialsSavedPlaceholderSecret`) + : t(`${i18nPrefix}CredentialsSavedPlaceholder`) + } + maxLength={200} + autoComplete="off" + inputMode="text" + spellCheck={false} + autoCapitalize="none" + enterKeyHint="done" + /> +
    +
    + {msg && ( +

    + {msg.text} +

    + )} + + + +
    + ); +} diff --git a/src/components/settings/integrations/fitbit-card.tsx b/src/components/settings/integrations/fitbit-card.tsx deleted file mode 100644 index 8d61cb3ad..000000000 --- a/src/components/settings/integrations/fitbit-card.tsx +++ /dev/null @@ -1,537 +0,0 @@ -"use client"; - -// v1.12.0 — Fitbit (Fitbit & Pixel) card. Mirrors the WHOOP card: a -// BYO-key credentials form first, then an OAuth connect, then the -// sync/test/disconnect action row + parked-resume banner. v1.12.1 — status -// reads off the consolidated /api/integrations/status envelope (the per-card -// /api/fitbit/status round-trip is gone); the pill/error/parked state comes off -// the same view-model like WHOOP. - -import { useState } from "react"; -import Link from "next/link"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { - ArrowRight, - HeartPulse, - Link2, - Loader2, - RefreshCw, - Save, - Unlink, -} from "lucide-react"; - -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, - AlertDialogTrigger, -} from "@/components/ui/alert-dialog"; -import { Badge } from "@/components/ui/badge"; -import { SettingsCardActions } from "@/components/settings/_card-actions"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { PasswordInput } from "@/components/ui/password-input"; -import { TagChip } from "@/components/ui/tag-chip"; -import { SettingsCard } from "@/components/settings/settings-card"; -import { SettingsCardHeader } from "@/components/settings/_card-header"; -import { IntegrationStatusPill } from "@/components/settings/integration-status-pill"; -import { TestConnectionButton } from "@/components/settings/test-connection-button"; -import { apiFetchRaw, apiPost } from "@/lib/api/api-fetch"; -import { WrittenOutcomeLine } from "@/components/outcome/written-outcome-line"; -import { useTranslations } from "@/lib/i18n/context"; -import { - invalidateKeys, - measurementDependentKeys, - queryKeys, -} from "@/lib/query-keys"; - -import { - IntegrationErrorMessage, - pillFailurePropsFor, - pillStateFor, - pillTimestampFor, - type IntegrationStatusViewModel, -} from "./shared"; -import { MetricFreshnessDisclosure } from "./metric-freshness-disclosure"; -import { - readSyncOutcome, - useSyncOutcomeMessage, - type SyncOutcomeState, -} from "./sync-outcome"; -import { - CallbackMismatchNotice, - IntegrationCardDescription, - IntegrationRedirectGuide, -} from "./setup-guide-link"; -import type { IntegrationCallbackUrls } from "@/lib/integrations/callback-urls"; -import { loadDocument } from "@/lib/navigation/load-document"; - -export function FitbitCard({ - viewModel, - callbackUrl, -}: { - viewModel: IntegrationStatusViewModel | undefined; - /** Server-resolved OAuth callback URL; see `getIntegrationCallbackUrls`. */ - callbackUrl: IntegrationCallbackUrls["fitbit"]; -}) { - const { t } = useTranslations(); - const describeSyncOutcome = useSyncOutcomeMessage(); - const [syncing, setSyncing] = useState(false); - const [syncResult, setSyncResult] = useState(null); - const [clientId, setClientId] = useState(""); - const [clientSecret, setClientSecret] = useState(""); - const [credsSaving, setCredsSaving] = useState(false); - const [credsMsg, setCredsMsg] = useState(null); - const [credsMsgType, setCredsMsgType] = useState<"success" | "error" | null>( - null, - ); - const queryClient = useQueryClient(); - - const status = viewModel; - - const disconnect = useMutation({ - mutationFn: async () => { - await apiPost("/api/fitbit/disconnect"); - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: queryKeys.fitbit() }); - queryClient.invalidateQueries({ - queryKey: queryKeys.integrationsStatus(), - }); - }, - }); - - // Clear a parked integration via the resume endpoint. The CTA is - // rendered inside the parked banner below; success invalidates both - // the per-card status (so the pill flips back to connected - // immediately) and the cross-integration envelope (so any other view - // picks up the change on its next focus). - const resume = useMutation({ - mutationFn: async () => { - return apiPost<{ resumed: boolean; wasParked: boolean }>( - "/api/integrations/fitbit/resume", - ); - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: queryKeys.fitbit() }); - queryClient.invalidateQueries({ - queryKey: queryKeys.integrationsStatus(), - }); - }, - }); - - async function handleSync(fullSync = false) { - setSyncing(true); - setSyncResult(null); - try { - const res = await apiFetchRaw("/api/fitbit/sync", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ fullSync }), - }); - const json = await res.json(); - const result = res.ok ? readSyncOutcome(json) : null; - if (result) { - // The tone comes off what the run wrote, not off `res.ok`. - setSyncResult({ - outcome: result.outcome, - message: describeSyncOutcome( - result, - t("settings.fitbitSyncResult", { count: result.imported }), - ), - }); - void invalidateKeys(queryClient, measurementDependentKeys); - queryClient.invalidateQueries({ queryKey: queryKeys.fitbit() }); - queryClient.invalidateQueries({ - queryKey: queryKeys.integrationsStatus(), - }); - } else { - setSyncResult({ - outcome: "failed", - message: json?.error || t("settings.fitbitSyncFailed"), - }); - } - } catch { - setSyncResult({ - outcome: "failed", - message: t("settings.fitbitSyncFailed"), - }); - } finally { - setSyncing(false); - } - } - - async function handleSaveCredentials(e: React.FormEvent) { - e.preventDefault(); - setCredsSaving(true); - setCredsMsg(null); - setCredsMsgType(null); - - try { - const res = await apiFetchRaw("/api/fitbit/credentials", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - clientId: clientId.trim(), - clientSecret: clientSecret.trim(), - }), - }); - - if (res.ok) { - setCredsMsg(t("settings.fitbitCredentialsSaved")); - setCredsMsgType("success"); - setClientId(""); - setClientSecret(""); - queryClient.invalidateQueries({ queryKey: queryKeys.fitbit() }); - } else { - try { - const json = await res.json(); - setCredsMsg(json.error || t("settings.savingError")); - } catch { - setCredsMsg(t("settings.savingError")); - } - setCredsMsgType("error"); - } - } catch { - setCredsMsg(t("common.networkError")); - setCredsMsgType("error"); - } - setCredsSaving(false); - } - - // The server resolves the verdict; the card only projects it. `connected` - // rides into that resolution, so a disconnected provider still lands on the - // "Not connected" pill without a second local rule here. - const pillState = pillStateFor(viewModel); - const pillLastSyncAt = pillTimestampFor(viewModel); - const errorMessage = - (pillState === "error" || - pillState === "parked" || - pillState === "warning") && - viewModel?.lastError - ? viewModel.lastError - : null; - - return ( - - - {t("settings.fitbitTag")} - - {t("settings.fitbitExperimentalBadge")} - - - } - description={ - - } - status={ - - } - /> - -
    - {errorMessage && } - {/* Parked-integration resume CTA. Surfaces only when the row - state is `parked` (>24h of persistent failures). The button - POSTs to /api/integrations/fitbit/resume which calls - `resumeIntegrationFromPark`; on success the per-card status - invalidates and the pill flips back to connected without a - page refresh. */} - {pillState === "parked" && ( -
    - - {t("settings.integrationPill.parkedReconnect")} - - -
    - )} - {resume.isError && ( -

    - {t("settings.integrationPill.resumeError")} -

    - )} - - {status?.connected && ( - - )} - {resume.isSuccess && resume.data?.wasParked && ( -

    - {t("settings.integrationPill.resumeSuccess")} -

    - )} - -
    -

    - {t("settings.fitbitCredentials")} -

    - {!status?.configured && ( - - )} -
    -
    -
    - - setClientId(e.target.value)} - placeholder={ - status?.configured - ? t("settings.fitbitCredentialsSavedPlaceholder") - : t("settings.fitbitClientId") - } - maxLength={200} - autoComplete="off" - inputMode="text" - spellCheck={false} - autoCapitalize="none" - enterKeyHint="next" - /> -
    -
    - - setClientSecret(e.target.value)} - placeholder={ - status?.configured - ? t("settings.fitbitCredentialsSavedPlaceholderSecret") - : t("settings.fitbitClientSecret") - } - maxLength={200} - autoComplete="off" - inputMode="text" - spellCheck={false} - autoCapitalize="none" - enterKeyHint="done" - /> -
    -
    - - - - {credsMsg && ( -

    - {credsMsg} -

    - )} -
    -
    - - {status?.connected ? ( - <> -
    - - - - - - - - - {t("settings.fitbitFullSyncTitle")} - - - {t("settings.fitbitFullSyncDescription")} - - - - {t("common.cancel")} - handleSync(true)}> - {t("settings.fitbitSynchronize")} - - - - - - - - - - - - - {t("settings.fitbitDisconnectTitle")} - - - {t("settings.fitbitDisconnectDescription")} - - - - {t("common.cancel")} - disconnect.mutate()} - > - {t("settings.fitbitDisconnect")} - - - - -
    - {status?.backfillCompleted === false && ( -

    - {t("settings.fitbitBackfillInProgress")} -

    - )} - {syncResult && ( - - )} - {/* connect→data loop: a discreet link to where this provider's - readings now surface — doubles as the "your data is richer" - cue. */} - - {t("settings.fitbitViewData")} - - - - ) : status?.configured ? ( - - - - ) : ( -

    - {t("settings.fitbitNoCredentials")} -

    - )} -
    - -
    - ); -} diff --git a/src/components/settings/integrations/google-health-card.tsx b/src/components/settings/integrations/google-health-card.tsx index 336848694..273a33300 100644 --- a/src/components/settings/integrations/google-health-card.tsx +++ b/src/components/settings/integrations/google-health-card.tsx @@ -1,72 +1,32 @@ "use client"; -// v1.27.0 — Google Health (Fitbit + Pixel Watch + Fitbit Air) card. Reads -// Fitbit and Pixel Watch data through the successor Google Health API — a -// separate, coexisting integration from the classic `fitbit` transport, which -// sunsets Sept 2026. Mirrors the Fitbit card anatomy: a BYO Google-Cloud -// client-id/secret form first, then an OAuth connect, then the -// sync/test/disconnect action row + parked-resume banner. Status reads off the -// consolidated /api/integrations/status envelope (no per-card round-trip). +// Google Health (Fitbit + Pixel Watch + Fitbit Air), read through the +// successor Google Health API: a separate, coexisting integration from the +// classic `fitbit` transport, which sunsets in September 2026. // -// One thing this card carries that the classic Fitbit card does not: a distinct -// RE-CONSENT CTA. Google expires the refresh token after 7 days while the -// operator's OAuth client stays in "Testing" publishing mode (the CASA-free -// path), so a connected user is periodically pushed back through OAuth. When the -// envelope reports `needsReauth`, the card surfaces a clear "Reconnect" banner -// separate from the connect/disconnect state. +// It renders the shared OAuth integration card. What it adds is its own sync +// choreography: a manual sync may outlive its request, so the card polls the +// authenticated status route until the run settles, shows the run's progress +// while it does, and lists the resources that did not complete in the details. +// Google's 7-day refresh expiry in "Testing" publishing mode is no longer a +// banner of its own: the server reports it as `action_required` / +// `reauth_required`, and the card's one primary button reconnects. import { useState } from "react"; -import Link from "next/link"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { - ArrowRight, - Link2, - Loader2, - RefreshCw, - Save, - Unlink, - Watch, -} from "lucide-react"; +import { useQueryClient } from "@tanstack/react-query"; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, - AlertDialogTrigger, -} from "@/components/ui/alert-dialog"; -import { Badge } from "@/components/ui/badge"; -import { SettingsCardActions } from "@/components/settings/_card-actions"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { PasswordInput } from "@/components/ui/password-input"; -import { TagChip } from "@/components/ui/tag-chip"; -import { SettingsCard } from "@/components/settings/settings-card"; -import { SettingsCardHeader } from "@/components/settings/_card-header"; -import { IntegrationStatusPill } from "@/components/settings/integration-status-pill"; -import { TestConnectionButton } from "@/components/settings/test-connection-button"; -import { apiFetchRaw, apiPost } from "@/lib/api/api-fetch"; import { WrittenOutcomeLine } from "@/components/outcome/written-outcome-line"; +import { Badge } from "@/components/ui/badge"; +import { TagChip } from "@/components/ui/tag-chip"; +import { apiFetchRaw } from "@/lib/api/api-fetch"; import { useTranslations } from "@/lib/i18n/context"; +import type { IntegrationCallbackUrls } from "@/lib/integrations/callback-urls"; import { invalidateKeys, measurementDependentKeys, queryKeys, } from "@/lib/query-keys"; -import { - IntegrationErrorMessage, - pillFailurePropsFor, - pillStateFor, - pillTimestampFor, - type IntegrationStatusViewModel, -} from "./shared"; -import { MetricFreshnessDisclosure } from "./metric-freshness-disclosure"; import { completedGoogleHealthResourceCount, failedGoogleHealthResources, @@ -74,18 +34,17 @@ import { readGoogleHealthProgress, type GoogleHealthProgress, } from "./google-health-progress-view"; +import { + OAUTH_INTEGRATIONS, + OAuthIntegrationCardView, +} from "./oauth-integration-card"; +import type { IntegrationStatusViewModel } from "./shared"; import { readSyncOutcome, useSyncOutcomeMessage, type SyncOutcomeResource, type SyncOutcomeState, } from "./sync-outcome"; -import { - CallbackMismatchNotice, - IntegrationCardDescription, -} from "./setup-guide-link"; -import type { IntegrationCallbackUrls } from "@/lib/integrations/callback-urls"; -import { loadDocument } from "@/lib/navigation/load-document"; export function GoogleHealthCard({ viewModel, @@ -102,17 +61,8 @@ export function GoogleHealthCard({ const [syncProgress, setSyncProgress] = useState( null, ); - const [clientId, setClientId] = useState(""); - const [clientSecret, setClientSecret] = useState(""); - const [credsSaving, setCredsSaving] = useState(false); - const [credsMsg, setCredsMsg] = useState(null); - const [credsMsgType, setCredsMsgType] = useState<"success" | "error" | null>( - null, - ); const queryClient = useQueryClient(); - const status = viewModel; - function invalidateCommittedWorkout(resources: SyncOutcomeResource[]) { const workoutCommitted = resources.some( (resource) => @@ -216,36 +166,6 @@ export function GoogleHealthCard({ } } - const disconnect = useMutation({ - mutationFn: async () => { - await apiPost("/api/google-health/disconnect"); - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: queryKeys.googleHealth() }); - queryClient.invalidateQueries({ - queryKey: queryKeys.integrationsStatus(), - }); - }, - }); - - // Clear a parked integration via the resume endpoint. The CTA is rendered - // inside the parked banner below; success invalidates both the per-card - // status and the cross-integration envelope so any other view picks up the - // change on its next focus. - const resume = useMutation({ - mutationFn: async () => { - return apiPost<{ resumed: boolean; wasParked: boolean }>( - "/api/integrations/google-health/resume", - ); - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: queryKeys.googleHealth() }); - queryClient.invalidateQueries({ - queryKey: queryKeys.integrationsStatus(), - }); - }, - }); - async function handleSync(fullSync = false) { setSyncing(true); setSyncResult(null); @@ -335,360 +255,48 @@ export function GoogleHealthCard({ } } - async function handleSaveCredentials(e: React.FormEvent) { - e.preventDefault(); - setCredsSaving(true); - setCredsMsg(null); - setCredsMsgType(null); - - try { - const res = await apiFetchRaw("/api/google-health/credentials", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - clientId: clientId.trim(), - clientSecret: clientSecret.trim(), - }), - }); - - if (res.ok) { - setCredsMsg(t("settings.googleHealthCredentialsSaved")); - setCredsMsgType("success"); - setClientId(""); - setClientSecret(""); - queryClient.invalidateQueries({ queryKey: queryKeys.googleHealth() }); - } else { - try { - const json = await res.json(); - setCredsMsg(json.error || t("settings.savingError")); - } catch { - setCredsMsg(t("settings.savingError")); - } - setCredsMsgType("error"); - } - } catch { - setCredsMsg(t("common.networkError")); - setCredsMsgType("error"); - } - setCredsSaving(false); - } - - // The server resolves the verdict; the card only projects it. `connected` - // rides into that resolution, so a disconnected provider still lands on the - // "Not connected" pill without a second local rule here. - const pillState = pillStateFor(viewModel); - const pillLastSyncAt = pillTimestampFor(viewModel); - const errorMessage = - (pillState === "error" || - pillState === "parked" || - pillState === "warning") && - viewModel?.lastError - ? viewModel.lastError - : null; - // The re-consent banner only makes sense while the connection still exists — - // a disconnected card has no token to renew. `needsReauth` is the 7-day - // Testing-mode expiry (or a revoked grant) surfaced by the status route. - const showReauth = Boolean(status?.connected && status?.needsReauth); const resourceFailures = failedGoogleHealthResources(syncProgress); const completedResources = completedGoogleHealthResourceCount(syncProgress); + const inProgress = syncProgress?.state === "in_progress"; return ( - - - {t("settings.googleHealthTag")} - - {t("settings.googleHealthBetaBadge")} - - - } - description={ - - } - status={ - - } - /> - -
    - {errorMessage && } - - {/* Re-consent CTA — distinct from parked/disconnected. Google expires - the refresh token after 7 days in "Testing" publishing mode (the - CASA-free path), so a connected user is periodically pushed back - through OAuth. Re-running connect mints a fresh grant. */} - {showReauth && ( -
    + {t("settings.googleHealthTag")} + - - {t("settings.googleHealthReauthBanner")} - - -
    - )} - - {/* Parked-integration resume CTA — surfaces only when the row state is - `parked` (>24h of persistent failures). */} - {pillState === "parked" && ( -
    - - {t("settings.integrationPill.parkedReconnect")} - - -
    - )} - {resume.isError && ( -

    - {t("settings.integrationPill.resumeError")} -

    - )} - - {status?.connected && ( - - )} - {resume.isSuccess && resume.data?.wasParked && ( -

    - {t("settings.integrationPill.resumeSuccess")} -

    - )} - -
    -

    - {t("settings.googleHealthCredentials")} -

    -
    -
    -
    - - setClientId(e.target.value)} - placeholder={ - status?.configured - ? t("settings.googleHealthCredentialsSavedPlaceholder") - : t("settings.googleHealthClientId") - } - maxLength={200} - autoComplete="off" - inputMode="text" - spellCheck={false} - autoCapitalize="none" - enterKeyHint="next" - /> -
    -
    - - setClientSecret(e.target.value)} - placeholder={ - status?.configured - ? t( - "settings.googleHealthCredentialsSavedPlaceholderSecret", - ) - : t("settings.googleHealthClientSecret") - } - maxLength={200} - autoComplete="off" - inputMode="text" - spellCheck={false} - autoCapitalize="none" - enterKeyHint="done" - /> -
    -
    - - - - {credsMsg && ( -

    - {credsMsg} -

    - )} -
    -
    - - {status?.connected ? ( - <> -
    - - - - - - - - - {t("settings.googleHealthFullSyncTitle")} - - - {t("settings.googleHealthFullSyncDescription")} - - - - {t("common.cancel")} - handleSync(true)}> - {t("settings.googleHealthSynchronize")} - - - - - - - - - - - - - {t("settings.googleHealthDisconnectTitle")} - - - {t("settings.googleHealthDisconnectDescription")} - - - - {t("common.cancel")} - disconnect.mutate()} - > - {t("settings.googleHealthDisconnect")} - - - - -
    - {status?.backfillCompleted === false && ( -

    - {t("settings.googleHealthBackfillInProgress")} -

    - )} - {syncProgress?.state === "in_progress" && ( + {t("settings.googleHealthBetaBadge")} + + + } + sync={{ + syncing, + result: syncResult, + run: (fullSync) => { + if (!fullSync && syncProgress?.state === "in_progress") { + void refreshSyncStatus(); + } else { + void handleSync(fullSync); + } + }, + }} + syncLabel={ + inProgress + ? t("settings.googleHealthSyncCheckStatus") + : t("settings.googleHealthSync") + } + fullSyncDisabled={inProgress} + syncResults={ + inProgress || syncResult ? ( +
    + {inProgress ? (

    - )} - {syncResult && ( + ) : null} + {syncResult ? ( - )} - {resourceFailures.length > 0 && ( -

    -

    - {t("settings.googleHealthResourceFailuresTitle")} -

    -
      - {resourceFailures.map((resource) => ( -
    • - - {resourceLabel(resource.resource)} - - {": "} - {resourceFailureMessage(resource)} -
    • - ))} -
    -
    - )} - {/* connect→data loop: a discreet link to where this provider's - readings now surface — doubles as the "your data is richer" - cue. */} - - {t("settings.googleHealthViewData")} - - - - ) : status?.configured ? ( - - - - ) : ( -

    + ) : null + } + details={ + resourceFailures.length > 0 ? ( +

    - {t("settings.googleHealthNoCredentials")} -

    - )} -
    - - +

    + {t("settings.googleHealthResourceFailuresTitle")} +

    +
      + {resourceFailures.map((resource) => ( +
    • + + {resourceLabel(resource.resource)} + + {": "} + {resourceFailureMessage(resource)} +
    • + ))} +
    +
    + ) : undefined + } + /> ); } diff --git a/src/components/settings/integrations/integration-actions.tsx b/src/components/settings/integrations/integration-actions.tsx new file mode 100644 index 000000000..797813c35 --- /dev/null +++ b/src/components/settings/integrations/integration-actions.tsx @@ -0,0 +1,208 @@ +"use client"; + +/** + * The requests behind an integration card's menu, shared by every provider + * controller: sync now (optionally the whole history), test the connection, + * and the lines that report what happened. The card renders the lines in its + * body; the menu only starts the request. + */ + +import { useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { CheckCircle2, Loader2 } from "lucide-react"; + +import { WrittenOutcomeLine } from "@/components/outcome/written-outcome-line"; +import { + runConnectionTest, + TestConnectionFailure, + type ConnectionTestResult, +} from "@/components/settings/test-connection-button"; +import { apiFetchRaw } from "@/lib/api/api-fetch"; +import { useTranslations } from "@/lib/i18n/context"; +import { + invalidateKeys, + measurementDependentKeys, + queryKeys, +} from "@/lib/query-keys"; + +import { + readSyncOutcome, + useSyncOutcomeMessage, + type SyncOutcomeState, +} from "./sync-outcome"; + +export interface ProviderSync { + syncing: boolean; + result: SyncOutcomeState | null; + run: (fullSync: boolean) => void; +} + +/** + * Sync now, for every provider whose sync route answers the shared written + * outcome. The tone comes off what the run wrote, never off `res.ok`. + */ +export function useStandardSync({ + provider, + i18nPrefix, + acceptsFullSync, + fullSyncResult, + statusQueryKey, +}: { + /** Route segment: POST `/api//sync`. */ + provider: string; + i18nPrefix: string; + /** The route takes `{ fullSync }`; the others take no body. */ + acceptsFullSync: boolean; + /** A full sync has its own success sentence (`…FullSyncResult`). */ + fullSyncResult?: boolean; + statusQueryKey?: readonly unknown[]; +}): ProviderSync { + const { t } = useTranslations(); + const describeSyncOutcome = useSyncOutcomeMessage(); + const queryClient = useQueryClient(); + const [syncing, setSyncing] = useState(false); + const [syncResult, setSyncResult] = useState(null); + + async function run(fullSync: boolean) { + setSyncing(true); + setSyncResult(null); + try { + const res = await apiFetchRaw( + `/api/${provider}/sync`, + acceptsFullSync + ? { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ fullSync }), + } + : { method: "POST" }, + ); + const json = await res.json(); + const result = res.ok ? readSyncOutcome(json) : null; + if (result) { + setSyncResult({ + outcome: result.outcome, + message: describeSyncOutcome( + result, + fullSync && fullSyncResult + ? t(`${i18nPrefix}FullSyncResult`, { count: result.imported }) + : t(`${i18nPrefix}SyncResult`, { count: result.imported }), + ), + }); + void invalidateKeys(queryClient, measurementDependentKeys); + if (statusQueryKey) { + queryClient.invalidateQueries({ queryKey: statusQueryKey }); + } + queryClient.invalidateQueries({ + queryKey: queryKeys.integrationsStatus(), + }); + } else { + setSyncResult({ + outcome: "failed", + message: json?.error || t(`${i18nPrefix}SyncFailed`), + }); + } + } catch { + setSyncResult({ + outcome: "failed", + message: t(`${i18nPrefix}SyncFailed`), + }); + } finally { + setSyncing(false); + } + } + + return { + syncing, + result: syncResult, + run: (fullSync) => void run(fullSync), + }; +} + +/** "Test connection" from the menu; the result shows in the card body. */ +export function useConnectionTest(endpoint: string) { + const [testing, setTesting] = useState(false); + const [result, setResult] = useState(null); + async function run() { + setTesting(true); + setResult(null); + try { + setResult(await runConnectionTest(endpoint)); + } finally { + setTesting(false); + } + } + return { testing, result, run: () => void run() }; +} + +/** + * What the last menu action reported: a running sync or test, the sync's + * written outcome, the test's answer. Renders nothing when nothing ran. + */ +export function ActionResultLines({ + provider, + sync, + test, +}: { + provider: string; + sync?: Pick; + test?: ReturnType; +}) { + const { t } = useTranslations(); + + function describeError(errorCode: string): string { + const key = `settings.testConnection.errors.${errorCode}`; + const translated = t(key); + return translated === key + ? t("settings.testConnection.errors.generic") + : translated; + } + + const busy = sync?.syncing || test?.testing; + const syncResult = sync?.result ?? null; + if (!busy && !syncResult && !test?.result) return null; + + return ( +
    + {busy ? ( +

    +

    + ) : null} + {syncResult ? ( + + ) : null} + {test?.result?.kind === "ok" ? ( +

    +

    + ) : null} + {test?.result?.kind === "error" ? ( + + ) : null} +
    + ); +} diff --git a/src/components/settings/integrations/integration-card.tsx b/src/components/settings/integrations/integration-card.tsx new file mode 100644 index 000000000..3a6cf059f --- /dev/null +++ b/src/components/settings/integrations/integration-card.tsx @@ -0,0 +1,608 @@ +"use client"; + +/** + * `` — the one card every connection in Settings renders. + * + * A card says three things: whether it works (a badge with one word), what + * that means (one sentence), and what the person can do (at most one button). + * Everything else is either diagnostics, collapsed under "Details for a bug + * report", or rarely needed, in the three-dot menu. + * + * The card renders the server's `health` and nothing else. It does not read a + * ledger state, a verdict, a token expiry or a failure counter, and it never + * decides a state of its own: the badge, the sentence and the button are each + * a projection of one `ConnectionHealth`. Per-provider controllers (OAuth, + * Nightscout, Apple Health) own the requests and hand the card their menu + * entries, their primary handlers and anything that belongs in the details. + * + * Anatomy, top to bottom (design standards §1, §12, §13): + * header `SettingsCardHeader`, the badge in its status slot + * sentence `text-sm` foreground, then one muted meta line + * body transient lines a controller adds (a sync result, a form) + * details collapsed, always present on a connected card + * actions `SettingsCardActions`, the last element: menu left, at most + * one primary right + */ + +import { useState, type ReactNode } from "react"; +import { + Check, + ChevronDown, + Copy, + ExternalLink, + Link2, + Loader2, + MoreHorizontal, + Play, + type LucideIcon, +} from "lucide-react"; + +import { SettingsCardActions } from "@/components/settings/_card-actions"; +import { SettingsCardHeader } from "@/components/settings/_card-header"; +import { SettingsCard } from "@/components/settings/settings-card"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { ConfirmDialog } from "@/components/ui/confirm-button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Skeleton } from "@/components/ui/skeleton"; +import { useFormatters, useTranslations } from "@/lib/i18n/context"; +import type { + ConnectionHealth, + ConnectionHealthState, +} from "@/lib/integrations/connection-health"; +import { cn } from "@/lib/utils"; + +import { + diagnosticsText, + formatHealthInstant, + healthBadgeLabel, + healthSentence, + unavailableItemLabel, +} from "./integration-health-copy"; + +/** The app version the build stamped; empty in a dev build. */ +const APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || ""; + +/** + * The three calm states take the status token on a light wash. "Action + * needed" is the solid destructive badge: red text on a red wash falls below + * AA in both themes, and this is the one state that should stand out anyway. + */ +const BADGE_TONE: Record = { + ok: "border-success/30 bg-success/15 text-success", + limited: "border-info/30 bg-info/15 text-info", + degraded: "border-warning/30 bg-warning/15 text-warning", + action_required: "", +}; + +/** The status badge: a dot and one word. The word is the accessible name. */ +export function HealthBadge({ state }: { state: ConnectionHealthState }) { + const { t } = useTranslations(); + return ( + + + ); +} + +export interface IntegrationMenuAction { + /** Stable id; becomes `data-action` and the `-menu-` testid. */ + id: string; + label: string; + icon: LucideIcon; + onSelect?: () => void; + /** Navigates instead of acting. External links open in a new tab. */ + href?: string; + external?: boolean; + disabled?: boolean; + destructive?: boolean; + /** Ask first; the action runs from the dialog's confirm button. */ + confirm?: { title: string; body: string; confirmLabel: string }; +} + +export interface IntegrationPrimaryHandlers { + onReconnect?: () => void; + onResume?: () => void; + pending?: boolean; + disabled?: boolean; +} + +export interface IntegrationCardProps { + /** Connection key; drives `data-integration` and the testids. */ + provider: string; + /** Provider name used inside sentences and the diagnostics. */ + providerName: string; + icon: LucideIcon; + title?: ReactNode; + titleAccessory?: ReactNode; + /** One meta sentence with the setup-guide link; shown while not connected. */ + description?: ReactNode; + /** The status has not loaded yet. */ + loading?: boolean; + connected: boolean; + /** The server-resolved health; null when the server has none to give. */ + health: ConnectionHealth | null; + /** Server fact: older history is still loading in the background. */ + historyLoading?: boolean; + /** Apple Health's "open HealthLog on your iPhone" sentence variant. */ + appleHealth?: boolean; + primary?: IntegrationPrimaryHandlers; + menu?: IntegrationMenuAction[]; + /** Provider extras for the details section (lists, delivery facts). */ + details?: ReactNode; + /** Extra redacted lines for the copied diagnostics. */ + diagnosticsExtra?: readonly string[]; + /** Lines below the sentence: results, errors, an opened form. */ + children?: ReactNode; + /** Not connected: the forms and guides that come before connecting. */ + setup?: ReactNode; + /** Not connected: the one primary button, usually "Connect". */ + connectAction?: ReactNode; + /** Overrides the `-card` testid (the read-only fallback row). */ + testId?: string; +} + +export function IntegrationCard({ + provider, + providerName, + icon, + title, + titleAccessory, + description, + loading = false, + connected, + health, + historyLoading, + appleHealth, + primary, + menu = [], + details, + diagnosticsExtra, + children, + setup, + connectAction, + testId, +}: IntegrationCardProps) { + const { t, locale } = useTranslations(); + const fmt = useFormatters(); + const [detailsOpen, setDetailsOpen] = useState(false); + const detailsId = `${provider}-integration-details`; + + const cardState = loading + ? "loading" + : !connected + ? "not_connected" + : (health?.state ?? "unknown"); + + const header = ( + + ) : null + } + /> + ); + + const shell = (body: ReactNode) => ( + + {header} + {body} + + ); + + if (loading) { + // A loading state paints the card and its header (§13), so the page does + // not move when the status lands. + return shell( +
    + + +
    , + ); + } + + if (!connected) { + return shell( + <> +

    + {t("settings.integrationCard.notConnected")} +

    + {setup} + {children} + {connectAction ? ( + + {connectAction} + + ) : null} + , + ); + } + + const sentence = health + ? healthSentence(health, t, { + provider: providerName, + historyLoading, + appleHealth, + locale, + fmt, + }) + : null; + const lastData = health + ? health.lastDataAt + ? t("settings.integrationCard.lastData", { + when: formatHealthInstant(health.lastDataAt, t, fmt), + }) + : t("settings.integrationCard.lastDataNone") + : null; + + const action = health?.action ?? null; + const primaryButton = + action === "reconnect" && primary?.onReconnect ? ( + + ) : action === "resume" && primary?.onResume ? ( + + ) : null; + + return shell( + <> + {sentence || lastData ? ( +
    + {sentence ? ( +

    + {sentence} +

    + ) : null} + {lastData ? ( +

    + {lastData} +

    + ) : null} +
    + ) : null} + {children} + {health ? ( + + unavailableItemLabel(u.item, t), + )} + > + {details} + + ) : details ? ( +
    {details}
    + ) : null} + {health || menu.length > 0 || primaryButton ? ( + + {health ? ( + + ) : null} +
    + {menu.length > 0 ? ( + + ) : null} + {primaryButton} +
    +
    + ) : null} + , + ); +} + +function PrimaryButton({ + action, + icon: Icon, + label, + onClick, + pending, + disabled, + provider, +}: { + action: "reconnect" | "resume"; + icon: LucideIcon; + label: string; + onClick: () => void; + pending?: boolean; + disabled?: boolean; + provider: string; +}) { + return ( + + ); +} + +/** + * The rare actions. A destructive or heavy entry asks first through the same + * confirmation the card used before; the dialog is controlled here because a + * menu item closes its menu, and with it any dialog nested inside. + */ +function IntegrationMenu({ + provider, + providerName, + items, +}: { + provider: string; + providerName: string; + items: IntegrationMenuAction[]; +}) { + const { t } = useTranslations(); + const [confirming, setConfirming] = useState( + null, + ); + + return ( + <> + + + + + + {items.map((item) => { + const Icon = item.icon; + const common = { + "data-slot": "integration-menu-item", + "data-action": item.id, + "data-testid": `${provider}-menu-${item.id}`, + disabled: item.disabled, + variant: item.destructive + ? ("destructive" as const) + : ("default" as const), + }; + if (item.href) { + return ( + + + + + ); + } + return ( + { + if (item.confirm) setConfirming(item); + else item.onSelect?.(); + }} + > + + ); + })} + + + {confirming?.confirm ? ( + { + if (!open) setConfirming(null); + }} + title={confirming.confirm.title} + body={confirming.confirm.body} + confirmLabel={confirming.confirm.confirmLabel} + destructive={confirming.destructive} + slot={`${provider}-${confirming.id}`} + onConfirm={() => { + const run = confirming.onSelect; + setConfirming(null); + run?.(); + }} + /> + ) : null} + + ); +} + +/** + * "Details for a bug report": collapsed by default and present in every state, + * so a report always looks the same. Its toggle sits at the start of the + * action row and the panel opens above that row, which stays the last thing in + * the card. The monospace block is exactly the text the copy button places on + * the clipboard. The panel is always in the markup and `hidden` while closed, + * so what a report would contain is inspectable without opening it. + */ +function DetailsPanel({ + id, + open, + text, + unavailable, + children, +}: { + id: string; + open: boolean; + text: string; + unavailable: string[]; + children?: ReactNode; +}) { + const { t } = useTranslations(); + const [copy, setCopy] = useState<"idle" | "copied" | "failed">("idle"); + + async function handleCopy() { + try { + await navigator.clipboard.writeText(text); + setCopy("copied"); + window.setTimeout(() => setCopy("idle"), 2000); + } catch { + setCopy("failed"); + } + } + + return ( + + ); +} diff --git a/src/components/settings/integrations/integration-fallback-row.tsx b/src/components/settings/integrations/integration-fallback-row.tsx index c18f6c55a..10cbc002d 100644 --- a/src/components/settings/integrations/integration-fallback-row.tsx +++ b/src/components/settings/integrations/integration-fallback-row.tsx @@ -20,21 +20,10 @@ import { Plug } from "lucide-react"; -import { SettingsCard } from "@/components/settings/settings-card"; -import { SettingsCardHeader } from "@/components/settings/_card-header"; -import { - formatRelative, - IntegrationStatusPill, -} from "@/components/settings/integration-status-pill"; import { useTranslations } from "@/lib/i18n/context"; -import { - pillFailurePropsFor, - pillStateFor, - pillTimestampFor, - type IntegrationKey, - type IntegrationStatusViewModel, -} from "./shared"; +import { IntegrationCard } from "./integration-card"; +import type { IntegrationKey, IntegrationStatusViewModel } from "./shared"; /** * Display names, mirroring the server-side `INTEGRATION_LABELS`. Only the @@ -52,50 +41,28 @@ export const INTEGRATION_DISPLAY_NAMES: Record = { strava: "Strava", }; +/** + * The same card every integration renders, read-only: the server's health, + * its sentence and its details, and no menu, because a dropped integration + * does not get its management surface back. + */ export function IntegrationFallbackRow({ status, - now, }: { status: IntegrationStatusViewModel; - /** Override "now" for deterministic testing. */ - now?: Date; }) { const { t } = useTranslations(); - - const pillState = pillStateFor(status); - const reference = now ?? new Date(); - const lastAttemptAt = - status.lastAttemptAt ?? status.syncHealth?.since ?? null; - const meta = lastAttemptAt - ? t("settings.integrationFallback.lastAttempt", { - relative: formatRelative( - Math.max(0, reference.getTime() - new Date(lastAttemptAt).getTime()), - t, - ), - }) - : t("settings.integrationFallback.noAttempt"); - + const name = + INTEGRATION_DISPLAY_NAMES[status.integration] ?? status.integration; return ( - - - } - /> -

    {meta}

    -
    + ); } diff --git a/src/components/settings/integrations/integration-health-copy.ts b/src/components/settings/integrations/integration-health-copy.ts new file mode 100644 index 000000000..f9340dd4e --- /dev/null +++ b/src/components/settings/integrations/integration-health-copy.ts @@ -0,0 +1,358 @@ +/** + * The words an integration card says about a connection's health. + * + * The server resolves one `ConnectionHealth` per connection and the card only + * renders it, so everything here is a projection: a badge word per state, one + * sentence per reason, a relative instant, and the plain-text diagnostics a + * person can paste into a bug report. Nothing in this module decides a state. + * + * Pure (a translator and the locale formatters are passed in) so every + * sentence and the copied diagnostics are unit-testable without a renderer. + */ +import type { + ConnectionHealth, + ConnectionHealthState, + UnavailableItem, +} from "@/lib/integrations/connection-health"; +import { pluralKey } from "@/lib/i18n/plural"; +import type { Locale } from "@/lib/i18n/config"; + +type Translate = ( + key: string, + params?: Record, +) => string; + +interface InstantFormatters { + dateShortSmart: (value: Date) => string; + time: (value: Date) => string; +} + +const DAY_MS = 24 * 60 * 60 * 1000; + +/** The one word in the status badge. */ +export function healthBadgeLabel( + state: ConnectionHealthState, + t: Translate, +): string { + switch (state) { + case "ok": + return t("settings.integrationCard.state.ok"); + case "limited": + return t("settings.integrationCard.state.limited"); + case "degraded": + return t("settings.integrationCard.state.degraded"); + case "action_required": + return t("settings.integrationCard.state.actionRequired"); + } +} + +/** + * An instant as a person reads it: "today, 07:12", "yesterday, 18:40", or the + * date with its time. The day comparison goes through the same zone-aware + * formatter that prints the date, so the boundary and the printed day can + * never disagree. + */ +export function formatHealthInstant( + iso: string, + t: Translate, + fmt: InstantFormatters, + now: Date = new Date(), +): string { + const at = new Date(iso); + if (Number.isNaN(at.getTime())) return ""; + const day = fmt.dateShortSmart(at); + const time = fmt.time(at); + if (day === fmt.dateShortSmart(now)) { + return t("settings.integrationCard.when.today", { time }); + } + if (day === fmt.dateShortSmart(new Date(now.getTime() - DAY_MS))) { + return t("settings.integrationCard.when.yesterday", { time }); + } + return t("settings.integrationCard.when.date", { date: day, time }); +} + +/** + * A readable name for a data type the provider withholds. Written as literal + * keys so the i18n coverage guards see each one; an unknown key degrades to a + * readable form of itself rather than leaking `snake_case`. + */ +export function unavailableItemLabel(item: string, t: Translate): string { + switch (item) { + case "spo2": + return t("settings.integrations.withings.plan.items.spo2"); + case "vascularAge": + return t("settings.integrations.withings.plan.items.vascularAge"); + case "pulseWaveVelocity": + return t("settings.integrations.withings.plan.items.pulseWaveVelocity"); + case "vo2Max": + return t("settings.integrations.withings.plan.items.vo2Max"); + case "visceralFat": + return t("settings.integrations.withings.plan.items.visceralFat"); + case "skinTemperature": + return t("settings.integrations.withings.plan.items.skinTemperature"); + case "ecg": + return t("settings.integrations.withings.plan.items.ecg"); + case "sleepScore": + return t("settings.integrations.withings.plan.items.sleepScore"); + case "sleepHrv": + return t("settings.integrations.withings.plan.items.sleepHrv"); + case "sleepHeartRate": + return t("settings.integrations.withings.plan.items.sleepHeartRate"); + case "sleepRespiratoryRate": + return t( + "settings.integrations.withings.plan.items.sleepRespiratoryRate", + ); + case "sleepSpo2": + return t("settings.integrations.withings.plan.items.sleepSpo2"); + case "bodyTemperature": + return t("settings.integrations.withings.plan.items.bodyTemperature"); + case "bodyFat": + return t("settings.integrations.withings.plan.items.bodyFat"); + case "fatMass": + return t("settings.integrations.withings.plan.items.fatMass"); + case "fatFreeMass": + return t("settings.integrations.withings.plan.items.fatFreeMass"); + case "muscleMass": + return t("settings.integrations.withings.plan.items.muscleMass"); + case "bodyWater": + return t("settings.integrations.withings.plan.items.bodyWater"); + case "boneMass": + return t("settings.integrations.withings.plan.items.boneMass"); + case "heartRate": + return t("settings.integrations.withings.plan.items.heartRate"); + case "bloodPressure": + return t("settings.integrations.withings.plan.items.bloodPressure"); + case "weight": + return t("settings.integrations.withings.plan.items.weight"); + case "vo2max": + return t("settings.integrations.oura.scope.items.vo2max"); + case "cardio_age": + return t("settings.integrations.oura.scope.items.cardio_age"); + case "resilience": + return t("settings.integrations.oura.scope.items.resilience"); + case "activity": + return t("settings.integrationCard.items.activity"); + case "sleep": + return t("settings.integrationCard.items.sleep"); + default: { + const words = item + .replace(/([a-z])([A-Z])/g, "$1 $2") + .replace(/[_-]+/g, " ") + .toLowerCase(); + return words; + } + } +} + +/** + * At most three names in a sentence. Up to three types are all named; more + * than three name the first two and count the rest ("SpO2, vascular age and + * 3 more"), and the full list lives in the details. + */ +export function namedItemList( + items: readonly UnavailableItem[], + t: Translate, + locale: Locale, +): string { + const labels = items.map((u) => unavailableItemLabel(u.item, t)); + const parts = + labels.length <= 3 + ? labels + : [ + ...labels.slice(0, 2), + t( + pluralKey( + "settings.integrationCard.moreValues", + labels.length - 2, + locale, + ), + { count: labels.length - 2 }, + ), + ]; + try { + return new Intl.ListFormat(locale, { + style: "long", + type: "conjunction", + }).format(parts); + } catch { + return parts.join(", "); + } +} + +export interface HealthSentenceOptions { + /** Provider name as the person knows it ("Oura", "Withings"). */ + provider: string; + /** Older history is still loading (a server fact, e.g. backfill pending). */ + historyLoading?: boolean; + /** Apple Health: data only moves when the iPhone app runs. */ + appleHealth?: boolean; + locale: Locale; + fmt: InstantFormatters; + now?: Date; +} + +/** The one sentence a card says under its header. */ +export function healthSentence( + health: ConnectionHealth, + t: Translate, + options: HealthSentenceOptions, +): string { + const { provider, locale, fmt } = options; + const since = health.since + ? formatHealthInstant(health.since, t, fmt, options.now) + : null; + const ofCause = (cause: UnavailableItem["cause"]) => + namedItemList( + health.unavailable.filter((u) => u.cause === cause), + t, + locale, + ); + + switch (health.reason) { + case "data_arriving": + return options.historyLoading + ? t("settings.integrationCard.reason.dataArrivingHistory") + : t("settings.integrationCard.reason.dataArriving"); + case "first_sync_pending": + return t("settings.integrationCard.reason.firstSyncPending"); + case "plan_limited": + return t("settings.integrationCard.reason.planLimited", { + provider, + list: ofCause("plan"), + }); + case "permission_limited": + return t("settings.integrationCard.reason.permissionLimited", { + list: ofCause("permission"), + }); + case "provider_errors": + return since + ? t("settings.integrationCard.reason.providerErrors", { + provider, + since, + }) + : t("settings.integrationCard.reason.providerErrorsNoSince", { + provider, + }); + case "sync_stalled": + return since + ? t("settings.integrationCard.reason.syncStalled", { since }) + : t("settings.integrationCard.reason.syncStalledNoSince"); + case "no_new_data": + if (options.appleHealth) { + return since + ? t("settings.integrationCard.reason.noNewDataApple", { since }) + : t("settings.integrationCard.reason.noNewDataAppleNoSince"); + } + return since + ? t("settings.integrationCard.reason.noNewData", { since }) + : t("settings.integrationCard.reason.noNewDataNoSince"); + case "unexpected_silence": + return since + ? t("settings.integrationCard.reason.unexpectedSilence", { + provider, + since, + }) + : t("settings.integrationCard.reason.unexpectedSilenceNoSince", { + provider, + }); + case "reauth_required": + return t("settings.integrationCard.reason.reauthRequired", { provider }); + case "paused_after_errors": + return since + ? t("settings.integrationCard.reason.pausedAfterErrors", { + provider, + since, + }) + : t("settings.integrationCard.reason.pausedAfterErrorsNoSince", { + provider, + }); + } +} + +/** `2026-10-14T18:40:12.000Z` → `2026-10-14 18:40 UTC`. */ +function utc(iso: string | null): string | null { + if (!iso) return null; + const at = new Date(iso); + if (Number.isNaN(at.getTime())) return null; + return `${at.toISOString().slice(0, 16).replace("T", " ")} UTC`; +} + +export interface DiagnosticsInput { + /** The running app version; empty when the build did not stamp one. */ + version: string; + provider: string; + health: ConnectionHealth; + /** Extra lines the card adds, already redacted (a failure streak count). */ + extra?: readonly string[]; +} + +/** + * The plain-text diagnostics, exactly as the details block shows them and the + * copy button places them on the clipboard. + * + * Built only from `health`, whose diagnostics the server already reduced to + * leg names, outcomes, instants and short error codes. No measured value, no + * token, no account id and no provider error message can reach this text, + * because none of them is on the input. + */ +export function diagnosticsText(input: DiagnosticsInput, t: Translate): string { + const { health } = input; + const none = t("settings.integrationCard.details.none"); + const lines: string[] = []; + lines.push( + `${["HealthLog", input.version].filter(Boolean).join(" ")} ${input.provider}`, + ); + const since = utc(health.since); + lines.push( + `${t("settings.integrationCard.details.state")}: ${health.state} / ${health.reason}${ + since ? ` ${t("settings.integrationCard.details.since")} ${since}` : "" + }`, + ); + lines.push( + `${t("settings.integrationCard.details.lastSync")}: ${utc(health.lastSuccessAt) ?? none}`, + ); + lines.push( + `${t("settings.integrationCard.details.lastData")}: ${utc(health.lastDataAt) ?? none}`, + ); + const d = health.diagnostics; + if (d.ledgerState || d.failingLegs.length > 0) { + const legs = + d.failingLegs.length > 0 + ? `, ${t("settings.integrationCard.details.failingParts")}: ${d.failingLegs.join(", ")}` + : ""; + lines.push(`Ledger: ${d.ledgerState ?? none}${legs}`); + } + for (const line of input.extra ?? []) lines.push(line); + if (d.recentCalls.length > 0) { + lines.push(`${t("settings.integrationCard.details.recentCalls")}:`); + const width = Math.max(...d.recentCalls.map((c) => c.endpoint.length)); + for (const call of d.recentCalls) { + const at = (utc(call.at) ?? "").replace(" UTC", ""); + const row = [ + at, + call.endpoint.padEnd(width), + call.status.padEnd(15), + call.errorCode ?? "", + ] + .join(" ") + .trimEnd(); + lines.push(` ${row}`); + } + } + if (health.unavailable.length > 0) { + const items = health.unavailable + .map( + (u) => + `${u.item} (${ + u.cause === "plan" + ? t("settings.integrationCard.details.causePlan") + : t("settings.integrationCard.details.causePermission") + })`, + ) + .join(", "); + lines.push( + `${t("settings.integrationCard.details.unavailable")}: ${items}`, + ); + } + return lines.join("\n"); +} diff --git a/src/components/settings/integrations/metric-freshness-disclosure.tsx b/src/components/settings/integrations/metric-freshness-list.tsx similarity index 55% rename from src/components/settings/integrations/metric-freshness-disclosure.tsx rename to src/components/settings/integrations/metric-freshness-list.tsx index 54a23864e..6fa69b335 100644 --- a/src/components/settings/integrations/metric-freshness-disclosure.tsx +++ b/src/components/settings/integrations/metric-freshness-list.tsx @@ -10,18 +10,17 @@ * an upstream that quietly stopped returning one data type. The integration * pill cannot express it, because the integration itself is fine. * - * Collapsed by default: a Withings or Google Health user carries 15-25 types - * and an always-open list would bury the card's actions. The collapsed summary - * still names how many types have gone quiet, so the signal never hides behind - * a click. + * It lives in the card's collapsed details: a Withings or Google Health user + * carries 15-25 types and an always-open list would bury the card. The summary + * line above the rows still names how many types have gone quiet. * * Honest absence is structural: the server only reports `(source, type)` pairs * that have rows, so a type a provider never delivered simply is not here. No * "expected but missing" row is invented. */ -import { useState, type ReactNode } from "react"; -import { AlertTriangle, ChevronDown } from "lucide-react"; +import type { ReactNode } from "react"; +import { AlertTriangle } from "lucide-react"; import { formatRelative } from "@/components/settings/integration-status-pill"; import { MEASUREMENT_TYPE_LABEL_KEYS } from "@/components/measurements/measurement-list-meta"; @@ -31,6 +30,8 @@ import { cn } from "@/lib/utils"; /** The pseudo-type carrying a source's newest workout. */ const WORKOUT_TYPE = "WORKOUTS"; +/** The pseudo-type carrying Apple Health medication doses. */ +const MEDICATION_DOSE_TYPE = "MEDICATION_DOSE"; /** * Turn an unmapped enum name into something readable. `MEASUREMENT_TYPE_LABEL_KEYS` @@ -42,18 +43,15 @@ function humanise(type: string): string { return words.charAt(0).toUpperCase() + words.slice(1); } -export function MetricFreshnessDisclosure< +export function MetricFreshnessList< Entry extends MetricFreshnessEntry = MetricFreshnessEntry, >({ entries, - idPrefix, now, title, detail, }: { entries: Entry[] | undefined; - /** Namespaces the disclosure's ids so several cards can coexist. */ - idPrefix: string; /** Override "now" for deterministic testing. */ now?: Date; /** Replaces the shared "received data" heading. */ @@ -65,7 +63,6 @@ export function MetricFreshnessDisclosure< detail?: (entry: Entry) => ReactNode; }) { const { t } = useTranslations(); - const [open, setOpen] = useState(false); if (!entries?.length) return null; @@ -73,6 +70,8 @@ export function MetricFreshnessDisclosure< const label = (type: string): string => { if (type === WORKOUT_TYPE) return t("settings.integrationFreshness.workouts"); + if (type === MEDICATION_DOSE_TYPE) + return t("settings.integrationFreshness.medicationDoses"); const key = MEASUREMENT_TYPE_LABEL_KEYS[type]; return key ? t(key) : humanise(type); }; @@ -92,93 +91,76 @@ export function MetricFreshnessDisclosure< const newest = entries.reduce((max, entry) => entry.lastSeenAt > max.lastSeenAt ? entry : max, ); - const panelId = `${idPrefix}-metric-freshness-panel`; return (
    - +

    - {open && ( -
      - {sorted.map((entry) => { - const extra = detail?.(entry); - return ( -
    • -
      - - {entry.stale ? ( -
    • + ); + })} +
    ); } diff --git a/src/components/settings/integrations/nightscout-card.tsx b/src/components/settings/integrations/nightscout-card.tsx index 5e082e322..df7673edd 100644 --- a/src/components/settings/integrations/nightscout-card.tsx +++ b/src/components/settings/integrations/nightscout-card.tsx @@ -1,50 +1,36 @@ "use client"; -// v1.17.0 — Nightscout CGM integration card. Unlike WHOOP / Fitbit (OAuth, -// BYO-key), Nightscout is a URL + token the self-hoster pastes once: the user -// runs their own instance and HealthLog pulls continuous glucose off it. The -// card reads the consolidated `/api/integrations/status` envelope like every -// sibling — it was the last card firing its own status round-trip, and the -// last place a fourth status dialect could hide. Warm copy, mobile-first, the -// private-network access is operator-owned through an exact-origin server -// allowlist; ordinary users cannot weaken the outbound-request policy. +// Nightscout CGM. Unlike the OAuth providers, Nightscout is a URL and an +// optional token the self-hoster pastes once: they run their own instance and +// HealthLog pulls continuous glucose from it. The card reads the consolidated +// `/api/integrations/status` envelope like every sibling and renders the +// shared integration card. Private-network access is operator-owned through an +// exact-origin server allowlist; an ordinary user cannot weaken the +// outbound-request policy, and the card says so when that is what stands in +// the way. import { useRef, useState } from "react"; -import Link from "next/link"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { ArrowRight, + BookOpen, Droplet, + KeyRound, Link2, Loader2, + Plug, RefreshCw, Save, Unlink, } from "lucide-react"; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, - AlertDialogTrigger, -} from "@/components/ui/alert-dialog"; -import { Button } from "@/components/ui/button"; import { SettingsCardActions } from "@/components/settings/_card-actions"; +import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { PasswordInput } from "@/components/ui/password-input"; import { TagChip } from "@/components/ui/tag-chip"; -import { SettingsCard } from "@/components/settings/settings-card"; -import { SettingsCardHeader } from "@/components/settings/_card-header"; -import { IntegrationStatusPill } from "@/components/settings/integration-status-pill"; -import { TestConnectionButton } from "@/components/settings/test-connection-button"; import { apiFetchRaw, apiPost } from "@/lib/api/api-fetch"; -import { WrittenOutcomeLine } from "@/components/outcome/written-outcome-line"; import { useTranslations } from "@/lib/i18n/context"; import { invalidateKeys, @@ -52,20 +38,21 @@ import { queryKeys, } from "@/lib/query-keys"; -import { MetricFreshnessDisclosure } from "./metric-freshness-disclosure"; import { - readSyncOutcome, - useSyncOutcomeMessage, - type SyncOutcomeState, -} from "./sync-outcome"; + ActionResultLines, + useConnectionTest, + useStandardSync, +} from "./integration-actions"; import { - IntegrationErrorMessage, - pillFailurePropsFor, - pillStateForVerdict, - pillTimestampFor, - type IntegrationStatusViewModel, -} from "./shared"; -import { IntegrationCardDescription } from "./setup-guide-link"; + IntegrationCard, + type IntegrationMenuAction, +} from "./integration-card"; +import { MetricFreshnessList } from "./metric-freshness-list"; +import { + IntegrationCardDescription, + integrationDocsHref, +} from "./setup-guide-link"; +import { failureStreakFor, type IntegrationStatusViewModel } from "./shared"; export function NightscoutCard({ viewModel, @@ -73,19 +60,17 @@ export function NightscoutCard({ viewModel?: IntegrationStatusViewModel; }) { const { t } = useTranslations(); - const describeSyncOutcome = useSyncOutcomeMessage(); const queryClient = useQueryClient(); - - const [url, setUrl] = useState(""); - const [token, setToken] = useState(""); - const [saving, setSaving] = useState(false); - const [msg, setMsg] = useState(null); - const [msgType, setMsgType] = useState<"success" | "error" | null>(null); - const [syncing, setSyncing] = useState(false); - const [syncResult, setSyncResult] = useState(null); - const status = viewModel; + const connected = status?.connected ?? false; + const sync = useStandardSync({ + provider: "nightscout", + i18nPrefix: "settings.nightscout", + acceptsFullSync: false, + }); + const test = useConnectionTest("/api/nightscout/test"); + const [editing, setEditing] = useState(false); const formRef = useRef(null); const disconnect = useMutation({ @@ -93,114 +78,50 @@ export function NightscoutCard({ await apiPost("/api/nightscout/disconnect"); }, onSuccess: () => { - setUrl(""); - setToken(""); // The card reads the consolidated envelope, so that is the one key a - // disconnect has to invalidate for the pill to flip back. + // disconnect has to invalidate for the card to flip back. queryClient.invalidateQueries({ queryKey: queryKeys.integrationsStatus(), }); }, }); - async function handleConnect(e: React.FormEvent) { - e.preventDefault(); - setSaving(true); - setMsg(null); - setMsgType(null); - try { - const res = await apiFetchRaw("/api/nightscout/connect", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - url: url.trim(), - token: token.trim(), - }), - }); - if (res.ok) { - setMsg(t("settings.nightscoutConnected")); - setMsgType("success"); - setToken(""); - void invalidateKeys(queryClient, measurementDependentKeys); - queryClient.invalidateQueries({ - queryKey: queryKeys.integrationsStatus(), - }); - } else { - let detail = t("settings.nightscoutConnectFailed"); - try { - const json = await res.json(); - if (json.error) detail = json.error; - } catch { - // keep the generic message - } - setMsg(detail); - setMsgType("error"); - } - } catch { - setMsg(t("common.networkError")); - setMsgType("error"); - } - setSaving(false); + // A pause or a lapsed token is resolved by submitting the address again: + // there is no OAuth round-trip to re-run, so the button opens the form and + // puts the cursor in the URL field. + function openForm() { + setEditing(true); + requestAnimationFrame(() => { + formRef.current?.scrollIntoView({ behavior: "smooth", block: "center" }); + formRef.current + ?.querySelector("#nightscout-url") + ?.focus(); + }); } - // Sync now. Same shape as the WHOOP card's incremental arm, minus the - // full-history dialog — Nightscout has no full-sync arm to call. - async function handleSync() { - setSyncing(true); - setSyncResult(null); - try { - const res = await apiFetchRaw("/api/nightscout/sync", { method: "POST" }); - const json = await res.json(); - const result = res.ok ? readSyncOutcome(json) : null; - if (result) { - // The tone comes off what the run wrote, not off `res.ok`. - setSyncResult({ - outcome: result.outcome, - message: describeSyncOutcome( - result, - t("settings.nightscoutSyncResult", { count: result.imported }), - ), - }); - void invalidateKeys(queryClient, measurementDependentKeys); - queryClient.invalidateQueries({ - queryKey: queryKeys.integrationsStatus(), - }); - } else { - setSyncResult({ - outcome: "failed", - message: json?.error || t("settings.nightscoutSyncFailed"), - }); - } - } catch { - setSyncResult({ - outcome: "failed", - message: t("settings.nightscoutSyncFailed"), - }); - } finally { - setSyncing(false); - } - } - - const pillState = pillStateForVerdict(status?.syncHealth?.verdict); + // The newest recorded attempt names the one failure the person cannot fix + // from here: the instance sits on a private network the operator has not + // approved. Read from the health diagnostics' short error code, never from + // a stored message. const operatorApprovalRequired = - status?.lastError?.startsWith("private_origin_not_approved") ?? false; - // `warning` joins the set now that a transient failure paints amber rather - // than red — the detail belongs on this line, not in a colour that tells the - // user to reconnect against something reconnecting cannot fix. - const errorMessage = - (pillState === "error" || - pillState === "parked" || - pillState === "warning") && - status?.lastError && - !operatorApprovalRequired - ? status.lastError - : null; + status?.health?.diagnostics.recentCalls[0]?.errorCode === + "private_origin_not_approved"; - return ( - - + ); + + if (!connected) { + return ( + {t("settings.nightscoutTag")}} description={ } - status={ - - } + loading={viewModel === undefined} + connected={false} + health={null} + setup={form} /> + ); + } -
    - {errorMessage && } - {operatorApprovalRequired && ( -
    -

    - {t("settings.nightscoutPrivateOperatorTitle")} -

    -

    - {t("settings.nightscoutPrivateOperatorDescription")} -

    -
    - )} + const menu: IntegrationMenuAction[] = [ + ...(operatorApprovalRequired + ? [] + : [ + { + id: "sync", + label: t("settings.nightscoutSync"), + icon: RefreshCw, + disabled: sync.syncing, + onSelect: () => sync.run(false), + }, + { + id: "test", + label: t("settings.testConnection.test"), + icon: Plug, + disabled: test.testing, + onSelect: test.run, + }, + ]), + { + id: "view-data", + label: t("settings.nightscoutViewData"), + icon: ArrowRight, + href: "/insights/blood-glucose", + }, + { + id: "credentials", + label: t("settings.integrationCard.menu.address"), + icon: KeyRound, + onSelect: () => setEditing((open) => !open), + }, + { + id: "setup-guide", + label: t("settings.integrationSetupGuide"), + icon: BookOpen, + href: integrationDocsHref("nightscout"), + external: true, + }, + { + id: "disconnect", + label: t("settings.nightscoutDisconnect"), + icon: Unlink, + destructive: true, + onSelect: () => disconnect.mutate(), + confirm: { + title: t("settings.nightscoutDisconnectTitle"), + body: t("settings.nightscoutDisconnectDescription"), + confirmLabel: t("settings.nightscoutDisconnect"), + }, + }, + ]; - {/* Parked-integration resume CTA. Surfaces only when the row state - is `parked` (>24h of persistent failures). Nightscout has no OAuth - redirect — the user re-validates by re-submitting the connect form, - so "reconnect" scrolls the form into view + focuses the URL field. - Markup matches the WHOOP card byte for byte. */} - {pillState === "parked" && ( -
    - - {t("settings.integrationPill.parkedReconnect")} - - -
    - )} + const streak = failureStreakFor(status); - {status?.connected && ( - - )} + return ( + {t("settings.nightscoutTag")}} + connected + health={status?.health ?? null} + primary={{ onReconnect: openForm, onResume: openForm }} + menu={menu} + diagnosticsExtra={ + streak + ? [ + t("settings.integrationCard.details.failureStreak", { + count: streak.count, + threshold: streak.threshold, + }), + ] + : undefined + } + details={ + status?.metricFreshness?.length ? ( + + ) : undefined + } + > + {operatorApprovalRequired ? ( + // The one fact the generic sentence cannot carry: the instance sits + // on a private network the operator has not approved, which the + // person cannot fix from here. Said in the body, not as a banner. +

    + {t("settings.nightscoutPrivateOperatorTitle")}{" "} + {t("settings.nightscoutPrivateOperatorDescription")} +

    + ) : null} + + {editing ? form : null} +
    + ); +} -
    -
    - - setUrl(e.target.value)} - placeholder={ - status?.configured - ? t("settings.nightscoutUrlSavedPlaceholder") - : "https://your-site.up.railway.app" - } - maxLength={2048} - autoComplete="off" - inputMode="url" - spellCheck={false} - autoCapitalize="none" - enterKeyHint="next" - /> -
    -
    - - setToken(e.target.value)} - placeholder={ - status?.hasToken - ? t("settings.nightscoutTokenSavedPlaceholder") - : t("settings.nightscoutTokenOptional") - } - maxLength={512} - autoComplete="off" - spellCheck={false} - autoCapitalize="none" - enterKeyHint="done" - /> -

    - {t("settings.nightscoutTokenHelp")} -

    -
    +function NightscoutForm({ + formRef, + connected, + configured, + hasToken, +}: { + formRef: React.RefObject; + connected: boolean; + configured: boolean; + hasToken: boolean; +}) { + const { t } = useTranslations(); + const queryClient = useQueryClient(); + const [url, setUrl] = useState(""); + const [token, setToken] = useState(""); + const [saving, setSaving] = useState(false); + const [msg, setMsg] = useState<{ + kind: "success" | "error"; + text: string; + } | null>(null); - - - + async function handleConnect(e: React.FormEvent) { + e.preventDefault(); + setSaving(true); + setMsg(null); + try { + const res = await apiFetchRaw("/api/nightscout/connect", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ url: url.trim(), token: token.trim() }), + }); + if (res.ok) { + setMsg({ kind: "success", text: t("settings.nightscoutConnected") }); + setToken(""); + void invalidateKeys(queryClient, measurementDependentKeys); + queryClient.invalidateQueries({ + queryKey: queryKeys.integrationsStatus(), + }); + } else { + let text = t("settings.nightscoutConnectFailed"); + try { + const json = await res.json(); + if (json.error) text = json.error; + } catch { + // keep the generic message + } + setMsg({ kind: "error", text }); + } + } catch { + setMsg({ kind: "error", text: t("common.networkError") }); + } + setSaving(false); + } - {msg && ( -

    - {msg} -

    + return ( + +
    + + setUrl(e.target.value)} + placeholder={ + configured + ? t("settings.nightscoutUrlSavedPlaceholder") + : "https://your-site.up.railway.app" + } + maxLength={2048} + autoComplete="off" + inputMode="url" + spellCheck={false} + autoCapitalize="none" + enterKeyHint="next" + /> +
    +
    + + setToken(e.target.value)} + placeholder={ + hasToken + ? t("settings.nightscoutTokenSavedPlaceholder") + : t("settings.nightscoutTokenOptional") + } + maxLength={512} + autoComplete="off" + spellCheck={false} + autoCapitalize="none" + enterKeyHint="done" + /> +

    + {t("settings.nightscoutTokenHelp")} +

    +
    + {msg && ( +

    + {msg.text} +

    + )} + + - - - )} - - - - - - - - {t("settings.nightscoutDisconnectTitle")} - - - {t("settings.nightscoutDisconnectDescription")} - - - - {t("common.cancel")} - disconnect.mutate()} - > - {t("settings.nightscoutDisconnect")} - - - - -
    - {syncResult && ( - - )} - {/* connect→data loop: a discreet link to where the glucose - readings now surface — doubles as the "your data is richer" - cue. */} - - {t("settings.nightscoutViewData")} - - - - )} -
    -
    + {connected + ? t("settings.nightscoutUpdate") + : t("settings.nightscoutConnect")} + + + ); } diff --git a/src/components/settings/integrations/oauth-integration-card.tsx b/src/components/settings/integrations/oauth-integration-card.tsx new file mode 100644 index 000000000..0d9d653f1 --- /dev/null +++ b/src/components/settings/integrations/oauth-integration-card.tsx @@ -0,0 +1,539 @@ +"use client"; + +/** + * The controller behind every OAuth integration card: Withings, WHOOP, + * Fitbit, Google Health, Polar, Oura and Strava. + * + * It owns the requests (connect, resume, sync, test, disconnect, the BYO + * credentials form) and hands `` what the person can do. The + * card decides nothing about health: it renders the server's `health` field. + * What used to be per-provider banners (the Withings plan note, the Oura scope + * note, the Withings activity-scope reconnect, the Google Health re-consent, + * the paused pill with its resume button) is now the sentence plus the one + * primary button that `health.action` names. + */ + +import { useState, type ReactNode } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { + Activity, + ArrowRight, + BookOpen, + CircleDot, + ExternalLink, + HeartPulse, + History, + KeyRound, + Link2, + Plug, + RefreshCw, + Unlink, + Watch, + type LucideIcon, +} from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { apiPost } from "@/lib/api/api-fetch"; +import { useTranslations } from "@/lib/i18n/context"; +import type { IntegrationCallbackUrls } from "@/lib/integrations/callback-urls"; +import { loadDocument } from "@/lib/navigation/load-document"; +import { WITHINGS_API_PLANS_URL } from "@/lib/withings/plan-features"; +import { + invalidateKeys, + measurementDependentKeys, + queryKeys, +} from "@/lib/query-keys"; + +import { CredentialsForm } from "./credentials-form"; +import { + ActionResultLines, + useConnectionTest, + useStandardSync, + type ProviderSync, +} from "./integration-actions"; +import { + IntegrationCard, + type IntegrationMenuAction, +} from "./integration-card"; +import { MetricFreshnessList } from "./metric-freshness-list"; +import { + CallbackMismatchNotice, + IntegrationCardDescription, + IntegrationRedirectGuide, + integrationDocsHref, + type IntegrationDocsProvider, +} from "./setup-guide-link"; +import { failureStreakFor, type IntegrationStatusViewModel } from "./shared"; + +export type OAuthIntegrationKey = + | "withings" + | "whoop" + | "fitbit" + | "google-health" + | "polar" + | "oura" + | "strava"; + +export interface OAuthIntegrationConfig { + /** Connection key; also the `/api//…` route segment. */ + provider: OAuthIntegrationKey; + /** i18n base, e.g. `settings.withings`. */ + i18nPrefix: string; + icon: LucideIcon; + docs: IntegrationDocsProvider; + /** Where this provider's data surfaces. */ + dataHref: string; + /** The sync route takes `{ fullSync }` and the menu offers the full reload. */ + fullSync: boolean; + /** A full sync reports with its own sentence (`…FullSyncResult`). */ + fullSyncResult?: boolean; + /** + * `required`: the person must store their own client id/secret before + * connecting (`configured`). `optional`: a shared app may exist on the + * server (`available`), and the person's own pair is an alternative. + */ + credentials: "required" | "optional"; + /** The provider ships a secret-specific saved placeholder. */ + secretPlaceholder: boolean; + /** Show the callback-URL guide before credentials are stored. */ + redirectGuide: boolean; + /** + * POST endpoint that clears a pause. Null: the grant itself has to be + * re-issued, so "Resume" re-runs connect, as it always did for these. + */ + resumeEndpoint: string | null; + testEndpoint: string; + statusQueryKey: () => readonly unknown[]; +} + +export const OAUTH_INTEGRATIONS: Record< + OAuthIntegrationKey, + OAuthIntegrationConfig +> = { + withings: { + provider: "withings", + i18nPrefix: "settings.withings", + icon: Link2, + docs: "withings", + dataHref: "/measurements", + fullSync: true, + fullSyncResult: true, + credentials: "required", + secretPlaceholder: true, + redirectGuide: true, + resumeEndpoint: "/api/integrations/withings/resume", + testEndpoint: "/api/integrations/withings/test", + statusQueryKey: queryKeys.withings, + }, + whoop: { + provider: "whoop", + i18nPrefix: "settings.whoop", + icon: Activity, + docs: "whoop", + dataHref: "/insights/recovery", + fullSync: true, + credentials: "required", + secretPlaceholder: true, + redirectGuide: true, + resumeEndpoint: "/api/integrations/whoop/resume", + testEndpoint: "/api/integrations/whoop/test", + statusQueryKey: queryKeys.whoop, + }, + fitbit: { + provider: "fitbit", + i18nPrefix: "settings.fitbit", + icon: HeartPulse, + docs: "fitbit", + dataHref: "/insights/sleep", + fullSync: true, + credentials: "required", + secretPlaceholder: true, + redirectGuide: true, + resumeEndpoint: "/api/integrations/fitbit/resume", + testEndpoint: "/api/integrations/fitbit/test", + statusQueryKey: queryKeys.fitbit, + }, + "google-health": { + provider: "google-health", + i18nPrefix: "settings.googleHealth", + icon: Watch, + docs: "google-health", + dataHref: "/insights/steps", + fullSync: true, + credentials: "required", + secretPlaceholder: true, + redirectGuide: false, + resumeEndpoint: "/api/integrations/google-health/resume", + testEndpoint: "/api/integrations/google-health/test", + statusQueryKey: queryKeys.googleHealth, + }, + polar: { + provider: "polar", + i18nPrefix: "settings.polar", + icon: Watch, + docs: "polar", + dataHref: "/insights/sleep", + fullSync: false, + credentials: "optional", + secretPlaceholder: false, + redirectGuide: true, + resumeEndpoint: null, + testEndpoint: "/api/polar/test", + statusQueryKey: queryKeys.polar, + }, + oura: { + provider: "oura", + i18nPrefix: "settings.oura", + icon: CircleDot, + docs: "oura", + dataHref: "/insights/sleep", + fullSync: false, + credentials: "optional", + secretPlaceholder: false, + redirectGuide: true, + resumeEndpoint: null, + testEndpoint: "/api/oura/test", + statusQueryKey: queryKeys.oura, + }, + strava: { + provider: "strava", + i18nPrefix: "settings.strava", + icon: Activity, + docs: "strava", + dataHref: "/insights/workouts", + fullSync: false, + credentials: "optional", + secretPlaceholder: false, + redirectGuide: true, + resumeEndpoint: null, + testEndpoint: "/api/strava/test", + statusQueryKey: queryKeys.strava, + }, +}; + +/** The standard controller: a provider whose sync answers in one response. */ +export function OAuthIntegrationCard(props: { + config: OAuthIntegrationConfig; + viewModel: IntegrationStatusViewModel | undefined; + callbackUrl: IntegrationCallbackUrls[OAuthIntegrationKey]; + icon?: LucideIcon; + titleAccessory?: ReactNode; +}) { + const { config } = props; + const sync = useStandardSync({ + provider: config.provider, + i18nPrefix: config.i18nPrefix, + acceptsFullSync: config.fullSync, + fullSyncResult: config.fullSyncResult, + statusQueryKey: config.statusQueryKey(), + }); + return ; +} + +/** + * The view half, so a provider with its own sync choreography (Google Health + * polls for completion) can hand in its own `sync` and keep everything else. + */ +export function OAuthIntegrationCardView({ + config, + viewModel, + callbackUrl, + icon, + titleAccessory, + sync, + syncLabel, + syncDisabled, + fullSyncDisabled, + syncResults, + details, +}: { + config: OAuthIntegrationConfig; + viewModel: IntegrationStatusViewModel | undefined; + callbackUrl: IntegrationCallbackUrls[OAuthIntegrationKey]; + icon?: LucideIcon; + titleAccessory?: ReactNode; + sync: ProviderSync; + /** Overrides the sync item's label (Google Health's "check status"). */ + syncLabel?: string; + syncDisabled?: boolean; + fullSyncDisabled?: boolean; + /** Replaces the standard sync result line. */ + syncResults?: ReactNode; + /** Provider extras for the details section. */ + details?: ReactNode; +}) { + const { t } = useTranslations(); + const queryClient = useQueryClient(); + const { provider, i18nPrefix } = config; + const statusQueryKey = config.statusQueryKey(); + const test = useConnectionTest(config.testEndpoint); + const [editingCredentials, setEditingCredentials] = useState(false); + + const providerName = t(i18nPrefix); + const connected = viewModel?.connected ?? false; + const configured = + config.credentials === "required" + ? (viewModel?.configured ?? false) + : (viewModel?.available ?? false); + const ownCredentials = + config.credentials === "required" + ? (viewModel?.configured ?? false) + : (viewModel?.hasOwnCredentials ?? false); + + function invalidateStatus() { + queryClient.invalidateQueries({ queryKey: statusQueryKey }); + queryClient.invalidateQueries({ + queryKey: queryKeys.integrationsStatus(), + }); + } + + function connect() { + void invalidateKeys(queryClient, measurementDependentKeys); + loadDocument(`/api/${provider}/connect`); + } + + const disconnect = useMutation({ + mutationFn: async () => { + await apiPost(`/api/${provider}/disconnect`); + }, + onSuccess: invalidateStatus, + }); + + const resume = useMutation({ + mutationFn: async () => { + if (!config.resumeEndpoint) return null; + return apiPost<{ resumed: boolean; wasParked: boolean }>( + config.resumeEndpoint, + ); + }, + onSuccess: invalidateStatus, + }); + + const credentialsForm = ( + + ); + + const description = ( + + ); + + if (!connected) { + return ( + + {config.redirectGuide && !ownCredentials ? ( + + ) : null} + {credentialsForm} + {!configured ? ( +

    + {config.credentials === "required" + ? t(`${i18nPrefix}NoCredentials`) + : t(`${i18nPrefix}Unavailable`)} +

    + ) : null} + + + } + connectAction={ + + } + /> + ); + } + + const busy = sync.syncing; + const menu: IntegrationMenuAction[] = [ + { + id: "sync", + label: syncLabel ?? t(`${i18nPrefix}Sync`), + icon: RefreshCw, + disabled: busy || syncDisabled, + onSelect: () => sync.run(false), + }, + ...(config.fullSync + ? [ + { + id: "full-sync", + label: t(`${i18nPrefix}FullSync`), + icon: History, + disabled: busy || fullSyncDisabled, + onSelect: () => sync.run(true), + confirm: { + title: t(`${i18nPrefix}FullSyncTitle`), + body: t(`${i18nPrefix}FullSyncDescription`), + confirmLabel: t(`${i18nPrefix}Synchronize`), + }, + } satisfies IntegrationMenuAction, + ] + : []), + { + id: "test", + label: t("settings.testConnection.test"), + icon: Plug, + disabled: test.testing, + onSelect: test.run, + }, + { + id: "view-data", + label: t(`${i18nPrefix}ViewData`), + icon: ArrowRight, + href: config.dataHref, + }, + { + id: "credentials", + label: t("settings.integrationCard.menu.credentials"), + icon: KeyRound, + onSelect: () => setEditingCredentials((open) => !open), + }, + { + id: "setup-guide", + label: t("settings.integrationSetupGuide"), + icon: BookOpen, + href: integrationDocsHref(config.docs), + external: true, + }, + { + id: "disconnect", + label: t(`${i18nPrefix}Disconnect`), + icon: Unlink, + destructive: true, + onSelect: () => disconnect.mutate(), + confirm: { + title: t(`${i18nPrefix}DisconnectTitle`), + body: t(`${i18nPrefix}DisconnectDescription`), + confirmLabel: t(`${i18nPrefix}Disconnect`), + }, + }, + ]; + + const streak = failureStreakFor(viewModel); + // Withings' paid API plan withholds some readings; the page that explains + // the plans stays one tap away in the details. + const planLimited = + provider === "withings" && + (viewModel?.health?.unavailable.some((u) => u.cause === "plan") ?? false); + + return ( + resume.mutate() : connect, + pending: resume.isPending, + disabled: config.credentials === "optional" && !configured, + }} + menu={menu} + diagnosticsExtra={ + streak + ? [ + t("settings.integrationCard.details.failureStreak", { + count: streak.count, + threshold: streak.threshold, + }), + ] + : undefined + } + details={ + details || planLimited || viewModel?.metricFreshness?.length ? ( + <> + {planLimited ? ( + + {t("settings.integrations.withings.plan.link")} + + ) : null} + {details} + + + ) : undefined + } + > + {syncResults ?? ( + + )} + {syncResults ? ( + + ) : null} + {resume.isError ? ( +

    + {t("settings.integrationPill.resumeError")} +

    + ) : null} + {resume.isSuccess && resume.data?.wasParked ? ( +

    + {t("settings.integrationPill.resumeSuccess")} +

    + ) : null} + {editingCredentials ? credentialsForm : null} + +
    + ); +} diff --git a/src/components/settings/integrations/oauth-provider-card.tsx b/src/components/settings/integrations/oauth-provider-card.tsx deleted file mode 100644 index 2811fd460..000000000 --- a/src/components/settings/integrations/oauth-provider-card.tsx +++ /dev/null @@ -1,551 +0,0 @@ -"use client"; - -// v1.17.0 (F4) — shared OAuth integration card for Polar / Oura. -// v1.17.1 — these are now per-user BYO-key integrations (like WHOOP / Fitbit): -// the card renders an optional "your OAuth app credentials" form (driven by the -// `credentials` prop) above the connect button. Credentials resolve DB-first -// then env on the server, so a user who pastes their own client id/secret uses -// their own app while existing env-configured deploys keep working unchanged. -// Mirrors the Nightscout card's self-contained status pattern. - -import { useState } from "react"; -import Link from "next/link"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { - ArrowRight, - Link2, - Loader2, - RefreshCw, - Save, - Unlink, - type LucideIcon, -} from "lucide-react"; - -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, - AlertDialogTrigger, -} from "@/components/ui/alert-dialog"; -import { Button } from "@/components/ui/button"; -import { SettingsCardActions } from "@/components/settings/_card-actions"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { PasswordInput } from "@/components/ui/password-input"; -import { SettingsCard } from "@/components/settings/settings-card"; -import { SettingsCardHeader } from "@/components/settings/_card-header"; -import { IntegrationStatusPill } from "@/components/settings/integration-status-pill"; -import { TestConnectionButton } from "@/components/settings/test-connection-button"; -import { apiFetchRaw, apiPost } from "@/lib/api/api-fetch"; -import { WrittenOutcomeLine } from "@/components/outcome/written-outcome-line"; -import { useTranslations } from "@/lib/i18n/context"; -import type { - MetricFreshnessEntry, - SyncHealth, -} from "@/lib/integrations/sync-verdict"; -import { - invalidateKeys, - measurementDependentKeys, - queryKeys, -} from "@/lib/query-keys"; -import { MetricFreshnessDisclosure } from "./metric-freshness-disclosure"; -import { OuraScopeNote } from "./oura-scope-note"; -import { - readSyncOutcome, - useSyncOutcomeMessage, - type SyncOutcomeState, -} from "./sync-outcome"; -import { - IntegrationErrorMessage, - pillFailurePropsFor, - pillStateForVerdict, - type IntegrationState, -} from "./shared"; -import { - CallbackMismatchNotice, - IntegrationCardDescription, - IntegrationRedirectGuide, - type IntegrationDocsProvider, -} from "./setup-guide-link"; -import type { IntegrationCallbackUrls } from "@/lib/integrations/callback-urls"; -import type { OuraScopeGatedCollection } from "@/lib/oura/scope"; -import { loadDocument } from "@/lib/navigation/load-document"; - -export interface OAuthProviderStatus { - connected: boolean; - configured: boolean; - available: boolean; - /** Whether the user has stored their own BYO client id/secret pair. */ - hasOwnCredentials?: boolean; - /** - * The raw ledger state, carried for shape parity with the envelope. The - * card paints from `syncHealth.verdict`, never from this — which is why - * v1.38.19's `unknown` member needs no arm here. - */ - state?: IntegrationState; - lastSuccessAt?: string | null; - lastAttemptAt?: string | null; - lastError?: string | null; - legacyLastSyncedAt?: string | null; - consecutiveFailuresByKind?: { - transient: number; - reauth_required: number; - persistent: number; - } | null; - failureThreshold?: number; - /** The server-resolved liveness verdict; the pill is a projection of it. */ - syncHealth?: SyncHealth; - metricFreshness?: MetricFreshnessEntry[]; - /** Oura: collections the stored grant does not cover. */ - scopeReconnect?: OuraScopeGatedCollection[]; -} - -export interface OAuthProviderCardProps { - /** Lower-case provider key, used for routes + query keys + testids. */ - provider: "polar" | "oura" | "strava"; - /** The provider's own query key, invalidated after connect / sync / save. */ - statusQueryKey: readonly unknown[]; - /** i18n key prefix (e.g. `settings.polar`). */ - i18nPrefix: string; - /** Distinct card glyph, kept in the same size/treatment as the other cards. */ - icon: LucideIcon; - /** Where this provider's synced data surfaces (e.g. `/insights/sleep`). */ - dataHref: string; - /** When set, render a per-user BYO OAuth-credentials form above the connect - * button. The endpoint is the PUT target (e.g. `/api/polar/credentials`). */ - credentials?: boolean; - /** - * The card's status, sourced off the consolidated - * `/api/integrations/status` envelope. The card used to fall back to its own - * `/api//status` round-trip when this was absent; that fallback is - * gone. Every mount passes a view-model, the per-provider response carries no - * `syncHealth` so the fallback would have painted a wrong verdict, and during - * envelope load it fired three redundant requests for a caller that does not - * exist. - */ - viewModel?: OAuthProviderStatus; - /** Server-resolved OAuth callback URL; see `getIntegrationCallbackUrls`. */ - callbackUrl: IntegrationCallbackUrls[OAuthProviderCardProps["provider"]]; -} - -export function OAuthProviderCard({ - provider, - statusQueryKey, - i18nPrefix, - icon, - dataHref, - credentials = false, - viewModel, - callbackUrl, -}: OAuthProviderCardProps) { - const { t } = useTranslations(); - const describeSyncOutcome = useSyncOutcomeMessage(); - const queryClient = useQueryClient(); - const [msg, setMsg] = useState(null); - const [syncing, setSyncing] = useState(false); - const [syncResult, setSyncResult] = useState(null); - const [clientId, setClientId] = useState(""); - const [clientSecret, setClientSecret] = useState(""); - const [credsSaving, setCredsSaving] = useState(false); - const [credsMsg, setCredsMsg] = useState(null); - const [credsMsgType, setCredsMsgType] = useState<"success" | "error" | null>( - null, - ); - - const status = viewModel; - - // Sync now. Same shape as the WHOOP card's incremental arm, minus the - // full-history dialog — none of these providers has a full-sync arm to call. - async function handleSync() { - setSyncing(true); - setSyncResult(null); - try { - const res = await apiFetchRaw(`/api/${provider}/sync`, { - method: "POST", - }); - const json = await res.json(); - const result = res.ok ? readSyncOutcome(json) : null; - if (result) { - // The tone comes off what the run wrote, not off `res.ok`. - setSyncResult({ - outcome: result.outcome, - message: describeSyncOutcome( - result, - t(`${i18nPrefix}SyncResult`, { count: result.imported }), - ), - }); - void invalidateKeys(queryClient, measurementDependentKeys); - queryClient.invalidateQueries({ queryKey: statusQueryKey }); - queryClient.invalidateQueries({ - queryKey: queryKeys.integrationsStatus(), - }); - } else { - setSyncResult({ - outcome: "failed", - message: json?.error || t(`${i18nPrefix}SyncFailed`), - }); - } - } catch { - setSyncResult({ - outcome: "failed", - message: t(`${i18nPrefix}SyncFailed`), - }); - } finally { - setSyncing(false); - } - } - - async function handleSaveCredentials(e: React.FormEvent) { - e.preventDefault(); - setCredsSaving(true); - setCredsMsg(null); - setCredsMsgType(null); - try { - const res = await apiFetchRaw(`/api/${provider}/credentials`, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - clientId: clientId.trim(), - clientSecret: clientSecret.trim(), - }), - }); - if (res.ok) { - setCredsMsg(t(`${i18nPrefix}CredentialsSaved`)); - setCredsMsgType("success"); - setClientId(""); - setClientSecret(""); - queryClient.invalidateQueries({ queryKey: statusQueryKey }); - // The card may read off the consolidated envelope — invalidate it too - // so the saved-credentials state repaints regardless of the source. - queryClient.invalidateQueries({ - queryKey: queryKeys.integrationsStatus(), - }); - } else { - try { - const json = await res.json(); - setCredsMsg(json.error || t("settings.savingError")); - } catch { - setCredsMsg(t("settings.savingError")); - } - setCredsMsgType("error"); - } - } catch { - setCredsMsg(t("common.networkError")); - setCredsMsgType("error"); - } - setCredsSaving(false); - } - - const disconnect = useMutation({ - mutationFn: async () => { - await apiPost(`/api/${provider}/disconnect`); - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: statusQueryKey }); - queryClient.invalidateQueries({ - queryKey: queryKeys.integrationsStatus(), - }); - }, - }); - - const pillState = pillStateForVerdict(status?.syncHealth?.verdict); - // The pill says what; this line says why. `warning` joins the set now that a - // transient failure no longer paints red — the detail moved here from a - // colour that told the user to reconnect against something reconnecting - // cannot fix. - const errorMessage = - (pillState === "error" || - pillState === "parked" || - pillState === "warning") && - status?.lastError - ? status.lastError - : null; - const serverUnavailable = status && !status.available; - - function handleConnect() { - setMsg(null); - void invalidateKeys(queryClient, measurementDependentKeys); - loadDocument(`/api/${provider}/connect`); - } - - return ( - - - } - status={ - - } - /> - -
    - {errorMessage && ( -
    - -
    - )} - - {/* Parked-integration resume CTA. Surfaces only when the row state - is `parked` (>24h of persistent failures). For an env-based OAuth - provider there are no stored BYO credentials to re-validate — the - grant itself has to be re-issued, so "reconnect" re-initiates the - existing connect flow (`/api//connect`); a successful - callback clears the park. Markup matches the WHOOP card byte for - byte. */} - {pillState === "parked" && ( -
    - - {t("settings.integrationPill.parkedReconnect")} - - -
    - )} - - {provider === "oura" && status?.connected && ( - - )} - - {status?.connected && ( - - )} - - {credentials && ( -
    -

    - {t(`${i18nPrefix}Credentials`)} -

    - {!status?.hasOwnCredentials && ( - - )} -
    -
    -
    - - setClientId(e.target.value)} - placeholder={ - status?.hasOwnCredentials - ? t(`${i18nPrefix}CredentialsSavedPlaceholder`) - : t(`${i18nPrefix}ClientId`) - } - maxLength={200} - autoComplete="off" - inputMode="text" - spellCheck={false} - autoCapitalize="none" - enterKeyHint="next" - /> -
    -
    - - setClientSecret(e.target.value)} - placeholder={ - status?.hasOwnCredentials - ? t(`${i18nPrefix}CredentialsSavedPlaceholder`) - : t(`${i18nPrefix}ClientSecret`) - } - maxLength={200} - autoComplete="off" - inputMode="text" - spellCheck={false} - autoCapitalize="none" - enterKeyHint="done" - /> -
    -
    - - - - {credsMsg && ( -

    - {credsMsg} -

    - )} -
    -
    - )} - - {serverUnavailable && ( -

    - {t(`${i18nPrefix}Unavailable`)} -

    - )} - - {status?.connected ? ( - <> -
    - - - - - - - - - - {t(`${i18nPrefix}DisconnectTitle`)} - - - {t(`${i18nPrefix}DisconnectDescription`)} - - - - {t("common.cancel")} - disconnect.mutate()} - > - {t(`${i18nPrefix}Disconnect`)} - - - - -
    - {syncResult && ( - - )} - {/* connect→data loop: a discreet link to where this provider's - readings now surface — doubles as the "your data is richer" - cue. */} - - {t(`${i18nPrefix}ViewData`)} - - - - ) : ( - - - - )} - - {msg && ( -

    - {msg} -

    - )} -
    - -
    - ); -} diff --git a/src/components/settings/integrations/oura-card.tsx b/src/components/settings/integrations/oura-card.tsx deleted file mode 100644 index cb669d917..000000000 --- a/src/components/settings/integrations/oura-card.tsx +++ /dev/null @@ -1,35 +0,0 @@ -"use client"; - -// v1.17.0 (F4) — Oura Cloud integration card. Thin wrapper over the shared -// OAuth card. -// v1.17.1 — per-user BYO OAuth credentials (DB-first then env). - -import { CircleDot } from "lucide-react"; - -import { - OAuthProviderCard, - type OAuthProviderStatus, -} from "@/components/settings/integrations/oauth-provider-card"; -import type { IntegrationCallbackUrls } from "@/lib/integrations/callback-urls"; -import { queryKeys } from "@/lib/query-keys"; - -export function OuraCard({ - viewModel, - callbackUrl, -}: { - viewModel?: OAuthProviderStatus; - callbackUrl: IntegrationCallbackUrls["oura"]; -}) { - return ( - - ); -} diff --git a/src/components/settings/integrations/oura-scope-note.tsx b/src/components/settings/integrations/oura-scope-note.tsx deleted file mode 100644 index 0ed0b75ca..000000000 --- a/src/components/settings/integrations/oura-scope-note.tsx +++ /dev/null @@ -1,60 +0,0 @@ -"use client"; - -import { Link2 } from "lucide-react"; - -import { Button } from "@/components/ui/button"; -import { useTranslations } from "@/lib/i18n/context"; -import type { OuraScopeGatedCollection } from "@/lib/oura/scope"; - -/** - * A calm note on the Oura card when the stored grant does not cover some - * collections (they answer 401 while their siblings succeed). Not an error and - * not counted toward the reconnect threshold: everything else keeps syncing, - * and one reconnect asks Oura for the missing scopes. - */ -export function OuraScopeNote({ - collections, - onReconnect, - disabled, -}: { - collections: readonly OuraScopeGatedCollection[] | undefined; - onReconnect: () => void; - disabled?: boolean; -}) { - const { t, locale } = useTranslations(); - if (!collections || collections.length === 0) return null; - - const labels: Record = { - spo2: t("settings.integrations.oura.scope.items.spo2"), - vo2max: t("settings.integrations.oura.scope.items.vo2max"), - cardio_age: t("settings.integrations.oura.scope.items.cardio_age"), - resilience: t("settings.integrations.oura.scope.items.resilience"), - }; - const list = new Intl.ListFormat(locale, { - style: "long", - type: "conjunction", - }).format(collections.map((c) => labels[c])); - - return ( -
    -

    - {t("settings.integrations.oura.scope.body", { list })} -

    - -
    - ); -} diff --git a/src/components/settings/integrations/polar-card.tsx b/src/components/settings/integrations/polar-card.tsx deleted file mode 100644 index 4900e449e..000000000 --- a/src/components/settings/integrations/polar-card.tsx +++ /dev/null @@ -1,35 +0,0 @@ -"use client"; - -// v1.17.0 (F4) — Polar AccessLink integration card. Thin wrapper over the -// shared OAuth card. -// v1.17.1 — per-user BYO OAuth credentials (DB-first then env). - -import { Watch } from "lucide-react"; - -import { - OAuthProviderCard, - type OAuthProviderStatus, -} from "@/components/settings/integrations/oauth-provider-card"; -import type { IntegrationCallbackUrls } from "@/lib/integrations/callback-urls"; -import { queryKeys } from "@/lib/query-keys"; - -export function PolarCard({ - viewModel, - callbackUrl, -}: { - viewModel?: OAuthProviderStatus; - callbackUrl: IntegrationCallbackUrls["polar"]; -}) { - return ( - - ); -} diff --git a/src/components/settings/integrations/shared.tsx b/src/components/settings/integrations/shared.tsx index 396a7b97d..56999a5b9 100644 --- a/src/components/settings/integrations/shared.tsx +++ b/src/components/settings/integrations/shared.tsx @@ -8,23 +8,12 @@ */ import { useQuery } from "@tanstack/react-query"; -import { AlertCircle } from "lucide-react"; -import type { IntegrationPillState } from "@/components/settings/integration-status-pill"; import { apiGet } from "@/lib/api/api-fetch"; -import type { - MetricFreshnessEntry, - SyncHealth, - SyncVerdict, -} from "@/lib/integrations/sync-verdict"; +import type { ConnectionHealth } from "@/lib/integrations/connection-health"; +import type { MetricFreshnessEntry } from "@/lib/integrations/sync-verdict"; import { queryKeys } from "@/lib/query-keys"; -import type { WithingsPlanFeature } from "@/lib/withings/plan-features"; -import type { OuraScopeGatedCollection } from "@/lib/oura/scope"; -// v1.4.15 Phase B2: shared status payload for both integration cards. -// v1.4.19 Phase A5: the redundant in-card status banner is gone — the -// IntegrationStatusPill now owns state + last-sync presentation, and -// the actionable error message is shown inline above the action row. export type IntegrationKey = | "withings" | "whoop" @@ -38,23 +27,20 @@ export type IntegrationKey = // Nightscout folds onto the envelope so the card stops fetching its own // status and inherits the shared verdict. | "nightscout"; -/** Mirrors `IntegrationState` in `src/lib/integrations/status.ts`. */ -export type IntegrationState = - | "connected" - | "error_transient" - | "error_reauth" - | "disconnected" - | "parked" - // v1.38.19 — no ledger row: the ledger has no history for this provider. - // Read like `disconnected`; `syncHealth.verdict` owns liveness. - | "unknown"; - +/** + * One envelope entry as the cards read it. Since v1.42.4 the cards render + * `health` alone; the older health-shaped fields (ledger `state`, `lastError`, + * `syncHealth`, token expiry, plan and scope lists, `needsReauth`) stay on the + * wire for one transition release and are deliberately not declared here, so + * the compiler refuses a card that starts reading them again. + */ export interface IntegrationStatusViewModel { integration: IntegrationKey; - state: IntegrationState; - lastSuccessAt: string | null; - lastAttemptAt: string | null; - lastError: string | null; + /** + * The one server-resolved state (ok / limited / degraded / action_required), + * null while the provider is not connected. + */ + health?: ConnectionHealth | null; consecutiveFailuresByKind?: { transient: number; reauth_required: number; @@ -64,38 +50,17 @@ export interface IntegrationStatusViewModel { failureThreshold?: number; configured?: boolean; connected?: boolean; - connectedAt?: string | null; - legacyLastSyncedAt?: string | null; - tokenExpiresAt?: string | null; - tokenExpired?: boolean | null; - enabled?: boolean; - // Withings activity-scope reconnect banner. - scope?: string | null; - hasActivityScope?: boolean; - // Withings: readings the account's API plan withholds (server-resolved). - planUnavailable?: WithingsPlanFeature[]; - // Oura: collections the stored grant does not cover (server-resolved). - scopeReconnect?: OuraScopeGatedCollection[]; - // WHOOP / Fitbit backfill-in-progress note. + /** WHOOP / Fitbit / Google Health: older history still loading. */ backfillCompleted?: boolean | null; - // v1.27.0 — Google Health surfaces `needsReauth` when the refresh token has - // lapsed (the 7-day "Testing"-mode expiry, or a revoked grant). The card - // paints a distinct re-consent CTA off this flag, separate from the parked - // state. Server-populated on the `/api/integrations/status` envelope. - needsReauth?: boolean; - // Polar / Oura OAuth card: usable-credentials + BYO-key flags. `available` - // greys out the connect button when no credentials resolve; `hasOwnCredentials` - // drives the saved-placeholder UI. - available?: boolean; - hasOwnCredentials?: boolean; - // Nightscout: a stored token, and the private-network opt-in. - hasToken?: boolean; - allowPrivateHost?: boolean; /** - * The server-resolved liveness verdict. Present on every envelope entry; - * `undefined` only while the envelope is still loading. + * Polar / Oura / Strava: usable credentials resolve (BYO or the shared + * app). Greys out "Connect" when false. */ - syncHealth?: SyncHealth; + available?: boolean; + /** Polar / Oura / Strava: the person stored their own client id/secret. */ + hasOwnCredentials?: boolean; + /** Nightscout: a stored token. */ + hasToken?: boolean; /** Per-metric last-seen timestamps with the server-computed stale flag. */ metricFreshness?: MetricFreshnessEntry[]; } @@ -106,10 +71,8 @@ export interface IntegrationStatusEnvelope { } /** - * Shared status fetch for the Settings → Integrations card. Returns - * the per-integration view-model AND the global threshold so the - * "{n}/{threshold} consecutive failures" string in the UI is single- - * sourced from the server. + * Shared status fetch for the Settings → Integrations cards. Returns the + * per-integration view-model and the global failure threshold. */ export function useIntegrationStatuses(enabled: boolean) { return useQuery({ @@ -135,116 +98,26 @@ export function pickStatus( } /** - * The one verdict→pill mapping in the tree. - * - * There used to be three of these, one per card family, and they disagreed: - * the same ledger state painted red on one card and orange on the next, on the - * same page. The server now resolves the verdict once and this projects it once - * — a fourth dialect cannot be minted without the structural guard noticing. - * - * `failing` deliberately lands on the warning tone, not the destructive one: - * **red is reserved for "your action fixes this"** (reconnect, resume). Telling - * a user to reconnect against a 503 sends them clicking at something they - * cannot repair; the streak detail survives in the inline error line and the - * "{n}/{threshold} consecutive failures" string the envelope single-sources. + * The current failure streak paired with the server-owned alert threshold, for + * the card's copied diagnostics. The card itself shows no counter: whether a + * streak matters is already in `health`. The alert ladder uses the same + * maximum, so the ratio cannot disagree with when a notification goes out. */ -export function pillStateForVerdict( - verdict: SyncVerdict | undefined, -): IntegrationPillState { - switch (verdict) { - case "fresh": - return "connected"; - case "stale": - return "stale"; - case "stalled": - return "stalled"; - case "failing": - return "warning"; - case "reauth_required": - return "error"; - case "parked": - return "parked"; - case "pending_first_sync": - return "pending-setup"; - case "disconnected": - case undefined: - return "disconnected"; - } -} - -/** Project a view-model onto its pill state. Undefined = still loading. */ -export function pillStateFor( - status: IntegrationStatusViewModel | undefined, -): IntegrationPillState { - return pillStateForVerdict(status?.syncHealth?.verdict); -} - -export interface IntegrationPillFailureProps { - failureCount?: number; - failureThreshold?: number; -} - -/** - * Pair the largest per-kind streak with the server-owned alert threshold. The - * alert ladder uses the same maximum, so the ratio cannot disagree with when a - * notification becomes eligible. - */ -export function pillFailurePropsFor( +export function failureStreakFor( status: | Pick< IntegrationStatusViewModel, "consecutiveFailuresByKind" | "failureThreshold" > | undefined, -): IntegrationPillFailureProps { +): { count: number; threshold: number } | null { const buckets = status?.consecutiveFailuresByKind; - const failureThreshold = status?.failureThreshold; - if (!buckets || failureThreshold === undefined) return {}; - - const failureCount = Math.max( + const threshold = status?.failureThreshold; + if (!buckets || threshold === undefined) return null; + const count = Math.max( buckets.transient, buckets.reauth_required, buckets.persistent, ); - return failureCount > 0 ? { failureCount, failureThreshold } : {}; -} - -/** - * The timestamp the pill shows inline. `connected` reads the last success (a - * connection row's own `lastSyncedAt` wins when it is newer, which is what - * repaints a fresh "just now" straight after a manual sync); the aging and - * failing states read the instant that triggered the verdict. - * - * `failing` must NOT fall through to the `legacyLastSyncedAt` arm below. That - * field is a connection row's own clock, kept fresh by the very sync that is - * erroring, so a provider that had delivered nothing for a fortnight painted a - * "just now" beside its warning. - */ -export function pillTimestampFor( - status: IntegrationStatusViewModel | undefined, -): string | null { - const verdict = status?.syncHealth?.verdict; - if (verdict === "stale" || verdict === "stalled" || verdict === "failing") { - return status?.syncHealth?.since ?? null; - } - return status?.legacyLastSyncedAt ?? status?.lastSuccessAt ?? null; -} - -/** - * Inline actionable error message that surfaces under the pill when a - * sync attempt failed. The pill conveys "something is wrong"; this - * line tells the user *what* is wrong so they can act on it. Keeping - * it deliberately small (one icon + one line) so it doesn't recreate - * the v1.4.18 redundant banner the maintainer removed. - */ -export function IntegrationErrorMessage({ message }: { message: string }) { - return ( -

    - - {message} -

    - ); + return count > 0 ? { count, threshold } : null; } diff --git a/src/components/settings/integrations/strava-card.tsx b/src/components/settings/integrations/strava-card.tsx deleted file mode 100644 index 534558e02..000000000 --- a/src/components/settings/integrations/strava-card.tsx +++ /dev/null @@ -1,36 +0,0 @@ -"use client"; - -// v1.28.x — Strava integration card. Thin wrapper over the shared OAuth card -// (per-user BYO OAuth credentials, DB-first then env). Strava is a workout -// source: connecting it syncs your activities into the workout surface, where -// the cross-source picker collapses any twin already captured by a wearable. - -import { Activity } from "lucide-react"; - -import { - OAuthProviderCard, - type OAuthProviderStatus, -} from "@/components/settings/integrations/oauth-provider-card"; -import type { IntegrationCallbackUrls } from "@/lib/integrations/callback-urls"; -import { queryKeys } from "@/lib/query-keys"; - -export function StravaCard({ - viewModel, - callbackUrl, -}: { - viewModel?: OAuthProviderStatus; - callbackUrl: IntegrationCallbackUrls["strava"]; -}) { - return ( - - ); -} diff --git a/src/components/settings/integrations/whoop-card.tsx b/src/components/settings/integrations/whoop-card.tsx deleted file mode 100644 index ad4c04d8d..000000000 --- a/src/components/settings/integrations/whoop-card.tsx +++ /dev/null @@ -1,519 +0,0 @@ -"use client"; - -import { useState } from "react"; -import Link from "next/link"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { - Activity, - ArrowRight, - Link2, - Loader2, - RefreshCw, - Save, - Unlink, -} from "lucide-react"; - -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, - AlertDialogTrigger, -} from "@/components/ui/alert-dialog"; -import { Button } from "@/components/ui/button"; -import { SettingsCardActions } from "@/components/settings/_card-actions"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { PasswordInput } from "@/components/ui/password-input"; -import { SettingsCard } from "@/components/settings/settings-card"; -import { SettingsCardHeader } from "@/components/settings/_card-header"; -import { IntegrationStatusPill } from "@/components/settings/integration-status-pill"; -import { TestConnectionButton } from "@/components/settings/test-connection-button"; -import { apiFetchRaw, apiPost } from "@/lib/api/api-fetch"; -import { WrittenOutcomeLine } from "@/components/outcome/written-outcome-line"; -import { useTranslations } from "@/lib/i18n/context"; -import { - invalidateKeys, - measurementDependentKeys, - queryKeys, -} from "@/lib/query-keys"; - -import { - IntegrationErrorMessage, - pillFailurePropsFor, - pillStateFor, - pillTimestampFor, - type IntegrationStatusViewModel, -} from "./shared"; -import { MetricFreshnessDisclosure } from "./metric-freshness-disclosure"; -import { - readSyncOutcome, - useSyncOutcomeMessage, - type SyncOutcomeState, -} from "./sync-outcome"; -import { - CallbackMismatchNotice, - IntegrationCardDescription, - IntegrationRedirectGuide, -} from "./setup-guide-link"; -import type { IntegrationCallbackUrls } from "@/lib/integrations/callback-urls"; -import { loadDocument } from "@/lib/navigation/load-document"; - -export function WhoopCard({ - viewModel, - callbackUrl, -}: { - viewModel: IntegrationStatusViewModel | undefined; - /** Server-resolved OAuth callback URL; see `getIntegrationCallbackUrls`. */ - callbackUrl: IntegrationCallbackUrls["whoop"]; -}) { - const { t } = useTranslations(); - const describeSyncOutcome = useSyncOutcomeMessage(); - const [syncing, setSyncing] = useState(false); - const [syncResult, setSyncResult] = useState(null); - const [clientId, setClientId] = useState(""); - const [clientSecret, setClientSecret] = useState(""); - const [credsSaving, setCredsSaving] = useState(false); - const [credsMsg, setCredsMsg] = useState(null); - const [credsMsgType, setCredsMsgType] = useState<"success" | "error" | null>( - null, - ); - const queryClient = useQueryClient(); - - // v1.12.1 — read off the consolidated /api/integrations/status - // envelope; the per-card /api/whoop/status round-trip is gone. - const status = viewModel; - - const disconnect = useMutation({ - mutationFn: async () => { - await apiPost("/api/whoop/disconnect"); - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: queryKeys.whoop() }); - queryClient.invalidateQueries({ - queryKey: queryKeys.integrationsStatus(), - }); - }, - }); - - // Clear a parked integration via the resume endpoint. The CTA is - // rendered inside the parked banner below; success invalidates both - // the per-card status (so the pill flips back to connected - // immediately) and the cross-integration envelope (so any other view - // picks up the change on its next focus). - const resume = useMutation({ - mutationFn: async () => { - return apiPost<{ resumed: boolean; wasParked: boolean }>( - "/api/integrations/whoop/resume", - ); - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: queryKeys.whoop() }); - queryClient.invalidateQueries({ - queryKey: queryKeys.integrationsStatus(), - }); - }, - }); - - async function handleSync(fullSync = false) { - setSyncing(true); - setSyncResult(null); - try { - const res = await apiFetchRaw("/api/whoop/sync", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ fullSync }), - }); - const json = await res.json(); - const result = res.ok ? readSyncOutcome(json) : null; - if (result) { - // The tone comes off what the run wrote, not off `res.ok`. - setSyncResult({ - outcome: result.outcome, - message: describeSyncOutcome( - result, - t("settings.whoopSyncResult", { count: result.imported }), - ), - }); - void invalidateKeys(queryClient, measurementDependentKeys); - queryClient.invalidateQueries({ queryKey: queryKeys.whoop() }); - queryClient.invalidateQueries({ - queryKey: queryKeys.integrationsStatus(), - }); - } else { - setSyncResult({ - outcome: "failed", - message: json?.error || t("settings.whoopSyncFailed"), - }); - } - } catch { - setSyncResult({ - outcome: "failed", - message: t("settings.whoopSyncFailed"), - }); - } finally { - setSyncing(false); - } - } - - async function handleSaveCredentials(e: React.FormEvent) { - e.preventDefault(); - setCredsSaving(true); - setCredsMsg(null); - setCredsMsgType(null); - - try { - const res = await apiFetchRaw("/api/whoop/credentials", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - clientId: clientId.trim(), - clientSecret: clientSecret.trim(), - }), - }); - - if (res.ok) { - setCredsMsg(t("settings.whoopCredentialsSaved")); - setCredsMsgType("success"); - setClientId(""); - setClientSecret(""); - queryClient.invalidateQueries({ queryKey: queryKeys.whoop() }); - } else { - try { - const json = await res.json(); - setCredsMsg(json.error || t("settings.savingError")); - } catch { - setCredsMsg(t("settings.savingError")); - } - setCredsMsgType("error"); - } - } catch { - setCredsMsg(t("common.networkError")); - setCredsMsgType("error"); - } - setCredsSaving(false); - } - - // The server resolves the verdict; the card only projects it. `connected` - // rides into that resolution, so a disconnected provider still lands on the - // "Not connected" pill without a second local rule here. - const pillState = pillStateFor(viewModel); - const pillLastSyncAt = pillTimestampFor(viewModel); - const errorMessage = - (pillState === "error" || - pillState === "parked" || - pillState === "warning") && - viewModel?.lastError - ? viewModel.lastError - : null; - - return ( - - - } - status={ - - } - /> - -
    - {errorMessage && } - {/* Parked-integration resume CTA. Surfaces only when the row - state is `parked` (>24h of persistent failures). The button - POSTs to /api/integrations/whoop/resume which calls - `resumeIntegrationFromPark`; on success the per-card status - invalidates and the pill flips back to connected without a - page refresh. Wider tap target than the inline action row so - it stays reachable on a Pixel 5 viewport. */} - {pillState === "parked" && ( -
    - - {t("settings.integrationPill.parkedReconnect")} - - -
    - )} - {resume.isError && ( -

    - {t("settings.integrationPill.resumeError")} -

    - )} - - {status?.connected && ( - - )} - {resume.isSuccess && resume.data?.wasParked && ( -

    - {t("settings.integrationPill.resumeSuccess")} -

    - )} - -
    -

    - {t("settings.whoopCredentials")} -

    - {!status?.configured && ( - - )} -
    -
    -
    - - setClientId(e.target.value)} - placeholder={ - status?.configured - ? t("settings.whoopCredentialsSavedPlaceholder") - : t("settings.whoopClientId") - } - maxLength={200} - autoComplete="off" - inputMode="text" - spellCheck={false} - autoCapitalize="none" - enterKeyHint="next" - /> -
    -
    - - setClientSecret(e.target.value)} - placeholder={ - status?.configured - ? t("settings.whoopCredentialsSavedPlaceholderSecret") - : t("settings.whoopClientSecret") - } - maxLength={200} - autoComplete="off" - inputMode="text" - spellCheck={false} - autoCapitalize="none" - enterKeyHint="done" - /> -
    -
    - - - - {credsMsg && ( -

    - {credsMsg} -

    - )} -
    -
    - - {status?.connected ? ( - <> -
    - - - - - - - - - {t("settings.whoopFullSyncTitle")} - - - {t("settings.whoopFullSyncDescription")} - - - - {t("common.cancel")} - handleSync(true)}> - {t("settings.whoopSynchronize")} - - - - - - - - - - - - - {t("settings.whoopDisconnectTitle")} - - - {t("settings.whoopDisconnectDescription")} - - - - {t("common.cancel")} - disconnect.mutate()} - > - {t("settings.whoopDisconnect")} - - - - -
    - {status?.backfillCompleted === false && ( -

    - {t("settings.whoopBackfillInProgress")} -

    - )} - {syncResult && ( - - )} - {/* connect→data loop: a discreet link to where this provider's - readings now surface — doubles as the "your data is richer" - cue. */} - - {t("settings.whoopViewData")} - - - - ) : status?.configured ? ( - - - - ) : ( -

    - {t("settings.whoopNoCredentials")} -

    - )} -
    - -
    - ); -} diff --git a/src/components/settings/integrations/withings-card.tsx b/src/components/settings/integrations/withings-card.tsx deleted file mode 100644 index b58e7f9da..000000000 --- a/src/components/settings/integrations/withings-card.tsx +++ /dev/null @@ -1,573 +0,0 @@ -"use client"; - -import { useState } from "react"; -import Link from "next/link"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { - ArrowRight, - Link2, - Loader2, - RefreshCw, - Save, - Unlink, -} from "lucide-react"; - -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, - AlertDialogTrigger, -} from "@/components/ui/alert-dialog"; -import { Button } from "@/components/ui/button"; -import { SettingsCardActions } from "@/components/settings/_card-actions"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { PasswordInput } from "@/components/ui/password-input"; -import { SettingsCard } from "@/components/settings/settings-card"; -import { SettingsCardHeader } from "@/components/settings/_card-header"; -import { IntegrationStatusPill } from "@/components/settings/integration-status-pill"; -import { TestConnectionButton } from "@/components/settings/test-connection-button"; -import { apiFetchRaw, apiPost } from "@/lib/api/api-fetch"; -import { WrittenOutcomeLine } from "@/components/outcome/written-outcome-line"; -import { useTranslations } from "@/lib/i18n/context"; -import { - invalidateKeys, - measurementDependentKeys, - queryKeys, -} from "@/lib/query-keys"; - -import { - IntegrationErrorMessage, - pillStateFor, - pillFailurePropsFor, - pillTimestampFor, - type IntegrationStatusViewModel, -} from "./shared"; -import { MetricFreshnessDisclosure } from "./metric-freshness-disclosure"; -import { WithingsPlanNote } from "./withings-plan-note"; -import { - readSyncOutcome, - useSyncOutcomeMessage, - type SyncOutcomeState, -} from "./sync-outcome"; -import { - CallbackMismatchNotice, - IntegrationCardDescription, - IntegrationRedirectGuide, -} from "./setup-guide-link"; -import type { IntegrationCallbackUrls } from "@/lib/integrations/callback-urls"; -import { loadDocument } from "@/lib/navigation/load-document"; - -export function WithingsCard({ - viewModel, - callbackUrl, -}: { - viewModel: IntegrationStatusViewModel | undefined; - /** Server-resolved OAuth callback URL; see `getIntegrationCallbackUrls`. */ - callbackUrl: IntegrationCallbackUrls["withings"]; -}) { - const { t } = useTranslations(); - const describeSyncOutcome = useSyncOutcomeMessage(); - const [syncing, setSyncing] = useState(false); - const [syncResult, setSyncResult] = useState(null); - const [clientId, setClientId] = useState(""); - const [clientSecret, setClientSecret] = useState(""); - const [credsSaving, setCredsSaving] = useState(false); - const [credsMsg, setCredsMsg] = useState(null); - const [credsMsgType, setCredsMsgType] = useState<"success" | "error" | null>( - null, - ); - const queryClient = useQueryClient(); - - // v1.12.1 — the Withings status is read off the consolidated - // /api/integrations/status envelope (the `viewModel` prop). The - // per-card /api/withings/status round-trip is gone; the envelope - // carries every field this card renders (connected / configured / - // last-sync / activity scope). - const status = viewModel; - - const disconnect = useMutation({ - mutationFn: async () => { - await apiPost("/api/withings/disconnect"); - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: queryKeys.withings() }); - queryClient.invalidateQueries({ - queryKey: queryKeys.integrationsStatus(), - }); - }, - }); - - // v1.4.43 W14 — clear a parked integration via the resume endpoint. - // The CTA is rendered inside the parked banner below; success - // invalidates both the per-card status (so the pill flips back to - // connected immediately) and the cross-integration envelope (so any - // other view picks up the change on its next focus). - const resume = useMutation({ - mutationFn: async () => { - return apiPost<{ resumed: boolean; wasParked: boolean }>( - "/api/integrations/withings/resume", - ); - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: queryKeys.withings() }); - queryClient.invalidateQueries({ - queryKey: queryKeys.integrationsStatus(), - }); - }, - }); - - async function handleSync(fullSync = false) { - setSyncing(true); - setSyncResult(null); - try { - const res = await apiFetchRaw("/api/withings/sync", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ fullSync }), - }); - const json = await res.json(); - const result = res.ok ? readSyncOutcome(json) : null; - if (result) { - // The tone comes off what the run wrote, not off `res.ok`. - setSyncResult({ - outcome: result.outcome, - message: describeSyncOutcome( - result, - fullSync - ? t("settings.withingsFullSyncResult", { count: result.imported }) - : t("settings.withingsSyncResult", { count: result.imported }), - ), - }); - void invalidateKeys(queryClient, measurementDependentKeys); - queryClient.invalidateQueries({ - queryKey: queryKeys.integrationsStatus(), - }); - } else { - setSyncResult({ - outcome: "failed", - message: json?.error || t("settings.withingsSyncFailed"), - }); - } - } catch { - setSyncResult({ - outcome: "failed", - message: t("settings.withingsSyncFailed"), - }); - } finally { - setSyncing(false); - } - } - - async function handleSaveCredentials(e: React.FormEvent) { - e.preventDefault(); - setCredsSaving(true); - setCredsMsg(null); - setCredsMsgType(null); - - try { - const res = await apiFetchRaw("/api/withings/credentials", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - clientId: clientId.trim(), - clientSecret: clientSecret.trim(), - }), - }); - - if (res.ok) { - setCredsMsg(t("settings.withingsCredentialsSaved")); - setCredsMsgType("success"); - setClientId(""); - setClientSecret(""); - queryClient.invalidateQueries({ queryKey: queryKeys.withings() }); - } else { - try { - const json = await res.json(); - setCredsMsg(json.error || t("settings.savingError")); - } catch { - setCredsMsg(t("settings.savingError")); - } - setCredsMsgType("error"); - } - } catch { - setCredsMsg(t("common.networkError")); - setCredsMsgType("error"); - } - setCredsSaving(false); - } - - // The pill state derives from the cross-integration status - // envelope. `legacyLastSyncedAt` is the connection row's own - // last-sync timestamp (carried in the same envelope) so the pill - // still paints a fresh "X min ago" right after a manual sync once - // the envelope refetches. When the envelope hasn't answered yet we - // fall back to "disconnected" so the card never renders status-less. - // The server resolves the verdict; the card only projects it. `connected` - // rides into that resolution, so a disconnected provider still lands on the - // "Not connected" pill without a second local rule here. - const pillState = pillStateFor(viewModel); - const pillLastSyncAt = pillTimestampFor(viewModel); - // v1.4.43 W14 — `parked` and `error` both want the underlying error - // message surfaced under the pill (the pill says "what" — the - // message says "why"). Other states leave the inline line off. - const errorMessage = - (pillState === "error" || - pillState === "parked" || - pillState === "warning") && - viewModel?.lastError - ? viewModel.lastError - : null; - - return ( - - - } - status={ - - } - /> - -
    - {errorMessage && } - {/* v1.4.25 W5d — Withings activity-scope reconnect banner. - Only paints when (a) the user has an active Withings - connection (so the credentials are already saved + a token - exists) AND (b) the persisted scope is missing - `user.activity`. Reconnecting takes the user through the - standard /api/withings/connect → Withings → callback flow; - the callback persists the upgraded scope and the banner - vanishes on the next status poll. */} - {status?.connected && status?.hasActivityScope === false && ( - - - {t("settings.integrations.withings.reconnect.banner.title")} - - - {t("settings.integrations.withings.reconnect.banner.body")} - - - {t("settings.integrations.withings.reconnect.banner.action")} - {" →"} - - - )} - {/* v1.4.43 W14 — parked-integration resume CTA. Surfaces only - when the row state is `parked` (>24h of persistent - failures). The button POSTs to /api/integrations/withings/ - resume which calls `resumeIntegrationFromPark`; on success - the per-card status invalidates and the pill flips back to - connected without a page refresh. The button is the - primary action the user can take from this card — wider - tap target than the inline action row so it's reachable - on a Pixel 5 viewport. */} - {pillState === "parked" && ( -
    - - {t("settings.integrationPill.parkedReconnect")} - - -
    - )} - {resume.isError && ( -

    - {t("settings.integrationPill.resumeError")} -

    - )} - - {status?.connected && ( - - )} - - {status?.connected && ( - - )} - {resume.isSuccess && resume.data?.wasParked && ( -

    - {t("settings.integrationPill.resumeSuccess")} -

    - )} -
    -

    - {t("settings.withingsCredentials")} -

    - {!status?.configured && ( - - )} -
    - {/* v1.4.27 MB7 / CF-53 — the credentials grid drops from - a 3-column row (client-id / secret / save) to a - 2-column row of inputs at `sm:`, with the Save button - lifted out into its own row below. The previous - "invisible Label" hack to align the button with the - input baselines fell apart on Galaxy Fold; lifting - the button into a dedicated `flex justify-end` row - gives it consistent placement on every viewport and - lets the input pair span the full width. */} -
    -
    - - setClientId(e.target.value)} - placeholder={ - status?.configured - ? t("settings.withingsCredentialsSavedPlaceholder") - : t("settings.withingsClientId") - } - maxLength={200} - autoComplete="off" - inputMode="text" - spellCheck={false} - autoCapitalize="none" - enterKeyHint="next" - /> -
    -
    - - setClientSecret(e.target.value)} - placeholder={ - status?.configured - ? t("settings.withingsCredentialsSavedPlaceholderSecret") - : t("settings.withingsClientSecret") - } - maxLength={200} - autoComplete="off" - inputMode="text" - spellCheck={false} - autoCapitalize="none" - enterKeyHint="done" - /> -
    -
    - - - - {credsMsg && ( -

    - {credsMsg} -

    - )} -
    -
    - - {status?.connected ? ( - <> - {/* v1.4.27 MB7 / CF-57 — the action row already wraps via - `flex-wrap`, but on Pixel 5 the four AlertDialog + - test-connection triggers each took a fractional slot - that read jagged. Force each button to a `min-w-[10rem]` - on ` - - - - - - - - - {t("settings.withingsFullSyncTitle")} - - - {t("settings.withingsFullSyncDescription")} - - - - {t("common.cancel")} - handleSync(true)}> - {t("settings.withingsSynchronize")} - - - - - - - - - - - - - {t("settings.withingsDisconnectTitle")} - - - {t("settings.withingsDisconnectDescription")} - - - - {t("common.cancel")} - disconnect.mutate()} - > - {t("settings.withingsDisconnect")} - - - - -
    - {syncResult && ( - - )} - {/* connect→data loop: a discreet link to where this provider's - readings now surface — doubles as the "your data is richer" - cue. */} - - {t("settings.withingsViewData")} - - - - ) : status?.configured ? ( - - - - ) : ( -

    - {t("settings.withingsNoCredentials")} -

    - )} - - -
    - ); -} diff --git a/src/components/settings/integrations/withings-plan-note.tsx b/src/components/settings/integrations/withings-plan-note.tsx deleted file mode 100644 index 433dfcab4..000000000 --- a/src/components/settings/integrations/withings-plan-note.tsx +++ /dev/null @@ -1,84 +0,0 @@ -"use client"; - -import { ExternalLink } from "lucide-react"; - -import { useTranslations } from "@/lib/i18n/context"; -import { - WITHINGS_API_PLANS_URL, - type WithingsPlanFeature, -} from "@/lib/withings/plan-features"; - -/** - * A calm note on the Withings card when the account's API plan withholds some - * readings (status 277 since the 2026-10-12 plan split). Not an error: the - * sync skips those readings and keeps importing the rest, so this sits in the - * card body in the info wash, never in the pill. - */ -export function WithingsPlanNote({ - features, -}: { - features: readonly WithingsPlanFeature[] | undefined; -}) { - const { t, locale } = useTranslations(); - if (!features || features.length === 0) return null; - - const labels: Record = { - spo2: t("settings.integrations.withings.plan.items.spo2"), - vascularAge: t("settings.integrations.withings.plan.items.vascularAge"), - pulseWaveVelocity: t( - "settings.integrations.withings.plan.items.pulseWaveVelocity", - ), - vo2Max: t("settings.integrations.withings.plan.items.vo2Max"), - visceralFat: t("settings.integrations.withings.plan.items.visceralFat"), - skinTemperature: t( - "settings.integrations.withings.plan.items.skinTemperature", - ), - ecg: t("settings.integrations.withings.plan.items.ecg"), - sleepScore: t("settings.integrations.withings.plan.items.sleepScore"), - sleepHrv: t("settings.integrations.withings.plan.items.sleepHrv"), - sleepHeartRate: t( - "settings.integrations.withings.plan.items.sleepHeartRate", - ), - sleepRespiratoryRate: t( - "settings.integrations.withings.plan.items.sleepRespiratoryRate", - ), - sleepSpo2: t("settings.integrations.withings.plan.items.sleepSpo2"), - bodyTemperature: t( - "settings.integrations.withings.plan.items.bodyTemperature", - ), - bodyFat: t("settings.integrations.withings.plan.items.bodyFat"), - fatMass: t("settings.integrations.withings.plan.items.fatMass"), - fatFreeMass: t("settings.integrations.withings.plan.items.fatFreeMass"), - muscleMass: t("settings.integrations.withings.plan.items.muscleMass"), - bodyWater: t("settings.integrations.withings.plan.items.bodyWater"), - boneMass: t("settings.integrations.withings.plan.items.boneMass"), - heartRate: t("settings.integrations.withings.plan.items.heartRate"), - bloodPressure: t("settings.integrations.withings.plan.items.bloodPressure"), - weight: t("settings.integrations.withings.plan.items.weight"), - }; - const list = new Intl.ListFormat(locale, { - style: "long", - type: "conjunction", - }).format(features.map((f) => labels[f])); - - return ( -
    -

    - {t("settings.integrations.withings.plan.title")} -

    -

    {t("settings.integrations.withings.plan.body", { list })}

    - - {t("settings.integrations.withings.plan.link")} - -
    - ); -} diff --git a/src/components/settings/managed-integration-status.tsx b/src/components/settings/managed-integration-status.tsx index ee2756674..b878f2a89 100644 --- a/src/components/settings/managed-integration-status.tsx +++ b/src/components/settings/managed-integration-status.tsx @@ -1,28 +1,27 @@ "use client"; import { useQuery } from "@tanstack/react-query"; -import { Plug } from "lucide-react"; +import { Droplet, ExternalLink, Plug } from "lucide-react"; -import { SettingsCardHeader } from "@/components/settings/_card-header"; -import { IntegrationStatusPill } from "@/components/settings/integration-status-pill"; -import { SettingsCard } from "@/components/settings/settings-card"; +import { IntegrationCard } from "@/components/settings/integrations/integration-card"; +import { OAUTH_INTEGRATIONS } from "@/components/settings/integrations/oauth-integration-card"; import { QueryErrorCard } from "@/components/ui/query-error-card"; +import { SectionHeading } from "@/components/ui/section-heading"; import { apiGet } from "@/lib/api/api-fetch"; import { useAuth } from "@/hooks/use-auth"; import { useTranslations } from "@/lib/i18n/context"; +import type { ConnectionHealth } from "@/lib/integrations/connection-health"; import { queryKeys } from "@/lib/query-keys"; import { assertRecordSettingsResponseForRecord } from "@/lib/record-settings"; -type ManagedIntegrationState = - "connected" | "error_transient" | "error_reauth" | "disconnected" | "parked"; +type ManagedIntegrationKey = keyof typeof INTEGRATION_DISPLAY_NAMES; interface ManagedIntegrationStatusResponse { recordId: string; integrations: Array<{ - integration: keyof typeof INTEGRATION_DISPLAY_NAMES; - state: ManagedIntegrationState; - lastSuccessAt: string | null; - lastAttemptAt: string | null; + integration: ManagedIntegrationKey; + /** The server-resolved state; null while the provider is not connected. */ + health: ConnectionHealth | null; }>; } @@ -37,25 +36,24 @@ const INTEGRATION_DISPLAY_NAMES = { strava: "Strava", } as const; -function recordStatusPillState(state: ManagedIntegrationState) { - switch (state) { - case "connected": - return "connected" as const; - case "error_transient": - return "warning" as const; - case "error_reauth": - return "error" as const; - case "parked": - return "parked" as const; - case "disconnected": - return "disconnected" as const; - } +/** Where the guardian boundary is explained, connected services included. */ +const SHARING_DOCS_HREF = + "https://docs.healthlog.dev/self-hosting/account-sharing#records-with-no-login"; + +function iconFor(key: ManagedIntegrationKey) { + if (key === "nightscout") return Droplet; + return OAUTH_INTEGRATIONS[key]?.icon ?? Plug; } /** - * The managed-record integration view is deliberately a status projection. - * It reads the active record's id from the resolved account-access payload and - * has no connection, credential, sync, or provider-control affordance. + * The managed record's connections, on the same card every integration in + * Settings renders, from the same server-resolved `health`. + * + * Read-only by construction: a guardian cannot connect, reconnect, resume, + * sync, test or disconnect a managed record's provider, because an OAuth flow + * cannot safely carry a record selector. So the cards carry no menu and no + * button, and one sentence above them says why, with the page that explains + * the boundary. */ export function ManagedIntegrationStatus() { const { t } = useTranslations(); @@ -78,60 +76,58 @@ export function ManagedIntegrationStatus() { return void statusQuery.refetch()} />; } - if (statusQuery.isLoading || !statusQuery.data) { - return ( - - -

    - {t("settings.sharedRecord.integrationStatusDetail")} -

    -
    + const loading = statusQuery.isLoading || !statusQuery.data; + const entries = + statusQuery.data?.integrations ?? + (Object.keys(INTEGRATION_DISPLAY_NAMES) as ManagedIntegrationKey[]).map( + (integration) => ({ integration, health: null }), ); - } return ( - - +

    + {t("settings.sharedRecord.integrationStatusDetail")}{" "} + + {t("settings.sharedRecord.integrationStatusDocs")} + +

      - {statusQuery.data.integrations.map((integration) => ( -
    • - - {INTEGRATION_DISPLAY_NAMES[integration.integration]} - - ( +
    • +
    • ))}
    -
    + ); } diff --git a/src/components/symptoms/__tests__/load-symptom-events.test.ts b/src/components/symptoms/__tests__/load-symptom-events.test.ts new file mode 100644 index 000000000..4a9098f35 --- /dev/null +++ b/src/components/symptoms/__tests__/load-symptom-events.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vitest"; + +import type { SymptomEventDTO } from "@/lib/symptoms/shared"; +import { loadSymptomEvents, type SymptomEventList } from "../use-symptoms"; + +/** + * A fake of `GET /api/symptoms/events`: newest first, `to` inclusive, at most + * `limit` rows, `hasMore` when the page is full. + */ +function fakeRoute(all: SymptomEventDTO[]) { + const urls: string[] = []; + const fetchPage = async (url: string): Promise => { + urls.push(url); + const params = new URL(url, "http://localhost").searchParams; + const to = params.get("to")!; + const limit = Number(params.get("limit")); + const rows = all + .filter((e) => e.occurredAt <= to) + .sort((a, b) => (a.occurredAt < b.occurredAt ? 1 : -1)) + .slice(0, limit); + return { events: rows, limit, hasMore: rows.length === limit }; + }; + return { urls, fetchPage }; +} + +function event(i: number, occurredAt: string): SymptomEventDTO { + return { id: `e-${i}`, occurredAt } as SymptomEventDTO; +} + +describe("loadSymptomEvents", () => { + it("reads past the first page until the window is exhausted", async () => { + const base = Date.parse("2026-06-01T00:00:00.000Z"); + // 1,200 occurrences an hour apart, two of them sharing a page boundary. + const all = Array.from({ length: 1_200 }, (_, i) => + event(i, new Date(base - i * 3_600_000).toISOString()), + ); + all.push(event(9_999, all[499].occurredAt)); + const { urls, fetchPage } = fakeRoute(all); + + const out = await loadSymptomEvents( + "1900-01-01T00:00:00.000Z", + "2026-06-02T00:00:00.000Z", + "ep-1", + fetchPage, + ); + + expect(out.events).toHaveLength(1_201); + expect(new Set(out.events.map((e) => e.id)).size).toBe(1_201); + expect(out.hasMore).toBe(false); + expect(urls.length).toBeGreaterThan(2); + expect(urls.every((u) => u.includes("episodeId=ep-1"))).toBe(true); + }); + + it("stops after one request when the first page is not full", async () => { + const { urls, fetchPage } = fakeRoute([ + event(1, "2026-05-01T00:00:00.000Z"), + ]); + const out = await loadSymptomEvents( + "2026-04-01T00:00:00.000Z", + "2026-06-01T00:00:00.000Z", + null, + fetchPage, + ); + expect(out.events).toHaveLength(1); + expect(urls).toHaveLength(1); + }); +}); diff --git a/src/components/symptoms/use-symptoms.ts b/src/components/symptoms/use-symptoms.ts index a95ebaed2..9c900f10c 100644 --- a/src/components/symptoms/use-symptoms.ts +++ b/src/components/symptoms/use-symptoms.ts @@ -55,9 +55,55 @@ export function useSymptomDefinitions(includeHidden = false, enabled = true) { }); } +/** Occurrences per request while walking a window. */ +const SYMPTOM_EVENTS_PAGE_SIZE = 500; + +/** + * Every occurrence inside `[from, to]` (ISO instants), newest first, read page + * by page. The route answers at most one page and says `hasMore`; each + * further page asks up to the oldest instant already read (`to` is + * inclusive, so the boundary instant comes back and is dropped by id). A + * chronic episode can hold more than one page, and a single capped read + * showed only its newest part. + */ +export async function loadSymptomEvents( + from: string, + to: string, + episodeId: string | null, + fetchPage: (url: string) => Promise = (url) => + apiGet(url), +): Promise { + const seen = new Set(); + const events: SymptomEventDTO[] = []; + let upTo = to; + for (;;) { + const params = new URLSearchParams({ + from, + to: upTo, + limit: String(SYMPTOM_EVENTS_PAGE_SIZE), + }); + if (episodeId) params.set("episodeId", episodeId); + const page = await fetchPage(`/api/symptoms/events?${params}`); + let added = 0; + for (const event of page.events) { + if (seen.has(event.id)) continue; + seen.add(event.id); + events.push(event); + added += 1; + } + const oldest = page.events[page.events.length - 1]; + // A page that adds nothing (a full page of one instant) cannot move the + // bound, so it ends the walk rather than repeating forever. + if (!page.hasMore || !oldest || added === 0) { + return { events, limit: SYMPTOM_EVENTS_PAGE_SIZE, hasMore: false }; + } + upTo = oldest.occurredAt; + } +} + /** * Occurrences inside `[from, to]` (ISO instants), optionally only those filed - * against one episode. At most 500, newest first. + * against one episode. Every one of them, newest first. */ export function useSymptomEvents( from: string, @@ -68,11 +114,7 @@ export function useSymptomEvents( return useQuery({ queryKey: queryKeys.symptomEvents(from, to, episodeId), enabled: options.enabled ?? true, - queryFn: () => { - const params = new URLSearchParams({ from, to, limit: "500" }); - if (episodeId) params.set("episodeId", episodeId); - return apiGet(`/api/symptoms/events?${params}`); - }, + queryFn: () => loadSymptomEvents(from, to, episodeId), }); } diff --git a/src/components/vaccinations/vaccination-document-picker.tsx b/src/components/vaccinations/vaccination-document-picker.tsx index 6fb195261..da8f17a0e 100644 --- a/src/components/vaccinations/vaccination-document-picker.tsx +++ b/src/components/vaccinations/vaccination-document-picker.tsx @@ -5,10 +5,11 @@ * transcribed from. The shared {@link EntityLinkPicker} — an inline summary * (removable chips + an add button) over a searchable, month-grouped sheet. * - * The whole vault is offered, not only the pages dated near the dose: one + * The whole vault is reachable, not only the pages dated near the dose: one * childhood record commonly covers a dozen doses given years apart, and it - * belongs on every one of them. The pages dated within the upload - * suggestion's window of this dose sit on top as suggestions. + * belongs on every one of them. The picker opens on the pages around the + * dose's date, the ones within the upload suggestion's window on top as + * suggestions, and pages older and newer or searches the vault from there. * * **The gate blanks the block, it does not post-filter it.** When the * `inboundDocuments` module is off, this renders nothing — no heading, no empty @@ -23,11 +24,15 @@ * than showing an empty set, which would both hide the links and offer to * save their removal. */ +import { useMemo } from "react"; import { FolderOpen } from "lucide-react"; -import { useTranslations } from "@/lib/i18n/context"; +import { useFormatters, useTranslations } from "@/lib/i18n/context"; import { EntityLinkPicker } from "@/components/links/entity-link-picker"; -import { useVaultDocumentOptions } from "@/components/links/vault-document-options"; +import { + linkedDocumentOption, + useVaultDocumentOptions, +} from "@/components/links/vault-document-options"; import { ImportFromSourceButton } from "@/components/documents/sources/import-from-source-button"; import { mergeIds } from "@/components/documents/sources/merge-ids"; @@ -35,6 +40,7 @@ export function VaccinationDocumentPicker({ enabled, anchor, documentIds, + linkedDocuments, seedPending = false, seedError = false, onRetrySeed, @@ -45,6 +51,15 @@ export function VaccinationDocumentPicker({ /** The dose's own date (ISO), for the suggestions on top. */ anchor: string | null; documentIds: string[]; + /** + * The pages the dose is filed against, as its own read names them, so their + * chips show whatever part of the vault is loaded. + */ + linkedDocuments?: ReadonlyArray<{ + id: string; + label: string | null; + date: string | null; + }>; /** The dose's own links are still loading. */ seedPending?: boolean; /** Reading the dose's own links failed. */ @@ -53,7 +68,15 @@ export function VaccinationDocumentPicker({ onChange: (documentIds: string[]) => void; }) { const { t } = useTranslations(); + const format = useFormatters(); const vault = useVaultDocumentOptions({ enabled, anchor }); + const known = useMemo( + () => + (linkedDocuments ?? []) + .map((link) => linkedDocumentOption(link, (iso) => format.date(iso))) + .filter((option) => option !== null), + [linkedDocuments, format], + ); if (!enabled) return null; @@ -73,6 +96,8 @@ export function VaccinationDocumentPicker({ selected={documentIds} onChange={onChange} options={vault.options} + server={vault.server} + knownOptions={known} searchPlaceholder={t("links.picker.searchPlaceholder")} emptyLabel={t("vaccinations.form.linkNothingToOffer")} extraAction={ diff --git a/src/components/vaccinations/vaccination-form.tsx b/src/components/vaccinations/vaccination-form.tsx index aff06ca44..bdb30c955 100644 --- a/src/components/vaccinations/vaccination-form.tsx +++ b/src/components/vaccinations/vaccination-form.tsx @@ -191,6 +191,12 @@ export function VaccinationForm({ */ savedDocuments?: { ids: string[] | null; + /** The same links with the label and date the detail read carries. */ + links?: ReadonlyArray<{ + id: string; + label: string | null; + date: string | null; + }>; pending: boolean; error: boolean; retry: () => void; @@ -376,6 +382,7 @@ export function VaccinationForm({ enabled={user?.modules?.inboundDocuments === true} anchor={anchor} documentIds={draft.documentIds ?? savedDocuments?.ids ?? []} + linkedDocuments={savedDocuments?.links} seedPending={ draft.documentIds === null && (savedDocuments?.pending ?? false) } diff --git a/src/components/vaccinations/vaccination-sheet.tsx b/src/components/vaccinations/vaccination-sheet.tsx index 3f76cc21b..3cb0e8130 100644 --- a/src/components/vaccinations/vaccination-sheet.tsx +++ b/src/components/vaccinations/vaccination-sheet.tsx @@ -141,6 +141,7 @@ export function VaccinationSheet({ ids: detail.data?.documents?.map((document) => document.id) ?? null, + links: detail.data?.documents, pending: detail.isPending, error: detail.isError, retry: () => void detail.refetch(), diff --git a/src/lib/analytics/sleep-night.ts b/src/lib/analytics/sleep-night.ts index 3d5997189..cbc3dc4a1 100644 --- a/src/lib/analytics/sleep-night.ts +++ b/src/lib/analytics/sleep-night.ts @@ -316,6 +316,15 @@ export interface SleepNight { * "sources disagree" hint. */ sourceDiscrepancy: SleepSourceDiscrepancy | null; + /** + * The night's MAIN session alone: the same-wake-day session with the most + * asleep minutes (the one `sourceDiscrepancy` reads), its wake instant and + * its own asleep minutes. A nap on the same wake day is in the night's + * totals but not here, so a reader that places the night on the clock (the + * sleep midpoint) is not dragged into the afternoon by it. Null when no + * session carries asleep minutes. + */ + mainSession: { end: Date; asleepMinutes: number } | null; } /** Sentinel for rows that carry no source — collapses to one bucket. */ @@ -738,6 +747,13 @@ export function reconstructSleepNights( sourceDiscrepancy: mainSessionRows ? sessionSourceDiscrepancy(mainSessionRows) : null, + mainSession: + mainSessionRows && mainSessionAsleep > 0 + ? { + end: new Date(mainSessionEnd), + asleepMinutes: mainSessionAsleep, + } + : null, }); } return nights.sort((a, b) => (a.night < b.night ? -1 : 1)); diff --git a/src/lib/data-wipe/wipe-plan.ts b/src/lib/data-wipe/wipe-plan.ts index da0dbb4af..a247fd823 100644 --- a/src/lib/data-wipe/wipe-plan.ts +++ b/src/lib/data-wipe/wipe-plan.ts @@ -231,6 +231,7 @@ export const WIPE_MODELS = [ "GoogleHealthOAuthState", "McpOAuthConnection", "IntegrationStatus", + "IntegrationWatch", // ── Notification transports ───────────────────────────────────────────── "NotificationChannel", diff --git a/src/lib/export/backup-plan.ts b/src/lib/export/backup-plan.ts index 340e4b68e..94d737678 100644 --- a/src/lib/export/backup-plan.ts +++ b/src/lib/export/backup-plan.ts @@ -670,6 +670,8 @@ export const NOT_IN_BACKUP_MODELS: Readonly> = { "A person's API token for their Paperless-ngx or Papra instance. A backup file is not a credential store, same reasoning as ApiToken; and restored on another host it would point at an address that host's operator never allowed in DOCUMENT_SOURCE_ORIGINS. The documents imported through it are carried with the vault; reconnecting takes one form.", IntegrationStatus: "A ledger of what this deployment's syncs did. Restoring it onto another host would assert a sync history that host never had.", + IntegrationWatch: + "What this host's integration watchdog observed and which operator incidents it announced. Restored elsewhere it would assert silences and alerts that host never saw.", // Credentials and device identity. ApiToken: diff --git a/src/lib/google-health/__tests__/backfill-resume-progress.test.ts b/src/lib/google-health/__tests__/backfill-resume-progress.test.ts index aa80d605d..69e9715e9 100644 --- a/src/lib/google-health/__tests__/backfill-resume-progress.test.ts +++ b/src/lib/google-health/__tests__/backfill-resume-progress.test.ts @@ -20,9 +20,17 @@ vi.mock("@/lib/logging/context", () => ({ })); import { + readGoogleHealthBackfillCursors, + recordGoogleHealthBackfillPage, runGoogleHealthCollection, runWithGoogleHealthBackfillResume, + type GoogleHealthBackfillCursor, } from "../sync-core"; +import { + googleHealthSliceBudget, + runWithGoogleHealthClientOutcome, + runWithGoogleHealthStop, +} from "../client"; beforeEach(() => { vi.clearAllMocks(); @@ -41,7 +49,9 @@ describe("runGoogleHealthCollection under a backfill", () => { expect([...done]).toEqual(["fetchSteps", "sleep"]); expect(prismaMock.googleHealthConnection.update).toHaveBeenCalledWith({ where: { userId: "u1" }, - data: { backfillProgress: { done: ["fetchSteps", "sleep"] } }, + data: { + backfillProgress: { done: ["fetchSteps", "sleep"], cursors: {} }, + }, }); }); @@ -63,3 +73,183 @@ describe("runGoogleHealthCollection under a backfill", () => { ); }); }); + +/** + * #1220 — a collection read page by page keeps its place inside the + * collection, not only once it is finished. A heart-rate history of years + * that one attempt could not finish used to start over from its newest page + * on every attempt. + */ +describe("the resume edge of a page-by-page collection", () => { + const at = (iso: string) => new Date(iso); + + it("moves the edge to the oldest sample of each page and persists it", async () => { + prismaMock.googleHealthConnection.update.mockResolvedValue({}); + const cursors = new Map(); + + const { advanced } = await runWithGoogleHealthBackfillResume( + "u1", + new Set(["sleep"]), + async () => { + await recordGoogleHealthBackfillPage("fetchHeartRate", { + first: at("2026-10-01T12:00:00Z"), + last: at("2026-10-01T00:00:00Z"), + }); + await recordGoogleHealthBackfillPage("fetchHeartRate", { + first: at("2026-09-30T23:59:00Z"), + last: at("2026-09-30T08:00:00Z"), + }); + }, + cursors, + ); + + expect(cursors.get("fetchHeartRate")).toEqual({ + at: "2026-09-30T08:00:00.000Z", + order: "desc", + pages: 2, + }); + expect(advanced.get("fetchHeartRate")).toBeNull(); + const last = prismaMock.googleHealthConnection.update.mock.calls.at( + -1, + )![0] as { data: { backfillProgress: unknown } }; + expect(last.data.backfillProgress).toEqual({ + done: ["sleep"], + cursors: { + fetchHeartRate: { + at: "2026-09-30T08:00:00.000Z", + order: "desc", + pages: 2, + }, + }, + }); + // And it reads back the way it was written. + expect( + readGoogleHealthBackfillCursors(last.data.backfillProgress).get( + "fetchHeartRate", + ), + ).toEqual(cursors.get("fetchHeartRate")); + }); + + it("follows a walk that runs oldest first", async () => { + prismaMock.googleHealthConnection.update.mockResolvedValue({}); + const cursors = new Map(); + await runWithGoogleHealthBackfillResume( + "u1", + new Set(), + () => + recordGoogleHealthBackfillPage("fetchWeight", { + first: at("2024-01-01T00:00:00Z"), + last: at("2024-03-01T00:00:00Z"), + }), + cursors, + ); + expect(cursors.get("fetchWeight")).toMatchObject({ + at: "2024-03-01T00:00:00.000Z", + order: "asc", + }); + }); + + it("does not count a page that moved nothing as progress", async () => { + prismaMock.googleHealthConnection.update.mockResolvedValue({}); + const cursors = new Map([ + [ + "fetchHeartRate", + { at: "2026-01-01T00:00:00.000Z", order: "desc", pages: 7 }, + ], + ]); + const { advanced } = await runWithGoogleHealthBackfillResume( + "u1", + new Set(), + () => + // The edge sample itself, read again: no further than before. + recordGoogleHealthBackfillPage("fetchHeartRate", { + first: at("2026-01-01T00:00:00Z"), + last: at("2026-01-01T00:00:00Z"), + }), + cursors, + ); + expect(advanced.size).toBe(0); + expect(cursors.get("fetchHeartRate")).toMatchObject({ + at: "2026-01-01T00:00:00.000Z", + pages: 8, + }); + }); + + it("counts no progress when the edge cannot be written", async () => { + prismaMock.googleHealthConnection.update.mockRejectedValue( + new Error("connection reset"), + ); + const cursors = new Map(); + const { advanced } = await runWithGoogleHealthBackfillResume( + "u1", + new Set(), + () => + recordGoogleHealthBackfillPage("fetchHeartRate", { + first: at("2026-10-01T12:00:00Z"), + last: at("2026-10-01T00:00:00Z"), + }), + cursors, + ); + expect(advanced.size).toBe(0); + expect(cursors.size).toBe(0); + expect(warnings.some((w) => w.includes("progress write failed"))).toBe( + true, + ); + }); + + it("clears a finished collection's edge with the same write that marks it done", async () => { + prismaMock.googleHealthConnection.update.mockResolvedValue({}); + const cursors = new Map([ + [ + "fetchHeartRate", + { at: "2026-01-01T00:00:00.000Z", order: "desc", pages: 7 }, + ], + ]); + const done = new Set(); + await runWithGoogleHealthBackfillResume( + "u1", + done, + () => runGoogleHealthCollection("fetchHeartRate", async () => 1), + cursors, + ); + expect([...done]).toEqual(["fetchHeartRate"]); + expect(cursors.size).toBe(0); + expect(prismaMock.googleHealthConnection.update).toHaveBeenCalledWith({ + where: { userId: "u1" }, + data: { backfillProgress: { done: ["fetchHeartRate"], cursors: {} } }, + }); + }); + + it("leaves a collection for the next slice once the slice has no budget left", async () => { + prismaMock.googleHealthConnection.update.mockResolvedValue({}); + const walk = vi.fn(async () => 5); + const budget = googleHealthSliceBudget(0, 100); + const { result } = await runWithGoogleHealthClientOutcome(() => + runWithGoogleHealthBackfillResume("u1", new Set(), () => + runWithGoogleHealthStop( + () => false, + () => runGoogleHealthCollection("fetchHeartRate", walk), + budget, + ), + ), + ); + expect(walk).not.toHaveBeenCalled(); + expect(result.collections).toEqual([ + { key: "fetchHeartRate", status: "deferred", pages: 0, durationMs: 0 }, + ]); + expect(budget.stopReason).toBe("time_budget"); + }); + + it("marks the cycle incomplete when it leaves a collection for later", async () => { + const { outcome } = await runWithGoogleHealthClientOutcome(() => + runWithGoogleHealthBackfillResume("u1", new Set(), () => + runWithGoogleHealthStop( + () => false, + () => runGoogleHealthCollection("fetchWeight", async () => 0), + googleHealthSliceBudget(0, 100), + ), + ), + ); + expect(outcome.truncated).toBe(true); + }); +}); diff --git a/src/lib/google-health/__tests__/edge-filter-fallback.test.ts b/src/lib/google-health/__tests__/edge-filter-fallback.test.ts new file mode 100644 index 000000000..af018c13e --- /dev/null +++ b/src/lib/google-health/__tests__/edge-filter-fallback.test.ts @@ -0,0 +1,643 @@ +/** + * A resumed backfill walk asks Google for the samples up to its edge with an + * upper bound (`sample_time.physical_time <= ""`). That bound is valid + * filter grammar but has not been seen working against the live API. If + * Google refuses it, every resumed slice would fail on its first request and + * the backfill would be stuck again. + * + * - A 400 on the first resumed request is answered by reading the + * collection again without the bound and passing over the samples beyond + * the edge here; the rows handed on are the ones the bound would have + * returned. + * - The refusal is noted per collection in the backfill's progress, and the + * next slice reads without the bound from the start. + * - A 400 that is about something else still fails the walk. + * - The pages passed over are not charged against the slice's budget, so a + * skip deeper than one slice's pages cannot keep a backfill from moving. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { safeFetchMock, prismaMock } = vi.hoisted(() => ({ + safeFetchMock: vi.fn(), + prismaMock: { googleHealthConnection: { update: vi.fn() } }, +})); +vi.mock("@/lib/safe-fetch", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, safeFetch: safeFetchMock }; +}); +vi.mock("@/lib/db", () => ({ prisma: prismaMock })); +vi.mock("@/lib/logging/context", () => ({ + annotate: () => {}, + getEvent: () => null, +})); + +import { + GOOGLE_HEALTH_DATA_TYPES, + GOOGLE_HEALTH_SKIP_MS, + type GoogleHealthDataPoint, + forEachDataPointPage, + googleHealthSliceBudget, + refusesEdgeFilter, + runWithGoogleHealthClientOutcome, + runWithGoogleHealthStop, +} from "../client"; +import { GoogleHealthApiError } from "../response-classifier"; +import { + googleHealthBackfillCursor, + noteGoogleHealthEdgeFilterUnsupported, + readGoogleHealthBackfillCursors, + readGoogleHealthBackfillEdgeFilterUnsupported, + recordGoogleHealthBackfillPage, + recordGoogleHealthBackfillResumePoint, + runWithGoogleHealthBackfillResume, + type GoogleHealthBackfillCursor, +} from "../sync-core"; + +const MINUTE = 60_000; +const NOW = Date.parse("2026-01-01T00:00:00Z"); +/** Reading `g` is `g` minutes old; the list runs newest first. */ +const TOTAL = 40; +const PAGE = 5; +const at = (g: number) => new Date(NOW - g * MINUTE); + +const server = { refuseUpperBound: false, requests: [] as string[] }; + +/** + * A heart-rate list built from the filter and the page token it is asked + * with, like the multi-year integration fixture. With `refuseUpperBound`, + * any filter carrying `<=` is refused the way Google refuses a filter. + */ +function respond(url: string): Response { + const u = new URL(url); + const filter = u.searchParams.get("filter") ?? ""; + server.requests.push(filter); + if (server.refuseUpperBound && filter.includes("<=")) { + return json( + { + error: { + code: 400, + status: "INVALID_ARGUMENT", + message: "Invalid filter expression.", + }, + }, + 400, + ); + } + const upTo = /physical_time <= "([^"]+)"/.exec(filter)?.[1]; + const newest = upTo + ? Math.max(0, Math.ceil((NOW - Date.parse(upTo)) / MINUTE)) + : 0; + const size = Number(u.searchParams.get("pageSize")); + const token = Number(u.searchParams.get("pageToken") ?? 0); + const start = newest + token * size; + const end = Math.min(TOTAL, start + size); + const dataPoints = []; + for (let g = start; g < end; g++) { + dataPoints.push({ + heartRate: { + beatsPerMinute: String(60 + g), + sampleTime: { physicalTime: at(g).toISOString() }, + }, + }); + } + return json({ + dataPoints, + ...(end < TOTAL ? { nextPageToken: String(token + 1) } : {}), + }); +} + +function json(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); +} + +type Query = Parameters[3]; + +/** Walk and collect what the walk hands on, page by page. */ +async function walk(query: Query) { + const pages: GoogleHealthDataPoint[][] = []; + const { outcome } = await runWithGoogleHealthClientOutcome(() => + forEachDataPointPage( + GOOGLE_HEALTH_DATA_TYPES.heartRate, + "token", + "fetchHeartRate", + { pageSize: PAGE, ...query }, + (points) => { + pages.push(points); + }, + ), + ); + return { pages, rows: pages.flat(), outcome }; +} + +beforeEach(() => { + safeFetchMock.mockReset(); + safeFetchMock.mockImplementation(async (url: string) => respond(url)); + prismaMock.googleHealthConnection.update.mockReset(); + prismaMock.googleHealthConnection.update.mockResolvedValue({}); + server.refuseUpperBound = false; + server.requests = []; +}); + +// The edge sits mid-page so the page that crosses it is split. +const EDGE = { at: at(17), order: "desc" as const }; + +describe("a resumed walk whose edge filter Google refuses", () => { + it("reads again without the bound and hands on exactly the rows the bound would have", async () => { + const filtered = await walk({ resumable: true, edge: EDGE }); + expect(server.requests[0]).toContain("<="); + expect(filtered.rows).toHaveLength(TOTAL - 17); + + server.requests = []; + server.refuseUpperBound = true; + const onFilterUnsupported = vi.fn(); + const fallback = await walk({ + resumable: true, + edge: { ...EDGE, onFilterUnsupported }, + }); + + expect(onFilterUnsupported).toHaveBeenCalledTimes(1); + // One refused request, then the whole walk without any filter. + expect(server.requests[0]).toContain("<="); + expect(server.requests.slice(1).every((f) => f === "")).toBe(true); + expect(fallback.rows).toEqual(filtered.rows); + expect(fallback.outcome.truncated).toBe(false); + }); + + it("reads without the bound from the start once the collection is known to refuse it", async () => { + const filtered = await walk({ resumable: true, edge: EDGE }); + server.requests = []; + server.refuseUpperBound = true; + const onFilterUnsupported = vi.fn(); + + const skipped = await walk({ + resumable: true, + edge: { ...EDGE, filter: false, onFilterUnsupported }, + }); + + expect(server.requests.some((f) => f.includes("<="))).toBe(false); + expect(onFilterUnsupported).not.toHaveBeenCalled(); + expect(skipped.rows).toEqual(filtered.rows); + }); + + it("follows a walk that runs oldest first", async () => { + // An ascending walk keeps the samples at or after its edge. + safeFetchMock.mockImplementation(async () => + json({ + dataPoints: [5, 4, 3, 2, 1, 0].map((g) => ({ + heartRate: { + beatsPerMinute: "60", + sampleTime: { physicalTime: at(g).toISOString() }, + }, + })), + }), + ); + const { rows } = await walk({ + resumable: true, + edge: { at: at(2), order: "asc", filter: false }, + }); + expect(rows).toHaveLength(3); + }); +}); + +describe("a 400 that is not about the edge filter", () => { + it("fails the walk when the first request names something else", async () => { + safeFetchMock.mockImplementation(async () => + json( + { + error: { + code: 400, + status: "INVALID_ARGUMENT", + message: "Invalid page token.", + }, + }, + 400, + ), + ); + const onFilterUnsupported = vi.fn(); + await expect( + walk({ resumable: true, edge: { ...EDGE, onFilterUnsupported } }), + ).rejects.toBeInstanceOf(GoogleHealthApiError); + expect(safeFetchMock).toHaveBeenCalledTimes(1); + expect(onFilterUnsupported).not.toHaveBeenCalled(); + }); + + it("fails the walk on a 400 past the first page, where the filter was already taken", async () => { + let n = 0; + safeFetchMock.mockImplementation(async (url: string) => + ++n === 2 + ? json({ error: { code: 400, status: "INVALID_ARGUMENT" } }, 400) + : respond(url), + ); + const onFilterUnsupported = vi.fn(); + await expect( + walk({ resumable: true, edge: { ...EDGE, onFilterUnsupported } }), + ).rejects.toBeInstanceOf(GoogleHealthApiError); + expect(onFilterUnsupported).not.toHaveBeenCalled(); + }); + + it("fails a walk without an edge on a 400 as before", async () => { + safeFetchMock.mockImplementation(async () => + json({ error: { code: 400, status: "INVALID_ARGUMENT" } }, 400), + ); + await expect(walk({ resumable: true })).rejects.toBeInstanceOf( + GoogleHealthApiError, + ); + expect(safeFetchMock).toHaveBeenCalledTimes(1); + }); + + it("is told apart by what Google says", () => { + expect(refusesEdgeFilter(undefined)).toBe(true); + expect(refusesEdgeFilter("INVALID_ARGUMENT: Invalid filter.")).toBe(true); + expect(refusesEdgeFilter("INVALID_ARGUMENT")).toBe(true); + expect(refusesEdgeFilter("FAILED_PRECONDITION: filter on field")).toBe( + true, + ); + expect(refusesEdgeFilter("INVALID_ARGUMENT: Invalid page token.")).toBe( + false, + ); + expect(refusesEdgeFilter("INVALID_ARGUMENT: page_size too large")).toBe( + false, + ); + expect(refusesEdgeFilter("FAILED_PRECONDITION: account not set up")).toBe( + false, + ); + }); +}); + +describe("passing over the samples beyond the edge inside a slice", () => { + it("is not charged against the slice's pages, so the slice still writes its share", async () => { + // The edge is three pages deep; the slice may read two. + const budget = googleHealthSliceBudget(60_000, 2); + const { pages, outcome } = await runWithGoogleHealthStop( + () => false, + () => + walk({ + resumable: true, + edge: { at: at(15), order: "desc", filter: false }, + }), + budget, + ); + expect(pages).toHaveLength(2); + expect(budget.skippedPages).toBe(3); + expect(budget.pages).toBe(5); + expect(budget.stopReason).toBe("page_budget"); + expect(outcome.truncated).toBe(true); + }); + + it("stops on the slice's clock while it is still passing over, and keeps its place", async () => { + let clock = 0; + safeFetchMock.mockImplementation(async (url: string) => { + clock += 10_000; + return respond(url); + }); + // Each request takes ten seconds and the slice has fifteen; passing over + // the first three pages takes thirty. The slice does not run on past its + // clock for the skip any more: it stops and saves where it got to. + const budget = googleHealthSliceBudget(15_000, 500, () => clock); + const onResumePoint = vi.fn(); + const { pages, outcome } = await runWithGoogleHealthStop( + () => false, + () => + walk({ + resumable: true, + edge: { at: at(15), order: "desc", filter: false, onResumePoint }, + }), + budget, + ); + expect(pages).toHaveLength(0); + expect(budget.deadline).toBe(15_000); + expect(budget.stopReason).toBe("time_budget"); + expect(outcome.truncated).toBe(true); + expect(onResumePoint).toHaveBeenCalledWith({ token: "1", page: 1 }); + }); + + it("stops passing over on its own limit inside a long slice, and keeps its place", async () => { + let clock = 0; + safeFetchMock.mockImplementation(async (url: string) => { + clock += 60_000; + return respond(url); + }); + // The edge is six pages deep; each request takes a minute, and the skip + // may run for three of the slice's sixty. + const budget = googleHealthSliceBudget(3_600_000, 500, () => clock); + const onResumePoint = vi.fn(); + const { pages, outcome } = await runWithGoogleHealthStop( + () => false, + () => + walk({ + resumable: true, + edge: { at: at(30), order: "desc", filter: false, onResumePoint }, + }), + budget, + ); + expect(GOOGLE_HEALTH_SKIP_MS).toBe(3 * 60_000); + expect(pages).toHaveLength(0); + expect(safeFetchMock).toHaveBeenCalledTimes(3); + expect(outcome.truncated).toBe(true); + // The slice itself is not over: other collections may still run. + expect(budget.stopReason).toBeNull(); + expect(onResumePoint).toHaveBeenCalledWith({ token: "2", page: 2 }); + }); + + it("goes on from the saved place, rereading only its page", async () => { + const filtered = await walk({ + resumable: true, + edge: { at: at(30), order: "desc" }, + }); + server.requests = []; + safeFetchMock.mockClear(); + const tokens: Array = []; + safeFetchMock.mockImplementation(async (url: string) => { + tokens.push(new URL(url).searchParams.get("pageToken")); + return respond(url); + }); + const { rows, outcome } = await walk({ + resumable: true, + edge: { + at: at(30), + order: "desc", + filter: false, + resumeFrom: { token: "2", page: 2 }, + }, + }); + expect(rows).toEqual(filtered.rows); + expect(tokens).toEqual(["2", "3", "4", "5", "6", "7"]); + expect(outcome.truncated).toBe(false); + }); + + it("saves its place on a rate limit it cannot wait out", async () => { + let n = 0; + safeFetchMock.mockImplementation(async (url: string) => + ++n === 3 + ? new Response( + JSON.stringify({ + error: { code: 429, status: "RESOURCE_EXHAUSTED" }, + }), + { + status: 429, + headers: { + "content-type": "application/json", + "retry-after": "3600", + }, + }, + ) + : respond(url), + ); + const budget = googleHealthSliceBudget(3_600_000, 500); + const onResumePoint = vi.fn(); + const { pages, outcome } = await runWithGoogleHealthStop( + () => false, + () => + walk({ + resumable: true, + edge: { at: at(30), order: "desc", filter: false, onResumePoint }, + }), + budget, + ); + expect(pages).toHaveLength(0); + expect(budget.stopReason).toBe("rate_limited"); + expect(outcome.truncated).toBe(true); + expect(onResumePoint).toHaveBeenCalledWith({ token: "1", page: 1 }); + }); + + it("reads from the newest page when Google refuses the saved token", async () => { + let n = 0; + safeFetchMock.mockImplementation(async (url: string) => + ++n === 1 + ? json( + { + error: { + code: 400, + status: "INVALID_ARGUMENT", + message: "Invalid page token.", + }, + }, + 400, + ) + : respond(url), + ); + const { rows, outcome } = await walk({ + resumable: true, + edge: { + at: at(30), + order: "desc", + filter: false, + resumeFrom: { token: "2", page: 2 }, + }, + }); + expect(rows).toHaveLength(TOTAL - 30); + expect(outcome.truncated).toBe(false); + }); + + it("reads from the newest page when the saved place no longer reaches the edge", async () => { + // Page 7 holds only samples older than the edge: the list moved under + // the token, and going on from it could step over samples nobody wrote. + const { rows } = await walk({ + resumable: true, + edge: { + at: at(30), + order: "desc", + filter: false, + resumeFrom: { token: "7", page: 7 }, + }, + }); + expect(rows.map((p) => p.heartRate)).toEqual( + Array.from({ length: TOTAL - 30 }, (_, i) => ({ + beatsPerMinute: String(60 + 30 + i), + sampleTime: { physicalTime: at(30 + i).toISOString() }, + })), + ); + }); + + it("does not charge the pages it passed over against the walk's own ceiling", async () => { + // Three pages passed over, then a ceiling of two written pages: both are + // written, not one. + const { pages, outcome } = await walk({ + resumable: true, + maxPages: 2, + edge: { at: at(15), order: "desc", filter: false }, + }); + expect(pages).toHaveLength(2); + expect(outcome.truncated).toBe(true); + }); + + it("still stops on the job's own budget", async () => { + let asked = 0; + const { pages, outcome } = await runWithGoogleHealthStop( + () => ++asked > 2, + () => + walk({ + resumable: true, + edge: { at: at(30), order: "desc", filter: false }, + }), + googleHealthSliceBudget(60_000, 500), + ); + expect(pages).toHaveLength(0); + expect(outcome.truncated).toBe(true); + }); + + it("gives up past its own ceiling instead of reading without end", async () => { + const { pages, outcome } = await walk({ + resumable: true, + edge: { at: at(30), order: "desc", filter: false, maxSkipPages: 2 }, + }); + expect(pages).toHaveLength(0); + expect(safeFetchMock).toHaveBeenCalledTimes(2); + expect(outcome.truncated).toBe(true); + }); +}); + +describe("the note that a collection refuses the edge filter", () => { + /** The progress the last write left in the column. */ + const lastProgress = () => + prismaMock.googleHealthConnection.update.mock.calls.at(-1)![0].data + .backfillProgress as unknown; + + it("is written once Google refuses, kept by later writes, and honoured by the next slice", async () => { + server.refuseUpperBound = true; + const cursors = new Map([ + [ + "fetchHeartRate", + { at: EDGE.at.toISOString(), order: "desc" as const, pages: 4 }, + ], + ]); + + // Slice one: resumes with the bound, is refused, falls back, writes a page. + await runWithGoogleHealthBackfillResume( + "u1", + new Set(), + async () => { + const cursor = googleHealthBackfillCursor("fetchHeartRate")!; + expect(cursor.edge?.filter).toBe(true); + await walk({ + resumable: true, + maxPages: 1, + edge: { + ...cursor.edge!, + onFilterUnsupported: () => + noteGoogleHealthEdgeFilterUnsupported("fetchHeartRate"), + }, + }); + await recordGoogleHealthBackfillPage("fetchHeartRate", { + first: at(17), + last: at(19), + }); + }, + new Map(cursors), + ); + expect(server.requests.filter((f) => f.includes("<="))).toHaveLength(1); + const progress = lastProgress(); + expect(progress).toMatchObject({ + edgeFilterUnsupported: ["fetchHeartRate"], + cursors: { fetchHeartRate: { at: at(19).toISOString() } }, + }); + + // Slice two: starts from what slice one left on disk. + server.requests = []; + const unsupported = readGoogleHealthBackfillEdgeFilterUnsupported(progress); + await runWithGoogleHealthBackfillResume( + "u1", + new Set(), + async () => { + const cursor = googleHealthBackfillCursor("fetchHeartRate")!; + expect(cursor.edge?.filter).toBe(false); + const { rows } = await walk({ resumable: true, edge: cursor.edge }); + expect(rows).toHaveLength(TOTAL - 19); + }, + readGoogleHealthBackfillCursors(progress), + unsupported, + ); + expect(server.requests.some((f) => f.includes("<="))).toBe(false); + }); + + it("leaves the progress in its old shape for a backfill that never met the refusal", async () => { + await runWithGoogleHealthBackfillResume("u1", new Set(), async () => { + await recordGoogleHealthBackfillPage("fetchHeartRate", { + first: at(0), + last: at(4), + }); + }); + expect(lastProgress()).not.toHaveProperty("edgeFilterUnsupported"); + }); + + it("keeps a saved place in the list, counts it as progress only when it moves forward, and hands it to the next slice", async () => { + server.refuseUpperBound = true; + const cursor = { + at: at(30).toISOString(), + order: "desc" as const, + pages: 4, + }; + const run = ( + cursors: Map, + point: { token: string; page: number }, + ) => + runWithGoogleHealthBackfillResume( + "u1", + new Set(), + () => recordGoogleHealthBackfillResumePoint("fetchHeartRate", point), + cursors, + new Set(["fetchHeartRate"]), + ); + + const first = await run(new Map([["fetchHeartRate", cursor]]), { + token: "4", + page: 4, + }); + expect(first.advanced.has("fetchHeartRate")).toBe(true); + const saved = readGoogleHealthBackfillCursors(lastProgress()); + expect(saved.get("fetchHeartRate")).toEqual({ + ...cursor, + resume: { token: "4", page: 4, reached: 4 }, + }); + + // The same place again, or one short of it, is not progress. + const again = await run(new Map(saved), { token: "4", page: 4 }); + expect(again.advanced.size).toBe(0); + const back = await run(new Map(saved), { token: "2", page: 2 }); + expect(back.advanced.size).toBe(0); + expect( + readGoogleHealthBackfillCursors(lastProgress()).get("fetchHeartRate") + ?.resume, + ).toEqual({ token: "2", page: 2, reached: 4 }); + + // A page written keeps the place; the next slice is handed it. + await runWithGoogleHealthBackfillResume( + "u1", + new Set(), + () => + recordGoogleHealthBackfillPage("fetchHeartRate", { + first: at(30), + last: at(34), + }), + new Map(saved), + new Set(["fetchHeartRate"]), + ); + const next = readGoogleHealthBackfillCursors(lastProgress()); + expect(next.get("fetchHeartRate")?.resume).toEqual( + saved.get("fetchHeartRate")?.resume, + ); + await runWithGoogleHealthBackfillResume( + "u1", + new Set(), + async () => { + expect( + googleHealthBackfillCursor("fetchHeartRate")?.edge, + ).toMatchObject({ filter: false, resumeFrom: { token: "4", page: 4 } }); + }, + next, + new Set(["fetchHeartRate"]), + ); + }); + + it("reads only well-formed keys", () => { + expect([ + ...readGoogleHealthBackfillEdgeFilterUnsupported({ + edgeFilterUnsupported: ["fetchHeartRate", 3, "x".repeat(65)], + }), + ]).toEqual(["fetchHeartRate"]); + expect(readGoogleHealthBackfillEdgeFilterUnsupported(null).size).toBe(0); + }); +}); diff --git a/src/lib/google-health/__tests__/rollup-request.test.ts b/src/lib/google-health/__tests__/rollup-request.test.ts index 54e23c31b..bed00a773 100644 --- a/src/lib/google-health/__tests__/rollup-request.test.ts +++ b/src/lib/google-health/__tests__/rollup-request.test.ts @@ -208,7 +208,8 @@ describe("fetchDailyRollUp — chunk walk + conservative fallback", () => { }); it("propagates a non-400 first-request failure without a fallback walk", async () => { - safeFetchMock.mockResolvedValueOnce(jsonResponse(500, {})); + // A 403 is neither the fallback's 400 nor a server error worth repeating. + safeFetchMock.mockResolvedValueOnce(jsonResponse(403, {})); await expect( fetchDailyRollUp(GOOGLE_HEALTH_DATA_TYPES.steps, "token", "fetchSteps", { start: new Date("2026-06-01T00:00:00Z"), diff --git a/src/lib/google-health/__tests__/sync-metrics-dense-pages.test.ts b/src/lib/google-health/__tests__/sync-metrics-dense-pages.test.ts index b4bf66c8e..becd43db9 100644 --- a/src/lib/google-health/__tests__/sync-metrics-dense-pages.test.ts +++ b/src/lib/google-health/__tests__/sync-metrics-dense-pages.test.ts @@ -32,6 +32,10 @@ vi.mock("../sync-core", () => ({ (_userId: string, info: { accessToken: string }) => async () => info.accessToken, runGoogleHealthCollection: (_key: string, fn: () => Promise) => fn(), + // Outside a backfill: no resume edge, nothing to record. + googleHealthBackfillCursor: () => null, + googleHealthHardFailureCount: () => 0, + recordGoogleHealthBackfillPage: vi.fn(async () => {}), })); vi.mock("@/lib/db", () => ({ diff --git a/src/lib/google-health/__tests__/sync-metrics-failsoft.test.ts b/src/lib/google-health/__tests__/sync-metrics-failsoft.test.ts index 0a4c021e0..1f0ce79d2 100644 --- a/src/lib/google-health/__tests__/sync-metrics-failsoft.test.ts +++ b/src/lib/google-health/__tests__/sync-metrics-failsoft.test.ts @@ -58,6 +58,10 @@ vi.mock("../sync-core", () => ({ (_userId: string, info: { accessToken: string }) => async () => info.accessToken, runGoogleHealthCollection: (_key: string, fn: () => Promise) => fn(), + // Outside a backfill: no resume edge, nothing to record. + googleHealthBackfillCursor: () => null, + googleHealthHardFailureCount: () => 0, + recordGoogleHealthBackfillPage: vi.fn(async () => {}), upsertGoogleHealthMeasurements: vi.fn(async () => ({ imported: 0, touched: [], diff --git a/src/lib/google-health/__tests__/sync-time-budget.test.ts b/src/lib/google-health/__tests__/sync-time-budget.test.ts new file mode 100644 index 000000000..8b2bd3b4b --- /dev/null +++ b/src/lib/google-health/__tests__/sync-time-budget.test.ts @@ -0,0 +1,85 @@ +/** + * A caller's time limit (`shouldStop`, the hourly job's budget) reaches every + * walk of the sync cycle, so a walk stops when it is spent; and a cycle that + * stopped on it is not stamped, so the next run reads the rest from the same + * watermark. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { prismaMock, recordSyncSuccess, resources } = vi.hoisted(() => { + return { + prismaMock: { + integrationStatus: { findUnique: vi.fn(async () => null) }, + googleHealthConnection: { + findUnique: vi.fn(async () => ({ lastSyncedAt: null })), + update: vi.fn(async () => ({})), + }, + }, + recordSyncSuccess: vi.fn(async () => {}), + resources: { + workout: vi.fn(async () => 0), + sleep: vi.fn(async () => 0), + activity: vi.fn(async () => 0), + metrics: vi.fn(async () => 0), + }, + }; +}); + +vi.mock("@/lib/db", () => ({ prisma: prismaMock })); + +// The account lock is a Postgres advisory lock on a connection of its own; +// these cases run without a database, so the lock is always free. +vi.mock("../sync-lock", () => ({ + withGoogleHealthSyncLock: async ( + _userId: string, + run: () => Promise, + ) => ({ + ran: true, + result: await run(), + }), +})); +vi.mock("@/lib/integrations/status", () => ({ + isReauthRequired: vi.fn(async () => false), + recordSyncSuccess, +})); +vi.mock("@/lib/rollups/measurement-rollups", () => ({ + collapseToTypeDayKeys: vi.fn(() => []), + recomputeUserRollups: vi.fn(async () => {}), +})); +vi.mock("@/lib/insights/comprehensive-generate", () => ({ + invalidateStatusInsightsForTypes: vi.fn(async () => {}), +})); +vi.mock("../sync-workout", () => ({ syncUserWorkout: resources.workout })); +vi.mock("../sync-sleep", () => ({ syncUserSleep: resources.sleep })); +vi.mock("../sync-activity", () => ({ syncUserActivity: resources.activity })); +vi.mock("../sync-metrics", () => ({ syncUserMetrics: resources.metrics })); + +import { googleHealthSliceExhausted } from "../client"; +import { syncUserGoogleHealth } from "../sync"; + +beforeEach(() => { + vi.clearAllMocks(); + prismaMock.googleHealthConnection.findUnique.mockResolvedValue({ + lastSyncedAt: null, + }); +}); + +describe("the caller's time limit inside the sync cycle", () => { + it("is what every resource's walks are asked, and an untouched one never stops", async () => { + let spent = false; + const asked: boolean[] = []; + resources.metrics.mockImplementation(async () => { + asked.push(googleHealthSliceExhausted()); + spent = true; + asked.push(googleHealthSliceExhausted()); + return 0; + }); + await syncUserGoogleHealth("budget-user", { shouldStop: () => spent }); + expect(asked).toEqual([false, true]); + + asked.length = 0; + spent = false; + await syncUserGoogleHealth("budget-user"); + expect(asked).toEqual([false, false]); + }); +}); diff --git a/src/lib/google-health/__tests__/walk-resume-and-retry.test.ts b/src/lib/google-health/__tests__/walk-resume-and-retry.test.ts new file mode 100644 index 000000000..ecaba31c6 --- /dev/null +++ b/src/lib/google-health/__tests__/walk-resume-and-retry.test.ts @@ -0,0 +1,192 @@ +/** + * #1220 — the list walk under a backfill slice. + * + * - A server error or a request that got no answer is repeated a few times + * instead of ending a walk of thousands of pages. + * - A resumable walk asks only for the samples beyond the edge an earlier + * attempt reached. + * - Inside a slice, a resumable walk stops on the slice's budget and on a + * rate limit it cannot wait out, and reports itself truncated rather than + * failing; a walk that is not resumable runs on under the job's limit. + */ +import { afterAll, beforeEach, describe, expect, it, vi } from "vitest"; + +const { safeFetchMock } = vi.hoisted(() => ({ safeFetchMock: vi.fn() })); +vi.mock("@/lib/safe-fetch", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, safeFetch: safeFetchMock }; +}); +vi.mock("@/lib/logging/context", () => ({ + annotate: () => {}, + getEvent: () => null, +})); + +import { SafeFetchError } from "@/lib/safe-fetch"; +import { + GOOGLE_HEALTH_DATA_TYPES, + GOOGLE_HEALTH_TRANSIENT_RETRY, + forEachDataPointPage, + googleHealthSliceBudget, + runWithGoogleHealthClientOutcome, + runWithGoogleHealthStop, +} from "../client"; +import { GoogleHealthApiError } from "../response-classifier"; + +function page(n: number, next: boolean, status = 200, headers = {}): Response { + return new Response( + JSON.stringify( + status === 200 + ? { + dataPoints: [{ n }], + ...(next ? { nextPageToken: String(n + 1) } : {}), + } + : { error: { code: status } }, + ), + { status, headers: { "content-type": "application/json", ...headers } }, + ); +} + +const baseDelay = GOOGLE_HEALTH_TRANSIENT_RETRY.baseDelayMs; +beforeEach(() => { + safeFetchMock.mockReset(); + GOOGLE_HEALTH_TRANSIENT_RETRY.baseDelayMs = 0; +}); +afterAll(() => { + GOOGLE_HEALTH_TRANSIENT_RETRY.baseDelayMs = baseDelay; +}); + +const walk = (query: Parameters[3] = {}) => + runWithGoogleHealthClientOutcome(() => + forEachDataPointPage( + GOOGLE_HEALTH_DATA_TYPES.heartRate, + "token", + "fetchHeartRate", + query, + () => {}, + ), + ); + +describe("a server error or a lost request mid-walk", () => { + it("is repeated and the walk goes on", async () => { + safeFetchMock + .mockResolvedValueOnce(page(0, true)) + .mockResolvedValueOnce(page(0, false, 503)) + .mockRejectedValueOnce(new SafeFetchError("aborted", "timeout")) + .mockResolvedValueOnce(page(1, false)); + + const { outcome } = await walk(); + + expect(safeFetchMock).toHaveBeenCalledTimes(4); + expect(outcome).toMatchObject({ pages: 2, truncated: false }); + }); + + it("fails the walk once the retries are spent", async () => { + safeFetchMock.mockResolvedValue(page(0, false, 500)); + await expect(walk()).rejects.toBeInstanceOf(GoogleHealthApiError); + expect(safeFetchMock).toHaveBeenCalledTimes( + GOOGLE_HEALTH_TRANSIENT_RETRY.maxRetries + 1, + ); + }); + + it("does not repeat an error that is not about the server", async () => { + safeFetchMock.mockRejectedValue(new TypeError("bad request init")); + await expect(walk()).rejects.toBeInstanceOf(TypeError); + expect(safeFetchMock).toHaveBeenCalledTimes(1); + }); +}); + +describe("a resumable walk", () => { + it("asks only for the samples at or beyond the recorded edge", async () => { + safeFetchMock.mockResolvedValue(page(0, false)); + await walk({ + resumable: true, + edge: { at: new Date("2025-03-01T08:00:00Z"), order: "desc" }, + }); + const url = new URL(safeFetchMock.mock.calls[0]![0] as string); + expect(url.searchParams.get("filter")).toBe( + 'heart_rate.sample_time.physical_time <= "2025-03-01T08:00:00.000Z"', + ); + }); + + it("stops on the slice's page budget and reports itself truncated", async () => { + safeFetchMock.mockImplementation(async (url: string) => + page(Number(new URL(url).searchParams.get("pageToken") ?? 0), true), + ); + const budget = googleHealthSliceBudget(60_000, 3); + const { outcome } = await runWithGoogleHealthStop( + () => false, + () => walk({ resumable: true }), + budget, + ); + expect(safeFetchMock).toHaveBeenCalledTimes(3); + expect(outcome.truncated).toBe(true); + expect(budget.stopReason).toBe("page_budget"); + }); + + it("starts the slice's clock at the first walk, not when the slice was set up", async () => { + safeFetchMock.mockResolvedValue(page(0, false)); + let clock = 0; + const budget = googleHealthSliceBudget(1_000, 500, () => clock); + // The slice waited ten minutes for the account's lock before walking. + clock = 600_000; + const { outcome } = await runWithGoogleHealthStop( + () => false, + () => walk({ resumable: true }), + budget, + ); + expect(outcome).toMatchObject({ pages: 1, truncated: false }); + expect(budget.deadline).toBe(601_000); + }); + + it("ends the slice on a rate limit it cannot wait out, without failing", async () => { + safeFetchMock + .mockResolvedValueOnce(page(0, true)) + .mockResolvedValueOnce(page(1, true, 429, { "retry-after": "3600" })); + const budget = googleHealthSliceBudget(60_000, 500); + const { outcome } = await runWithGoogleHealthStop( + () => false, + () => walk({ resumable: true }), + budget, + ); + expect(outcome).toMatchObject({ pages: 1, truncated: true }); + expect(budget.stopReason).toBe("rate_limited"); + }); + + it("still fails on a rate limit outside a slice", async () => { + safeFetchMock.mockResolvedValue( + page(0, true, 429, { "retry-after": "3600" }), + ); + await expect(walk({ resumable: true })).rejects.toBeInstanceOf( + GoogleHealthApiError, + ); + }); +}); + +describe("a walk that is not resumable", () => { + it("runs past the slice's budget to its end", async () => { + safeFetchMock.mockImplementation(async (url: string) => { + const n = Number(new URL(url).searchParams.get("pageToken") ?? 0); + return page(n, n < 5); + }); + const budget = googleHealthSliceBudget(0, 1); + const { outcome } = await runWithGoogleHealthStop( + () => false, + () => walk(), + budget, + ); + expect(safeFetchMock).toHaveBeenCalledTimes(6); + expect(outcome.truncated).toBe(false); + }); + + it("still stops on the job's own budget", async () => { + safeFetchMock.mockImplementation(async () => page(0, true)); + let asked = 0; + const { outcome } = await runWithGoogleHealthStop( + () => ++asked > 2, + () => walk(), + googleHealthSliceBudget(60_000, 500), + ); + expect(safeFetchMock).toHaveBeenCalledTimes(2); + expect(outcome.truncated).toBe(true); + }); +}); diff --git a/src/lib/google-health/client.ts b/src/lib/google-health/client.ts index 3bdab3aec..2cbf02b73 100644 --- a/src/lib/google-health/client.ts +++ b/src/lib/google-health/client.ts @@ -36,17 +36,18 @@ import { AsyncLocalStorage } from "node:async_hooks"; import { createHash, randomBytes } from "node:crypto"; import { annotate, getEvent } from "@/lib/logging/context"; -import { safeFetch } from "@/lib/safe-fetch"; +import { SafeFetchError, safeFetch } from "@/lib/safe-fetch"; import { wallClockInTz } from "@/lib/tz/wall-clock"; import { GoogleHealthApiError, classifyGoogleHealthResponse, type GoogleHealthClassification, } from "./response-classifier"; -import type { - GoogleHealthDataPoint, - GoogleHealthDataType, - GoogleHealthRollupPoint, +import { + googleHealthSampleInstant, + type GoogleHealthDataPoint, + type GoogleHealthDataType, + type GoogleHealthRollupPoint, } from "./mappers"; import { envValue } from "@/lib/env"; @@ -134,18 +135,176 @@ export async function runWithGoogleHealthClientOutcome( * reaching the page ceiling: the resource is incomplete, the cycle is not * stamped, and the reason shown is that only part of the history was * processed. Outside such a scope the walks never stop early. + * + * A backfill slice adds a second, tighter limit (`GoogleHealthSliceBudget`): + * minutes and pages rather than the job's hours. Only a walk that keeps its + * place across attempts (`resumable`) honours it, because only such a walk + * loses nothing by stopping; a walk that would start over from its first page + * runs on to the end under the job's limit alone. */ -const stopStorage = new AsyncLocalStorage<() => boolean>(); +interface GoogleHealthWalkLimits { + shouldStop: () => boolean; + slice?: GoogleHealthSliceBudget; +} -function stopRequested(): boolean { - return stopStorage.getStore()?.() === true; +/** Why a backfill slice stopped walking. */ +export type GoogleHealthSliceStopReason = + "time_budget" | "page_budget" | "rate_limited" | "job_budget"; + +/** + * One backfill slice's share of the work: a span of wall-clock time and a + * page count, both well inside the job's own expiry. The clock starts the + * first time a walk asks, which is after the slice has the account's lock: a + * slice that waited for an hourly sync to finish does not start out with its + * time already spent. `pages` counts every page the slice's walks have read; + * `stopReason` is set the first time a limit is reached and stays set, so + * every walk after it stops too. + */ +export interface GoogleHealthSliceBudget { + ms: number; + /** When the slice's time is up; null until the first walk asks. */ + deadline: number | null; + maxPages: number; + pages: number; + /** + * Of `pages`, those a walk only passed over on its way to its edge, because + * Google refused the edge filter (see `forEachDataPointPage`). They are not + * charged against `maxPages`. Their time is: a skip runs on the slice's + * clock like any other read, and on its own shorter limit + * (`GOOGLE_HEALTH_SKIP_MS`), and keeps its place for the next slice when it + * stops. Only a skip that had to start over from the newest page, because + * its saved place was refused, moves `deadline` on by the time it took. + */ + skippedPages: number; + stopReason: GoogleHealthSliceStopReason | null; + now: () => number; +} + +/** A fresh slice budget of `ms` milliseconds and `maxPages` pages. */ +export function googleHealthSliceBudget( + ms: number, + maxPages: number, + now: () => number = Date.now, +): GoogleHealthSliceBudget { + return { + ms, + deadline: null, + maxPages, + pages: 0, + skippedPages: 0, + stopReason: null, + now, + }; +} + +const stopStorage = new AsyncLocalStorage(); + +/** Whether the slice in scope has used its budget (and why, once it has). */ +function sliceExhausted(slice: GoogleHealthSliceBudget): boolean { + if (slice.stopReason) return true; + slice.deadline ??= slice.now() + slice.ms; + if (slice.now() >= slice.deadline) slice.stopReason = "time_budget"; + else if (slice.pages - slice.skippedPages >= slice.maxPages) { + slice.stopReason = "page_budget"; + } + return slice.stopReason !== null; +} + +/** + * Whether a walk should stop before its next request. Every walk stops on the + * job's limit; a resumable one also stops on the slice's. + */ +function stopRequested(resumable = false): boolean { + const limits = stopStorage.getStore(); + if (!limits) return false; + if (limits.shouldStop()) { + if (limits.slice && !limits.slice.stopReason) { + limits.slice.stopReason = "job_budget"; + } + return true; + } + return ( + resumable && limits.slice !== undefined && sliceExhausted(limits.slice) + ); +} + +/** + * Whether the backfill slice in scope has used its budget. A collection that + * has not started yet waits for the next slice rather than starting now. + * False outside a slice. + */ +export function googleHealthSliceExhausted(): boolean { + const limits = stopStorage.getStore(); + if (!limits) return false; + if (limits.shouldStop()) { + if (limits.slice && !limits.slice.stopReason) { + limits.slice.stopReason = "job_budget"; + } + return true; + } + return limits.slice !== undefined && sliceExhausted(limits.slice); +} + +/** Pages the slice in scope has read so far (0 outside a slice). */ +export function googleHealthSlicePagesRead(): number { + return stopStorage.getStore()?.slice?.pages ?? 0; +} + +/** Count one page read against the slice in scope, if any. */ +function noteSlicePage(): void { + const slice = stopStorage.getStore()?.slice; + if (slice) slice.pages += 1; +} + +/** + * Note one page the walk only passed over on its way to its edge: read, but + * not charged against the slice's pages (no-op outside a slice). + */ +function noteSliceSkip(): void { + const slice = stopStorage.getStore()?.slice; + if (slice) slice.skippedPages += 1; +} + +/** + * Give back to the slice in scope the time a skip took that had to start over + * from the newest page (no-op outside a slice). + */ +function extendSliceDeadline(ms: number): void { + const slice = stopStorage.getStore()?.slice; + if (slice && slice.deadline !== null) slice.deadline += ms; +} + +/** The slice clock in scope, or the wall clock outside a slice. */ +function sliceNow(): number { + return stopStorage.getStore()?.slice?.now() ?? Date.now(); +} + +/** End the slice in scope for `reason` (no-op outside a slice). */ +function endSlice(reason: GoogleHealthSliceStopReason): void { + const slice = stopStorage.getStore()?.slice; + if (slice && !slice.stopReason) slice.stopReason = reason; +} + +/** Whether a resumable walk can stop and keep its place here. */ +function inSlice(): boolean { + return stopStorage.getStore()?.slice !== undefined; } export async function runWithGoogleHealthStop( shouldStop: () => boolean, fn: () => Promise, + slice?: GoogleHealthSliceBudget, ): Promise { - return stopStorage.run(shouldStop, fn); + return stopStorage.run({ shouldStop, slice }, fn); +} + +/** + * Mark the resource in scope truncated without a walk: a collection a slice + * left for the next one. The cycle then reports itself incomplete, so the + * backfill does not mistake a skipped collection for an empty one. + */ +export function noteGoogleHealthDeferred(): void { + notePagination(0, 0, true); } export const GOOGLE_HEALTH_API_BASE = "https://health.googleapis.com/v4"; @@ -527,6 +686,7 @@ export function resolveGoogleHealthUserId( // camelCase nested objects (`sampleTime.physicalTime`, // `interval.startTime`, `civilStartTime.date`). // + // proto3 int64 fields arrive as JSON **strings** (`"12345"`) — every numeric // extractor coerces numeric strings before the finite check. @@ -553,6 +713,9 @@ export const GOOGLE_HEALTH_ACTIVITY_PAGE_SIZE = 25; */ export const GOOGLE_HEALTH_DENSE_MAX_PAGES = 10_000; +/** Page ceiling for every other list walk. */ +export const GOOGLE_HEALTH_DEFAULT_MAX_PAGES = 1000; + /** `dataPoints.list` envelope: `{ dataPoints, nextPageToken }`. */ interface GoogleHealthDataPointPage { dataPoints?: GoogleHealthDataPoint[]; @@ -578,8 +741,70 @@ interface DataPointQuery { * surfaces it so a live account reports which grammar Google accepted. */ onDateFilterStyle?: (style: GoogleHealthDateFilterStyle) => void; + /** + * The walk keeps its place across attempts (a backfill's resume cursor), so + * it may stop on the backfill slice's budget and on a rate limit it cannot + * wait out, and report itself truncated instead of failing: the next slice + * picks up from the edge it recorded. + */ + resumable?: boolean; + /** + * Where an earlier attempt of this walk got to: the oldest sample it wrote + * when the walk ran newest first (`desc`), the newest when it ran oldest + * first (`asc`). The walk asks only for the samples at or beyond it; the + * one sample on the edge itself is read again and overwrites in place. + * + * `filter: false` reads without the edge bound and passes over the samples + * beyond the edge here instead (see `forEachDataPointPage`); the walk falls + * back to that on its own when Google refuses the bound, and calls + * `onFilterUnsupported` once when it does. `tz` reads an offset-less sample + * time the way the edge was recorded. `maxSkipPages` bounds how far into + * the unbounded list the walk may get before it reaches the edge. + * + * A walk without the bound keeps its place in that list across slices: + * `resumeFrom` is where an earlier slice's walk stopped, and + * `onResumePoint` is told where this one stops (see + * `GoogleHealthResumePoint`). + */ + edge?: { + at: Date; + order: "desc" | "asc"; + filter?: boolean; + tz?: string; + onFilterUnsupported?: () => void | Promise; + maxSkipPages?: number; + resumeFrom?: GoogleHealthResumePoint; + onResumePoint?: (point: GoogleHealthResumePoint) => void | Promise; + }; } +/** + * Where a walk without the edge bound stopped in the unbounded list: the page + * token that fetched the last page it read, and that page's position (pages + * from the newest one, which is position 0). The next slice starts by reading + * that page again rather than the newest one, so it passes over only what is + * left between it and the edge instead of everything earlier slices wrote. + * + * Re-reading the last page, not the one after it, is what lets the walk check + * the token still points where it did: the page must hold a sample at or + * beyond the edge. A list that lost samples in between could otherwise move + * the token past samples nobody wrote. A page that fails the check, or a + * token Google refuses, sends the walk back to the newest page, the way it + * read before it kept its place. + */ +export interface GoogleHealthResumePoint { + token: string; + page: number; +} + +/** + * The longest a walk passes over samples beyond its edge in one slice before + * it stops and keeps its place for the next. Shorter than the slice, so a + * slice that has to skip still writes, and never longer than it: a skip runs + * on the slice's clock and the account's lock is held no longer than a slice. + */ +export const GOOGLE_HEALTH_SKIP_MS = 3 * 60 * 1000; + const pad2 = (n: number): string => String(n).padStart(2, "0"); /** @@ -797,6 +1022,21 @@ export function googleHealthRetryDelayMs( return undefined; } +/** + * How a data request rides out a server that did not answer properly: a + * 500, 502, 503 or 504, a timeout, or a broken connection. A short history + * reads a handful of pages and rarely meets one; a full-history heart-rate + * walk reads thousands, and a single one used to end the walk and throw away + * everything it had read. Three tries more, 2, 4 and 8 s apart (or the + * `Retry-After` a 503 names, up to a minute), before the request fails. + */ +export const GOOGLE_HEALTH_TRANSIENT_RETRY = { + maxRetries: 3, + baseDelayMs: 2_000, +}; + +const GOOGLE_HEALTH_TRANSIENT_STATUSES = new Set([500, 502, 503, 504]); + function waitMs(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } @@ -811,10 +1051,12 @@ interface GoogleHealthDataResponse { /** * Send one data request and answer with the response it finally got. * - * Two answers are handled here rather than handed back: + * Three answers are handled here rather than handed back: * - 429: the request waits (see `GOOGLE_HEALTH_RATE_LIMIT`) and is sent * again, up to `maxRetries` times, unless the caller's stop condition * says to end the walk. + * - a server error or no answer at all (see + * `GOOGLE_HEALTH_TRANSIENT_RETRY`): the same, with its own count. * - 401 with a token source: the token expired or was revoked between * the source's last check and this request. The source is asked once for * a fresh token and the request is repeated. A second 401, or a 401 on a @@ -827,9 +1069,13 @@ async function googleHealthDataRequest( token: GoogleHealthAccessToken, label: string, send: (accessToken: string) => Promise, - isExpectedFallback: (status: number) => boolean = () => false, + expectedFallbackNote: ( + status: number, + apiErrorDetail: string | undefined, + ) => string | undefined = () => undefined, ): Promise> { let rateLimited = 0; + let transient = 0; let refreshed = false; let forceRefresh = false; for (;;) { @@ -837,7 +1083,33 @@ async function googleHealthDataRequest( typeof token === "string" ? token : await token({ forceRefresh }); forceRefresh = false; const started = performance.now(); - const res = await send(accessToken); + let res: Response; + try { + res = await send(accessToken); + } catch (err) { + // No answer at all: the request timed out or the connection broke. + // On a walk of a thousand pages one of those is a matter of time, and + // it cost the whole walk. + const retry = + err instanceof SafeFetchError && + (err.kind === "timeout" || err.kind === "network") && + transient < GOOGLE_HEALTH_TRANSIENT_RETRY.maxRetries && + !stopRequested(); + const delayMs = + GOOGLE_HEALTH_TRANSIENT_RETRY.baseDelayMs * 2 ** transient; + getEvent()?.addExternalCall({ + service: "google-health", + method: label, + duration_ms: Math.round(performance.now() - started), + ...(retry + ? { note: `no_response_retrying_in_ms=${delayMs}` } + : { error: err instanceof Error ? err.message : String(err) }), + }); + if (!retry) throw err; + transient += 1; + await waitMs(delayMs); + continue; + } const json = (await res.json().catch(() => null)) as T | null; const verdict = classifyGoogleHealthResponse(res.status); const apiErrorDetail = @@ -859,26 +1131,39 @@ async function googleHealthDataRequest( !stopRequested(); const willRefresh = res.status === 401 && typeof token !== "string" && !refreshed; + const transientDelayMs = GOOGLE_HEALTH_TRANSIENT_STATUSES.has(res.status) + ? Math.min( + googleHealthRetryDelayMs(res, json) ?? + GOOGLE_HEALTH_TRANSIENT_RETRY.baseDelayMs * 2 ** transient, + GOOGLE_HEALTH_RATE_LIMIT.maxDelayMs, + ) + : 0; + const willRetryTransient = + GOOGLE_HEALTH_TRANSIENT_STATUSES.has(res.status) && + transient < GOOGLE_HEALTH_TRANSIENT_RETRY.maxRetries && + !stopRequested(); getEvent()?.addExternalCall({ service: "google-health", method: label, duration_ms: Math.round(performance.now() - started), status: res.status, - ...(isExpectedFallback(res.status) - ? { note: "date_filter_rejected_retrying_snake_case" } + ...(expectedFallbackNote(res.status, apiErrorDetail) + ? { note: expectedFallbackNote(res.status, apiErrorDetail) } : willWait ? { note: `rate_limited_retrying_in_ms=${backoffMs}` } : willRefresh ? { note: "token_rejected_retrying_with_fresh_token" } - : { - error: - verdict.classification === "success" - ? undefined - : apiErrorDetail - ? `${verdict.reason} ${apiErrorDetail}` - : verdict.reason, - }), + : willRetryTransient + ? { note: `server_error_retrying_in_ms=${transientDelayMs}` } + : { + error: + verdict.classification === "success" + ? undefined + : apiErrorDetail + ? `${verdict.reason} ${apiErrorDetail}` + : verdict.reason, + }), }); if (willWait) { @@ -886,6 +1171,11 @@ async function googleHealthDataRequest( await waitMs(backoffMs); continue; } + if (willRetryTransient) { + transient += 1; + await waitMs(transientDelayMs); + continue; + } if (willRefresh) { refreshed = true; forceRefresh = true; @@ -947,25 +1237,84 @@ export async function forEachDataPointPage( query: DataPointQuery, onPage: (points: GoogleHealthDataPoint[]) => void | Promise, ): Promise { - const maxPages = query.maxPages ?? 1000; + const maxPages = query.maxPages ?? GOOGLE_HEALTH_DEFAULT_MAX_PAGES; const pageSize = query.pageSize ?? GOOGLE_HEALTH_PAGE_SIZE; + const resumable = query.resumable === true; + const edge = query.edge; let requestCount = 0; const walk = async ( dateStyle: GoogleHealthDateFilterStyle, + edgeBy: "filter" | "skip", + resumeFrom: GoogleHealthResumePoint | undefined, + restarted: boolean, ): Promise => { let fetched = 0; - let pageToken: string | null | undefined; + let pageToken: string | null | undefined = resumeFrom?.token; + // The token that fetched the page last read, and that page's position. + let lastToken: string | undefined; + let lastPosition = -1; let pageCount = 0; let stopped = false; + // Reading without the edge bound: the pages before the edge are passed + // over, not written, until the first sample at or beyond it. + const skipTo = edge && edgeBy === "skip" ? edge : undefined; + let crossed = skipTo === undefined; + // Pages passed over in this walk. Never reset: the walk's page ceiling + // counts the pages it wrote, `pageCount - skipped`, before the crossing + // and after it alike. + let skipped = 0; + const startPosition = resumeFrom?.page ?? 0; + const skipStartedAt = skipTo ? sliceNow() : 0; + const maxSkip = skipTo?.maxSkipPages ?? GOOGLE_HEALTH_DENSE_MAX_PAGES; + const sampleAt = (point: GoogleHealthDataPoint): number | null => + googleHealthSampleInstant(point, dataType, skipTo?.tz)?.getTime() ?? null; + const beyondEdge = (point: GoogleHealthDataPoint): boolean | null => { + const t = sampleAt(point); + if (t === null || !skipTo) return null; + return skipTo.order === "desc" + ? t > skipTo.at.getTime() + : t < skipTo.at.getTime(); + }; + const reachesEdge = (point: GoogleHealthDataPoint): boolean => { + const t = sampleAt(point); + if (t === null || !skipTo) return false; + return skipTo.order === "desc" + ? t >= skipTo.at.getTime() + : t <= skipTo.at.getTime(); + }; do { - if (stopRequested()) { + // A skip that had to start over from the newest page runs as the walk + // did before it kept its place: on the job's budget alone, its time + // given back to the slice once it crosses. Every other read stops on + // the slice's budget, and a skip also on its own shorter limit; it + // keeps its place below, so the next slice goes on from there. + const untimed = !crossed && restarted; + if (untimed ? stopRequested(false) : stopRequested(resumable)) { + stopped = true; + break; + } + if (!crossed && startPosition + pageCount >= maxSkip) { + getEvent()?.addWarning( + `google-health ${verb}: passed over ${startPosition + pageCount} pages without reaching the resume edge`, + ); + stopped = true; + break; + } + if ( + !crossed && + !untimed && + resumable && + inSlice() && + sliceNow() - skipStartedAt >= GOOGLE_HEALTH_SKIP_MS + ) { stopped = true; break; } const params = new URLSearchParams({ pageSize: String(pageSize) }); + const filters: string[] = []; if (query.start) { const { field, bound } = incrementalFilter( dataType, @@ -973,8 +1322,21 @@ export async function forEachDataPointPage( query.tz, dateStyle, ); - params.set("filter", `${field} >= "${bound}"`); + filters.push(`${field} >= "${bound}"`); } + const edgeFiltered = edge !== undefined && edgeBy === "filter"; + if (edgeFiltered) { + const { field, bound } = incrementalFilter( + dataType, + edge.at, + query.tz, + dateStyle, + ); + filters.push( + `${field} ${edge.order === "desc" ? "<=" : ">="} "${bound}"`, + ); + } + if (filters.length > 0) params.set("filter", filters.join(" AND ")); if (pageToken) params.set("pageToken", pageToken); requestCount += 1; @@ -983,9 +1345,20 @@ export async function forEachDataPointPage( // failure: some accounts reject the documented camelCase prefix on // some types, and the very next request answers 200. It was logged as // an error next to the 200 that followed it. It is recorded as a note - // now; any other 400 still carries its error. + // now; any other 400 still carries its error. The same holds for the + // first resumed request refusing the edge bound, and for a saved + // place in the unbounded list Google no longer takes. const firstRequest = requestCount === 1; - const { json, verdict, apiErrorDetail } = + const firstOfWalk = pageCount === 0; + const edgeRefused = (status: number, detail: string | undefined) => + edgeFiltered && + firstOfWalk && + status === 400 && + refusesEdgeFilter(detail); + const resumeRefused = (status: number) => + resumeFrom !== undefined && firstOfWalk && status === 400; + const requestToken = pageToken ?? undefined; + const { status, json, verdict, apiErrorDetail } = await googleHealthDataRequest( accessToken, `${verb}(page=${pageCount})`, @@ -997,12 +1370,31 @@ export async function forEachDataPointPage( headers: { Authorization: `Bearer ${token}` }, }, ), - (status) => + (status, detail) => canFallBack && dateStyle === "camel" && firstRequest && - status === 400, + status === 400 + ? "date_filter_rejected_retrying_snake_case" + : edgeRefused(status, detail) + ? "edge_filter_rejected_reading_without_it" + : resumeRefused(status) + ? "resume_token_rejected_reading_from_newest" + : undefined, ); + if (edgeRefused(status, apiErrorDetail)) { + throw new EdgeFilterRefused(); + } + if (resumeRefused(status)) throw new ResumePointLost(); + // A rate limit the request could not wait out ends a resumable walk + // inside a backfill slice where it is: the pages before it are written + // and their edge recorded, so this is a pause, not a failure. The slice + // ends with it, since every other walk would meet the same quota. + if (status === 429 && resumable && inSlice()) { + endSlice("rate_limited"); + stopped = true; + break; + } if (verdict.classification !== "success") { throw new GoogleHealthApiError({ verb, @@ -1013,14 +1405,44 @@ export async function forEachDataPointPage( }); } - const page = json?.dataPoints ?? []; + let page = json?.dataPoints ?? []; + // A saved place has to still hold a sample at or beyond the edge; + // past it, samples nobody wrote could lie behind the token. + if (resumeFrom && firstOfWalk && !page.some(reachesEdge)) { + throw new ResumePointLost(); + } + if (requestToken !== undefined) { + lastToken = requestToken; + lastPosition = startPosition + pageCount; + } pageToken = json?.nextPageToken ?? null; pageCount += 1; fetched += page.length; + noteSlicePage(); + if (!crossed) { + // Placed samples beyond the edge were written by an earlier slice; + // one without a sample time cannot be placed and is passed over + // with them until the walk is past the edge. + const kept = page.filter((p) => beyondEdge(p) === false); + if (kept.length === 0) { + skipped += 1; + noteSliceSkip(); + continue; + } + crossed = true; + if (restarted) extendSliceDeadline(sliceNow() - skipStartedAt); + page = page.filter((p) => beyondEdge(p) !== true); + } await onPage(page); - } while (pageToken && pageCount < maxPages); + } while (pageToken && pageCount - skipped < maxPages); - notePagination(pageCount, fetched, stopped || Boolean(pageToken)); + const truncated = stopped || Boolean(pageToken); + // Keep the place for the next slice. Only a walk without the bound has + // one, and only one that ended short needs it. + if (skipTo && truncated && lastToken !== undefined) { + await skipTo.onResumePoint?.({ token: lastToken, page: lastPosition }); + } + notePagination(pageCount, fetched, truncated); }; // The fallback only exists for filtered daily-summary reads — every other @@ -1029,8 +1451,33 @@ export async function forEachDataPointPage( dataType.timeField === "date" && query.start !== undefined; let style: GoogleHealthDateFilterStyle = "camel"; + let edgeBy: "filter" | "skip" = edge?.filter === false ? "skip" : "filter"; + // From the saved place in the unbounded list when there is one; once from + // the newest page when that place is lost. + const walkSkipping = async (dateStyle: GoogleHealthDateFilterStyle) => { + const resumeFrom = edge?.resumeFrom; + if (!resumeFrom) return walk(dateStyle, "skip", undefined, false); + try { + await walk(dateStyle, "skip", resumeFrom, false); + } catch (err) { + if (!(err instanceof ResumePointLost)) throw err; + await walk(dateStyle, "skip", undefined, true); + } + }; + const run = async (dateStyle: GoogleHealthDateFilterStyle) => { + if (edgeBy === "skip") return walkSkipping(dateStyle); + try { + await walk(dateStyle, edgeBy, undefined, false); + } catch (err) { + if (!(err instanceof EdgeFilterRefused)) throw err; + // Once: the walk without the bound sends no edge filter to refuse. + edgeBy = "skip"; + await edge?.onFilterUnsupported?.(); + await walk(dateStyle, edgeBy, undefined, false); + } + }; try { - await walk("camel"); + await run("camel"); } catch (err) { const firstRequestRejected = canFallBack && @@ -1039,7 +1486,7 @@ export async function forEachDataPointPage( err.httpStatus === 400; if (!firstRequestRejected) throw err; style = "snake"; - await walk("snake"); + await run("snake"); } if (canFallBack) { annotate({ meta: { "googleHealth.dateFilter.style": style } }); @@ -1047,6 +1494,43 @@ export async function forEachDataPointPage( } } +/** + * Thrown inside a walk whose first request Google refused because of the + * resume edge's bound; `forEachDataPointPage` reads the collection again + * without it. + */ +class EdgeFilterRefused extends Error { + constructor() { + super("google-health: edge filter refused"); + } +} + +/** + * Thrown inside a walk that started from a saved place in the unbounded list + * (`GoogleHealthResumePoint`) when Google refused the token or the page it + * returned no longer reaches the edge; the walk starts over from the newest + * page. + */ +class ResumePointLost extends Error { + constructor() { + super("google-health: saved place in the list lost"); + } +} + +/** + * Whether a 400 on the first resumed request is Google refusing the edge + * bound, rather than something else about the request. The request differs + * from one Google answers every hour only by that bound, so a bare 400 or an + * INVALID_ARGUMENT counts, and so does any message naming the filter; one + * naming the page token, the page size or the access token does not. + */ +export function refusesEdgeFilter(detail: string | undefined): boolean { + if (detail === undefined) return true; + if (/filter/i.test(detail)) return true; + if (/token|page.?size/i.test(detail)) return false; + return /INVALID_ARGUMENT/.test(detail); +} + // ─── Daily roll-up reads (`POST …/dataPoints:dailyRollUp`) ───────────────── /** @@ -1245,7 +1729,7 @@ export async function fetchDailyRollUp( let totalPages = 0; let truncated = false; for (const chunk of chunkCivilRange(startCivil, endCivil, maxDays)) { - if (stopRequested()) { + if (stopRequested(false)) { truncated = true; break; } @@ -1295,6 +1779,7 @@ export async function fetchDailyRollUp( for (const p of json?.rollupDataPoints ?? []) points.push(p); pageToken = json?.nextPageToken ?? null; pageCount += 1; + noteSlicePage(); } while (pageToken && pageCount < 100); totalPages += pageCount; truncated ||= Boolean(pageToken); diff --git a/src/lib/google-health/mappers.ts b/src/lib/google-health/mappers.ts index d73666dc6..194a782ff 100644 --- a/src/lib/google-health/mappers.ts +++ b/src/lib/google-health/mappers.ts @@ -387,6 +387,21 @@ function resolveMeasuredAt( return fallback; } +/** + * The sample instant of a spot-reading `DataPoint` (`sample` data types), or + * null for any other shape or a point without a parseable time. A backfill + * reads it to record how far a walk has got. + */ +export function googleHealthSampleInstant( + point: GoogleHealthDataPoint, + dataType: GoogleHealthDataType, + tz?: string, +): Date | null { + if (dataType.timeField !== "sample") return null; + const t = readPath(point, `${dataType.key}.sampleTime.physicalTime`); + return typeof t === "string" ? parseLocalInstant(t, tz) : null; +} + /** * Stable anchor for a `DataPoint`'s externalId. A spot reading anchors on its * sample time; a daily summary on its civil date. Combined with a type-specific diff --git a/src/lib/google-health/sync-core.ts b/src/lib/google-health/sync-core.ts index dc215266d..9bf3bf29c 100644 --- a/src/lib/google-health/sync-core.ts +++ b/src/lib/google-health/sync-core.ts @@ -37,10 +37,14 @@ import { import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate"; import { googleHealthOutcomeTruncated, + googleHealthSliceExhausted, + googleHealthSlicePagesRead, + noteGoogleHealthDeferred, noteGoogleHealthMapped, noteGoogleHealthOutcomeFailure, noteGoogleHealthWritten, refreshAccessToken, + type GoogleHealthResumePoint, type GoogleHealthTokenSource, } from "./client"; import { getUserGoogleHealthCredentials } from "./credentials"; @@ -402,33 +406,92 @@ export async function runWithGoogleHealthSyncCycle( } /** - * Collections a full-history backfill already walked to the end, carried from - * one attempt of the backfill to the next. + * How far a full-history backfill has got, carried from one attempt of the + * backfill to the next. * - * A backfill that ended early (its job budget ran out, a collection failed) - * used to start the next attempt from the first page of the first collection, - * so the dense heart-rate walk, thousands of pages, was paid for again on - * every retry, and an account whose walk never fit into one attempt never - * finished at all. Each collection that completes cleanly is now written to - * `backfillProgress`, and the next attempt skips it and goes on with the - * collections it did not reach. A collection is the unit because Google - * offers no cursor that survives between attempts; its page tokens are not - * documented to outlive the walk that received them. + * Two grains. A collection walked to the end is noted in `done`, and the next + * attempt skips it. A collection read page by page (the spot readings: heart + * rate, weight, glucose, ...) also notes, after every page it writes, the + * edge it has reached: the oldest sample written so far when the walk runs + * newest first, as Google's list does. The next attempt asks Google only for + * the samples at or beyond that edge, so a collection that does not fit into + * one attempt is worked through over several instead of being read from its + * first page every time. + * + * The edge is a sample time, not Google's page token: page tokens are not + * documented to outlive the walk that received them, a sample time is a + * filter Google documents. The one sample on the edge itself is read twice + * and overwrites its own row. + * + * Before the edge existed only `done` was kept, and a heart-rate history of + * years that one attempt could not finish (its budget, a rate limit, one + * failed page among thousands) started again from the newest page on every + * attempt and never reached the end (#1220). + * + * The upper bound (`<=`) a newest-first walk resumes with is valid filter + * grammar but has not been seen working against the live API. A collection + * whose resumed request Google refuses with a 400 is noted in + * `edgeFilterUnsupported`, and its walks from then on read without the bound + * and pass over the samples beyond the edge themselves (see + * `forEachDataPointPage`). */ +export interface GoogleHealthBackfillCursor { + /** The edge: the sample instant (ISO 8601) the walk has written up to. */ + at: string; + /** The order the walk returned samples in: newest first, or oldest first. */ + order: "desc" | "asc"; + /** Pages this collection has read across every attempt so far. */ + pages: number; + /** + * Only for a collection read without the edge bound: where its last walk + * stopped in the unbounded list (see `GoogleHealthResumePoint`), and the + * furthest position any walk of it has saved. A saved place counts as the + * slice's progress only when it is further than every one before it, so a + * chain of slices that keep passing over the same pages ends. + */ + resume?: { token: string; page: number; reached: number }; +} + +/** What one collection came to in one backfill attempt. */ +export interface GoogleHealthBackfillCollectionReport { + key: string; + /** + * `done`: walked to the end. `stopped`: ended short on a budget, a page + * ceiling or a rate limit, with nothing failed. `deferred`: not started, + * left for the next slice. `failed`: a request or a write failed, or the + * collection answered 403. + */ + status: "done" | "stopped" | "deferred" | "failed"; + pages: number; + durationMs: number; + /** The edge before and after this attempt, for a resumable collection. */ + edgeFrom?: string | null; + edgeTo?: string | null; +} + interface BackfillResumeTracker { userId: string; done: Set; + cursors: Map; /** Collections this attempt skipped because an earlier one finished them. */ skipped: string[]; + /** Collections whose edge this attempt moved, with the edge they started from. */ + advanced: Map; + collections: GoogleHealthBackfillCollectionReport[]; + /** Collections whose resumed request Google refused the edge filter on. */ + edgeFilterUnsupported: Set; } const backfillResumeStorage = new AsyncLocalStorage(); +function progressRecord(value: unknown): Record { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; +} + /** Read the `backfillProgress` column into the set of finished collections. */ export function readGoogleHealthBackfillDone(value: unknown): Set { - const done = - value && typeof value === "object" && !Array.isArray(value) - ? (value as { done?: unknown }).done - : undefined; + const done = progressRecord(value).done; return new Set( Array.isArray(done) ? done.filter( @@ -438,20 +501,330 @@ export function readGoogleHealthBackfillDone(value: unknown): Set { ); } +/** Read the `backfillProgress` column into the per-collection edges. */ +export function readGoogleHealthBackfillCursors( + value: unknown, +): Map { + const out = new Map(); + const cursors = progressRecord(progressRecord(value).cursors); + for (const [key, raw] of Object.entries(cursors)) { + if (key.length > 64) continue; + const c = progressRecord(raw); + const at = typeof c.at === "string" ? c.at : null; + if (!at || Number.isNaN(Date.parse(at))) continue; + const r = progressRecord(c.resume); + const count = (v: unknown): number => + typeof v === "number" && Number.isFinite(v) && v > 0 ? Math.trunc(v) : 0; + const resume = + typeof r.token === "string" && + r.token.length > 0 && + r.token.length <= 4096 && + count(r.page) > 0 + ? { + token: r.token, + page: count(r.page), + reached: Math.max(count(r.reached), count(r.page)), + } + : undefined; + out.set(key, { + at: new Date(at).toISOString(), + order: c.order === "asc" ? "asc" : "desc", + pages: count(c.pages), + ...(resume ? { resume } : {}), + }); + } + return out; +} + +/** + * Read the `backfillProgress` column into the collections Google refused the + * edge filter on. + */ +export function readGoogleHealthBackfillEdgeFilterUnsupported( + value: unknown, +): Set { + const keys = progressRecord(value).edgeFilterUnsupported; + return new Set( + Array.isArray(keys) + ? keys.filter( + (key): key is string => typeof key === "string" && key.length <= 64, + ) + : [], + ); +} + +/** + * Write the tracker's progress with one change applied, and apply it to the + * tracker only once the write has landed: progress that is not on disk is + * progress the next attempt cannot see, so it must not count in this one. + */ +async function persistBackfillProgress( + tracker: BackfillResumeTracker, + next: { + done: Set; + cursors: Map; + edgeFilterUnsupported?: Set; + }, +): Promise { + const unsupported = + next.edgeFilterUnsupported ?? tracker.edgeFilterUnsupported; + await prisma.googleHealthConnection.update({ + where: { userId: tracker.userId }, + data: { + backfillProgress: { + done: [...next.done], + cursors: Object.fromEntries( + [...next.cursors].map(([key, c]) => [ + key, + { + at: c.at, + order: c.order, + pages: c.pages, + ...(c.resume ? { resume: c.resume } : {}), + }, + ]), + ), + // Only once there is something to say, so a backfill that never met + // the refusal keeps the shape it always had. + ...(unsupported.size > 0 + ? { edgeFilterUnsupported: [...unsupported] } + : {}), + }, + }, + }); + if (unsupported !== tracker.edgeFilterUnsupported) { + for (const key of unsupported) tracker.edgeFilterUnsupported.add(key); + } + // `next` may be the tracker's own collections: copy before clearing. + const done = [...next.done]; + const cursors = [...next.cursors]; + tracker.done.clear(); + for (const key of done) tracker.done.add(key); + tracker.cursors.clear(); + for (const [key, c] of cursors) tracker.cursors.set(key, c); +} + +/** What one backfill attempt did, as `runWithGoogleHealthBackfillResume` reports it. */ +export interface GoogleHealthBackfillAttempt { + result: T; + skipped: string[]; + /** Collections whose edge moved, each with the edge it started from. */ + advanced: Map; + collections: GoogleHealthBackfillCollectionReport[]; +} + /** * Run a full-history backfill attempt that resumes: collections in `done` are - * skipped, and each collection finished cleanly during this attempt is added - * to it and persisted at once, so an attempt that dies part-way keeps what it - * finished. + * skipped, a collection with an edge in `cursors` starts from it, and each + * collection finished or page written during this attempt is persisted at + * once, so an attempt that dies part-way keeps what it did. */ export async function runWithGoogleHealthBackfillResume( userId: string, done: Set, fn: () => Promise, -): Promise<{ result: T; skipped: string[] }> { - const tracker: BackfillResumeTracker = { userId, done, skipped: [] }; + cursors: Map = new Map(), + edgeFilterUnsupported: Set = new Set(), +): Promise> { + const tracker: BackfillResumeTracker = { + userId, + done, + cursors, + skipped: [], + advanced: new Map(), + collections: [], + edgeFilterUnsupported, + }; const result = await backfillResumeStorage.run(tracker, fn); - return { result, skipped: tracker.skipped }; + return { + result, + skipped: tracker.skipped, + advanced: tracker.advanced, + collections: tracker.collections, + }; +} + +/** + * Where a resumable collection's walk starts in this backfill attempt: the + * edge an earlier attempt recorded (if any), whether Google may be asked for + * it with a filter (false once Google refused that filter for the + * collection), and the pages it has already read. Null outside a backfill, + * where every walk reads its whole window. + */ +export function googleHealthBackfillCursor(key: GoogleHealthCollectionKey): { + edge?: { + at: Date; + order: "desc" | "asc"; + filter: boolean; + resumeFrom?: GoogleHealthResumePoint; + }; + pagesSoFar: number; +} | null { + const resume = backfillResumeStorage.getStore(); + if (!resume) return null; + const cursor = resume.cursors.get(key); + const filter = !resume.edgeFilterUnsupported.has(key); + return cursor + ? { + edge: { + at: new Date(cursor.at), + order: cursor.order, + filter, + ...(!filter && cursor.resume + ? { + resumeFrom: { + token: cursor.resume.token, + page: cursor.resume.page, + }, + } + : {}), + }, + pagesSoFar: cursor.pages, + } + : { pagesSoFar: 0 }; +} + +/** + * Record where a collection's walk without the edge bound stopped in the + * unbounded list, so the next slice goes on from there. A place further than + * every one saved before counts as this slice's progress (the backfill sends + * another slice on it), even when no page was written: a skip deeper than one + * slice is worked through over several. Outside a backfill, or for a + * collection without an edge, this does nothing. + */ +export async function recordGoogleHealthBackfillResumePoint( + key: GoogleHealthCollectionKey, + point: GoogleHealthResumePoint, +): Promise { + const resume = backfillResumeStorage.getStore(); + if (!resume) return; + const current = resume.cursors.get(key); + if (!current) return; + const reached = current.resume?.reached ?? 0; + const further = point.page > reached; + const cursors = new Map(resume.cursors); + cursors.set(key, { + ...current, + resume: { + token: point.token, + page: point.page, + reached: Math.max(reached, point.page), + }, + }); + try { + await persistBackfillProgress(resume, { done: resume.done, cursors }); + if (further && !resume.advanced.has(key)) { + resume.advanced.set(key, current.at); + } + } catch (err) { + getEvent()?.addWarning( + `google-health: backfill progress write failed for ${key}: ${err}`, + ); + annotate({ + meta: { "googleHealth.backfill.progressWriteFailed": key }, + }); + } +} + +/** + * Note that Google refused the edge filter on a resumed request of this + * collection, so every later slice reads without it from the start instead + * of paying for the refusal again. Annotated as + * `googleHealth.backfill.edgeFilterUnsupported` (the collections noted so + * far) on the run that met the refusal. A note that cannot be written is a + * warning, not a failure: the next slice meets the refusal once more and + * falls back again. Outside a backfill this does nothing. + */ +export async function noteGoogleHealthEdgeFilterUnsupported( + key: GoogleHealthCollectionKey, +): Promise { + const resume = backfillResumeStorage.getStore(); + if (!resume || resume.edgeFilterUnsupported.has(key)) return; + const unsupported = new Set([...resume.edgeFilterUnsupported, key]); + annotate({ + meta: { "googleHealth.backfill.edgeFilterUnsupported": [...unsupported] }, + }); + try { + await persistBackfillProgress(resume, { + done: resume.done, + cursors: resume.cursors, + edgeFilterUnsupported: unsupported, + }); + } catch (err) { + getEvent()?.addWarning( + `google-health: backfill progress write failed for ${key}: ${err}`, + ); + } +} + +/** + * Record that a resumable collection wrote one more page, holding samples + * from `first` to `last` in the order Google returned them. Moves the + * collection's edge to the far end of the page and persists it. Outside a + * backfill this does nothing. + * + * The order is read off the first page whose two ends differ and kept for + * the rest of the backfill; Google returns its lists newest first, which is + * also what a page that cannot tell is taken to mean. + */ +export async function recordGoogleHealthBackfillPage( + key: GoogleHealthCollectionKey, + page: { first: Date | null; last: Date | null }, +): Promise { + const resume = backfillResumeStorage.getStore(); + if (!resume) return; + const current = resume.cursors.get(key); + const order: "desc" | "asc" = + current?.order ?? + (page.first && page.last && page.first.getTime() < page.last.getTime() + ? "asc" + : "desc"); + const ends = [page.first, page.last].filter((d): d is Date => d !== null); + const far = + ends.length === 0 + ? null + : new Date( + order === "desc" + ? Math.min(...ends.map((d) => d.getTime())) + : Math.max(...ends.map((d) => d.getTime())), + ); + const currentAt = current ? Date.parse(current.at) : null; + const moved = + far !== null && + (currentAt === null || + (order === "desc" + ? far.getTime() < currentAt + : far.getTime() > currentAt)); + if (!current && !moved) return; + const nextCursor: GoogleHealthBackfillCursor = { + at: moved ? far.toISOString() : current!.at, + order, + pages: (current?.pages ?? 0) + 1, + // The saved place stays until the walk records where it stopped. + ...(current?.resume ? { resume: current.resume } : {}), + }; + const cursors = new Map(resume.cursors); + cursors.set(key, nextCursor); + try { + await persistBackfillProgress(resume, { done: resume.done, cursors }); + if (moved && !resume.advanced.has(key)) { + resume.advanced.set(key, current?.at ?? null); + } + } catch (err) { + // The page's rows are written; only the note of how far the walk got is + // missing, so the next attempt reads this page again. Not a failure. + getEvent()?.addWarning( + `google-health: backfill progress write failed for ${key}: ${err}`, + ); + annotate({ + meta: { "googleHealth.backfill.progressWriteFailed": key }, + }); + } +} + +/** Hard failures the cycle in scope has recorded so far. */ +export function googleHealthHardFailureCount(): number { + return hardFailStorage.getStore()?.failures.length ?? 0; } /** @@ -459,10 +832,12 @@ export async function runWithGoogleHealthBackfillResume( * one in scope. Outside a backfill this is just `fn()`. * * Inside one, a collection already walked to the end by an earlier attempt is - * skipped. Otherwise it runs, and it counts as finished only when nothing - * went wrong while it ran: no hard failure on the cycle's ledger, no 403 - * soft-skip, and no walk that ended short (its page ceiling, or the job's - * budget asking it to stop). Anything less is walked again next attempt. + * skipped, and so is one the slice has no budget left to start: it is left + * for the next slice, and the cycle reports itself incomplete. Otherwise it + * runs, and it counts as finished only when nothing went wrong while it ran: + * no hard failure on the cycle's ledger, no 403 soft-skip, and no walk that + * ended short (its page ceiling, or a budget asking it to stop). Anything + * less is walked again next attempt, from its edge when it has one. */ export async function runGoogleHealthCollection( key: GoogleHealthCollectionKey, @@ -474,15 +849,29 @@ export async function runGoogleHealthCollection( resume.skipped.push(key); return 0; } + if (googleHealthSliceExhausted()) { + noteGoogleHealthDeferred(); + resume.collections.push({ + key, + status: "deferred", + pages: 0, + durationMs: 0, + }); + return 0; + } + const startedAt = performance.now(); + const pagesBefore = googleHealthSlicePagesRead(); + const edgeFrom = resume.cursors.get(key)?.at ?? null; const hardBefore = hardFailStorage.getStore()?.failures.length ?? 0; const softBefore = softSkipStorage.getStore()?.count ?? 0; const truncatedBefore = googleHealthOutcomeTruncated(); const imported = await fn(); - const clean = - (hardFailStorage.getStore()?.failures.length ?? 0) === hardBefore && - (softSkipStorage.getStore()?.count ?? 0) === softBefore && - !truncatedBefore && - !googleHealthOutcomeTruncated(); + const failedHard = + (hardFailStorage.getStore()?.failures.length ?? 0) !== hardBefore || + (softSkipStorage.getStore()?.count ?? 0) !== softBefore; + const truncated = truncatedBefore || googleHealthOutcomeTruncated(); + const clean = !failedHard && !truncated; + let finished = false; if (clean) { // The collection counts as finished only once the note says so. The job // reads the size of `done` as this attempt's progress and sends another @@ -490,11 +879,13 @@ export async function runGoogleHealthCollection( // progress nobody can see next attempt, and a chain of slices could keep // "progressing" over the same collection forever. try { - await prisma.googleHealthConnection.update({ - where: { userId: resume.userId }, - data: { backfillProgress: { done: [...resume.done, key] } }, + const cursors = new Map(resume.cursors); + cursors.delete(key); + await persistBackfillProgress(resume, { + done: new Set([...resume.done, key]), + cursors, }); - resume.done.add(key); + finished = true; } catch (err) { // Not finished, then: the collection is walked again next attempt. It // must not fail a collection whose rows are written. @@ -506,6 +897,14 @@ export async function runGoogleHealthCollection( }); } } + const edgeTo = finished ? null : (resume.cursors.get(key)?.at ?? null); + resume.collections.push({ + key, + status: finished ? "done" : failedHard ? "failed" : "stopped", + pages: googleHealthSlicePagesRead() - pagesBefore, + durationMs: Math.round(performance.now() - startedAt), + ...(edgeFrom !== null || edgeTo !== null ? { edgeFrom, edgeTo } : {}), + }); return imported; } diff --git a/src/lib/google-health/sync-lock.ts b/src/lib/google-health/sync-lock.ts index db8ca51c8..ddb16523d 100644 --- a/src/lib/google-health/sync-lock.ts +++ b/src/lib/google-health/sync-lock.ts @@ -16,7 +16,9 @@ * the hourly poll skips the account for this tick, the manual trigger answers * that a sync is already running, and the backfill and the repair wait a * bounded time for a short run to finish before handing their job back to - * pg-boss to retry. + * pg-boss to retry. The hourly poll waits too, for about one backfill slice: + * a backfill lets go of the account between its slices, so the hourly sync + * gets its turn instead of being skipped for as long as the import runs. */ import { withJobLock, type GuardedRun } from "@/lib/jobs/job-lock"; @@ -32,6 +34,16 @@ export function googleHealthSyncLockKey(userId: string): string { */ export const GOOGLE_HEALTH_SYNC_LOCK_WAIT_MS = 10 * 60 * 1000; +/** + * How long the hourly poll waits for an account another run holds. A backfill + * slice walks for five minutes and then lets go + * (`GOOGLE_HEALTH_BACKFILL_SLICE_MS`); the page in flight, a rate-limit wait + * and the slice's own bookkeeping can add a minute or two, and the next slice + * starts a minute after this one ends. Eight minutes covers that, and leaves + * the hourly job well inside its fifteen-minute expiry. + */ +export const GOOGLE_HEALTH_POLL_LOCK_WAIT_MS = 8 * 60 * 1000; + /** * Run `run` while holding the account's Google Health lock. Resolves * `{ ran: false }` without calling `run` when another run holds it and does diff --git a/src/lib/google-health/sync-metrics.ts b/src/lib/google-health/sync-metrics.ts index a86dfb59e..743cccf6c 100644 --- a/src/lib/google-health/sync-metrics.ts +++ b/src/lib/google-health/sync-metrics.ts @@ -27,12 +27,14 @@ */ import { GOOGLE_HEALTH_DATA_TYPES, + GOOGLE_HEALTH_DEFAULT_MAX_PAGES, GOOGLE_HEALTH_DENSE_MAX_PAGES, type GoogleHealthDataType, type GoogleHealthMappedMeasurement, type GoogleHealthTokenSource, fetchDataPoints, forEachDataPointPage, + googleHealthSampleInstant, mapBloodGlucose, mapBodyFat, mapCoreBodyTemperature, @@ -44,11 +46,17 @@ import { mapRestingHeartRate, mapWeight, mapWristTemperature, + noteGoogleHealthDeferred, } from "./client"; import { getValidToken, + googleHealthBackfillCursor, + googleHealthHardFailureCount, googleHealthTokenSource, handleCollectionFetchError, + noteGoogleHealthEdgeFilterUnsupported, + recordGoogleHealthBackfillPage, + recordGoogleHealthBackfillResumePoint, runGoogleHealthCollection, upsertGoogleHealthMeasurements, type GoogleHealthMeasurementUpsert, @@ -108,13 +116,6 @@ const METRIC_RESOURCES: MetricResource[] = [ map: mapRespiratoryRate, verb: "fetchRespiratoryRate", }, - { - dataType: GOOGLE_HEALTH_DATA_TYPES.heartRate, - map: mapHeartRate, - verb: "fetchHeartRate", - maxPages: GOOGLE_HEALTH_DENSE_MAX_PAGES, - intraday: true, - }, { dataType: GOOGLE_HEALTH_DATA_TYPES.bloodGlucose, map: mapBloodGlucose, @@ -133,8 +134,34 @@ const METRIC_RESOURCES: MetricResource[] = [ map: mapWristTemperature, verb: "fetchSleepTemperature", }, + // Last: the dense stream. A backfill finishes every light collection in + // its first slice and then works through this one slice by slice; ahead + // of them it held them back until the whole stream was in. + { + dataType: GOOGLE_HEALTH_DATA_TYPES.heartRate, + map: mapHeartRate, + verb: "fetchHeartRate", + maxPages: GOOGLE_HEALTH_DENSE_MAX_PAGES, + intraday: true, + }, ]; +/** + * Whether a metric is walked resumably by a backfill: written page by page, + * and a spot reading whose sample time can mark how far the walk got. + */ +function isResumable(resource: MetricResource): boolean { + return resource.dataType.timeField === "sample"; +} + +/** + * The page ceilings of every resumable collection, summed: the most pages a + * backfill can ever spend on them across all of its attempts. + */ +export const GOOGLE_HEALTH_RESUMABLE_PAGE_CEILING = METRIC_RESOURCES.filter( + isResumable, +).reduce((sum, r) => sum + (r.maxPages ?? GOOGLE_HEALTH_DEFAULT_MAX_PAGES), 0); + export async function syncUserMetrics( userId: string, opts: GoogleHealthResourceSyncOptions = {}, @@ -245,6 +272,26 @@ async function syncMetricCollection( opts: GoogleHealthResourceSyncOptions, ): Promise { let imported = 0; + // Inside a backfill a spot-reading collection keeps its place: it starts + // from the edge an earlier attempt reached and records its new edge after + // every page it writes (see `GoogleHealthBackfillCursor`). Its page ceiling + // then counts across attempts, so a cursor that never ends is still bounded. + const cursor = isResumable(resource) + ? googleHealthBackfillCursor(resource.verb) + : null; + const ceiling = resource.maxPages ?? GOOGLE_HEALTH_DEFAULT_MAX_PAGES; + const maxPages = ceiling - (cursor?.pagesSoFar ?? 0); + if (cursor && maxPages <= 0) { + getEvent()?.addWarning( + `google-health ${resource.verb}: backfill reached its page ceiling of ${resource.maxPages ?? GOOGLE_HEALTH_DEFAULT_MAX_PAGES} pages for ${userId}`, + ); + noteGoogleHealthDeferred(); + return 0; + } + // Once one page's write fails, the edge stays where it is for the rest of + // the walk: moving it past the failed page would leave that page's rows + // behind it for good. + let edgeHeld = false; try { await forEachDataPointPage( resource.dataType, @@ -254,7 +301,30 @@ async function syncMetricCollection( start: resource.intraday ? (opts.intradayStart ?? opts.start) : opts.start, - maxPages: resource.maxPages, + maxPages, + ...(cursor + ? { + resumable: true, + edge: cursor.edge && { + ...cursor.edge, + tz, + // Without the bound the walk passes over what earlier slices + // wrote, and they wrote no more than the collection's ceiling. + maxSkipPages: ceiling, + onFilterUnsupported: () => + noteGoogleHealthEdgeFilterUnsupported(resource.verb), + // A place past a page whose write failed would leave that + // page's rows behind it, like a moved edge would. + onResumePoint: (point) => + edgeHeld + ? undefined + : recordGoogleHealthBackfillResumePoint( + resource.verb, + point, + ), + }, + } + : {}), }, async (points) => { const readings: GoogleHealthMeasurementUpsert[] = []; @@ -269,11 +339,23 @@ async function syncMetricCollection( }); } } + const failuresBefore = googleHealthHardFailureCount(); imported += ( await upsertGoogleHealthMeasurements(userId, readings, { deferRollup: opts.deferRollup, }) ).imported; + if (!cursor || points.length === 0) return; + if (googleHealthHardFailureCount() !== failuresBefore) edgeHeld = true; + if (edgeHeld) return; + await recordGoogleHealthBackfillPage(resource.verb, { + first: googleHealthSampleInstant(points[0]!, resource.dataType, tz), + last: googleHealthSampleInstant( + points[points.length - 1]!, + resource.dataType, + tz, + ), + }); }, ); } catch (err) { diff --git a/src/lib/google-health/sync.ts b/src/lib/google-health/sync.ts index b0a72601f..85ba41422 100644 --- a/src/lib/google-health/sync.ts +++ b/src/lib/google-health/sync.ts @@ -11,6 +11,7 @@ import { invalidateUserMeasurements } from "@/lib/cache/invalidate"; import { resolveUserTimezone } from "@/lib/tz/resolver"; import { runWithGoogleHealthClientOutcome, + runWithGoogleHealthStop, type GoogleHealthClientOutcome, } from "./client"; import { syncUserActivity } from "./sync-activity"; @@ -100,6 +101,14 @@ export interface GoogleHealthSyncOptions { * account's sync lock. Zero (the default) answers `busy` at once. */ waitForLockMs?: number; + /** + * The caller's time limit, asked before every request once the run holds + * the account (the hourly job's expiry). A walk that is told to stop ends + * there and reports itself truncated: the cycle is not stamped, so the next + * run reads the rest from the same watermark. Not for the backfill, which + * sets its own limits around the whole run. + */ + shouldStop?: () => boolean; } const RESOURCE_STATUSES = new Set([ @@ -292,7 +301,7 @@ async function runGoogleHealthSyncCycle( { name: "dense-heart-rate", fn: syncUserMetrics }, ]; - const cycle = await runWithGoogleHealthSyncCycle(async () => { + const runResources = async () => { let total = 0; let anyFailed = false; const resourceOutcomes: GoogleHealthResourceOutcome[] = []; @@ -334,7 +343,13 @@ async function runGoogleHealthSyncCycle( await persistProgress("in_progress", total, anyFailed, resourceOutcomes); } return { total, anyFailed, resources: resourceOutcomes }; - }); + }; + const shouldStop = opts.shouldStop; + const cycle = await runWithGoogleHealthSyncCycle(() => + shouldStop + ? runWithGoogleHealthStop(shouldStop, runResources) + : runResources(), + ); const total = cycle.result.total; let anyFailed = cycle.result.anyFailed || cycle.hardFailures.length > 0; diff --git a/src/lib/insights/derived/__tests__/sleep-score.test.ts b/src/lib/insights/derived/__tests__/sleep-score.test.ts index c464868a3..ef12bce2d 100644 --- a/src/lib/insights/derived/__tests__/sleep-score.test.ts +++ b/src/lib/insights/derived/__tests__/sleep-score.test.ts @@ -186,6 +186,21 @@ describe("reconstructNights", () => { expect(nights[0].midpoint).toBe(180); }); + it("places the midpoint on the main session, not on a nap the same wake day", () => { + // Overnight 23:00 to 07:00 (480 asleep minutes, midpoint 03:00) and an + // afternoon nap 14:00 to 15:00 that wakes on the same day. The night's + // totals carry the nap; its place on the clock must not. + const rows = night("2026-06-02", [ + ["CORE", 60, "00:00"], + ["CORE", 420, "07:00"], + ["CORE", 60, "15:00"], + ]); + const nights = reconstructNights(rows); + expect(nights).toHaveLength(1); + expect(nights[0].asleepMinutes).toBe(540); + expect(nights[0].midpoint).toBe(3 * 60); + }); + it("expresses the midpoint in the user's timezone when one is passed", () => { // Same 03:00Z midpoint, read in Asia/Kolkata (UTC+5:30, no DST) → // 08:30 local = 510 min-of-day, not 180. @@ -282,6 +297,39 @@ describe("reconstructNights", () => { }); describe("computeSleepScore", () => { + it("keeps consistency and timing on a steady sleeper who naps some afternoons", async () => { + // Seven nights 23:00 to 07:00, every other wake day with a 14:00 to 15:00 + // nap. The overnight rhythm never moves, so consistency and timing are + // perfect; reading the midpoint off the night's latest instant put the nap + // days about seven hours late and scored both at 0. + const rows: ReturnType = []; + for (let i = 0; i < 7; i += 1) { + const wake = new Date(Date.UTC(2026, 4, 27 + i)); + const day = wake.toISOString().slice(0, 10); + rows.push( + ...night(day, [ + ["IN_BED", 480, "07:00"], + ["CORE", 240, "03:00"], + ["DEEP", 120, "05:00"], + ["REM", 120, "07:00"], + ]), + ); + if (i % 2 === 0) rows.push(...night(day, [["CORE", 60, "15:00"]])); + } + findMany.mockResolvedValue(rows); + const result = await computeSleepScore("u1", PROFILE, { + now: new Date("2026-06-02T16:00:00Z"), + }); + expect(result.status).toBe("ok"); + if (result.status === "ok") { + const byKey = Object.fromEntries( + result.value.subScores.map((sub) => [sub.key, sub.value]), + ); + expect(byKey.consistency).toBe(100); + expect(byKey.timing).toBe(100); + } + }); + it("returns insufficient when no sleep rows exist", async () => { findMany.mockResolvedValue([]); const result = await computeSleepScore("u1", PROFILE, { now: NOW }); diff --git a/src/lib/insights/derived/sleep-score.ts b/src/lib/insights/derived/sleep-score.ts index 6a00dd7ff..37c8fec6c 100644 --- a/src/lib/insights/derived/sleep-score.ts +++ b/src/lib/insights/derived/sleep-score.ts @@ -392,13 +392,17 @@ export function reconstructNights( : n.asleepMinutes > 0 && awakeMinutes > 0 ? n.asleepMinutes + awakeMinutes : null; - // Midpoint = centre of the asleep span. The canonical night's `measuredAt` - // is the wake instant (latest stage END); the asleep span is - // `asleepMinutes` long ending there, so its centre is wake − asleep/2. + // Midpoint = centre of the MAIN session's asleep span: its wake instant + // minus half its own asleep minutes. Not the whole night's: a nap on the + // same wake day moves the night's latest instant to the end of the nap and + // adds its minutes, which put the midpoint of a 23:00 to 07:00 night with a + // 14:00 nap at about 11:00 and read every such day as a ~7-hour shift in + // consistency and timing. + const main = n.mainSession; const midpoint = - n.asleepMinutes > 0 + main && main.asleepMinutes > 0 ? minutesOfDay( - new Date(n.measuredAt.getTime() - (n.asleepMinutes / 2) * 60_000), + new Date(main.end.getTime() - (main.asleepMinutes / 2) * 60_000), tz, ) : null; diff --git a/src/lib/integrations/__tests__/connection-health.test.ts b/src/lib/integrations/__tests__/connection-health.test.ts new file mode 100644 index 000000000..5c0db5353 --- /dev/null +++ b/src/lib/integrations/__tests__/connection-health.test.ts @@ -0,0 +1,209 @@ +import { describe, expect, it } from "vitest"; +import { + readRecentCalls, + resolveConnectionHealth, + safeErrorCode, + type ConnectionHealthInput, +} from "../connection-health"; +import type { SyncHealth } from "../sync-verdict"; + +const NOW = Date.parse("2026-10-15T12:00:00.000Z"); +const HOUR = 60 * 60 * 1000; + +function input( + verdict: SyncHealth["verdict"], + extra: Partial = {}, +): ConnectionHealthInput { + return { + syncHealth: { + verdict, + since: verdict === "fresh" ? null : "2026-10-14T08:00:00.000Z", + }, + connected: true, + ledgerState: "connected", + lastSuccessAt: "2026-10-15T11:00:00.000Z", + lastAttemptAt: "2026-10-15T11:00:00.000Z", + failingSince: null, + failingLegs: [], + recentCalls: [], + lastDataAt: "2026-10-15T10:00:00.000Z", + silenceWindowMs: 24 * HOUR, + now: NOW, + ...extra, + }; +} + +describe("resolveConnectionHealth", () => { + it("returns null for a connection that does not exist", () => { + expect( + resolveConnectionHealth(input("fresh", { connected: false })), + ).toBeNull(); + expect(resolveConnectionHealth(input("disconnected"))).toBeNull(); + }); + + it("maps a fresh verdict to ok / data_arriving", () => { + const h = resolveConnectionHealth(input("fresh"))!; + expect(h.state).toBe("ok"); + expect(h.reason).toBe("data_arriving"); + expect(h.action).toBeNull(); + expect(h.since).toBeNull(); + }); + + it("maps a pending first sync to ok / first_sync_pending, not to a problem", () => { + const h = resolveConnectionHealth(input("pending_first_sync"))!; + expect(h.state).toBe("ok"); + expect(h.reason).toBe("first_sync_pending"); + }); + + it.each([ + ["reauth_required", "reauth_required", "reconnect"], + ["parked", "paused_after_errors", "resume"], + ] as const)( + "maps %s to action_required with exactly one action", + (verdict, reason, action) => { + const h = resolveConnectionHealth(input(verdict))!; + expect(h.state).toBe("action_required"); + expect(h.reason).toBe(reason); + expect(h.action).toBe(action); + expect(h.since).toBe("2026-10-14T08:00:00.000Z"); + }, + ); + + it("treats a provider-side reauth flag as action_required even on a fresh ledger", () => { + const h = resolveConnectionHealth(input("fresh", { needsReauth: true }))!; + expect(h.state).toBe("action_required"); + expect(h.action).toBe("reconnect"); + }); + + it.each([ + ["failing", "provider_errors"], + ["stalled", "sync_stalled"], + ["stale", "no_new_data"], + ] as const)("maps %s to degraded / %s, no action", (verdict, reason) => { + const h = resolveConnectionHealth(input(verdict))!; + expect(h.state).toBe("degraded"); + expect(h.reason).toBe(reason); + expect(h.action).toBeNull(); + expect(h.since).not.toBeNull(); + }); + + it("lists plan-withheld types as limited without an action", () => { + const h = resolveConnectionHealth( + input("fresh", { planUnavailable: ["spo2", "ecg", "spo2"] }), + )!; + expect(h.state).toBe("limited"); + expect(h.reason).toBe("plan_limited"); + expect(h.action).toBeNull(); + expect(h.unavailable).toEqual([ + { item: "spo2", cause: "plan" }, + { item: "ecg", cause: "plan" }, + ]); + }); + + it("offers one reconnect when a permission gap is part of the limit", () => { + const h = resolveConnectionHealth( + input("fresh", { + planUnavailable: ["ecg"], + permissionUnavailable: ["spo2"], + }), + )!; + expect(h.state).toBe("limited"); + expect(h.reason).toBe("permission_limited"); + expect(h.action).toBe("reconnect"); + }); + + it("lets a worse state win over limited, keeping the unavailable list", () => { + const h = resolveConnectionHealth( + input("failing", { planUnavailable: ["ecg"] }), + )!; + expect(h.state).toBe("degraded"); + expect(h.unavailable).toEqual([{ item: "ecg", cause: "plan" }]); + }); + + it("surfaces the watchdog silence flag as degraded / unexpected_silence", () => { + const h = resolveConnectionHealth( + input("fresh", { + lastDataAt: "2026-10-13T12:00:00.000Z", + silentSince: "2026-10-13T12:00:00.000Z", + }), + )!; + expect(h.state).toBe("degraded"); + expect(h.reason).toBe("unexpected_silence"); + expect(h.since).toBe("2026-10-13T12:00:00.000Z"); + }); + + it("drops a stale silence flag as soon as fresh data has arrived", () => { + const h = resolveConnectionHealth( + input("fresh", { + lastDataAt: "2026-10-15T11:30:00.000Z", + silentSince: "2026-10-13T12:00:00.000Z", + }), + )!; + expect(h.state).toBe("ok"); + }); + + it("lets a reauth outrank the silence flag", () => { + const h = resolveConnectionHealth( + input("reauth_required", { + lastDataAt: "2026-10-13T12:00:00.000Z", + silentSince: "2026-10-13T12:00:00.000Z", + }), + )!; + expect(h.state).toBe("action_required"); + }); +}); + +describe("recent-call diagnostics", () => { + it("keeps only closed-shape codes", () => { + expect(safeErrorCode("401")).toBe("401"); + expect(safeErrorCode("invalid_grant")).toBe("invalid_grant"); + expect(safeErrorCode("Bearer abc def")).toBeNull(); + expect(safeErrorCode("https://host/api?token=x")).toBeNull(); + expect(safeErrorCode(null)).toBeNull(); + }); + + it("reads well-formed entries and drops anything carrying free text", () => { + const calls = readRecentCalls([ + { + endpoint: "sleep", + status: "transient", + at: "2026-10-15T10:00:00Z", + errorCode: "503", + message: "upstream said something with a token", + }, + { + endpoint: "https://evil/?t=1", + status: "ok", + at: "2026-10-15T09:00:00Z", + errorCode: "token=secret value", + }, + { endpoint: "sleep", status: "exploded", at: "2026-10-15T08:00:00Z" }, + "not an object", + ]); + expect(calls).toEqual([ + { + endpoint: "sleep", + status: "transient", + at: "2026-10-15T10:00:00.000Z", + errorCode: "503", + }, + { + endpoint: "sync", + status: "ok", + at: "2026-10-15T09:00:00.000Z", + errorCode: null, + }, + ]); + expect(JSON.stringify(calls)).not.toContain("token"); + }); + + it("caps the published list at ten", () => { + const many = Array.from({ length: 15 }, (_, i) => ({ + endpoint: "sync", + status: "ok", + at: new Date(NOW - i * HOUR).toISOString(), + errorCode: null, + })); + expect(readRecentCalls(many)).toHaveLength(10); + }); +}); diff --git a/src/lib/integrations/__tests__/status.test.ts b/src/lib/integrations/__tests__/status.test.ts index 88eb2b38d..0c0ea8351 100644 --- a/src/lib/integrations/__tests__/status.test.ts +++ b/src/lib/integrations/__tests__/status.test.ts @@ -20,6 +20,7 @@ vi.mock("@/lib/logging/context", () => ({ vi.mock("@/lib/db", () => ({ prisma: { + $executeRaw: vi.fn(), integrationStatus: { findUnique: vi.fn(), upsert: vi.fn(), @@ -29,6 +30,9 @@ vi.mock("@/lib/db", () => ({ findUnique: vi.fn(), findMany: vi.fn(), }, + integrationWatch: { + findFirst: vi.fn(), + }, }, })); @@ -160,6 +164,50 @@ describe("recordSyncFailure — under threshold", () => { }); describe("recordSyncFailure — at threshold", () => { + it("does not page a streak the watchdog already announced for the same connection", async () => { + vi.mocked(prisma.integrationStatus.findUnique).mockResolvedValueOnce({ + consecutiveFailuresByKind: { + transient: 2, + reauth_required: 0, + persistent: 0, + }, + persistentFailureStartedAt: null, + alertedAt: null, + } as never); + vi.mocked(prisma.integrationStatus.upsert).mockResolvedValueOnce({ + alertedAt: null, + } as never); + vi.mocked(prisma.integrationWatch.findFirst).mockResolvedValueOnce({ + id: "watch-1", + } as never); + + await recordSyncFailure({ + userId: "u1", + integration: "withings", + kind: "transient", + message: "Withings refresh error: 503", + errorCode: "503", + }); + + expect(prisma.integrationWatch.findFirst).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + userId: "u1", + integration: "withings", + incidentReason: { not: null }, + alertedAt: { not: null }, + }), + }), + ); + expect(dispatchNotification).not.toHaveBeenCalled(); + // Stamped as announced, so later failures of the streak do not ask again. + expect(prisma.integrationStatus.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: { alertedAt: expect.any(Date) }, + }), + ); + }); + it("dispatches an admin Telegram alert when failure count reaches default threshold (3)", async () => { // v1.4.47 W1 — the alert ladder reads Math.max(...buckets) so the // existing row must seed the bucket at N-1 before the in-memory @@ -363,8 +411,12 @@ describe("recordSyncFailure — alert window", () => { expect(dispatchNotification).not.toHaveBeenCalled(); }); - it("DOES dispatch a fresh alert after 24h have elapsed", async () => { - // Existing bucket at 5 → in-memory increment lands at 6. + it("does NOT re-page the same streak once 24h have elapsed", async () => { + // One page per streak. `alertedAt` is cleared by every path that ends a + // streak, so a stamped value means this streak was already announced, + // however long ago. The daily re-page this test used to pin turned a + // week-long outage into seven identical messages; the integration + // watchdog owns the rest of the incident, including its recovery note. vi.mocked(prisma.integrationStatus.findUnique).mockResolvedValueOnce({ consecutiveFailuresByKind: { transient: 5, @@ -377,15 +429,6 @@ describe("recordSyncFailure — alert window", () => { vi.mocked(prisma.integrationStatus.upsert).mockResolvedValueOnce({ alertedAt: new Date(Date.now() - 25 * 60 * 60 * 1000), } as never); - vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({ - email: "u@example.com", - } as never); - vi.mocked(prisma.user.findMany).mockResolvedValueOnce([ - { id: "admin-1" }, - ] as never); - vi.mocked(prisma.integrationStatus.update).mockResolvedValueOnce( - {} as never, - ); await recordSyncFailure({ userId: "u1", @@ -394,7 +437,7 @@ describe("recordSyncFailure — alert window", () => { message: "Withings refresh error: 503 - upstream", }); - expect(dispatchNotification).toHaveBeenCalledOnce(); + expect(dispatchNotification).not.toHaveBeenCalled(); }); }); diff --git a/src/lib/integrations/__tests__/watchdog-classify.test.ts b/src/lib/integrations/__tests__/watchdog-classify.test.ts new file mode 100644 index 000000000..35dd48473 --- /dev/null +++ b/src/lib/integrations/__tests__/watchdog-classify.test.ts @@ -0,0 +1,428 @@ +import { describe, expect, it } from "vitest"; +import type { ConnectionHealth } from "../connection-health"; +import { + NORMAL_DELIVERY_MIN_DAYS, + SOURCE_PROFILES, + classifySilence, + FIRST_RUN_ALERTED_AT, + currentIncident, + decideWatchTransition, + type WatchRow, +} from "../watchdog-classify"; +import { formatWatchdogMessage } from "../watchdog"; + +const NOW = new Date("2026-10-15T12:00:00.000Z"); +const HOUR = 60 * 60 * 1000; +const ago = (hours: number) => new Date(NOW.getTime() - hours * HOUR); + +function health( + state: ConnectionHealth["state"], + reason: ConnectionHealth["reason"], + since: Date | null, +): ConnectionHealth { + return { + state, + reason, + since: since?.toISOString() ?? null, + action: null, + unavailable: [], + lastSuccessAt: null, + lastDataAt: null, + diagnostics: { + ledgerState: null, + failingLegs: [], + failingSince: null, + lastAttemptAt: null, + verdict: "fresh", + recentCalls: [], + }, + }; +} + +const openRow = (overrides: Partial = {}): WatchRow => ({ + silentSince: null, + incidentReason: "unexpected_silence", + incidentOpenedAt: ago(5), + alertedAt: ago(5), + ...overrides, +}); + +describe("classifySilence", () => { + // A wearable whose cloud receives data on its own: the 24 h window. + const wearable = SOURCE_PROFILES.fitbit; + const withings = SOURCE_PROFILES.withings; + + it("never flags an on-demand source for silence, however long and however regular", () => { + expect(withings.kind).toBe("on_demand"); + expect(SOURCE_PROFILES.strava.kind).toBe("on_demand"); + expect( + classifySilence({ + profile: withings, + lastDataAt: ago(24 * 30), + deliveredDays: 14, + currentSilentSince: null, + now: NOW, + }), + ).toEqual({ kind: "clear" }); + }); + + it("is clear while a continuous source delivers inside its window", () => { + expect( + classifySilence({ + profile: wearable, + lastDataAt: ago(20), + deliveredDays: null, + currentSilentSince: null, + now: NOW, + }), + ).toEqual({ kind: "clear" }); + }); + + it("asks for the history before flagging (the cheap hourly pass reads it lazily)", () => { + expect( + classifySilence({ + profile: wearable, + lastDataAt: ago(30), + deliveredDays: null, + currentSilentSince: null, + now: NOW, + }), + ).toEqual({ kind: "needs_history" }); + }); + + it("flags a silent source that delivered on most of the previous 14 days", () => { + expect( + classifySilence({ + profile: wearable, + lastDataAt: ago(30), + deliveredDays: NORMAL_DELIVERY_MIN_DAYS, + currentSilentSince: null, + now: NOW, + }), + ).toEqual({ kind: "flag", silentSince: ago(30) }); + }); + + it("does not flag a source its owner wears only now and then", () => { + expect( + classifySilence({ + profile: wearable, + lastDataAt: ago(30), + deliveredDays: NORMAL_DELIVERY_MIN_DAYS - 1, + currentSilentSince: null, + now: NOW, + }), + ).toEqual({ kind: "not_expected" }); + }); + + it("keeps a standing flag on the hourly pass without re-reading history", () => { + expect( + classifySilence({ + profile: wearable, + lastDataAt: ago(50), + deliveredDays: null, + currentSilentSince: ago(50), + now: NOW, + }), + ).toEqual({ kind: "flag", silentSince: ago(50) }); + }); + + it("drops a standing flag on the daily re-check once the history no longer says it delivers", () => { + expect( + classifySilence({ + profile: wearable, + lastDataAt: ago(24 * 6), + deliveredDays: 8, + currentSilentSince: ago(24 * 6), + now: NOW, + }), + ).toEqual({ kind: "not_expected" }); + }); + + it("waits 48 h for Oura and WHOOP, which upload only when their phone app is opened", () => { + for (const key of ["oura", "whoop"] as const) { + const profile = SOURCE_PROFILES[key]; + expect(profile.kind).toBe("continuous"); + // A day and a half without opening the app is ordinary. + expect( + classifySilence({ + profile, + lastDataAt: ago(36), + deliveredDays: 14, + currentSilentSince: null, + now: NOW, + }), + ).toEqual({ kind: "clear" }); + // Past two days it is not. + expect( + classifySilence({ + profile, + lastDataAt: ago(49), + deliveredDays: 14, + currentSilentSince: null, + now: NOW, + }), + ).toEqual({ kind: "flag", silentSince: ago(49) }); + } + }); + + it("keeps the 24 h window for wearables whose cloud receives data on its own", () => { + for (const key of [ + "fitbit", + "google-health", + "polar", + "nightscout", + ] as const) { + expect(SOURCE_PROFILES[key]).toEqual({ + kind: "continuous", + silenceWindowMs: 24 * HOUR, + }); + } + }); + + it("never alerts on silence for scales, cuffs and workout logs", () => { + for (const key of ["withings", "strava"] as const) { + expect(SOURCE_PROFILES[key].kind).toBe("on_demand"); + } + }); + + it("gives Apple Health a wider window than a wearable", () => { + expect(SOURCE_PROFILES["apple-health"].silenceWindowMs).toBe(48 * HOUR); + const apple = SOURCE_PROFILES["apple-health"]; + expect( + classifySilence({ + profile: apple, + lastDataAt: ago(30), + deliveredDays: 14, + currentSilentSince: null, + now: NOW, + }), + ).toEqual({ kind: "clear" }); + }); +}); + +describe("currentIncident", () => { + it("opens nothing for a degraded state younger than a day", () => { + expect( + currentIncident({ + health: health("degraded", "provider_errors", ago(5)), + silentSince: null, + ledgerAlerted: false, + now: NOW, + }), + ).toBeNull(); + }); + + it("opens an incident once degraded or action_required has lasted more than a day", () => { + expect( + currentIncident({ + health: health("action_required", "reauth_required", ago(25)), + silentSince: null, + ledgerAlerted: false, + now: NOW, + }), + ).toEqual({ reason: "reauth_required", since: ago(25) }); + }); + + it("follows the ledger's own page at once, so its recovery is reported too", () => { + expect( + currentIncident({ + health: health("degraded", "provider_errors", ago(3)), + silentSince: null, + ledgerAlerted: true, + now: NOW, + }), + ).toEqual({ reason: "provider_errors", since: ago(3) }); + }); + + it("opens a silence incident from the flag", () => { + expect( + currentIncident({ + health: health("ok", "data_arriving", null), + silentSince: ago(30), + ledgerAlerted: false, + now: NOW, + }), + ).toEqual({ reason: "unexpected_silence", since: ago(30) }); + }); + + it("never opens an incident for stale or stalled data on its own, however long it lasts", () => { + // An Apple Health account whose phone app stopped running reads as + // `no_new_data` for good; nothing an operator can do about it. + for (const reason of ["no_new_data", "sync_stalled"] as const) { + expect( + currentIncident({ + health: health("degraded", reason, ago(24 * 90)), + silentSince: null, + ledgerAlerted: false, + now: NOW, + }), + ).toBeNull(); + } + }); + + it("opens an incident for provider errors that have lasted more than a day", () => { + expect( + currentIncident({ + health: health("degraded", "provider_errors", ago(25)), + silentSince: null, + ledgerAlerted: false, + now: NOW, + }), + ).toEqual({ reason: "provider_errors", since: ago(25) }); + }); + + it("still opens a silence incident beside stale data", () => { + expect( + currentIncident({ + health: health("degraded", "no_new_data", ago(50)), + silentSince: ago(30), + ledgerAlerted: false, + now: NOW, + }), + ).toEqual({ reason: "unexpected_silence", since: ago(30) }); + }); + + it("has no incident for a limited connection", () => { + expect( + currentIncident({ + health: health("limited", "plan_limited", null), + silentSince: null, + ledgerAlerted: false, + now: NOW, + }), + ).toBeNull(); + }); +}); + +describe("decideWatchTransition: an incident recorded on the first run", () => { + it("closes without a recovery note, since none was ever sent", () => { + expect( + decideWatchTransition({ + row: { + silentSince: ago(30), + incidentReason: "unexpected_silence", + incidentOpenedAt: ago(2), + alertedAt: FIRST_RUN_ALERTED_AT, + }, + incident: null, + ledgerAlerted: false, + }), + ).toEqual({ + kind: "close", + notify: false, + previousReason: "unexpected_silence", + }); + }); +}); + +describe("decideWatchTransition", () => { + const incident = { reason: "unexpected_silence" as const, since: ago(30) }; + + it("opens and announces a new incident", () => { + expect( + decideWatchTransition({ row: null, incident, ledgerAlerted: false }), + ).toEqual({ kind: "open", ...incident }); + }); + + it("adopts an incident the ledger already paged about instead of paging again", () => { + expect( + decideWatchTransition({ row: null, incident, ledgerAlerted: true }), + ).toEqual({ kind: "adopt", ...incident }); + }); + + it("sends nothing while an announced incident stays open (no repeats)", () => { + expect( + decideWatchTransition({ + row: openRow(), + incident, + ledgerAlerted: false, + }), + ).toEqual({ kind: "keep", reason: "unexpected_silence", announce: false }); + }); + + it("treats a changed reason as the same incident", () => { + expect( + decideWatchTransition({ + row: openRow(), + incident: { reason: "reauth_required", since: ago(2) }, + ledgerAlerted: false, + }), + ).toEqual({ kind: "keep", reason: "reauth_required", announce: false }); + }); + + it("retries the announcement of an incident no channel accepted", () => { + expect( + decideWatchTransition({ + row: openRow({ alertedAt: null }), + incident, + ledgerAlerted: false, + }), + ).toEqual({ kind: "keep", reason: "unexpected_silence", announce: true }); + }); + + it("closes with one recovery note when the announced incident ends", () => { + expect( + decideWatchTransition({ + row: openRow(), + incident: null, + ledgerAlerted: false, + }), + ).toEqual({ + kind: "close", + notify: true, + previousReason: "unexpected_silence", + }); + }); + + it("closes quietly when the operator was never told it started", () => { + expect( + decideWatchTransition({ + row: openRow({ alertedAt: null }), + incident: null, + ledgerAlerted: false, + }), + ).toEqual({ + kind: "close", + notify: false, + previousReason: "unexpected_silence", + }); + }); + + it("does nothing when there was and is no incident", () => { + expect( + decideWatchTransition({ + row: null, + incident: null, + ledgerAlerted: false, + }), + ).toEqual({ kind: "none" }); + }); +}); + +describe("formatWatchdogMessage", () => { + it("names the provider, the account and the instant, in plain text", () => { + const m = formatWatchdogMessage({ + kind: "incident", + key: "oura", + subjectLabel: "someone@example.test", + reason: "unexpected_silence", + since: ago(30), + }); + expect(m.title).toBe("Oura needs attention for someone@example.test"); + expect(m.message).toContain("2026-10-14 06:00 UTC"); + expect(m.message).not.toMatch(/[—–·→]/); + }); + + it("says why a recovered incident ended", () => { + const m = formatWatchdogMessage({ + kind: "recovery", + key: "apple-health", + subjectLabel: "someone@example.test", + reason: "unexpected_silence", + since: null, + closedBecause: "disconnected", + }); + expect(m.title).toBe("Apple Health recovered for someone@example.test"); + expect(m.message).toContain("removed"); + }); +}); diff --git a/src/lib/integrations/connection-health-load.ts b/src/lib/integrations/connection-health-load.ts new file mode 100644 index 000000000..a309f8253 --- /dev/null +++ b/src/lib/integrations/connection-health-load.ts @@ -0,0 +1,436 @@ +/** + * Gather what `resolveConnectionHealth` needs, for one account, in one place. + * + * Every surface that publishes a connection's health (the consolidated + * integrations envelope, the per-provider status routes, the Apple Health + * config read, the watchdog) calls this loader, so the four states cannot + * drift into one opinion per route. It reads: + * + * - the sync ledger rows (state, streak, failing legs, recent calls), + * - which connections exist and the provider-side extras that bear on + * health (Withings plan + activity scope, Oura scope gaps, Google Health + * `needsReauth`), + * - the watchdog's silence flags, + * - the newest data per connected source (one index probe each). + * + * Read-only. A connection that does not exist resolves to null. + */ +import { prisma } from "@/lib/db"; +import type { MeasurementSource } from "@/generated/prisma/client"; +import type { IntegrationKey } from "./status"; +import { + INTEGRATION_CADENCE, + PUSH_CADENCE, + resolveSyncVerdict, + type SyncHealth, +} from "./sync-verdict"; +import { + CONNECTION_KEYS, + RECENT_CALLS_LIMIT, + readRecentCalls, + resolveConnectionHealth, + type ConnectionHealth, + type ConnectionKey, + type RecentCall, +} from "./connection-health"; +import { SOURCE_PROFILES } from "./watchdog-classify"; +import { + INTEGRATION_MEASUREMENT_SOURCE, + INTEGRATION_WORKOUT_SOURCE, +} from "./metric-freshness"; +import { parseWithingsApiPlanState } from "@/lib/withings/api-plan"; +import { withingsPlanUnavailableFeatures } from "@/lib/withings/plan-features"; +import { hasActivityScope } from "@/lib/withings/client"; +import { readOuraScopeDenied } from "@/lib/oura/credentials"; +import { ouraScopeReconnectList } from "@/lib/oura/scope"; + +export type ConnectionHealthMap = Record< + ConnectionKey, + ConnectionHealth | null +>; + +/** Everything the loader read for one connection, beside the resolved health. */ +export interface ConnectionFacts { + key: ConnectionKey; + connected: boolean; + syncHealth: SyncHealth; + lastDataAt: Date | null; + /** The sync ledger holds an unresolved operator page for this streak. */ + ledgerAlerted: boolean; + /** Health resolved WITHOUT the watchdog's silence flag (the watchdog's own input). */ + healthWithoutSilence: ConnectionHealth | null; + /** Health as published (silence flag folded in). */ + health: ConnectionHealth | null; + silentSince: Date | null; +} + +interface LedgerRow { + integration: string; + state: string; + lastSuccessAt: Date | null; + lastAttemptAt: Date | null; + failingSinceAt: Date | null; + failingLegs: unknown; + recentCalls: unknown; + alertedAt: Date | null; +} + +/** Newest measurement of one source: one probe on `(userId, source, measuredAt DESC)`. */ +async function newestMeasurementAt( + userId: string, + source: MeasurementSource, +): Promise { + const row = await prisma.measurement.findFirst({ + where: { userId, source, deletedAt: null }, + orderBy: { measuredAt: "desc" }, + select: { measuredAt: true }, + }); + return row?.measuredAt ?? null; +} + +async function newestWorkoutAt( + userId: string, + source: MeasurementSource, +): Promise { + const row = await prisma.workout.findFirst({ + where: { userId, source }, + orderBy: { startedAt: "desc" }, + select: { startedAt: true }, + }); + return row?.startedAt ?? null; +} + +function newestOf(...values: Array): Date | null { + let max: Date | null = null; + for (const v of values) { + if (v && (max === null || v.getTime() > max.getTime())) max = v; + } + return max; +} + +function iso(value: Date | null | undefined): string | null { + return value ? value.toISOString() : null; +} + +function readLegs(value: unknown): string[] { + if (!Array.isArray(value)) return []; + return value.filter( + (v): v is string => + typeof v === "string" && /^[A-Za-z0-9_.:-]{1,48}$/.test(v), + ); +} + +/** + * Load the facts and resolved health for every connection key of one account. + * `keys` narrows the work for a single-provider route. + */ +export async function loadConnectionFacts( + userId: string, + keys: readonly ConnectionKey[] = CONNECTION_KEYS, + now: Date = new Date(), +): Promise> { + const wanted = new Set(keys); + const [ + ledgerRows, + watchRows, + dbUser, + withingsConn, + whoopConn, + fitbitConn, + googleHealthConn, + healthKitArrivals, + ] = await Promise.all([ + prisma.integrationStatus.findMany({ + where: { userId }, + select: { + integration: true, + state: true, + lastSuccessAt: true, + lastAttemptAt: true, + failingSinceAt: true, + failingLegs: true, + recentCalls: true, + alertedAt: true, + }, + }), + prisma.integrationWatch.findMany({ + where: { userId }, + select: { integration: true, silentSince: true }, + }), + prisma.user.findUnique({ + where: { id: userId }, + select: { + polarAccessTokenEncrypted: true, + ouraAccessTokenEncrypted: true, + ouraScopeDenied: true, + stravaAccessTokenEncrypted: true, + nightscoutUrlEncrypted: true, + healthKitLastSyncedAt: true, + }, + }), + wanted.has("withings") + ? prisma.withingsConnection.findUnique({ + where: { userId }, + select: { lastSyncedAt: true, scope: true, apiPlanState: true }, + }) + : null, + wanted.has("whoop") + ? prisma.whoopConnection.findUnique({ + where: { userId }, + select: { lastSyncedAt: true, whoopUserId: true }, + }) + : null, + wanted.has("fitbit") + ? prisma.fitbitConnection.findUnique({ + where: { userId }, + select: { lastSyncedAt: true }, + }) + : null, + wanted.has("google-health") + ? prisma.googleHealthConnection.findUnique({ + where: { userId }, + select: { lastSyncedAt: true, needsReauth: true }, + }) + : null, + wanted.has("apple-health") + ? prisma.healthKitTypeSync.findMany({ + where: { userId }, + select: { type: true, lastReceivedAt: true, lastNewSampleAt: true }, + orderBy: { lastReceivedAt: "desc" }, + }) + : [], + ]); + + const ledger = new Map( + ledgerRows.map((row) => [row.integration, row]), + ); + const silence = new Map( + watchRows.map((row) => [row.integration, row.silentSince]), + ); + + const connected: Record = { + withings: !!withingsConn, + whoop: !!whoopConn?.whoopUserId, + fitbit: !!fitbitConn, + "google-health": !!googleHealthConn, + polar: !!dbUser?.polarAccessTokenEncrypted, + oura: !!dbUser?.ouraAccessTokenEncrypted, + strava: !!dbUser?.stravaAccessTokenEncrypted, + nightscout: !!dbUser?.nightscoutUrlEncrypted, + // Apple Health has nothing to connect; it is "connected" once the phone + // has delivered anything (a live batch, or rows from the export import). + "apple-health": false, + }; + const legacyLastSyncedAt: Partial> = { + withings: withingsConn?.lastSyncedAt ?? null, + whoop: whoopConn?.whoopUserId ? (whoopConn.lastSyncedAt ?? null) : null, + fitbit: fitbitConn?.lastSyncedAt ?? null, + "google-health": googleHealthConn?.lastSyncedAt ?? null, + }; + + // Newest data per wanted, connected source. Apple Health is probed even + // before it counts as connected, because delivered data is what connects it. + const lastData = new Map(); + await Promise.all( + [...wanted].map(async (key) => { + if (key === "apple-health") { + const measured = await newestMeasurementAt(userId, "APPLE_HEALTH"); + lastData.set(key, measured); + return; + } + if (!connected[key]) return; + const mSource = INTEGRATION_MEASUREMENT_SOURCE[key as IntegrationKey]; + const wSource = INTEGRATION_WORKOUT_SOURCE[key as IntegrationKey]; + const [m, w] = await Promise.all([ + mSource ? newestMeasurementAt(userId, mSource) : null, + wSource ? newestWorkoutAt(userId, wSource) : null, + ]); + lastData.set(key, newestOf(m, w)); + }), + ); + + const out = new Map(); + const nowMs = now.getTime(); + + for (const key of keys) { + if (key === "apple-health") { + out.set( + key, + appleHealthFacts({ + lastSyncedAt: dbUser?.healthKitLastSyncedAt ?? null, + arrivals: healthKitArrivals, + newestMeasuredAt: lastData.get("apple-health") ?? null, + silentSince: silence.get(key) ?? null, + now, + }), + ); + continue; + } + + const row = ledger.get(key); + const isConnected = connected[key]; + const syncHealth = resolveSyncVerdict({ + connected: isConnected, + configured: isConnected, + state: row?.state ?? "unknown", + lastSuccessAt: row?.lastSuccessAt ?? null, + lastAttemptAt: row?.lastAttemptAt ?? null, + failingSinceAt: row?.failingSinceAt ?? null, + legacyLastSyncedAt: legacyLastSyncedAt[key] ?? null, + cadence: INTEGRATION_CADENCE[key], + now: nowMs, + }); + + let planUnavailable: string[] = []; + let permissionUnavailable: string[] = []; + if (key === "withings" && withingsConn) { + planUnavailable = withingsPlanUnavailableFeatures( + parseWithingsApiPlanState(withingsConn.apiPlanState), + ); + // A connection whose grant predates the activity scope cannot read + // activity or sleep; one reconnect asks for it. A null scope is the + // same legacy case `hasActivityScope` already treats as missing. + if (!hasActivityScope(withingsConn.scope ?? null)) { + permissionUnavailable = ["activity", "sleep"]; + } + } + if (key === "oura" && isConnected) { + permissionUnavailable = ouraScopeReconnectList( + readOuraScopeDenied(dbUser?.ouraScopeDenied), + ); + } + + const lastDataAt = lastData.get(key) ?? null; + const silentSince = silence.get(key) ?? null; + const common = { + syncHealth, + connected: isConnected, + ledgerState: row?.state ?? null, + lastSuccessAt: iso(row?.lastSuccessAt ?? legacyLastSyncedAt[key] ?? null), + lastAttemptAt: iso(row?.lastAttemptAt), + failingSince: iso(row?.failingSinceAt), + failingLegs: readLegs(row?.failingLegs), + recentCalls: readRecentCalls(row?.recentCalls), + lastDataAt: iso(lastDataAt), + needsReauth: key === "google-health" && !!googleHealthConn?.needsReauth, + planUnavailable, + permissionUnavailable, + silenceWindowMs: SOURCE_PROFILES[key].silenceWindowMs, + now: nowMs, + }; + out.set(key, { + key, + connected: isConnected, + syncHealth, + lastDataAt, + ledgerAlerted: row?.alertedAt != null, + healthWithoutSilence: resolveConnectionHealth({ + ...common, + silentSince: null, + }), + health: resolveConnectionHealth({ + ...common, + silentSince: iso(silentSince), + }), + silentSince, + }); + } + return out; +} + +function appleHealthFacts(input: { + lastSyncedAt: Date | null; + arrivals: Array<{ + type: string; + lastReceivedAt: Date; + lastNewSampleAt: Date | null; + }>; + newestMeasuredAt: Date | null; + silentSince: Date | null; + now: Date; +}): ConnectionFacts { + const newestReceived = newestOf( + ...input.arrivals.map((a) => a.lastReceivedAt), + ); + const newestNewSample = newestOf( + ...input.arrivals.map((a) => a.lastNewSampleAt), + ); + // "Last import with data": a sync that brought a new sample. Rows from the + // export import carry no arrival record, so their newest sample stands in. + const lastDataAt = newestNewSample ?? input.newestMeasuredAt; + const connected = + input.lastSyncedAt !== null || + newestReceived !== null || + input.newestMeasuredAt !== null; + // Same rule the config read uses (`newestAppleHealthDataAt`): anything that + // arrived counts as liveness for the push cadence. + const syncHealth = resolveSyncVerdict({ + configured: connected, + connected, + lastDataAt: newestOf( + input.lastSyncedAt, + newestReceived, + input.newestMeasuredAt, + ), + legacyLastSyncedAt: input.lastSyncedAt, + cadence: PUSH_CADENCE, + now: input.now, + }); + const recentCalls: RecentCall[] = input.arrivals + .slice(0, RECENT_CALLS_LIMIT) + .map((a) => ({ + endpoint: `batch:${a.type}`, + status: "ok", + at: a.lastReceivedAt.toISOString(), + errorCode: null, + })); + const common = { + syncHealth, + connected, + ledgerState: null, + lastSuccessAt: iso(input.lastSyncedAt ?? newestReceived), + lastAttemptAt: iso(newestReceived), + failingSince: null, + failingLegs: [], + recentCalls, + lastDataAt: iso(lastDataAt), + silenceWindowMs: SOURCE_PROFILES["apple-health"].silenceWindowMs, + now: input.now.getTime(), + }; + return { + key: "apple-health", + connected, + syncHealth, + lastDataAt, + ledgerAlerted: false, + healthWithoutSilence: resolveConnectionHealth({ + ...common, + silentSince: null, + }), + health: resolveConnectionHealth({ + ...common, + silentSince: iso(input.silentSince), + }), + silentSince: input.silentSince, + }; +} + +/** The published health per connection key, for routes. */ +export async function loadConnectionHealth( + userId: string, + keys: readonly ConnectionKey[] = CONNECTION_KEYS, +): Promise { + const facts = await loadConnectionFacts(userId, keys); + const out = {} as ConnectionHealthMap; + for (const key of CONNECTION_KEYS) out[key] = facts.get(key)?.health ?? null; + return out; +} + +/** One connection's published health, for a single-provider route. */ +export async function loadOneConnectionHealth( + userId: string, + key: ConnectionKey, +): Promise { + const facts = await loadConnectionFacts(userId, [key]); + return facts.get(key)?.health ?? null; +} diff --git a/src/lib/integrations/connection-health.ts b/src/lib/integrations/connection-health.ts new file mode 100644 index 000000000..fb5cd53e5 --- /dev/null +++ b/src/lib/integrations/connection-health.ts @@ -0,0 +1,390 @@ +/** + * One health state per connection, resolved on the server. + * + * Every integration used to express its condition in its own vocabulary: the + * ledger `state` (`error_transient`, `error_reauth`, `parked`), the liveness + * `syncHealth.verdict`, Withings' `planUnavailable` and `hasActivityScope`, + * Oura's `scopeReconnect`, Google Health's `needsReauth`, a token expiry, a + * backfill flag. Each card combined those itself, so the same fact could read + * as a red error on one card and as a footnote on the next. + * + * This module folds all of it into four states a person can act on: + * + * ok data is arriving (or the first sync is still due). + * limited it works, but some data types are unavailable because of + * the provider plan or a missing permission; `unavailable` + * names them. + * degraded temporary provider trouble or unexpected silence; the + * server keeps retrying. `since` dates it. + * action_required the person has to do something; `action` names the one + * thing. + * + * Plus a short reason code, the last successful sync, the newest data the + * source delivered, and a redacted diagnostics object that is safe to paste + * into a bug report: endpoint (leg) names, outcomes, instants and short error + * codes. Never a message, a token, a value or a user id. + * + * Pure: no Prisma, no I/O, type imports only, so the resolver is unit-testable + * and the types are importable from client code. The loader that gathers the + * inputs lives in `./connection-health-load`. + */ +import type { IntegrationKey } from "./status"; +import type { SyncHealth } from "./sync-verdict"; + +/** Every connection the health read covers: the polled providers + Apple Health. */ +export type ConnectionKey = IntegrationKey | "apple-health"; + +export const CONNECTION_KEYS: readonly ConnectionKey[] = [ + "withings", + "whoop", + "fitbit", + "google-health", + "polar", + "oura", + "strava", + "nightscout", + "apple-health", +]; + +export const CONNECTION_HEALTH_STATES = [ + "ok", + "limited", + "degraded", + "action_required", +] as const; +export type ConnectionHealthState = (typeof CONNECTION_HEALTH_STATES)[number]; + +/** + * Why the state is what it is. One code per state branch, so a client can pick + * its sentence from the code alone. + */ +export const CONNECTION_HEALTH_REASONS = [ + // ok + "data_arriving", + "first_sync_pending", + // limited + "plan_limited", + "permission_limited", + // degraded + "provider_errors", + "sync_stalled", + "no_new_data", + "unexpected_silence", + // action_required + "reauth_required", + "paused_after_errors", +] as const; +export type ConnectionHealthReason = (typeof CONNECTION_HEALTH_REASONS)[number]; + +/** The one thing a person can do about an `action_required` (or `limited`) connection. */ +export const CONNECTION_HEALTH_ACTIONS = ["reconnect", "resume"] as const; +export type ConnectionHealthAction = (typeof CONNECTION_HEALTH_ACTIONS)[number]; + +export interface UnavailableItem { + /** A provider data type key (`spo2`, `vo2Max`, `activity`, …). */ + item: string; + /** Withheld by the provider's plan, or not covered by the granted permission. */ + cause: "plan" | "permission"; +} + +/** Outcome of one recorded sync attempt. */ +export const RECENT_CALL_STATUSES = [ + "ok", + "transient", + "persistent", + "reauth_required", +] as const; +export type RecentCallStatus = (typeof RECENT_CALL_STATUSES)[number]; + +export interface RecentCall { + /** The sync leg name the code recorded (`sleep`, `measures`, `sync`), or for Apple Health `batch:`. */ + endpoint: string; + status: RecentCallStatus; + at: string; + /** A short error code (`401`, `invalid_grant`, `scope_missing`) or null. */ + errorCode: string | null; +} + +/** How many attempts the ring keeps and the diagnostics publish. */ +export const RECENT_CALLS_LIMIT = 10; + +export interface ConnectionDiagnostics { + /** The ledger state string as stored, for whoever reads a bug report. */ + ledgerState: string | null; + /** Sync legs currently recorded as failing. */ + failingLegs: string[]; + /** Start of the current failure streak, if any. */ + failingSince: string | null; + lastAttemptAt: string | null; + /** The liveness verdict the state was derived from. */ + verdict: SyncHealth["verdict"]; + /** Newest first, at most `RECENT_CALLS_LIMIT`. */ + recentCalls: RecentCall[]; +} + +export interface ConnectionHealth { + state: ConnectionHealthState; + reason: ConnectionHealthReason; + /** When the current non-ok condition started; null for `ok` and `limited`. */ + since: string | null; + /** The one action that resolves it; null when none is needed or possible. */ + action: ConnectionHealthAction | null; + /** Data types this connection cannot deliver; empty when nothing is withheld. */ + unavailable: UnavailableItem[]; + lastSuccessAt: string | null; + /** + * The 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. + */ + lastDataAt: string | null; + diagnostics: ConnectionDiagnostics; +} + +export interface ConnectionHealthInput { + /** The liveness verdict from `resolveSyncVerdict`. */ + syncHealth: SyncHealth; + /** Not connected: there is no health to resolve. */ + connected: boolean; + ledgerState: string | null; + lastSuccessAt: string | null; + lastAttemptAt: string | null; + failingSince: string | null; + failingLegs: readonly string[]; + recentCalls: readonly RecentCall[]; + lastDataAt: string | null; + /** Provider-side reauth signal outside the ledger (Google Health `needsReauth`). */ + needsReauth?: boolean; + /** Types withheld by the provider plan (Withings). */ + planUnavailable?: readonly string[]; + /** Types the granted permission does not cover (Oura scopes, Withings activity scope). */ + permissionUnavailable?: readonly string[]; + /** + * The watchdog's unexpected-silence flag: the newest-data instant at the + * moment it flagged, or null when not flagged. + */ + silentSince?: string | null; + /** + * How long the source may go without data before the silence flag counts. + * A flag is ignored once `lastDataAt` is newer than this window, so a source + * that delivered after the last watchdog pass reads healthy at once. + */ + silenceWindowMs?: number; + now?: number; +} + +function toMs(value: string | null | undefined): number | null { + if (!value) return null; + const ms = Date.parse(value); + return Number.isFinite(ms) ? ms : null; +} + +/** + * Fold the inputs into one state. First match wins, from the state that asks + * the most of the person down to the one that asks nothing: + * + * 1. action_required — the grant is gone, or the connection paused itself. + * 2. degraded — failing, stalled, stale, or unexpectedly silent. + * 3. limited — working, with named types withheld. + * 4. ok. + * + * Returns null for a connection that does not exist: there is nothing to say + * about its health, and a state would suggest otherwise. + */ +export function resolveConnectionHealth( + input: ConnectionHealthInput, +): ConnectionHealth | null { + if (!input.connected || input.syncHealth.verdict === "disconnected") { + return null; + } + const now = input.now ?? Date.now(); + const { verdict, since: verdictSince } = input.syncHealth; + + const unavailable: UnavailableItem[] = [ + ...dedupe(input.planUnavailable).map((item): UnavailableItem => ({ + item, + cause: "plan", + })), + ...dedupe(input.permissionUnavailable).map((item): UnavailableItem => ({ + item, + cause: "permission", + })), + ]; + + const diagnostics: ConnectionDiagnostics = { + ledgerState: input.ledgerState, + failingLegs: [...input.failingLegs], + failingSince: input.failingSince, + lastAttemptAt: input.lastAttemptAt, + verdict, + recentCalls: input.recentCalls.slice(0, RECENT_CALLS_LIMIT), + }; + + const base = { + unavailable, + lastSuccessAt: input.lastSuccessAt, + lastDataAt: input.lastDataAt, + diagnostics, + }; + + // 1. The person has to act. + if (verdict === "reauth_required" || input.needsReauth) { + return { + ...base, + state: "action_required", + reason: "reauth_required", + since: verdictSince ?? input.failingSince, + action: "reconnect", + }; + } + if (verdict === "parked") { + return { + ...base, + state: "action_required", + reason: "paused_after_errors", + since: verdictSince ?? input.failingSince, + action: "resume", + }; + } + + // 2. Temporary trouble; the server keeps trying. + if (verdict === "failing") { + return { + ...base, + state: "degraded", + reason: "provider_errors", + since: verdictSince, + action: null, + }; + } + if (verdict === "stalled") { + return { + ...base, + state: "degraded", + reason: "sync_stalled", + since: verdictSince, + action: null, + }; + } + if (verdict === "stale") { + return { + ...base, + state: "degraded", + reason: "no_new_data", + since: verdictSince, + action: null, + }; + } + if (isSilenceStillTrue(input, now)) { + return { + ...base, + state: "degraded", + reason: "unexpected_silence", + since: input.silentSince ?? null, + action: null, + }; + } + + // 3. Working, with something withheld. A permission gap is fixable by one + // reconnect; a plan gap is not fixable here at all. + const permissionGap = unavailable.some((u) => u.cause === "permission"); + if (unavailable.length > 0) { + return { + ...base, + state: "limited", + reason: permissionGap ? "permission_limited" : "plan_limited", + since: null, + action: permissionGap ? "reconnect" : null, + }; + } + + // 4. Fine. + return { + ...base, + state: "ok", + reason: + verdict === "pending_first_sync" ? "first_sync_pending" : "data_arriving", + since: null, + action: null, + }; +} + +/** + * The watchdog flag stands only while no data newer than the silence window + * has arrived. The watchdog clears it on its next pass; this keeps the read + * honest in the hour between. + */ +function isSilenceStillTrue( + input: ConnectionHealthInput, + now: number, +): boolean { + if (!input.silentSince) return false; + const window = input.silenceWindowMs; + const lastData = toMs(input.lastDataAt); + if (window != null && lastData != null && now - lastData <= window) { + return false; + } + return true; +} + +function dedupe(values: readonly string[] | undefined): string[] { + if (!values) return []; + return [...new Set(values)]; +} + +// ── Recent-call ring ───────────────────────────────────────────────── + +/** Leg names and error codes are identifiers; anything else is dropped. */ +const SAFE_TOKEN = /^[A-Za-z0-9_.:-]{1,48}$/; + +/** A leg name safe to publish, or `sync` when the caller passed none or something unexpected. */ +export function safeEndpointName(value: string | null | undefined): string { + return value && SAFE_TOKEN.test(value) ? value : "sync"; +} + +/** + * An error code safe to publish, or null. Codes are short identifiers the sync + * code passes (`401`, `invalid_grant`, `scope_missing`); a value that does not + * look like one (a sentence, a URL, something carrying a token) is dropped + * rather than trimmed, because a trimmed secret is still a secret. + */ +export function safeErrorCode(value: string | null | undefined): string | null { + return value && SAFE_TOKEN.test(value) ? value : null; +} + +/** + * Read the stored JSON ring into typed entries, newest first. Anything that is + * not a well-formed entry is skipped; every string is re-checked against the + * same closed shapes the writer applies, so a hand-edited row cannot publish + * free text through the diagnostics. + */ +export function readRecentCalls(value: unknown): RecentCall[] { + if (!Array.isArray(value)) return []; + const out: RecentCall[] = []; + for (const raw of value) { + if (!raw || typeof raw !== "object") continue; + const entry = raw as Record; + const status = entry.status; + const at = entry.at; + if ( + typeof status !== "string" || + !(RECENT_CALL_STATUSES as readonly string[]).includes(status) || + typeof at !== "string" || + !Number.isFinite(Date.parse(at)) + ) { + continue; + } + out.push({ + endpoint: safeEndpointName( + typeof entry.endpoint === "string" ? entry.endpoint : null, + ), + status: status as RecentCallStatus, + at: new Date(at).toISOString(), + errorCode: safeErrorCode( + typeof entry.errorCode === "string" ? entry.errorCode : null, + ), + }); + if (out.length >= RECENT_CALLS_LIMIT) break; + } + return out; +} diff --git a/src/lib/integrations/healthkit-type-sync.ts b/src/lib/integrations/healthkit-type-sync.ts index 20570d0b0..4c456242a 100644 --- a/src/lib/integrations/healthkit-type-sync.ts +++ b/src/lib/integrations/healthkit-type-sync.ts @@ -3,9 +3,12 @@ * * The batch route records, per measurement type it received, when the type * last arrived, under which `syncTrigger`, and when it last brought a new or - * updated sample (as opposed to duplicates only). The Apple Health card reads - * it to show which types are actually flowing. A scoped ingest credential - * does not write it, the same rule as `lastSyncedAt`. + * updated sample (as opposed to duplicates only). Since v1.42.4 the intake + * bulk route records Apple Health doses the same way under the key + * `MEDICATION_DOSE`, and every row also keeps when a `background` batch last + * carried the type. The Apple Health card reads it to show which types are + * actually flowing. A scoped ingest credential does not write it, the same + * rule as `lastSyncedAt`. * * Sample time (`max(measuredAt)`, what the freshness list showed before) and * arrival time are different questions. A type the phone only hands over @@ -17,19 +20,32 @@ import type { MeasurementType, Prisma } from "@/generated/prisma/client"; import { prisma } from "@/lib/db"; import { logCaught } from "@/lib/logging/signal"; import { + MEDICATION_DOSE_FRESHNESS_TYPE, WORKOUT_FRESHNESS_TYPE, type MetricFreshnessEntry, } from "./sync-verdict"; -export type HealthKitSyncTrigger = - "foreground" | "background" | "push" | "manual"; - -const TRIGGERS: ReadonlySet = new Set([ +/** The `syncTrigger` values the Apple Health ingest routes accept. */ +export const HEALTHKIT_SYNC_TRIGGERS = [ "foreground", "background", "push", "manual", -]); +] as const; + +export type HealthKitSyncTrigger = (typeof HEALTHKIT_SYNC_TRIGGERS)[number]; + +const TRIGGERS: ReadonlySet = new Set(HEALTHKIT_SYNC_TRIGGERS); + +/** + * The ledger key for Apple Health medication doses, which are not a + * `MeasurementType`. Also the `type` of their `metricFreshness` entry. + */ +export const MEDICATION_DOSE_ARRIVAL_TYPE = MEDICATION_DOSE_FRESHNESS_TYPE; + +/** A ledger key: a measurement type, or the dose key above. */ +export type HealthKitArrivalType = + MeasurementType | typeof MEDICATION_DOSE_ARRIVAL_TYPE; /** What one batch did for one type. */ export interface HealthKitTypeArrival { @@ -44,7 +60,9 @@ export interface HealthKitTypeArrival { * types). `last_received_at` and `last_trigger` describe the latest batch * that carried the type, so they are overwritten; `last_new_sample_at` only * moves when this batch brought something new, so a duplicates-only re-send - * keeps the earlier date standing. + * keeps the earlier date standing. `last_background_at` moves only on a + * `background` batch, so opening the app afterwards does not hide the + * earlier background arrival. * * Best effort: the ledger is a diagnostic beside rows that are already * written, so a failure here leaves a `warn` line and never fails the ingest. @@ -53,7 +71,7 @@ export async function recordHealthKitTypeArrivals( tx: Prisma.TransactionClient, input: { userId: string; - counts: ReadonlyMap; + counts: ReadonlyMap; trigger: HealthKitSyncTrigger | null; at?: Date; }, @@ -70,13 +88,16 @@ export async function recordHealthKitTypeArrivals( // serialised in the process's local zone and these are zone-less UTC // columns (same reasoning as the access-token sunset in refresh-token.ts). const at = (input.at ?? new Date()).toISOString(); + const background = input.trigger === "background"; try { await tx.$executeRaw` INSERT INTO "healthkit_type_syncs" - ("user_id", "type", "last_received_at", "last_trigger", "last_new_sample_at") - SELECT ${input.userId}, u.t::"measurement_type", + ("user_id", "type", "last_received_at", "last_trigger", + "last_new_sample_at", "last_background_at") + SELECT ${input.userId}, u.t, ${at}::timestamptz AT TIME ZONE 'UTC', ${input.trigger}, - CASE WHEN u.n THEN ${at}::timestamptz AT TIME ZONE 'UTC' END + CASE WHEN u.n THEN ${at}::timestamptz AT TIME ZONE 'UTC' END, + CASE WHEN ${background}::boolean THEN ${at}::timestamptz AT TIME ZONE 'UTC' END FROM unnest(${types}::text[], ${brought}::boolean[]) AS u(t, n) ON CONFLICT ("user_id", "type") DO UPDATE SET "last_received_at" = EXCLUDED."last_received_at", @@ -84,6 +105,10 @@ export async function recordHealthKitTypeArrivals( "last_new_sample_at" = COALESCE( EXCLUDED."last_new_sample_at", "healthkit_type_syncs"."last_new_sample_at" + ), + "last_background_at" = COALESCE( + EXCLUDED."last_background_at", + "healthkit_type_syncs"."last_background_at" ) `; } catch (error) { @@ -98,10 +123,11 @@ export interface HealthKitTypeArrivalRecord { lastReceivedAt: string; lastTrigger: HealthKitSyncTrigger | null; lastNewSampleAt: string | null; + lastBackgroundAt: string | null; } /** - * Every recorded type for one account, keyed by `MeasurementType`. A stored + * Every recorded type for one account, keyed by the ledger key. A stored * trigger outside the known set (none is written, but the column is text) * reads as null rather than reaching the client as an unknown label. */ @@ -115,6 +141,7 @@ export async function getHealthKitTypeArrivals( lastReceivedAt: true, lastTrigger: true, lastNewSampleAt: true, + lastBackgroundAt: true, }, }); const out = new Map(); @@ -126,6 +153,7 @@ export async function getHealthKitTypeArrivals( ? (row.lastTrigger as HealthKitSyncTrigger) : null, lastNewSampleAt: row.lastNewSampleAt?.toISOString() ?? null, + lastBackgroundAt: row.lastBackgroundAt?.toISOString() ?? null, }); } return out; @@ -165,11 +193,13 @@ export interface HealthKitFreshnessEntry extends MetricFreshnessEntry { lastTrigger?: HealthKitSyncTrigger | null; /** When a live sync last brought a new or updated sample of it. */ lastNewSampleAt?: string | null; + /** When a `background` sync last carried it; a later foreground one keeps it. */ + lastBackgroundAt?: string | null; } /** * Lay the arrival ledger over the per-type freshness entries. Every - * measurement type gets the three fields, null when no live sync has carried + * measurement type and the `MEDICATION_DOSE` entry get the four fields, null when no live sync has carried * it yet (a type that so far came only from the export import, or from before * the ledger existed). The `WORKOUTS` entry is left without them: workouts * arrive through their own batch route, which the ledger does not cover, and @@ -189,6 +219,7 @@ export function withHealthKitArrivals( lastReceivedAt: arrival?.lastReceivedAt ?? null, lastTrigger: arrival?.lastTrigger ?? null, lastNewSampleAt: arrival?.lastNewSampleAt ?? null, + lastBackgroundAt: arrival?.lastBackgroundAt ?? null, }; }); } diff --git a/src/lib/integrations/metric-freshness.ts b/src/lib/integrations/metric-freshness.ts index d45eb557d..bcda04877 100644 --- a/src/lib/integrations/metric-freshness.ts +++ b/src/lib/integrations/metric-freshness.ts @@ -29,6 +29,7 @@ import type { import { listLiveMeasurementTypes } from "@/lib/measurements/live-types"; import type { IntegrationKey } from "./status"; import { + MEDICATION_DOSE_FRESHNESS_TYPE, WORKOUT_FRESHNESS_TYPE, type MetricFreshnessSample, } from "./sync-verdict"; @@ -185,6 +186,34 @@ export async function getSourceFreshness( return sortByType(entries); } +/** + * The newest Apple Health medication dose, as the `MEDICATION_DOSE` sample of + * the Apple Health freshness list (v1.42.4). A dose row carries an + * `externalId` only when it came from Apple Health (the intake bulk route + * accepts one solely with `source: "APPLE_HEALTH"`, and a slot write that + * converges onto an existing row keeps the id), so that column, not + * `source`, is what identifies them. A taken dose is dated by when it was + * taken, a skipped one by its slot. Null when there is no live Apple Health + * dose, so the entry is absent rather than invented. + */ +export async function getAppleHealthDoseFreshness( + userId: string, +): Promise { + const rows = await prisma.$queryRaw>` + SELECT max(COALESCE(e."taken_at", e."scheduled_for")) AS "last_seen" + FROM "medication_intake_events" e + WHERE e."user_id" = ${userId} + AND e."external_id" IS NOT NULL + AND e."deleted_at" IS NULL + `; + const lastSeen = rows[0]?.last_seen; + if (!lastSeen) return null; + return { + type: MEDICATION_DOSE_FRESHNESS_TYPE, + lastSeenAt: new Date(lastSeen).toISOString(), + }; +} + /** * Compute per-`(integration, type)` last-value timestamps for the sync * integrations, keyed by `IntegrationKey`. Two grouped queries (measurements + diff --git a/src/lib/integrations/status.ts b/src/lib/integrations/status.ts index d00fbdad5..d3f0675cb 100644 --- a/src/lib/integrations/status.ts +++ b/src/lib/integrations/status.ts @@ -40,6 +40,12 @@ import { type DispatchOutcome, } from "@/lib/notifications/dispatcher"; import type { IntegrationClassification } from "@/lib/integrations/http-status-classifier"; +import { + RECENT_CALLS_LIMIT, + safeEndpointName, + safeErrorCode, + type RecentCallStatus, +} from "@/lib/integrations/connection-health"; export type IntegrationKey = | "withings" @@ -194,13 +200,14 @@ export function getPersistentFailureThreshold(): number { } /** - * Re-alert window: once we've paged on a streak we hold the alert for - * 24h before paging again on the same streak (idempotency). The streak - * is implicitly "reset" by a single success, which clears every - * per-kind bucket and `alertedAt` — so a flapping integration that - * succeeds once an hour will not page repeatedly. + * One page per failure streak. `alertedAt` is cleared by every path that ends + * a streak (a success entitled to clear the row, a reconnect, a resume, a + * disconnect), so a stamped `alertedAt` means "this streak was already + * announced". It used to re-page every 24 h for as long as the streak ran, + * which turned a week-long outage into seven identical messages; the + * integration watchdog (`./watchdog`) now owns the rest of the incident, + * including the one recovery note when it ends. */ -const ALERT_REPEAT_WINDOW_MS = 24 * 60 * 60 * 1000; /** * v1.4.43 W14 — once a `persistent` failure streak has been running @@ -474,6 +481,12 @@ export async function recordSyncSuccess( ...(ownedLastError ? { lastError: null } : {}), }, }); + await appendRecentCall(userId, integration, { + endpoint: leg, + status: "ok", + at: now, + errorCode: null, + }); // Meta only — the enclosing wide event owns its own action name (the // cron task), and overwriting it here would rename the job. annotate({ @@ -516,6 +529,12 @@ export async function recordSyncSuccess( alertedAt: null, }, }); + await appendRecentCall(userId, integration, { + endpoint: leg, + status: "ok", + at: now, + errorCode: null, + }); } export interface RecordSyncFailureInput { @@ -718,6 +737,13 @@ export async function recordSyncFailure( }, }); + await appendRecentCall(userId, integration, { + endpoint: leg, + status: effectiveKind, + at: now, + errorCode: errorCode ?? null, + }); + // Audit log entry — fire-and-await so an integration test can assert // it. The auth/audit helper is its own DB write so it's safe to call // serially without bloating latency in the success path (which never @@ -771,10 +797,34 @@ export async function recordSyncFailure( const threshold = getPersistentFailureThreshold(); const alertSignal = Math.max(...Object.values(buckets)); if (alertSignal >= threshold) { - const previouslyAlerted = - row.alertedAt && - now.getTime() - row.alertedAt.getTime() < ALERT_REPEAT_WINDOW_MS; - if (!previouslyAlerted) { + const previouslyAlerted = row.alertedAt != null; + // The watchdog may have announced this connection already (it pages a + // day-long provider error streak, a reauth or a silence on its own). Its + // open incident covers this streak too, and its recovery note ends it; + // a second page about the same trouble would only repeat it. + const watchdogAnnounced = + !previouslyAlerted && + !!(await prisma.integrationWatch.findFirst({ + where: { + userId, + integration, + incidentReason: { not: null }, + alertedAt: { not: null }, + }, + select: { id: true }, + })); + if (watchdogAnnounced) { + await prisma.integrationStatus.update({ + where: { userId_integration: { userId, integration } }, + data: { alertedAt: now }, + }); + annotate({ + action: { + name: "integrations.alert.covered_by_watchdog", + details: { integration }, + }, + }); + } else if (!previouslyAlerted) { const outcome = await maybeAlertAdmins({ userId, integration, @@ -970,6 +1020,13 @@ export async function parkIntegrationAtReauth(opts: { }, }); + await appendRecentCall(userId, integration, { + endpoint: leg, + status: "reauth_required", + at: now, + errorCode, + }); + if (isFreshPark) { await auditLog("integrations.reauth_required", { userId, @@ -1024,6 +1081,13 @@ export async function markReauthRequired( }, }); + await appendRecentCall(userId, integration, { + endpoint: "token_refresh", + status: "reauth_required", + at: now, + errorCode: null, + }); + await auditLog("integrations.reauth_required", { userId, details: { integration, message }, @@ -1090,6 +1154,64 @@ export async function markReconnected( // ── helpers ──────────────────────────────────────────────────────── +interface RecentCallWrite { + endpoint: string | null; + status: RecentCallStatus; + at: Date; + errorCode: string | null; +} + +/** + * Prepend one attempt to the row's `recentCalls` ring and keep the newest + * `RECENT_CALLS_LIMIT`. One statement, no read: the ring is rebuilt inside + * Postgres, so a hot sync path pays one UPDATE rather than a read-modify-write. + * + * The entry is built from closed shapes only. The leg name and error code go + * through the same filters the reader applies (`safeEndpointName`, + * `safeErrorCode`), and the failure message is never part of it, so nothing a + * provider sent can reach the diagnostics a user copies into a bug report. + * + * Fail-soft: the ring is diagnostics. A failed append must never fail or undo + * the ledger write it describes, so the error becomes a wide-event warning. + */ +async function appendRecentCall( + userId: string, + integration: IntegrationKey, + call: RecentCallWrite, +): Promise { + const entry = JSON.stringify({ + endpoint: safeEndpointName(call.endpoint), + status: call.status, + at: call.at.toISOString(), + errorCode: safeErrorCode(call.errorCode), + }); + try { + // Parameter-bound throughout; the ring is rebuilt newest-first and cut at + // the limit. A row whose column is NULL or not an array starts empty. + await prisma.$executeRaw` + UPDATE "integration_statuses" + SET "recent_calls" = ( + SELECT COALESCE(jsonb_agg(e ORDER BY ord), '[]'::jsonb) + FROM ( + SELECT e, ord + FROM jsonb_array_elements( + jsonb_build_array(${entry}::jsonb) || + CASE WHEN jsonb_typeof("recent_calls") = 'array' + THEN "recent_calls" ELSE '[]'::jsonb END + ) WITH ORDINALITY AS t(e, ord) + ORDER BY ord + LIMIT ${RECENT_CALLS_LIMIT} + ) newest + ) + WHERE "user_id" = ${userId} AND "integration" = ${integration} + `; + } catch (err) { + getEvent()?.addWarning( + `IntegrationStatus recent-call append failed: ${err instanceof Error ? err.message : String(err)}`, + ); + } +} + /** * Encrypt with a fallback that swallows crypto-config errors so a * misconfigured ENCRYPTION_KEY can never break the audit/error path. @@ -1247,7 +1369,36 @@ async function maybeAlertAdmins(input: AlertInput): Promise { where: { id: input.userId }, select: { email: true }, }); + const payload = formatAdminAlertPayload({ + ...input, + subjectLabel: subject?.email ?? input.userId, + }); + return dispatchAdminSystemAlert({ + subjectUserId: input.userId, + title: payload.title, + message: payload.message, + metadata: payload.metadata, + }); +} +/** + * Send one `SYSTEM_ALERT` to every admin through the existing dispatcher, the + * operator path the sync ledger and the integration watchdog share. Each admin + * receives it on whatever channels they enabled (Telegram, ntfy, Web Push, + * APNs); nothing here adds a channel. + * + * The returned outcome aggregates the dispatcher's real per-admin outcomes, so + * one attempted delivery cannot be hidden by another admin having no enabled + * channel. A caller stamps its own "already announced" marker only when + * `channelsAttempted > 0`. + */ +export async function dispatchAdminSystemAlert(input: { + /** The account the alert is about; picks the tap target per admin. */ + subjectUserId: string; + title: string; + message: string; + metadata: Record; +}): Promise { const admins = await prisma.user.findMany({ where: { role: "ADMIN" }, select: { id: true }, @@ -1261,10 +1412,7 @@ async function maybeAlertAdmins(input: AlertInput): Promise { }; } - const payload = formatAdminAlertPayload({ - ...input, - subjectLabel: subject?.email ?? input.userId, - }); + const payload = input; const outcome: DispatchOutcome = { dispatched: false, channelsAttempted: 0, @@ -1282,7 +1430,7 @@ async function maybeAlertAdmins(input: AlertInput): Promise { // Tap target: an admin whose own sync fails fixes it in their own // integration settings; anyone else's shows on the admin page. url: - admin.id === input.userId + admin.id === input.subjectUserId ? "/settings/integrations" : "/admin/integrations", }, diff --git a/src/lib/integrations/sync-verdict.ts b/src/lib/integrations/sync-verdict.ts index d0313902b..2a0032fde 100644 --- a/src/lib/integrations/sync-verdict.ts +++ b/src/lib/integrations/sync-verdict.ts @@ -271,14 +271,25 @@ export const SPARSE_METRIC_TYPES: ReadonlySet = new Set([ "AUDIO_EXPOSURE_EVENT", // People train irregularly; a quiet training week is not a broken pipe. "WORKOUTS", + // As-needed and paused medications leave weeks without a dose by design. + "MEDICATION_DOSE", ]); /** The pseudo-type carrying a source's newest workout, next to its metrics. */ export const WORKOUT_FRESHNESS_TYPE = "WORKOUTS"; +/** + * The pseudo-type carrying Apple Health medication doses on the Apple Health + * freshness list (v1.42.4), and their key in the per-type arrival ledger. + */ +export const MEDICATION_DOSE_FRESHNESS_TYPE = "MEDICATION_DOSE"; + /** Newest recorded reading for one `(source, type)` pair, as queried. */ export interface MetricFreshnessSample { - /** The `MeasurementType` (e.g. "RESPIRATORY_RATE"), or `WORKOUTS`. */ + /** + * The `MeasurementType` (e.g. "RESPIRATORY_RATE"), `WORKOUTS`, or + * `MEDICATION_DOSE` on the Apple Health list. + */ type: string; /** ISO timestamp of the newest (non-deleted) reading for this metric. */ lastSeenAt: string; diff --git a/src/lib/integrations/watchdog-classify.ts b/src/lib/integrations/watchdog-classify.ts new file mode 100644 index 000000000..8b10705e3 --- /dev/null +++ b/src/lib/integrations/watchdog-classify.ts @@ -0,0 +1,248 @@ +/** + * The integration watchdog's judgement, kept pure. + * + * Two questions per connection, answered from data the runner gathers: + * + * 1. Is a source that normally delivers unexpectedly silent? + * Only a CONTINUOUS source can be: a wearable, a CGM, a phone that + * records steps all day. Its own history decides "normally": it must have + * delivered on at least `NORMAL_DELIVERY_MIN_DAYS` of the previous + * `HISTORY_DAYS` days. An ON-DEMAND source (a scale, a blood-pressure + * cuff, a workout log) is silent whenever its owner does not measure, + * which says nothing about the pipe, so silence never counts for it; it + * is watched for errors only. + * + * 2. Is there an incident the operator should hear about? + * Unexpected silence; or an `action_required` state, or a `degraded` + * one because the provider keeps erroring (`provider_errors`), that has + * lasted longer than `INCIDENT_AFTER_MS`; or a failure streak the sync + * ledger already paged about (adopted, so the operator still gets the + * one recovery note when it ends). `no_new_data` and `sync_stalled` on + * their own never page: they mostly mean the owner stopped using the + * source (an Apple Health account whose phone app has not run for a week + * reads as stale for good), which is nothing an operator can fix. + * + * One incident per connection at a time. It is announced once and closed once; + * a change of reason while it is open (silence turning into a reauth) is the + * same incident and sends nothing. + */ +import type { + ConnectionHealth, + ConnectionHealthReason, + ConnectionKey, +} from "./connection-health"; + +export type SourceCadenceKind = "continuous" | "on_demand"; + +export interface SourceProfile { + kind: SourceCadenceKind; + /** How long a continuous source may go without data before silence counts. */ + silenceWindowMs: number; +} + +const HOUR_MS = 60 * 60 * 1000; +const DAY_MS = 24 * HOUR_MS; + +/** How many days of a connection's own history decide "normally delivers". */ +export const HISTORY_DAYS = 14; + +/** Days with data, out of `HISTORY_DAYS`, that make a source "normally delivering". */ +export const NORMAL_DELIVERY_MIN_DAYS = 10; + +/** How long a non-ok health state must last before it is an operator incident. */ +export const INCIDENT_AFTER_MS = DAY_MS; + +/** + * Per connection. Withings is mostly scales and cuffs, Strava is workouts: + * both are on-demand and never alert on silence, only on errors. Everything + * worn or attached around the clock is continuous. + * + * The window is how long a continuous source may stay quiet before silence + * counts, and it follows how each source actually delivers: + * + * - 24 h for wearables and CGMs whose cloud receives data on its own + * (Fitbit, Google Health, Polar, Nightscout). + * - 48 h for Oura and WHOOP. Their rings and straps upload only when the + * phone app is opened, so a day without opening it is ordinary and must + * not page anyone. + * - 48 h for Apple Health. iOS defers background delivery when the phone is + * low on storage, in low-power mode or simply not unlocked, and a day of + * that is ordinary too. + * + * The history gate already limits the check to sources that deliver almost + * daily; the window only decides how long a gap is ordinary. + */ +export const SOURCE_PROFILES: Record = { + withings: { kind: "on_demand", silenceWindowMs: DAY_MS }, + strava: { kind: "on_demand", silenceWindowMs: DAY_MS }, + whoop: { kind: "continuous", silenceWindowMs: 2 * DAY_MS }, + oura: { kind: "continuous", silenceWindowMs: 2 * DAY_MS }, + fitbit: { kind: "continuous", silenceWindowMs: DAY_MS }, + "google-health": { kind: "continuous", silenceWindowMs: DAY_MS }, + polar: { kind: "continuous", silenceWindowMs: DAY_MS }, + nightscout: { kind: "continuous", silenceWindowMs: DAY_MS }, + "apple-health": { kind: "continuous", silenceWindowMs: 2 * DAY_MS }, +}; + +/** Incident reason codes. The silence one is the watchdog's own. */ +export type IncidentReason = ConnectionHealthReason; + +export interface WatchRow { + silentSince: Date | null; + incidentReason: string | null; + incidentOpenedAt: Date | null; + alertedAt: Date | null; +} + +export interface SilenceInput { + profile: SourceProfile; + /** Newest data instant, or null when the source never delivered. */ + lastDataAt: Date | null; + /** + * Distinct days with data in the `HISTORY_DAYS` before the silence window, + * or null when the runner has not computed it (it only does when needed). + */ + deliveredDays: number | null; + /** The current flag, so an already-flagged source is not re-judged hourly. */ + currentSilentSince: Date | null; + now: Date; +} + +export type SilenceVerdict = + /** Data is inside the window (or the source is on-demand): no flag. */ + | { kind: "clear" } + /** Silent, and the history has to be read before deciding. */ + | { kind: "needs_history" } + /** Silent, and the history says it should not be. */ + | { kind: "flag"; silentSince: Date } + /** Silent, but it does not normally deliver: the owner is not using it. */ + | { kind: "not_expected" }; + +/** + * Decide the silence flag. `deliveredDays` is only needed for a silent, + * not-yet-flagged continuous source (the hourly pass reads it lazily) and for a + * re-check of a flag (the daily pass passes it so a source its owner has + * stopped using drops out once its history no longer says it delivers). + */ +export function classifySilence(input: SilenceInput): SilenceVerdict { + const { profile, lastDataAt, now } = input; + if (profile.kind === "on_demand") return { kind: "clear" }; + // A source that never delivered has no history to break with. + if (lastDataAt === null) return { kind: "clear" }; + if (now.getTime() - lastDataAt.getTime() <= profile.silenceWindowMs) { + return { kind: "clear" }; + } + if (input.deliveredDays === null) { + // Already flagged and nothing asked for a re-check: keep the flag as is. + if (input.currentSilentSince) { + return { kind: "flag", silentSince: input.currentSilentSince }; + } + return { kind: "needs_history" }; + } + if (input.deliveredDays >= NORMAL_DELIVERY_MIN_DAYS) { + return { + kind: "flag", + silentSince: input.currentSilentSince ?? lastDataAt, + }; + } + return { kind: "not_expected" }; +} + +/** + * The incident condition right now, or null when there is none. + * + * `health` is the resolved connection health WITHOUT the watchdog's own + * silence flag folded in, so the silence condition and the state condition + * stay separate. `ledgerAlerted` is true while the sync ledger holds an + * unresolved page for the current streak. + */ +export function currentIncident(input: { + health: ConnectionHealth | null; + silentSince: Date | null; + ledgerAlerted: boolean; + now: Date; +}): { reason: IncidentReason; since: Date } | null { + const { health, silentSince, now } = input; + if (health === null) return null; + const troubled = + health.state === "action_required" || health.state === "degraded"; + // What pages on its own: the owner has to act, or the provider errors. A + // streak the ledger already paged about is followed whatever it reads as + // now, so its end is still reported. + const pages = + health.state === "action_required" || + (health.state === "degraded" && health.reason === "provider_errors"); + if (troubled && (pages || input.ledgerAlerted)) { + // A provider-side reauth flag (Google Health) can stand without a ledger + // streak to date it; the last success is then when it last worked. + const sinceIso = health.since ?? health.lastSuccessAt; + const since = sinceIso ? new Date(sinceIso) : null; + const lasted = + since !== null && now.getTime() - since.getTime() > INCIDENT_AFTER_MS; + if (lasted || input.ledgerAlerted) { + return { reason: health.reason, since: since ?? now }; + } + } + if (silentSince) { + return { reason: "unexpected_silence", since: silentSince }; + } + return null; +} + +/** + * The `alertedAt` an incident gets when the watchdog's first pass after a + * deploy found it already standing: counted as announced, so it is never + * paged, but never actually sent either, so its end sends no recovery note. + * The epoch, because no real announcement can carry it, and it keeps the + * marker inside the existing row. + */ +export const FIRST_RUN_ALERTED_AT = new Date(0); + +/** Whether an `alertedAt` records a message that was really sent. */ +export function wasAnnounced(alertedAt: Date | null | undefined): boolean { + return ( + alertedAt != null && alertedAt.getTime() !== FIRST_RUN_ALERTED_AT.getTime() + ); +} + +export type WatchTransition = + /** No incident before, none now. */ + | { kind: "none" } + /** A new incident: announce it. */ + | { kind: "open"; reason: IncidentReason; since: Date } + /** A new incident the ledger already announced: record it, send nothing. */ + | { kind: "adopt"; reason: IncidentReason; since: Date } + /** Still open: send nothing. `announce` when the open incident was never announced (no channel last time). */ + | { kind: "keep"; reason: IncidentReason; announce: boolean } + /** Over. `notify` when the operator had been told it started. */ + | { kind: "close"; notify: boolean; previousReason: string }; + +/** + * Compare the open incident (if any) with the current condition. Pure; the + * runner performs the side effects. + */ +export function decideWatchTransition(input: { + row: WatchRow | null; + incident: { reason: IncidentReason; since: Date } | null; + ledgerAlerted: boolean; +}): WatchTransition { + const open = input.row?.incidentReason ?? null; + if (open === null) { + if (input.incident === null) return { kind: "none" }; + return input.ledgerAlerted + ? { kind: "adopt", ...input.incident } + : { kind: "open", ...input.incident }; + } + if (input.incident === null) { + return { + kind: "close", + notify: wasAnnounced(input.row?.alertedAt), + previousReason: open, + }; + } + return { + kind: "keep", + reason: input.incident.reason, + announce: input.row?.alertedAt == null && !input.ledgerAlerted, + }; +} diff --git a/src/lib/integrations/watchdog.ts b/src/lib/integrations/watchdog.ts new file mode 100644 index 000000000..797087652 --- /dev/null +++ b/src/lib/integrations/watchdog.ts @@ -0,0 +1,607 @@ +/** + * The integration watchdog: notices a connection that has gone quiet or stayed + * broken, flags it for the connection health read, and tells the operator once. + * + * Runs on two schedules (`src/lib/jobs/integration-watchdog.ts`): + * + * hourly — cheap. One health load per account with a connection (the same + * loader the routes use), plus a 14-day history count only for a + * continuous source that is silent and not flagged yet. + * daily — the same pass, and it re-reads the history behind every standing + * silence flag, so a source its owner simply stopped using drops out + * once its own history no longer says it delivers. + * + * Judgement lives in `./watchdog-classify` (pure, unit-tested); this module + * gathers inputs and performs the side effects: one `IntegrationWatch` row per + * watched connection, one operator alert per incident through the existing + * admin path (`dispatchAdminSystemAlert`), one recovery note when it ends. + * + * Operator messages carry the provider, the account's email (as the ledger's + * own alerts do), a reason and an instant. Never a token, a value or an error + * message from the provider. + */ +import { prisma } from "@/lib/db"; +import type { MeasurementSource } from "@/generated/prisma/client"; +import { getEvent } from "@/lib/logging/context"; +import { dispatchAdminSystemAlert } from "./status"; +import { CONNECTION_KEYS, type ConnectionKey } from "./connection-health"; +import { + loadConnectionFacts, + type ConnectionFacts, +} from "./connection-health-load"; +import { INTEGRATION_MEASUREMENT_SOURCE } from "./metric-freshness"; +import { + FIRST_RUN_ALERTED_AT, + HISTORY_DAYS, + SOURCE_PROFILES, + classifySilence, + currentIncident, + decideWatchTransition, + type IncidentReason, + type WatchRow, +} from "./watchdog-classify"; + +export type WatchdogMode = "hourly" | "daily"; + +export interface WatchdogSummary { + usersScanned: number; + usersFailed: number; + connectionsConsidered: number; + silencesFlagged: number; + incidentsOpened: number; + incidentsClosed: number; + notified: number; + /** + * The pass found no watch row at all: the first one after the watchdog was + * deployed. Incidents standing at that moment are recorded as already + * announced instead of paged, so a deploy does not page once for every + * connection that has been broken or quiet for months. + */ + firstRun: boolean; +} + +const DAY_MS = 24 * 60 * 60 * 1000; + +const LABELS: Record = { + withings: "Withings", + whoop: "WHOOP", + fitbit: "Fitbit", + "google-health": "Google Health", + polar: "Polar", + oura: "Oura", + strava: "Strava", + nightscout: "Nightscout", + "apple-health": "Apple Health", +}; + +function measurementSourceFor(key: ConnectionKey): MeasurementSource | null { + if (key === "apple-health") return "APPLE_HEALTH"; + return INTEGRATION_MEASUREMENT_SOURCE[key] ?? null; +} + +/** + * Distinct UTC days with data in the `HISTORY_DAYS` before the silence window. + * The window ends where silence starts to count, so the history being read is + * the one the silence is measured against. + */ +async function countDeliveredDays( + userId: string, + key: ConnectionKey, + now: Date, +): Promise { + const source = measurementSourceFor(key); + if (!source) return 0; + const to = new Date(now.getTime() - SOURCE_PROFILES[key].silenceWindowMs); + const from = new Date(to.getTime() - HISTORY_DAYS * DAY_MS); + const rows = await prisma.$queryRaw>` + SELECT COUNT(DISTINCT date_trunc('day', m."measured_at"))::int AS "days" + FROM "measurements" m + WHERE m."user_id" = ${userId} + AND m."source" = ${source}::"measurement_source" + AND m."deleted_at" IS NULL + AND m."measured_at" >= ${from} + AND m."measured_at" < ${to} + `; + return Number(rows[0]?.days ?? 0); +} + +/** + * Every account that has a connection the watchdog covers, or a watch row that + * still holds a flag or an incident (so a removed connection gets its incident + * closed). + */ +async function listWatchedUserIds(): Promise { + const [withings, whoop, fitbit, google, userRows, healthKit, watches] = + await Promise.all([ + prisma.withingsConnection.findMany({ select: { userId: true } }), + prisma.whoopConnection.findMany({ + where: { whoopUserId: { not: null } }, + select: { userId: true }, + }), + prisma.fitbitConnection.findMany({ select: { userId: true } }), + prisma.googleHealthConnection.findMany({ select: { userId: true } }), + prisma.user.findMany({ + where: { + OR: [ + { polarAccessTokenEncrypted: { not: null } }, + { ouraAccessTokenEncrypted: { not: null } }, + { stravaAccessTokenEncrypted: { not: null } }, + { nightscoutUrlEncrypted: { not: null } }, + { healthKitLastSyncedAt: { not: null } }, + ], + }, + select: { id: true }, + }), + prisma.healthKitTypeSync.findMany({ + distinct: ["userId"], + select: { userId: true }, + }), + prisma.integrationWatch.findMany({ + where: { + OR: [ + { silentSince: { not: null } }, + { incidentReason: { not: null } }, + ], + }, + select: { userId: true }, + }), + ]); + const ids = new Set(); + for (const r of [...withings, ...whoop, ...fitbit, ...google, ...healthKit]) { + ids.add(r.userId); + } + for (const r of userRows) ids.add(r.id); + for (const r of watches) ids.add(r.userId); + return [...ids].sort(); +} + +function formatInstant(value: Date): string { + return `${value.toISOString().slice(0, 16).replace("T", " ")} UTC`; +} + +const INCIDENT_COPY: Record string> = { + unexpected_silence: (l) => + `${l} has delivered nothing although it normally delivers every day. The connection reports no error.`, + reauth_required: (l) => `${l} needs to be reconnected by the account owner.`, + paused_after_errors: (l) => + `${l} paused itself after a day of failing syncs. Resume or reconnect it in the integration settings.`, + provider_errors: (l) => + `${l} syncs have been failing for more than a day. HealthLog keeps retrying.`, + sync_stalled: (l) => `${l} sync has not run for more than a day.`, + no_new_data: (l) => `${l} has delivered no new data for a while.`, + // The remaining reasons belong to healthy states and never open an + // incident; the copy exists so the table stays total. + data_arriving: (l) => `${l} is delivering data.`, + first_sync_pending: (l) => `${l} is waiting for its first sync.`, + plan_limited: (l) => `${l} withholds some data types under its plan.`, + permission_limited: (l) => `${l} lacks permission for some data types.`, +}; + +/** + * Pure formatter for the two operator messages, exported for tests. Plain + * text: the dispatcher sends it to Telegram without a parse mode, and the only + * interpolations are a provider label, an email and server-built instants. + */ +export function formatWatchdogMessage(input: { + kind: "incident" | "recovery"; + key: ConnectionKey; + subjectLabel: string; + reason: string; + since: Date | null; + closedBecause?: "healthy" | "disconnected" | "not_expected"; +}): { title: string; message: string } { + const label = LABELS[input.key]; + if (input.kind === "incident") { + const copy = + INCIDENT_COPY[input.reason as IncidentReason]?.(label) ?? + `${label} needs attention.`; + const since = input.since ? ` Since ${formatInstant(input.since)}.` : ""; + return { + title: `${label} needs attention for ${input.subjectLabel}`, + message: `${copy}${since}`, + }; + } + const why = + input.closedBecause === "disconnected" + ? "The connection was removed." + : input.closedBecause === "not_expected" + ? "It has not delivered regularly for a while, so the watchdog no longer expects data from it." + : "Data is arriving again."; + return { + title: `${label} recovered for ${input.subjectLabel}`, + message: `${label} is fine again. ${why}`, + }; +} + +/** Run one watchdog pass over every watched account. */ +export async function runIntegrationWatchdog(opts: { + mode: WatchdogMode; + now?: Date; +}): Promise { + const now = opts.now ?? new Date(); + const summary: WatchdogSummary = { + usersScanned: 0, + usersFailed: 0, + connectionsConsidered: 0, + silencesFlagged: 0, + incidentsOpened: 0, + incidentsClosed: 0, + notified: 0, + firstRun: (await prisma.integrationWatch.count()) === 0, + }; + const userIds = await listWatchedUserIds(); + const outbox: WatchdogNote[] = []; + for (const userId of userIds) { + summary.usersScanned += 1; + try { + await watchUser(userId, opts.mode, now, summary, outbox); + } catch (err) { + // One account's failure must not stop the pass for everyone else. + summary.usersFailed += 1; + getEvent()?.addWarning( + `integration watchdog failed for one account: ${err instanceof Error ? err.message : String(err)}`, + ); + } + } + await flushWatchdogOutbox(outbox, now, summary); + return summary; +} + +/** One operator message the pass has decided to send. */ +export interface WatchdogNote { + kind: "incident" | "recovery"; + userId: string; + key: ConnectionKey; + subjectLabel: string; + reason: string; + since: Date | null; + closedBecause?: "healthy" | "disconnected" | "not_expected"; +} + +/** + * More than this many accounts of one provider opening (or closing) an + * incident in the same pass is one provider-wide event, not that many + * separate ones: a provider outage, an expired app credential, a changed API. + * The operator gets one message for it instead of one per account. + */ +export const WATCHDOG_AGGREGATE_ABOVE = 3; + +const AGGREGATE_REASON_COPY: Partial> = { + unexpected_silence: "silent although they normally deliver every day", + reauth_required: "waiting to be reconnected by their owners", + paused_after_errors: "paused after a day of failing syncs", + provider_errors: "failing to sync for more than a day", +}; + +/** The most account emails one aggregated message names. */ +const AGGREGATE_NAMED = 10; + +/** + * Pure formatter for the provider-wide messages, exported for tests. Same + * rules as `formatWatchdogMessage`: plain text, a provider label, emails and + * server-built instants only. + */ +export function formatWatchdogAggregate( + kind: "incident" | "recovery", + key: ConnectionKey, + notes: WatchdogNote[], +): { title: string; message: string } { + const label = LABELS[key]; + const n = notes.length; + const named = notes.slice(0, AGGREGATE_NAMED).map((x) => x.subjectLabel); + const more = n > AGGREGATE_NAMED ? ` and ${n - AGGREGATE_NAMED} more` : ""; + const accounts = `Accounts: ${named.join(", ")}${more}.`; + if (kind === "recovery") { + return { + title: `${label} recovered for ${n} connections`, + message: `${label} is fine again for ${n} connections. ${accounts}`, + }; + } + const byReason = new Map(); + for (const note of notes) { + const list = byReason.get(note.reason) ?? []; + list.push(note); + byReason.set(note.reason, list); + } + const lines = [...byReason].map(([reason, list]) => { + const copy = + AGGREGATE_REASON_COPY[reason as IncidentReason] ?? "needing attention"; + const earliest = list + .map((x) => x.since) + .filter((d): d is Date => d !== null) + .sort((a, b) => a.getTime() - b.getTime())[0]; + const since = earliest ? ` since ${formatInstant(earliest)}` : ""; + return `${list.length} ${copy}${since}.`; + }); + return { + title: `${label}: ${n} connections need attention`, + message: `${lines.join(" ")} ${accounts}`, + }; +} + +/** + * Send what the pass decided, per provider: one message per account, or one + * for the provider when more than `WATCHDOG_AGGREGATE_ABOVE` accounts share + * it. An incident counts as announced (its `alertedAt` stamped) once a channel + * took the message, whichever form it went out in. A send that fails is a + * warning; the incident stays unannounced and the next pass tries again. + */ +export async function flushWatchdogOutbox( + outbox: WatchdogNote[], + now: Date, + summary: WatchdogSummary, +): Promise { + const groups = new Map(); + for (const note of outbox) { + const id = `${note.kind}:${note.key}`; + const list = groups.get(id) ?? []; + list.push(note); + groups.set(id, list); + } + outbox.length = 0; + + const markAnnounced = async (notes: WatchdogNote[]) => { + for (const note of notes) { + await prisma.integrationWatch.updateMany({ + where: { + userId: note.userId, + integration: note.key, + incidentReason: { not: null }, + }, + data: { alertedAt: now }, + }); + } + }; + + for (const notes of groups.values()) { + const { kind, key } = notes[0]!; + try { + if (notes.length > WATCHDOG_AGGREGATE_ABOVE) { + const text = formatWatchdogAggregate(kind, key, notes); + const outcome = await dispatchAdminSystemAlert({ + // About many accounts: no one owner, so every admin's tap opens the + // admin page. + subjectUserId: "", + title: text.title, + message: text.message, + metadata: { + integration: key, + watchdog: kind, + connections: notes.length, + }, + }); + if (outcome.channelsAttempted > 0) { + summary.notified += 1; + if (kind === "incident") await markAnnounced(notes); + } + continue; + } + for (const note of notes) { + const text = formatWatchdogMessage(note); + const outcome = await dispatchAdminSystemAlert({ + subjectUserId: note.userId, + title: text.title, + message: text.message, + metadata: { + integration: key, + reason: note.reason, + watchdog: kind, + }, + }); + if (outcome.channelsAttempted > 0) { + summary.notified += 1; + if (kind === "incident") await markAnnounced([note]); + } + } + } catch (err) { + getEvent()?.addWarning( + `integration watchdog could not send its ${kind} message for ${key}: ${err instanceof Error ? err.message : String(err)}`, + ); + } + } +} + +/** + * Watch every connection of one account. Exported for tests. Its operator + * messages go to `outbox` when one is passed (the pass sends them together at + * its end), and are sent before it returns otherwise. + */ +export async function watchUser( + userId: string, + mode: WatchdogMode, + now: Date, + summary: WatchdogSummary, + outbox?: WatchdogNote[], +): Promise { + if (!outbox) { + const own: WatchdogNote[] = []; + await watchUser(userId, mode, now, summary, own); + await flushWatchdogOutbox(own, now, summary); + return; + } + const [facts, rows, subject] = await Promise.all([ + loadConnectionFacts(userId, CONNECTION_KEYS, now), + prisma.integrationWatch.findMany({ where: { userId } }), + prisma.user.findUnique({ where: { id: userId }, select: { email: true } }), + ]); + const rowByKey = new Map(rows.map((r) => [r.integration, r])); + const subjectLabel = subject?.email ?? "an account"; + + for (const key of CONNECTION_KEYS) { + const fact = facts.get(key); + const row = rowByKey.get(key) ?? null; + if (!fact) continue; + if (!fact.connected && row === null) continue; + summary.connectionsConsidered += 1; + await watchConnection({ + userId, + key, + fact, + row, + mode, + now, + subjectLabel, + summary, + outbox, + }); + } +} + +async function watchConnection(input: { + userId: string; + key: ConnectionKey; + fact: ConnectionFacts; + row: (WatchRow & { id: string }) | null; + mode: WatchdogMode; + now: Date; + subjectLabel: string; + summary: WatchdogSummary; + outbox: WatchdogNote[]; +}): Promise { + const { userId, key, fact, row, mode, now, summary } = input; + const profile = SOURCE_PROFILES[key]; + + // ── Silence ──────────────────────────────────────────────────────── + let silentSince: Date | null = null; + let closedBecause: "healthy" | "disconnected" | "not_expected" = + fact.connected ? "healthy" : "disconnected"; + if (fact.connected) { + const base = { + profile, + lastDataAt: fact.lastDataAt, + currentSilentSince: row?.silentSince ?? null, + now, + }; + // The daily pass re-reads the history behind a standing flag. + const recheck = mode === "daily" && row?.silentSince != null; + let verdict = classifySilence({ + ...base, + deliveredDays: recheck + ? await countDeliveredDays(userId, key, now) + : null, + }); + if (verdict.kind === "needs_history") { + verdict = classifySilence({ + ...base, + deliveredDays: await countDeliveredDays(userId, key, now), + }); + } + if (verdict.kind === "flag") silentSince = verdict.silentSince; + if (verdict.kind === "not_expected") closedBecause = "not_expected"; + } + if (silentSince && !row?.silentSince) summary.silencesFlagged += 1; + + // ── Incident ─────────────────────────────────────────────────────── + const incident = currentIncident({ + health: fact.connected ? fact.healthWithoutSilence : null, + silentSince, + ledgerAlerted: fact.ledgerAlerted, + now, + }); + const transition = decideWatchTransition({ + row, + incident, + ledgerAlerted: fact.ledgerAlerted, + }); + + let incidentReason = row?.incidentReason ?? null; + let incidentOpenedAt = row?.incidentOpenedAt ?? null; + let alertedAt = row?.alertedAt ?? null; + + // Sent once the pass is over (`flushWatchdogOutbox`), so many accounts of + // one provider failing at once come as one message. + const announce = (reason: string, since: Date | null) => { + input.outbox.push({ + kind: "incident", + userId, + key, + subjectLabel: input.subjectLabel, + reason, + since, + }); + }; + + switch (transition.kind) { + case "none": + break; + case "open": + summary.incidentsOpened += 1; + incidentReason = transition.reason; + incidentOpenedAt = now; + if (summary.firstRun) { + // Standing since before the watchdog first looked: recorded as + // announced, not paged (see `WatchdogSummary.firstRun`), and marked + // so its end sends no recovery note either. + alertedAt = FIRST_RUN_ALERTED_AT; + } else { + announce(transition.reason, transition.since); + } + break; + case "adopt": + // The ledger already paged about this streak; record it as announced so + // the operator gets the recovery note and no second page. + summary.incidentsOpened += 1; + incidentReason = transition.reason; + incidentOpenedAt = now; + alertedAt = now; + break; + case "keep": + incidentReason = transition.reason; + if (alertedAt === null && fact.ledgerAlerted) { + alertedAt = now; + } else if (transition.announce && incident) { + // Opened earlier while no admin channel accepted the alert. + announce(transition.reason, incident.since); + } + break; + case "close": + summary.incidentsClosed += 1; + if (transition.notify) { + input.outbox.push({ + kind: "recovery", + userId, + key, + subjectLabel: input.subjectLabel, + reason: transition.previousReason, + since: null, + closedBecause, + }); + } + incidentReason = null; + incidentOpenedAt = null; + alertedAt = null; + break; + } + + // Nothing to remember and nothing remembered: no row. Except on the first + // pass, which leaves a row for every connection it looked at, so the pass + // after it is not taken for a first one too. + if ( + row === null && + silentSince === null && + incidentReason === null && + !(summary.firstRun && fact.connected) + ) { + return; + } + + await prisma.integrationWatch.upsert({ + where: { userId_integration: { userId, integration: key } }, + create: { + userId, + integration: key, + silentSince, + incidentReason, + incidentOpenedAt, + alertedAt, + checkedAt: now, + }, + update: { + silentSince, + incidentReason, + incidentOpenedAt, + alertedAt, + checkedAt: now, + }, + }); +} diff --git a/src/lib/jobs/__tests__/google-health-backfill.test.ts b/src/lib/jobs/__tests__/google-health-backfill.test.ts index 4e5e5a269..b9fb2c788 100644 --- a/src/lib/jobs/__tests__/google-health-backfill.test.ts +++ b/src/lib/jobs/__tests__/google-health-backfill.test.ts @@ -31,7 +31,13 @@ const { bossSend: vi.fn(), // Collections the mocked walk "finishes" in this attempt, added to the // resume set the way `runGoogleHealthCollection` adds them. - resumeState: { finishes: [] as string[], doneBefore: [] as string[] }, + resumeState: { + finishes: [] as string[], + doneBefore: [] as string[], + /** Collections whose resume edge the mocked walk moves. */ + advances: [] as string[], + collections: [] as Array<{ key: string; status: string }>, + }, })); vi.mock("@/lib/db", () => ({ prisma: prismaMock })); @@ -54,6 +60,8 @@ vi.mock("@/lib/safe-fetch", async (importOriginal) => { vi.mock("@/lib/google-health/sync-core", () => ({ GOOGLE_HEALTH_INTEGRATION_KEY: "google-health", readGoogleHealthBackfillDone: () => new Set(resumeState.doneBefore), + readGoogleHealthBackfillCursors: () => new Map(), + readGoogleHealthBackfillEdgeFilterUnsupported: () => new Set(), runWithGoogleHealthBackfillResume: async ( _userId: string, done: Set, @@ -61,7 +69,12 @@ vi.mock("@/lib/google-health/sync-core", () => ({ ) => { const result = await fn(); for (const key of resumeState.finishes) done.add(key); - return { result, skipped: [] }; + return { + result, + skipped: [], + advanced: new Map(resumeState.advances.map((key) => [key, null])), + collections: resumeState.collections, + }; }, })); vi.mock("@/lib/google-health/sync", () => ({ @@ -71,8 +84,11 @@ vi.mock("@/lib/google-health/sync", () => ({ import { GOOGLE_HEALTH_BACKFILL_MAX_SLICE, + GOOGLE_HEALTH_BACKFILL_THROTTLED_DELAY_SECONDS, runGoogleHealthBackfillForUser, } from "../google-health-backfill"; +import { GOOGLE_HEALTH_RESUMABLE_PAGE_CEILING } from "@/lib/google-health/sync-metrics"; +import { INTEGRATION_BACKFILL_CONTINUATION_DELAY_SECONDS } from "@/lib/jobs/integration-backfill-admission"; import { GOOGLE_HEALTH_DATA_TYPES, forEachDataPointPage, @@ -87,6 +103,8 @@ beforeEach(() => { bossSend.mockResolvedValue("job-next"); resumeState.finishes = []; resumeState.doneBefore = []; + resumeState.advances = []; + resumeState.collections = []; }); describe("runGoogleHealthBackfillForUser — verdict-gated marker", () => { @@ -261,8 +279,73 @@ describe("runGoogleHealthBackfillForUser — slices", () => { expect(bossSend).not.toHaveBeenCalled(); }); - it("caps the chain at the number of collections plus two", () => { - expect(GOOGLE_HEALTH_BACKFILL_MAX_SLICE).toBe(17 + 2); + it("derives the chain's ceiling from the work a backfill can do", () => { + // Every collection once, plus every page the resumable collections may + // ever write (heart rate's 10 000 and four others' 1 000) and as many + // again for a saved place in the list moving forward, plus slack. + expect(GOOGLE_HEALTH_BACKFILL_MAX_SLICE).toBe( + 17 + 2 * GOOGLE_HEALTH_RESUMABLE_PAGE_CEILING + 2, + ); + expect(GOOGLE_HEALTH_RESUMABLE_PAGE_CEILING).toBe(10_000 + 4 * 1000); + }); + + it("continues a slice that only moved a collection's edge", async () => { + resumeState.advances = ["fetchHeartRate"]; + syncUserGoogleHealthMock.mockResolvedValue({ imported: 900, failed: true }); + + await expect( + runGoogleHealthBackfillForUser("u1", undefined, 7), + ).resolves.toEqual({ imported: 900, continued: true }); + expect(bossSend).toHaveBeenCalledTimes(1); + expect(bossSend.mock.calls[0]![1]).toMatchObject({ + data: { userId: "u1", slice: 8 }, + }); + expect(bossSend.mock.calls[0]![2]).toMatchObject({ + startAfter: INTEGRATION_BACKFILL_CONTINUATION_DELAY_SECONDS, + }); + }); + + it("waits longer before the next slice when this one ended on a rate limit", async () => { + resumeState.advances = ["fetchHeartRate"]; + syncUserGoogleHealthMock.mockResolvedValue({ imported: 10, failed: true }); + // The slice ended the way a resumable walk ends it when Google keeps + // answering 429 past every wait. + const client = await import("@/lib/google-health/client"); + const spy = vi + .spyOn(client, "googleHealthSliceBudget") + .mockImplementation(() => ({ + ms: 300_000, + deadline: null, + maxPages: 500, + pages: 3, + skippedPages: 0, + stopReason: "rate_limited", + now: Date.now, + })); + try { + await runGoogleHealthBackfillForUser("u1"); + } finally { + spy.mockRestore(); + } + expect(bossSend.mock.calls[0]![2]).toMatchObject({ + startAfter: GOOGLE_HEALTH_BACKFILL_THROTTLED_DELAY_SECONDS, + }); + }); + + it("names where it is stuck when a slice moved nothing", async () => { + resumeState.collections = [ + { key: "fetchWeight", status: "done" }, + { key: "fetchHeartRate", status: "failed" }, + { key: "fetchBloodGlucose", status: "deferred" }, + ]; + syncUserGoogleHealthMock.mockResolvedValue({ imported: 0, failed: true }); + + await expect( + runGoogleHealthBackfillForUser("u1", undefined, 3), + ).rejects.toThrow( + /marker not stamped \(no progress in slice 3; fetchHeartRate:failed,fetchBloodGlucose:deferred\)/, + ); + expect(bossSend).not.toHaveBeenCalled(); }); it("stamps the marker on the slice that finishes the history", async () => { diff --git a/src/lib/jobs/__tests__/integration-backfill-drain-event.test.ts b/src/lib/jobs/__tests__/integration-backfill-drain-event.test.ts new file mode 100644 index 000000000..e6b8421af --- /dev/null +++ b/src/lib/jobs/__tests__/integration-backfill-drain-event.test.ts @@ -0,0 +1,117 @@ +/** + * #1220 — every admitted full-history import runs inside its own background + * event, so what the provider walk records on the ambient event (each + * request with its status, every warning, every annotation) reaches the log. + * Before, the drain ran the import with no event in scope, every one of those + * calls was a no-op, and a failing backfill left nothing but its error + * message behind. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { Job } from "pg-boss"; + +import type { WideEvent } from "@/lib/logging/types"; + +const { emitted, runGoogleHealthBackfillMock } = vi.hoisted(() => ({ + emitted: [] as WideEvent[], + runGoogleHealthBackfillMock: vi.fn(), +})); + +vi.mock("@/lib/logging/transports", () => ({ + emitIfSampled: (event: WideEvent) => emitted.push(event), +})); +vi.mock("@/lib/jobs/integration-backfill-lock", () => ({ + withIntegrationBackfillLock: async (_id: string, run: () => unknown) => ({ + ran: true, + result: await run(), + }), +})); +vi.mock("@/lib/jobs/google-health-backfill", () => ({ + runGoogleHealthBackfillForUser: runGoogleHealthBackfillMock, +})); +vi.mock("@/lib/jobs/reminder/shared", () => ({ workerLog: () => {} })); + +import { drainIntegrationBackfillAdmission } from "../integration-backfill-drain"; +import type { IntegrationBackfillAdmissionPayload } from "../integration-backfill-admission"; +import { annotate, getEvent } from "@/lib/logging/context"; + +function job( + data: IntegrationBackfillAdmissionPayload, +): Job { + return { + id: "job-1", + data, + expireInSeconds: 3600, + } as unknown as Job; +} + +const payload: IntegrationBackfillAdmissionPayload = { + kind: "google-health-backfill", + data: { userId: "u1", enqueuedAt: "2026-10-14T11:00:00Z", slice: 2 }, +}; + +beforeEach(() => { + emitted.length = 0; + runGoogleHealthBackfillMock.mockReset(); +}); + +describe("drainIntegrationBackfillAdmission — one event per admitted run", () => { + it("emits what the import recorded, with the job it belongs to", async () => { + runGoogleHealthBackfillMock.mockImplementation(async () => { + getEvent()?.addExternalCall({ + service: "google-health", + method: "fetchHeartRate(page=0)", + duration_ms: 120, + status: 200, + }); + annotate({ meta: { "googleHealth.backfill.slice": { pages: 1 } } }); + return { imported: 1000, continued: true }; + }); + + await drainIntegrationBackfillAdmission([job(payload)]); + + expect(emitted).toHaveLength(1); + const [event] = emitted; + expect(event!.background).toMatchObject({ + task_name: "job.integration_backfill", + result: { kind: "google-health-backfill", imported: 1000, continued: 1 }, + }); + expect(event!.meta).toMatchObject({ + kind: "google-health-backfill", + identity: "google-health-backfill|u1", + job_id: "job-1", + slice: 2, + "googleHealth.backfill.slice": { pages: 1 }, + }); + expect(event!.external_calls).toEqual([ + expect.objectContaining({ + method: "fetchHeartRate(page=0)", + status: 200, + }), + ]); + }); + + it("emits the calls of a run that throws, with its error", async () => { + runGoogleHealthBackfillMock.mockImplementation(async () => { + getEvent()?.addExternalCall({ + service: "google-health", + method: "fetchHeartRate(page=812)", + duration_ms: 15000, + status: 503, + error: "transient HTTP 503", + }); + throw new Error("google-health backfill incomplete for user u1"); + }); + + await expect( + drainIntegrationBackfillAdmission([job(payload)]), + ).rejects.toThrow(/incomplete/); + + expect(emitted).toHaveLength(1); + expect(emitted[0]!.level).toBe("error"); + expect(emitted[0]!.error?.message).toMatch(/incomplete/); + expect(emitted[0]!.external_calls?.[0]).toMatchObject({ + method: "fetchHeartRate(page=812)", + status: 503, + }); + }); +}); diff --git a/src/lib/jobs/google-health-backfill.ts b/src/lib/jobs/google-health-backfill.ts index 4f3a00294..4b07bda36 100644 --- a/src/lib/jobs/google-health-backfill.ts +++ b/src/lib/jobs/google-health-backfill.ts @@ -21,7 +21,9 @@ import { integrationBackfillSourceOptions, } from "@/lib/jobs/integration-backfill-admission"; import { + readGoogleHealthBackfillCursors, readGoogleHealthBackfillDone, + readGoogleHealthBackfillEdgeFilterUnsupported, runWithGoogleHealthBackfillResume, } from "@/lib/google-health/sync-core"; import { @@ -29,7 +31,12 @@ import { syncUserGoogleHealth, } from "@/lib/google-health/sync"; import { GOOGLE_HEALTH_SYNC_LOCK_WAIT_MS } from "@/lib/google-health/sync-lock"; -import { runWithGoogleHealthStop } from "@/lib/google-health/client"; +import { + googleHealthSliceBudget, + runWithGoogleHealthStop, +} from "@/lib/google-health/client"; +import { GOOGLE_HEALTH_RESUMABLE_PAGE_CEILING } from "@/lib/google-health/sync-metrics"; +import { reportJobProgress } from "@/lib/jobs/job-observer"; import { GOOGLE_HEALTH_COLLECTION_KEYS } from "@/lib/google-health/collection-keys"; export const GOOGLE_HEALTH_BACKFILL_QUEUE = "google-health-backfill"; @@ -42,15 +49,56 @@ export const GOOGLE_HEALTH_BACKFILL_QUEUE = "google-health-backfill"; export const GOOGLE_HEALTH_BACKFILL_CONCURRENCY = 1; /** - * The highest slice a backfill chain may reach. Every continued slice has - * finished at least one more collection than the one before it, so a healthy - * chain is at most one slice per collection long; the two extra slices are - * slack for the first one and the one that stamps the marker. A slice past - * this is a chain that counts progress it does not make, and it fails rather - * than queueing another. + * One slice of a backfill: how long it walks and how many pages it reads + * before it lets go of the account and hands the rest to the next slice. + * + * Far inside the admission job's expiry (sixteen hours) on purpose. A slice + * holds the account's sync lock while it walks, and the hourly sync of that + * account waits for the lock (`GOOGLE_HEALTH_POLL_LOCK_WAIT_MS`), so a slice + * has to be short enough for the hourly sync to get its turn: before slices, + * one attempt held the lock for as long as the import ran and every hourly + * sync of the account in that time was skipped. The page budget keeps a + * slice on a fast link from spending a whole quota window in one go. + */ +export const GOOGLE_HEALTH_BACKFILL_SLICE_MS = 5 * 60 * 1000; +export const GOOGLE_HEALTH_BACKFILL_SLICE_PAGES = 500; + +/** + * The pause before the next slice when this one ended on a rate limit it + * could not wait out: long enough for Google's per-minute window to refill + * several times over, so the next slice does not open on the same 429. + */ +export const GOOGLE_HEALTH_BACKFILL_THROTTLED_DELAY_SECONDS = 5 * 60; + +/** + * The highest slice a backfill chain may reach, derived from the work a + * backfill can do rather than guessed. + * + * A slice is continued only when it made progress that is on disk: it + * finished a collection, it moved a resumable collection's edge after + * writing at least one page, or a walk without the edge bound saved a place + * in the list further than any before it. Collections finish once each, a + * resumable collection's pages are capped across all attempts by its page + * ceiling (`GOOGLE_HEALTH_RESUMABLE_PAGE_CEILING`, the sum of them), and a + * saved place only ever moves forward, through no more pages than the ones + * written plus the ones passed over, which the same ceiling bounds. So no + * chain of progressing slices is longer than the collections plus twice + * those pages; the two extra are slack for the first slice and the one that + * stamps the marker. In + * practice a chain is a handful of slices per year of dense history. A slice + * past this is counting progress it does not make, and fails rather than + * queueing another. */ export const GOOGLE_HEALTH_BACKFILL_MAX_SLICE = - GOOGLE_HEALTH_COLLECTION_KEYS.length + 2; + GOOGLE_HEALTH_COLLECTION_KEYS.length + + 2 * GOOGLE_HEALTH_RESUMABLE_PAGE_CEILING + + 2; + +/** Test seam: a slice's budget, when not the production one. */ +export interface GoogleHealthBackfillSliceOptions { + sliceMs?: number; + slicePages?: number; +} export interface GoogleHealthBackfillPayload { userId: string; @@ -58,42 +106,40 @@ export interface GoogleHealthBackfillPayload { } /** - * Per-user backfill handler. Runs a full-history sync for one account and - * stamps `backfillCompletedAt` ONLY on a clean run, so the discovery query - * drops it. Idempotent: the per-resource upserts are key-stable, so a re-run - * (e.g. a reboot mid-walk) overwrites rather than duplicating. Mirrors - * `runFitbitBackfillForUser`. + * Per-user backfill handler. Runs one slice of a full-history sync for one + * account and stamps `backfillCompletedAt` ONLY on a clean run, so the + * discovery query drops it. Idempotent: the per-resource upserts are + * key-stable, so a re-run (e.g. a reboot mid-walk) overwrites rather than + * duplicating. Mirrors `runFitbitBackfillForUser`. * * Verdict-gated: a partial hard failure (one collection 400ing, a write - * failing) THROWS so pg-boss retries the job — stamping the marker over a - * partial walk would freeze the gap in forever. A connection parked at - * `error_reauth` is NOT clean either, but throwing against a dead grant would - * just burn the retry budget on the same no-op: return WITHOUT stamping — the - * boot discovery re-enqueues the account on the next boot, and the stamp lands - * once the user reconnects and a clean walk completes. + * failing) is not complete, and stamping the marker over a partial walk would + * freeze the gap in forever. A connection parked at `error_reauth` is NOT + * clean either, but throwing against a dead grant would just burn the retry + * budget on the same no-op: return WITHOUT stamping — the boot discovery + * re-enqueues the account on the next boot, and the stamp lands once the user + * reconnects and a clean walk completes. * - * `shouldStop` is the admission job's budget (`jobBudget`). A per-minute - * heart-rate history runs to thousands of pages, and the walk asks it before - * every page: once it says stop, the walk ends where it is, the cycle reports - * itself truncated, and this throws like any other incomplete run, without - * the stamp. The rows already written stay. + * Resumable at two grains (`GoogleHealthBackfillCursor`): every collection the + * walk finishes cleanly is noted in `backfillProgress.done` and skipped from + * then on, and every spot-reading collection notes the edge it has written up + * to after each page, so the next attempt starts there instead of at the + * newest page. The note is cleared once the stamp lands, and on every + * reconnect. * - * Resumable: every collection the walk finishes cleanly is noted in - * `backfillProgress`, and the next attempt (the pg-boss retry, or the next - * boot's discovery) skips those and walks only what is left - * (`runWithGoogleHealthBackfillResume`). The note is cleared once the stamp - * lands, and on every reconnect. + * Runs in slices. A slice walks until its own budget is spent + * (`GOOGLE_HEALTH_BACKFILL_SLICE_MS` / `_PAGES`), Google throttles it past + * what a wait covers, or the job's budget (`shouldStop`) runs out, and then + * lets go of the account. A slice that made progress on disk (a collection + * finished, an edge moved) is not a failure: it sends the next slice + * (`slice + 1`) and returns `continued`, without the error line or the + * queue's failure count. Only a slice that moved nothing throws, with what + * each collection came to in the message, because that one is stuck: the + * pg-boss retry and, after it, the next boot take it from there. The chain is + * capped at `GOOGLE_HEALTH_BACKFILL_MAX_SLICE`. * - * Runs in slices: an attempt that ends before the whole history is in but - * finished at least one more collection than it started with made progress. - * It is not a failure: it sends the next slice (`slice + 1`) and returns - * `continued`, without the error line or the queue's failure count. Only an - * attempt that finished nothing new throws, because that one is stuck: the - * pg-boss retry and, after it, the next boot take it from there. Progress is - * counted only once it is persisted (`runGoogleHealthCollection` adds a - * collection to `done` after its note is written), and the chain is capped at - * `GOOGLE_HEALTH_BACKFILL_MAX_SLICE`: a slice past it throws instead of - * running or sending another. + * What the slice did is annotated on the run's event as + * `googleHealth.backfill.slice` (see `docs/integrations/google-health.md`). * * Never beside another run of the same account: the sync takes the account's * lock, waiting a bounded time for an hourly or manual run to finish. If the @@ -104,6 +150,7 @@ export async function runGoogleHealthBackfillForUser( userId: string, shouldStop: () => boolean = () => false, slice: number = 0, + sliceOptions: GoogleHealthBackfillSliceOptions = {}, ): Promise<{ imported: number; continued?: boolean }> { if (slice > GOOGLE_HEALTH_BACKFILL_MAX_SLICE) { annotate({ @@ -142,22 +189,63 @@ export async function runGoogleHealthBackfillForUser( return { imported: 0 }; } const done = readGoogleHealthBackfillDone(connection?.backfillProgress); + const cursors = readGoogleHealthBackfillCursors(connection?.backfillProgress); + const edgeFilterUnsupported = readGoogleHealthBackfillEdgeFilterUnsupported( + connection?.backfillProgress, + ); const doneBefore = done.size; + const budget = googleHealthSliceBudget( + sliceOptions.sliceMs ?? GOOGLE_HEALTH_BACKFILL_SLICE_MS, + sliceOptions.slicePages ?? GOOGLE_HEALTH_BACKFILL_SLICE_PAGES, + ); + const startedAt = Date.now(); const { result: { imported, failed, busy }, skipped, - } = await runWithGoogleHealthBackfillResume(userId, done, () => - runWithGoogleHealthStop(shouldStop, () => - syncUserGoogleHealth(userId, { - fullSync: true, - waitForLockMs: GOOGLE_HEALTH_SYNC_LOCK_WAIT_MS, - }), - ), + advanced, + collections, + } = await runWithGoogleHealthBackfillResume( + userId, + done, + () => + runWithGoogleHealthStop( + shouldStop, + () => + syncUserGoogleHealth(userId, { + fullSync: true, + waitForLockMs: GOOGLE_HEALTH_SYNC_LOCK_WAIT_MS, + }), + budget, + ), + cursors, + edgeFilterUnsupported, ); - if (skipped.length > 0) { - annotate({ meta: { "googleHealth.backfill.resumedPast": skipped } }); - } + + const finished = done.size - doneBefore; + const sliceReport = { + slice, + elapsedMs: Date.now() - startedAt, + pages: budget.pages, + // Of those, the pages read only to pass over samples beyond an edge, + // where Google refused the edge filter. + skippedPages: budget.skippedPages, + stopReason: budget.stopReason, + imported, + collectionsDone: done.size, + collectionsTotal: GOOGLE_HEALTH_COLLECTION_KEYS.length, + collectionsFinished: finished, + resumedPast: skipped.length, + collections, + // Where each unfinished resumable collection has got to, after this slice. + edges: Object.fromEntries([...cursors].map(([key, c]) => [key, c.at])), + }; + annotate({ meta: { "googleHealth.backfill.slice": sliceReport } }); + reportJobProgress({ + google_health_backfill_slice: slice, + google_health_backfill_pages: budget.pages, + google_health_backfill_collections_done: done.size, + }); if (busy) { throw new Error( @@ -166,10 +254,10 @@ export async function runGoogleHealthBackfillForUser( } if (failed) { - // `done` is the resume set this attempt added to as it finished - // collections; a larger set than it started with is progress. - const finished = done.size - doneBefore; - if (finished > 0 && slice + 1 <= GOOGLE_HEALTH_BACKFILL_MAX_SLICE) { + // Progress is what this slice put on disk: collections finished, and + // edges moved. Either is enough to go on; neither means stuck. + const progressed = finished > 0 || advanced.size > 0; + if (progressed && slice + 1 <= GOOGLE_HEALTH_BACKFILL_MAX_SLICE) { const boss = getGlobalBoss(); const sent = boss ? await enqueueIntegrationBackfillAdmission( @@ -182,7 +270,9 @@ export async function runGoogleHealthBackfillForUser( slice: slice + 1, }, }, - INTEGRATION_BACKFILL_CONTINUATION_DELAY_SECONDS, + budget.stopReason === "rate_limited" + ? GOOGLE_HEALTH_BACKFILL_THROTTLED_DELAY_SECONDS + : INTEGRATION_BACKFILL_CONTINUATION_DELAY_SECONDS, ) : null; if (sent) { @@ -192,8 +282,10 @@ export async function runGoogleHealthBackfillForUser( details: { imported, slice, + stopReason: budget.stopReason, collectionsFinished: finished, collectionsDone: done.size, + edgesMoved: [...advanced.keys()], }, }, }); @@ -202,12 +294,23 @@ export async function runGoogleHealthBackfillForUser( // No follow-up could be queued; fall through to the retry path so the // work is not dropped. } - // No collection finished in this attempt: the backfill is stuck, not - // slow. Surface through the pg-boss retry path (retryLimit 3, backoff). - // If the failure parked the connection at error_reauth meanwhile, the - // next attempt takes the reauth return above instead of failing again. + // Nothing moved in this slice: the backfill is stuck, not slow. Surface + // through the pg-boss retry path (retryLimit 3, backoff) with what each + // collection came to, so the log line says where it is stuck. If the + // failure parked the connection at error_reauth meanwhile, the next + // attempt takes the reauth return above instead of failing again. + annotate({ + action: { + name: "google_health.backfill.stuck", + details: { slice, stopReason: budget.stopReason }, + }, + }); + const where = collections + .filter((c) => c.status !== "done") + .map((c) => `${c.key}:${c.status}`) + .join(","); throw new Error( - `google-health backfill incomplete for user ${userId} — marker not stamped`, + `google-health backfill incomplete for user ${userId} — marker not stamped (no progress in slice ${slice}${budget.stopReason ? `, stopped on ${budget.stopReason}` : ""}${where ? `; ${where}` : ""})`, ); } @@ -219,7 +322,7 @@ export async function runGoogleHealthBackfillForUser( annotate({ action: { name: "google_health.backfill.complete", - details: { imported }, + details: { imported, slice }, }, }); return { imported }; diff --git a/src/lib/jobs/health-connect-import-worker.ts b/src/lib/jobs/health-connect-import-worker.ts index 074caf22e..58ba0c6db 100644 --- a/src/lib/jobs/health-connect-import-worker.ts +++ b/src/lib/jobs/health-connect-import-worker.ts @@ -32,6 +32,7 @@ import { invalidateStatusInsightsForTypes } from "@/lib/insights/status-invalida import { jobDone, jobFailed, type JobOutcome } from "@/lib/jobs/job-outcome"; import { trackBackgroundTask } from "@/lib/logging/background-tasks"; import { annotate } from "@/lib/logging/context"; +import { withBackgroundEvent } from "@/lib/logging/background"; import { recomputeUserRollups, ROLLUP_FOLD_WINDOW_MS, @@ -102,7 +103,18 @@ export async function handleHealthConnectImport( ): Promise { let failure: JobOutcome | null = null; for (const job of jobs) { - const outcome = await runHealthConnectImport(job.data); + // Its own event per import, so the `annotate` calls below and anything + // the import records on the ambient event reach the log instead of + // vanishing for want of one. + const outcome = await withBackgroundEvent( + "job.health_connect_import", + async (event) => { + event.addMeta("import_job_id", job.data.importJobId); + const result = await runHealthConnectImport(job.data); + if (!result.ok) event.setError(result.cause ?? result.reason); + return result; + }, + ); if (!outcome.ok && failure === null) failure = outcome; } return failure ?? jobDone({ jobs: jobs.length }); diff --git a/src/lib/jobs/integration-backfill-drain.ts b/src/lib/jobs/integration-backfill-drain.ts index 68d7f7174..df6ad556e 100644 --- a/src/lib/jobs/integration-backfill-drain.ts +++ b/src/lib/jobs/integration-backfill-drain.ts @@ -143,6 +143,49 @@ async function runAdmitted( } } +/** The task name of the event each admitted run writes. */ +export const INTEGRATION_BACKFILL_RUN_TASK = "job.integration_backfill"; + +/** + * Run one admitted job inside its own background event. + * + * The runners write what they do through the ambient event: every request a + * provider walk makes (`addExternalCall`, with its status, duration and the + * page it asked for), every warning, every `annotate`. Without an event in + * scope each of those is a no-op, so a full-history import that failed left + * nothing behind but the job's own error message: not the collection it was + * on, not the status the provider answered, not how far it got (#1220). The + * hourly syncs always ran inside one; the imports did not. Every kind gets + * one here, emitted when the run settles, thrown error included. + */ +async function runObserved( + job: Job, + identity: string, + shouldStop: () => boolean, + totals: RunCounts, +): Promise { + await withBackgroundEvent(INTEGRATION_BACKFILL_RUN_TASK, async (event) => { + event.addMeta("kind", job.data.kind); + event.addMeta("identity", identity); + event.addMeta("job_id", job.id); + event.addMeta("slice", job.data.data.slice ?? 0); + const before = { ...totals }; + await runAdmitted(job.data, shouldStop, totals); + event.setBackground({ + task_name: INTEGRATION_BACKFILL_RUN_TASK, + result: { + kind: job.data.kind, + imported: totals.imported - before.imported, + continued: totals.continued - before.continued, + removed: totals.removed - before.removed, + deleted: totals.deleted - before.deleted, + markers: totals.markers - before.markers, + linked: totals.linked - before.linked, + }, + }); + }); +} + /** * Drain a batch of admission jobs. There is no per-job error isolation: a * runner that throws fails the job and the admission retry policy applies. So @@ -166,7 +209,7 @@ export async function drainIntegrationBackfillAdmission( for (const job of jobs) { const identity = integrationBackfillIdentity(job.data); const run = await withIntegrationBackfillLock(identity, () => - runAdmitted(job.data, shouldStop, totals), + runObserved(job, identity, shouldStop, totals), ); if (!run.ran) { alreadyRunning += 1; diff --git a/src/lib/jobs/integration-watchdog.ts b/src/lib/jobs/integration-watchdog.ts new file mode 100644 index 000000000..f4ce883e8 --- /dev/null +++ b/src/lib/jobs/integration-watchdog.ts @@ -0,0 +1,72 @@ +/** + * Queues, schedules and handler for the integration watchdog + * (`src/lib/integrations/watchdog.ts`). + * + * Two queues rather than one queue with two payloads, so each schedule shows + * up under its own name in the job ledger. Both passes take the same advisory + * lock: the daily pass is a superset of the hourly one, and two passes side by + * side could each open the same incident and page twice. + */ +import type { Job } from "pg-boss"; +import { withJobLock } from "@/lib/jobs/job-lock"; +import { jobDone, jobFailed, type JobOutcome } from "@/lib/jobs/job-outcome"; +import { withBackgroundEvent } from "@/lib/logging/background"; +import { + runIntegrationWatchdog, + type WatchdogMode, +} from "@/lib/integrations/watchdog"; + +export const INTEGRATION_WATCHDOG_HOURLY_QUEUE = "integration-watchdog-hourly"; +export const INTEGRATION_WATCHDOG_DAILY_QUEUE = "integration-watchdog-daily"; + +/** + * :47 is a free minute off every hourly sync tick (used: 0, 5, 8, 11, 13, 15, + * 17, 18, 20, 30, 35, 41, 50), late enough in the hour that the syncs before it + * have written what they fetched. + */ +export const INTEGRATION_WATCHDOG_HOURLY_CRON = "47 * * * *"; +/** Once a day, inside the maintenance window, off the hourly :47. */ +export const INTEGRATION_WATCHDOG_DAILY_CRON = "52 4 * * *"; + +const LOCK_KEY = "job:integration-watchdog:pass"; + +export interface IntegrationWatchdogPayload { + triggeredAt?: string; +} + +function handlerFor(mode: WatchdogMode) { + return async ( + jobs: Job[], + ): Promise => { + void jobs; + return withBackgroundEvent( + `job.integration_watchdog.${mode}`, + async (evt) => { + try { + const run = await withJobLock(LOCK_KEY, () => + runIntegrationWatchdog({ mode }), + ); + if (!run.ran) return jobDone({ already_running: 1 }); + const s = run.result; + evt.addMeta("integration_watchdog", s); + return jobDone({ + users_scanned: s.usersScanned, + users_failed: s.usersFailed, + considered: s.connectionsConsidered, + silences_flagged: s.silencesFlagged, + incidents_opened: s.incidentsOpened, + incidents_closed: s.incidentsClosed, + notified: s.notified, + first_run: s.firstRun ? 1 : 0, + }); + } catch (err) { + evt.addWarning(`integration watchdog failed: ${err}`); + return jobFailed("integration watchdog pass failed", err); + } + }, + ); + }; +} + +export const handleIntegrationWatchdogHourly = handlerFor("hourly"); +export const handleIntegrationWatchdogDaily = handlerFor("daily"); diff --git a/src/lib/jobs/job-outcome.ts b/src/lib/jobs/job-outcome.ts index 8f8a8a2cc..b09cb08e8 100644 --- a/src/lib/jobs/job-outcome.ts +++ b/src/lib/jobs/job-outcome.ts @@ -113,6 +113,8 @@ export const JOB_FACT_ALLOWLIST: ReadonlySet = new Set([ "fetches", "finalised", "finalized", + // v1.42.4 — integration watchdog: the pass found no watch row (first run). + "first_run", "forced", "generated", "geo_backfill_carrier_resolved", @@ -125,6 +127,9 @@ export const JOB_FACT_ALLOWLIST: ReadonlySet = new Set([ "hourly_rows_upserted", "import_staging_swept", "imported", + // v1.42.4 — integration watchdog: operator incidents opened / closed. + "incidents_closed", + "incidents_opened", "in_slot", "in_window", "insufficient", @@ -242,6 +247,8 @@ export const JOB_FACT_ALLOWLIST: ReadonlySet = new Set([ "sent", "served", "side_effects_migrated", + // v1.42.4 — integration watchdog: sources newly flagged as silent. + "silences_flagged", "single_user", "skipped", "skipped_already_dispatched", diff --git a/src/lib/jobs/queue-runtime.ts b/src/lib/jobs/queue-runtime.ts index 2a9a88692..4efb8728d 100644 --- a/src/lib/jobs/queue-runtime.ts +++ b/src/lib/jobs/queue-runtime.ts @@ -295,6 +295,12 @@ export const QUEUE_RUNTIME: Readonly> = { "geo-backfill": short("At most 500 audit rows per pass."), "geolite2-fetch": short("One database download."), "tls-pin-monitor": short("One TLS handshake."), + "integration-watchdog-hourly": short( + "A few indexed reads per account with a connection and no provider call; a 14-day history count only for a silent source.", + ), + "integration-watchdog-daily": short( + "The hourly pass plus one 14-day history count per standing silence flag.", + ), "mood-reminder-cleanup": short("One retention DELETE."), "push-attempt-cleanup": short("One retention DELETE."), "arrival-reaction-cleanup": short("One retention DELETE."), diff --git a/src/lib/jobs/reminder/__tests__/google-health-sync-budget.test.ts b/src/lib/jobs/reminder/__tests__/google-health-sync-budget.test.ts new file mode 100644 index 000000000..24ffdcb44 --- /dev/null +++ b/src/lib/jobs/reminder/__tests__/google-health-sync-budget.test.ts @@ -0,0 +1,121 @@ +/** + * The hourly Google Health job runs under pg-boss's fifteen-minute expiry. + * An account whose backfill slice holds it can make the job wait eight of + * those minutes, and the sync after the wait used to run as long as it + * needed: past the expiry pg-boss marked the job failed and retried it beside + * the run still going. The pass now spends only the job's budget: the lock + * wait is capped at what is left, the sync is handed the budget and stops + * cleanly when it runs out, and an account reached after that is left for the + * next hour. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const { syncUserGoogleHealth, connections } = vi.hoisted(() => ({ + syncUserGoogleHealth: vi.fn(), + connections: [] as Array<{ userId: string }>, +})); + +vi.mock("@/lib/google-health/sync", () => ({ + GOOGLE_HEALTH_POLL_CONCURRENCY: 1, + isGoogleHealthParked: vi.fn(async () => false), + syncUserGoogleHealth, +})); +vi.mock("../shared", () => ({ + getWorkerPrisma: () => ({ + googleHealthConnection: { findMany: vi.fn(async () => connections) }, + }), +})); +vi.mock("@/lib/jobs/reminder-satisfy", () => ({ + enqueueReminderSatisfy: vi.fn(async () => {}), +})); +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); + +import type { Job } from "pg-boss"; +import { handleGoogleHealthSync } from "../google-health-sync"; +import { JOB_BUDGET_SHARE } from "@/lib/jobs/job-budget"; +import { GOOGLE_HEALTH_POLL_LOCK_WAIT_MS } from "@/lib/google-health/sync-lock"; + +const START = new Date("2026-10-15T10:00:00.000Z").getTime(); +const EXPIRY_S = 15 * 60; +const BUDGET_MS = EXPIRY_S * 1000 * JOB_BUDGET_SHARE; + +function cronJob(): Job<{ userId?: string }> { + return { + id: "tick", + name: "google-health-sync", + data: {}, + expireInSeconds: EXPIRY_S, + } as unknown as Job<{ userId?: string }>; +} + +beforeEach(() => { + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(START); + syncUserGoogleHealth.mockReset(); + connections.length = 0; +}); +afterEach(() => { + vi.useRealTimers(); +}); + +describe("the hourly Google Health job and its expiry", () => { + it("hands the sync what is left of the budget after the lock wait, and stops it there", async () => { + connections.push({ userId: "waited" }, { userId: "late" }); + const seen: Array<{ beforeEnd: boolean; atEnd: boolean }> = []; + syncUserGoogleHealth.mockImplementation( + async (_userId: string, opts: { shouldStop: () => boolean }) => { + // Eight minutes waiting for the backfill slice to let go. + vi.setSystemTime(START + GOOGLE_HEALTH_POLL_LOCK_WAIT_MS); + const beforeEnd = opts.shouldStop(); + // The walk runs on until the budget is spent, and is told to stop. + vi.setSystemTime(START + BUDGET_MS); + seen.push({ beforeEnd, atEnd: opts.shouldStop() }); + return { + state: "truncated", + imported: 120, + failed: true, + resources: [], + }; + }, + ); + + const outcome = await handleGoogleHealthSync([cronJob()]); + + expect(syncUserGoogleHealth).toHaveBeenCalledTimes(1); + expect(syncUserGoogleHealth.mock.calls[0]![1]).toMatchObject({ + waitForLockMs: GOOGLE_HEALTH_POLL_LOCK_WAIT_MS, + }); + expect(seen).toEqual([{ beforeEnd: false, atEnd: true }]); + // A stop on the budget is the next hour's work, not a failure to retry; + // the account after it waits for the next hour. + expect(outcome.did).toMatchObject({ + users_partial: 1, + users_skipped: 1, + users_failed: 0, + users_retryable: 0, + }); + }); + + it("never waits for the lock past the budget", async () => { + connections.push({ userId: "a" }, { userId: "b" }); + syncUserGoogleHealth + .mockImplementationOnce(async () => { + // The first account takes most of the budget. + vi.setSystemTime(START + BUDGET_MS - 60_000); + return { state: "complete", imported: 1, failed: false, resources: [] }; + }) + .mockImplementationOnce(async () => ({ + state: "complete", + imported: 1, + failed: false, + resources: [], + })); + + await handleGoogleHealthSync([cronJob()]); + + expect(syncUserGoogleHealth).toHaveBeenCalledTimes(2); + expect(syncUserGoogleHealth.mock.calls[1]![1]).toMatchObject({ + waitForLockMs: 60_000, + }); + }); +}); diff --git a/src/lib/jobs/reminder/google-health-sync.ts b/src/lib/jobs/reminder/google-health-sync.ts index f58329bc0..0e49f4f64 100644 --- a/src/lib/jobs/reminder/google-health-sync.ts +++ b/src/lib/jobs/reminder/google-health-sync.ts @@ -15,6 +15,8 @@ import { isGoogleHealthParked, syncUserGoogleHealth, } from "@/lib/google-health/sync"; +import { GOOGLE_HEALTH_POLL_LOCK_WAIT_MS } from "@/lib/google-health/sync-lock"; +import { jobBudget, jobDeadline } from "@/lib/jobs/job-budget"; import { enqueueReminderSatisfy } from "@/lib/jobs/reminder-satisfy"; import { cleanupExpiredGoogleHealthOAuthStates } from "@/lib/jobs/google-health-oauth-state-cleanup"; import { @@ -57,6 +59,18 @@ export async function handleGoogleHealthSync( }); } + // The job's own expiry bounds the pass (`jobBudget`). An account that + // waited for its backfill slice gets what is left of it, not a fresh + // hour: the lock wait is capped at the time remaining, the sync stops + // its walks once the budget is spent and leaves the rest of its window + // to the next hour (the cycle is not stamped), and an account reached + // only after the budget is gone is left for the next hour untouched. + // Before, a tick could run on past its expiry, and pg-boss then marked + // it failed and retried it beside the run still going. + const shouldStop = jobBudget(jobs); + const deadline = Math.min( + ...jobs.map((job) => jobDeadline(job) ?? Number.POSITIVE_INFINITY), + ); // Fan the cohort out with bounded concurrency + per-user error isolation: // one slow Google response can't stall the whole cohort, and a single // user's failure is warned without aborting the pass. @@ -64,15 +78,43 @@ export async function handleGoogleHealthSync( const verdicts = await Promise.all( targets.map(({ userId }) => limit(async (): Promise => { + if (shouldStop()) return { status: "skipped", imported: 0 }; if (await isGoogleHealthParked(userId)) { return { status: "parked", imported: 0 }; } try { - const result = await syncUserGoogleHealth(userId); + // A backfill holds the account for one slice at a time, so + // the tick waits for the slice to end rather than skipping the + // account for as long as the whole import runs. + const result = await syncUserGoogleHealth(userId, { + waitForLockMs: Math.max( + 0, + Math.min( + GOOGLE_HEALTH_POLL_LOCK_WAIT_MS, + deadline - Date.now(), + ), + ), + shouldStop, + }); // Another run of this account (its backfill, a manual sync) - // holds the account; this tick leaves it to that run. + // still held the account after the wait; this tick leaves it + // to that run. if (result.busy) return { status: "skipped", imported: 0 }; + // Stopped on the job's budget: the rest of the window is the + // next hour's, which is not a failure to retry. + if (result.failed && shouldStop()) { + if (result.imported > 0) { + fireAndForget(enqueueReminderSatisfy(userId), { + action: "reminder.satisfy.enqueue", + }); + } + return { + status: result.imported > 0 ? "partial" : "skipped", + imported: result.imported, + retryable: false, + }; + } const parked = result.failed && result.imported === 0 && diff --git a/src/lib/jobs/reminder/register-integration-sync.ts b/src/lib/jobs/reminder/register-integration-sync.ts index f241b4b62..9e5b55bec 100644 --- a/src/lib/jobs/reminder/register-integration-sync.ts +++ b/src/lib/jobs/reminder/register-integration-sync.ts @@ -70,6 +70,15 @@ import { type IntegrationBackfillAdmissionPayload, } from "@/lib/jobs/integration-backfill-admission"; import { drainIntegrationBackfillAdmission } from "@/lib/jobs/integration-backfill-drain"; +import { + INTEGRATION_WATCHDOG_DAILY_CRON, + INTEGRATION_WATCHDOG_DAILY_QUEUE, + INTEGRATION_WATCHDOG_HOURLY_CRON, + INTEGRATION_WATCHDOG_HOURLY_QUEUE, + handleIntegrationWatchdogDaily, + handleIntegrationWatchdogHourly, + type IntegrationWatchdogPayload, +} from "@/lib/jobs/integration-watchdog"; import { workerLog } from "./shared"; import { jobDone } from "@/lib/jobs/job-outcome"; import { @@ -320,6 +329,10 @@ const allQueues = [ STRAVA_BACKFILL_QUEUE, // v1.30.x — daily sweep for the native OIDC handoff ledger. OIDC_NATIVE_HANDOFF_CLEANUP_QUEUE, + // v1.42.4 — the integration watchdog: an hourly cheap pass and a daily + // pass that re-reads the history behind standing silence flags. + INTEGRATION_WATCHDOG_HOURLY_QUEUE, + INTEGRATION_WATCHDOG_DAILY_QUEUE, ]; const schedules: ScheduleEntry[] = [ @@ -377,6 +390,18 @@ const schedules: ScheduleEntry[] = [ [OURA_SYNC_QUEUE, OURA_SYNC_CRON], // v1.28.x — hourly Strava (:17) OAuth poll. [STRAVA_SYNC_QUEUE, STRAVA_SYNC_CRON], + // v1.42.4 — integration watchdog, hourly at :47 and daily at 04:52. The + // next tick is the retry: a missed pass only delays a flag by an hour. + [ + INTEGRATION_WATCHDOG_HOURLY_QUEUE, + INTEGRATION_WATCHDOG_HOURLY_CRON, + cronIsTheRetry, + ], + [ + INTEGRATION_WATCHDOG_DAILY_QUEUE, + INTEGRATION_WATCHDOG_DAILY_CRON, + cronIsTheRetry, + ], // v1.30.x — daily 03:28 Europe/Berlin prune for expired native OIDC handoffs. [ OIDC_NATIVE_HANDOFF_CLEANUP_QUEUE, @@ -725,6 +750,21 @@ export async function registerIntegrationSyncQueues( { localConcurrency: 1 }, handleStravaSync, ); + // v1.42.4 — integration watchdog. Single-flight per process; both queues + // also share one advisory lock, so the daily and hourly passes never run + // side by side on any replica. + await createAndWork( + boss, + INTEGRATION_WATCHDOG_HOURLY_QUEUE, + { localConcurrency: 1 }, + handleIntegrationWatchdogHourly, + ); + await createAndWork( + boss, + INTEGRATION_WATCHDOG_DAILY_QUEUE, + { localConcurrency: 1 }, + handleIntegrationWatchdogDaily, + ); // v1.28.x — self-converging Strava backfill. This source queue retains the // per-connection singleton and forwards admitted work to the shared drain. await createAndWork( diff --git a/src/lib/jobs/subsystem-surface.ts b/src/lib/jobs/subsystem-surface.ts index e4fc73d43..c4f51546c 100644 --- a/src/lib/jobs/subsystem-surface.ts +++ b/src/lib/jobs/subsystem-surface.ts @@ -43,6 +43,8 @@ const subsystemSurface = { "polar-sync": { audience: "account" }, "oura-sync": { audience: "account" }, "strava-sync": { audience: "account" }, + "integration-watchdog-hourly": { audience: "system" }, + "integration-watchdog-daily": { audience: "system" }, "strava-backfill": { audience: "system" }, "oidc-native-handoff-cleanup": { audience: "system" }, diff --git a/src/lib/labs/overview.ts b/src/lib/labs/overview.ts new file mode 100644 index 000000000..cd89e02dc --- /dev/null +++ b/src/lib/labs/overview.ts @@ -0,0 +1,131 @@ +/** + * Per-marker overview of an account's lab history (`GET /api/labs/overview`). + * + * The Labs list shows one row per biomarker: its newest reading, how many + * readings it has, and a small trend. It used to build that by fetching the + * newest 500 raw rows and grouping them in the browser, so on a long history + * every marker whose readings all lay beyond the 500th row vanished from the + * list without a word. The grouping now happens here, over every live row, so + * the answer does not depend on how many readings the other markers have. + * + * Grouping matches what the list always did: by the linked biomarker, or for a + * legacy unlinked row case-insensitively by its analyte name. + */ +import { prisma } from "@/lib/db"; +import { serialiseLabResult } from "@/lib/labs/serialise"; + +/** + * Readings per marker that feed the trend sparkline, newest kept. The + * sparkline is a 72px glyph; more points than this only overlap. The count + * beside it is the full count, not this cap. + */ +export const LAB_OVERVIEW_TREND_POINTS = 50; + +export interface LabOverviewMarker { + /** `bm:`, or `analyte:` for a legacy row. */ + key: string; + /** Every live reading of this marker. */ + count: number; + /** Values of the newest readings, oldest first; null for a qualitative one. */ + trend: (number | null)[]; + /** The newest reading, as the list endpoint serialises a row. */ + latest: ReturnType; +} + +/** + * One window-function read ranks every live row inside its marker, keeps the + * newest `LAB_OVERVIEW_TREND_POINTS` of each, and a second read loads the + * newest row per marker with its biomarker so it serialises exactly like a + * list row. Markers come back newest reading first. + */ +export async function loadLabOverview( + userId: string, +): Promise { + // Parameter-bound throughout. `id` breaks a `taken_at` tie the same way the + // list endpoint's order does, so "newest" means the same row on both. + const ranked = await prisma.$queryRaw< + Array<{ + id: string; + grp: string; + value: number | null; + rn: number; + n: number; + }> + >` + SELECT r."id", r."grp", r."value", r."rn"::int AS "rn", r."n"::int AS "n" + FROM ( + SELECT g."id", g."grp", g."value", + row_number() OVER ( + PARTITION BY g."grp" ORDER BY g."taken_at" DESC, g."id" DESC + ) AS "rn", + count(*) OVER (PARTITION BY g."grp") AS "n" + FROM ( + SELECT l."id", l."value", l."taken_at", + CASE + WHEN l."biomarker_id" IS NOT NULL THEN 'bm:' || l."biomarker_id" + ELSE 'analyte:' || lower(l."analyte") + END AS "grp" + FROM "lab_results" l + WHERE l."user_id" = ${userId} AND l."deleted_at" IS NULL + ) g + ) r + WHERE r."rn" <= ${LAB_OVERVIEW_TREND_POINTS} + `; + if (ranked.length === 0) return []; + + const groups = new Map< + string, + { + latestId: string | null; + count: number; + points: Array<[number, number | null]>; + } + >(); + for (const row of ranked) { + let group = groups.get(row.grp); + if (!group) { + group = { latestId: null, count: row.n, points: [] }; + groups.set(row.grp, group); + } + if (row.rn === 1) group.latestId = row.id; + group.points.push([row.rn, row.value]); + } + + const latestIds = [...groups.values()] + .map((g) => g.latestId) + .filter((id): id is string => id !== null); + const rows = await prisma.labResult.findMany({ + where: { userId, id: { in: latestIds } }, + include: { + biomarker: { + select: { + id: true, + name: true, + unit: true, + lowerBound: true, + upperBound: true, + panel: true, + }, + }, + }, + }); + const byId = new Map(rows.map((row) => [row.id, row])); + + const markers: LabOverviewMarker[] = []; + for (const [key, group] of groups) { + const row = group.latestId ? byId.get(group.latestId) : undefined; + // Deleted between the two reads: the marker is gone, not half-shown. + if (!row) continue; + markers.push({ + key, + count: group.count, + trend: group.points.sort((a, b) => b[0] - a[0]).map(([, value]) => value), + latest: serialiseLabResult(row, row.biomarker ?? null), + }); + } + return markers.sort((a, b) => { + const byTime = Date.parse(b.latest.takenAt) - Date.parse(a.latest.takenAt); + if (byTime !== 0) return byTime; + return a.latest.id < b.latest.id ? 1 : a.latest.id > b.latest.id ? -1 : 0; + }); +} diff --git a/src/lib/logging/__tests__/event-builder.test.ts b/src/lib/logging/__tests__/event-builder.test.ts index 2ff65ce65..c8900f8f9 100644 --- a/src/lib/logging/__tests__/event-builder.test.ts +++ b/src/lib/logging/__tests__/event-builder.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from "vitest"; -import { WideEventBuilder } from "../event-builder"; +import { + EXTERNAL_CALLS_MAX, + EXTERNAL_CALLS_PLAIN_MAX, + WideEventBuilder, +} from "../event-builder"; describe("WideEventBuilder.setHttp", () => { it("stores plain http metadata as-is", () => { @@ -140,3 +144,46 @@ describe("WideEventBuilder.capStatusLevel", () => { expect(evt.toJSON().level).toBe("warn"); }); }); + +describe("WideEventBuilder — bounded external calls", () => { + it("lists failures beyond the share of plain successes, and counts the rest", () => { + const evt = new WideEventBuilder("background"); + for (let i = 0; i < 500; i++) { + evt.addExternalCall({ + service: "google-health", + method: `page=${i}`, + duration_ms: 1, + status: 200, + }); + } + evt.addExternalCall({ + service: "google-health", + method: "page=500", + duration_ms: 1, + status: 503, + error: "transient HTTP 503", + }); + const event = evt.toJSON(); + expect(event.external_calls).toHaveLength(EXTERNAL_CALLS_PLAIN_MAX + 1); + expect(event.external_calls!.at(-1)).toMatchObject({ status: 503 }); + expect(event.external_calls_omitted).toBe(500 - EXTERNAL_CALLS_PLAIN_MAX); + + for (let i = 0; i < 1000; i++) { + evt.addExternalCall({ + service: "google-health", + method: `retry=${i}`, + duration_ms: 1, + status: 429, + note: "rate_limited_retrying_in_ms=2000", + }); + } + const after = evt.toJSON(); + expect(after.external_calls).toHaveLength(EXTERNAL_CALLS_MAX); + expect(after.external_calls_omitted).toBe( + 500 - + EXTERNAL_CALLS_PLAIN_MAX + + 1000 - + (EXTERNAL_CALLS_MAX - EXTERNAL_CALLS_PLAIN_MAX - 1), + ); + }); +}); diff --git a/src/lib/logging/event-builder.ts b/src/lib/logging/event-builder.ts index 09afd9819..2429e9d59 100644 --- a/src/lib/logging/event-builder.ts +++ b/src/lib/logging/event-builder.ts @@ -35,6 +35,12 @@ function redactDeep(value: T, depth = 0): T { return value; } +/** The most external calls one event lists. */ +export const EXTERNAL_CALLS_MAX = 200; + +/** The most calls without an error or a note one event lists. */ +export const EXTERNAL_CALLS_PLAIN_MAX = 60; + /** * Baut ein Wide Event Schritt fuer Schritt auf. * Wird am Anfang eines Requests/Tasks erstellt und am Ende emittiert. @@ -44,6 +50,7 @@ export class WideEventBuilder { private startTime: number; private highestLevel: LogLevel = "info"; private statusLevelCeiling: LogLevel = "error"; + private plainExternalCalls = 0; constructor(kind: EventKind = "http") { this.startTime = performance.now(); @@ -208,6 +215,23 @@ export class WideEventBuilder { addExternalCall(call: NonNullable[0]): this { if (!this.event.external_calls) this.event.external_calls = []; + // Bounded. A long background run (a full-history backfill walks hundreds + // of pages in one event) would otherwise hold every call in memory and + // write them all as one log line. Calls that failed or carry a note are + // what a reader looks for, so they keep the larger share; plain successes + // stop being listed sooner. Whatever is left out is counted, never lost + // silently. + const calls = this.event.external_calls; + const notable = call.error !== undefined || call.note !== undefined; + if ( + calls.length >= EXTERNAL_CALLS_MAX || + (!notable && this.plainExternalCalls >= EXTERNAL_CALLS_PLAIN_MAX) + ) { + this.event.external_calls_omitted = + (this.event.external_calls_omitted ?? 0) + 1; + return this; + } + if (!notable) this.plainExternalCalls += 1; // An outbound failure carries whatever the remote said, and for several // integrations that is the request URL — which for Telegram embeds the bot // token. `setError` and `setHttp` scrub on the way in; this entry point did diff --git a/src/lib/logging/types.ts b/src/lib/logging/types.ts index 086357230..a126d5681 100644 --- a/src/lib/logging/types.ts +++ b/src/lib/logging/types.ts @@ -123,6 +123,11 @@ export interface WideEvent { */ note?: string; }>; + /** + * How many external calls the event did not list because it had already + * listed its share (`EXTERNAL_CALLS_MAX`, `EXTERNAL_CALLS_PLAIN_MAX`). + */ + external_calls_omitted?: number; // Hintergrundaufgabe (nur bei kind === "background") background?: { diff --git a/src/lib/medications/scheduling/__tests__/extra-dose.test.ts b/src/lib/medications/scheduling/__tests__/extra-dose.test.ts new file mode 100644 index 000000000..715e568c7 --- /dev/null +++ b/src/lib/medications/scheduling/__tests__/extra-dose.test.ts @@ -0,0 +1,139 @@ +/** + * An extra dose next to a slot that already carries one. + * + * Two ends: the write path keeps a different external dose record off a slot + * that is already taken (`isAnotherDose`), and the read ledger lets the dose + * stored on the slot claim it before an extra one stored on its own instant, + * whatever order the rows were read in. + */ +import { describe, expect, it } from "vitest"; + +import { buildSlotBands, type SlotWindowInput } from "../attribution"; +import { reconstructDoseHistory, type HistoryIntake } from "../dose-history"; +import { DOUBLE_ENTRY_TOLERANCE_MS, isAnotherDose } from "../slot-upsert"; + +const MIN = 60_000; +const t0 = new Date("2026-06-09T20:00:00Z"); +const plus = (minutes: number) => new Date(t0.getTime() + minutes * MIN); + +describe("isAnotherDose", () => { + // 22:00 in Berlin; the next local day starts two hours later. + const TZ = "Europe/Berlin"; + const external = (id: string, minutes: number, namesSlot = false) => ({ + takenAt: plus(minutes), + externalId: id, + namesSlot, + }); + + it("a slot with nothing taken is never held by another dose", () => { + expect( + isAnotherDose( + { takenAt: null, externalId: null }, + external("hk-2", 600), + TZ, + ), + ).toBe(false); + }); + + it("the same external record is the same dose", () => { + expect( + isAnotherDose( + { takenAt: t0, externalId: "hk-1" }, + external("hk-1", 600), + TZ, + ), + ).toBe(false); + }); + + it("a take on another local day is another dose, whoever recorded either", () => { + // 22:00 and 00:30 the next morning in Berlin: an hour and a half apart, + // two calendar days. + for (const held of [ + { takenAt: t0, externalId: "hk-1" }, + { takenAt: t0, externalId: null }, + ]) { + expect(isAnotherDose(held, external("hk-2", 150, true), TZ)).toBe(true); + expect(isAnotherDose(held, external("hk-2", 24 * 60 + 22), TZ)).toBe( + true, + ); + } + }); + + it("a take logged in HealthLog converges with an external record of the same day", () => { + const held = { takenAt: t0, externalId: null }; + expect(isAnotherDose(held, external("hk-2", 20), TZ)).toBe(false); + expect(isAnotherDose(held, external("hk-2", -90), TZ)).toBe(false); + }); + + it("a new record that names the slot is the slot's dose recorded again (an edit in Health)", () => { + expect( + isAnotherDose( + { takenAt: t0, externalId: "hk-1" }, + external("hk-1-edited", 30, true), + TZ, + ), + ).toBe(false); + }); + + it("an unscheduled record of the same day is another dose beyond a double entry", () => { + const held = { takenAt: t0, externalId: "hk-1" }; + expect(isAnotherDose(held, external("hk-2", 30), TZ)).toBe(true); + expect( + isAnotherDose( + held, + { + takenAt: new Date(t0.getTime() + DOUBLE_ENTRY_TOLERANCE_MS), + externalId: "hk-2", + namesSlot: false, + }, + TZ, + ), + ).toBe(false); + }); +}); + +describe("reconstructDoseHistory: the slot's own dose claims the slot", () => { + const slot: SlotWindowInput = { + at: t0, + timeOfDay: "22:00", + onTimeStart: plus(-60), + onTimeEnd: plus(60), + lateGraceMs: 180 * MIN, + }; + const bands = buildSlotBands([slot]); + const onSlot: HistoryIntake = { + id: "on-slot", + scheduledFor: t0, + takenAt: plus(5), + skipped: false, + }; + const extra: HistoryIntake = { + id: "extra", + scheduledFor: plus(30), + takenAt: plus(30), + skipped: false, + }; + + it.each([ + ["slot row first", [onSlot, extra]], + ["extra row first (newest scheduledFor first)", [extra, onSlot]], + ])("%s", (_label, intakes) => { + const rows = reconstructDoseHistory(bands, intakes, plus(600), null); + const slotRow = rows.find((row) => row.kind === "slot"); + const adHoc = rows.filter((row) => row.kind === "ad_hoc"); + expect(slotRow?.intake?.id).toBe("on-slot"); + expect(adHoc.map((row) => row.intake?.id)).toEqual(["extra"]); + }); + + it("among takes not stored on the slot, the nearer one claims it", () => { + const far: HistoryIntake = { ...extra, id: "far" }; + const near: HistoryIntake = { + id: "near", + scheduledFor: plus(10), + takenAt: plus(10), + skipped: false, + }; + const rows = reconstructDoseHistory(bands, [far, near], plus(600), null); + expect(rows.find((row) => row.kind === "slot")?.intake?.id).toBe("near"); + }); +}); diff --git a/src/lib/medications/scheduling/dose-history.ts b/src/lib/medications/scheduling/dose-history.ts index b4d31aef3..a126eff2c 100644 --- a/src/lib/medications/scheduling/dose-history.ts +++ b/src/lib/medications/scheduling/dose-history.ts @@ -273,8 +273,33 @@ export function reconstructDoseHistory( } } - for (const i of taken) { + // The best fit claims a slot first, whatever order the caller read the rows + // in. A take the write path stored ON the slot it attributes to goes before + // one stored on its own instant (an extra dose recorded unscheduled), then + // on-time before late, then the nearer take. Processing in the caller's + // order (newest `scheduledFor` first on the history route) let an extra dose + // logged half an hour after the slot claim it and pushed the slot's own + // dose out as the off-schedule one. + const ranked = taken.map((i) => { const matched = attributeIntakeToSlot(i.takenAt as Date, bands); + if (!matched) return { i, matched, key: [2, 0, 0] as const }; + const storedOnSlot = + Math.abs(i.scheduledFor.getTime() - matched.band.at.getTime()) <= + ANCHOR_EPSILON_MS; + return { + i, + matched, + key: [ + storedOnSlot ? 0 : 1, + matched.status === "on_time" ? 0 : 1, + Math.abs((i.takenAt as Date).getTime() - matched.band.at.getTime()), + ] as const, + }; + }); + ranked.sort( + (a, b) => a.key[0] - b.key[0] || a.key[1] - b.key[1] || a.key[2] - b.key[2], + ); + for (const { i, matched } of ranked) { if (matched && !claim.has(matched.band)) { claim.set(matched.band, { intake: i, diff --git a/src/lib/medications/scheduling/slot-upsert.ts b/src/lib/medications/scheduling/slot-upsert.ts index 55eef2a2b..07b35a7ff 100644 --- a/src/lib/medications/scheduling/slot-upsert.ts +++ b/src/lib/medications/scheduling/slot-upsert.ts @@ -18,6 +18,7 @@ import type { PrismaClient } from "@/generated/prisma/client"; import { prisma as defaultPrisma } from "@/lib/db"; import { isP2002 } from "@/lib/prisma-errors"; +import { userDayKey } from "@/lib/tz/format"; import { resolveCanonicalSlotInstant } from "@/lib/medications/scheduling/resolve-slot-instant"; import { attributeTakenToSlot, @@ -746,6 +747,112 @@ export async function findPinConflict(input: { }); } +/** + * How far apart two different external dose records may sit and still be one + * dose entered twice rather than two doses. + */ +export const DOUBLE_ENTRY_TOLERANCE_MS = 60_000; + +/** + * Is `held` (the take a slot already carries) a different dose from the + * incoming external dose record, both attributed to the same slot by band? + * + * Band attribution maps a take to the slot whose band contains it, and a + * weekly schedule's band reaches a day either side of the slot. Without this + * check an extra dose logged the day after a Tuesday dose landed on the + * Tuesday slot and overwrote it last-write-wins: the Tuesday time was + * replaced and the extra dose existed nowhere (#1221). The rule: + * + * - Nothing taken on the slot yet (pending, skipped): not another dose, the + * incoming take is the slot's dose. + * - The same external record: the same dose. + * - A take on a different local calendar day (the user's zone): another + * dose. A slot is one dose a day; the band only reaches across midnight + * so a late take still counts for its slot. + * - The same local day, and the held take was logged in HealthLog (no + * external record): the same dose. A tap at 08:00 and the same dose + * recorded in Health at 08:20 converge onto the slot, one dose and one + * stock consumption. + * - The same local day, two different external records: the record says + * which it is. One that names this slot as its scheduled time is the + * slot's dose, recorded again: editing a dose in Health replaces it with + * a new record (a new UUID, possibly a new time), and that must move the + * slot's dose, not add a second one. One without a scheduled time of its + * own (Health logs an extra dose as unscheduled, and the phone then sends + * its own time) is another dose, unless it sits within + * {@link DOUBLE_ENTRY_TOLERANCE_MS} of the held one, which is one dose + * entered twice. + */ +export function isAnotherDose( + held: { takenAt: Date | null; externalId: string | null }, + incoming: { + takenAt: Date; + externalId: string | null; + /** The record's scheduled time is the slot itself. */ + namesSlot: boolean; + }, + userTz: string, +): boolean { + if (held.takenAt === null) return false; + if (held.externalId !== null && held.externalId === incoming.externalId) { + return false; + } + if ( + userDayKey(held.takenAt, userTz) !== userDayKey(incoming.takenAt, userTz) + ) { + return true; + } + if (held.externalId === null || incoming.externalId === null) return false; + if (incoming.namesSlot) return false; + const gap = Math.abs(held.takenAt.getTime() - incoming.takenAt.getTime()); + return gap > DOUBLE_ENTRY_TOLERANCE_MS; +} + +/** + * Does the slot at `canonicalSlot` already carry a recorded take that is a + * different dose from the incoming one (see {@link isAnotherDose})? The + * caller records the incoming take as its own unscheduled dose instead of + * converging onto the slot. + */ +export async function slotHeldByAnotherDose(input: { + userId: string; + medicationId: string; + canonicalSlot: Date; + takenAt: Date; + externalId: string | null; + /** The scheduled time the incoming record carries, if any. */ + scheduledFor: Date | null; + userTz: string; + client?: Pick; +}): Promise { + const client = input.client ?? defaultPrisma; + const rows = await client.medicationIntakeEvent.findMany({ + where: { + userId: input.userId, + medicationId: input.medicationId, + scheduledFor: input.canonicalSlot, + deletedAt: null, + takenAt: { not: null }, + }, + select: { takenAt: true, externalId: true }, + }); + const namesSlot = + input.scheduledFor !== null && + Math.abs(input.scheduledFor.getTime() - input.canonicalSlot.getTime()) <= + DOUBLE_ENTRY_TOLERANCE_MS; + return rows.some((row) => + isAnotherDose( + row, + { + takenAt: input.takenAt, + externalId: input.externalId, + namesSlot, + }, + input.userTz, + ), + ); +} + export interface ResolveForcedSlotInput { userId: string; medicationId: string; diff --git a/src/lib/openapi/routes/insights/schemas.ts b/src/lib/openapi/routes/insights/schemas.ts index f13be444c..6f77494fd 100644 --- a/src/lib/openapi/routes/insights/schemas.ts +++ b/src/lib/openapi/routes/insights/schemas.ts @@ -2878,6 +2878,11 @@ export const glp1TimelineResponse = z .describe( "Newest first, capped at `limit`. A heterogeneous union: which optional keys are present depends on `kind`, and no entry carries all of them.", ), + medications: z + .array(z.object({ id: z.string(), name: z.string() })) + .describe( + "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.", + ), }) .meta({ id: "Glp1TimelineResponse", diff --git a/src/lib/openapi/routes/integrations.ts b/src/lib/openapi/routes/integrations.ts index eb8be83ed..1773fe2ac 100644 --- a/src/lib/openapi/routes/integrations.ts +++ b/src/lib/openapi/routes/integrations.ts @@ -31,6 +31,12 @@ * come from `src/lib/validations/*` so the wire contract stays single-source. */ import { z } from "zod/v4"; +import { + CONNECTION_HEALTH_ACTIONS, + CONNECTION_HEALTH_REASONS, + CONNECTION_HEALTH_STATES, + RECENT_CALL_STATUSES, +} from "@/lib/integrations/connection-health"; import type { ZodOpenApiObject } from "zod-openapi"; import { fitbitCredentialsSchema } from "@/lib/validations/fitbit"; import { googleHealthCredentialsSchema } from "@/lib/validations/google-health"; @@ -97,12 +103,107 @@ const syncHealth = z "Server-resolved sync-health verdict. The single source of liveness truth — `lastSyncedAt` alone cannot express 'this pipe stopped delivering'.", }); +// Mirrors `ConnectionHealth` in `src/lib/integrations/connection-health.ts`: +// the one server-resolved state per connection every client renders. +export const connectionHealth = z + .object({ + state: z + .enum(CONNECTION_HEALTH_STATES) + .describe( + "`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: z + .enum(CONNECTION_HEALTH_REASONS) + .describe( + "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: z.iso + .datetime({ offset: true }) + .nullable() + .describe( + "When the current degraded / action_required condition started. Null for ok and limited.", + ), + action: z + .enum(CONNECTION_HEALTH_ACTIONS) + .nullable() + .describe( + "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: z + .array( + z.object({ + item: z + .string() + .describe( + "Provider data-type key, e.g. `spo2`, `vo2Max`, `activity`.", + ), + cause: z.enum(["plan", "permission"]), + }), + ) + .describe( + "Data types this connection cannot deliver. Present in every state, empty when nothing is withheld.", + ), + lastSuccessAt: z.iso + .datetime({ offset: true }) + .nullable() + .describe( + "Last successful sync. For Apple Health, the last accepted batch.", + ), + lastDataAt: z.iso + .datetime({ offset: true }) + .nullable() + .describe( + "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: z + .object({ + ledgerState: z + .string() + .nullable() + .describe("The stored sync-ledger state. Null for Apple Health."), + failingLegs: z.array(z.string()), + failingSince: z.iso.datetime({ offset: true }).nullable(), + lastAttemptAt: z.iso.datetime({ offset: true }).nullable(), + verdict: z + .string() + .describe("The `SyncHealth` verdict the state was derived from."), + recentCalls: z + .array( + z.object({ + endpoint: z + .string() + .describe( + "Sync leg name (`sleep`, `measures`, `sync`, `token_refresh`), or `batch:` for Apple Health. Never a URL.", + ), + status: z.enum(RECENT_CALL_STATUSES), + at: z.iso.datetime({ offset: true }), + errorCode: z + .string() + .nullable() + .describe( + "Short error code (`401`, `invalid_grant`, `scope_missing`); anything not shaped like one is dropped.", + ), + }), + ) + .max(10) + .describe("The last ten sync attempts, newest first."), + }) + .describe( + "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.", + ), + }) + .meta({ + id: "ConnectionHealth", + 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.", + }); + const metricFreshnessEntry = z .object({ type: z .string() .describe( - "The measurement type (e.g. `RESPIRATORY_RATE`), or `WORKOUTS` for the workout leg.", + "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: z.iso .datetime({ offset: true }) @@ -144,11 +245,18 @@ const appleHealthMetricFreshnessEntry = metricFreshnessEntry .describe( "When a live sync batch last brought a new or updated sample of this type, as opposed to duplicates only. Null when none has. Same omission rules as `lastReceivedAt`.", ), + lastBackgroundAt: z.iso + .datetime({ offset: true }) + .nullable() + .optional() + .describe( + '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`.', + ), }) .meta({ id: "AppleHealthMetricFreshnessEntry", 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).", + '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). 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`.', }); // Issue #778 — the two backfill-progress figures the server genuinely holds. @@ -212,6 +320,12 @@ const healthKitConfigResponse = z .describe( "Backfill/sync progress summary. Present on the GET read (null when the progress read failed); omitted from the PATCH echo.", ), + health: connectionHealth + .nullable() + .optional() + .describe( + "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.", + ), }) .meta({ id: "HealthKitConfigResponse", @@ -405,6 +519,12 @@ const nightscoutStatusResponse = z "Last recorded failure message, decrypted from the ledger. Null when the last attempt succeeded or none has run.", ), syncHealth: syncHealth.optional(), + health: connectionHealth + .nullable() + .optional() + .describe( + "The unified connection state. Present whenever the connection exists; null in the rare case it resolved to none. Absent on the not-connected body.", + ), }) .meta({ id: "NightscoutStatusResponse", @@ -483,6 +603,12 @@ const whoopStatusResponse = z .describe( "Space-separated OAuth scope string granted by WHOOP. Null on a legacy connection that predates scope tracking.", ), + health: connectionHealth + .nullable() + .optional() + .describe( + "The unified connection state. Present whenever the connection exists; null in the rare case it resolved to none. Absent on the not-connected body.", + ), }) .meta({ id: "WhoopStatusResponse", @@ -529,6 +655,12 @@ const withingsStatusResponse = z .describe( "Pre-computed convenience flag the reconnect banner reads. A null `scope` counts as missing activity.", ), + health: connectionHealth + .nullable() + .optional() + .describe( + "The unified connection state. Present whenever the connection exists; null in the rare case it resolved to none. Absent on the not-connected body.", + ), }) .meta({ id: "WithingsStatusResponse", @@ -810,6 +942,12 @@ function connectionStatusShape() { .describe( "OAuth scope string granted at authorisation. Null on a legacy connection that predates scope tracking.", ), + health: connectionHealth + .nullable() + .optional() + .describe( + "The unified connection state. Present whenever the connection exists; null in the rare case it resolved to none. Absent on the not-connected body.", + ), }; } @@ -863,6 +1001,12 @@ const oauthProviderStatusResponse = z lastAttemptAt: z.iso.datetime({ offset: true }).nullable().optional(), lastError: z.string().nullable().optional(), syncHealth: syncHealth.optional(), + health: connectionHealth + .nullable() + .optional() + .describe( + "The unified connection state. Present whenever the connection exists; null in the rare case it resolved to none. Absent on the not-connected body.", + ), }) .meta({ id: "OAuthProviderStatusResponse", @@ -947,6 +1091,11 @@ const integrationEnvelopeEntry = z ), hasToken: z.boolean().optional().describe("Nightscout only."), allowPrivateHost: z.boolean().optional().describe("Nightscout only."), + health: connectionHealth + .nullable() + .describe( + "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).", + ), }) .meta({ id: "IntegrationStatusEntry", diff --git a/src/lib/openapi/routes/labs.ts b/src/lib/openapi/routes/labs.ts index 3fcc7e5ca..27a25f337 100644 --- a/src/lib/openapi/routes/labs.ts +++ b/src/lib/openapi/routes/labs.ts @@ -12,6 +12,7 @@ import { z } from "zod/v4"; import { createLabResultSchema, + labOverviewQuerySchema, listLabResultsSchema, updateLabResultSchema, } from "@/lib/validations/labs"; @@ -116,6 +117,29 @@ const listResponse = z }) .meta({ id: "ListLabResultsResponse" }); +const labOverviewQuery = labOverviewQuerySchema.meta({ + id: "LabOverviewQuery", + description: + "The overview takes no query parameters; it always covers the whole live history. An unknown parameter is refused with 422.", +}); + +const labOverviewMarker = z + .object({ + key: z.string(), + count: z.number().int(), + trend: z.array(z.number().nullable()), + latest: labResultRow, + }) + .meta({ + id: "LabOverviewMarker", + description: + "One biomarker in the caller's lab history. `key` is `bm:` for a catalog-linked marker or `analyte:` 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.", + }); + +const labOverviewResponse = z + .object({ markers: z.array(labOverviewMarker) }) + .meta({ id: "LabOverviewResponse" }); + const notFound = { "404": { description: "Lab result not found (or owned by another user).", @@ -175,6 +199,27 @@ export const labsPaths: NonNullable = { }, }, }, + "/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.", + requestParams: { query: labOverviewQuery }, + responses: { + ...recordRefusal(), + "200": { + description: "Lab overview.", + content: { + "application/json": { + schema: dataEnvelope(labOverviewResponse, "LabOverviewEnvelope"), + }, + }, + }, + ...stdResponses, + }, + }, + }, "/api/labs/{id}": { get: { tags: ["Labs"], diff --git a/src/lib/openapi/routes/measurements.ts b/src/lib/openapi/routes/measurements.ts index f5396914c..295ad924b 100644 --- a/src/lib/openapi/routes/measurements.ts +++ b/src/lib/openapi/routes/measurements.ts @@ -130,7 +130,7 @@ const batchPayloadSchema = z .enum(["foreground", "background", "push", "manual"]) .optional() .describe( - 'Diagnostic-only. Names what triggered this sync (foreground app open, background refresh, a push wake, or a user-initiated `manual` "Sync all" run, since v1.42). Recorded on the ingest wide event and kept on the 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.', + 'Diagnostic-only. Names what triggered this sync (foreground app open, background refresh, a push wake, or a user-initiated `manual` "Sync all" run, since v1.42). Recorded on the ingest wide event and kept on the 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, 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.', ), }) .meta({ diff --git a/src/lib/openapi/routes/medications/paths.ts b/src/lib/openapi/routes/medications/paths.ts index 59c23b3cb..ac6ecd4e5 100644 --- a/src/lib/openapi/routes/medications/paths.ts +++ b/src/lib/openapi/routes/medications/paths.ts @@ -198,6 +198,12 @@ const bulkIntakeEntry = z const bulkIntakePayload = z .object({ entries: z.array(bulkIntakeEntry).min(1).max(500), + syncTrigger: z + .enum(["foreground", "background", "push", "manual"]) + .optional() + .describe( + '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.', + ), }) .meta({ id: "BulkMedicationIntakeRequest", diff --git a/src/lib/openapi/routes/record-settings.ts b/src/lib/openapi/routes/record-settings.ts index d8622e60f..fc7dde72d 100644 --- a/src/lib/openapi/routes/record-settings.ts +++ b/src/lib/openapi/routes/record-settings.ts @@ -25,6 +25,7 @@ import { z } from "zod/v4"; import { METRIC_BOUNDS } from "@/lib/analytics/effective-range"; import { MANAGED_RECORD_SETTINGS_PATCH_SCHEMAS } from "@/lib/record-settings"; +import { connectionHealth } from "./integrations"; import { dataEnvelope, errorEnvelope, @@ -96,6 +97,11 @@ const recordSettingsIntegrationsResponse = z ]), lastSuccessAt: z.iso.datetime({ offset: true }).nullable(), lastAttemptAt: z.iso.datetime({ offset: true }).nullable(), + health: connectionHealth + .nullable() + .describe( + "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.", + ), }), ) .describe( @@ -105,7 +111,7 @@ const recordSettingsIntegrationsResponse = z .meta({ id: "RecordSettingsIntegrationsResponse", 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.", + "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.", }); // ── The six family DTOs ────────────────────────────────────────────── @@ -353,7 +359,7 @@ export const recordSettingsPaths: NonNullable = { 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.", + "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: { ...recordRefusal(), "200": { diff --git a/src/lib/oura/__tests__/client.test.ts b/src/lib/oura/__tests__/client.test.ts index ec294e1f9..b8e6b0893 100644 --- a/src/lib/oura/__tests__/client.test.ts +++ b/src/lib/oura/__tests__/client.test.ts @@ -330,6 +330,24 @@ describe("mapReadiness", () => { }); describe("mapSleep", () => { + it("maps a rejected (`rest`) or deleted period to nothing", () => { + const base: OuraSleep = { + id: "rec-r", + day: "2026-06-10", + bedtime_start: "2026-06-10T14:00:00.000Z", + bedtime_end: "2026-06-10T15:00:00.000Z", + sleep_phase_5_min: "222222222222", + lowest_heart_rate: 58, + average_hrv: 40, + }; + expect(mapSleep({ ...base, type: "rest" })).toEqual([]); + expect(mapSleep({ ...base, type: "deleted" })).toEqual([]); + // Periods Oura counts still map, a nap included. + for (const type of ["long_sleep", "sleep", "late_nap", undefined]) { + expect(mapSleep({ ...base, type }).length).toBeGreaterThan(0); + } + }); + it("falls back to stage totals (no hypnogram) and record-scopes the externalId", () => { const s: OuraSleep = { id: "rec-9", diff --git a/src/lib/oura/__tests__/sync.test.ts b/src/lib/oura/__tests__/sync.test.ts index 691199cee..299f6e892 100644 --- a/src/lib/oura/__tests__/sync.test.ts +++ b/src/lib/oura/__tests__/sync.test.ts @@ -415,6 +415,75 @@ describe("syncUserOura", () => { ); }); + it("imports nothing for a rejected or deleted sleep period and retires what an earlier sync stored for it", async () => { + getConnMock.mockResolvedValue(CONN); + fetchSleepMock.mockResolvedValue([ + { + id: "rec-rest", + type: "rest", + day: "2026-06-10", + bedtime_start: "2026-06-10T14:00:00.000Z", + bedtime_end: "2026-06-10T15:00:00.000Z", + sleep_phase_5_min: "222222222222", + lowest_heart_rate: 58, + }, + { + id: "rec-del", + type: "deleted", + day: "2026-06-09", + bedtime_end: "2026-06-09T16:00:00.000Z", + light_sleep_duration: 1800, + }, + ]); + const stored = [ + { + id: "m1", + type: "SLEEP_DURATION", + measuredAt: new Date("2026-06-10T15:00:00.000Z"), + }, + { + id: "m2", + type: "RESTING_HEART_RATE", + measuredAt: new Date("2026-06-10T15:00:00.000Z"), + }, + ]; + findManyMock.mockResolvedValue(stored); + updateManyMock.mockResolvedValue({ count: 2 }); + + await syncUserOura("u1"); + + // Nothing of either period reaches the write path. + expect( + createdRows().filter((r) => String(r.externalId).startsWith("sleep:")), + ).toEqual([]); + // The rows of both periods are looked up by their record prefix, any type. + const lookup = findManyMock.mock.calls + .map((c) => c[0] as { where: Record }) + .find((a) => Array.isArray(a.where.OR)); + expect(lookup).toBeDefined(); + expect(lookup!.where).toMatchObject({ + userId: "u1", + source: "OURA", + deletedAt: null, + }); + expect(lookup!.where.type).toBeUndefined(); + expect(lookup!.where.OR).toEqual([ + { externalId: { startsWith: "sleep:rec-rest:" } }, + { externalId: { startsWith: "sleep:rec-del:" } }, + ]); + // ...and soft-deleted by id, then their rollup days recomputed. + const retire = updateManyMock.mock.calls + .map((c) => c[0] as { where: { id?: { in: string[] } }; data: unknown }) + .find((a) => a.where.id); + expect(retire?.where.id?.in).toEqual(["m1", "m2"]); + expect(retire?.data).toEqual({ deletedAt: expect.any(Date) }); + expect(recomputeMock).toHaveBeenCalledWith( + "u1", + "RESTING_HEART_RATE", + stored[1]!.measuredAt, + ); + }); + it("never sweeps when the sleep collection returns no records", async () => { getConnMock.mockResolvedValue(CONN); fetchReadinessMock.mockResolvedValue([ diff --git a/src/lib/oura/client.ts b/src/lib/oura/client.ts index 44b9e9087..876b8d40b 100644 --- a/src/lib/oura/client.ts +++ b/src/lib/oura/client.ts @@ -309,7 +309,11 @@ export interface OuraCollection { export interface OuraSleep { id: string; day: string; - /** `long` | `short` | `nap` | `rest` — disambiguates a nap from the main sleep. */ + /** + * `long_sleep` | `sleep` | `late_nap` | `rest` | `deleted`. `rest` is a + * detected period the person rejected and `deleted` one they removed; see + * `isRetiredOuraSleepPeriod`. + */ type?: string | null; bedtime_start?: string; bedtime_end?: string; @@ -796,13 +800,35 @@ function mapSleepTimeline(s: OuraSleep): MappedMeasurement[] | null { } /** - * Map one Oura sleep record. When the real `sleep_phase_5_min` hypnogram is + * Oura sleep periods that are not sleep. Oura's `type` marks a detected period + * the person turned down in the app's confirm prompt as `rest` ("falsely + * detected sleep / nap, rejected by the user") and one they deleted as + * `deleted`; the sleep collection still returns both. Importing them made an + * afternoon on the sofa a second sleep session on that day, which added its + * minutes to the night and pulled the night's midpoint into the afternoon. + * `sleep`, `long_sleep` and `late_nap` are periods Oura counts, and an unknown + * or missing type stays in, as before. + */ +const RETIRED_OURA_SLEEP_TYPES: ReadonlySet = new Set([ + "rest", + "deleted", +]); + +/** True for an Oura sleep period the person rejected or deleted. */ +export function isRetiredOuraSleepPeriod(s: Pick): boolean { + return typeof s.type === "string" && RETIRED_OURA_SLEEP_TYPES.has(s.type); +} + +/** + * Map one Oura sleep record. A rejected or deleted period + * (`isRetiredOuraSleepPeriod`) maps to nothing. When the real `sleep_phase_5_min` hypnogram is * present we emit timed per-segment `SLEEP_DURATION` rows (Oura's measured * timeline); otherwise we fall back to the four per-stage totals stamped on the * sleep END. Either way we add efficiency, HRV, RHR (`lowest_heart_rate`), and * respiratory rate. */ export function mapSleep(s: OuraSleep, tz?: string): MappedMeasurement[] { + if (isRetiredOuraSleepPeriod(s)) return []; const measuredAt = dayAnchor(s.day, tz, s.bedtime_end); if (!measuredAt) return []; const out: MappedMeasurement[] = []; diff --git a/src/lib/oura/sync.ts b/src/lib/oura/sync.ts index d9d764f53..51a785ef3 100644 --- a/src/lib/oura/sync.ts +++ b/src/lib/oura/sync.ts @@ -52,6 +52,7 @@ import { type InsertedMeasurementArrivalRow, } from "@/lib/arrivals/measurement-emit"; import { invalidateUserMeasurements } from "@/lib/cache/invalidate"; +import { afterMeasurementMutation } from "@/lib/rollups/after-measurement-mutation"; import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate"; import type { SyncWriteResult } from "@/lib/outcome/written-outcome"; import { maybeEnqueueMorningRefresh } from "@/lib/daily/morning-refresh-trigger"; @@ -72,6 +73,7 @@ import { fetchResilience, fetchSleep, fetchVo2Max, + isRetiredOuraSleepPeriod, mapCardiovascularAge, mapDailyActivity, mapDailySleep, @@ -245,6 +247,12 @@ export interface OuraFetchResult { * run-indexed `seg:` row — before the fresh set upserts. */ sleepSweeps: SleepSegmentSweep[]; + /** + * The `sleep::` prefix of every fetched sleep period Oura marks as + * rejected or deleted (`isRetiredOuraSleepPeriod`). Such a period maps to no + * rows, and whatever an earlier sync imported for it is retired. + */ + sleepRetirements: string[]; /** * Collections refused with 401 while a sibling succeeded with the same * token: the token is fine, the grant does not cover them. Not failures; the @@ -335,6 +343,7 @@ async function fetchAll( // upsert. A collection that throws pushes nothing (the fetch is its first // await), so a 401-retry cannot double-enter a record. const sleepSweeps: SleepSegmentSweep[] = []; + const sleepRetirements: string[] = []; const collections: OuraCollectionSpec[] = [ { @@ -354,6 +363,10 @@ async function fetchAll( const records = await fetchSleep(t, query); const ups: OuraMeasurementUpsert[] = []; for (const s of records) { + if (isRetiredOuraSleepPeriod(s)) { + if (s.id) sleepRetirements.push(`sleep:${s.id}:`); + continue; + } const rows = toUpsert( mapSleep(s, tz), "sleep", @@ -480,6 +493,7 @@ async function fetchAll( readings: first.readings, ...splitScopeDenied(first.failures, first.succeeded > 0), sleepSweeps, + sleepRetirements, }; } @@ -492,6 +506,7 @@ async function fetchAll( readings: first.readings, failures: first.failures, sleepSweeps, + sleepRetirements, scopeDenied: null, }; @@ -510,6 +525,7 @@ async function fetchAll( readings: first.readings, failures: first.failures, sleepSweeps, + sleepRetirements, scopeDenied: null, }; @@ -520,9 +536,55 @@ async function fetchAll( // proves the grant as much as one that succeeded after it. ...splitScopeDenied(retry.failures, first.succeeded + retry.succeeded > 0), sleepSweeps, + sleepRetirements, }; } +/** + * Retire what earlier syncs imported for sleep periods Oura now reports as + * rejected or deleted: every live OURA row keyed under the period's + * `sleep::` prefix, whatever its type, because the period's stage + * rows and its nightly scalars (lowest heart rate, HRV, breathing rate, + * efficiency) all came from a period that was not sleep. Soft delete, then the + * shared write tail refolds the touched days and re-warms the touched types. + * Best-effort like the segment sweep: a failure never fails the sync, and the + * record stays inside the lookback window, so the next run retries it. + */ +export async function retireOuraSleepRecords( + userId: string, + prefixes: readonly string[], +): Promise { + // A bare `sleep::` (no record id) would match nothing useful and must never + // widen to the whole source. + const bounded = prefixes.filter((p) => /^sleep:[^:]+:$/.test(p)); + if (bounded.length === 0) return 0; + try { + const rows = await prisma.measurement.findMany({ + where: { + userId, + source: "OURA", + deletedAt: null, + OR: bounded.map((prefix) => ({ externalId: { startsWith: prefix } })), + }, + select: { id: true, type: true, measuredAt: true }, + }); + if (rows.length === 0) return 0; + const res = await prisma.measurement.updateMany({ + where: { userId, id: { in: rows.map((r) => r.id) }, deletedAt: null }, + data: { deletedAt: new Date() }, + }); + await afterMeasurementMutation(userId, rows, "oura"); + invalidateUserMeasurements(userId); + annotate({ meta: { oura_sleep_retired_rows: res.count } }); + return res.count; + } catch (err) { + getEvent()?.addWarning( + `oura: retiring rejected sleep periods failed for ${userId}: ${err}`, + ); + return 0; + } +} + /** * Sync one user's Oura data. * @@ -589,6 +651,7 @@ export async function syncUserOura( // sync; the record stays inside the lookback window, so the next tick // retries it. await sweepStaleSleepSegments(userId, "OURA", result.sleepSweeps); + await retireOuraSleepRecords(userId, result.sleepRetirements); // Import everything the healthy collections returned regardless of whether a // sibling collection failed — one bad collection must not blank the source. diff --git a/src/lib/query-keys/__tests__/picker-list-keys.test.ts b/src/lib/query-keys/__tests__/picker-list-keys.test.ts index ec41804a9..f22bfd8be 100644 --- a/src/lib/query-keys/__tests__/picker-list-keys.test.ts +++ b/src/lib/query-keys/__tests__/picker-list-keys.test.ts @@ -1,11 +1,11 @@ /** * One key, one fetch. The visit form's link pickers read labs and illness - * episodes with their own page size (200 results, 100 episodes), the Labs - * list and the illness hook with theirs (500, the route's default 50). Under - * a shared key whichever read landed first served the other, so the picker - * could offer 50 episodes or the Labs list could render a 200-row page as - * the whole list. The picker reads carry their own keys, still under the - * domain roots so a lab or episode write evicts them. + * episodes in their own windows and pages, the Labs list and the illness hook + * with theirs. Under a shared key whichever read landed first served the + * other, so the picker could offer 50 episodes or the Labs list could render + * a picker page as the whole list. The picker reads carry their own keys, + * still under the domain roots so a lab or episode write evicts them, and + * each direction, split and term is its own key. */ import { describe, expect, it } from "vitest"; @@ -22,20 +22,36 @@ const LIST_PARAMS = { describe("the link pickers' reads have keys of their own", () => { it("labs: differs from the Labs list and sits under the lab root", () => { - const key = queryKeys.labResultsPicker(200); + const key = queryKeys.labResultsPicker("older", null, ""); expect(key).not.toEqual(queryKeys.labResultsList(LIST_PARAMS)); expect(key.slice(0, 1)).toEqual(queryKeys.labResults()); }); it("episodes: differs from the illness list and sits under the illness root", () => { - const key = queryKeys.illnessEpisodesPicker(100); + const key = queryKeys.illnessEpisodesPicker("older", null, ""); expect(key).not.toEqual(queryKeys.illnessEpisodes(true)); expect(key.slice(0, 1)).toEqual(queryKeys.illness()); }); - it("a different page size is a different key", () => { - expect(queryKeys.labResultsPicker(200)).not.toEqual( - queryKeys.labResultsPicker(500), + it("a different direction, split or term is a different key", () => { + const base = queryKeys.labResultsPicker("older", "2019-05-17", ""); + expect(base).not.toEqual( + queryKeys.labResultsPicker("newer", "2019-05-17", ""), + ); + expect(base).not.toEqual( + queryKeys.labResultsPicker("older", "2020-01-01", ""), + ); + expect(queryKeys.labResultsPicker("search", null, "a")).not.toEqual( + queryKeys.labResultsPicker("search", null, "b"), + ); + }); + + it("documents and visits: under their roots", () => { + expect( + queryKeys.inboundDocumentVaultPicker("older", null, "").slice(0, 1), + ).toEqual(["documents"]); + expect(queryKeys.encounterPicker("older", null, "").slice(0, 1)).toEqual( + queryKeys.encounters(), ); }); }); diff --git a/src/lib/query-keys/documents.ts b/src/lib/query-keys/documents.ts index a562672b7..beee7a450 100644 --- a/src/lib/query-keys/documents.ts +++ b/src/lib/query-keys/documents.ts @@ -61,12 +61,24 @@ export const documentKeys = { inboundDocumentPicker: (q: string) => ["documents", "inbound", "picker", q] as const, /** - * The whole vault as a link-picker option list (every page, walked at the - * list route's ceiling), shared by the visit and vaccination forms. Own - * segment because the shape is a flat document array, not a page. + * The vault as a link-picker option list, shared by the visit and + * vaccination forms: one paged direction from the record's date or a + * search. Own segment because the page shape is the picker's, not the + * library's. */ - inboundDocumentVaultPicker: () => - ["documents", "inbound", "vault-picker"] as const, + inboundDocumentVaultPicker: ( + direction: string, + pivot: string | null, + term: string, + ) => + [ + "documents", + "inbound", + "vault-picker", + direction, + pivot ?? "", + term, + ] as const, /** * Storage usage + effective limits (`GET /api/documents/inbound/usage`). * Invalidated through the `["documents"]` prefix after every upload / diff --git a/src/lib/query-keys/encounters.ts b/src/lib/query-keys/encounters.ts index f9c5586ad..84fab90e2 100644 --- a/src/lib/query-keys/encounters.ts +++ b/src/lib/query-keys/encounters.ts @@ -45,6 +45,13 @@ export const encounterKeys = { status ?? null, episodeId ?? null, ] as const, + /** + * The document sheet's visit picker: one paged direction from the + * document's date. Its own key because its page shape (offset steps inside + * a window) is not the list's. + */ + encounterPicker: (direction: string, pivot: string | null, term: string) => + ["encounters", "picker", direction, pivot ?? "", term] as const, encounter: (id: string) => ["encounters", "detail", id] as const, /** * The procedure history for one filter. Under the `["encounters"]` root, so diff --git a/src/lib/query-keys/illness.ts b/src/lib/query-keys/illness.ts index 61e5c2f35..c1f658aef 100644 --- a/src/lib/query-keys/illness.ts +++ b/src/lib/query-keys/illness.ts @@ -15,10 +15,14 @@ export const illnessKeys = { illness: () => ["illness"] as const, illnessEpisodes: (includeResolved: boolean) => ["illness", "episodes", includeResolved] as const, - // The visit form's episode picker, with its own page size (the illness - // pages read the route's default); a shared key would serve one to the other. - illnessEpisodesPicker: (limit: number) => - ["illness", "episodes-picker", limit] as const, + // The visit form's episode picker: one paged direction from the visit's + // date or a search, with its own page shape; a shared key would serve one + // read to the other. + illnessEpisodesPicker: ( + direction: string, + pivot: string | null, + term: string, + ) => ["illness", "episodes-picker", direction, pivot ?? "", term] as const, illnessEpisode: (id: string) => ["illness", "episode", id] as const, illnessDayLog: (episodeId: string, date: string) => ["illness", "day-log", episodeId, date] as const, diff --git a/src/lib/query-keys/labs.ts b/src/lib/query-keys/labs.ts index 45cdf949a..ee585e878 100644 --- a/src/lib/query-keys/labs.ts +++ b/src/lib/query-keys/labs.ts @@ -29,6 +29,11 @@ export const labKeys = { params.sortDir, ] as const, + // The Labs list: one entry per biomarker over the whole history + // (`GET /api/labs/overview`). The `lab-results` prefix lets every result + // mutation's `labResults()` invalidation evict it. + labResultsOverview: () => ["lab-results", "overview"] as const, + // `/labs?analyte=` (MCP deep links): the newest reading stored under // that exact analyte name, one row, to find its marker's page. Its own key // because the page size differs from every list read; the `lab-results` @@ -36,11 +41,12 @@ export const labKeys = { labAnalyteLink: (analyte: string) => ["lab-results", "analyte-link", analyte] as const, - // The visit form's lab picker: newest results, its own page size. Its own - // key because the Labs list reads a different page size, and a shared key - // would serve one read's page to the other. - labResultsPicker: (limit: number) => - ["lab-results", "picker", limit] as const, + // The visit form's lab picker: one paged direction from the visit's date + // (`older` / `newer`) or a search across the whole history. Its own key + // because the Labs list reads other pages, and a shared key would serve + // one read's page to the other. + labResultsPicker: (direction: string, pivot: string | null, term: string) => + ["lab-results", "picker", direction, pivot ?? "", term] as const, // v1.25 — paginated (offset) reading feed for the biomarker detail page. // Distinct from `labResultsList` (single-page key) so the infinite-query diff --git a/src/lib/validations/encounters.ts b/src/lib/validations/encounters.ts index 9955204dd..4d8126699 100644 --- a/src/lib/validations/encounters.ts +++ b/src/lib/validations/encounters.ts @@ -164,6 +164,11 @@ export const encounterListQuerySchema = z */ episodeId: id.optional(), limit: z.coerce.number().int().min(1).max(200).optional(), + /** + * Rows to skip in each arm, for a picker that pages a long history in + * `limit`-sized steps inside a date window. + */ + offset: z.coerce.number().int().min(0).max(100_000).optional(), }) .strict() .refine((q) => !(q.from && q.to) || q.from <= q.to, { diff --git a/src/lib/validations/illness.ts b/src/lib/validations/illness.ts index b96933709..afc5e5a83 100644 --- a/src/lib/validations/illness.ts +++ b/src/lib/validations/illness.ts @@ -136,6 +136,17 @@ export type IllnessEpisodeResolve = z.infer; export const illnessEpisodeListQuerySchema = z.object({ limit: z.coerce.number().int().min(1).max(100).optional(), includeResolved: z.union([z.literal("true"), z.literal("false")]).optional(), + /** + * Paging and a date window over the onset, for a picker that opens on the + * episodes around a visit and pages older or newer from there. + */ + offset: z.coerce.number().int().min(0).max(100_000).optional(), + from: z.iso.datetime({ offset: true }).optional(), + to: z.iso.datetime({ offset: true }).optional(), + /** Onset order; newest first by default. */ + sortDir: z.enum(["asc", "desc"]).optional(), + /** Case-insensitive substring over the episode's label. */ + q: z.string().trim().min(1).max(120).optional(), }); /* ── day-log capture (one row per day of an episode) ─────────────── */ diff --git a/src/lib/validations/labs.ts b/src/lib/validations/labs.ts index d7c2ea3c3..834ef6721 100644 --- a/src/lib/validations/labs.ts +++ b/src/lib/validations/labs.ts @@ -284,6 +284,11 @@ export const listLabResultsSchema = z.object({ biomarkerId: z.string().trim().min(1).max(64).optional(), analyte: z.string().trim().min(1).max(120).optional(), panel: z.string().trim().min(1).max(120).optional(), + /** + * Case-insensitive substring over the analyte and the panel name, so a + * picker can find one result in a long history without paging to it. + */ + q: z.string().trim().min(1).max(120).optional(), from: z .string() .datetime() @@ -301,6 +306,13 @@ export const listLabResultsSchema = z.object({ export type ListLabResultsInput = z.infer; +/** + * The per-marker overview takes no parameters: it always covers the whole + * live history. Strict, so a stray `limit` is refused rather than silently + * ignored by a caller expecting it to cap something. + */ +export const labOverviewQuerySchema = z.object({}).strict(); + /** * Reference-range classification lives in `@/lib/labs/reference-range` so the * API response, the doctor-report PDF, and the lab list card all share one diff --git a/tests/integration/google-health-backfill-multi-year.test.ts b/tests/integration/google-health-backfill-multi-year.test.ts new file mode 100644 index 000000000..da6915dcc --- /dev/null +++ b/tests/integration/google-health-backfill-multi-year.test.ts @@ -0,0 +1,561 @@ +/** + * #1220 — a Google Health history backfill over three years of heart rate, + * driven through the admission drain slice by slice until it completes, + * against Postgres. + * + * What went wrong. A backfill noted its progress only once a whole collection + * was walked to the end. Three years of heart rate at one reading a minute is + * some 1 600 pages, and an attempt that did not get through all of them (its + * budget, a rate limit it could not wait out, one failed request among + * thousands) started the next attempt from the newest page again. The account + * never got past its first unfinished collection, every attempt failed with + * "marker not stamped", and none of them left a line saying where it stopped, + * because the drain ran the import outside any log event. + * + * What this pins, end to end: + * - the backfill completes, through as many slices as the history needs, + * and every slice after the first starts where the one before it stopped; + * - every reading is imported exactly once: the rows match the history, the + * slices' import counts add up to it, and no row was ever rewritten; + * - a server error, a lost request and a rate limit Google will not lift + * for an hour each cost a retry or a slice boundary, never the walk; + * - the hourly sync runs between slices (and beside one, by waiting for + * it) instead of being skipped for as long as the import runs; + * - every slice writes a log event naming its requests and where it got to; + * - the heap does not grow with the history (run it under the production + * ceiling: `NODE_OPTIONS=--max-old-space-size=524`); + * - and, in a second run over four years, a Google that refuses the resume + * edge's upper bound (`<=`): the backfill falls back to reading without + * it, notes the refusal so it is met only once, keeps its place in the + * unbounded list from slice to slice so the pages it reads stay linear in + * the history (before, every slice passed over everything the slices + * before it wrote), and still imports every reading exactly once. + * + * Google is replaced at the `safeFetch` seam by a generator that builds each + * page on request from the filter and page token it is asked with, so the + * fixture never sits in memory. + */ +import v8 from "node:v8"; +import vm from "node:vm"; + +import type { Job } from "pg-boss"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; + +import type { WideEvent } from "@/lib/logging/types"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +const USER = "gh-multi-year-owner"; +const MINUTE = 60_000; +/** The newest reading: now, so the hourly sync's window lands on real data. */ +const NOW = Math.floor(Date.now() / MINUTE) * MINUTE; +/** Three years of heart rate, one reading a minute. */ +const POINTS = 3 * 365 * 24 * 60; + +/** + * How much the collected heap may grow over the second half of the history: + * from the first sample taken inside a page request once the second slice is + * over, to the most any later sample reaches. Every sample is taken mid-walk, + * so each carries the same in-flight working set (one page, the ORM's and the + * driver's buffers, some 35 MB here) and only growth is compared. The first + * two slices are left out because the first hourly sync and the first + * rate-limited slice load code paths once. Measured, not chosen: the line + * stays within half a MB of flat. A backfill that kept what it read would + * grow by hundreds of MB over the same stretch. + */ +const GROWTH_BUDGET_BYTES = 16 * 1024 * 1024; + +const fake = vi.hoisted(() => ({ + now: 0, + total: 0, + /** Heart-rate requests seen, the backfill's among them, and readings served. */ + heartRateRequests: 0, + backfillRequests: 0, + servedPoints: 0, + /** Faults to inject, keyed by the backfill request number they hit. */ + faults: new Map(), + onRequest: null as (() => void) | null, + /** Refuse any filter with an upper bound, and count the refusals. */ + refuseUpperBound: false, + refused: 0, + /** Backfill requests that carried an upper bound. */ + upperBoundRequests: 0, +})); + +vi.mock("@/lib/safe-fetch", async (importOriginal) => { + const actual = await importOriginal(); + const respond = (body: unknown, status = 200, headers = {}) => + new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json", ...headers }, + }); + return { + ...actual, + // A plain function, not `vi.fn`: a mock records every result, and here + // each result is a page of a thousand readings. + safeFetch: async (url: string) => { + const u = new URL(url); + if (!u.pathname.endsWith("/dataTypes/heart-rate/dataPoints")) { + // Every other collection is empty for this account. + return respond({}); + } + fake.heartRateRequests += 1; + fake.onRequest?.(); + // The hourly sync always asks from a lower bound; the backfill never. + const filter = u.searchParams.get("filter") ?? ""; + const fromBackfill = !filter.includes(">="); + if (fromBackfill) fake.backfillRequests += 1; + if (filter.includes("<=")) { + fake.upperBoundRequests += 1; + if (fake.refuseUpperBound) { + fake.refused += 1; + return respond( + { + error: { + code: 400, + status: "INVALID_ARGUMENT", + message: "Invalid filter expression.", + }, + }, + 400, + ); + } + } + const fault = fromBackfill + ? fake.faults.get(fake.backfillRequests) + : undefined; + if (fault) { + fake.faults.delete(fake.backfillRequests); + if (fault === "timeout") { + throw new actual.SafeFetchError("safeFetch aborted", "timeout"); + } + if (fault === "503") { + return respond({ error: { code: 503, status: "UNAVAILABLE" } }, 503); + } + return respond( + { error: { code: 429, status: "RESOURCE_EXHAUSTED" } }, + 429, + { "retry-after": "3600" }, + ); + } + + // Newest first, one reading a minute: reading `g` is `g` minutes old. + // The hourly sync asks from a lower bound (`>=`), a resumed backfill + // up to its edge (`<=`). + const from = /physical_time >= "([^"]+)"/.exec(filter)?.[1]; + const upTo = /physical_time <= "([^"]+)"/.exec(filter)?.[1]; + const newest = upTo + ? Math.max(0, Math.ceil((fake.now - Date.parse(upTo)) / MINUTE)) + : 0; + const oldest = from + ? Math.min( + fake.total - 1, + Math.floor((fake.now - Date.parse(from)) / MINUTE), + ) + : fake.total - 1; + const pageSize = Number(u.searchParams.get("pageSize") ?? 1000); + const page = Number(u.searchParams.get("pageToken") ?? 0); + const start = newest + page * pageSize; + const end = Math.min(oldest + 1, start + pageSize); + const dataPoints = []; + for (let g = start; g < end; g++) { + dataPoints.push({ + name: `users/me/dataTypes/heart-rate/dataPoints/${7_000_000_000 + g}`, + dataSource: { platform: "FITBIT" }, + heartRate: { + beatsPerMinute: String(52 + (g % 61)), + sampleTime: { + physicalTime: new Date(fake.now - g * MINUTE).toISOString(), + }, + }, + }); + } + fake.servedPoints += dataPoints.length; + return respond({ + dataPoints, + ...(end <= oldest ? { nextPageToken: String(page + 1) } : {}), + }); + }, + }; +}); + +/** Jobs the backfill and the hourly sync queue, recorded. */ +const sent = vi.hoisted(() => ({ + jobs: [] as Array<{ queue: string; data: unknown }>, +})); +vi.mock("@/lib/jobs/boss-instance", async (importOriginal) => ({ + ...(await importOriginal()), + getGlobalBoss: () => ({ + send: async (queue: string, data: unknown) => { + sent.jobs.push({ queue, data }); + return `job-${sent.jobs.length}`; + }, + }), +})); + +/** The log events the runs write, recorded instead of printed. */ +const logged = vi.hoisted(() => ({ events: [] as WideEvent[] })); +vi.mock("@/lib/logging/transports", async (importOriginal) => ({ + ...(await importOriginal()), + emitIfSampled: (event: WideEvent) => { + logged.events.push(event); + }, +})); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +const forceGc = ((): (() => void) => { + v8.setFlagsFromString("--expose-gc"); + const gc = vm.runInNewContext("gc") as () => void; + v8.setFlagsFromString("--no-expose-gc"); + return gc; +})(); + +function liveHeapBytes(): number { + forceGc(); + forceGc(); + return process.memoryUsage().heapUsed; +} + +const mb = (bytes: number): string => (bytes / 1024 / 1024).toFixed(1); + +function report(line: string): void { + process.stderr.write(`[gh-multi-year] ${line}\n`); +} + +/** A fresh account with `points` minutes of heart rate behind it. */ +async function seed(points: number): Promise { + const prisma = getPrismaClient(); + await truncateAllTables(prisma); + const { encrypt } = await import("@/lib/crypto"); + await prisma.user.create({ + data: { id: USER, username: USER, timezone: "Europe/Berlin" }, + }); + await prisma.googleHealthConnection.create({ + data: { + userId: USER, + googleUserId: `google-${USER}`, + accessToken: encrypt("access"), + refreshToken: encrypt("refresh"), + tokenExpiresAt: new Date(Date.now() + 7 * 86_400_000), + }, + }); + fake.now = NOW; + fake.total = points; + fake.heartRateRequests = 0; + fake.backfillRequests = 0; + fake.servedPoints = 0; + fake.faults.clear(); + fake.onRequest = null; + fake.refuseUpperBound = false; + fake.refused = 0; + fake.upperBoundRequests = 0; +} + +beforeAll(async () => { + await seed(POINTS); +}, 120_000); + +afterAll(async () => { + await truncateAllTables(getPrismaClient()); +}); + +type AdmissionPayload = + import("@/lib/jobs/integration-backfill-admission").IntegrationBackfillAdmissionPayload; + +function admissionJob( + payload: AdmissionPayload, + n: number, +): Job { + return { + id: `admission-${n}`, + name: "integration-backfill-admission", + data: payload, + // The production expiry: sixteen hours. + expireInSeconds: 16 * 60 * 60, + } as unknown as Job; +} + +function nextSlice(): AdmissionPayload | null { + const next = sent.jobs.find( + (j) => j.queue === "integration-backfill-admission", + ); + return next ? (next.data as AdmissionPayload) : null; +} + +describe("Google Health backfill over three years of heart rate (#1220)", () => { + it("completes slice by slice, imports every reading once, and keeps the hourly sync running", async () => { + const { drainIntegrationBackfillAdmission } = + await import("@/lib/jobs/integration-backfill-drain"); + const { handleGoogleHealthSync } = + await import("@/lib/jobs/reminder/google-health-sync"); + const { GOOGLE_HEALTH_TRANSIENT_RETRY } = + await import("@/lib/google-health/client"); + const { GOOGLE_HEALTH_BACKFILL_SLICE_PAGES } = + await import("@/lib/jobs/google-health-backfill"); + GOOGLE_HEALTH_TRANSIENT_RETRY.baseDelayMs = 0; + + // A server error and a lost request in the first slice, and a rate limit + // Google will not lift for an hour in the second. + fake.faults.set(40, "503"); + fake.faults.set(80, "timeout"); + fake.faults.set(GOOGLE_HEALTH_BACKFILL_SLICE_PAGES + 120, "429-long"); + + // Heap samples from inside the page requests, between two pages of the + // work being measured, once the second slice is over (see the budget). + let measuring = false; + let anchor: number | null = null; + let peak = 0; + let samples = 0; + fake.onRequest = () => { + if (!measuring || fake.heartRateRequests % 50 !== 0) return; + const live = liveHeapBytes(); + if (anchor === null) { + anchor = peak = live; + return; + } + samples++; + peak = Math.max(peak, live); + }; + + const prisma = getPrismaClient(); + let payload: AdmissionPayload | null = { + kind: "google-health-backfill", + data: { userId: USER, enqueuedAt: new Date().toISOString() }, + }; + const slices: Array<{ + slice: number; + imported: number; + continued: number; + }> = []; + let hourlyBeside: Promise | null = null; + + for (let n = 0; payload && n < 40; n++) { + sent.jobs = []; + logged.events = []; + const drained = drainIntegrationBackfillAdmission([ + admissionJob(payload, n), + ]); + // Once, start the hourly sync while a slice is running: it waits for + // the slice instead of skipping the account. + if (n === 2) { + hourlyBeside = handleGoogleHealthSync([] as never); + } + const outcome = await drained; + expect(outcome.ok).toBe(true); + const did = (outcome as { did: Record }).did; + slices.push({ + slice: payload.data.slice ?? 0, + imported: did.imported, + continued: did.continued, + }); + + // The slice wrote one log event, with its requests and where it got to. + const runEvents = logged.events.filter( + (e) => e.background?.task_name === "job.integration_backfill", + ); + expect(runEvents).toHaveLength(1); + const sliceMeta = runEvents[0]!.meta?.["googleHealth.backfill.slice"] as + { pages: number; stopReason: string | null; edges: object } | undefined; + expect(sliceMeta).toBeDefined(); + expect( + runEvents[0]!.external_calls?.some((c) => + c.method.startsWith("fetchHeartRate(page="), + ), + ).toBe(true); + report( + `slice ${payload.data.slice ?? 0}: pages=${sliceMeta!.pages} ` + + `stop=${sliceMeta!.stopReason} imported=${did.imported} ` + + `edges=${JSON.stringify(sliceMeta!.edges)}`, + ); + + if (n === 1) measuring = true; + payload = nextSlice(); + + // Between slices the account is free, and the hourly sync runs. + if (payload) { + const hourly = await handleGoogleHealthSync([] as never); + expect(hourly).toMatchObject({ + ok: true, + did: { users_skipped: 0, users_failed: 0, users_parked: 0 }, + }); + } + } + + expect(await hourlyBeside).toMatchObject({ + ok: true, + did: { users_skipped: 0 }, + }); + + // Completed, and the note of how far it got is gone with it. + const connection = await prisma.googleHealthConnection.findUniqueOrThrow({ + where: { userId: USER }, + select: { backfillCompletedAt: true, backfillProgress: true }, + }); + expect(connection.backfillCompletedAt).toBeInstanceOf(Date); + expect(connection.backfillProgress).toBeNull(); + expect(payload).toBeNull(); + + // More than one slice, every one before the last continued, and the + // rate-limited one among them. + expect(slices.length).toBeGreaterThan(2); + expect(slices.slice(0, -1).every((s) => s.continued === 1)).toBe(true); + expect(fake.faults.size).toBe(0); + expect(slices.map((s) => s.slice)).toEqual(slices.map((_, i) => i)); + + // Every reading exactly once. + const rows = await prisma.measurement.count({ + where: { userId: USER, type: "PULSE", source: "GOOGLE_HEALTH" }, + }); + expect(rows).toBe(POINTS); + expect(slices.reduce((sum, s) => sum + s.imported, 0)).toBe(POINTS); + const versions = await prisma.measurement.aggregate({ + where: { userId: USER, type: "PULSE", source: "GOOGLE_HEALTH" }, + _max: { syncVersion: true }, + _min: { syncVersion: true }, + }); + expect(versions._max.syncVersion).toBe(versions._min.syncVersion); + // Each slice re-reads only the reading on its edge (plus the hourly + // sync's own window), not the history before it. + report( + `requests=${fake.heartRateRequests} served=${fake.servedPoints} ` + + `points=${POINTS} slices=${slices.length}`, + ); + + // The heap did not grow with the history. + const growth = peak - (anchor ?? 0); + report( + `heap growth after the second slice: ${mb(growth)} MB over ${samples} ` + + `samples (budget ${mb(GROWTH_BUDGET_BYTES)} MB, ceiling ` + + `${mb(v8.getHeapStatistics().heap_size_limit)} MB, peak ${mb(peak)} MB)`, + ); + expect(samples).toBeGreaterThan(5); + expect(growth).toBeLessThan(GROWTH_BUDGET_BYTES); + }, 3_600_000); + + it("completes when Google refuses the resume edge's upper bound, meeting the refusal once", async () => { + // Four years: the first slice reads without an edge, the second resumes + // with the bound and is refused, every later one reads without it. Long + // enough that passing over everything already written on every slice + // would read the history several times over. + const points = 4 * 365 * 24 * 60; + await seed(points); + fake.refuseUpperBound = true; + + const { drainIntegrationBackfillAdmission } = + await import("@/lib/jobs/integration-backfill-drain"); + const { handleGoogleHealthSync } = + await import("@/lib/jobs/reminder/google-health-sync"); + const { GOOGLE_HEALTH_TRANSIENT_RETRY } = + await import("@/lib/google-health/client"); + const { GOOGLE_HEALTH_BACKFILL_SLICE_PAGES } = + await import("@/lib/jobs/google-health-backfill"); + GOOGLE_HEALTH_TRANSIENT_RETRY.baseDelayMs = 0; + + const prisma = getPrismaClient(); + let payload: AdmissionPayload | null = { + kind: "google-health-backfill", + data: { userId: USER, enqueuedAt: new Date().toISOString() }, + }; + let hourlyRuns = 0; + const slices: Array<{ + imported: number; + continued: number; + skippedPages: number; + refusalNoted: unknown; + }> = []; + + for (let n = 0; payload && n < 40; n++) { + sent.jobs = []; + logged.events = []; + const outcome = await drainIntegrationBackfillAdmission([ + admissionJob(payload, n), + ]); + expect(outcome.ok).toBe(true); + const did = (outcome as { did: Record }).did; + const runEvents = logged.events.filter( + (e) => e.background?.task_name === "job.integration_backfill", + ); + expect(runEvents).toHaveLength(1); + const meta = runEvents[0]!.meta ?? {}; + const sliceMeta = meta["googleHealth.backfill.slice"] as { + pages: number; + skippedPages: number; + stopReason: string | null; + }; + slices.push({ + imported: did.imported, + continued: did.continued, + skippedPages: sliceMeta.skippedPages, + refusalNoted: meta["googleHealth.backfill.edgeFilterUnsupported"], + }); + report( + `refusing slice ${payload.data.slice ?? 0}: pages=${sliceMeta.pages} ` + + `skipped=${sliceMeta.skippedPages} stop=${sliceMeta.stopReason} ` + + `imported=${did.imported}`, + ); + payload = nextSlice(); + if (payload) { + const hourly = await handleGoogleHealthSync([] as never); + expect(hourly).toMatchObject({ + ok: true, + did: { users_skipped: 0, users_failed: 0, users_parked: 0 }, + }); + hourlyRuns += 1; + } + } + + const connection = await prisma.googleHealthConnection.findUniqueOrThrow({ + where: { userId: USER }, + select: { backfillCompletedAt: true, backfillProgress: true }, + }); + expect(connection.backfillCompletedAt).toBeInstanceOf(Date); + expect(connection.backfillProgress).toBeNull(); + expect(payload).toBeNull(); + expect(slices.slice(0, -1).every((s) => s.continued === 1)).toBe(true); + // The hourly sync ran between every two slices. + expect(slices.length).toBeGreaterThan(4); + expect(hourlyRuns).toBe(slices.length - 1); + + // The pages read stay linear in the history: every page once, the one + // pass over what the first slice wrote when the refusal was met, and a + // page or two per slice where it picks up again. Passing over everything + // already written on every slice read more than three times as many. + const historyPages = Math.ceil(points / 1000); + report( + `refusing run: backfill requests=${fake.backfillRequests} ` + + `history pages=${historyPages} slices=${slices.length}`, + ); + expect(fake.backfillRequests).toBeLessThanOrEqual( + historyPages + GOOGLE_HEALTH_BACKFILL_SLICE_PAGES + 2 * slices.length, + ); + + // The bound was asked for once, refused once, and never again: the + // refusal was noted on the slice that met it and honoured after. + expect(fake.upperBoundRequests).toBe(1); + expect(fake.refused).toBe(1); + expect(slices.filter((s) => s.refusalNoted !== undefined)).toEqual([ + expect.objectContaining({ refusalNoted: ["fetchHeartRate"] }), + ]); + // Only the slice that met the refusal passed over what the slice before + // it wrote, and it still wrote its share; every later one went on from + // where the last stopped. + const skipping = slices.filter((s) => s.skippedPages > 0); + expect(skipping).toHaveLength(1); + expect(skipping.every((s) => s.imported > 0)).toBe(true); + + // Every reading exactly once, none of them rewritten. + const rows = await prisma.measurement.count({ + where: { userId: USER, type: "PULSE", source: "GOOGLE_HEALTH" }, + }); + expect(rows).toBe(points); + expect(slices.reduce((sum, s) => sum + s.imported, 0)).toBe(points); + const versions = await prisma.measurement.aggregate({ + where: { userId: USER, type: "PULSE", source: "GOOGLE_HEALTH" }, + _max: { syncVersion: true }, + _min: { syncVersion: true }, + }); + expect(versions._max.syncVersion).toBe(versions._min.syncVersion); + }, 3_600_000); +}); diff --git a/tests/integration/google-health-backfill-rate-limit.test.ts b/tests/integration/google-health-backfill-rate-limit.test.ts index 2cf7bd9a1..3ddb049fa 100644 --- a/tests/integration/google-health-backfill-rate-limit.test.ts +++ b/tests/integration/google-health-backfill-rate-limit.test.ts @@ -57,6 +57,8 @@ const fake = vi.hoisted(() => ({ throttledPages: new Set(), /** Revoke the current access token when this heart-rate page is asked. */ revokeAtPage: -1, + /** Answer every heart-rate request with a 500. */ + failHeartRate: false, /** Hold the request for this heart-rate page until `release` is called. */ holdAtPage: -1, release: null as null | (() => void), @@ -138,13 +140,24 @@ vi.mock("@/lib/safe-fetch", async (importOriginal) => { { "retry-after": "0" }, ); } + if (fake.failHeartRate) { + return json({ error: { code: 500, status: "INTERNAL" } }, 500); + } if (page === fake.holdAtPage) { fake.holdAtPage = -1; await fake.held; } - const end = Math.min(fake.heartRatePoints, (page + 1) * 10); + // Newest first, one point a minute; a resumed walk's `<=` edge + // filter starts the list at the edge. + const edge = /physical_time <= "([^"]+)"/.exec( + u.searchParams.get("filter") ?? "", + )?.[1]; + const first = edge + ? Math.ceil((fake.now - Date.parse(edge)) / 60_000) + : 0; + const end = Math.min(fake.heartRatePoints, first + (page + 1) * 10); const dataPoints = []; - for (let g = page * 10; g < end; g++) { + for (let g = first + page * 10; g < end; g++) { dataPoints.push({ name: `users/me/dataTypes/heart-rate/dataPoints/${1_000_000 + g}`, heartRate: { @@ -221,6 +234,7 @@ beforeEach(async () => { fake.throttledPages = new Set(); fake.revokeAtPage = -1; fake.holdAtPage = -1; + fake.failHeartRate = false; fake.release = null; fake.held = null; fake.requestsByPath = new Map(); @@ -398,8 +412,19 @@ describe("Google Health backfill under rate limits and token expiry (#1194)", () ); expect(done).not.toContain("fetchHeartRate"); + // And the heart-rate stream keeps the edge it reached: the oldest of + // the thirty samples it wrote. + expect( + (afterFirst?.backfillProgress as { cursors: Record }) + .cursors.fetchHeartRate, + ).toMatchObject({ + at: new Date(fake.now - 29 * 60_000).toISOString(), + pages: 3, + }); + // Second attempt, the queued slice: the finished collections are not - // read again. + // read again, and the stream starts at its edge, not at its newest page: + // the 31 samples from the edge on are four pages, not six. fake.requestsByPath = new Map(); await runGoogleHealthBackfillForUser(USER, undefined, 1); @@ -408,7 +433,7 @@ describe("Google Health backfill under rate limits and token expiry (#1194)", () expect( fake.requestsByPath.get("steps/dataPoints:dailyRollUp"), ).toBeUndefined(); - expect(fake.requestsByPath.get("heart-rate/dataPoints")).toBe(6); + expect(fake.requestsByPath.get("heart-rate/dataPoints")).toBe(4); expect(await pulseRows()).toBe(60); const afterSecond = await getPrismaClient().googleHealthConnection.findUnique({ @@ -418,7 +443,7 @@ describe("Google Health backfill under rate limits and token expiry (#1194)", () expect(afterSecond?.backfillProgress).toBeNull(); }); - it("keeps failing a backfill whose slice finished nothing new", async () => { + it("continues a slice that only moved the stream's edge, and fails one that moved nothing", async () => { const { runGoogleHealthBackfillForUser } = await import("@/lib/jobs/google-health-backfill"); @@ -428,13 +453,29 @@ describe("Google Health backfill under rate limits and token expiry (#1194)", () await runGoogleHealthBackfillForUser(USER, stopAtThirdPage); expect(sent.jobs).toHaveLength(1); - // The next slice cannot get through the heart-rate stream either: no - // collection finishes, so this one is stuck and says so. + // The next slice finishes no collection either, but it moves the edge + // thirty samples further back: progress, so it continues. fake.requestsByPath = new Map(); await expect( runGoogleHealthBackfillForUser(USER, stopAtThirdPage, 1), - ).rejects.toThrow(/incomplete/); - expect(sent.jobs).toHaveLength(1); + ).resolves.toMatchObject({ continued: true }); + expect(sent.jobs).toHaveLength(2); + + // A slice whose every heart-rate request fails moves nothing: it is + // stuck, says where, and queues nothing. + const { GOOGLE_HEALTH_TRANSIENT_RETRY } = + await import("@/lib/google-health/client"); + const baseDelay = GOOGLE_HEALTH_TRANSIENT_RETRY.baseDelayMs; + GOOGLE_HEALTH_TRANSIENT_RETRY.baseDelayMs = 0; + fake.failHeartRate = true; + try { + await expect( + runGoogleHealthBackfillForUser(USER, undefined, 2), + ).rejects.toThrow(/no progress in slice 2; fetchHeartRate:failed/); + } finally { + GOOGLE_HEALTH_TRANSIENT_RETRY.baseDelayMs = baseDelay; + } + expect(sent.jobs).toHaveLength(2); const row = await getPrismaClient().googleHealthConnection.findUnique({ where: { userId: USER }, }); @@ -459,27 +500,32 @@ describe("Google Health backfill under rate limits and token expiry (#1194)", () } expect(fake.holdAtPage).toBe(-1); - const requestsBefore = [...fake.requestsByPath.values()].reduce( - (a, b) => a + b, - 0, - ); + const total = () => + [...fake.requestsByPath.values()].reduce((a, b) => a + b, 0); + const requestsBefore = total(); const manual = await syncUserGoogleHealth(USER); expect(manual.busy).toBe(true); - const hourly = await handleGoogleHealthSync([] as never); - expect(hourly).toMatchObject({ - ok: true, - did: { users_skipped: 1, users_parked: 0, users_failed: 0 }, + // The hourly poll waits for the account instead of skipping it: it does + // nothing while the backfill holds the lock ... + let hourlySettled = false; + const hourly = handleGoogleHealthSync([] as never).finally(() => { + hourlySettled = true; }); - const requestsAfter = [...fake.requestsByPath.values()].reduce( - (a, b) => a + b, - 0, - ); - expect(requestsAfter).toBe(requestsBefore); + await new Promise((r) => setTimeout(r, 2_500)); + expect(hourlySettled).toBe(false); + expect(total()).toBe(requestsBefore); + // ... and runs once the backfill lets go. fake.release?.(); await backfill; + const requestsAfterBackfill = total(); + expect(await hourly).toMatchObject({ + ok: true, + did: { users_skipped: 0, users_parked: 0, users_failed: 0 }, + }); + expect(total()).toBeGreaterThan(requestsAfterBackfill); expect(await pulseRows()).toBe(60); expect(await ledgerState()).toBe("connected"); - }); + }, 60_000); }); diff --git a/tests/integration/google-health-sync-progress.test.ts b/tests/integration/google-health-sync-progress.test.ts index 0a80f416c..45f857cd6 100644 --- a/tests/integration/google-health-sync-progress.test.ts +++ b/tests/integration/google-health-sync-progress.test.ts @@ -42,7 +42,9 @@ async function seedConnection(userId: string): Promise { googleUserId: `google-${userId}`, accessToken: "encrypted-access", refreshToken: "encrypted-refresh", - tokenExpiresAt: new Date("2027-01-01T00:00:00.000Z"), + // Valid for a year from whenever the suite runs: a fixed date would + // expire and send the sync down the refresh path. + tokenExpiresAt: new Date(Date.now() + 365 * 86_400_000), }, }); } diff --git a/tests/integration/healthkit-type-sync-migration.test.ts b/tests/integration/healthkit-type-sync-migration.test.ts new file mode 100644 index 000000000..66e7638d3 --- /dev/null +++ b/tests/integration/healthkit-type-sync-migration.test.ts @@ -0,0 +1,189 @@ +/** + * Migration 0401 over a ledger that already holds enum-typed rows. + * + * `healthkit_type_syncs.type` moves from the `measurement_type` enum to text + * so the ledger can hold `MEDICATION_DOSE`, and `last_background_at` arrives + * beside it. The suite has already applied every migration, so this puts the + * table back into its 0384 shape, seeds rows the way production holds them, + * applies the 0401 file as shipped, and checks that every row kept its key and + * its dates, that a row whose latest arrival was a background one starts with + * that date, that the primary key still refuses a second row for the same + * (user, type), and that the writer can now record a dose. + */ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +import { getPrismaClient, truncateAllTables } from "./setup"; +import { recordHealthKitTypeArrivals } from "@/lib/integrations/healthkit-type-sync"; + +const prisma = getPrismaClient(); +const USER = "healthkit-type-sync-migration"; + +const MIGRATION = readFileSync( + resolve( + process.cwd(), + "prisma", + "migrations", + "0401_healthkit_type_sync_text_and_background", + "migration.sql", + ), + "utf8", +); + +function steps(): string[] { + return MIGRATION.split("\n") + .filter((line) => !line.trimStart().startsWith("--")) + .join("\n") + .split(";") + .map((s) => s.trim()) + .filter((s) => s.length > 0); +} + +async function runMigration(): Promise { + await prisma.$transaction(async (tx) => { + for (const step of steps()) await tx.$executeRawUnsafe(step); + }); +} + +async function typeColumn(): Promise<{ data_type: string; udt_name: string }> { + const rows = await prisma.$queryRaw< + Array<{ data_type: string; udt_name: string }> + >` + SELECT data_type, udt_name FROM information_schema.columns + WHERE table_name = 'healthkit_type_syncs' AND column_name = 'type' + `; + return rows[0]; +} + +const RECEIVED = { + WEIGHT: "2026-09-01 06:00:00", + PULSE: "2026-09-02 07:30:00", + RESPIRATORY_RATE: "2026-09-03 08:45:00", +} as const; + +type Row = { + type: string; + last_received_at: Date; + last_trigger: string | null; + last_new_sample_at: Date | null; + last_background_at: Date | null; +}; + +const readRows = () => + prisma.$queryRaw` + SELECT "type", "last_received_at", "last_trigger", "last_new_sample_at", + "last_background_at" + FROM "healthkit_type_syncs" WHERE "user_id" = ${USER} ORDER BY "type" + `; + +beforeAll(async () => { + await truncateAllTables(prisma); + await prisma.user.create({ + data: { id: USER, username: USER, timezone: "UTC" }, + }); + // Back to the 0384 shape: the enum column, no background date. + await prisma.$executeRawUnsafe( + `ALTER TABLE "healthkit_type_syncs" DROP COLUMN "last_background_at"`, + ); + await prisma.$executeRawUnsafe( + `ALTER TABLE "healthkit_type_syncs" ALTER COLUMN "type" SET DATA TYPE "measurement_type" USING "type"::"measurement_type"`, + ); + await prisma.$executeRawUnsafe(` + INSERT INTO "healthkit_type_syncs" + ("user_id", "type", "last_received_at", "last_trigger", "last_new_sample_at") + VALUES + ('${USER}', 'WEIGHT', '${RECEIVED.WEIGHT}', 'background', '${RECEIVED.WEIGHT}'), + ('${USER}', 'PULSE', '${RECEIVED.PULSE}', 'foreground', NULL), + ('${USER}', 'RESPIRATORY_RATE', '${RECEIVED.RESPIRATORY_RATE}', NULL, '${RECEIVED.RESPIRATORY_RATE}') + `); +}); + +afterAll(async () => { + // Leave the table in the shipped shape for the files that run after this one, + // whatever happened above. + const column = await typeColumn(); + if (column.udt_name === "measurement_type") await runMigration(); +}); + +describe("migration 0401 over enum-typed ledger rows", () => { + it("starts from the enum column", async () => { + expect((await typeColumn()).udt_name).toBe("measurement_type"); + }); + + it("keeps every row, its key and its dates, and seeds the background date", async () => { + await runMigration(); + + expect(await typeColumn()).toEqual({ data_type: "text", udt_name: "text" }); + const rows = await readRows(); + expect(rows.map((r) => r.type)).toEqual([ + "PULSE", + "RESPIRATORY_RATE", + "WEIGHT", + ]); + const byType = Object.fromEntries(rows.map((r) => [r.type, r])); + for (const [type, at] of Object.entries(RECEIVED)) { + expect(byType[type].last_received_at.toISOString()).toBe( + `${at.replace(" ", "T")}.000Z`, + ); + } + expect(byType.WEIGHT.last_trigger).toBe("background"); + expect(byType.WEIGHT.last_new_sample_at?.toISOString()).toBe( + "2026-09-01T06:00:00.000Z", + ); + expect(byType.PULSE.last_new_sample_at).toBeNull(); + // Only a row whose latest arrival was a background one knows a date. + expect(byType.WEIGHT.last_background_at?.toISOString()).toBe( + "2026-09-01T06:00:00.000Z", + ); + expect(byType.PULSE.last_background_at).toBeNull(); + expect(byType.RESPIRATORY_RATE.last_background_at).toBeNull(); + }); + + it("keeps the primary key on (user, type)", async () => { + const pkey = await prisma.$queryRaw>` + SELECT indexdef FROM pg_indexes + WHERE tablename = 'healthkit_type_syncs' + AND indexname = 'healthkit_type_syncs_pkey' + `; + expect(pkey[0]?.indexdef).toContain("(user_id, type)"); + await expect( + prisma.$executeRawUnsafe( + `INSERT INTO "healthkit_type_syncs" ("user_id", "type", "last_received_at") VALUES ('${USER}', 'WEIGHT', now())`, + ), + ).rejects.toThrow(); + }); + + it("lets the writer upsert a measurement type and record a dose", async () => { + const at = new Date("2026-10-01T09:00:00.000Z"); + await recordHealthKitTypeArrivals(prisma, { + userId: USER, + counts: new Map([ + ["WEIGHT", { accepted: 1, newSamples: 0 }], + ["MEDICATION_DOSE", { accepted: 2, newSamples: 2 }], + ]), + trigger: "foreground", + at, + }); + const rows = await prisma.healthKitTypeSync.findMany({ + where: { userId: USER }, + orderBy: { type: "asc" }, + }); + expect(rows.map((r) => r.type)).toEqual([ + "MEDICATION_DOSE", + "PULSE", + "RESPIRATORY_RATE", + "WEIGHT", + ]); + const weight = rows.find((r) => r.type === "WEIGHT")!; + expect(weight.lastReceivedAt.toISOString()).toBe(at.toISOString()); + expect(weight.lastTrigger).toBe("foreground"); + // The foreground arrival leaves the seeded background date standing. + expect(weight.lastBackgroundAt?.toISOString()).toBe( + "2026-09-01T06:00:00.000Z", + ); + const dose = rows.find((r) => r.type === "MEDICATION_DOSE")!; + expect(dose.lastNewSampleAt?.toISOString()).toBe(at.toISOString()); + expect(dose.lastBackgroundAt).toBeNull(); + }); +}); diff --git a/tests/integration/healthkit-type-sync.test.ts b/tests/integration/healthkit-type-sync.test.ts index 7074a5e5b..b495e402d 100644 --- a/tests/integration/healthkit-type-sync.test.ts +++ b/tests/integration/healthkit-type-sync.test.ts @@ -300,3 +300,182 @@ describe("per-type arrival ledger (#1173)", () => { }); }); }); + +async function seedAppleMedication(): Promise { + const med = await getPrismaClient().medication.create({ + data: { + userId: USER_ID, + name: "Magnesium", + dose: "400mg", + active: true, + // Dose events from Apple Health only target a mirrored medication. + externalSource: "APPLE_HEALTH", + externalId: "hk-medication-type-sync", + }, + }); + return med.id; +} + +async function postDoses(body: Record): Promise { + const { POST } = await import("@/app/api/medications/intake/bulk/route"); + return POST( + new NextRequest("http://localhost/api/medications/intake/bulk", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }), + ); +} + +function appleDose(medicationId: string, takenAt: string, externalId: string) { + return { + medicationId, + scheduledFor: takenAt, + takenAt, + source: "APPLE_HEALTH", + externalId, + }; +} + +type FreshnessRow = { + type: string; + lastSeenAt: string; + stale: boolean; + lastReceivedAt?: string | null; + lastTrigger?: string | null; + lastNewSampleAt?: string | null; + lastBackgroundAt?: string | null; +}; + +async function readFreshness(): Promise> { + const { GET } = await import("@/app/api/integrations/healthkit/route"); + const res = await (GET as unknown as (r: NextRequest) => Promise)( + new NextRequest("http://localhost/api/integrations/healthkit"), + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { metricFreshness: FreshnessRow[] }; + }; + return Object.fromEntries( + body.data.metricFreshness.map((row) => [row.type, row]), + ); +} + +const D1 = "2026-10-02T08:00:00.000Z"; +const D2 = "2026-10-03T08:00:00.000Z"; + +describe("Apple Health dose arrivals and the last background arrival (v1.42.4)", () => { + it("serves a dose sent in the background as the MEDICATION_DOSE entry", async () => { + await armSession(); + const medicationId = await seedAppleMedication(); + const res = await postDoses({ + syncTrigger: "background", + entries: [appleDose(medicationId, D1, "hk-dose-1")], + }); + expect(res.status).toBe(200); + + const dose = (await readFreshness()).MEDICATION_DOSE; + expect(dose).toMatchObject({ + lastSeenAt: D1, + stale: false, + lastTrigger: "background", + }); + expect(dose.lastReceivedAt).toEqual(expect.any(String)); + expect(dose.lastBackgroundAt).toBe(dose.lastReceivedAt); + expect(dose.lastNewSampleAt).toBe(dose.lastReceivedAt); + expect( + Math.abs(Date.parse(dose.lastReceivedAt!) - Date.now()), + ).toBeLessThan(60_000); + }); + + it("keeps the background date when a later foreground batch replays the dose", async () => { + await armSession(); + const medicationId = await seedAppleMedication(); + const first = appleDose(medicationId, D1, "hk-dose-1"); + await postDoses({ syncTrigger: "background", entries: [first] }); + const before = (await readFreshness()).MEDICATION_DOSE; + + await new Promise((resolve) => setTimeout(resolve, 20)); + const res = await postDoses({ + syncTrigger: "foreground", + entries: [first, appleDose(medicationId, D2, "hk-dose-2")], + }); + const body = (await res.json()) as { + data: { entries: Array<{ status: string }> }; + }; + expect(body.data.entries.map((e) => e.status)).toEqual([ + "duplicate", + "inserted", + ]); + + const after = (await readFreshness()).MEDICATION_DOSE; + expect(after.lastTrigger).toBe("foreground"); + expect(Date.parse(after.lastReceivedAt!)).toBeGreaterThan( + Date.parse(before.lastReceivedAt!), + ); + expect(after.lastBackgroundAt).toBe(before.lastBackgroundAt); + expect(after.lastSeenAt).toBe(D2); + }); + + it("records no dose arrival for a dose taken in the app", async () => { + await armSession(); + const medicationId = await seedAppleMedication(); + // The mirror gate refuses non-Apple entries on a mirrored medication only + // for APPLE_HEALTH entries; a plain entry lands as an app dose. + const res = await postDoses({ + syncTrigger: "background", + entries: [{ medicationId, scheduledFor: D1, takenAt: D1 }], + }); + expect(res.status).toBe(200); + expect( + await getPrismaClient().healthKitTypeSync.count({ + where: { userId: USER_ID }, + }), + ).toBe(0); + expect((await readFreshness()).MEDICATION_DOSE).toBeUndefined(); + }); + + it("keeps a measurement type's background date across a later foreground batch", async () => { + await armSession(); + await postBatch({ + syncTrigger: "background", + entries: [ + entry("HKQuantityTypeIdentifierBodyMass", 70, "kg", T1, "uuid-w1"), + ], + }); + const background = + await getPrismaClient().healthKitTypeSync.findUniqueOrThrow({ + where: { userId_type: { userId: USER_ID, type: "WEIGHT" } }, + }); + expect(background.lastBackgroundAt?.toISOString()).toBe( + background.lastReceivedAt.toISOString(), + ); + + await new Promise((resolve) => setTimeout(resolve, 20)); + await postBatch({ + syncTrigger: "foreground", + entries: [ + entry("HKQuantityTypeIdentifierBodyMass", 71, "kg", T2, "uuid-w2"), + ], + }); + const weight = (await readFreshness()).WEIGHT; + expect(weight.lastTrigger).toBe("foreground"); + expect(weight.lastReceivedAt).not.toBe( + background.lastReceivedAt.toISOString(), + ); + expect(weight.lastBackgroundAt).toBe( + background.lastBackgroundAt?.toISOString(), + ); + }); + + it("does not date a background arrival for a push-triggered batch", async () => { + await armSession(); + await postBatch({ + syncTrigger: "push", + entries: [ + entry("HKQuantityTypeIdentifierBodyMass", 70, "kg", T1, "uuid-w1"), + ], + }); + expect((await readFreshness()).WEIGHT.lastBackgroundAt).toBeNull(); + }); +}); diff --git a/tests/integration/integration-watchdog.test.ts b/tests/integration/integration-watchdog.test.ts new file mode 100644 index 000000000..777e3ce80 --- /dev/null +++ b/tests/integration/integration-watchdog.test.ts @@ -0,0 +1,554 @@ +/** + * The integration watchdog and the connection health read against real + * Postgres: the 14-day history count, the one-alert-per-incident ledger, the + * recovery note, the on-demand exemption, the adoption of a page the sync + * ledger already sent, and the recent-call ring the diagnostics read. + * + * The dispatcher is mocked: what is asserted is how many operator messages + * the watchdog asks for, not their delivery. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +process.env.ENCRYPTION_KEY ??= + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +import { getPrismaClient, truncateAllTables } from "./setup"; + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/notifications/dispatcher", () => ({ + dispatchNotification: vi.fn().mockResolvedValue({ + dispatched: true, + channelsAttempted: 1, + channelsSucceeded: 1, + }), +})); + +import { dispatchNotification } from "@/lib/notifications/dispatcher"; +import { runIntegrationWatchdog } from "@/lib/integrations/watchdog"; +import { loadConnectionHealth } from "@/lib/integrations/connection-health-load"; +import { + recordSyncFailure, + recordSyncSuccess, +} from "@/lib/integrations/status"; + +const HOUR = 60 * 60 * 1000; +const DAY = 24 * HOUR; +const NOW = new Date("2026-10-15T12:00:00.000Z"); +const USER = "user-watchdog-subject"; +const ADMIN = "user-watchdog-admin"; + +const prisma = () => getPrismaClient(); + +async function seedDailyReadings( + userId: string, + source: "OURA" | "WITHINGS", + days: number[], + newestHoursAgo: number, +) { + const rows = days.map((d) => ({ + userId, + type: "RESTING_HEART_RATE" as const, + value: 60, + unit: "bpm", + source, + measuredAt: new Date(NOW.getTime() - d * DAY - 6 * HOUR), + })); + rows.push({ + userId, + type: "RESTING_HEART_RATE" as const, + value: 60, + unit: "bpm", + source, + measuredAt: new Date(NOW.getTime() - newestHoursAgo * HOUR), + }); + await prisma().measurement.createMany({ data: rows }); +} + +function dispatchedTitles(): string[] { + return vi + .mocked(dispatchNotification) + .mock.calls.map((c) => String((c[0] as { title: string }).title)); +} + +beforeEach(async () => { + await truncateAllTables(prisma()); + await prisma().user.create({ + data: { + id: ADMIN, + username: "wd-admin", + email: "admin@example.test", + role: "ADMIN", + }, + }); + await prisma().user.create({ + data: { + id: USER, + username: "wd-subject", + email: "subject@example.test", + ouraAccessTokenEncrypted: "not-a-real-token", + }, + }); + vi.mocked(dispatchNotification).mockClear(); + // The watchdog has run on this instance before: its first pass after a + // deploy records what stands without paging (pinned on its own below). + await prisma().integrationWatch.create({ + data: { userId: ADMIN, integration: "withings" }, + }); +}); + +describe("integration watchdog", () => { + it("flags a normally-daily wearable gone silent, alerts once, then sends one recovery note", async () => { + // Thirteen consecutive days of data before the window, newest 54 h ago: + // past Oura's 48 h window (it uploads only when its phone app is opened). + await seedDailyReadings( + USER, + "OURA", + Array.from({ length: 13 }, (_, i) => i + 3), + 54, + ); + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(NOW); + try { + await recordSyncSuccess(USER, "oura"); + + const first = await runIntegrationWatchdog({ mode: "hourly", now: NOW }); + expect(first.silencesFlagged).toBe(1); + expect(first.incidentsOpened).toBe(1); + expect(dispatchedTitles()).toEqual([ + "Oura needs attention for subject@example.test", + ]); + + const watch = await prisma().integrationWatch.findUniqueOrThrow({ + where: { userId_integration: { userId: USER, integration: "oura" } }, + }); + expect(watch.incidentReason).toBe("unexpected_silence"); + expect(watch.silentSince).not.toBeNull(); + expect(watch.alertedAt).not.toBeNull(); + + const health = await loadConnectionHealth(USER, ["oura"]); + expect(health.oura?.state).toBe("degraded"); + expect(health.oura?.reason).toBe("unexpected_silence"); + + // Next hour, and the daily pass: still silent, nothing new is sent. + const later = new Date(NOW.getTime() + HOUR); + await runIntegrationWatchdog({ mode: "hourly", now: later }); + await runIntegrationWatchdog({ mode: "daily", now: later }); + expect(dispatchNotification).toHaveBeenCalledTimes(1); + + // Data arrives again: the incident closes with one recovery note. + await prisma().measurement.create({ + data: { + userId: USER, + type: "RESTING_HEART_RATE", + value: 61, + unit: "bpm", + source: "OURA", + measuredAt: new Date(later.getTime() - 10 * 60 * 1000), + }, + }); + const recovered = await runIntegrationWatchdog({ + mode: "hourly", + now: new Date(later.getTime() + HOUR), + }); + expect(recovered.incidentsClosed).toBe(1); + expect(dispatchedTitles()).toEqual([ + "Oura needs attention for subject@example.test", + "Oura recovered for subject@example.test", + ]); + const cleared = await prisma().integrationWatch.findUniqueOrThrow({ + where: { userId_integration: { userId: USER, integration: "oura" } }, + }); + expect(cleared.silentSince).toBeNull(); + expect(cleared.incidentReason).toBeNull(); + + // Healthy and quiet: further passes send nothing. + await runIntegrationWatchdog({ + mode: "hourly", + now: new Date(later.getTime() + 2 * HOUR), + }); + expect(dispatchNotification).toHaveBeenCalledTimes(2); + } finally { + vi.useRealTimers(); + } + }); + + it("leaves a wearable alone when its owner wears it only now and then", async () => { + await seedDailyReadings(USER, "OURA", [3, 7, 11], 54); + await recordSyncSuccess(USER, "oura"); + const summary = await runIntegrationWatchdog({ mode: "hourly", now: NOW }); + expect(summary.silencesFlagged).toBe(0); + expect(dispatchNotification).not.toHaveBeenCalled(); + expect( + await prisma().integrationWatch.count({ where: { userId: USER } }), + ).toBe(0); + }); + + it("never alerts on silence for an on-demand source such as a scale", async () => { + await prisma().user.update({ + where: { id: USER }, + data: { ouraAccessTokenEncrypted: null }, + }); + await prisma().withingsConnection.create({ + data: { + userId: USER, + withingsUserId: "w-1", + accessToken: "x", + refreshToken: "y", + tokenExpiresAt: new Date(NOW.getTime() + DAY), + scope: "user.metrics,user.activity", + }, + }); + // Daily weigh-ins for two weeks, then nothing for three days. + await seedDailyReadings( + USER, + "WITHINGS", + Array.from({ length: 13 }, (_, i) => i + 4), + 72, + ); + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(NOW); + try { + await recordSyncSuccess(USER, "withings"); + const summary = await runIntegrationWatchdog({ mode: "daily", now: NOW }); + expect(summary.connectionsConsidered).toBe(1); + expect(summary.silencesFlagged).toBe(0); + expect(dispatchNotification).not.toHaveBeenCalled(); + const health = await loadConnectionHealth(USER, ["withings"]); + expect(health.withings?.state).toBe("ok"); + } finally { + vi.useRealTimers(); + } + }); + + it("adopts a failure streak the ledger already paged about and reports its end once", async () => { + await seedDailyReadings(USER, "OURA", [2], 5); + for (let i = 0; i < 3; i++) { + await recordSyncFailure({ + userId: USER, + integration: "oura", + kind: "transient", + message: "Oura error: 503 with a body that must never be published", + errorCode: "503", + }); + } + // The ledger's own 3-strike page. + expect(dispatchNotification).toHaveBeenCalledTimes(1); + + const now = new Date(); + const adopted = await runIntegrationWatchdog({ mode: "hourly", now }); + expect(adopted.incidentsOpened).toBe(1); + expect(dispatchNotification).toHaveBeenCalledTimes(1); + + await recordSyncSuccess(USER, "oura"); + const closed = await runIntegrationWatchdog({ mode: "hourly", now }); + expect(closed.incidentsClosed).toBe(1); + expect(dispatchNotification).toHaveBeenCalledTimes(2); + expect(dispatchedTitles()[1]).toBe( + "Oura recovered for subject@example.test", + ); + }); +}); + +describe("what pages and what does not", () => { + it("never pages for an Apple Health account whose phone app stopped delivering weeks ago", async () => { + // Daily data until forty days ago, then nothing: the push cadence reads + // stale (`no_new_data`) for good. The owner stopped using the app; there + // is nothing for an operator to do. + await prisma().user.update({ + where: { id: USER }, + data: { + ouraAccessTokenEncrypted: null, + healthKitLastSyncedAt: new Date(NOW.getTime() - 40 * DAY), + }, + }); + await prisma().measurement.createMany({ + data: Array.from({ length: 30 }, (_, i) => ({ + userId: USER, + type: "ACTIVITY_STEPS" as const, + value: 5000, + unit: "count", + source: "APPLE_HEALTH" as const, + measuredAt: new Date(NOW.getTime() - (40 + i) * DAY), + })), + }); + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(NOW); + try { + const health = await loadConnectionHealth(USER, ["apple-health"]); + expect(health["apple-health"]?.state).toBe("degraded"); + expect(health["apple-health"]?.reason).toBe("no_new_data"); + // A week of hourly and daily passes. + for (let day = 0; day < 7; day++) { + const at = new Date(NOW.getTime() + day * DAY); + const hourly = await runIntegrationWatchdog({ + mode: "hourly", + now: at, + }); + const daily = await runIntegrationWatchdog({ mode: "daily", now: at }); + expect(hourly.incidentsOpened + daily.incidentsOpened).toBe(0); + } + expect(dispatchNotification).not.toHaveBeenCalled(); + expect( + await prisma().integrationWatch.count({ + where: { userId: USER, incidentReason: { not: null } }, + }), + ).toBe(0); + } finally { + vi.useRealTimers(); + } + }); + + it("records what stands on its first pass after a deploy without paging, and pages normally after", async () => { + await prisma().integrationWatch.deleteMany(); + // A wearable that has been silent since before the watchdog existed. + await seedDailyReadings( + USER, + "OURA", + Array.from({ length: 13 }, (_, i) => i + 3), + 54, + ); + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(NOW); + try { + await recordSyncSuccess(USER, "oura"); + const first = await runIntegrationWatchdog({ mode: "hourly", now: NOW }); + expect(first.firstRun).toBe(true); + expect(first.incidentsOpened).toBe(1); + expect(dispatchNotification).not.toHaveBeenCalled(); + const watch = await prisma().integrationWatch.findUniqueOrThrow({ + where: { userId_integration: { userId: USER, integration: "oura" } }, + }); + expect(watch.incidentReason).toBe("unexpected_silence"); + // Marked as announced, so no later pass pages it either. + expect(watch.alertedAt).not.toBeNull(); + + const later = new Date(NOW.getTime() + HOUR); + const second = await runIntegrationWatchdog({ + mode: "hourly", + now: later, + }); + expect(second.firstRun).toBe(false); + expect(dispatchNotification).not.toHaveBeenCalled(); + + // It ends without a recovery note: nothing was ever sent about it. + await prisma().measurement.create({ + data: { + userId: USER, + type: "RESTING_HEART_RATE", + value: 61, + unit: "bpm", + source: "OURA", + measuredAt: new Date(later.getTime() - 10 * 60 * 1000), + }, + }); + const recovered = await runIntegrationWatchdog({ + mode: "hourly", + now: new Date(later.getTime() + HOUR), + }); + expect(recovered.incidentsClosed).toBe(1); + expect(dispatchNotification).not.toHaveBeenCalled(); + + // A new incident after the first pass pages as usual. + for (let i = 0; i < 3; i++) { + await recordSyncFailure({ + userId: USER, + integration: "whoop", + kind: "transient", + message: "WHOOP error: 503", + errorCode: "503", + }); + } + expect(dispatchNotification).toHaveBeenCalledTimes(1); + } finally { + vi.useRealTimers(); + } + }); + + it("leaves a row behind on a first pass that found nothing wrong, so the next pass is not a first one", async () => { + await prisma().integrationWatch.deleteMany(); + await seedDailyReadings(USER, "OURA", [1, 2, 3], 2); + await recordSyncSuccess(USER, "oura"); + const first = await runIntegrationWatchdog({ mode: "hourly", now: NOW }); + expect(first.firstRun).toBe(true); + expect(await prisma().integrationWatch.count()).toBe(1); + const second = await runIntegrationWatchdog({ mode: "hourly", now: NOW }); + expect(second.firstRun).toBe(false); + }); +}); + +describe("the ledger and the watchdog page once between them", () => { + it("does not page a failure streak the watchdog already announced for the connection", async () => { + await prisma().integrationWatch.create({ + data: { + userId: USER, + integration: "oura", + silentSince: new Date(NOW.getTime() - 3 * DAY), + incidentReason: "unexpected_silence", + incidentOpenedAt: new Date(NOW.getTime() - DAY), + alertedAt: new Date(NOW.getTime() - DAY), + }, + }); + for (let i = 0; i < 3; i++) { + await recordSyncFailure({ + userId: USER, + integration: "oura", + kind: "transient", + message: "Oura error: 503", + errorCode: "503", + }); + } + expect(dispatchNotification).not.toHaveBeenCalled(); + const ledger = await prisma().integrationStatus.findUniqueOrThrow({ + where: { userId_integration: { userId: USER, integration: "oura" } }, + }); + expect(ledger.alertedAt).not.toBeNull(); + }); +}); + +describe("many accounts of one provider at once", () => { + async function silentOuraAccounts(n: number): Promise { + const ids: string[] = []; + for (let i = 0; i < n; i++) { + const id = `user-watchdog-many-${i}`; + await prisma().user.create({ + data: { + id, + username: `wd-many-${i}`, + email: `many-${i}@example.test`, + ouraAccessTokenEncrypted: "not-a-real-token", + }, + }); + await seedDailyReadings( + id, + "OURA", + Array.from({ length: 13 }, (_, d) => d + 3), + 54, + ); + await recordSyncSuccess(id, "oura"); + ids.push(id); + } + return ids; + } + + it("sends one provider-wide message and one recovery instead of one per account", async () => { + await prisma().user.update({ + where: { id: USER }, + data: { ouraAccessTokenEncrypted: null }, + }); + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(NOW); + try { + const ids = await silentOuraAccounts(5); + const opened = await runIntegrationWatchdog({ mode: "hourly", now: NOW }); + expect(opened.incidentsOpened).toBe(5); + expect(dispatchedTitles()).toEqual([ + "Oura: 5 connections need attention", + ]); + const message = String( + ( + vi.mocked(dispatchNotification).mock.calls[0]![0] as { + message: string; + } + ).message, + ); + expect(message).toMatch( + /^5 silent although they normally deliver every day since \d{4}-\d{2}-\d{2} \d{2}:\d{2} UTC\./, + ); + // Every incident counts as announced: none is paged again. + expect( + await prisma().integrationWatch.count({ + where: { integration: "oura", alertedAt: { not: null } }, + }), + ).toBe(5); + await runIntegrationWatchdog({ + mode: "hourly", + now: new Date(NOW.getTime() + HOUR), + }); + expect(dispatchNotification).toHaveBeenCalledTimes(1); + + // All of them deliver again: one recovery note. + const later = new Date(NOW.getTime() + 2 * HOUR); + for (const id of ids) { + await prisma().measurement.create({ + data: { + userId: id, + type: "RESTING_HEART_RATE", + value: 61, + unit: "bpm", + source: "OURA", + measuredAt: new Date(later.getTime() - 10 * 60 * 1000), + }, + }); + } + const closed = await runIntegrationWatchdog({ + mode: "hourly", + now: later, + }); + expect(closed.incidentsClosed).toBe(5); + expect(dispatchedTitles()).toEqual([ + "Oura: 5 connections need attention", + "Oura recovered for 5 connections", + ]); + } finally { + vi.useRealTimers(); + } + }); + + it("still sends one message per account up to the threshold", async () => { + await prisma().user.update({ + where: { id: USER }, + data: { ouraAccessTokenEncrypted: null }, + }); + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(NOW); + try { + await silentOuraAccounts(3); + await runIntegrationWatchdog({ mode: "hourly", now: NOW }); + expect(dispatchedTitles().sort()).toEqual([ + "Oura needs attention for many-0@example.test", + "Oura needs attention for many-1@example.test", + "Oura needs attention for many-2@example.test", + ]); + } finally { + vi.useRealTimers(); + } + }); +}); + +describe("recent-call ring", () => { + it("keeps the newest ten attempts, newest first, without any message text", async () => { + for (let i = 0; i < 7; i++) { + await recordSyncFailure({ + userId: USER, + integration: "oura", + kind: "transient", + message: `secret-bearing upstream body ${i}`, + errorCode: i === 6 ? "token=abc def" : "503", + leg: "sleep", + }); + } + for (let i = 0; i < 5; i++) { + await recordSyncSuccess(USER, "oura", { leg: "sleep" }); + } + const row = await prisma().integrationStatus.findUniqueOrThrow({ + where: { userId_integration: { userId: USER, integration: "oura" } }, + }); + const calls = row.recentCalls as Array>; + expect(calls).toHaveLength(10); + expect(calls.slice(0, 5).every((c) => c.status === "ok")).toBe(true); + expect(calls[5]).toMatchObject({ + endpoint: "sleep", + status: "transient", + errorCode: null, + }); + expect(calls[6]).toMatchObject({ errorCode: "503" }); + expect(JSON.stringify(calls)).not.toContain("secret"); + + const health = await loadConnectionHealth(USER, ["oura"]); + expect(health.oura?.diagnostics.recentCalls).toHaveLength(10); + expect(JSON.stringify(health.oura)).not.toContain(USER); + }); +}); diff --git a/tests/integration/labs-overview-long-history.test.ts b/tests/integration/labs-overview-long-history.test.ts new file mode 100644 index 000000000..bf0bbaa6c --- /dev/null +++ b/tests/integration/labs-overview-long-history.test.ts @@ -0,0 +1,276 @@ +/** + * `GET /api/labs/overview` against a real Postgres, on a long history. + * + * The Labs list used to group the newest 500 raw rows in the browser, so on + * an account with thousands of readings every marker measured only long ago + * had no row at all. The overview groups server-side over every live row; + * this suite seeds 2,000 readings over 25 years and checks that each marker + * comes back once, with its full count, its newest reading and a trend. + */ +import { NextRequest } from "next/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { cookieJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +const USER_ID = "user-labs-overview-test"; +const OTHER_USER_ID = "user-labs-overview-other"; +const DAY_MS = 24 * 60 * 60 * 1000; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => { + cookieJar.set(name, value); + }, + delete: (name: string) => { + cookieJar.delete(name); + }, + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +/** Days before now, so the fixture never ages out of its own windows. */ +const daysAgo = (days: number) => new Date(Date.now() - days * DAY_MS); + +const FREQUENT_READINGS = 1_700; +const RARE_MARKERS = 58; +const RARE_READINGS_EACH = 5; +const LEGACY_READINGS = 10; +const TOTAL = + FREQUENT_READINGS + RARE_MARKERS * RARE_READINGS_EACH + LEGACY_READINGS; + +interface Seeded { + frequentId: string; + rareIds: string[]; +} + +/** + * 2,000 live readings over 25 years: one marker measured almost daily over + * the last five years (1,700), 58 markers measured five times each between 25 + * and 6 years ago (290), and one legacy unlinked marker whose ten readings + * spell the analyte in different cases (10). The newest 500 rows are all the + * frequent marker, which is exactly the history the capped list lost. + */ +async function seedLongHistory(): Promise { + const prisma = getPrismaClient(); + const frequent = await prisma.biomarker.create({ + data: { + userId: USER_ID, + name: "Glucose", + unit: "mg/dL", + lowerBound: 70, + upperBound: 99, + }, + }); + const rare = await Promise.all( + Array.from({ length: RARE_MARKERS }, (_, i) => + prisma.biomarker.create({ + data: { + userId: USER_ID, + name: `Rare marker ${String(i).padStart(2, "0")}`, + unit: "U/L", + lowerBound: 1, + upperBound: 50, + }, + }), + ), + ); + + const rows: Array<{ + userId: string; + biomarkerId: string | null; + analyte: string; + value: number; + unit: string; + takenAt: Date; + }> = []; + for (let i = 0; i < FREQUENT_READINGS; i++) { + rows.push({ + userId: USER_ID, + biomarkerId: frequent.id, + analyte: "Glucose", + value: 80 + (i % 20), + unit: "mg/dL", + takenAt: daysAgo(1 + i), + }); + } + rare.forEach((marker, m) => { + for (let k = 0; k < RARE_READINGS_EACH; k++) { + rows.push({ + userId: USER_ID, + biomarkerId: marker.id, + analyte: marker.name, + value: 10 + k, + unit: "U/L", + // Between ~25 and ~6 years ago, one marker per fortnight-ish slot. + takenAt: daysAgo(25 * 365 - m * 120 - k * 20), + }); + } + }); + for (let k = 0; k < LEGACY_READINGS; k++) { + rows.push({ + userId: USER_ID, + biomarkerId: null, + analyte: k % 2 === 0 ? "Vitamin X" : "VITAMIN x", + value: 1 + k, + unit: "nmol/L", + takenAt: daysAgo(24 * 365 - k * 30), + }); + } + await prisma.labResult.createMany({ data: rows }); + + // Noise that must not count: a deleted reading of a rare marker, and + // another account's reading. + await prisma.labResult.create({ + data: { + userId: USER_ID, + biomarkerId: rare[0].id, + analyte: rare[0].name, + value: 99, + unit: "U/L", + takenAt: daysAgo(0), + deletedAt: daysAgo(0), + }, + }); + await prisma.labResult.create({ + data: { + userId: OTHER_USER_ID, + analyte: "Other account marker", + value: 1, + unit: "U/L", + takenAt: daysAgo(0), + }, + }); + return { frequentId: frequent.id, rareIds: rare.map((m) => m.id) }; +} + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + const prisma = getPrismaClient(); + await prisma.user.createMany({ + data: [ + { id: USER_ID, username: "labs-ov", email: "labs-ov@example.test" }, + { + id: OTHER_USER_ID, + username: "labs-ov-other", + email: "labs-ov-other@example.test", + }, + ], + }); + const session = await prisma.session.create({ + data: { userId: USER_ID, expiresAt: new Date(Date.now() + 60 * 60 * 1000) }, + }); + cookieJar.set("healthlog_session", session.id); +}); + +interface OverviewBody { + data: { + markers: Array<{ + key: string; + count: number; + trend: (number | null)[]; + latest: { + id: string; + biomarkerId: string | null; + analyte: string; + value: number | null; + takenAt: string; + }; + }>; + }; +} + +async function getOverview(query = "") { + const { GET } = await import("@/app/api/labs/overview/route"); + return GET(new NextRequest(`http://localhost/api/labs/overview${query}`)); +} + +describe("GET /api/labs/overview on a 25-year history", () => { + it("returns every marker once, with its full count and newest reading", async () => { + const seeded = await seedLongHistory(); + + const res = await getOverview(); + expect(res.status).toBe(200); + const { markers } = ((await res.json()) as OverviewBody).data; + + // 1 frequent + 58 rare + 1 legacy, and nothing from the other account. + expect(markers).toHaveLength(1 + RARE_MARKERS + 1); + expect(markers.reduce((sum, m) => sum + m.count, 0)).toBe(TOTAL); + expect(TOTAL).toBe(2_000); + + const byKey = new Map(markers.map((m) => [m.key, m])); + for (const id of seeded.rareIds) { + const marker = byKey.get(`bm:${id}`); + expect(marker, `marker ${id} missing`).toBeDefined(); + // The deleted reading neither counts nor becomes the newest one. + expect(marker!.count).toBe(RARE_READINGS_EACH); + expect(marker!.latest.value).toBe(14); + expect(marker!.trend).toEqual([10, 11, 12, 13, 14]); + } + + const frequent = byKey.get(`bm:${seeded.frequentId}`)!; + expect(frequent.count).toBe(FREQUENT_READINGS); + // Capped trend, oldest first, ending on the newest reading. + expect(frequent.trend).toHaveLength(50); + expect(frequent.trend.at(-1)).toBe(frequent.latest.value); + expect(frequent.latest.value).toBe(80); + + // Legacy rows group case-insensitively by analyte. + const legacy = byKey.get("analyte:vitamin x")!; + expect(legacy.count).toBe(LEGACY_READINGS); + expect(legacy.latest.biomarkerId).toBeNull(); + expect(legacy.latest.value).toBe(LEGACY_READINGS); + + // Newest reading first. + expect(markers[0].key).toBe(`bm:${seeded.frequentId}`); + const times = markers.map((m) => Date.parse(m.latest.takenAt)); + expect([...times].sort((a, b) => b - a)).toEqual(times); + + expect( + markers.some((m) => m.latest.analyte === "Other account marker"), + ).toBe(false); + }); + + it("covers the markers a 500-row page of the list endpoint drops", async () => { + await seedLongHistory(); + const { GET } = await import("@/app/api/labs/route"); + const page = await GET( + new NextRequest("http://localhost/api/labs?limit=500&sortDir=desc"), + ); + const pageBody = (await page.json()) as { + data: { results: Array<{ biomarkerId: string | null }> }; + }; + const onPage = new Set(pageBody.data.results.map((r) => r.biomarkerId)); + // The old list's view of this account: one marker. + expect(onPage.size).toBe(1); + + const { markers } = ((await (await getOverview()).json()) as OverviewBody) + .data; + expect(markers).toHaveLength(1 + RARE_MARKERS + 1); + }); + + it("refuses a query parameter instead of ignoring it", async () => { + const res = await getOverview("?limit=500"); + expect(res.status).toBe(422); + }); + + it("answers an empty list for an account without readings", async () => { + const res = await getOverview(); + expect(res.status).toBe(200); + expect(((await res.json()) as OverviewBody).data.markers).toEqual([]); + }); +}); diff --git a/tests/integration/medication-extra-dose-apple-health.test.ts b/tests/integration/medication-extra-dose-apple-health.test.ts new file mode 100644 index 000000000..4e58ba78d --- /dev/null +++ b/tests/integration/medication-extra-dose-apple-health.test.ts @@ -0,0 +1,497 @@ +/** + * An extra (off-schedule) dose recorded in Apple Health must arrive as its own + * unscheduled dose. + * + * The phone posts every HealthKit dose event to the bulk route with the + * event's UUID as `externalId`, `scheduledFor` set to the event's scheduled + * date or, for an extra dose that has none, its own time. A taken write binds + * to a slot by window band, and a weekly schedule's band reaches a day either + * side of its slot. An extra dose on the Wednesday after a Tuesday dose + * therefore landed on the Tuesday slot and overwrote it: the Tuesday time was + * replaced by the Wednesday one and the extra dose existed nowhere. + * + * Three shapes, each through the shipped `POST` and `GET` exports: an extra + * dose far from any slot, one half an hour after a slot already taken, and one + * on a day the schedule has no slot. Each asserts the stored rows AND the dose + * history the app reads back, where the extra dose must show as off-schedule + * and the slot keep its own dose. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { NextRequest } from "next/server"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +import { localHmAsUtc } from "@/lib/tz/local-day"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => { + cookieJar.set(name, value); + }, + delete: (name: string) => { + cookieJar.delete(name); + }, + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +const TZ = "Europe/Berlin"; +const DAY_MS = 24 * 60 * 60 * 1000; +const MIN = 60_000; + +let counter = 0; + +async function signedInUser(): Promise { + const prisma = getPrismaClient(); + const suffix = `extra-dose-${counter++}`; + const user = await prisma.user.create({ + data: { + username: suffix, + email: `${suffix}@example.test`, + role: "USER", + timezone: TZ, + }, + }); + const session = await prisma.session.create({ + data: { userId: user.id, expiresAt: new Date(Date.now() + 3_600_000) }, + }); + cookieJar.set("healthlog_session", session.id); + return user.id; +} + +async function seedMedication( + userId: string, + schedule: { timesOfDay: string[]; daysOfWeek: string | null }, +): Promise { + const med = await getPrismaClient().medication.create({ + data: { + userId, + name: "Magnesium", + dose: "400mg", + active: true, + // Dose events from Apple Health only target a mirrored medication. + externalSource: "APPLE_HEALTH", + externalId: `hk-medication-${userId}`, + startsOn: new Date(Date.now() - 60 * DAY_MS), + // The ledger expects slots only from the medication's creation on. + createdAt: new Date(Date.now() - 60 * DAY_MS), + schedules: { + create: { + windowStart: schedule.timesOfDay[0], + windowEnd: schedule.timesOfDay[0], + timesOfDay: schedule.timesOfDay, + daysOfWeek: schedule.daysOfWeek, + scheduleType: "SCHEDULED", + }, + }, + }, + }); + return med.id; +} + +/** A local day at least two weeks back with the given weekday (0 = Sunday). */ +function pastLocalDay(weekday: number): Date { + const day = new Date(Date.now() - 21 * DAY_MS); + day.setUTCHours(12, 0, 0, 0); + while (day.getUTCDay() !== weekday) { + day.setTime(day.getTime() - DAY_MS); + } + return day; +} + +interface BulkEntry { + medicationId: string; + scheduledFor: string; + takenAt: string; + externalId: string; +} + +/** The phone's shape: one HealthKit dose event per entry. */ +function appleDose( + medicationId: string, + takenAt: Date, + externalId: string, + scheduledFor: Date = takenAt, +): BulkEntry { + return { + medicationId, + scheduledFor: scheduledFor.toISOString(), + takenAt: takenAt.toISOString(), + externalId, + }; +} + +async function postBulk(entries: BulkEntry[]) { + const { POST } = await import("@/app/api/medications/intake/bulk/route"); + const response = await POST( + new NextRequest("http://localhost/api/medications/intake/bulk", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + entries: entries.map((entry) => ({ + ...entry, + source: "APPLE_HEALTH", + })), + }), + }), + ); + expect(response.status).toBe(200); + const body = (await response.json()) as { + data: { entries: Array<{ status: string; id?: string }> }; + }; + return body.data.entries; +} + +type Handler = ( + request: NextRequest, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + context: { params: Promise }, +) => Promise; + +interface LedgerRow { + kind: "slot" | "ad_hoc"; + at: string; + status: string; + intake: { id: string | null; takenAt: string | null } | null; +} + +async function readLedger( + medicationId: string, + from: Date, + to: Date, +): Promise { + const { GET } = await import("@/app/api/medications/[id]/dose-history/route"); + const search = new URLSearchParams({ + from: from.toISOString(), + to: to.toISOString(), + }); + const response = await (GET as Handler)( + new NextRequest( + `http://localhost/api/medications/${medicationId}/dose-history?${search}`, + { method: "GET" }, + ), + { params: Promise.resolve({ id: medicationId }) }, + ); + expect(response.status).toBe(200); + const body = (await response.json()) as { data: { rows: LedgerRow[] } }; + return body.data.rows; +} + +async function rowsOf(medicationId: string) { + return getPrismaClient().medicationIntakeEvent.findMany({ + where: { medicationId, deletedAt: null }, + orderBy: { takenAt: "asc" }, + }); +} + +function takenAtOf(row: LedgerRow | undefined): number | null { + const taken = row?.intake?.takenAt; + return taken ? new Date(taken).getTime() : null; +} + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); +}); + +describe("an extra dose from Apple Health is stored as an unscheduled dose", () => { + it("(a) far from any slot", async () => { + const userId = await signedInUser(); + const medicationId = await seedMedication(userId, { + timesOfDay: ["08:00"], + daysOfWeek: null, + }); + const day = pastLocalDay(3); + const slot = localHmAsUtc(day, TZ, 8, 0); + const extraAt = localHmAsUtc(day, TZ, 15, 0); + + const results = await postBulk([ + appleDose(medicationId, new Date(slot.getTime() + 5 * MIN), "hk-a-1"), + appleDose(medicationId, extraAt, "hk-a-2"), + ]); + expect(results.map((r) => r.status)).toEqual(["inserted", "inserted"]); + + const rows = await rowsOf(medicationId); + expect(rows).toHaveLength(2); + const extra = rows.find((row) => row.externalId === "hk-a-2"); + expect(extra?.takenAt?.getTime()).toBe(extraAt.getTime()); + expect(extra?.scheduledFor.getTime()).toBe(extraAt.getTime()); + + const ledger = await readLedger( + medicationId, + new Date(slot.getTime() - 6 * 3_600_000), + new Date(slot.getTime() + 18 * 3_600_000), + ); + const adHoc = ledger.filter((row) => row.kind === "ad_hoc"); + expect(adHoc).toHaveLength(1); + expect(takenAtOf(adHoc[0])).toBe(extraAt.getTime()); + const slotRow = ledger.find((row) => row.kind === "slot"); + expect(slotRow?.status).toBe("taken_on_time"); + }); + + it("(b) 30 minutes after a slot already taken", async () => { + const userId = await signedInUser(); + const medicationId = await seedMedication(userId, { + timesOfDay: ["22:00"], + daysOfWeek: null, + }); + const day = pastLocalDay(2); + const slot = localHmAsUtc(day, TZ, 22, 0); + const ownAt = new Date(slot.getTime() + 2 * MIN); + const extraAt = new Date(slot.getTime() + 30 * MIN); + + // The slot's own dose first, then the extra one in a later sync. + expect( + (await postBulk([appleDose(medicationId, ownAt, "hk-b-1", slot)])).map( + (r) => r.status, + ), + ).toEqual(["inserted"]); + expect( + (await postBulk([appleDose(medicationId, extraAt, "hk-b-2")])).map( + (r) => r.status, + ), + ).toEqual(["inserted"]); + + const rows = await rowsOf(medicationId); + expect(rows).toHaveLength(2); + const own = rows.find((row) => row.externalId === "hk-b-1"); + const extra = rows.find((row) => row.externalId === "hk-b-2"); + // The slot keeps its own dose, time unchanged. + expect(own?.scheduledFor.getTime()).toBe(slot.getTime()); + expect(own?.takenAt?.getTime()).toBe(ownAt.getTime()); + // The extra dose sits on its own instant. + expect(extra?.scheduledFor.getTime()).toBe(extraAt.getTime()); + expect(extra?.takenAt?.getTime()).toBe(extraAt.getTime()); + + const ledger = await readLedger( + medicationId, + new Date(slot.getTime() - 6 * 3_600_000), + new Date(slot.getTime() + 6 * 3_600_000), + ); + const slotRow = ledger.find((row) => row.kind === "slot"); + expect(slotRow?.status).toBe("taken_on_time"); + expect(takenAtOf(slotRow)).toBe(ownAt.getTime()); + const adHoc = ledger.filter((row) => row.kind === "ad_hoc"); + expect(adHoc).toHaveLength(1); + expect(takenAtOf(adHoc[0])).toBe(extraAt.getTime()); + }); + + it("(b2) a re-sync of the same dose event stays one dose", async () => { + const userId = await signedInUser(); + const medicationId = await seedMedication(userId, { + timesOfDay: ["22:00"], + daysOfWeek: null, + }); + const slot = localHmAsUtc(pastLocalDay(2), TZ, 22, 0); + const ownAt = new Date(slot.getTime() + 2 * MIN); + + await postBulk([appleDose(medicationId, ownAt, "hk-b2-1", slot)]); + const again = await postBulk([ + appleDose(medicationId, ownAt, "hk-b2-1", slot), + ]); + expect(again.map((r) => r.status)).toEqual(["duplicate"]); + expect(await rowsOf(medicationId)).toHaveLength(1); + }); + + it("(c) on a day without a slot, next to the weekly slot the day before", async () => { + const userId = await signedInUser(); + // Tuesdays and Fridays at 22:00. + const medicationId = await seedMedication(userId, { + timesOfDay: ["22:00"], + daysOfWeek: "2,5", + }); + const tuesday = pastLocalDay(2); + const wednesday = new Date(tuesday.getTime() + DAY_MS); + const tuesdaySlot = localHmAsUtc(tuesday, TZ, 22, 0); + const tuesdayDose = localHmAsUtc(tuesday, TZ, 20, 23); + const wednesdayDose = localHmAsUtc(wednesday, TZ, 20, 45); + + await postBulk([appleDose(medicationId, tuesdayDose, "hk-c-tue")]); + const results = await postBulk([ + appleDose(medicationId, wednesdayDose, "hk-c-wed"), + ]); + expect(results.map((r) => r.status)).toEqual(["inserted"]); + + const rows = await rowsOf(medicationId); + expect(rows).toHaveLength(2); + const tue = rows.find((row) => row.externalId === "hk-c-tue"); + const wed = rows.find((row) => row.externalId === "hk-c-wed"); + // Tuesday keeps its own time. + expect(tue?.scheduledFor.getTime()).toBe(tuesdaySlot.getTime()); + expect(tue?.takenAt?.getTime()).toBe(tuesdayDose.getTime()); + // Wednesday is its own unscheduled dose. + expect(wed?.scheduledFor.getTime()).toBe(wednesdayDose.getTime()); + expect(wed?.takenAt?.getTime()).toBe(wednesdayDose.getTime()); + + const ledger = await readLedger( + medicationId, + new Date(tuesday.getTime() - 2 * DAY_MS), + new Date(tuesday.getTime() + 2 * DAY_MS), + ); + const tuesdayRow = ledger.find( + (row) => + row.kind === "slot" && + new Date(row.at).getTime() === tuesdaySlot.getTime(), + ); + expect(takenAtOf(tuesdayRow)).toBe(tuesdayDose.getTime()); + const adHoc = ledger.filter((row) => row.kind === "ad_hoc"); + expect(adHoc.map(takenAtOf)).toEqual([wednesdayDose.getTime()]); + }); +}); + +/** Stock the medication with one open bottle of 100 units, one per dose. */ +async function stock(userId: string, medicationId: string): Promise { + const prisma = getPrismaClient(); + await prisma.medication.update({ + where: { id: medicationId }, + data: { unitsPerDose: 1 }, + }); + await prisma.medicationInventoryItem.create({ + data: { + userId, + medicationId, + state: "IN_USE", + containerType: "BOTTLE", + unitsTotal: 100, + unitsRemaining: 100, + firstUseAt: new Date(Date.now() - 60 * DAY_MS), + }, + }); +} + +async function unitsRemaining(medicationId: string): Promise { + const item = await getPrismaClient().medicationInventoryItem.findFirstOrThrow( + { where: { medicationId } }, + ); + return Number(item.unitsRemaining); +} + +/** A take tapped in HealthLog: no external record, no Apple provenance. */ +async function postAppTap(medicationId: string, slot: Date, takenAt: Date) { + const { POST } = await import("@/app/api/medications/intake/bulk/route"); + const response = await POST( + new NextRequest("http://localhost/api/medications/intake/bulk", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + entries: [ + { + medicationId, + scheduledFor: slot.toISOString(), + takenAt: takenAt.toISOString(), + }, + ], + }), + }), + ); + expect(response.status).toBe(200); +} + +describe("the same dose recorded twice is one dose", () => { + it("(d) a tap in HealthLog at 08:00 and the dose in Health at 08:20 the same day", async () => { + const userId = await signedInUser(); + const medicationId = await seedMedication(userId, { + timesOfDay: ["08:00"], + daysOfWeek: null, + }); + await stock(userId, medicationId); + const day = pastLocalDay(4); + const slot = localHmAsUtc(day, TZ, 8, 0); + const tapAt = slot; + const appleAt = new Date(slot.getTime() + 20 * MIN); + + await postAppTap(medicationId, slot, tapAt); + expect(await unitsRemaining(medicationId)).toBe(99); + // Health sends it as the slot's scheduled dose, and once more as an + // unscheduled one: either way it is the tapped dose. + for (const [externalId, scheduledFor] of [ + ["hk-d-1", slot], + ["hk-d-2", appleAt], + ] as const) { + const results = await postBulk([ + appleDose(medicationId, appleAt, externalId, scheduledFor), + ]); + expect(results.map((r) => r.status)).toEqual(["updated"]); + } + + const rows = await rowsOf(medicationId); + expect(rows).toHaveLength(1); + expect(rows[0]!.scheduledFor.getTime()).toBe(slot.getTime()); + // One dose, one unit off the bottle. + expect(await unitsRemaining(medicationId)).toBe(99); + const ledger = await readLedger( + medicationId, + new Date(slot.getTime() - 6 * 3_600_000), + new Date(slot.getTime() + 6 * 3_600_000), + ); + expect(ledger.filter((row) => row.kind === "ad_hoc")).toHaveLength(0); + }); + + it("(e) a dose edited in Health: a new record, moved by 30 minutes, inside the same slot", async () => { + const userId = await signedInUser(); + const medicationId = await seedMedication(userId, { + timesOfDay: ["22:00"], + daysOfWeek: null, + }); + await stock(userId, medicationId); + const slot = localHmAsUtc(pastLocalDay(2), TZ, 22, 0); + const firstAt = new Date(slot.getTime() + 2 * MIN); + const editedAt = new Date(slot.getTime() + 32 * MIN); + + await postBulk([appleDose(medicationId, firstAt, "hk-e-1", slot)]); + // Health replaces an edited dose with a new record; it still names the + // slot as its scheduled time. + const results = await postBulk([ + appleDose(medicationId, editedAt, "hk-e-2", slot), + ]); + expect(results.map((r) => r.status)).toEqual(["updated"]); + + const rows = await rowsOf(medicationId); + expect(rows).toHaveLength(1); + expect(rows[0]!.externalId).toBe("hk-e-2"); + expect(rows[0]!.takenAt?.getTime()).toBe(editedAt.getTime()); + expect(rows[0]!.scheduledFor.getTime()).toBe(slot.getTime()); + expect(await unitsRemaining(medicationId)).toBe(99); + }); + + it("(f) an extra dose the next day still stays its own dose, consuming its own unit", async () => { + const userId = await signedInUser(); + const medicationId = await seedMedication(userId, { + timesOfDay: ["22:00"], + daysOfWeek: "2,5", + }); + await stock(userId, medicationId); + const tuesday = pastLocalDay(2); + const tuesdaySlot = localHmAsUtc(tuesday, TZ, 22, 0); + const wednesdayDose = localHmAsUtc( + new Date(tuesday.getTime() + DAY_MS), + TZ, + 20, + 45, + ); + + await postAppTap(medicationId, tuesdaySlot, tuesdaySlot); + const results = await postBulk([ + appleDose(medicationId, wednesdayDose, "hk-f-wed"), + ]); + expect(results.map((r) => r.status)).toEqual(["inserted"]); + expect(await rowsOf(medicationId)).toHaveLength(2); + expect(await unitsRemaining(medicationId)).toBe(98); + }); +}); diff --git a/tests/integration/mood-prognosis-ladder.test.ts b/tests/integration/mood-prognosis-ladder.test.ts index 888e69581..7176734b4 100644 --- a/tests/integration/mood-prognosis-ladder.test.ts +++ b/tests/integration/mood-prognosis-ladder.test.ts @@ -15,7 +15,7 @@ * tooltip. */ import { NextRequest } from "next/server"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { cookieJar } from "./mock-next-headers"; import { getPrismaClient, switchSessionTo, truncateAllTables } from "./setup"; @@ -46,7 +46,14 @@ vi.mock("@/lib/db-compat", () => ({ })); /** Fixed clock: a ladder test that slides with the calendar is a date bomb. */ -const NOW = new Date("2026-08-08T09:00:00.000Z"); +const FIXED_NOW = new Date("2026-08-08T09:00:00.000Z"); +/** + * The instant the seed and the job are dated against. The ladder cases keep + * the fixed clock; the route cases move it to the real one, because the route + * reads the forecast against the wall clock and a forecast seeded at a fixed + * date goes stale under it as the calendar moves on. + */ +let NOW = FIXED_NOW; function dayKey(daysAgo: number): string { return new Date(NOW.getTime() - daysAgo * DAY_MS).toISOString().slice(0, 10); @@ -271,6 +278,13 @@ describe("the minimum-n ladder, end to end", () => { }); describe("GET /api/mood/prognosis", () => { + beforeEach(() => { + NOW = new Date(); + }); + afterEach(() => { + NOW = FIXED_NOW; + }); + /** * A signed-in browser session. * diff --git a/tests/integration/oura-retired-sleep.test.ts b/tests/integration/oura-retired-sleep.test.ts new file mode 100644 index 000000000..8dfaf2e39 --- /dev/null +++ b/tests/integration/oura-retired-sleep.test.ts @@ -0,0 +1,116 @@ +/** + * Oura sleep periods the person rejected (`rest`) or deleted: against the + * real schema, retiring one soft-deletes every live OURA row under its record + * prefix, whatever the type, leaves a counted sleep period and the other + * sources alone, and recomputes the rollup day so the day stops counting the + * retired reading. + */ +import { beforeEach, describe, expect, it } from "vitest"; + +import { recomputeBucketsForMeasurement } from "@/lib/rollups/measurement-rollups"; + +import { getPrismaClient, truncateAllTables } from "./setup"; + +const USER = "user-oura-retired-sleep"; +const END = new Date(Date.UTC(2026, 5, 10, 15, 0, 0)); +const NIGHT_END = new Date(Date.UTC(2026, 5, 10, 7, 0, 0)); + +beforeEach(async () => { + const prisma = getPrismaClient(); + await truncateAllTables(prisma); + await prisma.user.create({ + data: { + id: USER, + username: "oura-retired-sleep", + email: "oura-retired-sleep@example.test", + }, + }); + const row = ( + type: "SLEEP_DURATION" | "RESTING_HEART_RATE", + externalId: string, + value: number, + measuredAt: Date, + source: "OURA" | "APPLE_HEALTH" = "OURA", + ) => ({ + userId: USER, + type, + value, + unit: type === "SLEEP_DURATION" ? "minutes" : "bpm", + measuredAt, + source, + externalId, + sleepStage: type === "SLEEP_DURATION" ? ("CORE" as const) : null, + }); + await prisma.measurement.createMany({ + data: [ + // The rejected afternoon period: a stage row and its nightly scalar. + row( + "SLEEP_DURATION", + "sleep:rec-rest:seg:2026-06-10T14:00:00.000Z", + 60, + END, + ), + row("RESTING_HEART_RATE", "sleep:rec-rest:rhr", 58, END), + // The real night before it, same day. + row( + "SLEEP_DURATION", + "sleep:rec-night:seg:2026-06-09T23:00:00.000Z", + 480, + NIGHT_END, + ), + row("RESTING_HEART_RATE", "sleep:rec-night:rhr", 48, NIGHT_END), + // Another source's row whose id happens to share the prefix shape. + row("RESTING_HEART_RATE", "sleep:rec-rest:rhr", 52, END, "APPLE_HEALTH"), + ], + }); + await recomputeBucketsForMeasurement(USER, "RESTING_HEART_RATE", END); +}); + +describe("retireOuraSleepRecords", () => { + it("retires only the rejected period's OURA rows and recomputes the day", async () => { + const prisma = getPrismaClient(); + const { retireOuraSleepRecords } = await import("@/lib/oura/sync"); + + const before = await prisma.measurementRollup.findFirst({ + where: { + userId: USER, + type: "RESTING_HEART_RATE", + granularity: "DAY", + source: "OURA", + }, + }); + expect(before?.count).toBe(2); + + const removed = await retireOuraSleepRecords(USER, ["sleep:rec-rest:"]); + expect(removed).toBe(2); + + const live = await prisma.measurement.findMany({ + where: { userId: USER, deletedAt: null }, + select: { externalId: true, source: true }, + }); + const key = (r: { externalId: string | null; source: string }) => + `${r.source}|${r.externalId}`; + expect(live.sort((a, b) => key(a).localeCompare(key(b)))).toEqual([ + { externalId: "sleep:rec-rest:rhr", source: "APPLE_HEALTH" }, + { externalId: "sleep:rec-night:rhr", source: "OURA" }, + { + externalId: "sleep:rec-night:seg:2026-06-09T23:00:00.000Z", + source: "OURA", + }, + ]); + + const after = await prisma.measurementRollup.findFirst({ + where: { + userId: USER, + type: "RESTING_HEART_RATE", + granularity: "DAY", + source: "OURA", + }, + }); + expect(after?.count).toBe(1); + expect(after?.mean).toBe(48); + + // A malformed prefix never widens to the whole source. + expect(await retireOuraSleepRecords(USER, ["sleep::", "sleep:"])).toBe(0); + }); +}); diff --git a/tests/integration/pending-migrations-notice.test.ts b/tests/integration/pending-migrations-notice.test.ts new file mode 100644 index 000000000..3631e6612 --- /dev/null +++ b/tests/integration/pending-migrations-notice.test.ts @@ -0,0 +1,78 @@ +/** + * The boot notice `docker-entrypoint.sh` prints before `migrate deploy` + * (`scripts/pending-migrations.mjs`), against the migrated test database. + * + * Every shipped migration is applied here, so the count is zero; a + * migrations directory with one more entry than the database has applied + * stands for a freshly pulled image, and a schema with no migrations table + * for a first boot. + */ +import { cpSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; + +import { + pendingMigrations, + pendingNotice, + shippedMigrations, +} from "../../scripts/pending-migrations.mjs"; + +const MIGRATIONS = join(process.cwd(), "prisma", "migrations"); +const tempDirs: string[] = []; + +afterEach(() => { + for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true }); +}); + +function connectionString(): string { + const url = process.env.DATABASE_URL; + if (!url) throw new Error("DATABASE_URL is not set for the integration run"); + return url; +} + +describe("pending-migrations boot notice", () => { + it("counts nothing pending on a database that has applied every migration", async () => { + const pending = await pendingMigrations({ + connectionString: connectionString(), + migrationsDir: MIGRATIONS, + }); + expect(shippedMigrations(MIGRATIONS).length).toBeGreaterThan(300); + expect(pending).toEqual([]); + expect(pendingNotice(pending.length)).toBe( + "HealthLog: Database schema is up to date.", + ); + }); + + it("counts a migration the image ships and the database has not applied", async () => { + const dir = mkdtempSync(join(tmpdir(), "hl-migrations-")); + tempDirs.push(dir); + cpSync(MIGRATIONS, dir, { recursive: true }); + mkdirSync(join(dir, "9999_not_applied_yet")); + writeFileSync(join(dir, "9999_not_applied_yet", "migration.sql"), ""); + // A directory without migration.sql is not a migration. + mkdirSync(join(dir, "9998_no_sql")); + + const pending = await pendingMigrations({ + connectionString: connectionString(), + migrationsDir: dir, + }); + expect(pending).toEqual(["9999_not_applied_yet"]); + const notice = pendingNotice(pending.length); + expect(notice).toContain("1 database migration pending"); + expect(notice).toContain("pg_dump"); + }); + + it("treats a schema without a migrations table as a first boot", async () => { + const url = new URL(connectionString()); + url.searchParams.set("schema", "schema_never_migrated"); + const pending = await pendingMigrations({ + connectionString: url.toString(), + migrationsDir: MIGRATIONS, + }); + expect(pending).toEqual(shippedMigrations(MIGRATIONS)); + expect(pendingNotice(pending.length)).toMatch( + /^HealthLog: \d+ database migrations pending\./, + ); + }); +}); diff --git a/tests/integration/record-settings-integrations-health.test.ts b/tests/integration/record-settings-integrations-health.test.ts new file mode 100644 index 000000000..bbd8a61c6 --- /dev/null +++ b/tests/integration/record-settings-integrations-health.test.ts @@ -0,0 +1,125 @@ +/** + * A guardian reads the managed record's connections through the same + * server-resolved `health` the record's owner would see, and only the + * record's: a connection on the guardian's own account must not appear on + * the record, and the record's connection must answer from the record's rows. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables, switchSessionTo } from "./setup"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => { + cookieJar.set(name, value); + }, + delete: (name: string) => { + cookieJar.delete(name); + }, + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +interface ManagedIntegration { + integration: string; + state: string; + health: { state: string; reason: string } | null; +} + +async function readIntegrations(): Promise<{ + recordId: string; + integrations: ManagedIntegration[]; +}> { + const { GET } = await import("@/app/api/record-settings/integrations/route"); + const res = await GET(); + expect(res.status).toBe(200); + return (await res.json()).data; +} + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); +}); + +describe("GET /api/record-settings/integrations", () => { + it("publishes the record's own health per provider, never the guardian's", async () => { + const prisma = getPrismaClient(); + const guardian = await prisma.user.create({ + data: { + username: "health-guardian", + email: "health-guardian@example.test", + role: "USER", + timezone: "Europe/Berlin", + locale: "en", + onboardingCompletedAt: new Date(), + // The guardian's own Polar: must not surface on the record. + polarAccessTokenEncrypted: "not-a-real-token", + }, + }); + const { createManagedProfile } = + await import("@/lib/managed-profiles/create"); + const { profile } = await createManagedProfile({ + creatorId: guardian.id, + displayName: "Managed record", + dateOfBirth: null, + locale: "en", + timezone: "UTC", + gender: null, + }); + // The record's own Oura, synced once. + await prisma.user.update({ + where: { id: profile.id }, + data: { ouraAccessTokenEncrypted: "not-a-real-token" }, + }); + const { recordSyncSuccess } = await import("@/lib/integrations/status"); + await recordSyncSuccess(profile.id, "oura"); + + const session = await prisma.session.create({ + data: { + userId: guardian.id, + expiresAt: new Date(Date.now() + 60_000), + }, + }); + cookieJar.set("healthlog_session", session.id); + await switchSessionTo(session.id, profile.id); + + const data = await readIntegrations(); + expect(data.recordId).toBe(profile.id); + expect(data.integrations).toHaveLength(8); + + const byKey = new Map(data.integrations.map((i) => [i.integration, i])); + const oura = byKey.get("oura"); + expect(oura?.health).not.toBeNull(); + expect(oura?.health?.state).toBe("ok"); + // Health and the legacy state agree on "a pipe exists". + expect(oura?.state).toBe("connected"); + + // The guardian's Polar is the guardian's, not the record's. + expect(byKey.get("polar")?.health).toBeNull(); + for (const key of [ + "withings", + "whoop", + "fitbit", + "nightscout", + "google-health", + "strava", + ]) { + expect(byKey.get(key)?.health, key).toBeNull(); + } + }); +}); diff --git a/tests/integration/sharing-manage-handler-matrix.test.ts b/tests/integration/sharing-manage-handler-matrix.test.ts index 34583f305..6b7d83fce 100644 --- a/tests/integration/sharing-manage-handler-matrix.test.ts +++ b/tests/integration/sharing-manage-handler-matrix.test.ts @@ -795,7 +795,12 @@ manageContract("POST /api/measurement-reminders/[id]/snooze", { POST as Handler, "POST", `/api/measurement-reminders/${row.id}/snooze`, - { until: "2027-01-15" }, + // A snooze must end in the future; a fixed date stops being one. + { + until: new Date(Date.now() + 90 * 86_400_000) + .toISOString() + .slice(0, 10), + }, { id: row.id }, ); }, diff --git a/tests/integration/source-priority-two-axis.test.ts b/tests/integration/source-priority-two-axis.test.ts index b90ef2bd7..4710e0740 100644 --- a/tests/integration/source-priority-two-axis.test.ts +++ b/tests/integration/source-priority-two-axis.test.ts @@ -34,6 +34,15 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { cookieJar, headerJar } from "./mock-next-headers"; import { getPrismaClient, truncateAllTables } from "./setup"; +/** + * The night the fixtures sleep, ten days back from whenever the suite runs. + * The analytics route summarises a window that ends now; a fixed date slides + * out of it as the calendar moves on. + */ +const SLEEP_DAY = new Date(Date.now() - 10 * 86_400_000) + .toISOString() + .slice(0, 10); + vi.mock("next/headers", async () => { const { cookieJar, headerJar } = await import("./mock-next-headers"); return { @@ -130,7 +139,7 @@ describe("GET /api/analytics — two-axis canonical source resolution (W8c)", () value: 420, unit: "minutes", source: "WITHINGS", - measuredAt: new Date("2026-05-12T07:00:00.000Z"), + measuredAt: new Date(`${SLEEP_DAY}T07:00:00.000Z`), }, }); @@ -162,7 +171,7 @@ describe("GET /api/analytics — two-axis canonical source resolution (W8c)", () value: 480, unit: "minutes", source: "APPLE_HEALTH", - measuredAt: new Date("2026-05-12T06:00:00.000Z"), + measuredAt: new Date(`${SLEEP_DAY}T06:00:00.000Z`), externalId: "ah-sleep-1", }, }); @@ -174,7 +183,7 @@ describe("GET /api/analytics — two-axis canonical source resolution (W8c)", () value: 410, unit: "minutes", source: "WITHINGS", - measuredAt: new Date("2026-05-12T07:00:00.000Z"), + measuredAt: new Date(`${SLEEP_DAY}T07:00:00.000Z`), }, }); @@ -200,7 +209,7 @@ describe("GET /api/analytics — two-axis canonical source resolution (W8c)", () value: 380, unit: "minutes", source: "APPLE_HEALTH", - measuredAt: new Date("2026-05-12T06:00:00.000Z"), + measuredAt: new Date(`${SLEEP_DAY}T06:00:00.000Z`), externalId: "ah-sleep-watch", deviceType: "watch", }, @@ -212,7 +221,7 @@ describe("GET /api/analytics — two-axis canonical source resolution (W8c)", () value: 480, unit: "minutes", source: "APPLE_HEALTH", - measuredAt: new Date("2026-05-12T06:30:00.000Z"), + measuredAt: new Date(`${SLEEP_DAY}T06:30:00.000Z`), externalId: "ah-sleep-phone", deviceType: "phone", }, @@ -240,7 +249,7 @@ describe("GET /api/analytics — two-axis canonical source resolution (W8c)", () value: 480, unit: "minutes", source: "APPLE_HEALTH", - measuredAt: new Date("2026-05-12T06:00:00.000Z"), + measuredAt: new Date(`${SLEEP_DAY}T06:00:00.000Z`), externalId: "ah-sleep-watch", deviceType: "watch", }, @@ -252,7 +261,7 @@ describe("GET /api/analytics — two-axis canonical source resolution (W8c)", () value: 380, unit: "minutes", source: "APPLE_HEALTH", - measuredAt: new Date("2026-05-12T06:30:00.000Z"), + measuredAt: new Date(`${SLEEP_DAY}T06:30:00.000Z`), externalId: "ah-sleep-phone", deviceType: "phone", }, diff --git a/tests/integration/timezone-per-user.test.ts b/tests/integration/timezone-per-user.test.ts index 8a191e52a..218764d21 100644 --- a/tests/integration/timezone-per-user.test.ts +++ b/tests/integration/timezone-per-user.test.ts @@ -420,8 +420,15 @@ describe("per-user timezone — Pacific/Auckland end-to-end", () => { // doesn't trigger a tz divergence on its own. The point is to // confirm the route returns a non-zero streak and goes through // the user-tz path without crashing. + // Three consecutive days ending a week back: the summary reads its + // streaks from a window that ends now, so fixed dates would slide out. + const firstDay = Date.UTC( + new Date().getUTCFullYear(), + new Date().getUTCMonth(), + new Date().getUTCDate() - 9, + ); for (let i = 0; i < 3; i++) { - const dt = new Date(`2026-05-${12 + i}T11:30:00.000Z`); + const dt = new Date(firstDay + i * 86_400_000 + 11.5 * 3_600_000); await prisma.measurement.create({ data: { userId: me.id, diff --git a/tests/integration/visit-link-picker-long-history.test.ts b/tests/integration/visit-link-picker-long-history.test.ts new file mode 100644 index 000000000..b9d286a4d --- /dev/null +++ b/tests/integration/visit-link-picker-long-history.test.ts @@ -0,0 +1,311 @@ +/** + * A visit can link a record from anywhere in a long history. + * + * The visit form's lab picker loaded the newest 200 results and offered only + * those, the condition picker the newest 100 conditions. On a record with a + * long history everything older could not be linked: a 2019 visit could not + * reach its own 2019 lab panel. The pickers now open on a window around the + * visit's date and page and search through the list routes. + * + * Everything here goes through the shipped route exports with the requests + * the pickers build (`picker-requests.ts`), against a record of 1,200 lab + * results spread over seven years, so the window, the search and the paging + * are proven against real SQL, and the link is saved and read back. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +process.env.ENCRYPTION_KEY ??= + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +import { NextRequest } from "next/server"; + +import { + GET as listEncounters, + POST as createEncounter, +} from "@/app/api/encounters/route"; +import { GET as listEpisodes } from "@/app/api/illness/episodes/route"; +import { GET as listLabs } from "@/app/api/labs/route"; +import { + encounterPickerUrl, + episodePickerUrl, + labPickerUrl, +} from "@/components/links/picker-requests"; +import { + nextOffset, + pickerPivot, + type PickerPageRequest, +} from "@/components/links/use-link-picker-pages"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => cookieJar.set(name, value), + delete: (name: string) => cookieJar.delete(name), + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +const OWNER_ID = "long-history-owner"; +const DAY_MS = 24 * 60 * 60 * 1000; +/** Historical on purpose: the visit and its panel sit years back. */ +const VISIT_AT = "2019-04-03T09:30:00.000Z"; +const FERRITIN_AT = new Date("2019-04-03T07:00:00.000Z"); +const RESULT_COUNT = 1_200; + +async function seedOwner() { + const prisma = getPrismaClient(); + await prisma.user.create({ + data: { + id: OWNER_ID, + username: "long-history", + email: "long-history@example.test", + timezone: "Europe/Berlin", + modulePreferencesJson: { labs: true, illness: true }, + }, + }); + const session = await prisma.session.create({ + data: { + userId: OWNER_ID, + expiresAt: new Date(Date.now() + 7 * DAY_MS), + mfaVerifiedAt: new Date(), + }, + }); + cookieJar.set("healthlog_session", session.id); +} + +/** + * 1,200 glucose results, one every two days back from today, plus the one + * ferritin result drawn on the morning of the 2019 visit. + */ +async function seedLabs(): Promise { + const prisma = getPrismaClient(); + const now = Date.now(); + await prisma.labResult.createMany({ + data: Array.from({ length: RESULT_COUNT }, (_, i) => ({ + userId: OWNER_ID, + panel: "Metabolic", + analyte: "Glucose", + value: 90 + (i % 10), + unit: "mg/dL", + takenAt: new Date(now - (i + 1) * 2 * DAY_MS), + })), + }); + const ferritin = await prisma.labResult.create({ + data: { + userId: OWNER_ID, + panel: "Iron studies", + analyte: "Ferritin", + value: 54, + unit: "ng/mL", + takenAt: FERRITIN_AT, + }, + }); + return ferritin.id; +} + +async function get( + handler: (request: NextRequest) => Promise, + url: string, +): Promise { + const response = await handler(new NextRequest(`http://localhost${url}`)); + expect(response.status, url).toBe(200); + return ((await response.json()) as { data: T }).data; +} + +interface LabPage { + results: Array<{ id: string; analyte: string; takenAt: string }>; + meta: { total: number }; +} + +const labPage = (request: PickerPageRequest) => + get(listLabs as never, labPickerUrl(request)); + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); + await seedOwner(); +}); + +describe("the visit form's lab picker on a 1,200-result record", () => { + it("the old newest-200 page never reached the 2019 result", async () => { + const ferritinId = await seedLabs(); + const page = await get( + listLabs as never, + "/api/labs?limit=200&offset=0&sortDir=desc", + ); + expect(page.meta.total).toBe(RESULT_COUNT + 1); + expect(page.results.some((row) => row.id === ferritinId)).toBe(false); + }); + + it("opens on the visit's own date, with the 2019 result on the first page", async () => { + const ferritinId = await seedLabs(); + const pivot = pickerPivot(VISIT_AT); + const first = await labPage({ + direction: "older", + pivot, + term: "", + cursor: null, + }); + expect(first.results.some((row) => row.id === ferritinId)).toBe(true); + // Nothing after the window's split is on it. + for (const row of first.results) { + expect(new Date(row.takenAt).getTime()).toBeLessThanOrEqual( + new Date(pivot!).getTime(), + ); + } + + // "Load newer" pages forward from the split, oldest first, without + // repeating a row. + const seen = new Set(first.results.map((row) => row.id)); + let cursor: string | null = null; + let newerRows = 0; + let previous = new Date(pivot!).getTime(); + for (let guard = 0; guard < 20; guard++) { + const page = await labPage({ + direction: "newer", + pivot, + term: "", + cursor, + }); + for (const row of page.results) { + expect(seen.has(row.id)).toBe(false); + seen.add(row.id); + const at = new Date(row.takenAt).getTime(); + expect(at).toBeGreaterThanOrEqual(previous); + previous = at; + } + newerRows += page.results.length; + cursor = nextOffset(cursor, page.results.length); + if (cursor === null) break; + } + // Older window plus every newer page cover the whole record once. + expect(seen.size).toBe(RESULT_COUNT + 1); + expect(newerRows).toBeGreaterThan(0); + }); + + it("a search finds the 2019 result among 1,200 others, by analyte and by panel", async () => { + const ferritinId = await seedLabs(); + for (const term of ["ferritin", "iron stud"]) { + const page = await labPage({ + direction: "search", + pivot: null, + term, + cursor: null, + }); + expect(page.results.map((row) => row.id)).toEqual([ferritinId]); + } + }); + + it("the found result links to the visit and reads back on it", async () => { + const ferritinId = await seedLabs(); + const created = await createEncounter( + new Request("http://localhost/api/encounters", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + occurredAt: VISIT_AT, + status: "DONE", + kind: "ROUTINE", + labResultIds: [ferritinId], + }), + }) as never, + ); + expect(created.status).toBe(201); + + const list = await get<{ + past: Array<{ + id: string; + links?: { labResults: Array<{ id: string; label: string | null }> }; + }>; + }>( + listEncounters as never, + encounterPickerUrl({ + direction: "older", + pivot: pickerPivot(VISIT_AT), + term: "", + cursor: null, + }), + ); + expect(list.past).toHaveLength(1); + expect(list.past[0]!.links?.labResults).toEqual([ + expect.objectContaining({ + id: ferritinId, + label: "Iron studies · Ferritin", + }), + ]); + }); +}); + +describe("the condition picker's window and search", () => { + it("pages the episodes around a date and finds one by name", async () => { + const prisma = getPrismaClient(); + const now = Date.now(); + await prisma.illnessEpisode.createMany({ + data: Array.from({ length: 150 }, (_, i) => ({ + userId: OWNER_ID, + label: `Cold ${i}`, + type: "INFECTION" as const, + onsetAt: new Date(now - (i + 1) * 10 * DAY_MS), + })), + }); + const old = await prisma.illnessEpisode.create({ + data: { + userId: OWNER_ID, + label: "Tendinitis", + type: "INJURY", + onsetAt: new Date("2019-03-20T00:00:00.000Z"), + }, + }); + + const pivot = pickerPivot(VISIT_AT); + const around = await get>( + listEpisodes as never, + episodePickerUrl({ direction: "older", pivot, term: "", cursor: null }), + ); + expect(around[0]!.id).toBe(old.id); + + const found = await get>( + listEpisodes as never, + episodePickerUrl({ + direction: "search", + pivot: null, + term: "tendin", + cursor: null, + }), + ); + expect(found.map((row) => row.id)).toEqual([old.id]); + + // Offset paging walks the whole newer side without a repeat. + const ids = new Set(); + let cursor: string | null = null; + for (let guard = 0; guard < 5; guard++) { + const page: Array<{ id: string }> = await get( + listEpisodes as never, + episodePickerUrl({ direction: "newer", pivot, term: "", cursor }), + ); + for (const row of page) { + expect(ids.has(row.id)).toBe(false); + ids.add(row.id); + } + cursor = nextOffset(cursor, page.length); + if (cursor === null) break; + } + expect(ids.size).toBe(150); + }); +});