mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
Merge remote-tracking branch 'origin/release/v1.46.0' into fix/v146-security
# Conflicts: # src/lib/ai/coach/labs-snapshot.ts # src/lib/ai/coach/snapshot.ts
This commit is contained in:
commit
21aa791c3a
54 files changed
+544
-338
No files matched your search
@@ -50,7 +50,7 @@ Try the [live demo](https://demo.healthlog.dev) to see a working install — sig
|
||||
- **Clinical signals, opt-in and additive.** Validated self-checks for mental wellbeing and sleep (WHO-5, PHQ-9, GAD-7, Sleep Condition Indicator — official questionnaire wording, calm crisis-resource handling), grip strength, a 0–10 pain score, waist and body measurements, and a longevity lab panel — each added through one signal registry, so a new signal wires into charts, correlations, the coach, the FHIR map, and the API at once.
|
||||
- **Records, structured.** Allergies and family history as structured records that flow into the doctor report (reactions and notes are encrypted, the substance and condition names are not), plus lab values you key in or scan from a printed report — OCR extracts the stated values, units, and dates for you to review and confirm before anything is saved. HealthLog transcribes what a report says; it never interprets or diagnoses from it. A vaccination record keeps a lifetime immunization log, doses grouped under the disease they protect against, and prints in the doctor report.
|
||||
- **A document vault for the paper trail.** An opt-in module stores every letter, report, and scan encrypted at rest — PDFs and photos preview inline, Office and text files stay download-only, and nothing user-uploaded can ever execute in the app. One searchable timeline with type, condition, and year filters, drag-and-drop and paste-to-upload, bulk filing, undo-able delete with a 30-day grace, and per-file/per-account limits the operator tunes.
|
||||
- **Context that explains the numbers.** An opt-in environmental module folds weather, daylight, and temperature in as influence signals (winter darkness against mood, heat against blood pressure) using a home location you set, so a pattern has somewhere to point.
|
||||
- **Context that explains the numbers.** An environmental module folds weather, daylight, temperature, air quality, and pollen in as influence signals (winter darkness against mood, heat against blood pressure) using a home location you set, so a pattern has somewhere to point. It is on by default, and nothing leaves the host until someone sets that location.
|
||||
- **Your devices, one server.** Withings, WHOOP, Oura and Polar sync over OAuth2, Google Health connects Fitbit and Pixel Watch through your own Google Cloud client, Nightscout streams CGM glucose, an Apple Health `export.zip` folds your full history in, and the native iOS app streams HealthKit live. A per-metric source priority decides which reading is canonical when wearables overlap.
|
||||
- **Medication tracking that tells the truth.** Flexible schedules (weekly injections, weekday-only, intervals, PRN, cyclic), a configurable intake window per dose, and a traceable dose history where every slot reads taken, late, skipped, or missed — the same ledger the compliance rate is computed from, so the percentage can never disagree with the timeline.
|
||||
- **AI insights you own.** Daily briefing, health scores, correlations, and a conversational coach grounded in your own measurements and interpreted against cited guideline reference bands — where a value sits and what that means in plain words, never a diagnosis. The coach proposes concrete if-then plans you accept or decline, and suppresses proactive check-ins when there's nothing genuine to say. Bring your own OpenAI or Anthropic key, or point at a local endpoint (Ollama, LM Studio, vLLM) so AI requests stay on your network.
|
||||
@@ -85,7 +85,7 @@ Verify the running version:
|
||||
curl -s http://localhost:3000/api/version # returns version + buildSha + builtAt
|
||||
```
|
||||
|
||||
`SESSION_COOKIE_SECURE=false` is the one setting plain-HTTP self-hosts must set: without it the session cookie carries the `Secure` flag, the browser drops it over HTTP, and login silently fails. Leave it unset (or `true`) only when a TLS reverse proxy serves HTTPS — and then set `NEXT_PUBLIC_APP_URL` and `APP_URL` to the public URL first (see the [self-hosting guide](https://docs.healthlog.dev/self-hosting/)).
|
||||
`SESSION_COOKIE_SECURE=false` is the one setting plain-HTTP self-hosts must set: without it the session cookie carries the `Secure` flag, the browser drops it over HTTP, and login silently fails. Leave it unset (or `true`) only when a TLS reverse proxy serves HTTPS — and then set `NEXT_PUBLIC_APP_URL` and `APP_URL` to the public URL first (see the [self-hosting guide](https://docs.healthlog.dev/getting-started/self-host-in-10-minutes/)).
|
||||
|
||||
Persistent state lives in exactly one named volume (`postgres-data`, the Postgres data); the app container is stateless, so backing up the database backs up everything. An install from v1.45 or earlier moves its Postgres 16 database into it on the first start ([details](docs/ops/postgres-major-upgrade.md)).
|
||||
|
||||
|
||||
+13
-7
@@ -17,9 +17,13 @@ notes, and machine-readable specs that ship alongside the source:
|
||||
encryption-key rotation, password reset and account recovery, env
|
||||
checks, TLS pinning, data repair. These are what an operator reaches
|
||||
for during an incident.
|
||||
- [`self-hosting/`](./self-hosting/) — horizontal-scaling notes
|
||||
(`HEALTHLOG_PROCESS_TYPE=web|worker|all`) and the deploy-pipeline
|
||||
recipe. Routine install steps stay on `docs.healthlog.dev`. Includes
|
||||
- [`self-hosting/`](./self-hosting/) — operator notes next to the code:
|
||||
[`getting-started.md`](./self-hosting/getting-started.md) (install and
|
||||
updates, the page the boot log points at), the TrueNAS, Unraid and
|
||||
Portainer installs with their catalog templates, Tailscale, the reverse
|
||||
proxy, horizontal scaling (`HEALTHLOG_PROCESS_TYPE=web|worker|all`),
|
||||
notification channels, the environment module, document sources and
|
||||
reproductive-health data. Includes
|
||||
[`self-hosting/mcp.md`](./self-hosting/mcp.md) — enabling the
|
||||
off-by-default MCP connector and pointing Claude.ai / ChatGPT /
|
||||
Claude Desktop at it, and [`self-hosting/sso.md`](./self-hosting/sso.md)
|
||||
@@ -44,9 +48,11 @@ notes, and machine-readable specs that ship alongside the source:
|
||||
- [`migration/`](./migration/) — migration notes. Currently only
|
||||
v1.3 to v1.4; later releases are covered by `CHANGELOG.md` and
|
||||
<https://docs.healthlog.dev>.
|
||||
- [`integrations/`](./integrations/) — per-integration reference
|
||||
(Withings, Fitbit, Google Health, WHOOP, Apple Health, AI providers,
|
||||
data import).
|
||||
- [`integrations/`](./integrations/) — per-integration reference:
|
||||
Withings, WHOOP, Fitbit, Google Health, Strava, Nightscout, Dawarich and
|
||||
the Garmin route; the Apple Health, Health Connect, document and data
|
||||
imports; AI providers; and Home Assistant with its blueprint. Oura and
|
||||
Polar are covered on the docs site only.
|
||||
- [`adr/`](./adr/) — architecture decision records.
|
||||
- [`security/`](./security/) — threat-model notes.
|
||||
- [`diagrams/`](./diagrams/) — architecture diagrams rendered through
|
||||
@@ -70,4 +76,4 @@ notes, and machine-readable specs that ship alongside the source:
|
||||
|
||||
If you're a self-hoster looking to spin up an instance, start at
|
||||
<https://docs.healthlog.dev> or jump to the [Quick Start in the project
|
||||
README](../README.md#quick-start).
|
||||
README](../README.md#quick-start-docker-compose).
|
||||
+34
-35
@@ -82145,7 +82145,7 @@ components:
|
||||
- type: "null"
|
||||
syncHealth:
|
||||
deprecated: true
|
||||
description: "Deprecated: removed in v1.44, read `health`. Present on the GET read; omitted from the PATCH echo."
|
||||
description: "Deprecated, still served: read `health`. Present on the GET read; omitted from the PATCH echo."
|
||||
$ref: "#/components/schemas/SyncHealth"
|
||||
metricFreshness:
|
||||
description: Per-metric-type freshness for the `APPLE_HEALTH` source, with the per-type arrival facts. Present on the
|
||||
@@ -82823,7 +82823,7 @@ components:
|
||||
type: boolean
|
||||
state:
|
||||
deprecated: true
|
||||
description: "Deprecated: removed in v1.44, read `health`."
|
||||
description: "Deprecated, still served: read `health`."
|
||||
type: string
|
||||
enum:
|
||||
- connected
|
||||
@@ -82846,14 +82846,14 @@ components:
|
||||
- type: "null"
|
||||
lastError:
|
||||
deprecated: true
|
||||
description: "Deprecated: removed in v1.44, read `health`. Last recorded failure message, decrypted from the ledger.
|
||||
Null when the last attempt succeeded or none has run."
|
||||
description: "Deprecated, still served: read `health`. Last recorded failure message, decrypted from the ledger. Null
|
||||
when the last attempt succeeded or none has run."
|
||||
type:
|
||||
- string
|
||||
- "null"
|
||||
syncHealth:
|
||||
deprecated: true
|
||||
description: "Deprecated: removed in v1.44, read `health`."
|
||||
description: "Deprecated, still served: read `health`."
|
||||
$ref: "#/components/schemas/SyncHealth"
|
||||
health:
|
||||
description: The unified connection state. Present whenever the connection exists; null in the rare case it resolved to
|
||||
@@ -83042,9 +83042,9 @@ components:
|
||||
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)))$
|
||||
tokenExpired:
|
||||
deprecated: true
|
||||
description: "Deprecated: removed in v1.44, read `health`. The stored access token's expiry is in the past. Read
|
||||
straight off the row — unlike the Withings status read, this endpoint does not refresh; the sync path
|
||||
refreshes lazily instead."
|
||||
description: "Deprecated, still served: read `health`. The stored access token's expiry is in the past. Read straight
|
||||
off the row — unlike the Withings status read, this endpoint does not refresh; the sync path refreshes
|
||||
lazily instead."
|
||||
type: boolean
|
||||
tokenExpiresAt:
|
||||
type: string
|
||||
@@ -83133,8 +83133,7 @@ components:
|
||||
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)))$
|
||||
tokenExpired:
|
||||
deprecated: true
|
||||
description: "Deprecated: removed in v1.44, read `health`. True after a refresh attempt that did not produce a live
|
||||
token."
|
||||
description: "Deprecated, still served: read `health`. True after a refresh attempt that did not produce a live token."
|
||||
type: boolean
|
||||
tokenRefreshFailed:
|
||||
description: The read tried to refresh an expired token and could not — either the per-user credentials are gone or
|
||||
@@ -83154,8 +83153,8 @@ components:
|
||||
- "null"
|
||||
hasActivityScope:
|
||||
deprecated: true
|
||||
description: "Deprecated: removed in v1.44, read `health`. Pre-computed convenience flag the reconnect banner reads. A
|
||||
null `scope` counts as missing activity."
|
||||
description: "Deprecated, still served: read `health`. 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
|
||||
@@ -83464,8 +83463,8 @@ components:
|
||||
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)))$
|
||||
tokenExpired:
|
||||
deprecated: true
|
||||
description: "Deprecated: removed in v1.44, read `health`. Read straight off the row — this endpoint does not refresh;
|
||||
the sync path refreshes lazily."
|
||||
description: "Deprecated, still served: read `health`. Read straight off the row — this endpoint does not refresh; the
|
||||
sync path refreshes lazily."
|
||||
type: boolean
|
||||
tokenExpiresAt:
|
||||
type: string
|
||||
@@ -83635,8 +83634,8 @@ components:
|
||||
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)))$
|
||||
tokenExpired:
|
||||
deprecated: true
|
||||
description: "Deprecated: removed in v1.44, read `health`. Read straight off the row — this endpoint does not refresh;
|
||||
the sync path refreshes lazily."
|
||||
description: "Deprecated, still served: read `health`. Read straight off the row — this endpoint does not refresh; the
|
||||
sync path refreshes lazily."
|
||||
type: boolean
|
||||
tokenExpiresAt:
|
||||
type: string
|
||||
@@ -83686,7 +83685,7 @@ components:
|
||||
when every type was read to its start. Kept after the backfill completes; cleared on reconnect.
|
||||
needsReauth:
|
||||
deprecated: true
|
||||
description: "Deprecated: removed in v1.44, read `health`. The stored refresh token was rejected with `invalid_grant` —
|
||||
description: "Deprecated, still served: read `health`. 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` and from `disconnected`: the connection still exists, it just
|
||||
cannot mint a token until the user reconnects. Only Google Health has this field."
|
||||
@@ -83890,7 +83889,7 @@ components:
|
||||
description: The user has stored their own BYO client id and secret.
|
||||
state:
|
||||
deprecated: true
|
||||
description: "Deprecated: removed in v1.44, read `health`."
|
||||
description: "Deprecated, still served: read `health`."
|
||||
type: string
|
||||
enum:
|
||||
- connected
|
||||
@@ -83913,13 +83912,13 @@ components:
|
||||
- type: "null"
|
||||
lastError:
|
||||
deprecated: true
|
||||
description: "Deprecated: removed in v1.44, read `health`."
|
||||
description: "Deprecated, still served: read `health`."
|
||||
type:
|
||||
- string
|
||||
- "null"
|
||||
syncHealth:
|
||||
deprecated: true
|
||||
description: "Deprecated: removed in v1.44, read `health`."
|
||||
description: "Deprecated, still served: read `health`."
|
||||
$ref: "#/components/schemas/SyncHealth"
|
||||
health:
|
||||
description: The unified connection state. Present whenever the connection exists; null in the rare case it resolved to
|
||||
@@ -84314,7 +84313,7 @@ components:
|
||||
- disconnected
|
||||
- parked
|
||||
- unknown
|
||||
description: "Deprecated: removed in v1.44, read `health`."
|
||||
description: "Deprecated, still served: read `health`."
|
||||
deprecated: true
|
||||
lastSuccessAt:
|
||||
anyOf:
|
||||
@@ -84330,7 +84329,7 @@ components:
|
||||
- type: "null"
|
||||
lastError:
|
||||
deprecated: true
|
||||
description: "Deprecated: removed in v1.44, read `health`."
|
||||
description: "Deprecated, still served: read `health`."
|
||||
type:
|
||||
- string
|
||||
- "null"
|
||||
@@ -84357,11 +84356,11 @@ components:
|
||||
additionalProperties: false
|
||||
- type: "null"
|
||||
deprecated: true
|
||||
description: "Deprecated: removed in v1.44, read `health`. Per-kind consecutive-failure counters, or null when the row
|
||||
has never written a bucket payload. Compare against the top-level `threshold` to know how close the provider
|
||||
is to the operator alert."
|
||||
description: "Deprecated, still served: read `health`. Per-kind consecutive-failure counters, or null when the row has
|
||||
never written a bucket payload. Compare against the top-level `threshold` to know how close the provider is
|
||||
to the operator alert."
|
||||
syncHealth:
|
||||
description: "Deprecated: removed in v1.44, read `health`."
|
||||
description: "Deprecated, still served: read `health`."
|
||||
deprecated: true
|
||||
$ref: "#/components/schemas/SyncHealth"
|
||||
metricFreshness:
|
||||
@@ -84398,7 +84397,7 @@ components:
|
||||
- type: "null"
|
||||
tokenExpired:
|
||||
deprecated: true
|
||||
description: "Deprecated: removed in v1.44, read `health`. Null — not false — when there is no connection to judge."
|
||||
description: "Deprecated, still served: read `health`. Null — not false — when there is no connection to judge."
|
||||
type:
|
||||
- boolean
|
||||
- "null"
|
||||
@@ -84441,14 +84440,14 @@ components:
|
||||
- "null"
|
||||
hasActivityScope:
|
||||
deprecated: true
|
||||
description: "Deprecated: removed in v1.44, read `health`. Withings only."
|
||||
description: "Deprecated, still served: read `health`. Withings only."
|
||||
type: boolean
|
||||
planUnavailable:
|
||||
deprecated: true
|
||||
description: "Deprecated: removed in v1.44, read `health`. Withings only. The readings this connection's Withings API
|
||||
plan withholds (status 277 since the 2026-10-12 plan split), deduplicated and in display order. Empty when
|
||||
the plan refuses nothing. The sync skips these and keeps importing everything else; the list refreshes when
|
||||
the weekly re-probe asks again."
|
||||
description: "Deprecated, still served: read `health`. Withings only. The readings this connection's Withings API plan
|
||||
withholds (status 277 since the 2026-10-12 plan split), deduplicated and in display order. Empty when the
|
||||
plan refuses nothing. The sync skips these and keeps importing everything else; the list refreshes when the
|
||||
weekly re-probe asks again."
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
@@ -84477,7 +84476,7 @@ components:
|
||||
- weight
|
||||
needsReauth:
|
||||
deprecated: true
|
||||
description: "Deprecated: removed in v1.44, read `health`. Google Health only."
|
||||
description: "Deprecated, still served: read `health`. Google Health only."
|
||||
type: boolean
|
||||
available:
|
||||
description: Oura / Polar / Strava only.
|
||||
@@ -84487,7 +84486,7 @@ components:
|
||||
type: boolean
|
||||
scopeReconnect:
|
||||
deprecated: true
|
||||
description: "Deprecated: removed in v1.44, read `health`. Oura only. Collections the stored grant does not cover: Oura
|
||||
description: "Deprecated, still served: read `health`. Oura only. Collections the stored grant does not cover: Oura
|
||||
refused them for a missing scope (named in its 401, or inferred when sibling collections succeeded with the
|
||||
same token), or the scopes Oura reported at connect leave theirs out. Not a failure and not counted toward
|
||||
the reconnect threshold; one reconnect asks for the missing scopes. Empty when the grant covers everything
|
||||
@@ -84543,7 +84542,7 @@ components:
|
||||
description: The unified connection state, null when the provider is not connected (and for Fitbit once
|
||||
`retirement.ended`). It supersedes `state`, `lastError`, `consecutiveFailuresByKind`, `syncHealth`,
|
||||
`tokenExpired`, `needsReauth`, `hasActivityScope`, `planUnavailable` and `scopeReconnect` on this entry;
|
||||
those nine are deprecated and removed in v1.44.
|
||||
those nine are deprecated and still served for clients that predate `health`.
|
||||
required:
|
||||
- integration
|
||||
- state
|
||||
|
||||
@@ -295,7 +295,7 @@ Concrete consequences:
|
||||
RMSSD HRV has its own ladder, **WHOOP ≻ OURA ≻ POLAR ≻ APPLE_HEALTH**,
|
||||
separate from the SDNN one.
|
||||
|
||||
Override per-user via Settings → Sources (`/settings/sources`).
|
||||
Override per-user via Settings → Source priority (`/settings/sources`).
|
||||
|
||||
## Failure modes
|
||||
|
||||
|
||||
@@ -1,82 +1,7 @@
|
||||
# Dawarich integration
|
||||
|
||||
HealthLog can read daily movement context from a [Dawarich](https://dawarich.app)
|
||||
instance: how far you moved, how much of it by car or by train, how many places
|
||||
you visited, how long you were away from home, and whether a day was spent in
|
||||
another country. It is part of the Environment module and runs only while that
|
||||
module is on.
|
||||
|
||||
HealthLog never stores a coordinate, a track, a place name or a visit. It reads
|
||||
the day summaries and keeps one row of numbers per day. The place list is read
|
||||
live when you pick your home and is not saved.
|
||||
|
||||
## What is read
|
||||
|
||||
Once a night (03:20 Europe/Berlin) HealthLog fetches the last seven days, ending
|
||||
yesterday. Connecting fetches the last 30 days. Each request carries your API
|
||||
key as `Authorization: Bearer <key>`.
|
||||
|
||||
| Dawarich endpoint | What HealthLog keeps |
|
||||
| ----------------------- | -------------------------------------------------------------------------------------- |
|
||||
| `GET /api/v1/timeline` | total distance, distance by car (`driving`), distance by train, places visited |
|
||||
| `GET /api/v1/visits` | minutes spent at your home place, to work out time away from home |
|
||||
| `GET /api/v1/residency` | the country of each day, sealed at rest, and whether it differs from your home country |
|
||||
| `GET /api/v1/places` | nothing; the list is shown once so you can pick your home |
|
||||
| `GET /api/v1/users/me` | nothing; used to check the key |
|
||||
|
||||
Time away from home is the day's length minus the time a visit at your home
|
||||
place covers. Without a home place, or on a day without a single visit, it is
|
||||
left empty rather than shown as zero. Dawarich leaves out a mode that moved
|
||||
less than half a kilometre; HealthLog then reads that mode as zero for the day.
|
||||
|
||||
Residency needs API access on a hosted Dawarich plan; a self-hosted instance
|
||||
has it. Without it there are no travel days, and everything else still works.
|
||||
Dawarich keys residency by UTC date, so on a day that crosses a border late in
|
||||
the evening the country can be the next morning's.
|
||||
|
||||
## Connect
|
||||
|
||||
1. In Dawarich, open your account settings and copy the API key.
|
||||
2. In HealthLog, turn on the Environment module, then open
|
||||
**Settings → Integrations → Dawarich**.
|
||||
3. Enter the address of the instance and the key, and connect.
|
||||
4. Pick your home place from the list, and set your home country if you like.
|
||||
Without one, the country you spent most days in over the last year counts.
|
||||
|
||||
Run Dawarich and HealthLog in the same time zone. Dawarich groups the
|
||||
timeline by its own zone, HealthLog by yours; if they differ, a day can come
|
||||
back without its distances.
|
||||
|
||||
## A Dawarich instance on your own network
|
||||
|
||||
A public instance works without any server setting. An instance on a private
|
||||
network (a LAN address, a `.lan` name, a container on the same host) is refused
|
||||
unless the operator lists its exact origin:
|
||||
|
||||
```env
|
||||
DAWARICH_PRIVATE_ORIGINS="http://dawarich.lan:3000"
|
||||
```
|
||||
|
||||
The grammar is the one `NIGHTSCOUT_PRIVATE_ORIGINS` uses
|
||||
([Nightscout](./nightscout.md)): one `scheme://host[:port]` per entry, comma
|
||||
separated, no path, no wildcard and no range. Listing a host does not open its
|
||||
subdomains or another port. The unspecified address, link-local and the
|
||||
cloud-metadata range can never be listed. Restart HealthLog after changing it.
|
||||
The variable is on the `environment:` whitelist in `docker-compose.yml`.
|
||||
|
||||
## Where the values show up
|
||||
|
||||
- The day view has an **On the move** section with the day's figures.
|
||||
- Time away from home and the distance travelled join the correlation scan as
|
||||
two more factors.
|
||||
- Runs of at least two travel days that no trip covers yet are offered as
|
||||
trips under **Settings → Environment → Location periods**. Using one fills
|
||||
in the dates; you pick the place.
|
||||
- The Coach and the MCP `get_day` tool see the figures and whether it was a
|
||||
travel day, never the country.
|
||||
|
||||
## Disconnect
|
||||
|
||||
Disconnecting stops the nightly fetch and keeps the stored days. **Disconnect
|
||||
and delete days** removes them as well. The days are part of the backup; the
|
||||
address and key are not, so a restored account connects again.
|
||||
The Dawarich guide lives on the docs site:
|
||||
<https://docs.healthlog.dev/guides/connect-dawarich/>. It covers what is read
|
||||
and what is never stored, connecting an instance, allowing one on your own
|
||||
network (`DAWARICH_PRIVATE_ORIGINS`), where the values show up, and
|
||||
disconnecting.
|
||||
@@ -103,7 +103,7 @@ The Fitbit client id/secret are per-user (Settings), not env vars.
|
||||
|
||||
If Fitbit and another source (Withings, WHOOP, Apple Health) both supply the same
|
||||
vital — resting heart rate, blood oxygen, body temperature or respiratory rate —
|
||||
the source priority ladder in **Settings → Sources** decides which one wins
|
||||
the source priority ladder in **Settings → Source priority** decides which one wins
|
||||
per metric. FITBIT is ranked in every default metric ladder; override
|
||||
per metric on that page.
|
||||
|
||||
|
||||
@@ -305,7 +305,7 @@ triggers the verification + CASA requirements above.)
|
||||
|
||||
If Google Health and another source (Withings, WHOOP, Apple Health) both supply
|
||||
the same vital — resting heart rate, blood oxygen, respiratory rate — the source
|
||||
priority ladder in **Settings → Sources** decides which one wins
|
||||
priority ladder in **Settings → Source priority** decides which one wins
|
||||
per metric.
|
||||
|
||||
## Disconnect
|
||||
|
||||
@@ -1,74 +1,6 @@
|
||||
# Strava integration
|
||||
|
||||
HealthLog reads your Strava activities and folds them into the same workout
|
||||
history as every other source. Strava is a **workout source only** — its API is
|
||||
activity-centric and exposes no sleep, recovery, body-composition or glucose
|
||||
data, so connecting it adds runs, rides, swims and the rest to your workout
|
||||
surface and nothing else.
|
||||
|
||||
The OAuth app is brought **per user**: each self-hoster registers their own
|
||||
Strava API application and pastes the client id/secret into Settings. Strava
|
||||
caps every newly-created app at "single-player mode" (an athlete capacity of 1)
|
||||
until you request a capacity increase — perfectly fine for a personal
|
||||
self-host, and the reason a single shared app cannot serve many operators.
|
||||
|
||||
## What you get — and what you don't
|
||||
|
||||
**Covered** (one `Workout` row per activity): sport type, start and end time,
|
||||
moving duration, distance, elevation gain, average and maximum heart rate (when
|
||||
the activity recorded it), and calories. Power, cadence, the "trainer"/"commute"
|
||||
flags and the activity name are kept on the row's metadata for provenance but
|
||||
are **not** surfaced as training analytics — HealthLog treats Strava as another
|
||||
workout source, not a training-load platform.
|
||||
|
||||
**Not covered**: Strava exposes no daily metrics (steps, sleep, resting heart
|
||||
rate, weight, …). Those come from your wearable's own integration or from Apple
|
||||
Health / Google Health Connect.
|
||||
|
||||
## Duplicate activities are collapsed automatically
|
||||
|
||||
If you record a run on an Apple Watch **and** it also lands in Strava, the same
|
||||
run arrives twice — once via Apple Health, once via Strava. HealthLog keeps one
|
||||
canonical row per logical workout using your **source-priority ladder**: by
|
||||
default the device-native capture (Apple Watch, WHOOP, Withings) wins over the
|
||||
Strava copy, because a Strava activity is often a re-upload with lower-fidelity
|
||||
or missing heart-rate data. A run you record **only** on Strava (phone GPS)
|
||||
survives untouched. You can promote Strava above the wearables in
|
||||
**Settings → Sources** if you prefer the Strava copy.
|
||||
|
||||
## 1. Register a Strava API application
|
||||
|
||||
1. Sign in at <https://www.strava.com/settings/api>.
|
||||
2. Create an application. For **Authorization Callback Domain** enter the host
|
||||
of your HealthLog instance (the domain only, no scheme or path).
|
||||
3. Copy the **Client ID** and **Client Secret**.
|
||||
|
||||
## 2. Paste the credentials into HealthLog
|
||||
|
||||
1. Open **Settings → Integrations** and find the **Strava** card.
|
||||
2. Paste the Client ID and Client Secret into the **API Credentials** fields and
|
||||
save. (An operator can instead configure a shared app via the
|
||||
`STRAVA_CLIENT_ID` / `STRAVA_CLIENT_SECRET` environment variables; per-user
|
||||
credentials always take precedence.)
|
||||
3. Click **Connect Strava** and approve the access request. HealthLog requests
|
||||
the `activity:read_all` scope so it can read every activity, including those
|
||||
you marked "only me".
|
||||
|
||||
## 3. First sync
|
||||
|
||||
The first history import runs in the background shortly after you connect;
|
||||
afterwards an hourly poll pulls new activities. Strava rotates its refresh token
|
||||
on every refresh, and HealthLog persists the rotated token each time, so the
|
||||
connection stays healthy without you re-authorising.
|
||||
|
||||
## Disconnecting
|
||||
|
||||
Disconnecting revokes the grant at Strava and clears the stored tokens.
|
||||
Activities already imported stay in your history; reconnecting resumes the sync.
|
||||
|
||||
## Related
|
||||
|
||||
- Wearable that syncs to Strava but not directly to HealthLog? See the device's
|
||||
own page, or ingest via [Apple Health](./apple-health.md) /
|
||||
[Google Health Connect](./google-health.md).
|
||||
- **Garmin owners:** Garmin has no direct connector — see [garmin.md](./garmin.md).
|
||||
The Strava guide lives on the docs site:
|
||||
<https://docs.healthlog.dev/guides/connect-strava/>. It covers what Strava
|
||||
adds (workouts only), how duplicate activities from a wearable are collapsed,
|
||||
registering the Strava API application, the first sync and disconnecting.
|
||||
@@ -88,7 +88,7 @@ or right away after reconnecting Withings once.
|
||||
needed — `user.metrics` covers weight / BP / heart-list / sleep /
|
||||
SpO₂ / body comp / temperature / VO₂ max; `user.activity` is the
|
||||
workout-equivalent stream for steps / energy / distance / floors.
|
||||
See `WITHINGS_OAUTH_SCOPE` in `src/lib/withings/client.ts`.
|
||||
See `WITHINGS_OAUTH_SCOPE` in `src/lib/withings/oauth.ts`.
|
||||
4. Save the application. Withings will issue a **Client ID** and a
|
||||
**Client Secret** — keep the secret out of any chat log; it grants
|
||||
read access to every linked account's full health history.
|
||||
@@ -145,7 +145,7 @@ callback. The portal only owns the OAuth callback URL.
|
||||
## 4. Link from the Settings page
|
||||
|
||||
With the credentials saved in Settings, open
|
||||
`/settings/integrations/withings` and click **Connect Withings**. The flow:
|
||||
`/settings/integrations#withings` and click **Connect Withings**. The flow:
|
||||
|
||||
1. The Connect button redirects to Withings' `oauth2/authorize`.
|
||||
2. The user signs in on Withings and grants the requested scopes.
|
||||
@@ -181,7 +181,7 @@ import:
|
||||
when Apple Health is also linked, so the Apple Health stream wins
|
||||
for the cumulative axis. The Withings rows stay in the DB.
|
||||
|
||||
Override per-user via Settings → Sources (`/settings/sources`).
|
||||
Override per-user via Settings → Source priority (`/settings/sources`).
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
@@ -199,7 +199,7 @@ Override per-user via Settings → Sources (`/settings/sources`).
|
||||
### The webhook stops firing
|
||||
|
||||
Webhook subscriptions can lapse after a token revoke or a portal-
|
||||
side cleanup. Re-subscribe by opening `/settings/integrations/withings`
|
||||
side cleanup. Re-subscribe by opening `/settings/integrations#withings`
|
||||
and clicking **Disconnect** then **Connect Withings** again — the
|
||||
callback exchange re-runs `Notify.subscribe()` with a fresh callback
|
||||
URL.
|
||||
|
||||
+5
-2
@@ -270,10 +270,13 @@ always confirm the served build after a deploy:
|
||||
```sh
|
||||
# Asserts prod + demo serve 1.9.0, prints each target's buildSha,
|
||||
# exits non-zero on a mismatch or timeout after a bounded retry loop.
|
||||
pnpm dlx tsx scripts/assert-deploy.ts 1.9.0
|
||||
HEALTHLOG_PROD_URL=https://healthlog.example.com \
|
||||
pnpm dlx tsx scripts/assert-deploy.ts 1.9.0
|
||||
```
|
||||
|
||||
Pass `--only=prod` / `--only=demo` to check a single target, or
|
||||
`HEALTHLOG_PROD_URL` is the base URL of your instance; it has no default and
|
||||
is required unless you pass `--only=demo`. Pass `--only=prod` /
|
||||
`--only=demo` to check a single target, or
|
||||
`--attempts=N --interval=ms` to widen the retry window while a slow
|
||||
pull settles.
|
||||
|
||||
|
||||
@@ -96,8 +96,9 @@ maintenance refuses to start while one is.
|
||||
|
||||
Have at least twice the largest size free on the database volume.
|
||||
|
||||
- **Take a backup** (Settings, Data, Backups), as before any operation on the
|
||||
whole table.
|
||||
- **Take a backup**: a database dump, as in
|
||||
[Updates](../self-hosting/getting-started.md#updates), as before any
|
||||
operation on the whole table.
|
||||
|
||||
## Starting it
|
||||
|
||||
|
||||
@@ -214,11 +214,15 @@ as something to log in as.
|
||||
or delete the profile under a fresh second factor. Deleting the profile
|
||||
deletes everything recorded for it.
|
||||
- A guardian may keep that record's settings: modules, units, thresholds,
|
||||
its language and timezone, and its notification preferences. Connected
|
||||
services are visible as a status only. Connect, callback, sync,
|
||||
credential, disconnect, resume and test are not guardian surfaces in
|
||||
this release, and their deep links land on the shared-record unavailable
|
||||
page rather than on a raw refusal.
|
||||
its language and timezone, and its notification preferences. A guardian
|
||||
can also connect and disconnect the record's Withings, WHOOP, Oura, Polar,
|
||||
Strava and Google Health from its integrations page; the data lands in the
|
||||
profile's record, and a profile without its own provider app uses the
|
||||
guardian's. Fitbit (no new connections), Nightscout and Dawarich cannot be
|
||||
connected for a profile. Sync, the connection test and provider
|
||||
credentials are not guardian surfaces: once connected, the record syncs on
|
||||
its own schedule, and their deep links land on the shared-record
|
||||
unavailable page rather than on a raw refusal.
|
||||
- **Its reminders go to its guardians.** A record with no login has no
|
||||
channels of its own, so medication reminders, measurement reminders,
|
||||
safety-floor alerts and low-stock alerts fan out to every active
|
||||
|
||||
@@ -86,10 +86,11 @@ backfill button in settings fetches both for the range it is given.
|
||||
Since v1.42 the home, each dated location period and each stored day keep
|
||||
their coarse location sealed with the instance's encryption key (one value
|
||||
per row, `{ lat, lon, label }`, under the label
|
||||
`healthlog/environment-location/v1`). The boot-time encryption backfill seals
|
||||
rows written by older releases and clears their readable columns; the
|
||||
readable columns are dropped in a later release once the backfill reports
|
||||
zero rows everywhere. Key rotation (`scripts/rotate-encryption-key.ts`, or the
|
||||
`healthlog/environment-location/v1`). The boot-time encryption backfill sealed
|
||||
rows written by older releases and cleared their readable columns, and
|
||||
migration `0409` (v1.43) dropped those columns; it refuses to run while a row
|
||||
still holds a readable location
|
||||
([migration-0409-plaintext-location.md](../ops/migration-0409-plaintext-location.md)). Key rotation (`scripts/rotate-encryption-key.ts`, or the
|
||||
in-app rotation) covers all three.
|
||||
|
||||
What the person sees is unchanged: the settings page and
|
||||
|
||||
@@ -9,8 +9,7 @@ already installed, longer if a TLS-fronted public hostname is in scope
|
||||
|
||||
- **Docker 24+ with Compose v2.** The bundled `docker-compose.yml`
|
||||
pulls a multi-arch image from GitHub Container Registry (linux/amd64
|
||||
- linux/arm64), so a Raspberry Pi 5 or an x86 VPS both work
|
||||
unchanged.
|
||||
and linux/arm64), so a Raspberry Pi 5 or an x86 VPS both work unchanged.
|
||||
- **3 GB RAM (4 GB recommended), 10 GB free disk.** The Postgres data
|
||||
volume grows with measurement history; the Apple Health import worker
|
||||
can briefly hold a 1.5 GB upload in `/tmp` while it parses. The app
|
||||
@@ -23,11 +22,12 @@ already installed, longer if a TLS-fronted public hostname is in scope
|
||||
below the supported floor. On a busy instance, raise it.
|
||||
- **Outbound HTTPS** to `ghcr.io` (image pulls) plus whichever
|
||||
integration endpoints you plan to enable (Withings, OpenAI, etc.).
|
||||
The opt-in environmental-context module additionally reaches
|
||||
`archive-api.open-meteo.com` and `geocoding-api.open-meteo.com` for
|
||||
weather and daylight; it stays off until you
|
||||
enable it and set a home location, and every outbound call goes
|
||||
through the SSRF-guarded egress wrapper.
|
||||
The environmental-context module additionally reaches
|
||||
`archive-api.open-meteo.com`, `air-quality-api.open-meteo.com` and
|
||||
`geocoding-api.open-meteo.com` for weather, air quality and the city
|
||||
search. It is on by default, but nothing leaves the host until someone
|
||||
sets a home location, and every outbound call goes through the
|
||||
SSRF-guarded egress wrapper.
|
||||
Three requests happen by default without anything connected: the
|
||||
HaveIBeenPwned range query when a password is set (only the first five
|
||||
characters of the password's SHA-1 are sent), a daily check of the
|
||||
|
||||
@@ -19,7 +19,7 @@ on top of it, see [building skills](../api/mcp-skills.md).
|
||||
|
||||
> **Read this before enabling writes.** A remote, write-capable connector
|
||||
> reached over OAuth is a powerful combination. Read the
|
||||
> [trust model](#trust-model-read-this-before-enabling-writes) at the end
|
||||
> [trust model](#trust-model--read-this-before-enabling-writes) at the end
|
||||
> of this page first.
|
||||
|
||||
## Prerequisites
|
||||
@@ -43,7 +43,7 @@ LAN-only origin works for Claude Desktop over stdio).
|
||||
The switch lives with every other module: **Settings → Modules**, the
|
||||
**MCP** row. Turn it on and the `/mcp` endpoint goes live for your account.
|
||||
|
||||
**Settings → MCP** is where you use it. The page has three parts:
|
||||
**Settings → MCP connector** is where you use it. The page has three parts:
|
||||
|
||||
- **State** — whether the module is on, with a link to its switch in
|
||||
Modules, and your endpoint URL (`<origin>/mcp`). Turning the module off
|
||||
@@ -55,7 +55,7 @@ The switch lives with every other module: **Settings → Modules**, the
|
||||
|
||||
## 2. Mint a connector token
|
||||
|
||||
Still in **Settings → MCP → Tokens**:
|
||||
Still in **Settings → MCP connector → Tokens**:
|
||||
|
||||
1. Choose the scope with the **write** toggle:
|
||||
- **off** → a read-only token (`health:read`). This is the default and
|
||||
@@ -106,7 +106,7 @@ refused (`403`) as a DNS-rebinding defence.
|
||||
client-id-metadata-document clients with dynamic registration as a
|
||||
fallback.
|
||||
3. Approve the consent screen. The connection then appears under
|
||||
**Settings → MCP → Connections**, where you can revoke it.
|
||||
**Settings → MCP connector → Connections**, where you can revoke it.
|
||||
|
||||
No token to paste — OAuth mints and rotates a short-lived `health:read`
|
||||
access token (60 minutes, with a refresh token) behind the scenes.
|
||||
|
||||
@@ -95,7 +95,7 @@ different things depending on which button you press.
|
||||
two actions below, the row stays. A start that was folded into a later
|
||||
cycle stays for as long as that cycle does, because deleting the later
|
||||
cycle gives it back.
|
||||
- **Settings → Delete All Data** removes the rows outright, tombstones
|
||||
- **Settings → Advanced → Delete all data** removes the rows outright, tombstones
|
||||
included. Everything the account owns goes except what you sign in
|
||||
with; the confirmation dialog lists it.
|
||||
- **Deleting the account** removes the account row, and the database
|
||||
|
||||
@@ -49,9 +49,10 @@ HealthLog behind a reverse proxy with its own domain, change App URL to that
|
||||
|
||||
Settings the form does not show go under Additional Environment Variables, for
|
||||
example `UPDATE_CHECK_DISABLED=1`. The full list is in
|
||||
[environment.md](environment.md). To use your own GeoLite2 databases, add the
|
||||
directory under Additional Storage and point `GEOLITE2_DIR` at its mount path
|
||||
([geolite2.md](geolite2.md)).
|
||||
[`.env.production.example`](../../.env.production.example) and on the
|
||||
[environment variables reference](https://docs.healthlog.dev/reference/environment-variables/).
|
||||
To use your own GeoLite2 databases, add the directory under Additional Storage
|
||||
and point `GEOLITE2_DIR` at its mount path ([geolite2.md](geolite2.md)).
|
||||
|
||||
## Plain HTTP on the LAN
|
||||
|
||||
@@ -206,15 +207,15 @@ before you touch it.
|
||||
Until the catalog app is listed, install HealthLog with Apps, Discover Apps,
|
||||
the menu next to Custom App, Install via YAML, using these values:
|
||||
|
||||
| Setting | Value |
|
||||
| ------------ | ---------------------------------------------------------------------------------------- |
|
||||
| Image | `ghcr.io/mbombeck/healthlog`, tag `X.Y.Z` (amd64 and arm64) |
|
||||
| Port | `3000` (HTTP) |
|
||||
| User | runs as uid `1001` by default; no extra capabilities, `no-new-privileges` is fine |
|
||||
| Health check | `GET /api/health`, `200` when ready |
|
||||
| Start period | at least 60 s, with more retries than the image's 5 × 30 s: migrations run at every boot |
|
||||
| Database | PostgreSQL 18 (16 works too), no extensions, in its own container with its own dataset |
|
||||
| Volumes | none for the app; the database gets its own dataset |
|
||||
| Setting | Value |
|
||||
| ------------ | -------------------------------------------------------------------------------------- |
|
||||
| Image | `ghcr.io/mbombeck/healthlog`, tag `X.Y.Z` (amd64 and arm64) |
|
||||
| Port | `3000` (HTTP) |
|
||||
| User | runs as uid `1001` by default; no extra capabilities, `no-new-privileges` is fine |
|
||||
| Health check | `GET /api/health`, `200` when ready |
|
||||
| Start period | 10 min, like the image: migrations run at every boot and wait out a held lock |
|
||||
| Database | PostgreSQL 18 (16 works too), no extensions, in its own container with its own dataset |
|
||||
| Volumes | none for the app; the database gets its own dataset |
|
||||
|
||||
| Variable | Required | Notes |
|
||||
| ----------------------- | -------- | ---------------------------------------------------------------------------------------- |
|
||||
|
||||
@@ -116,8 +116,8 @@ PostgreSQL container you installed in Step 1. Back that one up.
|
||||
|
||||
`SESSION_COOKIE_SECURE=false` is required for plain-HTTP LAN installs
|
||||
(`http://tower:3000`). Left at its default, the session cookie carries the
|
||||
`Secure` flag, the browser refuses to send it over HTTP, and login appears to
|
||||
"do nothing" — the page reloads to the login screen with no error.
|
||||
`Secure` flag, which the browser would drop over HTTP. The login page says so
|
||||
before anything is sent: "Signing in cannot complete on this page".
|
||||
|
||||
Set `SESSION_COOKIE_SECURE=true` (or leave it unset) only when a TLS reverse
|
||||
proxy serves HealthLog over HTTPS. In that case also point `NEXT_PUBLIC_APP_URL`
|
||||
|
||||
@@ -311,7 +311,7 @@ Tiles at the top of the dashboard live in a **single row**. Width caps
|
||||
at the chart's width directly below. **Never** wrap to a second row.
|
||||
|
||||
If the user enables more tiles than fit, the dashboard surfaces a banner
|
||||
inviting them to deactivate or reorder via Settings → Dashboard. The
|
||||
inviting them to deactivate or reorder via Settings → Arrangement. The
|
||||
layout editor in Settings shows a live preview of the constraint and
|
||||
flags overflow before save.
|
||||
|
||||
|
||||
+3
-2
@@ -11769,8 +11769,9 @@
|
||||
"dismiss": "Verwerfen",
|
||||
"empty": "Aus diesem Dokument konnte nichts gelesen werden.",
|
||||
"unavailableBody": "Richte einen KI-Anbieter ein, um Titel, Typ und Datum vorzuschlagen.",
|
||||
"unavailableLocalOcr": "Aktiviere lokale Texterkennung in den KI-Einstellungen, um Bilder auf diesem Gerät zu lesen.",
|
||||
"unavailableLocalOcr": "Schalte „Scans auf diesem Gerät lesen“ unter Einstellungen → Module → Labor ein, um Bilder auf diesem Gerät zu lesen.",
|
||||
"unavailableAction": "KI-Einstellungen öffnen",
|
||||
"unavailableLocalOcrAction": "Labor-Einstellungen öffnen",
|
||||
"errorGeneric": "Dokument konnte nicht gelesen werden. Bitte erneut versuchen.",
|
||||
"errorOcr": "Aus diesem Bild konnte kein Text gelesen werden.",
|
||||
"errorRateLimited": "Das stündliche KI-Leselimit ist erreicht. Bitte später erneut versuchen.",
|
||||
@@ -11782,7 +11783,7 @@
|
||||
"errorFileType": "Dieser Dateityp kann nicht gelesen werden.",
|
||||
"errorNotIndexed": "Lies das Dokument zuerst aus, dann extrahiere.",
|
||||
"errorOutputLimit": "Der KI-Anbieter hat sein Ausgabelimit erreicht, bevor er einen einzigen Wert geliefert hat. Versuche ein Modell, das längere Antworten erlaubt.",
|
||||
"errorLocalOcrDisabled": "Aktiviere zuerst die lokale Texterkennung in den KI-Einstellungen.",
|
||||
"errorLocalOcrDisabled": "Schalte zuerst „Scans auf diesem Gerät lesen“ unter Einstellungen → Module → Labor ein.",
|
||||
"errorProvider": "Dafür ist kein KI-Anbieter eingerichtet.",
|
||||
"errorTextImageOnly": "Lokale Texterkennung liest nur Bilder, nicht diese Datei.",
|
||||
"errorConsent": "Für das Lesen von Dokumenten mit KI brauchst du zuerst deine Einwilligung.",
|
||||
|
||||
+3
-2
@@ -11769,8 +11769,9 @@
|
||||
"dismiss": "Dismiss",
|
||||
"empty": "Nothing could be read from this document.",
|
||||
"unavailableBody": "Set up an AI provider to suggest a title, type, and date.",
|
||||
"unavailableLocalOcr": "Turn on local OCR in the AI settings to read images on this device.",
|
||||
"unavailableLocalOcr": "Turn on “Read scans on this device” under Settings → Modules → Labs to read images on this device.",
|
||||
"unavailableAction": "Open AI settings",
|
||||
"unavailableLocalOcrAction": "Open lab settings",
|
||||
"errorGeneric": "Couldn't read the document. Try again.",
|
||||
"errorOcr": "Couldn't read text from this image.",
|
||||
"errorRateLimited": "The hourly AI reading limit is reached. Try again later.",
|
||||
@@ -11782,7 +11783,7 @@
|
||||
"errorFileType": "This file type can't be read.",
|
||||
"errorNotIndexed": "Read the document first, then extract.",
|
||||
"errorOutputLimit": "The AI provider reached its output limit before it returned a single value. Try a model that allows longer answers.",
|
||||
"errorLocalOcrDisabled": "Turn on local OCR in the AI settings first.",
|
||||
"errorLocalOcrDisabled": "Turn on “Read scans on this device” under Settings → Modules → Labs first.",
|
||||
"errorProvider": "No AI provider is set up for this.",
|
||||
"errorTextImageOnly": "Local OCR reads images only, not this file.",
|
||||
"errorConsent": "Reading documents with AI needs your consent first.",
|
||||
|
||||
+3
-2
@@ -11769,8 +11769,9 @@
|
||||
"dismiss": "Descartar",
|
||||
"empty": "No se pudo leer nada de este documento.",
|
||||
"unavailableBody": "Configura un proveedor de IA para sugerir título, tipo y fecha.",
|
||||
"unavailableLocalOcr": "Activa el OCR local en los ajustes de IA para leer imágenes en este dispositivo.",
|
||||
"unavailableLocalOcr": "Activa «Leer escaneos en este dispositivo» en Ajustes → Módulos → Laboratorio para leer imágenes en este dispositivo.",
|
||||
"unavailableAction": "Abrir ajustes de IA",
|
||||
"unavailableLocalOcrAction": "Abrir ajustes de laboratorio",
|
||||
"errorGeneric": "No se pudo leer el documento. Inténtalo de nuevo.",
|
||||
"errorOcr": "No se pudo leer texto de esta imagen.",
|
||||
"errorRateLimited": "Se alcanzó el límite de lecturas de IA por hora. Inténtalo más tarde.",
|
||||
@@ -11782,7 +11783,7 @@
|
||||
"errorFileType": "Este tipo de archivo no se puede leer.",
|
||||
"errorNotIndexed": "Primero lee el documento y luego extrae.",
|
||||
"errorOutputLimit": "El proveedor de IA alcanzó su límite de salida antes de devolver un solo valor. Prueba con un modelo que permita respuestas más largas.",
|
||||
"errorLocalOcrDisabled": "Activa primero el OCR local en los ajustes de IA.",
|
||||
"errorLocalOcrDisabled": "Activa primero «Leer escaneos en este dispositivo» en Ajustes → Módulos → Laboratorio.",
|
||||
"errorProvider": "No hay ningún proveedor de IA configurado para esto.",
|
||||
"errorTextImageOnly": "El OCR local solo lee imágenes, no este archivo.",
|
||||
"errorConsent": "Leer documentos con IA necesita primero tu consentimiento.",
|
||||
|
||||
+3
-2
@@ -11769,8 +11769,9 @@
|
||||
"dismiss": "Ignorer",
|
||||
"empty": "Impossible de lire ce document.",
|
||||
"unavailableBody": "Configurez un fournisseur d'IA pour suggérer un titre, un type et une date.",
|
||||
"unavailableLocalOcr": "Activez l'OCR local dans les paramètres d'IA pour lire les images sur cet appareil.",
|
||||
"unavailableLocalOcr": "Activez « Lire les scans sur cet appareil » dans Paramètres → Modules → Analyses pour lire les images sur cet appareil.",
|
||||
"unavailableAction": "Ouvrir les paramètres d'IA",
|
||||
"unavailableLocalOcrAction": "Ouvrir les paramètres des analyses",
|
||||
"errorGeneric": "Impossible de lire le document. Réessayez.",
|
||||
"errorOcr": "Impossible de lire le texte de cette image.",
|
||||
"errorRateLimited": "La limite horaire de lectures IA est atteinte. Réessayez plus tard.",
|
||||
@@ -11782,7 +11783,7 @@
|
||||
"errorFileType": "Ce type de fichier ne peut pas être lu.",
|
||||
"errorNotIndexed": "Lisez d’abord le document, puis extrayez.",
|
||||
"errorOutputLimit": "Le fournisseur d’IA a atteint sa limite de sortie avant de renvoyer la moindre valeur. Essayez un modèle qui accepte des réponses plus longues.",
|
||||
"errorLocalOcrDisabled": "Activez d'abord l'OCR local dans les paramètres d'IA.",
|
||||
"errorLocalOcrDisabled": "Activez d'abord « Lire les scans sur cet appareil » dans Paramètres → Modules → Analyses.",
|
||||
"errorProvider": "Aucun fournisseur d'IA n'est configuré pour cela.",
|
||||
"errorTextImageOnly": "L'OCR local ne lit que les images, pas ce fichier.",
|
||||
"errorConsent": "La lecture de documents par IA nécessite d'abord votre consentement.",
|
||||
|
||||
+3
-2
@@ -11769,8 +11769,9 @@
|
||||
"dismiss": "Ignora",
|
||||
"empty": "Impossibile leggere questo documento.",
|
||||
"unavailableBody": "Configura un provider IA per suggerire titolo, tipo e data.",
|
||||
"unavailableLocalOcr": "Attiva l'OCR locale nelle impostazioni IA per leggere le immagini su questo dispositivo.",
|
||||
"unavailableLocalOcr": "Attiva «Leggi le scansioni su questo dispositivo» in Impostazioni → Moduli → Esami per leggere le immagini su questo dispositivo.",
|
||||
"unavailableAction": "Apri impostazioni IA",
|
||||
"unavailableLocalOcrAction": "Apri le impostazioni degli esami",
|
||||
"errorGeneric": "Impossibile leggere il documento. Riprova.",
|
||||
"errorOcr": "Impossibile leggere il testo da questa immagine.",
|
||||
"errorRateLimited": "Il limite orario di letture IA è stato raggiunto. Riprova più tardi.",
|
||||
@@ -11782,7 +11783,7 @@
|
||||
"errorFileType": "Questo tipo di file non può essere letto.",
|
||||
"errorNotIndexed": "Leggi prima il documento, poi estrai.",
|
||||
"errorOutputLimit": "Il fornitore di IA ha raggiunto il suo limite di output prima di restituire un solo valore. Prova con un modello che consente risposte più lunghe.",
|
||||
"errorLocalOcrDisabled": "Attiva prima l'OCR locale nelle impostazioni IA.",
|
||||
"errorLocalOcrDisabled": "Attiva prima «Leggi le scansioni su questo dispositivo» in Impostazioni → Moduli → Esami.",
|
||||
"errorProvider": "Nessun provider IA è configurato per questo.",
|
||||
"errorTextImageOnly": "L'OCR locale legge solo immagini, non questo file.",
|
||||
"errorConsent": "La lettura dei documenti con IA richiede prima il tuo consenso.",
|
||||
|
||||
+3
-2
@@ -11769,8 +11769,9 @@
|
||||
"dismiss": "닫기",
|
||||
"empty": "이 문서에서 읽어낼 내용이 없었어요.",
|
||||
"unavailableBody": "AI 제공자를 설정하면 제목·종류·날짜를 추천해요.",
|
||||
"unavailableLocalOcr": "AI 설정에서 로컬 OCR을 켜면 이 기기에서 이미지를 읽어요.",
|
||||
"unavailableLocalOcr": "설정 → 모듈 → 검사에서 “이 기기에서 스캔 읽기”를 켜면 이 기기에서 이미지를 읽어요.",
|
||||
"unavailableAction": "AI 설정 열기",
|
||||
"unavailableLocalOcrAction": "검사 설정 열기",
|
||||
"errorGeneric": "문서를 읽지 못했어요. 다시 시도해 주세요.",
|
||||
"errorOcr": "이 이미지에서 텍스트를 읽지 못했어요.",
|
||||
"errorRateLimited": "시간당 AI 읽기 한도에 도달했어요. 나중에 다시 시도해 주세요.",
|
||||
@@ -11782,7 +11783,7 @@
|
||||
"errorFileType": "이 파일 형식은 읽을 수 없어요.",
|
||||
"errorNotIndexed": "문서를 먼저 읽은 뒤에 추출해 주세요.",
|
||||
"errorOutputLimit": "AI 제공자가 값을 하나도 돌려주기 전에 출력 한도에 도달했어요. 더 긴 답변을 허용하는 모델로 다시 시도해 주세요.",
|
||||
"errorLocalOcrDisabled": "AI 설정에서 로컬 OCR을 먼저 켜 주세요.",
|
||||
"errorLocalOcrDisabled": "설정 → 모듈 → 검사에서 “이 기기에서 스캔 읽기”를 먼저 켜 주세요.",
|
||||
"errorProvider": "이 작업에 쓸 AI 제공자가 설정돼 있지 않아요.",
|
||||
"errorTextImageOnly": "로컬 OCR은 이미지만 읽어요. 이 파일은 읽을 수 없어요.",
|
||||
"errorConsent": "AI로 문서를 읽으려면 먼저 동의가 필요합니다.",
|
||||
|
||||
+3
-2
@@ -11769,8 +11769,9 @@
|
||||
"dismiss": "Negeren",
|
||||
"empty": "Er kon niets uit dit document worden gelezen.",
|
||||
"unavailableBody": "Stel een AI-provider in om een titel, soort en datum voor te stellen.",
|
||||
"unavailableLocalOcr": "Zet lokale OCR aan in de AI-instellingen om afbeeldingen op dit apparaat te lezen.",
|
||||
"unavailableLocalOcr": "Zet “Scans op dit apparaat lezen” aan onder Instellingen → Modules → Labwaarden om afbeeldingen op dit apparaat te lezen.",
|
||||
"unavailableAction": "AI-instellingen openen",
|
||||
"unavailableLocalOcrAction": "Instellingen voor labwaarden openen",
|
||||
"errorGeneric": "Het document kon niet worden gelezen. Probeer het opnieuw.",
|
||||
"errorOcr": "Er kon geen tekst uit deze afbeelding worden gelezen.",
|
||||
"errorRateLimited": "De limiet voor lezen met AI per uur is bereikt. Probeer het later opnieuw.",
|
||||
@@ -11782,7 +11783,7 @@
|
||||
"errorFileType": "Dit bestandstype kan niet worden gelezen.",
|
||||
"errorNotIndexed": "Laat het document eerst lezen en extraheer daarna.",
|
||||
"errorOutputLimit": "De AI-provider bereikte zijn uitvoerlimiet voordat hij één waarde teruggaf. Probeer een model dat langere antwoorden toestaat.",
|
||||
"errorLocalOcrDisabled": "Zet eerst lokale OCR aan in de AI-instellingen.",
|
||||
"errorLocalOcrDisabled": "Zet eerst “Scans op dit apparaat lezen” aan onder Instellingen → Modules → Labwaarden.",
|
||||
"errorProvider": "Hiervoor is geen AI-provider ingesteld.",
|
||||
"errorTextImageOnly": "Lokale OCR leest alleen afbeeldingen, niet dit bestand.",
|
||||
"errorConsent": "Voor het lezen van documenten met AI is eerst je toestemming nodig.",
|
||||
|
||||
+3
-2
@@ -11769,8 +11769,9 @@
|
||||
"dismiss": "Odrzuć",
|
||||
"empty": "Nie udało się nic odczytać z tego dokumentu.",
|
||||
"unavailableBody": "Skonfiguruj dostawcę AI, aby zaproponować tytuł, typ i datę.",
|
||||
"unavailableLocalOcr": "Włącz lokalne OCR w ustawieniach AI, aby odczytywać obrazy na tym urządzeniu.",
|
||||
"unavailableLocalOcr": "Włącz „Odczytuj skany na tym urządzeniu” w Ustawienia → Moduły → Badania, aby odczytywać obrazy na tym urządzeniu.",
|
||||
"unavailableAction": "Otwórz ustawienia AI",
|
||||
"unavailableLocalOcrAction": "Otwórz ustawienia badań",
|
||||
"errorGeneric": "Nie udało się odczytać dokumentu. Spróbuj ponownie.",
|
||||
"errorOcr": "Nie udało się odczytać tekstu z tego obrazu.",
|
||||
"errorRateLimited": "Godzinowy limit odczytów AI został osiągnięty. Spróbuj ponownie później.",
|
||||
@@ -11782,7 +11783,7 @@
|
||||
"errorFileType": "Tego typu pliku nie można odczytać.",
|
||||
"errorNotIndexed": "Najpierw odczytaj dokument, potem wyodrębnij.",
|
||||
"errorOutputLimit": "Dostawca AI osiągnął limit odpowiedzi, zanim zwrócił choć jedną wartość. Spróbuj modelu, który pozwala na dłuższe odpowiedzi.",
|
||||
"errorLocalOcrDisabled": "Najpierw włącz lokalne OCR w ustawieniach AI.",
|
||||
"errorLocalOcrDisabled": "Najpierw włącz „Odczytuj skany na tym urządzeniu” w Ustawienia → Moduły → Badania.",
|
||||
"errorProvider": "Nie skonfigurowano dostawcy AI do tego celu.",
|
||||
"errorTextImageOnly": "Lokalne OCR odczytuje tylko obrazy, nie ten plik.",
|
||||
"errorConsent": "Odczyt dokumentów przez AI wymaga najpierw Twojej zgody.",
|
||||
|
||||
+3
-2
@@ -11769,8 +11769,9 @@
|
||||
"dismiss": "Dispensar",
|
||||
"empty": "Nada pôde ser lido deste documento.",
|
||||
"unavailableBody": "Configure um provedor de IA para sugerir um título, tipo e data.",
|
||||
"unavailableLocalOcr": "Ative o OCR local nas configurações de IA para ler imagens neste dispositivo.",
|
||||
"unavailableLocalOcr": "Ative “Ler digitalizações neste dispositivo” em Configurações → Módulos → Exames para ler imagens neste dispositivo.",
|
||||
"unavailableAction": "Abrir configurações de IA",
|
||||
"unavailableLocalOcrAction": "Abrir configurações de exames",
|
||||
"errorGeneric": "Não foi possível ler o documento. Tente novamente.",
|
||||
"errorOcr": "Não foi possível ler texto desta imagem.",
|
||||
"errorRateLimited": "O limite horário de leitura por IA foi atingido. Tente novamente mais tarde.",
|
||||
@@ -11782,7 +11783,7 @@
|
||||
"errorFileType": "Este tipo de arquivo não pode ser lido.",
|
||||
"errorNotIndexed": "Leia o documento primeiro, depois extraia.",
|
||||
"errorOutputLimit": "O provedor de IA atingiu o limite de saída antes de devolver um único valor. Tente um modelo que permita respostas mais longas.",
|
||||
"errorLocalOcrDisabled": "Ative o OCR local nas configurações de IA primeiro.",
|
||||
"errorLocalOcrDisabled": "Ative primeiro “Ler digitalizações neste dispositivo” em Configurações → Módulos → Exames.",
|
||||
"errorProvider": "Nenhum provedor de IA está configurado para isso.",
|
||||
"errorTextImageOnly": "O OCR local lê apenas imagens, não este arquivo.",
|
||||
"errorConsent": "Ler documentos com IA precisa do seu consentimento primeiro.",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$comment": "Comment lines that open with a release version, per file under src/. Maintained by src/__tests__/version-stamp-ratchet.test.ts.",
|
||||
"count": 2897,
|
||||
"total": 9249,
|
||||
"total": 9243,
|
||||
"entries": {
|
||||
"__tests__/account-payload-consumer-guard.test.ts": 2,
|
||||
"__tests__/acting-account-boundary-guard.test.ts": 7,
|
||||
@@ -360,7 +360,7 @@
|
||||
"app/api/integrations/healthkit/__tests__/route.test.ts": 4,
|
||||
"app/api/integrations/healthkit/route.ts": 3,
|
||||
"app/api/integrations/status/__tests__/route.test.ts": 2,
|
||||
"app/api/integrations/status/route.ts": 7,
|
||||
"app/api/integrations/status/route.ts": 6,
|
||||
"app/api/integrations/withings/resume/__tests__/route.test.ts": 1,
|
||||
"app/api/integrations/withings/resume/route.ts": 1,
|
||||
"app/api/internal/deploy-webhook/__tests__/route.test.ts": 2,
|
||||
@@ -1381,7 +1381,7 @@
|
||||
"components/settings/integrations/connections-panel.tsx": 5,
|
||||
"components/settings/integrations/garmin-info-note.tsx": 1,
|
||||
"components/settings/integrations/notification-channels-panel.tsx": 1,
|
||||
"components/settings/integrations/setup-guide-link.tsx": 5,
|
||||
"components/settings/integrations/setup-guide-link.tsx": 2,
|
||||
"components/settings/integrations/shared.tsx": 3,
|
||||
"components/settings/labs-section.tsx": 1,
|
||||
"components/settings/layout-groups.ts": 2,
|
||||
@@ -1737,7 +1737,7 @@
|
||||
"lib/ai/coach/turn/reply-guards.ts": 18,
|
||||
"lib/ai/coach/turn/reuse-turn.ts": 1,
|
||||
"lib/ai/coach/turn/sse.ts": 10,
|
||||
"lib/ai/coach/turn/steps.ts": 3,
|
||||
"lib/ai/coach/turn/steps.ts": 1,
|
||||
"lib/ai/coach/turn/types.ts": 17,
|
||||
"lib/ai/coach/types.ts": 58,
|
||||
"lib/ai/coach/visits-snapshot.ts": 1,
|
||||
|
||||
@@ -502,8 +502,8 @@ export const GET = apiHandler(async () => {
|
||||
} satisfies IntegrationViewModel & NightscoutExtras,
|
||||
{
|
||||
...publish(dawarichStatus),
|
||||
// v1.43 — Dawarich, read by the card through `health`. The ledger core
|
||||
// rides along like on every entry, and goes with the rest in v1.44.
|
||||
// Dawarich, read by the card through `health`. The ledger core rides
|
||||
// along like on every entry.
|
||||
connected: dawarichConfigured,
|
||||
configured: dawarichConfigured,
|
||||
health: health["dawarich"],
|
||||
@@ -524,8 +524,9 @@ interface IntegrationViewModel {
|
||||
* 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.
|
||||
* it. Those stay on the wire, deprecated in the published document; the
|
||||
* cards render `health`, and only their copied diagnostics still read
|
||||
* `consecutiveFailuresByKind`. No native client reads this envelope.
|
||||
*/
|
||||
health: ConnectionHealth | null;
|
||||
state: string;
|
||||
|
||||
@@ -98,7 +98,7 @@ export function BackupsSection() {
|
||||
{/* External docs link — `noopener noreferrer` because this
|
||||
leaves the admin shell. */}
|
||||
<a
|
||||
href="https://docs.healthlog.dev/admin/backups"
|
||||
href="https://docs.healthlog.dev/guides/backups/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary inline-flex items-center gap-1 underline-offset-2 hover:underline"
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
|
||||
const record = vi.hoisted(() => ({ inSharedRecord: false }));
|
||||
vi.mock("@/hooks/use-record-capabilities", () => ({
|
||||
useRecordCapabilities: () => ({ inSharedRecord: record.inSharedRecord }),
|
||||
}));
|
||||
import type { DocumentSuggestionDto } from "@/lib/validations/inbound-documents";
|
||||
import {
|
||||
AiUnavailableHint,
|
||||
@@ -57,10 +62,26 @@ describe("<AiUnavailableHint>", () => {
|
||||
expect(html).not.toContain('role="alert"');
|
||||
});
|
||||
|
||||
it("nudges toward local OCR when a text-only provider needs it", () => {
|
||||
it("points at the on-device scan switch when a text-only provider needs it", () => {
|
||||
const html = render(<AiUnavailableHint reason="enable-local-ocr" />);
|
||||
expect(html).toContain("local OCR");
|
||||
expect(html).toContain('href="/settings/ai"');
|
||||
// The switch lives on the Labs module page, under its own label.
|
||||
expect(html).toContain("Read scans on this device");
|
||||
expect(html).toContain("Settings → Modules → Labs");
|
||||
expect(html).toContain('href="/settings/modules/labs#labs-local-ocr"');
|
||||
expect(html).toContain("Open lab settings");
|
||||
expect(html).not.toContain('href="/settings/ai"');
|
||||
});
|
||||
|
||||
it("keeps the sentence but withholds the Settings link inside somebody else's record", () => {
|
||||
record.inSharedRecord = true;
|
||||
try {
|
||||
const html = render(<AiUnavailableHint reason="enable-local-ocr" />);
|
||||
expect(html).toContain("Read scans on this device");
|
||||
expect(html).not.toContain('data-slot="assist-settings-link"');
|
||||
expect(html).not.toContain('href="/settings');
|
||||
} finally {
|
||||
record.inSharedRecord = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
|
||||
const record = vi.hoisted(() => ({ inSharedRecord: false }));
|
||||
vi.mock("@/hooks/use-record-capabilities", () => ({
|
||||
useRecordCapabilities: () => ({ inSharedRecord: record.inSharedRecord }),
|
||||
}));
|
||||
import type { DocumentSuggestionDto } from "@/lib/validations/inbound-documents";
|
||||
import { DocumentAiSection } from "../document-ai-section";
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ import Link from "next/link";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import type { AiRunPhase } from "@/hooks/use-ai-run";
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
@@ -64,9 +65,13 @@ export function AiRunPhaseNote({
|
||||
}
|
||||
|
||||
/**
|
||||
* Calm pointer to the AI settings, shown when assist is unavailable. When the
|
||||
* document is already searchable (auto-indexed locally) the copy stays honest —
|
||||
* it is searchable, an AI provider only adds a richer read.
|
||||
* Calm pointer to the setting that would make assist available: the AI
|
||||
* settings when no provider is configured, the on-device scan switch on the
|
||||
* Labs module page when the provider cannot read images. Both live under
|
||||
* `/settings`, which is the caller's own and closed inside somebody else's
|
||||
* record, so the link is withheld there and the sentence stands alone. When
|
||||
* the document is already searchable (auto-indexed locally) the copy stays
|
||||
* honest — it is searchable, an AI provider only adds a richer read.
|
||||
*/
|
||||
export function AiUnavailableHint({
|
||||
reason,
|
||||
@@ -74,10 +79,11 @@ export function AiUnavailableHint({
|
||||
reason: "no-provider" | "enable-local-ocr" | null;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const body =
|
||||
reason === "enable-local-ocr"
|
||||
? t("documents.assist.unavailableLocalOcr")
|
||||
: t("documents.assist.unavailableBody");
|
||||
const { inSharedRecord } = useRecordCapabilities();
|
||||
const localOcr = reason === "enable-local-ocr";
|
||||
const body = localOcr
|
||||
? t("documents.assist.unavailableLocalOcr")
|
||||
: t("documents.assist.unavailableBody");
|
||||
return (
|
||||
<div
|
||||
data-slot="assist-unavailable"
|
||||
@@ -85,14 +91,25 @@ export function AiUnavailableHint({
|
||||
>
|
||||
<Sparkles className="mt-0.5 size-3.5 shrink-0" aria-hidden />
|
||||
<p className="min-w-0">
|
||||
{body}{" "}
|
||||
<Link
|
||||
href="/settings/ai"
|
||||
data-slot="assist-settings-link"
|
||||
className="text-primary font-medium underline-offset-4 hover:underline"
|
||||
>
|
||||
{t("documents.assist.unavailableAction")}
|
||||
</Link>
|
||||
{body}
|
||||
{!inSharedRecord && (
|
||||
<>
|
||||
{" "}
|
||||
<Link
|
||||
href={
|
||||
localOcr
|
||||
? "/settings/modules/labs#labs-local-ocr"
|
||||
: "/settings/ai"
|
||||
}
|
||||
data-slot="assist-settings-link"
|
||||
className="text-primary font-medium underline-offset-4 hover:underline"
|
||||
>
|
||||
{localOcr
|
||||
? t("documents.assist.unavailableLocalOcrAction")
|
||||
: t("documents.assist.unavailableAction")}
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -251,8 +251,13 @@ describe("IntegrationsSection — one status display per card", () => {
|
||||
for (const provider of PROVIDERS) {
|
||||
expect(html).toContain(`data-testid="${provider}-setup-guide"`);
|
||||
expect(html).toContain(
|
||||
`href="https://docs.healthlog.dev/integrations/${provider}"`,
|
||||
`href="https://docs.healthlog.dev/guides/connect-${provider}/"`,
|
||||
);
|
||||
}
|
||||
// Garmin has no connector; its note goes straight to the section of the
|
||||
// Google Health guide that explains the route, not through a redirect.
|
||||
expect(html).toContain(
|
||||
'href="https://docs.healthlog.dev/guides/connect-google-health/#garmin-owners"',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -139,7 +139,7 @@ describe("<ManagedIntegrationStatus>", () => {
|
||||
"Services you connect here send their data to Managed record's record, not to yours.",
|
||||
);
|
||||
expect(html).toContain(
|
||||
'href="https://docs.healthlog.dev/self-hosting/account-sharing#records-with-no-login"',
|
||||
'href="https://docs.healthlog.dev/guides/managed-profiles/#connected-services"',
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ describe("<FitbitRetirementNotice>", () => {
|
||||
expect(html).toContain("Google Health reads the same Fitbit and Pixel");
|
||||
expect(html).toContain("Everything already imported stays.");
|
||||
expect(html).toContain('href="#google-health"');
|
||||
expect(html).toContain("/google-health");
|
||||
expect(html).toContain("/guides/connect-google-health/");
|
||||
});
|
||||
|
||||
it("says the connection has stopped once the day has come", () => {
|
||||
|
||||
@@ -11,7 +11,7 @@ import { ExternalLink, Watch } from "lucide-react";
|
||||
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { INTEGRATION_DOCS_BASE } from "@/components/settings/integrations/setup-guide-link";
|
||||
import { GARMIN_DOCS_HREF } from "@/components/settings/integrations/setup-guide-link";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
export function GarminInfoNote() {
|
||||
@@ -25,7 +25,7 @@ export function GarminInfoNote() {
|
||||
<p className="text-muted-foreground">
|
||||
{t("settings.garminInfo.body")}{" "}
|
||||
<a
|
||||
href={`${INTEGRATION_DOCS_BASE}/garmin`}
|
||||
href={GARMIN_DOCS_HREF}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
data-testid="garmin-docs-link"
|
||||
|
||||
@@ -17,11 +17,16 @@ import { ExternalLink } from "lucide-react";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
/**
|
||||
* Base for every provider setup runbook. The provider key is appended as a
|
||||
* path segment, e.g. `https://docs.healthlog.dev/integrations/whoop`. Kept as
|
||||
* a single constant so the host never drifts across cards.
|
||||
* The docs site's guides, where every provider runbook lives at
|
||||
* `connect-<provider>/`, e.g. `https://docs.healthlog.dev/guides/connect-whoop/`.
|
||||
* These are the pages themselves: the older `/integrations/<provider>` paths
|
||||
* only resolve through redirects. Kept as a single constant so the host never
|
||||
* drifts across cards.
|
||||
*/
|
||||
export const INTEGRATION_DOCS_BASE = "https://docs.healthlog.dev/integrations";
|
||||
export const DOCS_GUIDES_BASE = "https://docs.healthlog.dev/guides";
|
||||
|
||||
/** Garmin has no connector; the Google Health guide explains the route. */
|
||||
export const GARMIN_DOCS_HREF = `${DOCS_GUIDES_BASE}/connect-google-health/#garmin-owners`;
|
||||
|
||||
export type IntegrationDocsProvider =
|
||||
| "whoop"
|
||||
@@ -30,15 +35,12 @@ export type IntegrationDocsProvider =
|
||||
| "polar"
|
||||
| "oura"
|
||||
| "nightscout"
|
||||
// v1.27.0 — Google Health runbook (docs.healthlog.dev/integrations/google-health).
|
||||
| "google-health"
|
||||
// v1.28.x — Strava runbook (docs.healthlog.dev/integrations/strava).
|
||||
| "strava"
|
||||
// v1.43 — Dawarich runbook (docs.healthlog.dev/integrations/dawarich).
|
||||
| "dawarich";
|
||||
|
||||
export function integrationDocsHref(provider: IntegrationDocsProvider): string {
|
||||
return `${INTEGRATION_DOCS_BASE}/${provider}`;
|
||||
return `${DOCS_GUIDES_BASE}/connect-${provider}/`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -54,7 +54,7 @@ const INTEGRATION_DISPLAY_NAMES = {
|
||||
|
||||
/** 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";
|
||||
"https://docs.healthlog.dev/guides/managed-profiles/#connected-services";
|
||||
|
||||
function iconFor(key: ManagedIntegrationKey) {
|
||||
if (key === "nightscout") return Droplet;
|
||||
|
||||
@@ -27,7 +27,8 @@ import {
|
||||
summariseTitle,
|
||||
} from "../persistence";
|
||||
import { encryptToBytes } from "../bytes-codec";
|
||||
import type { CoachProvenance } from "../types";
|
||||
import { MAX_TURN_STEPS } from "../activity/contract";
|
||||
import type { CoachProvenance, CoachStep } from "../types";
|
||||
|
||||
describe("summariseTitle", () => {
|
||||
it("returns the input unchanged when below 80 chars", () => {
|
||||
@@ -212,6 +213,46 @@ describe("appendMessage — provenance round-trip", () => {
|
||||
expect(out.metricSource).toEqual(envelope);
|
||||
});
|
||||
|
||||
it("restores every step the turn may record, and none past the cap", async () => {
|
||||
const step = (n: number): CoachStep => ({
|
||||
id: `s${n}`,
|
||||
tool: "get_metric_series",
|
||||
labelKey: "coach.step.readWindow",
|
||||
label: "Read the series",
|
||||
domain: "bp",
|
||||
window: "last30days",
|
||||
status: "done",
|
||||
count: n,
|
||||
});
|
||||
const recorded = Array.from({ length: MAX_TURN_STEPS }, (_, i) =>
|
||||
step(i + 1),
|
||||
);
|
||||
stubEchoCreate();
|
||||
const full = await appendMessage({
|
||||
conversationId: "conv_1",
|
||||
role: "assistant",
|
||||
content: "A long answer.",
|
||||
metricSource: { windows: [], metrics: [], steps: recorded },
|
||||
});
|
||||
expect(full.metricSource?.steps).toEqual(recorded);
|
||||
|
||||
// A stored blob longer than the cap is cut at the cap, not before it.
|
||||
stubEchoCreate(
|
||||
JSON.stringify({
|
||||
windows: [],
|
||||
metrics: [],
|
||||
steps: [...recorded, step(MAX_TURN_STEPS + 1)],
|
||||
}),
|
||||
);
|
||||
const over = await appendMessage({
|
||||
conversationId: "conv_1",
|
||||
role: "assistant",
|
||||
content: "An over-long blob.",
|
||||
metricSource: { windows: [], metrics: [] },
|
||||
});
|
||||
expect(over.metricSource?.steps).toEqual(recorded);
|
||||
});
|
||||
|
||||
it("tolerates a legacy blob without the new fields (no throw, fields absent)", async () => {
|
||||
// A pre-feature row: only the original windows/metrics envelope on disk.
|
||||
stubEchoCreate(
|
||||
|
||||
@@ -41,6 +41,13 @@ export const ACTIVITY_TEXT_MAX_CHARS = 400;
|
||||
/** `a1`..`a99`: the 100th entry of a turn is not recorded. */
|
||||
export const ACTIVITY_MAX_ENTRIES = 99;
|
||||
|
||||
/**
|
||||
* `s1`..`s64`: the live steps of one turn (four per round of the longest
|
||||
* turn). Later tool calls still run but show no step. The turn records up to
|
||||
* this many and a reload restores up to this many, from this one constant.
|
||||
*/
|
||||
export const MAX_TURN_STEPS = 64;
|
||||
|
||||
export interface ActivityStart {
|
||||
phase: CoachActivityPhase;
|
||||
round: number;
|
||||
|
||||
@@ -51,7 +51,10 @@
|
||||
* and no lab row is read, so neither the Coach prompt nor its `get_labs` tool
|
||||
* sees a value. It used to ride regardless, a decision from before Labs could
|
||||
* be switched off; the data routes (`/api/labs`) stay ungated, because a
|
||||
* module decides what a model is given, not what the owner can read.
|
||||
* module decides what a model is given, not what the owner can read. With the
|
||||
* module on, the block is `null` only when no reading falls inside the
|
||||
* lookback limit (the whole record when there is none), or, for a named read,
|
||||
* when no marker matches the analyte.
|
||||
*/
|
||||
import { sameLabUnit } from "@/lib/labs/unit-normalise";
|
||||
import {
|
||||
|
||||
@@ -28,7 +28,11 @@ import {
|
||||
coachStopSchema,
|
||||
coachTrailSchema,
|
||||
} from "./stream-events";
|
||||
import { ACTIVITY_MAX_ENTRIES, TRAIL_MAX_BYTES } from "./activity/contract";
|
||||
import {
|
||||
ACTIVITY_MAX_ENTRIES,
|
||||
MAX_TURN_STEPS,
|
||||
TRAIL_MAX_BYTES,
|
||||
} from "./activity/contract";
|
||||
import { commitTurnWrites } from "./memory/turn-writes";
|
||||
import { COACH_CONVERSATION_TITLE_MAX } from "./types";
|
||||
import { isRedundantViewChip } from "./follow-ups/view-chip";
|
||||
@@ -56,7 +60,6 @@ import type {
|
||||
CoachResultEntry,
|
||||
CoachResultMeta,
|
||||
CoachResultTable,
|
||||
CoachStep,
|
||||
CoachStepDomain,
|
||||
CoachSuggestion,
|
||||
CoachTrail,
|
||||
@@ -225,11 +228,6 @@ function restoreOne<T>(raw: unknown, schema: z.ZodType<T>): T | undefined {
|
||||
return parsed.success ? parsed.data : undefined;
|
||||
}
|
||||
|
||||
/** The live steps a turn persisted (at most 48 per turn since v1.41). */
|
||||
function restoreSteps(raw: unknown): CoachStep[] | undefined {
|
||||
return restoreEach(raw, coachStepSchema, 48);
|
||||
}
|
||||
|
||||
/** The method line; dropped whole when it does not parse. */
|
||||
function restoreMethod(raw: unknown): CoachMethod | undefined {
|
||||
const parsed = coachMethodSchema.safeParse(raw);
|
||||
@@ -341,7 +339,8 @@ function provenanceFromJson(raw: string | null): CoachProvenance | null {
|
||||
const toolCalls = restoreToolCalls(parsed.toolCalls);
|
||||
// v1.39.4 — the dialog fields: steps, method, table metadata, chips, the
|
||||
// clarification choices, and the forced-answer marker.
|
||||
const steps = restoreSteps(parsed.steps);
|
||||
// Up to the same cap the turn records steps under.
|
||||
const steps = restoreEach(parsed.steps, coachStepSchema, MAX_TURN_STEPS);
|
||||
const method = restoreMethod(parsed.method);
|
||||
const results = restoreResultMetas(parsed.results);
|
||||
// A reply stored before view chips were withheld for a table the answer
|
||||
|
||||
@@ -667,8 +667,9 @@ async function buildCoachSnapshotImpl(
|
||||
// v1.18.11 (#65) — lab-result context. Like illness it is CONTEXT, not a
|
||||
// scope-gated metric: attached without a `registerBlock` so the budget
|
||||
// degrader never sheds it. The helper is module-gated internally: with the
|
||||
// labs module off it reads nothing and answers null. The block carries the most-recent resolved reading per biomarker (last 12 months,
|
||||
// capped) so the Coach can answer "what was my LDL" without re-deriving.
|
||||
// labs module off it reads nothing and answers null. The block carries the
|
||||
// latest resolved reading per biomarker (whole record or the lookback limit,
|
||||
// capped) so the Coach can answer "what was my LDL".
|
||||
const labsBlockPromise = buildLabsSnapshotBlock(userId, now, reach);
|
||||
|
||||
// v1.38 — doctor-visit context. Like illness/labs it is attempted always (a
|
||||
|
||||
@@ -34,7 +34,8 @@ import {
|
||||
} from "@/lib/ai/coach/tools/definitions";
|
||||
import type { CoachToolResult } from "@/lib/ai/coach/tools/run/types";
|
||||
|
||||
import { MAX_TURN_STEPS, snapshotStep, toStep } from "../steps";
|
||||
import { MAX_TURN_STEPS } from "@/lib/ai/coach/activity/contract";
|
||||
import { snapshotStep, toStep } from "../steps";
|
||||
|
||||
const SOURCES = coachScopeSourceSchema.options;
|
||||
const WINDOWS = coachScopeWindowSchema.options;
|
||||
|
||||
@@ -38,13 +38,7 @@ import {
|
||||
} from "@/lib/ai/coach/tools/definitions";
|
||||
import { isTurnResultRef } from "@/lib/ai/coach/results/refs";
|
||||
import { COMPARE_SERIES_TOOL_NAME } from "@/lib/ai/coach/tools/compare-series";
|
||||
|
||||
/**
|
||||
* At most this many steps per turn; later calls run but show no step.
|
||||
* v1.41 — four per round of the longest turn: 48 for twelve rounds, 64 since
|
||||
* v1.41.2 raised the cap to sixteen.
|
||||
*/
|
||||
export const MAX_TURN_STEPS = 64;
|
||||
import { MAX_TURN_STEPS } from "@/lib/ai/coach/activity/contract";
|
||||
|
||||
const SCOPE_SOURCES: ReadonlySet<string> = new Set(
|
||||
coachScopeSourceSchema.options,
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
* Doc ↔ code parity guard for the Google Health operator runbook.
|
||||
*
|
||||
* The setup-guide link on the Settings card sends a mid-setup user to
|
||||
* `docs.healthlog.dev/integrations/google-health`, rendered from
|
||||
* `docs/integrations/google-health.md`. If the client ever requests a scope the
|
||||
* `docs.healthlog.dev/guides/connect-google-health/`; the operator runbook in
|
||||
* this repository is `docs/integrations/google-health.md`. If the client ever requests a scope the
|
||||
* runbook doesn't list, or the walkthrough regresses back to a wrong provider
|
||||
* (the file was once a Fitbit-Web-API walkthrough), a self-hoster follows a
|
||||
* broken recipe and the OAuth consent screen rejects the connection.
|
||||
@@ -20,7 +20,7 @@ import { describe, expect, it } from "vitest";
|
||||
|
||||
import { GOOGLE_HEALTH_CORE_SCOPES } from "@/lib/google-health/oauth";
|
||||
import {
|
||||
INTEGRATION_DOCS_BASE,
|
||||
DOCS_GUIDES_BASE,
|
||||
integrationDocsHref,
|
||||
} from "@/components/settings/integrations/setup-guide-link";
|
||||
|
||||
@@ -70,7 +70,7 @@ describe("google-health setup runbook parity", () => {
|
||||
// The literal argument also compile-checks that "google-health" is a member
|
||||
// of the IntegrationDocsProvider union.
|
||||
expect(integrationDocsHref("google-health")).toBe(
|
||||
`${INTEGRATION_DOCS_BASE}/google-health`,
|
||||
`${DOCS_GUIDES_BASE}/connect-google-health/`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -411,6 +411,74 @@
|
||||
"grounding": {
|
||||
"type": "string"
|
||||
},
|
||||
"searchedWindow": {
|
||||
"type": "string"
|
||||
},
|
||||
"available": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"count": {
|
||||
"type": "number"
|
||||
},
|
||||
"firstDate": {
|
||||
"type": "string"
|
||||
},
|
||||
"lastDate": {
|
||||
"type": "string"
|
||||
},
|
||||
"series": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"series": {
|
||||
"type": "string"
|
||||
},
|
||||
"unit": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"count": {
|
||||
"type": "number"
|
||||
},
|
||||
"mean": {
|
||||
"type": "number"
|
||||
},
|
||||
"min": {
|
||||
"type": "number"
|
||||
},
|
||||
"max": {
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"series",
|
||||
"unit",
|
||||
"count",
|
||||
"mean",
|
||||
"min",
|
||||
"max"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"reachableWithWindow": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"count",
|
||||
"firstDate",
|
||||
"lastDate",
|
||||
"reachableWithWindow"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"analyte": {
|
||||
"type": "string"
|
||||
},
|
||||
|
||||
@@ -540,6 +540,52 @@ describe("get_illness_recovery — condition body site", () => {
|
||||
});
|
||||
|
||||
describe("get_labs", () => {
|
||||
it.each([{}, { analyte: "LDL" }])(
|
||||
"declares empty-read availability for ordinary and filtered reads: %j",
|
||||
async (args) => {
|
||||
const { z } = await import("zod/v4");
|
||||
const empty = {
|
||||
present: false,
|
||||
reason: "out_of_window",
|
||||
searchedWindow: "lastYear",
|
||||
available: {
|
||||
count: 2,
|
||||
firstDate: "2020-01-01",
|
||||
lastDate: "2020-01-02",
|
||||
reachableWithWindow: null,
|
||||
},
|
||||
};
|
||||
vi.mocked(executeCoachTool).mockResolvedValue(empty);
|
||||
const def = tool("get_labs");
|
||||
const result = await def.run(CTX, args);
|
||||
expect(executeCoachTool).toHaveBeenCalledWith({
|
||||
userId: CTX.userId,
|
||||
name: "get_labs",
|
||||
rawArguments: JSON.stringify(args),
|
||||
});
|
||||
expect(z.object(def.outputShape!).strict().parse(result)).toEqual(empty);
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps output validation closed and availability typed", async () => {
|
||||
const { z } = await import("zod/v4");
|
||||
const schema = z.object(tool("get_labs").outputShape!).strict();
|
||||
expect(z.toJSONSchema(schema).additionalProperties).toBe(false);
|
||||
expect(schema.safeParse({ present: false, unexpected: true }).success).toBe(
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
schema.safeParse({ present: false, searchedWindow: 7 }).success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
schema.safeParse({ present: false, available: { count: "2" } }).success,
|
||||
).toBe(false);
|
||||
expect(schema.parse({ present: false, reason: "no_data" })).toEqual({
|
||||
present: false,
|
||||
reason: "no_data",
|
||||
});
|
||||
});
|
||||
|
||||
it("forwards the optional analyte filter and returns readings with units + bands", async () => {
|
||||
vi.mocked(executeCoachTool).mockResolvedValue({
|
||||
present: true,
|
||||
|
||||
@@ -5,16 +5,14 @@ import { resolveMarkerForRead } from "@/lib/labs/biomarker-resolve";
|
||||
import { getLabHistory, LAB_HISTORY_MAX_LIMIT } from "@/lib/mcp/rich-reads";
|
||||
import { decodeOffsetCursor } from "@/lib/mcp/pagination";
|
||||
import type { McpAuthContext } from "../auth";
|
||||
import { coachReadOutput } from "./output-shapes";
|
||||
import { runCoachTool, whenModuleOn } from "./shared";
|
||||
import { READ_ONLY_ANNOTATIONS, type McpToolDefinition } from "./types";
|
||||
|
||||
/** Output schema for `get_labs` — latest-per-biomarker OR a paginated history. */
|
||||
const getLabsOutput: z.ZodRawShape = {
|
||||
present: z.boolean(),
|
||||
reason: z.string().optional(),
|
||||
// Latest-mode payload (the Coach labs section, carried opaque).
|
||||
data: z.unknown().optional(),
|
||||
grounding: z.string().optional(),
|
||||
// Latest-mode payload, including typed empty-read availability metadata.
|
||||
...coachReadOutput,
|
||||
// History-mode payload (per-analyte trajectory + pagination).
|
||||
analyte: z.string().optional(),
|
||||
readings: z
|
||||
|
||||
@@ -75,10 +75,12 @@ const healthKitEntry = z
|
||||
"One resolved HealthKit metric mapping (defaults merged with the user's stored overrides).",
|
||||
});
|
||||
|
||||
// The health-shaped fields that predate `health` (v1.42.4). They stay on the
|
||||
// wire through v1.43 so App 1.2.0 keeps decoding, are marked deprecated in the
|
||||
// document, and go in v1.44.
|
||||
const LEGACY_HEALTH_FIELD = "Deprecated: removed in v1.44, read `health`.";
|
||||
// The health-shaped fields that predate `health`. They are marked deprecated
|
||||
// in the document and stay on the wire: native builds from before `health`
|
||||
// still decode them, the current one falls back to them only against a server
|
||||
// that sends no `health`, and the web card's copied diagnostics read the
|
||||
// failure streak. No removal release is set.
|
||||
const LEGACY_HEALTH_FIELD = "Deprecated, still served: read `health`.";
|
||||
|
||||
function legacy(text?: string): { deprecated: true; description: string } {
|
||||
return {
|
||||
@@ -1184,7 +1186,7 @@ const integrationEnvelopeEntry = z
|
||||
health: connectionHealth
|
||||
.nullable()
|
||||
.describe(
|
||||
"The unified connection state, null when the provider is not connected (and for Fitbit once `retirement.ended`). It supersedes `state`, `lastError`, `consecutiveFailuresByKind`, `syncHealth`, `tokenExpired`, `needsReauth`, `hasActivityScope`, `planUnavailable` and `scopeReconnect` on this entry; those nine are deprecated and removed in v1.44.",
|
||||
"The unified connection state, null when the provider is not connected (and for Fitbit once `retirement.ended`). It supersedes `state`, `lastError`, `consecutiveFailuresByKind`, `syncHealth`, `tokenExpired`, `needsReauth`, `hasActivityScope`, `planUnavailable` and `scopeReconnect` on this entry; those nine are deprecated and still served for clients that predate `health`.",
|
||||
),
|
||||
})
|
||||
.meta({
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* The steps of a long Coach answer survive a reload against a real Postgres.
|
||||
*
|
||||
* The turn records up to `MAX_TURN_STEPS` steps on `metricSource.steps`; the
|
||||
* read path restores them from `coach_messages.metric_source_json`. Both ends
|
||||
* read the one constant, so a full turn's worth comes back whole and an
|
||||
* over-long stored blob is cut at that cap, not before it.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { MAX_TURN_STEPS } from "@/lib/ai/coach/activity/contract";
|
||||
import {
|
||||
appendMessage,
|
||||
createConversation,
|
||||
fetchConversationWithMessages,
|
||||
} from "@/lib/ai/coach/persistence";
|
||||
import type { CoachStep } from "@/lib/ai/coach/types";
|
||||
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllTables(getPrismaClient());
|
||||
});
|
||||
|
||||
function step(n: number): CoachStep {
|
||||
return {
|
||||
id: `s${n}`,
|
||||
tool: "get_metric_series",
|
||||
labelKey: "coach.step.readWindow",
|
||||
label: "Read the last 30 days",
|
||||
domain: "bp",
|
||||
window: "last30days",
|
||||
status: "done",
|
||||
count: n,
|
||||
};
|
||||
}
|
||||
|
||||
const RECORDED = Array.from({ length: MAX_TURN_STEPS }, (_, i) => step(i + 1));
|
||||
|
||||
async function seedConversation(): Promise<{
|
||||
userId: string;
|
||||
conversationId: string;
|
||||
}> {
|
||||
const user = await getPrismaClient().user.create({
|
||||
data: { username: "steps-owner", role: "USER" },
|
||||
});
|
||||
const convo = await createConversation({ userId: user.id, title: "long" });
|
||||
return { userId: user.id, conversationId: convo.id };
|
||||
}
|
||||
|
||||
describe("Coach steps across a reload", () => {
|
||||
it("restores every step of a full turn", async () => {
|
||||
const { userId, conversationId } = await seedConversation();
|
||||
const reply = await appendMessage({
|
||||
conversationId,
|
||||
role: "assistant",
|
||||
content: "A long answer.",
|
||||
metricSource: { windows: [], metrics: [], steps: RECORDED },
|
||||
});
|
||||
|
||||
const reloaded = await fetchConversationWithMessages(
|
||||
userId,
|
||||
conversationId,
|
||||
);
|
||||
const message = reloaded?.messages.find((m) => m.id === reply.id);
|
||||
expect(message?.metricSource?.steps).toHaveLength(MAX_TURN_STEPS);
|
||||
expect(message?.metricSource?.steps).toEqual(RECORDED);
|
||||
});
|
||||
|
||||
it("cuts an over-long stored blob at the cap, not before it", async () => {
|
||||
const { userId, conversationId } = await seedConversation();
|
||||
const reply = await appendMessage({
|
||||
conversationId,
|
||||
role: "assistant",
|
||||
content: "An over-long blob.",
|
||||
metricSource: { windows: [], metrics: [] },
|
||||
});
|
||||
await getPrismaClient().coachMessage.update({
|
||||
where: { id: reply.id },
|
||||
data: {
|
||||
metricSourceJson: JSON.stringify({
|
||||
windows: [],
|
||||
metrics: [],
|
||||
steps: [...RECORDED, step(MAX_TURN_STEPS + 1)],
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const reloaded = await fetchConversationWithMessages(
|
||||
userId,
|
||||
conversationId,
|
||||
);
|
||||
const message = reloaded?.messages.find((m) => m.id === reply.id);
|
||||
expect(message?.metricSource?.steps).toEqual(RECORDED);
|
||||
});
|
||||
});
|
||||
@@ -313,6 +313,9 @@ describe("GET /api/fhir/metadata", () => {
|
||||
z.looseObject({
|
||||
type: z.string(),
|
||||
interaction: z.array(z.object({ code: z.string() })),
|
||||
searchParam: z.array(
|
||||
z.object({ name: z.string(), type: z.string() }),
|
||||
),
|
||||
operation: z.array(z.object({ name: z.string() })).optional(),
|
||||
}),
|
||||
),
|
||||
@@ -330,6 +333,21 @@ describe("GET /api/fhir/metadata", () => {
|
||||
expect(patient).toHaveLength(1);
|
||||
expect(patient[0].operation?.map((o) => o.name)).toEqual(["everything"]);
|
||||
|
||||
// Paging on every search; the Observation search alone honours the
|
||||
// time-series range and the code filter, and says so.
|
||||
for (const resource of resources) {
|
||||
expect(resource.searchParam, resource.type).toEqual([
|
||||
{ name: "_count", type: "number" },
|
||||
{ name: "_offset", type: "number" },
|
||||
...(resource.type === "Observation"
|
||||
? [
|
||||
{ name: "date", type: "date" },
|
||||
{ name: "code", type: "token" },
|
||||
]
|
||||
: []),
|
||||
]);
|
||||
}
|
||||
|
||||
const root = path.join(process.cwd(), "src/app/api/fhir");
|
||||
for (const resource of resources) {
|
||||
for (const { code } of resource.interaction) {
|
||||
|
||||
Reference in new issue
Block a user