Compare commits

...
2475 Commits
Author SHA1 Message Date
dependabot[bot] 0eadcdc326 build(deps): bump the production-weekly group across 1 directory with 7 updates (#1354)
Bumps the production-weekly group with 7 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [@parse/node-apn](https://github.com/parse-community/node-apn) | `8.1.1` | `8.2.0` |
| [@tanstack/react-query](https://github.com/TanStack/query/tree/HEAD/packages/react-query) | `5.105.1` | `5.105.3` |
| [@tanstack/react-virtual](https://github.com/TanStack/virtual/tree/HEAD/packages/react-virtual) | `3.14.25` | `3.14.26` |
| [jose](https://github.com/panva/jose) | `6.2.14` | `6.2.15` |
| [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react) | `1.66.0` | `1.67.0` |
| [mmdb-lib](https://github.com/runk/mmdb-lib) | `3.0.3` | `3.0.4` |
| [otpauth](https://github.com/hectorm/otpauth) | `9.5.2` | `9.6.0` |



Updates `@parse/node-apn` from 8.1.1 to 8.2.0
- [Release notes](https://github.com/parse-community/node-apn/releases)
- [Changelog](https://github.com/parse-community/node-apn/blob/master/CHANGELOG.md)
- [Commits](https://github.com/parse-community/node-apn/compare/8.1.1...8.2.0)

Updates `@tanstack/react-query` from 5.105.1 to 5.105.3
- [Release notes](https://github.com/TanStack/query/releases)
- [Changelog](https://github.com/TanStack/query/blob/main/packages/react-query/CHANGELOG.md)
- [Commits](https://github.com/TanStack/query/commits/@tanstack/react-query@5.105.3/packages/react-query)

Updates `@tanstack/react-virtual` from 3.14.25 to 3.14.26
- [Release notes](https://github.com/TanStack/virtual/releases)
- [Changelog](https://github.com/TanStack/virtual/blob/main/packages/react-virtual/CHANGELOG.md)
- [Commits](https://github.com/TanStack/virtual/commits/@tanstack/react-virtual@3.14.26/packages/react-virtual)

Updates `jose` from 6.2.14 to 6.2.15
- [Release notes](https://github.com/panva/jose/releases)
- [Changelog](https://github.com/panva/jose/blob/main/CHANGELOG.md)
- [Commits](https://github.com/panva/jose/compare/v6.2.14...v6.2.15)

Updates `lucide-react` from 1.66.0 to 1.67.0
- [Release notes](https://github.com/lucide-icons/lucide/releases)
- [Commits](https://github.com/lucide-icons/lucide/commits/1.67.0/packages/lucide-react)

Updates `mmdb-lib` from 3.0.3 to 3.0.4
- [Release notes](https://github.com/runk/mmdb-lib/releases)
- [Commits](https://github.com/runk/mmdb-lib/compare/v3.0.3...v3.0.4)

Updates `otpauth` from 9.5.2 to 9.6.0
- [Release notes](https://github.com/hectorm/otpauth/releases)
- [Commits](https://github.com/hectorm/otpauth/compare/v9.5.2...v9.6.0)

---
updated-dependencies:
- dependency-name: "@parse/node-apn"
  dependency-version: 8.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production-weekly
- dependency-name: "@tanstack/react-query"
  dependency-version: 5.105.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production-weekly
- dependency-name: "@tanstack/react-virtual"
  dependency-version: 3.14.26
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production-weekly
- dependency-name: jose
  dependency-version: 6.2.15
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production-weekly
- dependency-name: lucide-react
  dependency-version: 1.67.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production-weekly
- dependency-name: mmdb-lib
  dependency-version: 3.0.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production-weekly
- dependency-name: otpauth
  dependency-version: 9.6.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production-weekly
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-07 03:46:18 +00:00
Marc-André Bombeck 5e1c39438f Merge pull request #1359 from MBombeck/fix/next-16.4.5
build(deps): move Next.js to 16.4.5
2026-11-07 04:19:53 +01:00
Marc-André Bombeck ce32b0ef89 Merge remote-tracking branch 'origin/main' into fix/next-16.4.5
# Conflicts:
#	pnpm-lock.yaml
2026-11-07 04:03:17 +01:00
Marc-André Bombeck 73b9544f83 test(e2e): check the PDF trailer instead of Content-Length
Next 16.4.5 drops content-length, content-encoding and transfer-encoding
from every route handler response, so the header the export route sets
never reaches the browser. A truncated body still loses its %%EOF
trailer, and that is what the spec now looks for.
2026-11-07 03:37:26 +01:00
dependabot[bot] 5793db9f2a build(deps-dev): bump eslint-config-next from 16.4.3 to 16.4.5 (#1358)
Bumps [eslint-config-next](https://github.com/vercel/next.js/tree/HEAD/packages/eslint-config-next) from 16.4.3 to 16.4.5.
- [Release notes](https://github.com/vercel/next.js/releases)
- [Commits](https://github.com/vercel/next.js/commits/v16.4.5/packages/eslint-config-next)

---
updated-dependencies:
- dependency-name: eslint-config-next
  dependency-version: 16.4.5
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-07 02:23:50 +00:00
dependabot[bot] cef48d20f0 build(deps-dev): bump @next/bundle-analyzer from 16.4.3 to 16.4.5 (#1357)
Bumps [@next/bundle-analyzer](https://github.com/vercel/next.js/tree/HEAD/packages/next-bundle-analyzer) from 16.4.3 to 16.4.5.
- [Release notes](https://github.com/vercel/next.js/releases)
- [Commits](https://github.com/vercel/next.js/commits/v16.4.5/packages/next-bundle-analyzer)

---
updated-dependencies:
- dependency-name: "@next/bundle-analyzer"
  dependency-version: 16.4.5
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-07 02:18:09 +00:00
dependabot[bot] a91689a0be build(deps): bump the codeql-action group with 3 updates (#1351)
Bumps the codeql-action group with 3 updates: [github/codeql-action/init](https://github.com/github/codeql-action), [github/codeql-action/analyze](https://github.com/github/codeql-action) and [github/codeql-action/upload-sarif](https://github.com/github/codeql-action).


Updates `github/codeql-action/init` from 4.39.1 to 4.39.3
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/9b3254085494b55a0f7c4c2b3d2d52cc712ce370...33d7b85da1a04171e211befe2c739303b9a4c066)

Updates `github/codeql-action/analyze` from 4.39.1 to 4.39.3
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/9b3254085494b55a0f7c4c2b3d2d52cc712ce370...33d7b85da1a04171e211befe2c739303b9a4c066)

Updates `github/codeql-action/upload-sarif` from 4.39.1 to 4.39.3
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/9b3254085494b55a0f7c4c2b3d2d52cc712ce370...33d7b85da1a04171e211befe2c739303b9a4c066)

---
updated-dependencies:
- dependency-name: github/codeql-action/init
  dependency-version: 4.39.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: codeql-action
- dependency-name: github/codeql-action/analyze
  dependency-version: 4.39.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: codeql-action
- dependency-name: github/codeql-action/upload-sarif
  dependency-version: 4.39.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: codeql-action
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-07 02:04:41 +00:00
Marc-André Bombeck bee4ac4f73 build(deps): move Next.js to 16.4.5
16.4.5 is a security release with seven moderate advisories. Most are about 'use cache' and Cache Components, which the app does not use; one covers self-hosted apps that forward fetched responses.
2026-11-07 02:22:28 +01:00
Marc-André Bombeck 8eb2fb773f Merge pull request #1337 from MBombeck/release/v1.47.1
release/v1.47.1
2026-11-07 02:01:37 +01:00
Marc-André Bombeck 84dace4f89 chore(release): v1.47.1 2026-11-07 01:44:29 +01:00
Marc-André Bombeck 879c92f6ee docs(changelog): note the plural fix 2026-11-07 01:37:23 +01:00
Marc-André Bombeck 4921188f22 fix(i18n): carry the count in One strings where one is more than 1
The plural tier comes from CLDR, and in Russian the one category also
holds 21, 31, 101, while French and Brazilian Portuguese file 0 there.
Thirty One strings in those bundles spelled out the 1, so 21 days read
as one day. They now take {count}, as do two Russian One strings that
spelled the number as a word, and every direct call of such a key passes
the count. A guard finds the locales whose one is wider than 1 from
Intl.PluralRules and holds both ends.
2026-11-07 01:33:19 +01:00
Marc-André Bombeck 2adea7052b docs(mcp): name the upcoming periods in the get_cycle description
The cycle read returns the projected period starts after the next one,
each with its band, under upcoming; the tool description for MCP clients
and the Coach did not say so.
2026-11-07 01:33:19 +01:00
Marc-André Bombeck 31997e3232 docs(proxy): say that frame-ancestors governs framing
The comments still named X-Frame-Options DENY as what refuses the vault
preview embed and claimed the serve route keeps a sandbox directive. A
browser that reads the CSP's frame-ancestors 'self' ignores
X-Frame-Options, and the document CSP deliberately carries no sandbox.
No behaviour change.
2026-11-07 01:33:19 +01:00
Marc-André Bombeck aea9796170 fix(cycle): raise the calendar feed fetch limits
A link subscribed on a phone, a tablet and a computer at a five-minute
refresh came close to the 30 fetches an hour a link was allowed, and a
household behind one address shared 120. The per-link bucket is now 120
an hour and the per-address one 600. The token is a random secret checked
by its HMAC, so guessing links is not what these limits stop.
2026-11-07 01:33:19 +01:00
Marc-André Bombeck 78611277fe fix(report): clamp a long report window in local days
The 730-day clamp subtracted 730 times 24 hours from the end, which lands
an hour off local midnight when an odd number of clock changes lies in
between. The start is now the end's wall clock 730 calendar days back in
the account's zone.
2026-11-07 01:33:19 +01:00
Marc-André Bombeck 46a1a5d320 fix(cycle): answer a raced feed rotation with 409 instead of 500
Two rotations at once both revoked the same live feed, and the second
successor then hit the one-live-feed index with an unhandled unique
violation. The losing rotation now gets the mint path's 409
cycle.feed.exists, and the winner's link stands.
2026-11-07 01:33:19 +01:00
Marc-André Bombeck 01884e91fa fix(sync): place a start=now cursor a minute behind the present
updatedAt is stamped when a write is built, not when it commits, so a
transaction in flight when the cursor was handed out could commit a row
stamped before it, and a watermark at serverNow skipped that row for
good. The watermarks now sit one minute back; client upserts are
idempotent, so the overlap is harmless. The token's issue time stays
serverNow.
2026-11-07 01:33:19 +01:00
Marc-André Bombeck 126bab8c00 fix(labs): preview a CSV text row with the window the write keeps
The dry run showed the printed range beside a text result, which the
write stores on the new marker and not on the reading. The preview and
the write now read the reading's window from one helper, and the
placeholder marker of a dry run takes its bounds through the same rule
as a real mint.
2026-11-07 01:04:26 +01:00
Marc-André Bombeck c7b3f6e4d0 fix(labs): drop a unitless text range when a marker adopts a real unit
A marker minted from a text result that stated no unit kept the printed
numbers as its range, and the first numeric reading then gave the marker
its unit without touching them, so a "30 - 100" printed in ng/mL became a
window in nmol/L. Numbers stated without a unit are dimensionless: they
stay when the adopted unit is the unity "1" and otherwise give way to the
window the first number printed in its own unit, or to none.
2026-11-07 01:04:26 +01:00
Marc-André Bombeck 24dd8a28e3 fix(cycle): keep the wearable repair from reopening a deleted cycle
A cycle the person deleted, or whose first day she deleted or cleared,
left its wearable days without a cycle when nothing lay before them, and
the boot-time repair took them for the pre-fix backlog and reopened it on
every deploy. A day within the hard minimum cycle length after a removed
start is no longer a candidate. The repair also skips accounts with the
cycle module off, as the syncs do, and their rows no longer make a boot
enqueue the job.
2026-11-07 01:04:13 +01:00
Marc-André Bombeck 6c5146942b fix(cycle): stamp a wearable period end only on a cycle it opened
Garmin's period end was written on any cycle starting on Garmin's start
date, a cycle the person opened herself included, even when her own days
ran past it. The stamp now requires the cycle's first day to be the
wearable's own live row.
2026-11-07 01:04:12 +01:00
Marc-André Bombeck 2aa98bb6c2 docs(changelog): note symptom lines on the timeline 2026-11-07 00:42:16 +01:00
Marc-André Bombeck a605a7223f Merge branch 'feat/v1471-981' into release/v1.47.1 2026-11-07 00:41:43 +01:00
Marc-André Bombeck 74e691cecd feat(timeline): draw person-defined symptoms as value lines
A custom symptom could feed pattern discovery and the Coach but could not
be looked at over time. The timeline now offers every symptom the record
has logged, hidden ones included, beside the measurements and lab markers,
and draws a chosen one as a value line.

`values` takes `symptom:<definitionId>`, checked for its shape and capped
like a lab marker; an id the record does not hold draws nothing. A day's
value is its highest intensity on the record's local day; a point is the
mean of the bucket's day values over the days logged, with the number of
those days, and a day without an entry is not a zero. The line sits on a
fixed 0 to 10 scale. Symptoms are read under the illness lane's gate, the
module and sharing domain the symptom routes use. An `all` window reaches
back to a chosen symptom's first entry.

The response carries `symptomSeries` and `availableSymptoms`; the OpenAPI
contract is regenerated. The page words a point's count as days and the
value out of ten. The date-key schema moves to its own module so the
symptom schemas can share it.
2026-11-07 00:38:53 +01:00
Marc-André Bombeck be7a65bdb9 ci(trivy): report only fixable findings from the published-image scan 2026-11-06 20:19:51 +01:00
Marc-André Bombeck a74d2df3c5 docs(changelog): keep the feed's 404 inside its sentence 2026-11-06 20:05:04 +01:00
Marc-André Bombeck 2522a784f9 docs(changelog): lab markers grouped by category and the cycle calendar subscription 2026-11-06 20:04:42 +01:00
Marc-André Bombeck 3b3ef5c0a1 fix(cycle): keep the calendar feed to the owner and a managed profile's guardian
A calendar feed is a standing credential whose contents end up in a
third party's calendar cloud. Putting a record's cycle dates there is
the decision of the person they are about, or of the guardian of
somebody who cannot make it, and no delegate of an adult's record,
MANAGE included.

The subscription routes resolve through requireGuardianAuth instead of
requireRecordAuth("manage", "cycle"): the owner on their own record, or
a MANAGE grant on a record carrying the managed-profile marker. Every
verb follows, the revoke too. The feed re-reads the marker on each fetch
as well as the guardian's grant, so a row minted for somebody else on an
adult's record is a 404 whatever grant stands behind it. The settings
card is offered only where the routes answer.

The routes move from the delegable and MANAGE literals to the guardian
list, and the MANAGE handler inventory is back to 93.
2026-11-06 18:03:37 +01:00
Marc-André Bombeck e8ef590af7 docs: describe the cycle calendar subscription
A how-to for subscribing from Google, Apple and Outlook and what the
feed shows; a section on the reproductive-health page saying that once
it is on, the dates also sit with the calendar provider and turning it
off does not reach those copies; a reverse-proxy note with Caddy and
Nginx examples for keeping path credentials out of access logs; and
API_TOKEN_HMAC_KEY rotation named as ending every feed link.
2026-11-06 18:03:37 +01:00
Marc-André Bombeck d9a31524a2 feat(cycle): serve the cycle forecast as a subscribable calendar
GET /api/cycle/feed/<hlf_token>[.ics] answers any calendar app with the
record's forecast as RFC 5545: all-day events for the next few periods
over their widening windows, optionally past periods, and for a
conception goal that asks for it the next fertile window. Dates and a
title only; discreet titles and the calendar name "HealthLog" by default.
The body is built live on every fetch in the record's own zone and
language, never the request's. ETag is the content hash and a matching
If-None-Match answers 304; DTSTAMP moves only when the content does.

The feed is anonymous, so every gate is read per fetch: the auth-surface
limit before any lookup, the token's shape, the HMAC lookup of a live
feed, a per-token bucket, then the record and carrier not pending
deletion, the operator's external-access switch, cycle tracking for the
record, and for a feed a delegate minted, a live MANAGE grant covering
cycle. Every miss is the same 404.

/api/cycle/feed-subscription (status, turn on, options, turn off) and
its rotate are cookie-only through requireRecordAuth("manage", "cycle"),
with a fresh proof on turning on and rotating and a per-actor mint
bucket. The URL is returned once; the status carries the prefix, the
options, the fetch statistics as a calendar-app family and the
projection state.

The hlf_ family is registered with the path and token redactors, the
secret-shape refusal, the proxy's public paths and edge headers, the
URL-credential, sharing, recent-proof, step-up and module-gate guards,
the error-code catalogue, the activity feed and the destructive-control
registry. The cycle settings tab gains the subscription card and the
predictions panel lists the later cycles.
2026-11-06 18:03:37 +01:00
Marc-André Bombeck ac31ac40b5 feat(cycle): project the cycles after the next one
The calendar read, the Coach and MCP cycle block and the coming calendar
feed now share one loader for the forecast's inputs (cycles, day logs,
overnight wrist temperatures) and one gate (`gatedPrediction`: no
forecast while prediction is off or raw-chart mode is on). The Coach
block reads the calendar's default day-log window, so its next period
is the date the calendar page shows, and resolves the luteal length
through the engine's own clamp.

`projectCycles` extends the engine's next-period forecast a few cycles
ahead. Cycle 1 is the engine's result unchanged; later starts are
anchored on it from the fractional median length, so six cycles out
carry no rounding drift; later bands widen as the SD of a sum of k
lengths with an estimated mean, with the engine's sparsity penalty, and
never narrow. The list stops where a band passes ten days or half a
cycle, holds the next period only while the engine is still learning or
for perimenopause, and is empty once the next period's band has passed
with nothing logged.

`/api/cycle/calendar` publishes it additively as `projection` and
`projectionState`; the Coach block carries it as `upcoming`.
2026-11-06 18:03:27 +01:00
Marc-André Bombeck 2480a0138d feat(cycle): add the calendar-feed credential table
A record's cycle calendar subscription is a URL credential: one row per
feed, only the HMAC of the raw hlf_ token stored, and at most one live
feed per record through a partial unique index on user_id where
revoked_at is null. Both foreign keys cascade, so deleting the record
or the carrier who minted the feed ends it.

The row is a credential everywhere else too. It stays out of the
backup (a restore would bring back a revoked link), Delete All Data
takes it on both sides, a credential remediation revokes the feeds on
the record and the ones the account minted elsewhere, and a
managed-profile handover claim revokes any feed a guardian minted.

backupVerdict moves beside the plan into its own module so the plan
stays the lists and their reasons.
2026-11-06 18:03:27 +01:00
Marc-André Bombeck c0470df14c test(labs): tie the category fields to their readers
A per-envelope guard walks the category leaves of LabOverviewResponse,
customPanels on ListBiomarkersResponse and adoptedPanel on
BiomarkerMergeResult out of the Zod schemas and ties each to the web
reader that renders it, so a new leaf without a reader fails. The
resolved-field guard gains a rule that bans the category ingredients
(catalogKeyForName, resolveMarkerCategory, summariseCategory,
categoriseOverview, ALIAS_PANEL, buildLocalisedLabelIndex) from client
code: the category, its order and its status are read, never rebuilt.

Refs #1349
2026-11-06 17:57:28 +01:00
Marc-André Bombeck c96e0e0f78 chore(demo): seed lab readings without a draw label as their panel
A draw label such as "Annual blood panel" is a panel the labs list
would show as one category holding every marker, so the demo list
would stay flat with categories turned on. Without a panel each marker
lands in its catalog category by name, both before and after the boot
backfill links the rows.
2026-11-06 17:54:02 +01:00
Marc-André Bombeck 65f1a3db91 feat(labs): keep a marker's panel when it is merged into one without
A merge into a target without a panel now takes over the first panel
a source carries, in the order the sources were given, so the merged
marker keeps its category. The result reports it as adoptedPanel, on
a dry run as well, and the merge preview says so before the person
confirms. A target with its own panel keeps it. The ledger does not
record panels; a merge has no undo.

The integration suite also proves the clear path end to end: an
explicit null on PUT clears panel, context and both bounds.

Refs #1349
2026-11-06 17:53:56 +01:00
Marc-André Bombeck 910f28dcfe feat(labs): suggest panels in the marker form and let an edit clear fields
The panel field offers the catalog panels in the page language and the
person's own panels, which GET /api/biomarkers now lists as
customPanels (one spelling per folded form). The field stays free
text; a catalog panel typed in any language lands in the same category.

An edit used to omit an emptied panel, context or bound, so the stored
value survived the save. The form now sends null for a field that held
a value and was emptied. A new marker still omits empty fields, and a
field that was empty when the edit began stays out of the body, so a
context the list could not decrypt is never wiped by an unrelated save.

Refs #1349
2026-11-06 17:51:34 +01:00
Marc-André Bombeck 92638ec089 feat(labs): show the labs list under server-resolved categories
GET /api/labs/overview now answers categories and grouped next to the
markers. A marker's category is its panel: a catalog panel in any
shipped language folds to one key, any other panel becomes the
person's own category under an opaque hash key, and a marker without
a panel falls back to the catalog panel of its name. ALIAS_PANEL
places the ten analytes that have names but no seed. Each category
carries its label in the caller's language, its members, counts per
range status and one combined status (above wins over below; no
alarm tone). grouped is true only when grouping adds something.

The list renders this as given: with groupBy set to category it shows
a disclosure header per category in both views, the person's sort
applies inside each category, and groupsOpen decides which start open.
A category opened or closed by hand stays so in this browser until the
choice changes. Settings > Arrangement > Labs gets both choices. The
overview query key carries the locale, since the labels are localised.

Refs #1349
2026-11-06 17:49:38 +01:00
Marc-André Bombeck e2170115c7 feat(settings): carry category choices in the labs list layout
The list layout gains two choices that only the labs list reads:
groupBy (none or category, default none) and groupsOpen (expanded,
flagged or collapsed, default expanded). The resolver keeps a valid
stored value and drops anything else to the default, the PUT merges
both field by field, and the hook exposes a setter for each. A
browser value from before the server column never held them, so the
legacy parse fills them from the defaults.
2026-11-06 17:44:38 +01:00
Marc-André Bombeck 2e14b2d8b5 fix(documents): wait out the upload route's busy answer instead of failing
The inbound route admits two uploads per person (four per process) and
answers the next with 429 `uploadBusy` and `Retry-After: 1`. The vault
queue sent three at once, so a multi-file drop provoked that answer
itself, and the card read it as the hourly limit: "Try again in a few
minutes".

The queue now runs at `DOCUMENT_UPLOAD_PER_USER_CONCURRENCY`, moved to
`inbound-constants.ts` so client and route share one number. A busy
answer is classified on its own, carries its Retry-After, and is retried
for the same file (Retry-After clamped to 1-10 s plus jitter, at most six
attempts within 30 s) while the row stays in its uploading state. Only a
spent budget shows an error, with copy that says the server was busy.
Unmounting aborts any pending wait. The Coach's new-file attach uses the
same retry. The hourly-limit copy now names the hour.
2026-11-06 12:35:25 +01:00
Marc-André Bombeck 53e5b47e0d fix(today): centre the score's meta lines under the ring
From md up the hero's score column sat on the trailing edge, so
"steady for 4 weeks" and its siblings ended at the ring's right edge
instead of standing under its centre. Below md the lines keep their
full-width row flush with the dial.
2026-11-06 11:26:04 +01:00
Marc-André Bombeck 0882ebd79f fix(timeline): keep a focused point's card when another point loses the pointer
A pointer leaving any point target closed whichever card was open, and a
blur did the same. With the pointer resting on one line's point and the
keyboard on another (the focus scrolls the chart, so the resting pointer
leaves the first point), the focused point lost its card and focusing it
again fired nothing. A point now releases only its own card, and hands it
back to the focused point when there is one.

The lab-marker journey drove into this when the menu click left the
pointer over a value point. It now sets that up on purpose and asserts the
card survives the pointer leaving, in place of the retry that could never
reopen a card on an already focused target.
2026-11-05 17:13:40 +01:00
Marc-André Bombeck 7b4c67b372 feat(workouts): attach a route to a stored workout that has none
The workout batch kept `route` only for entries it inserted. An entry
matching a stored workout reported `duplicate` and its track was dropped,
so turning on `storeWorkoutRoutes` only helped workouts recorded from then
on.

A matching entry that carries a route now attaches it when the stored
workout has no track and the account keeps tracks, the same way the
heart-rate series enrichment works: the planner probes for a stored
track, the write seals it like an insert does, and the entry reports
`enriched` (once, also when it attaches series and track together). The
climb derived from the track fills an empty `elevationM`, as it would on
insert. A workout that already has a track keeps it, and with routes off
nothing is written.

Track rows now go through `createManyAndReturn` on the unique
`workoutId`, so a track that lands between the probe and the write wins
and this entry settles as a duplicate instead of failing or adding a
second row.
2026-11-05 16:04:08 +01:00
Marc-André Bombeck da3d29424d fix(visits): cap the window before a visit at the longest report
The window before a booked visit started at the last matching visit
however far back it lay, while the health-report export covers 730 days
and fell back to its 90-day default for anything longer. The card then
offered a report the export did not build. The window now stops at the
export's limit, read from one shared constant, and preparation.capped says
when it did; the web says "The last two years" for it.

The export clamps an over-long explicit range to the last 730 days before
its end instead of falling back to 90 days, so a custom range or a linked
window cannot shrink silently either, and its day count forgives the hour
a clock change adds, so two years of local days count as 730.
2026-11-05 14:12:09 +01:00
Marc-André Bombeck f39598f2e7 feat(coach): name a read past the lookback limit on the trail
A read whose readings all lie past the lookback limit was reported on its
step as outside_window, and its fetch entry carried no reason, so a client
could not tell an empty window from data the limit keeps out. The step now
carries reason outside_reach and the fetch entry mirrors it. The method
line and the web trail keep the window sentence; the answer names the
limit as before.
2026-11-05 14:12:09 +01:00
Marc-André Bombeck 2e28d4ed4c fix(query): refetch a query whose first load was in flight at invalidation
TanStack restarts an in-flight fetch on invalidation only when the query
already holds data. A query still on its first load gets the in-flight
promise back, and that fetch's success clears the invalidated flag, so an
answer read before the write sticks with nothing scheduled to correct it.

The vault hit this when an upload settled just as a typed search switched
the list to a fresh key: the list stayed on "No matching documents" while
the stored row existed. invalidateKeys now waits for such a first load to
end and invalidates that query once more.
2026-11-05 13:05:57 +01:00
Marc-André Bombeck 63f9d2f60e test(integration): let the server time out a statement before the test does
A statement-level hang in the integration suite surfaced only as a
60-second test timeout, because statement_timeout sat at the same 60 s.
At 45 s the server cancels first, and its error names the statement and
the lock or wait it was stuck on.
2026-11-05 12:01:57 +01:00
Marc-André Bombeck ef61c7d5d1 fix(labs): keep the printed range on a marker minted from a text result
A report that prints a text result such as "SEE NOTE" with a numeric
range beside it minted the marker without bounds: the document commit,
the scan commit, the free-text lab write and the CSV import each passed
null bounds for a qualitative reading, and the CSV row reader and the
scan review payload dropped the range before that. The marker's first
number then had nothing to be judged against.

Every mint path now passes the printed window. A range that is text
itself yields no bounds, an existing marker keeps its own, a transposed
window is not minted, and the reading itself still records no window.
The scan review shows the range on a text row that mints a new marker.

Refs #1313
2026-11-05 10:59:52 +01:00
Marc-André Bombeck e9886ee554 fix(security): let the app frame its own blob previews
A blob: document inherits the policy of the page that minted it, and
WebKit applies the inherited frame-ancestors to the blob frame's own
parent. With 'none', Safari on macOS and iOS refused the PDF preview
iframe and showed an empty box; Chromium does not apply it there and
rendered the preview. 'self' keeps every foreign origin out.

Refs #1311
2026-11-05 00:52:02 +01:00
Marc-André Bombeck 6800ff2620 fix(i18n): translate the stray word in the Russian connect-source hint 2026-11-05 00:00:03 +01:00
Marc-André Bombeck e9ae024035 fix(onboarding): connect a Fitbit answer through Google Health
Fitbit shut its classic Web API down on 2026-10-30 and Fitbit data now
arrives through Google Health, yet answering "Fitbit" still ended the setup
on the classic card, read the classic connection's status and named it.

Fitbit stays an answer, because people still own the devices. Its task now
opens the Google Health card, reads that connection, is named after it, and
says on every tile that Fitbit data comes through Google Health. The
question carries the same hint under the Fitbit choice, and the checklist
names the connection the same way.

The onboarding-source guard keeps Google Health out of the questions, now
with a reason a test holds: the Fitbit answer is the way there.
2026-11-04 23:59:18 +01:00
Marc-André Bombeck 341061de5c feat(measurements): store resting energy as its own metric
HealthKit's basalEnergyBurned sat in the deferred set, so the batch route
refused it per entry and acceptedTypes left it out. It now maps to a new
BASAL_ENERGY_BURNED type in kcal (migration 0493), beside active energy
and never added to it.

It is cumulative like active energy: summed per day on the read path and
in the rollups, drained into one daily row, imported from export.zip,
listed and charted with its own label in every locale, part of the report
selection and the backup, and readable over MCP. It carries no personal
record direction and feeds no score, insight or Coach snapshot.

Health Connect is left unmapped on purpose: its basal metabolic rate is a
rate at one instant, not a day's energy. No other integration reports a
resting figure of its own.

Refs #1340
2026-11-04 23:45:55 +01:00
Marc-André Bombeck 4894f5be5b test(labs): seed the marker behind the lab line in the GET contract
The contract walk had no fixture for `/api/biomarkers/{id}/series`, so
the new route was reported instead of parsed. It now takes the seeded
biomarker, which already carries a lab value, and is checked against its
published schema like `/api/biomarkers/{id}`.

Refs #1342
2026-11-04 22:07:34 +01:00
Marc-André Bombeck 27a1cd6c2f feat(integrations): list imported cycle days in the details
The details of the Garmin and Oura cards listed measurement types and
workouts only, so cycle days a wearable imported appeared nowhere, and a
bug report could not say whether any had arrived. The status envelope now
carries a `CYCLE_DAYS` entry per cycle-importing source with the newest
day and the count, and the freshness list renders it. It counts as a
sparse type, so a month between periods never reads as a dead pipe.

Refs #1333
2026-11-04 22:03:18 +01:00
Marc-André Bombeck afdb8163ce fix(onboarding): offer Garmin as a source
The setup question about where readings come from listed every other
connection on Settings and not Garmin, so the flow could neither record it
nor end on connecting it. Garmin is now a Q4 source and a browser
connectable one: the key is pasted on its card, so nothing can be missing
up front.

A guard now pins that every connection the health read covers is either an
onboarding source or named with the reason it is not.

Refs #1333
2026-11-04 22:03:08 +01:00
Marc-André Bombeck 148af765c9 fix(cycle): open cycles from Garmin and Oura period days
Both wearables wrote their period days with a bare create and so skipped
the seam every other cycle writer goes through: a first bleeding day never
opened its cycle and no day was attributed to one. An account whose
periods came from a watch or a ring held day-logs only, with no cycle
start, no lengths and no forecast.

The create now goes through one shared helper that keeps the
create-only-if-absent rule and then runs the same attribution and
`ensureCycleForBleedingDay` inference as the shared upsert. The hourly
legs and the newest-first Garmin backfill both use it; a finished Garmin
period also stamps its last day on a cycle that has no period end yet.

Rows imported before this change are repaired by a one-shot job sent from
boot discovery while candidates exist. It opens or attributes cycles
oldest first per account, touches no day-log content, and converges to an
empty candidate set.

Refs #1333
2026-11-04 22:02:48 +01:00
Marc-André Bombeck a35b77c147 feat(labs): serve one marker's lab line under the labs module
GET /api/biomarkers/{id}/series answers the line labSeries on
/api/timeline draws for that marker, gated on the labs module instead of
the opt-in timeline. The window rules move into resolveTimelineWindow and
the shared query rules into withWindowRules, so both routes cut the same
window and read the same loader; all starts at the marker's first reading.

Refs #1342
2026-11-04 21:24:12 +01:00
Marc-André Bombeck a79fc4168a feat(sync): let a client start the delta feed at the present
GET /api/sync/changes?start=now answers an empty page whose cursor sits at
serverNow in every domain, in the same opaque format the feed hands out.
A client that only needs what changes from now on, such as the Apple
Health write-back, pages forward from it instead of draining the whole
history first. start and cursor together are refused with 422.

Refs #1339
2026-11-04 21:24:12 +01:00
Marc-André Bombeck 59434cd463 feat(healthkit): list the accepted sample types on the integration read
GET /api/integrations/healthkit carries acceptedTypes, the quantity and
category identifiers the batch route maps today. The list is derived from
APPLE_HEALTH_TYPE_MAP alone, so a deferred type is never in it and the
client no longer needs a release to follow a mapping change.

Refs #1340
2026-11-04 21:18:30 +01:00
Marc-André Bombeck 66533497de fix(auth): carry purgeAt on the native OIDC deletion-pending redirect
The web leg of the OIDC callback already names the purge date when the
account is pending deletion; the native leg redirected with the bare
reason. It now adds the same purgeAt parameter to the custom-scheme
redirect, and only the date.

Refs #1341
2026-11-04 21:18:30 +01:00
Marc-André Bombeck a812fb699f fix(mental-health): read the country from the region subtag for every language
A tag such as en-BE, fr-CH or en-AT fell through to the international
crisis set because region detection hung on the language. The region
subtag now decides first for every language; bare language codes keep the
combined sets, and the web's folded pt-BR bundle stays ambiguous.

Refs #1338
2026-11-04 21:02:02 +01:00
Marc-André Bombeck d3401b5b89 refactor(records): move the life circumstances field primitives into their own file
The draft state for the typed fields pushed the form past its size
baseline. The primitives own only their input state and read nothing from
the form, so they move out and the baselines tighten.
2026-11-04 17:59:54 +01:00
Marc-André Bombeck 41f90bac3f chore(deps): move @hono/node-server to 2.x and lift systeminformation
The serveStatic double decode has no fix on the 1.x line, and the MCP
SDK accepts 2.x. The app uses the web-standard transport and never loads
it. systeminformation sits under the shadcn CLI at dev time.
2026-11-04 16:56:20 +01:00
Marc-André Bombeck d98e16557b chore(deps): keep Node majors out of the image PRs
Refs #1334
2026-11-04 16:54:29 +01:00
Marc-André Bombeck 40200b55a7 fix(records): let the smoking history fields take keystrokes
The quit month only accepted text that was already a whole YYYY-MM
month and the start year clamped every keystroke into 1900..now, so
neither could be typed into. Both keep a draft while typing and commit a
valid value. Dependabot also stops proposing Node majors for the image.

Refs #1334
2026-11-04 16:54:14 +01:00
dependabot[bot] 25770912ce build(deps): bump @tanstack/react-query (#1336)
Bumps the production-weekly group with 1 update: [@tanstack/react-query](https://github.com/TanStack/query/tree/HEAD/packages/react-query).


Updates `@tanstack/react-query` from 5.105.0 to 5.105.1
- [Release notes](https://github.com/TanStack/query/releases)
- [Changelog](https://github.com/TanStack/query/blob/main/packages/react-query/CHANGELOG.md)
- [Commits](https://github.com/TanStack/query/commits/@tanstack/react-query@5.105.1/packages/react-query)

---
updated-dependencies:
- dependency-name: "@tanstack/react-query"
  dependency-version: 5.105.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production-weekly
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-04 14:39:22 +00:00
Marc-André Bombeck 90865eb485 Merge release/v1.47.0 2026-11-04 15:04:24 +01:00
Marc-André Bombeck 27b054d8c4 fix(image): take scheme-less health-check URLs, anchor the compose pin, say which tools left 2026-11-04 14:32:58 +01:00
Marc-André Bombeck fb143a2d47 docs(release): screenshots for v1.47.0 2026-11-04 14:21:49 +01:00
Marc-André Bombeck bc4b9f4350 chore(release): v1.47.0 2026-11-04 14:13:49 +01:00
Marc-André Bombeck 3149805592 Merge branch 'fix/v147-debian-slim' into release/v1.47.0 2026-11-04 13:15:49 +01:00
Marc-André Bombeck 849fe4b2c6 build: stop tracing the musl canvas prebuilts the glibc image never loads 2026-11-04 13:15:49 +01:00
Marc-André Bombeck e547a77683 docs: describe the image as the official Node 22 Debian slim image
The unreleased changelog entry said the image moved to Wolfi. It now
names the official Node image, why (Raspberry Pi 4 rendering, Node
security releases arriving with the image), that the image carries no
package manager, and that psql is no longer in the app image. The README
and the getting-started prerequisites say the same.
2026-11-04 13:14:04 +01:00
Marc-André Bombeck 09d932ce2a ci: fail the container scan only on findings with a fix
The Debian base carries HIGH advisories the distribution has not fixed
(util-linux, ncurses, systemd, acl, sqlite, perl-base), which no change
here can remove. The blocking image scan now runs with --ignore-unfixed,
so a finding with a published fix still fails the job, and a separate
step before it lists every HIGH and CRITICAL finding without failing.
The scheduled scan's critical gate follows the same rule; its SARIF
report keeps everything.
2026-11-04 13:14:04 +01:00
Marc-André Bombeck b6b693717c build: run the image on the official Node 22 Debian slim image
The runner moves from Wolfi to node:22-trixie-slim, the same image and
digest as the dependency and build stages. Wolfi's free repository keeps
only its newest Node stream current, so nodejs-22 stayed at 22.23.2 and
missed two releases with HIGH fixes; the official image carries 22.23.4.

The runner removes npm, npx, corepack and yarn before anything else and
fails the build if one is left. The Prisma CLI install moves to its own
prisma-cli stage on the same base and is copied over, so npm never runs
in the runner. pg_dump and pg_restore 18.6 come from a pgclient stage on
postgres:18.6-trixie, pinned by digest, with the libpq they link; the
Kerberos, LDAP and SASL libraries come from apt. The PGDG package would
bring Perl and the wrapper scripts, and its repository drops a pinned
minor once the next one is out.

The native ABI and glibc check between builder and runner stays as a
guard. runtime-package-pins now holds every stage to a digest, the
runner to the builder's image, the runner free of package managers, and
the pgclient minor to the bundled database's. Dependabot ignores a
PostgreSQL major in the Dockerfile as it does in the compose file.
2026-11-04 13:13:56 +01:00
Marc-André Bombeck 5dd6453773 fix(garmin): compare the cached token's key in memory, drop an unused export 2026-11-04 12:02:46 +01:00
Marc-André Bombeck de47cb7b31 test(analytics): list the Garmin and training types in the parity goldens 2026-11-04 11:15:03 +01:00
Marc-André Bombeck 8a89d5cfc3 Merge branch 'feat/v147-garmin' into release/v1.47.0 2026-11-04 10:58:59 +01:00
Marc-André Bombeck 2348073eeb docs(garmin): describe the service choice, key validity and skipped data
The Garmin page says the key goes only to the service picked, how to
correct a key's validity from the card, that a refused key is retried
daily, that weigh-ins reach back past the first watch, that removed
nights disappear, and where data Garmin does not provide is listed. The
changelog entry names the service choice.
2026-11-04 10:56:52 +01:00
Marc-André Bombeck 83c06051fe fix(garmin): read weigh-ins in both shapes, keep refused resources out of the way
Weigh-ins: Garmin's document has no samplePk and gives kilograms, the live
service sends a samplePk and grams. A weigh-in now takes its id from
samplePk or else its own GMT instant, reads each mass by magnitude (above
1000 is grams), and is written whole or not at all: a missing weight, a
missing instant or one implausible value drops every row of it, counted
on the wide event by reason.

Refused resources: a lasting 4xx on one resource (a cycle summary for an
account that tracks no cycle) is noted once on the progress column,
counts as done for the backfill, is skipped by the hourly sync and is
listed in the card's details. It no longer holds the backfill open or
turns the connection red. The cycle leg never parks the connection,
whatever it is refused with.

Backfill: weigh-ins walk back to the start of Garmin Connect on their
own, so history before the first watch day and a scale-only account
arrive. Progress and the completion stamp are written only while the
connection the slice started on is still the stored one.

Key: connect sends the key to the one service the person picks (Garmin
Connect or Garmin Connect China), never to both. A parked key is asked
about again once a day with a single token exchange and reconnected when
Garmin accepts it. The key's end is read from the token's own iat and
exp, an estimate the key outlived is cleared after an accepted exchange,
and the validity can be corrected from the card's menu (PATCH
/api/garmin/key-expiry, with the day the key was created) without
pasting it again.

Sync: an incremental run reaches back to the last successful sync, up to
a year, so an outage longer than the 30-day window leaves no gap; a night
Garmin no longer reports (deleted, or turned into a nap) is retired.

Card: connect errors are translated from meta.errorCode in every locale.

Types: BODY_BATTERY, STRESS_LEVEL, TRAINING_READINESS and
ACUTE_TRAINING_LOAD get source ladders of their own (the device or the
bridge first, then the hand-entered sources in their usual order), join
the ranked types and the provenance picker, and the recovery-empty
description names them.
2026-11-04 10:56:52 +01:00
Marc-André Bombeck 73f16e5389 docs(garmin): document the Personal API integration
The Garmin page now explains how to create a key, what syncs and where
it lands, how key expiry is handled, and what the Personal API does not
carry. The Home Assistant guide shows how to push Training Readiness and
the acute training load, the Apple Health and Google Health pages point
at the direct integration, and the changelog records the release notes.
2026-11-04 10:14:17 +01:00
Marc-André Bombeck 13e232c836 test(garmin): pin the key exchange, the mapping and a sync against a fake Garmin
Unit tests cover the token cache, the single re-exchange on a refused
token, the key's end read from a capped token, the 429 wait, the range
chunking, and every resource's mapping over made-up records shaped from
Garmin's document. The card test pins the shared anatomy, the menu and
the expiry sentences. The integration test runs the sync, the backfill,
connect and disconnect against a fake Garmin served over HTTP and the
real schema: rows per resource under GARMIN, no second row on a re-sync,
a refused key parked after one retry, a 429 that stops the run, a
history walked to its start across slices, and a purge on disconnect
that keeps the imported history.
2026-11-04 10:14:17 +01:00
Marc-André Bombeck e2ba86b12f feat(garmin): sync Garmin Connect through the Personal API
Garmin Connect now offers a Personal API: a person creates a key for
their own account and any client can buy short-lived tokens with it.
HealthLog takes that key in a new Garmin card, checks it with Garmin
before storing it sealed on the user row, and polls every hour.

- Transport: key exchange with a per-process token cache keyed on the
  key, one re-exchange on a refused token and then reauth_required, the
  429 Retry-After carried to the caller, the China host tried when the
  global one does not know the key, answers read with a byte cap, all
  egress through safeFetch to the two fixed hosts.
- Sync: daily summaries, sleep summaries, HRV, weigh-ins and the
  activity search, chunked to each resource's documented range, read one
  after another and stopped at the first rate limit; today's menstrual
  cycle summary adds logged period days the person has not logged.
- Backfill: a resumable walk through the shared admission lane that
  learns where the history starts from the daily summaries and the
  activities, waits out Garmin's rate limit and stamps its marker.
- Types: GARMIN source; BODY_BATTERY and STRESS_LEVEL for Garmin's own
  day scores, kept apart from the computed STRESS_SCORE; TRAINING_READINESS
  and ACUTE_TRAINING_LOAD for figures a bridge pushes through the ingest
  token. All four chart on the recovery page and sit on the ladders,
  units, ranges, labels and report groups like the device-native scores.
- Key expiry: learned from a token capped short of a day, else estimated
  from the validity picked at connect; the card warns a week ahead and
  asks for a new key once Garmin refuses the old one.
- Ledger, health, freshness, watchdog, backup classes, wipe plan,
  encrypted-column registry, rotation script, OpenAPI and all ten
  locales carry the new integration. The old "no Garmin connector" note
  is gone.
2026-11-04 10:14:10 +01:00
Marc-André Bombeck 67909f2d9d fix(db): derive the app role's password with scrypt
The key it comes from is one the operator chose, and a fast hash over a
weak one would be cheap to walk back from a leaked verifier.
2026-11-04 01:46:59 +01:00
Marc-André Bombeck f266555fe8 build: hold each message catalog to 250 KB gz, for the Russian one
Cyrillic is two bytes per letter in UTF-8, so the Russian catalog is
240 KB gz against under 210 for the Latin-script ones. The reason and the
numbers are in the budget file.
2026-11-04 01:46:58 +01:00
Marc-André Bombeck 4762ca43c2 docs(changelog): v1.47.0 entries 2026-11-04 00:56:27 +01:00
Marc-André Bombeck a162990d18 test(medications): drop the timeout the BYSETPOS property no longer needs 2026-11-04 00:45:53 +01:00
Marc-André Bombeck 65dc9751e0 test(medications): generate BYSETPOS rules that always have an occurrence
A rule with no occurrence (BYSETPOS=2 over one weekday) makes rrule walk
to its year 9999 on every call, seconds per case, which no schedule the
app accepts can produce. The reviewed counterexample is a fixed case.
Also formats the locale settings file.
2026-11-04 00:45:28 +01:00
Marc-André Bombeck 1bbd2d97c7 Merge branch 'fix/v147-screen-negations' into release/v1.47.0 2026-11-04 00:17:01 +01:00
Marc-André Bombeck 1a4703f4fe feat(insights): name the part of the day on the blood pressure stats while narrowed
The stat strip reads the chart's visible window, so with the chart
narrowed to the morning its figures were morning figures under a plain
Systolic label. The column label now says so. Russian texts for the
time-of-day card.
2026-11-04 00:17:00 +01:00
Marc-André Bombeck 62d1f57de8 fix(ai): block a negated take, a pause, an increment and a liquid dose
Four instruction shapes passed the outbound dose screen in every language:
a negated intake ("Don't take atorvastatin tonight", "Nehmen Sie kein
Atorvastatin"), a pause ("Take a break from enalapril"), an increment with
no target ("Add 4 more units of insulin"), and a measured volume of a liquid
drug form ("Take 10 ml of the syrup").

Each bank gains the shapes, anchored on a medication noun, a drug unit or a
liquid drug form rather than on the verb. A negated intake counts as a hold
only when the clause closes or a time word follows and no protective
qualifier sits in the object ("a double dose", "more than", "half"), so the
standard warnings still pass. `ml` stays out of the drug units: the liquid
shape needs a syrup, solution, drops or medication noun beside it, so
"drink 500 ml of water" passes.

The Russian and Korean banks now know the generic-name stems in their own
script (INN_STEM_CYRILLIC, INN_STEM_HANGUL), and the English object slot no
longer crosses another language's negation, which had blocked the Dutch
warning "Stop niet met ramipril zonder overleg".
2026-11-04 00:14:56 +01:00
Marc-André Bombeck 22f83aed6e Merge branch 'feat/v147-bp-time-of-day' into release/v1.47.0 2026-11-04 00:13:45 +01:00
Marc-André Bombeck b93241aa87 feat(insights): blood pressure by time of day
Home monitoring reads morning and evening readings as separate sets, so
the blood pressure page now shows the morning, midday and evening means
side by side over the chart's range, each with its count and pulse, and
the morning-minus-evening difference. A control under them narrows the
chart to one part of the day; the choice lives in `?partOfDay=` so it
survives a reload and a shared link, and the whole day keeps a bare URL.

The split is resolved on the server in the record's own time zone and
published as one response, `GET /api/measurements/blood-pressure/time-of-day`,
so every client renders the same numbers. Morning is 04:00 to 11:59,
midday 12:00 to 17:59, evening 18:00 to 03:59: the guidelines define the
sets by waking and bedtime rather than by the clock, so the windows are
wide, and a reading after midnight is a late evening one. Pairs follow
the blood-pressure series' rule (same source within five minutes); the
pulse is the reading's own. A part shows a mean from three readings on.

`GET /api/measurements` takes `partOfDay` on the blood-pressure and pulse
series read: each local day folds only that part's readings, a day runs
from 04:00, and long ranges fold weekly instead of reading rollups that
cannot tell the hour.

Refs #1331
2026-11-04 00:12:49 +01:00
Marc-André Bombeck cac0668119 test: read the Dockerfile through readSource in the wget shim test
Plus six Russian strings smoothed in review.
2026-11-03 23:52:57 +01:00
Marc-André Bombeck b2fb295d43 Merge pull request #1322 from l1137l/feat/ru-locale 2026-11-03 23:50:35 +01:00
Marc-André Bombeck 0007973b78 fix(ai): tighten the ru dose bank
End a dose unit at a Cyrillic letter, so «3 месяца» no longer reads as
"3 МЕ". Keep ml out of the drug units as the other banks do, and stand
the target-dose shapes down on a dietary noun (water, sodium, caffeine),
which blocked plain nutrition advice. Cover the stop and inject verbs
(перестаньте, бросить, колите) the English bank already blocks.
2026-11-03 23:45:36 +01:00
Marc-André Bombeck 3e2e2ad56b Merge branch 'pr-1322' into review/pr-1322 2026-11-03 23:37:22 +01:00
Marc-André Bombeck 2d8b782f34 Merge branch 'fix/v147-review-dbrole' into release/v1.47.0 2026-11-03 23:35:54 +01:00
Marc-André Bombeck a80fcf5276 docs: name the per-database role and describe the owner-owned queue schema 2026-11-03 23:31:38 +01:00
Marc-André Bombeck a10bebdc43 fix(db): give each database its own app role and keep the queue schema the owner's
Roles are cluster-wide. Two HealthLog databases on one server shared
healthlog_app: each start set its own password, locking the other
instance out, and the role held grants on both databases. The role is
now healthlog_app_<database>_<hash>, marked with a comment naming its
database, its password is derived per database, and a role holding an
explicit CONNECT on another database is not used.

The role owned the pgboss schema, so it could plant a trigger, a rule or
a function body that a later owner session (a rollback, DB_APP_ROLE=off,
a fallback start) would run with the owner's rights. pg-boss changes its
schema only on install, on migration and for partitioned queues, which
the app does not use. The start now installs and migrates the schema as
the owner with pg-boss's own plans, and the worker runs pg-boss with
migrate: false when it does not own the schema. The role gets rows only,
read-only on pgboss.bam (SQL the migration runner executes) and on the
version column. Every start, on every path, takes back what a
HealthLog role owns there, drops triggers and rules, and after a
take-back restores the queue functions to pg-boss's own definitions and
retires the legacy role. Bloated queue indexes, which pg-boss only
reports when it does not own them, are rebuilt at start.
2026-11-03 23:31:37 +01:00
Marc-André Bombeck 41e75b5bde Merge branch 'fix/v147-review-pdf' into release/v1.47.0 2026-11-03 23:30:26 +01:00
Marc-André Bombeck da48f1af9e Merge branch 'fix/v147-review-wolfi' into release/v1.47.0 2026-11-03 23:30:25 +01:00
Marc-André Bombeck 7af9ebe480 docs(stats): say what the r² clamp does and does not fix 2026-11-03 23:30:21 +01:00
Marc-André Bombeck 0e7134a2ea fix(labs): let a deleted number not hold a text marker's unit, and read the winner's unit after a race
Refs #1313
2026-11-03 23:30:21 +01:00
Marc-André Bombeck 55a3a9b53d fix(medications): keep the rrule anchor for a rule with BYSETPOS
The library drops a period's candidates before DTSTART before BYSETPOS
picks among them, so a start moved into the middle of a week chose a
different day. The property now generates BYSETPOS rules and kept red
without the guard.
2026-11-03 23:30:20 +01:00
Marc-André Bombeck 42cae738f1 build: pin the runner's Node and PostgreSQL client packages
nodejs-22 and postgresql-18-client are installed at exact package
versions, so a rebuild of the same commit runs the same Node and the
same pg_dump; apk upgrade still moves the libraries under them. No
updater reads an apk pin. The image scan does the job instead: Wolfi
records the fixed version for each advisory, so a pinned package with a
published fix turns the HIGH/CRITICAL gate red and names the version.
A guard holds the pin format and keeps the client's minor on the bundled
database's.
2026-11-03 23:27:06 +01:00
Marc-André Bombeck a11ea8444d build: ship a wget for health checks written outside the image
The Wolfi runner has no wget, but health checks outside this repository
call it: compose files copied from here up to v1.46.6 run
'wget --no-verbose --tries=1 --spider .../api/health', and the TrueNAS
catalog app renders 'wget --quiet --spider <url>'. Either replaces the
image's HEALTHCHECK, so such a container never turned healthy and a proxy
that routes on health dropped its traffic.

/usr/local/bin/wget now runs scripts/wget-shim.mjs: the URL, --spider,
-q, -nv, --tries, --timeout/-T, -O - and -O /dev/null, -S; other flags are
ignored. Exit 0 on 2xx/3xx, 1 on any other status, a refused connection
or the timeout. It follows no redirect and refuses any other output file
before sending a request, so it is no download tool. The image's own
HEALTHCHECK and the bundled compose file keep the Node probe.
2026-11-03 23:27:06 +01:00
Marc-André Bombeck 5705184857 test(documents): drop a mock of a module that no longer exists 2026-11-03 23:24:49 +01:00
Marc-André Bombeck 253176d01a fix(documents): index every page of a PDF read by its text layer
"Read with AI" and the index backfill wrote a text-layer read as a complete
`local-pdf` index although it held only the first ten pages (the render
cap), while the local text index reads up to 200. `prepareVisionInput` takes
the number of pages a text-layer read may take, and both index writers ask
for the local index's own cap, so the index is complete.

The `partial` flag on a text read had no reader and is gone; the
extraction and the lab scan report their missing pages themselves.

Refs #1311
2026-11-03 23:24:49 +01:00
Marc-André Bombeck 763d6c600f fix(labs): bound each text-layer read of an unrenderable PDF
`planTextLayerReads` grouped whole pages into the reads the deadline
allows, with no bound on a read's size, while the text path refuses a part
past `OCR_TEXT_READ_MAX_CHARS`. A dense report in a request's three reads
went out as reads of 12 000 characters and more, which ends at the output
limit or loses rows.

`planTextLayerChunks` now cuts a page longer than one read at line
boundaries, keeps a page per read while the deadline allows that many, and
otherwise packs pages into reads of at most twice the surface's target
(8 000 characters for the lab scan, 5 000 for the document extraction).
What the reads cannot take is read from the start, and the pages left over
are reported with the pages without a text layer: `unreadPages` on the lab
scan, `extractionIncomplete` on a document.

Refs #1311
2026-11-03 23:24:38 +01:00
Marc-André Bombeck 119431dc15 fix(documents): fence document text in the summary and the filing suggestion
Both text modes spliced the text after a bare `OCR TEXT:` label, while the
extraction, the lab scan and the label read fence it. A PDF read by its
text layer on a server that cannot render now reaches these prompts, and
the automatic summary reads imported PDFs that way, so text the page does
not show (white on white) could write past its own end. The text is now
fenced with `fenceDocumentText`, the browser-OCR path included, and a marker
forged inside it is scrubbed.

Refs #1311
2026-11-03 23:24:26 +01:00
Marc-André Bombeck b975e86b39 test(clinician-share): start every case on an all-modules-on map
Two cases switch the doctor report off for the rest of their block, and
the blocks that did not set the map themselves inherited it under a
shuffled order, so a record share stopped aggregating.
2026-11-03 22:39:16 +01:00
Marc-André Bombeck 144840bab9 test(documents): type the fixtures the text-layer cases added instead of casting them
The stored bytes are already a Buffer and the MIME literal already fits
its union, so neither needs a cast; the external pick goes through one
helper. The as-never ratchet tightens accordingly.
2026-11-03 22:36:18 +01:00
Marc-André Bombeck 7cb67c2e75 Merge branch 'fix/v147-nightly' into release/v1.47.0 2026-11-03 22:30:50 +01:00
Marc-André Bombeck a598b00081 test(coach): start every snapshot case with no readings
The query-shape case read the measurement mock without setting it and
passed only after a case that left rows behind.
2026-11-03 22:25:43 +01:00
Marc-André Bombeck 1ac69420d5 test: reset to the factory answer instead of casting a new one
A reset mock goes back to the implementation its factory gave it, which
is the fresh session and the open capability these cases want; the cast
replacements grew the test-realism count.
2026-11-03 22:22:32 +01:00
Marc-André Bombeck 9a3140a4d3 test(backup): hold the record-shaped slices at their edges
The health score day, the intraday curve, a medication with its
composition and custom category, an intake row and the cycle's own
symptoms had fields and defaults no slice test met, added since the
floor was set; the backup-validation score had drifted to 40.2 % under
its 41 % floor. It is 54.1 % with these cases, and the floor follows
to 53 %.
2026-11-03 22:15:05 +01:00
Marc-André Bombeck fa439601c4 test(labs): give the unitless-marker refusal its own marker
The refusal case leaned on the marker the case before it mints, and
under a shuffled order it ran first and minted it itself.
2026-11-03 22:13:32 +01:00
Marc-André Bombeck b1cb387062 Merge branch 'fix/v147-pdf-unrenderable' into release/v1.47.0
# Conflicts:
#	.env.production.example
2026-11-03 22:10:14 +01:00
Marc-André Bombeck 3547c4bd2f test: pin the intake history's success badge at the one it now renders
The taken badge moved into one shared component, so the file carries the
token once.
2026-11-03 22:08:05 +01:00
Marc-André Bombeck 394828449a test(documents): read unrenderable PDFs through the routes against Postgres 2026-11-03 22:07:28 +01:00
Marc-André Bombeck df6d20b696 fix(documents): read a PDF the server cannot render by its text layer
On a host whose CPU cannot run the page renderer (x64 without AVX2, arm64
musl without LSE/RCpc), every AI read of a PDF for a provider that only
takes images failed with "Scanning PDFs needs a Claude vision provider",
which says nothing about the cause. Most medical PDFs are produced
digitally and carry every word in their text layer, which pdf.js reads
without rendering.

`prepareVisionInput` now answers a text read (`mode: "text"`) when the
renderer is unavailable on this host, the provider cannot take the PDF
whole and the PDF has a usable text layer. Summary, suggest and the
background summary send that text through their existing text mode;
"Read with AI" and the index backfill write it as the local text index it
is, with no provider call; the automatic index leaves it to its local path.
`planVisionReads` does the same for the extraction and the lab scan: text
reads by page group, with anchored provenance, and the pages without a text
layer reported as `extractionIncomplete` on a document and as `unreadPages`
on a lab scan. A supplement label PDF is read from its text layer too.

A scan on such a host now answers `documents.inbound.pdfRendererUnavailable`
and `labs.ocr.pdfRendererUnavailable` (422): this server's processor cannot
run the page renderer, so a scanned PDF can only be read by a provider that
takes PDF files directly, or as photos. The `pdfNeedsAnthropic` codes are no
longer emitted, since with a working renderer every provider reads a PDF;
the recorded index outcome keeps its `pdf-needs-anthropic` value with
corrected copy.

The extraction path no longer folds a render failure of one document into
the capability error: like `prepareVisionInput` since #776 it reports
"The PDF pages couldn't be rendered for scanning." (`extractFailed`), and
the lab scan answers `labs.ocr.extractFailed`.

Hosts that render, and providers that take PDFs whole, are unchanged.

Refs #1311
2026-11-03 22:07:22 +01:00
Marc-André Bombeck c47f61f02a feat(documents): read a PDF's text layer page by page without rendering
The reader child gains a `page-texts` request: the first pages' text
layers, each read the way a rendered page's is (cut and marked past the
per-page cap), plus the document's page count. Nothing is rendered, so it
works where the page renderer cannot run.

`readPdfTextLayer` judges what that read is worth: a page counts only with
at least 40 characters of its own besides white space, so a scanner's
stamped page number or fax header does not pass for a page of text; a PDF
with no such page is a scan and is reported as one.
2026-11-03 22:07:11 +01:00
Marc-André Bombeck d1ab195b43 Merge pull request #1330 from FloMaetschke/feature/injection-site-in-intake-history 2026-11-03 22:06:35 +01:00
Marc-André Bombeck a503c8855d Merge branch 'feat/v147-wolfi' into release/v1.47.0 2026-11-03 21:56:23 +01:00
Marc-André Bombeck 5d36afb487 build: run the image on a Wolfi runner
The dependency and build stages stay on node:22-trixie-slim, where pnpm
picks the glibc native packages. The runner moves to wolfi-base, pinned
by its index digest: glibc, about fifty packages, and every one of them
known to the image scan, Node and the PostgreSQL client included.

nodejs-22, tzdata and postgresql-18-client come from apk, so the pgclient
stage and its pin guard go. The builder writes its Node module ABI and
glibc version out and the runner refuses to build on another ABI or an
older glibc. Wolfi's npm is major 12 and runs no install scripts unless
package.json allows them, so the Prisma CLI install names the three
packages that have one, and the build checks that the schema engine file
is there before asking the CLI about it. npm is installed for that step
and removed in the same layer; the check that nothing is left asks a
fresh shell, whose command cache cannot remember the deleted binary.
2026-11-03 21:55:30 +01:00
Marc-André Bombeck 2c33c2a0a0 test(integration): stop cases reading what another case left behind
- the AI-state cases take their baseline in a world of their own
  instead of from the S0 case;
- the live-window case drops a rollup tier the rollup case built;
- the job readers start each case on an empty queue, and the
  no-schema case starts pg-boss again after it;
- the server move case reseeds the server it emptied;
- the extremes cases rebuild the tier one of them breaks on purpose;
- the first Google Health backfill seeds its own history;
- the batch budget warms the process caches as the reads do;
- the HOT-update count is read as a difference: since Postgres 15 the
  transaction view also shows an earlier transaction's unflushed counts;
- the MANAGE matrix checks its effect set after all cases rather than
  in a case of its own that a shuffle could run first.
2026-11-03 21:35:57 +01:00
Marc-André Bombeck d7046c9737 test(integration): keep the stateful walks in order, give the rest their own rows
The migration before-and-after files, the dump directory, the geo
backfill's three passes, the settings trip, the backup parity steps,
the lab conversion record and the pulse method's dry run before its
rebuild are each one sequence by design; they now say so and keep their
order with shuffle: false. The CSV import's zone and crash cases wrote
into the account the lab and medication walks count, and now use
accounts of their own.
2026-11-03 21:35:57 +01:00
Marc-André Bombeck 713806fe9b test(parity): keep each route walk in order under a shuffled run
A parity file is one account walked through a route's cases, each
request reading what the earlier ones stored, and the recorded files
pin that sequence. The nightly shuffle reordered the steps and every
snapshot after the first moved. Keep each walk in order with
shuffle: false, wrapping the files whose top-level blocks build on each
other, and note the rule for order-bound files in the testing guide.
2026-11-03 21:35:56 +01:00
Marc-André Bombeck 899a054e64 Merge branch 'feat/v147-mcp-reading-id' into release/v1.47.0 2026-11-03 21:33:50 +01:00
Marc-André Bombeck 66709222b2 docs(api): describe the lab reading id next to the analyte id 2026-11-03 21:33:18 +01:00
Marc-André Bombeck 52e6bcd480 test(mcp): prove a report's lab link lands on that report's reading
Two draws of one analyte a year apart and two reports on the same day, against
Postgres: each report lists its own reading id, and fetching it returns that
reading with that report in its links, not the latest one.
2026-11-03 21:33:17 +01:00
Marc-André Bombeck ba27e728cf feat(mcp): fetch one lab reading by its own id
A link to a lab result named its analyte, and fetch lab:<analyte> answers
with the latest reading of that marker. Following a link from a report that
holds an earlier draw therefore landed on a later reading, whose links named
a different report and visit.

fetch labresult:<id> now returns that one reading (value or text result,
unit, reference range, status, date) with its own links, behind the labs
module and narrowed to the caller's live rows. Every link to a lab result
uses this id, each reading in get_labs carries it, and fetch lab:<analyte>
names the reading it quotes in metadata.readingId. The analyte view and
search are unchanged.

Refs #1323
2026-11-03 21:33:16 +01:00
Marc-André Bombeck 691c2e7301 refactor(labs): shape one reading from one shared select
The labs block and a single-reading read now build a reading from the same
select and the same shaping, so one reading fetched by its id renders exactly
like the latest reading of a marker.
2026-11-03 21:33:14 +01:00
Marc-André Bombeck cd344125d5 fix(labs): let a text-only marker take the unit of its first number
A marker minted from a text result ("SEE NOTE") has an empty unit, and a
marker with a reading has its unit locked, so the first number to arrive,
a unitless ratio included, was refused against a unit nothing had chosen.
While every reading is text the unit has nothing to contradict, so the
first number sets it. A marker that already holds a number keeps its unit.

Refs #1313
2026-11-03 21:19:28 +01:00
Marc-André Bombeck 377e0d0d3a build: copy pg_dump and pg_restore from the PostgreSQL image
postgresql-client-18 from the PGDG repository brought Perl and Debian's
client wrapper scripts into the runner for two binaries the app calls by
name. A pgclient stage on postgres:18.6-trixie, pinned by digest, now
hands over pg_dump, pg_restore and the libpq they link; the Debian
libraries libpq needs (Kerberos, LDAP, SASL) still come from apt so the
upgrade and the image scan see them. The runner registers libpq with the
loader and fails the build when either binary has an unresolved library;
the major 18 version checks stay.

The client minor follows the bundled database's: a guard holds the two
pins together, and the Dockerfile's Dependabot watch now ignores a
PostgreSQL major the same way the compose watch does.
2026-11-03 21:18:44 +01:00
Marc-André Bombeck cf7905dc92 refactor(medications): hold recurrence.ts at its size baseline 2026-11-03 20:58:46 +01:00
Marc-André Bombeck 227c80878c test: give every case its own mock state under a shuffled order
Cases leaned on what an earlier case set up or left behind: a once-value
the code under test never consumed, an implementation swapped in and
kept by clearAllMocks, a payload only another block's setup provided,
the zone resolver's per-user cache, module state in a hoisted object,
and a scope set one case discovered for the next. Each now resets or
states what it reads.
2026-11-03 20:58:38 +01:00
Marc-André Bombeck 4f7802ba28 test(browser): leave no state behind for the next case
Await the form's explicit unmount: an unmount left running overlaps the
next render's act, and React then commits nothing for the rest of the
file. Return to the newest history entry after stepping back, so the
next case's push adds an entry instead of replacing the forward one.
2026-11-03 20:58:38 +01:00
Marc-André Bombeck 54d0647a6d fix(stats): keep r² within [0, 1] when y sits far from zero
The centred sums lose their last digits when every y is close to one
large value, and a two-point fit came out at r² 1.00000004. Clamp the
ratio to 1 and the y sum of squares at 0, and pin the two shrunk
counterexamples the nightly property run found.
2026-11-03 20:58:37 +01:00
Marc-André Bombeck 270c6ca619 refactor(medications): keep the rrule start alignment in its own module
recurrence.ts sits on the source-size ratchet; the helper and its reasoning
read on their own.
2026-11-03 20:57:46 +01:00
Marc-André Bombeck d8743e9111 chore: drop the trivyignore entries for npm's bundled packages
Both covered copies inside the npm CLI of the Node base image. The
runner removes npm, so neither CVE matches anything in the image; a scan
of the built image with an empty ignore file reports neither.
2026-11-03 20:53:29 +01:00
Marc-André Bombeck 3d0ae05192 docs: say the published image renders documents on a Raspberry Pi 4
The image runs the glibc canvas build now, which needs no ARMv8.2
features, so the Pi 4 gets thumbnails and scanned-PDF rendering back
without a code change: the gate only ever closed for musl on arm64. The
gate's comment, the env example and the getting-started prerequisites
say so, and keep NATIVE_CANVAS as the override. The x64 AVX2 gate stays:
the glibc x64 build rendered a PDF page under an emulated core without
AVX2, but it still carries AVX2 code and that one run does not cover
every path.
2026-11-03 20:53:28 +01:00
Marc-André Bombeck de7858100f build: run the image on Debian trixie instead of Alpine
Every stage now starts from node:22-trixie-slim, pinned by its multi-arch
index digest, so the native modules load their glibc builds. The arm64
musl Skia build needs ARMv8.2 (LSE atomics, RCpc loads) and dies with
SIGILL on an ARMv8.0 core such as the Raspberry Pi 4's Cortex-A72; the
glibc build runs there (#1311).

The runner keeps what it had: tzdata, TZ, the build-time OS upgrade, the
nextjs user (uid 1001, now in group nodejs gid 1001), the backup and
GeoLite2 directories and every build-time assertion. pg_dump and
pg_restore of major 18 come from the PGDG apt repository, whose signing
key is fetched by node and checked against a pinned SHA-256 before apt
trusts it; PATH names the major 18 directory so the binary does not
depend on Debian's version-picking wrapper. The canvas hoist takes the
-gnu binary package by the pinned version and still fails the build on a
mismatch. bookworm-slim was the other candidate; on the day of the
switch it carried a CRITICAL zlib finding that trixie-slim does not.
2026-11-03 20:53:20 +01:00
Marc-André Bombeck 65e35761ec build: probe container health with Node's fetch instead of wget
The probe now needs nothing but the node binary the image already runs,
which frees the base image from carrying wget. The image HEALTHCHECK and
the compose healthcheck use the same one-liner: a non-2xx answer, a
refused connection or the 4 s abort exit 1, inside Docker's 5 s timeout.
The boot-storm guard reads the abort from the fetch and refuses a probe
that names wget or curl.
2026-11-03 20:53:20 +01:00
Marc-André Bombeck 775933b7d5 Merge branch 'feat/v147-dbroles' into release/v1.47.0 2026-11-03 20:52:17 +01:00
Marc-André Bombeck 3d1a6224ba test: drop vi imports the removed schema-mirror mocks left unused 2026-11-03 20:49:31 +01:00
Marc-André Bombeck df1e19f1c2 docs: describe the app's database role, its limits and how to opt out 2026-11-03 20:38:46 +01:00
Marc-André Bombeck c7ff22ab00 chore(env): whitelist DB_APP_ROLE and document it beside the database settings 2026-11-03 20:37:48 +01:00
Marc-André Bombeck 485161a657 feat(db): run the app as a role that cannot change the schema
After the migrations the entrypoint ensures healthlog_app, a login role
with row access to the app's schema, MAINTAIN on measurements and
ownership of the pgboss schema, and starts the app with a DATABASE_URL
for it in place of the owner's. The role cannot alter or drop tables,
create roles, schemas or functions in the app's schema, read server
files or run programs, or write the migration history.

The password is an HMAC of a fixed label under API_TOKEN_HMAC_KEY, so a
separate worker container derives the same one, and it is sent as a
SCRAM verifier. Every boot re-applies the grants in one transaction
under an advisory lock, and drops as the owner the indexes the
maintenance job may no longer drop. When the owner cannot create roles,
the server is older than Postgres 17, or the role cannot log in, the
boot says why and the app stays on the owner as before. DB_APP_ROLE=off
skips it.
2026-11-03 20:36:37 +01:00
Marc-André Bombeck 95525a0259 fix(jobs): let pg-boss create its schema only when it is missing
Postgres checks the database's CREATE privilege before it checks
whether the schema exists, so CREATE SCHEMA IF NOT EXISTS fails for a
role that may not create schemas even when the schema is there. The
worker now asks first and passes createSchema: false when it exists.
2026-11-03 20:32:53 +01:00
Marc-André Bombeck f05a112c61 feat(jobs): leave index drops to the owner when maintenance runs without it
DROP INDEX belongs to the table's owner; the MAINTAIN privilege covers
VACUUM and REINDEX but not the drops of retired indexes and invalid
rebuild copies. A run that does not own measurements now skips those
drops and names what is left, for the boot to drop as the owner. The
retired-index list moves to a plain module the boot script can read.
2026-11-03 20:32:53 +01:00
Marc-André Bombeck b6da360856 refactor(db): drop the runtime schema mirror
ensureDbCompatibility ran ALTER TABLE / CREATE TABLE IF NOT EXISTS on the
login, register, token and session paths, mirroring columns every
migration under prisma/migrations already creates. The entrypoint applies
those migrations before the server listens and stops the boot when one
fails, so the mirror never had work to do, and DDL on a request path
needs the table's owner: a connection that may only read and write rows
fails every login on it. Drop the module, its call sites and the mocks
that stubbed it out.
2026-11-03 20:32:35 +01:00
Marc-André Bombeck 381726f471 perf(medications): start the rrule walk just before the window
between() iterates from DTSTART, so every compliance read walked a daily
schedule from the day the medication was created: about 4 ms per rate,
growing with the medication's age, and enough under a loaded test run to
push a compliance property past its timeout. DAILY and WEEKLY rules repeat
with a fixed period, so the start moves forward by whole periods to just
before the window. A property test holds the occurrences in the window
equal to the walk from the real anchor.
2026-11-03 20:23:31 +01:00
Florian Maetschke 8643a1ab8e Note the injection-site chip in the changelog 2026-11-03 12:03:33 +01:00
Florian Maetschke a3516160c0 Share the taken/skipped chip between the two history layouts
The desktop table and the mobile cards carried the same status badge
twice. Pulling it into one component makes room for the injection-site
chip without growing the list function past its size baseline, and
the baseline tightens to the new, lower count.
2026-11-03 11:49:41 +01:00
Florian Maetschke 5aac158b12 Show the injection site in the intake history
An injected dose already stores where it went, but the per-medication
history only showed when it was taken and where the entry came from.
The list now renders the site as a chip next to the status, on the
desktop table and the mobile cards, so the rotation can be read back
from the history. Rows without a site are unchanged.
2026-11-03 11:46:07 +01:00
dependabot[bot] 540f4ff84f build(deps): bump the production-weekly group across 1 directory with 3 updates (#1329)
Bumps the production-weekly group with 3 updates in the / directory: [@aws-sdk/client-s3](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-s3), [@aws-sdk/lib-storage](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/lib/lib-storage) and [@napi-rs/canvas](https://github.com/Brooooooklyn/canvas).


Updates `@aws-sdk/client-s3` from 3.1159.0 to 3.1161.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-s3/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1161.0/clients/client-s3)

Updates `@aws-sdk/lib-storage` from 3.1159.0 to 3.1161.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/lib/lib-storage/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1161.0/lib/lib-storage)

Updates `@napi-rs/canvas` from 1.0.13 to 1.1.0
- [Release notes](https://github.com/Brooooooklyn/canvas/releases)
- [Changelog](https://github.com/Brooooooklyn/canvas/blob/main/CHANGELOG.md)
- [Commits](https://github.com/Brooooooklyn/canvas/compare/v1.0.13...v1.1.0)

---
updated-dependencies:
- dependency-name: "@aws-sdk/client-s3"
  dependency-version: 3.1161.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production-weekly
- dependency-name: "@aws-sdk/lib-storage"
  dependency-version: 3.1161.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production-weekly
- dependency-name: "@napi-rs/canvas"
  dependency-version: 1.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production-weekly
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-02 21:12:37 +00:00
l1137l c6372bde94 Merge main v1.46.6 into feat/ru-locale
- baselines: take main's entries, keep the two ru-driven line counts (coach-prose-grounding 1091, insight-generator 1115) and regenerate the function baseline.
- messages/ru.json: translate the five unit-less markers keys the release added (labs.unit.none/noneHint, labs.ocr.errorUnitMismatchNoUnit, labs.ocr.unitDiffersBadgeNoUnit, recordImport.unitlessHint) — parity is 11 159 keys again.
2026-11-02 21:07:44 +00:00
Marc-André Bombeck 2c8140a502 Merge pull request #1328 from MBombeck/release/v1.46.6
release: v1.46.6
2026-11-02 21:39:10 +01:00
Marc-André Bombeck 1bde3fc618 chore: drop exports nothing imports
knip 6.44 follows the members a dynamic import reads, and the Insights
page reads two of the derived barrel's: the barrel now exports those two,
whose building blocks are imported from their own modules. BAND_VAR had
no reader, and the drawer re-exported useResettableValue for a test that
only imports nextResettableValue. The size and version-stamp baselines
take the lower numbers.
2026-11-02 21:22:16 +01:00
l1137l 48bfbad4e6 fix(i18n): address the ru review — units, Cyrillic regexes, locale registry
Dose bank
- DRUG_UNIT learns мг / мкг / мл / ЕД / МЕ, so "2,4 мг" anchors a dose again.
- The bank matches verb-first Russian word order too ("принимайте 2 таблетки",
  "пропустите дозу метформина", "начните с 5 мг"), and the Cyrillic drug names
  people actually see on the box; the deferral words ("спросите врача",
  "как назначено") stand those shapes down.

Cyrillic-safe regexes
- \w and \b are ASCII in JavaScript: every stem in the ru bank and in
  population-norms/ru.ts is [а-яё]*, and the word edges are lookarounds
  (?<![а-яёa-z]), so «высокий риск» and «нормальный диапазон» match again.
- coach-prose-grounding: Russian month names in the genitive, a space as the
  thousands separator in parseLocaleNumber and numFrag, and Cyrillic-safe
  edges around the written-date pattern.

Registered everywhere a locale has to be
- targetLanguageName() returns "Russian", so the coach, the period narrative
  and the document descriptions stop saying "write in English".
- i18n-key-tool.mjs, split-description.mjs, LOINC name normalisation (it
  stripped Cyrillic, so no Russian lab name resolved), HOST_TLDS (ru/by/kz/ua),
  no-key-fallbacks (32 tables), country-label, the AUDIT-C standard drink.
- docs/api/openapi.yaml regenerated.

Questionnaires
- The PHQ-9 / GAD-7 source comments no longer claim no validated Russian
  version exists (Zolotareva 2023; Pfizer's official screeners).
- WHO-5 items are gender-neutral: «чувствовал(а)».
- "body fat" and "fat mass" both read «Жировая масса»; body fat is now
  «Процент жировой массы», which is what the two metrics measure.

Tests
- ru in the 13 locale tables, plus mood, onboarding, instruments, clinician
  view, status summary, describe screen, citation coverage and LOINC.
- i18n-no-dot-separator: the ru bundle is exempt from the dash rule (a spaced
  dash is Russian punctuation, not a fragment join); the middle dot still
  fails there.
- Two 800-line baselines raised by the 12 and 5 lines this change adds.

pnpm typecheck, pnpm openapi:check and the full suite (36 316 tests) pass.
2026-11-02 20:22:03 +00:00
Marc-André Bombeck 94da0bc352 test: tighten the version-stamp baseline for the review row 2026-11-02 20:58:15 +01:00
Marc-André Bombeck 165120e845 chore(release): v1.46.6 2026-11-02 20:52:22 +01:00
Marc-André Bombeck 49a0eb92ff fix(labs): word a value without a unit the same on every remaining surface
The briefing's lab input passed the stored "1" to the model, the unit
mismatch refusal read "is in 1", and the trend card left a gap where a
language puts the unit before a particle. get_labs says that a value
without a unit has an empty unit, and a lab link id is scrubbed like its
label. The review row is back at its baseline length.
2026-11-02 20:52:18 +01:00
Marc-André Bombeck 6a8ac98613 fix(labs): let a unit typed key by key pass through "1"
The no-unit state was read from the value, so the first keystroke of
10^9/L or 1/min ticked the box and locked the field. Only the box makes
the choice now; a typed 1 stays text.
2026-11-02 20:52:17 +01:00
Marc-André Bombeck 70cc753c49 Merge branch 'fix/pdf-unicode-font' into release/v1.46.6 2026-11-02 20:38:27 +01:00
Marc-André Bombeck ee0adae99c test(doctor-report): read the font guard's files through readSource 2026-11-02 20:36:16 +01:00
Marc-André Bombeck 0115a02d22 fix(doctor-report): draw the PDF in an embedded Unicode font
Helvetica's WinAnsi encoding turned every glyph outside Latin-1 into an
empty box: a Polish report lost its diacritics, a Korean report was
unreadable, and free text in any other script broke in every locale.

The renderer now registers the embedded ReportSans family on each
document, names it in every setFont, and makes it the autoTable default.
The Hangul variant is read only when the report is Korean or its data
contains Hangul. Font files are cached per process; streams are
compressed so the subset fonts add about 30 KB to a report.

The arrows, superscripts and comparison signs now print as themselves,
so the WinAnsi sanitiser and its ASCII fallbacks are gone. Tests render
Polish, Russian and Korean strings and arrows and read them back out of
the text layer.
2026-11-02 20:32:17 +01:00
Marc-André Bombeck 80a62afbbf build(pdf): add Unicode fonts for the doctor report
The report's built-in Helvetica is WinAnsi-only. These fonts cover what
the report can carry: ReportSans (Noto Sans plus the arrows and
mathematical operators of Noto Sans Math; Latin incl. Extended-A/B,
Greek, Cyrillic) in regular, bold and italic, and ReportSansKR, the same
plus Hangul from Noto Sans KR, in regular and bold.

scripts/build-pdf-fonts.py rebuilds them from the upstream sources. All
are SIL OFL 1.1; the licence sits beside the files. The directory is
named in outputFileTracingIncludes because the renderer reads the files
by a path the tracer cannot follow.
2026-11-02 20:32:11 +01:00
Marc-André Bombeck a822aa5a0e fix(documents): send a read that states no unit itself to review
A unit of "1" is the explicit no-unit choice a person makes. When a
document read returns it on its own, the value is staged for review
instead of passing straight through, so a unit the read dropped never
lands as a dimensionless value.
2026-11-02 20:31:33 +01:00
Marc-André Bombeck 2c6fb82326 Merge branch 'feat/unitless' into release/v1.46.6 2026-11-02 20:30:41 +01:00
Marc-André Bombeck e605fe0ae8 docs(api): describe the dimensionless lab unit on every lab unit field
The unit on lab results, biomarkers, the scanned-report commit and the
document fact edit now states that "1" means the value has no unit, that
it matches only itself, and that an empty unit stays refused. The lab CSV
import description names the 1 in the unit column.

Refs #1313
2026-11-02 20:28:35 +01:00
Marc-André Bombeck f9431da64f feat(labs): show a value without a unit as the number alone
Every place a lab unit is printed beside a value now goes through one
helper, so a reading stored in "1" reads "3.6" and not "3.6 1": the
list, history and detail, the chart and its tooltips, the reference bar
and range notes, the trend card, the timeline, the day view, the
clinician page, the doctor-report PDF and the changes card. Where the
unit stands alone (a marker's subtitle, a picker row, the merge preview)
it reads "No unit".

The Coach's lab block, the get_labs history and the visit-prep summary
hand the model an empty unit for such a value, so it never quotes the
"1" either.

Refs #1313
2026-11-02 20:28:34 +01:00
Marc-André Bombeck 5405ce053d feat(fhir): code a lab value without a unit as the UCUM unity
A value stored with unit "1" carries system and code on its quantity
even when the analyte map knows no coding for it: the unity is UCUM by
definition.

Refs #1313
2026-11-02 20:28:34 +01:00
Marc-André Bombeck 79ac5670c7 feat(labs): offer an explicit no-unit choice for ratios and indices
A ratio (cholesterol/HDL, albumin/globulin) or an index such as INR has
no unit, and every lab write path refuses a numeric value without one.
That refusal stays: an empty unit usually means the extraction dropped
it. Instead the value is stored as the UCUM unity "1", chosen on purpose.

The unit field when a marker is defined, on a scanned report's row and
in the document review edit now carries a "No unit" checkbox that sets
"1". The CSV import takes 1 in the unit column, as the template and the
import sheet say; an empty cell is still refused, and other spellings
such as "ratio" or "-" stay the literal unit they always were.

"1" compares as a unit of its own: it matches only itself, so it is
refused under a marker kept in mg/dL and the other way round.

Refs #1313
2026-11-02 20:28:23 +01:00
Marc-André Bombeck cae6edec64 Merge branch 'feat/mcp-links' into release/v1.46.6 2026-11-02 20:25:04 +01:00
Marc-André Bombeck 5f9c8461a3 test(mcp): pin record links against Postgres
Source system and id without the address, the Documents switch, the empty
answer, the cap, document links to labs and medications, row ids, and that
another account's document never appears, not even through a link row that
points across accounts.
2026-11-02 20:24:28 +01:00
Marc-André Bombeck 34302867aa feat(mcp): list links on lab and medication reads, ids on visit and condition rows
get_labs readings (the latest, earlier values in full, trajectory readings)
carry the fetch ids of what they link to, with linksTruncated; each linked
record is described once per answer in linkedRecords, because one report
usually holds a whole panel and would otherwise repeat on every reading.
fetch lab: and fetch med: list their links like the other record kinds, and
get_medication_schedule rows carry their id and links inline. get_visits and
get_illness_recovery rows carry visit: and condition: ids, so a row's links
are one fetch away. The labs and illness blocks gain an opt-in to carry row
ids; the Coach never asks for them.
2026-11-02 20:24:28 +01:00
Marc-André Bombeck ea7c6c860e feat(mcp): share one record-link builder and list labs and medications on a document
The links fetch lists beside a visit, condition, document or vaccination move
out of record-search into record-links, so the lab and medication reads can
list theirs through the same builder. A linked document imported from
Paperless-ngx or Papra now names its source system and its id there, never
the instance address. fetch document: also lists the lab results read from
it and the medications filed against it, each behind its own module.
2026-11-02 20:24:27 +01:00
Marc-André Bombeck a2ce11fec4 Merge pull request #1327 from johnnyrepo026/fix/review-edit-failed-rows
fix(documents): offer the edit control on review rows whose save was refused
2026-11-02 20:15:16 +01:00
Marc-André Bombeck 7dfc2cbbea test(series): follow the hour bucket into the route mock and the dated-fixture guard
The route test recognised the bucket statement by its old GROUP BY
column, and the new fall-back-night test reads from a fixed date, which
the dated-fixture guard now allowlists with its reason.
2026-11-02 20:14:18 +01:00
Marc-André Bombeck e7c625cab7 chore(deps): next 16.4.3 and the weekly dependency updates
Folds the open dependency updates into one change: next,
@next/bundle-analyzer and eslint-config-next 16.4.3, the production
group (aws-sdk, MCP SDK, node-apn, TanStack Query and Virtual, dotenv,
jose, lucide-react, nodemailer) and the development group (Stryker,
@types/node, knip, shadcn). The runtime prisma install in the Dockerfile
pins dotenv in step with the app.
2026-11-02 20:10:38 +01:00
Marc-André Bombeck 0810285061 fix(series): keep the repeated hour of a fall-back night apart
The hour grain of the dense series read truncated the local wall clock,
so the hour a fall-back night runs twice folded both instances into one
bucket: on that day a sensor stream showed one mean for two hours of
readings. An hour bucket now starts at the reading's instant less its
local minutes and seconds, which keeps the two hours apart and still
starts a half-hour zone's buckets on the half hour.

The new integration test pins a New York fall-back night and a
half-hour zone to fixed dates, so the case runs every day instead of
only when the rolling window of the existing tests covers one.
2026-11-02 20:06:15 +01:00
johnnyrepo026 e3aae483b0 fix(documents): offer the edit control on review rows whose save was refused
A confident lab value that Finish review refused (no unit, or a unit that disagrees with the marker) showed the reason but no way to act on it, so it could only be discarded. The Check control now also opens on a row with a commit failure, not only on a low-confidence one.

FactRow grows by one line (the data-slot the new test finds the control by), so its size baseline moves from 262 to 263.

Refs #1313
2026-11-02 12:21:37 -06:00
l1137l 6b9d222643 feat(i18n): add Russian (ru) locale
Full Russian catalog (11,154 keys, 1:1 with en, identical nesting) and every
locale wiring point the app declares, so ru is a first-class locale instead
of an English fallback:

- messages/ru.json, project.inlang/settings.json, i18n boot script
- i18n config / load-locale, format-locale (ru-RU), date-fns ru
- labs: parse-reference-range RANGE_PROSE for Russian lab sheets
  ("3,5 - 5,0", "от 3,5 до 5,0", "до 5,6", "не более 5,6")
- AI: citation-coverage normative stems, coach system prompt and insights
  reply footer (Russian prompts must override the hard-coded
  "Respond in English"), population-norm patterns
- safety: outbound-screen Russian imperative/dose/unit/causality bank
- mental health: PHQ-9, GAD-7, WHO-5 and SCI wording

verify-message-catalogs.mjs passes for all 10 locales.
2026-11-01 17:11:31 +00:00
Marc-André Bombeck 577e43004f Merge pull request #1312 from MBombeck/fix/pdf-reader-crash-report
fix(documents): keep PDF reading alive on CPUs the canvas build skips
2026-11-01 01:45:56 +01:00
Marc-André Bombeck 773b943bde fix(documents): keep PDF reading alive on CPUs the canvas build skips
The arm64 musl build of the native canvas uses LSE atomics and RCpc
loads throughout, so loading it on an ARMv8.0 core such as the
Raspberry Pi 4 ends the process with SIGILL. The CPU gate only knew
the x64 AVX2 case, and pdf.js loads the canvas as soon as it starts,
so on such a host every PDF read died in the child: no text layer, no
index, and the manual read reported a busy reader.

The gate now also closes on linux/arm64 under musl when the CPU lacks
the atomics or lrcpc feature. A closed gate travels to the PDF child,
which refuses to resolve the canvas module, so pdf.js carries on
without it and the text layer reads; rendering and thumbnails keep
their existing fail-soft path. A child that ends without an answer
now logs its exit code and signal.

Refs #1311
2026-11-01 01:24:37 +01:00
Marc-André Bombeck 9a6d9b6793 Merge pull request #1310 from MBombeck/fix/display-name-greeting
fix(ui): name the account by its display name
2026-11-01 01:14:41 +01:00
Marc-André Bombeck 1c4bcce6c7 fix(ui): name the account by its display name
The dashboard greeting, the Insights greeting, the top bar and the
sidebar read the username, so a display name set on the profile card
reached the reports but none of the surfaces that address the account.
They now read one helper that returns the display name when set and
falls back to the username otherwise.

Refs #1309
2026-11-01 00:56:24 +01:00
Marc-André Bombeck 022c6de0b5 Merge pull request #1307 from MBombeck/fix/rollup-coverage-history
Count a type as rollup-covered only when its buckets reach its oldest reading
2026-10-31 12:14:49 +01:00
Marc-André Bombeck defa43d46c test(parity): fold the whole fixture history so the walk keeps the rollup path
The fixtures built their rollups with the read refresh, which folds ninety
days. Under the stricter coverage a type folded only that far takes the
live path; the fixtures now fold the whole history first, as the boot
backfill does, so the walk still reads the rollup tier.

The analytics snapshots change only in what the old coverage hid: the
summaries count all 120 weight and blood-pressure readings instead of the
91 the folded window held, with the means and the all-time in-target share
that follow from them.
2026-10-31 11:54:28 +01:00
Marc-André Bombeck 2d1f3fef1e test(e2e): say what is known about the missed lab-reading focus
The comment named a cause that was never observed.
2026-10-31 11:25:41 +01:00
Marc-André Bombeck 3d5d5fc4ff fix(rollups): count a type as covered only when its buckets reach its oldest reading
The coverage probe called a type covered as soon as one DAY bucket existed.
A write hook folds the day it writes, so a type whose history was not yet
folded (an account before its backfill, or a read racing the detached
refresh) had one fresh bucket, took the bucket-composed path, and its
summary counted that one day: three weight readings read as one.

A type is now covered when its oldest DAY bucket starts no later than its
oldest live reading inside the fold window. The bucket holding a reading
starts at or before it in any time zone, so the comparison holds without
knowing the account's day boundaries. Both sides are one index descent per
type; anything not covered falls back to the live aggregate as before.
2026-10-31 11:25:04 +01:00
Marc-André Bombeck 779097933e Merge pull request #1306 from MBombeck/fix/google-health-account-id-heal
Read the Google Health account id back; refuse a WHOOP profile without one
2026-10-31 11:16:54 +01:00
Marc-André Bombeck c66ffa9914 test(e2e): focus the lab reading once the timeline has redrawn it
The chart redraws its point targets when the marker's readings arrive; a
target focused just before that is replaced and no tip opens.
2026-10-31 10:59:23 +01:00
Marc-André Bombeck beb7187c2b test(e2e): wait for the saved range before reloading the score chart
The range tab saves with a PUT; reloading straight after the click could
cancel it, and the reloaded chart opened on thirty days again.
2026-10-31 10:42:26 +01:00
Marc-André Bombeck 717eb1d1e1 test(rollups): wait for the warm-up refresh before writing the next row
The summaries read starts ensureUserRollupsFresh detached. When the third
row's DAY bucket landed before that refresh, WEIGHT counted as covered by
its one bucket and the read reported a count of 1; a CI shard hit exactly
that.
2026-10-31 10:22:27 +01:00
Marc-André Bombeck 087c8868cf test: type the off-spec WHOOP profile and drop a version-stamped comment
The realism ratchet counts as-never casts and the version-stamp ratchet
refuses comment lines that open with a release; both caught the previous
commits.
2026-10-31 10:00:39 +01:00
Marc-André Bombeck 369c063701 chore(deps): raise node-forge to 1.4.2 and drop its audit exception
Five high advisories against node-forge <= 1.4.0 (signature, certificate
chain and TLS record handling) joined the one the exception covered. The
copy arrives only through @parse/node-apn; 1.4.2 is what node-apn 8.1.1
pins, and with the floor raised the exception has nothing left to cover.
2026-10-31 09:37:33 +01:00
Marc-André Bombeck b42b0d5fee fix(whoop): refuse a profile without a user id instead of storing "undefined"
String(profile.user_id) turned a missing id into the literal "undefined",
so every such connection would have looked like one WHOOP account and the
unique account id would have refused the second record. The callback now
treats it like a failed exchange (reason=token), as the Polar callback
already does for a token body without x_user_id.
2026-10-31 09:34:25 +01:00
Marc-André Bombeck 5306ba7614 fix(google-health): read the account id back for connections that lost it
Migration 0491 cleared the shared "profile" placeholder to null. Those
connections sync normally, but the one-account-per-record check cannot see
them, so the same Google account could still be connected to a second
record until the first one reconnected.

After a clean sync cycle, a connection without an id now reads it the way
the callback does (profile, then identity) and stores it, guarded on the id
still being null so a reconnect landing meanwhile wins. An id another
record already holds is not written, and a failed read leaves the id for
the next cycle without touching the cycle's verdict. The outcome is on the
wide event as googleHealth.accountId.heal.
2026-10-31 09:34:25 +01:00
Marc-André Bombeck eb47f04a66 Merge pull request #1305 from MBombeck/release/v1.46.5
v1.46.5: Azure OpenAI reasoning deployments
2026-10-30 17:44:07 +01:00
Marc-André Bombeck 685cb2fa01 fix(ai): learn the max_tokens refusal from the field the request carried
A call that ran beside the one that learned the lesson read today's
selection instead and failed with the 400. The matcher now also wants the
refusal to name max_tokens, so a refusal of another field that echoes the
request does not switch the token field.

Refs #1304
2026-10-30 17:13:09 +01:00
Marc-André Bombeck 31ecb75901 test(backup): golden fixture for v1.46.5 2026-10-30 17:09:12 +01:00
Marc-André Bombeck 2676f2fce0 chore(release): v1.46.5 2026-10-30 17:09:01 +01:00
Marc-André Bombeck 21af344054 fix(ai): send max_completion_tokens to Azure OpenAI reasoning deployments
An OpenAI-compatible gateway entry pointed at an Azure OpenAI v1 endpoint
was treated as a generic gateway and sent max_tokens and temperature, which
a reasoning deployment refuses with a 400. An Azure v1 endpoint now takes
the api.openai.com contract for a reasoning model, and any gateway that
refuses max_tokens for a model is asked again with max_completion_tokens
and remembers it, which covers deployments named something of their own.

Refs #1304
2026-10-30 17:08:43 +01:00
Marc-André Bombeck 0462c23841 Merge pull request #1303 from MBombeck/release/v1.46.4
v1.46.4: Google Health connects on every profile of an instance
2026-10-30 13:51:10 +01:00
Marc-André Bombeck 29b5bc78a0 chore(release): v1.46.4 2026-10-30 13:18:16 +01:00
Marc-André Bombeck 0d8bcbc52b test(backup): keep the 1.46.4 backup file as a golden 2026-10-30 13:18:15 +01:00
Marc-André Bombeck cd6cbc06f5 fix(google-health): store the account's id, not the word "profile"
The profile's resource name is `users/{user}/profile`, and the connect
stored its last segment, the same constant for every account. Since the
one-account-per-record check, a second record on an instance could not
connect or reconnect Google Health: it was refused as `already_linked`.

The connect now stores the `{user}` segment, reads `users/me/identity`
when the profile answers through the `me` alias, and stores the id as
unknown when neither names the account. A migration makes the column
nullable and clears the placeholder, so the next connect stores the
real id.
2026-10-30 13:17:54 +01:00
Marc-André Bombeck 8f8f98eac7 Merge pull request #1302 from MBombeck/fix/google-health-sleep-sweep
fix(google-health): protect only a night's own segments in the sleep sweep
2026-10-29 23:56:59 +01:00
Marc-André Bombeck a79a04f3ef test(dawarich): take clock times out before looking for coordinates
The privacy check looks for the fixture's latitude 52.52 in every stored
row, and a timestamp written at :52.52x seconds holds the same digits. It
failed once in CI with no coordinate stored. Clock times are masked first;
a coordinate in any field is still found.
2026-10-29 23:36:32 +01:00
Marc-André Bombeck cd98db82eb test(insights): date the comparison's sleep readings by calendar day
The readings sat at now minus n times 24 hours, which keeps the wall-clock
hour only between two clock changes. Run late in the evening after the
October change, two of them fell into the same night and the average read
525 minutes instead of 420.
2026-10-29 23:16:00 +01:00
Marc-André Bombeck e3ebc01e34 docs(changelog): note the Google Health sleep sweep fix in 1.46.3 2026-10-29 23:13:43 +01:00
Marc-André Bombeck eed83befa3 fix(google-health): protect only a night's own segments in the sleep sweep
Every night's replace-by-window clean-up carried every fresh segment id of
the fetch. On a first sync that reads years of sleep that is one query per
night with tens of thousands of bound values, over Postgres's limit, and
the process spent minutes building them before each one failed. The sweep
can only reach rows inside the window, so each window now protects the
fresh rows inside it, from whichever session reported them.

Refs #1195
2026-10-29 23:13:43 +01:00
Marc-André Bombeck cb6c725a8c Merge pull request #1301 from MBombeck/release/v1.46.3
Release v1.46.3
2026-10-29 23:06:46 +01:00
Marc-André Bombeck 0e57247b29 chore(release): v1.46.3 2026-10-29 22:18:59 +01:00
Marc-André Bombeck 1bdeae5cc6 test(backup): keep the 1.46.3 backup file as a golden 2026-10-29 22:18:58 +01:00
Marc-André Bombeck 1f0b05f141 Merge branch 'fix/v1463-review' into release/v1.46.3 2026-10-29 22:18:02 +01:00
Marc-André Bombeck 3674c1757d fix(timeline): leave hidden lab markers out of the values menu
Hiding a marker takes it out of the labs catalogue and the entry
pickers, but the timeline still offered it among the lab values.

The timeline now offers only markers that are not hidden. A hidden
marker that was chosen before keeps drawing its line and stays in the
menu as a ticked entry, named from that line, so it can be unticked,
the way the entry form keeps a hidden marker that is already selected.
Once unticked it is no longer offered.
2026-10-29 22:01:43 +01:00
Marc-André Bombeck 5dec158c9d fix(ai): keep the reasoning-field day for what a 400 taught
The dialect cache gave a day to every value that says the reasoning
field is refused, and that includes the combined "field refused, reasons
regardless" value, which one answerless reply writes. One reply could
therefore stretch a lesson to a full fresh day, against the rule that a
lesson read from a single reply keeps the hour.

A lesson now says what taught it. A 400 refusal gives a refused field
its day. A reply that upgrades a refused field to the combined value
keeps the refusal's remaining lifetime rather than a new day or the
hour, so the refused request does not come back early either.
2026-10-29 21:58:40 +01:00
Marc-André Bombeck c920266f32 fix(backup): explain a second restore of one file into another account
Restored rows keep the ids they had in the file. Once an erased
account's copy had gone into one account, the same file uploaded into a
second account restored into a primary-key conflict, reported only as
the generic "the database rejected a record" failure.

For a copy put into a chosen account, a primary-key conflict now ends
the restore with backup.restore.rows_exist_elsewhere and a sentence that
says the file was most likely restored into another account already.
The transaction still rolls back, so neither account changes. The code
is in the catalogue, the console names it, and the runbook says a file
goes into one account only.
2026-10-29 21:50:34 +01:00
Marc-André Bombeck 94854bd8f7 fix(backup): trace an account made for an upload when it is made
The upload creates a passwordless account before it reads the file and
deletes it again when the file is refused. The only audit row was
written after a successful store, so a refused upload whose delete also
failed left an account nobody could sign into and no record of where it
came from.

The account and its admin.user.create row (outcome "created") now
commit together, and the discard writes admin.user.delete with outcome
"deleted" or "failed", so a leftover account always has a trace. The
runbook says how to remove one by hand.
2026-10-29 21:42:18 +01:00
Marc-André Bombeck 16d1b542a5 fix(backup): keep an erased account's copy out of admin accounts
A restore read the account settings under the operator's AI endpoint
policy whenever the receiving account was an admin. For a copy put into
a chosen account, the settings were written by the erased account's
owner, not by the operator, yet an admin account could be chosen as the
target, and an ordinary target promoted later got the same trust.

Admin accounts are now refused as a target, in the check and the
picker, with backup.target.admin_account. A copy uploaded into a chosen
account is stored as RETARGETED_UPLOAD_<ms>, and its restore never reads
its settings as the operator's, whatever the account's role is by then.
2026-10-29 21:36:33 +01:00
Marc-André Bombeck dfe34386be fix(ai): read a Codex sign-in's memory only under its own account
Every write to the per-sign-in Codex memory checked the account digest,
but the reads did not. A credential reconnected to another ChatGPT
account therefore tried the old account's working model first, sorted
by its refusals and trusted its model list, until the first write
rebound the entry, and the vision gate kept answering from the old
account in the meantime.

The client reads (working model, refusals, model list) now take the
credential and treat an entry learned under another account as empty.
The vision gate holds no account id, so the connect routes rebind the
entry and the disconnect routes drop it, and the gate reads the entry
as its process last bound it.
2026-10-29 21:36:33 +01:00
Marc-André Bombeck 648810745a fix(backup): let an account fresh from setup take an erased account's copy
The empty-account check counted every backed-up model, and signing up
and answering the setup questions always leaves a setup row, plus a
cycle profile when the cycle area is chosen. The account of someone who
has signed up again, the case the restore target is for, was therefore
always refused.

Those two models are one row per account and are deleted and re-created
from the copy by every restore, so they no longer count. A reading or
medication entered on setup's last screen still refuses the account,
because the restore would delete it. A guard holds the exempt models to
that shape, and the erased-account integration file now drives the real
setup routes before the restore.
2026-10-29 21:14:25 +01:00
Marc-André Bombeck 21f6f5796b fix(timeline): cap lab markers instead of refusing the request
A values list naming more than 24 lab markers answered 422. The list is a
device preference, so once a 25th marker was ticked every later visit
failed: the page rendered its error card, and the values menu that could
untick it only renders on success.

The server now keeps the first 24 markers and leaves the rest out, as it
already does for a marker the record no longer has; a malformed key still
answers 422. The page applies the same cap before it asks, and the values
menu holds further unchosen markers back at 24 with a line saying why,
while a chosen one can always be removed.
2026-10-29 21:06:52 +01:00
Marc-André Bombeck 00d129ad42 Merge branch 'feat/v1463-restore' into release/v1.46.3 2026-10-29 20:46:23 +01:00
Marc-André Bombeck 169a0f1df4 Merge branch 'feat/v1463-labs' into release/v1.46.3
# Conflicts:
#	src/__tests__/baselines/source-file-lines.json
2026-10-29 20:43:40 +01:00
Marc-André Bombeck 77a76a9e33 refactor(backup): keep the upload flow owned by the backups section
The section that renders the recent-proof dialog is the one that hands
the proof to each gated flow, which the recent-proof callers test reads
from its source. The upload hook goes back there and the upload
component takes the flow it returns.
2026-10-29 20:41:58 +01:00
Marc-André Bombeck 8d765139d3 test(timeline): walk a lab marker through the timeline journey
The journey picks a lab marker from the values menu, sees its readings
drawn with the range behind them and the one below it as a triangle,
reads that reading's card by keyboard with its report, checks the page
with axe, reloads to find the choice kept, and on a phone finds the
readings on their lab days without a sideways scroll before unchoosing
the marker again. The mocks answer lab markers only when a test asks for
them, so the other value-line steps keep counting what they counted, and
the two values triggers (the toolbar's and the phone's) are addressed by
their surface.

Refs #1299
2026-10-29 20:41:58 +01:00
Marc-André Bombeck 46350b6461 refactor(backup): one success line for a stored upload
Both upload paths now announce a stored copy through one function, so
the upload hook keeps the single success affordance it is pinned at,
and the upload card and target dialog are wired in their own component
rather than in the backups section, which stays under its size line.
2026-10-29 20:37:11 +01:00
Marc-André Bombeck 8ed6eb5793 refactor(backup): publish the target codes and keep the upload handler short
The target refusals answer with eight new meta.errorCode values; they
are now constants the catalogue sweep reads and entries in the
published catalogue, so a client can branch on them without triggering
each one. The upload handler had grown past its size baseline with the
target branch, so the target checks, the file read and the schema check
move into named functions beside it, unchanged. The target check route
holds its params to a strict schema like every other route.
2026-10-29 20:37:11 +01:00
Marc-André Bombeck b8a983bdec feat(timeline): draw lab markers as lines under the values
With the server naming lab markers as value lines, the page offers them:
the values menu lists the markers the record has numeric readings for,
under the groups the labs list sorts them into, and a choice is stored
and requested like any measurement line. The phone toolbar gains the same
menu as an icon button, since the chronicle shows the chosen lines too.

A lab row sits under the value lines in their rhythm, on its own scale.
Each reading is its own point on the day of the draw, joined in time
order by a thin stroke and never averaged. The reference range is painted
behind the points as the labs page paints it, a faint band with dashed
edges, and the scale holds it so the band is always in view. A reading
outside its range is a triangle pointing the way it left and is worded as
outside wherever it is written out, so colour is never the only sign.

A point reads on its own by pointer, keyboard and tap: the day, the value
with every digit it was stored with, the verdict, the range and the
report it was read from (a link once pinned). The selection bar lists
the bucket's readings with their report, a lab day's row in the phone
chronicle lists that day's readings, a screen-reader table lists them
all, and a marker with readings in a unit that does not convert says how
many were left out. Nothing else on the timeline changes.

To make room without growing the large timeline files, the legend, the
screen-reader tables and the value-line geometry move into modules of
their own, and the chart, the chronicle, the point cards and the page
each hand a part to a helper; the size baselines are written back lower.

Refs #1299
2026-10-29 20:31:36 +01:00
Marc-André Bombeck 42b2c72e28 feat(timeline): serve lab markers as value lines
The timeline showed a lab report as an event on the axis, but the lines
under the lanes could only be measurements. A lab marker can now be named
in `values` as `lab:<biomarkerId>`, beside the measurement types and
`MOOD`, so one choice, one request and one stored list carry every line.

`GET /api/timeline` answers with `labSeries`, one entry per named marker
with its readings in the window as single points (lab values come a few a
year, so they are never averaged into buckets), each dated by the record's
local day, judged against its own window and linked to the report it was
read from when the caller may see documents. `availableLabs` lists every
marker the record has a numeric reading for, so a client offers only
those.

A reading stored in another unit than its marker's is converted through
the unit-conversion registry where the pair is known, and otherwise left
out and counted in `excluded`, never drawn on a scale it is not in. A
marker id that is not in the record yields no line instead of a 422, so a
stored choice survives a deleted marker; ids are capped at 24 a request.

Refs #1299
2026-10-29 20:31:27 +01:00
Marc-André Bombeck 646ad3c5d8 Merge branch 'feat/v1463-codex' into release/v1.46.3 2026-10-29 20:27:15 +01:00
Marc-André Bombeck b1bbffc69b docs(backup): restoring the copy of an erased account
The runbook said a restore writes into an account that exists under the
same id, which left an operator with an erased account's file and no
way in. It now says where such a copy can go, how the new account is
signed into, why an account with records is refused, and what does not
come back.
2026-10-29 20:20:55 +01:00
Marc-André Bombeck a40bd53d45 feat(backup): offer an account when an upload's owner is gone
An upload refused with backup.owner.missing no longer ends in an error
toast. The backups console keeps the file and opens a dialog: a new
account by default, which cannot hold anything yet, or an existing one,
which the server checks as soon as it is picked and which the admin
confirms by typing its username. Managed profiles and accounts pending
deletion are not offered. The file is then sent again with that target,
and a refusal stays in the dialog beside the choice it refuses.

The new-account path says the account has no password yet and where to
set its first one. Copy in every app language.
2026-10-29 20:20:28 +01:00
Marc-André Bombeck 2220855c8d docs(ai): describe the Codex model list read and per-sign-in memory
The protocol spec now documents the /models request, the fields read
from it, the order the walk is built in, the per-sign-in cache and the
codex.models.* annotations (the codex.slug.* keys it named never existed).
The operator env comments say that CODEX_MODEL still goes first and that
CODEX_MODEL_FALLBACK_CHAIN replaces the order and skips the list.

Refs #1287
2026-10-29 20:17:48 +01:00
Marc-André Bombeck 5ab1fb782e feat(ai): pick Codex models from the account's live list, per sign-in
A fixed preference list goes stale at every retirement: when GPT-5.5
retired for ChatGPT sign-in on 2026-10-14, every model the shipped list
named was refused and Codex answered 503 until a release changed it. The
working model also sat in one slot for the whole process, so two sign-ins
on different plans overwrote each other's model and the vision gate read
whichever had answered last.

The client now reads the models the account is offered the way the Codex
CLI does (GET /backend-api/codex/models?client_version=..., same sign-in
as the responses call; shape from codex-rs/protocol/src/openai_models.rs)
and walks: CODEX_MODEL first; our preference order kept to what is
offered; the other offered models in the list's priority order, leaving
out hidden, specialist and frontier-tier entries; the rest of our order
last, so the walk never holds fewer models than before. With
CODEX_MODEL_FALLBACK_CHAIN set the order is the operator's and the list is
not read.

The list is a hint, never a gate. The read goes through safeFetch with a
5 s bound and a 4 MiB cap, is shared by concurrent requests, cached per
sign-in for an hour, and a failed read waits ten minutes. Any failure
leaves the static walk, one ai.codex.models_unreadable warning per sign-in
reports it, and each request that builds the walk annotates a pinned
codex.models.* shape.

The working model, refused models and the list are kept per stored
credential (a person's connection, or the operator-shared central one),
bounded by TTL and a 1000-entry LRU, with a digest of the account id so a
credential reconnected to another account starts empty. A model the list
offered that then answers model_not_found goes to the back of that
sign-in's line for six hours. When the list names a model's input
modalities, the Codex vision gate reads them; otherwise the model family
decides, as before.

Refs #1287
2026-10-29 20:17:42 +01:00
Marc-André Bombeck 8014fd4aae feat(backup): restore an erased account's copy into a chosen account
After an account is erased the operator still has its disaster-recovery
file, and the upload refused it because its owner was gone. The refusal
now carries backup.owner.missing, and the file can be sent again with a
target in the query: an existing account that holds no records yet,
confirmed by typing its username, or a new account created for the copy.
The file's owner is rewritten to that account as it is read, so the
stored copy, its preview, its download and its restore are that
account's, and the restore that follows is the ordinary one.

The new account has no password and no email; an admin sets its first
password with the existing reset. A file whose own account is still here
is refused with a target, and an account made for a refused file is
removed again. The refusal of an unknown owner no longer writes the
owner's id into the audit trail, since the erasure took every row that
named it.
2026-10-29 20:11:14 +01:00
Marc-André Bombeck 9baeb95bab feat(backup): say whether an account can take an erased account's copy
A restore replaces the account's records and has no merge, so the copy
of an account that no longer exists may only go into an account that
holds none of the records a backup carries. The check reads every
backed-up model by its userId, reaches the owner-less children through
their parent, and is held to the schema so a new model cannot be read
as empty. Managed profiles and accounts pending deletion are refused as
well. The admin console asks GET /api/admin/backups/targets/{userId}
before it sends a file again, so a refusal costs no upload.
2026-10-29 20:11:08 +01:00
Marc-André Bombeck 973bb8e5fc feat(backup): rewrite a backup file's owner as it streams
A backup names its owner once, in the top-level userId, and the restore
writes every section under that account and no other. Pointing the copy
of an erased account at another account is therefore one member, not a
walk over every section. This changes that member on the bytes' way in,
without holding the document, and reports the owner the file declared.
Nested userId values and every other byte pass through unchanged.
2026-10-29 20:11:02 +01:00
Marc-André Bombeck 1d374dd4d5 refactor(ai): move the Codex reasoning wire into its own module
The reasoning-field planning and the refusal learning are a self-contained
part of the Codex wire. Moving them out of codex-client.ts leaves room for
the per-sign-in model selection that follows without growing a file the
size ratchet already holds at its limit.

Refs #1287
2026-10-29 19:55:20 +01:00
Marc-André Bombeck b1fe1f0597 fix(ai): hold a refused reasoning field for a day
A gateway that refuses the reasoning field does so by its configuration,
so the lesson now holds for 24 hours instead of one. At the hour every
expiry cost one refused request, a few a day on a gateway that runs the
briefings unattended. Lessons read from a single reply keep the hour.

Refs #1279
2026-10-29 19:39:38 +01:00
Marc-André Bombeck f17ad1f7b3 Merge pull request #1300 from MBombeck/release/v1.46.2
Release v1.46.2
2026-10-29 12:11:46 +01:00
Marc-André Bombeck a587be009d test(backup): report a broken pipe in the dump test's docker wrapper
Under a loaded full run the wrapper around pg_dump and pg_restore crashed
with an unhandled stream error, and the dump under test reported only the
tail of that stack. The wrapper now leaves the exit code to the tool and
writes the reason to stderr.
2026-10-29 11:51:39 +01:00
Marc-André Bombeck 20585f3b3d chore(release): v1.46.2 2026-10-29 11:28:49 +01:00
Marc-André Bombeck 14eaa5c7c5 test(backup): keep the 1.46.2 backup file as a golden 2026-10-29 11:28:48 +01:00
Marc-André Bombeck d1ea803260 Merge branch 'fix/v1462-review' into release/v1.46.2 2026-10-29 11:27:58 +01:00
Marc-André Bombeck 34f8a856b4 fix(labs): register the lab repair's undecryptable count as a job fact
The count rides out in the job's outcome facts, whose vocabulary is an
allowlist, and is kept as a failure tally so the catch that feeds it is
not a silent one.
2026-10-29 11:16:18 +01:00
Marc-André Bombeck 6b820fa3c5 fix(rollups): defer the dense fold when the backfill check cannot read the queue
The check whether an account's full rollup backfill is queued or
running answered false on any error, so a failed read let the dense
fold delete raw samples the backfill still had to read. A missing queue
table still answers false, since a web-only deployment has no backfill;
any other failure now counts as pending and is annotated. A backfill
that failed for good is still not pending, as the comment now says.
2026-10-29 11:05:43 +01:00
Marc-André Bombeck ec0f67f850 fix(admin): give the single-account rollup recompute the boot fold's budget
The admin recompute for one account called the full fold without a
statement budget, so on the large accounts it is meant for each yearly
statement ran under the database's ordinary limit and timed out, as the
boot job had before it got its own. It now passes the same per-statement
budget.
2026-10-29 11:04:21 +01:00
Marc-André Bombeck 6fb50b01b8 fix(auth): offer the provider re-proof to a browser only
The MFA status read listed oidc among the proof methods for a Bearer
caller too, although the provider round trip stamps a browser session
and nothing else, and the API contract says a token is never offered
it. The status read now leaves it out on the Bearer path.
2026-10-29 11:03:09 +01:00
Marc-André Bombeck fc117d7cd9 fix(auth): date a provider re-proof by its auth_time
The re-proof accepted an auth_time up to five minutes old and then
stamped the session at callback time, so the proof held for close to
ten minutes after the person signed in at the provider. It now stamps
the authentication instant, clamped to now for a provider clock that
runs ahead, and the gates' five-minute window runs from there.
2026-10-29 11:01:25 +01:00
Marc-André Bombeck 6df0fad3b7 fix(labs): let the lab date repair skip a damaged fact and read the app's zone
One staged fact that would not decrypt threw out of the whole repair, so
the job failed, retried, and was enqueued again on every boot. It is now
skipped and counted as undecryptable.

The candidate query read an account zone Postgres does not know as UTC,
while the repair resolves it to the instance default and then
Europe/Berlin. A value confirmed near midnight stayed a candidate the
repair always left alone, so the set never converged. The query now
falls back the same way.
2026-10-29 11:00:01 +01:00
Marc-André Bombeck a6f4c386fe fix(jobs): judge a system job's failure streak per job, per account
The alert added every attempt of a failed job to the streak, so one job
with two retries read as three failures. It also judged a queue by its
newest job across all accounts: on a queue that runs one job per
account, one account failing every hour beside accounts that succeed
never reached the streak, or flipped between failing and recovered.

The streak now counts jobs, and is kept per account (the job's userId,
or the whole queue for jobs without one). A queue is failing while any
account has failed three jobs in a row, and recovered only when a
failing account has itself run successfully since. A reminder needs a
failure newer than the notice before it, so a one-off job that failed
and does not run again is announced once.
2026-10-29 10:58:04 +01:00
Marc-André Bombeck ec2ec40003 fix(measurements): report folded provider heart rate to the change feed
The dense fold deleted Google Health and Oura samples outright, on the
reasoning that no client pulls them. The change feed serves every
source, so a client mirroring it kept those samples next to the hourly
means with nothing to tell it they were gone.

The nightly increment of a server-pulled source is tombstoned again: the
feed reports each sample and the tombstone cleanup purges them after the
retention. A backlog day, such as an account's first fold, is still
deleted outright, and the account's delta cursors expire instead, since
tombstoning millions of rows would undo the point of the fold. The
compaction-tombstone class leaves these sources out, so the feed, the
backlog purge and the restore treat their tombstones like any deletion.
Apple Health and Health Connect are unchanged.
2026-10-29 10:52:54 +01:00
Marc-André Bombeck dba031d4ad test(integration): record the release's own behaviour in the goldens
The status prompts carry the unattended flag for background calls, the
queue list holds the lab date repair, and an account whose only way to
confirm is its identity provider is now offered that way.
2026-10-29 10:42:40 +01:00
Marc-André Bombeck 17d9c15dbc fix(measurements): keep a folded hour out of a blood-pressure reading
The reading delete found the cuff's pulse by owner, source and identical
instant. The dense fold anchors an hour of heart rate at the local HH:30
under the samples' source, so a reading taken at half past took that hour
with it. Aggregate (stats:) rows are never part of a reading now, for the
delete and for the restore.
2026-10-29 10:34:01 +01:00
Marc-André Bombeck ad5d154561 Merge branch 'feat/v1462-scale' into release/v1.46.2
# Conflicts:
#	src/__tests__/baselines/version-stamp-comments.json
2026-10-29 10:18:22 +01:00
Marc-André Bombeck a9c2e25099 fix(rollups): trail the rollup window from the caller's moment
readBestGranularityRollups opened its window at the wall clock, while the
live fallback beside it used the caller's now. A read anchored on another
moment saw different days depending on whether the account already had
DAY buckets; the Today overview's integration test turned red once its
fixed morning fell out of the wall-clock window. Every caller that holds a
now passes it through.
2026-10-29 10:13:22 +01:00
Marc-André Bombeck 1525be5d53 Merge branch 'feat/v1462-tests' into release/v1.46.2 2026-10-29 10:04:42 +01:00
Marc-André Bombeck f24589c294 test(integration): take a large Google Health account through the fixes
420 000 heart-rate readings, one every five minutes over about four
years, written through the real Google Health backfill with Google's
API faked at the safeFetch seam. The account's DAY rollups lose their
reading extremes, as rows folded before 0417 have them; the boot
discovery finds it, and the windowed full backfill refills every bucket
in the fold window with the raw readings' own extremes. The dense fold
then thins everything older than 90 days to hourly Google Health rows
(420 000 rows to about 59 000) while the DAY tier keeps its figures,
and a second full-history backfill over the same samples inserts
nothing. Without the ingest guard it re-inserts about 394 000 of them.

Each step reports its duration on stderr. On a developer machine:
backfill about 85 s, discovery 0.2 s, full backfill 4 s, fold 40 s,
second backfill 4 s.
2026-10-29 10:03:49 +01:00
Marc-André Bombeck 66081e7102 test(backup): restore the backup of an account that was erased
Nothing covered the operator's position after an erasure: a backup file,
an admin session, and a server that no longer knows the account. Export
the shared backup seed, delete the account through the settings route
under a 14-day window and run the purge job past its end, then:

- scan every text and JSON column for the account's id; only a
  rate-limit bucket that expires within a day may remain;
- upload the file as an admin: refused as owner_not_found, nothing
  stored (the documented contract);
- recreate the account row under its id, upload and restore: every
  section's count matches the export;
- point the file at a freshly created account, upload and restore: every
  count matches and nothing names the erased account afterwards.
2026-10-29 10:03:10 +01:00
Marc-André Bombeck 9d27b6f103 test(backup): restore every release's own backup file
Every backup test exports and restores with the same code, so a change
that moves both ends at once passes all of them and still breaks a file
a self-hoster downloaded last month. Commit the disaster-recovery file
the 1.46.1 exporter writes for the shared backup seed, with a manifest of
its counts and SHA-256, and restore each committed file through the admin
upload and restore routes into an empty account, comparing the re-exported
counts section by section.

The folder takes one file per release (GOLDEN_BACKUP_WRITE=1 writes the
current one; the README says when). The seed's own illness-symptom
catalogue key is swapped for a shipped one so every link resolves on a
fresh instance. Ciphertext is sealed under the suite's fixed key. A
measurement tombstone that ages past retention may only lower its count.
2026-10-29 09:58:20 +01:00
Marc-André Bombeck 1340200cac test(e2e): hold the header height in every app language
A translated label that runs a little longer than the English one wraps,
and a wrapping header grows; every English-only check stays green while
one locale ships a taller dashboard header. The new sweep switches its own
account through each locale the app ships (via the locale API, with
<html lang> asserted before measuring), then compares the top bar and the
dashboard's page-header row on a 390 px phone and a desktop window with
the height English gives them, within 1 px.

The locale list is read from the i18n config, so a new language is swept
without editing the spec. It runs in the desktop project only because the
account's language is one value shared by its tests.
2026-10-29 09:58:14 +01:00
Marc-André Bombeck f74fcf88b1 docs(self-hosting): add a section on large accounts
What an account with tens of millions of readings looks like when it
runs into the per-statement limit (empty cards, job.run.failed with
57014), what the full backfill, the hourly heart-rate fold and the
admin alert now do about it, and when raising
DATABASE_STATEMENT_TIMEOUT_MS still makes sense. The variable was only
named in the compose file and the example env until now (#1298).
2026-10-29 09:54:52 +01:00
Marc-André Bombeck f99f87eca6 feat(jobs): tell admins when a system job keeps failing
A full rollup backfill that failed on every boot showed only on the
admin page's card of failing background jobs. From the account's side
the only symptom was empty dashboard cards, and nothing pointed the
operator at the card (#1298).

A new hourly evaluation reads the newest terminal runs of every queue
whose failure has no account-facing surface (the system audience in
subsystem-surface.ts) from pg-boss's own ledger. A failed run adds its
attempts, retries included, and the first successful run ends the
streak. Three failed attempts in a row send every admin a SYSTEM_ALERT
once, a reminder at most once a day while the queue keeps failing, and
one notice when it runs successfully again: the decision the backup
health notices use, with the same compare-and-set claim, so two workers
never send a notice twice.

The memory is one row per announced queue in system_job_alert_notices
(migration 0490). The notice names the queue, the streak and the last
error, and opens the admin status page.
2026-10-29 09:54:43 +01:00
Marc-André Bombeck d0be3ae106 Merge branch 'feat/v1462-auth' into release/v1.46.2 2026-10-29 09:45:37 +01:00
Marc-André Bombeck 4a99658632 Merge branch 'feat/v1462-data' into release/v1.46.2 2026-10-29 09:45:37 +01:00
Marc-André Bombeck a571dcc47a refactor(measurements): drop a fold-source helper nothing calls
The ingest guard reads the per-source map through the folded-window rules, so the standalone predicate had no caller.
2026-10-29 09:43:37 +01:00
Marc-André Bombeck f92889ab88 refactor(auth): keep the account payload handler and chat schemas in size
The createdAt field and the locale import grew files the size ratchet
holds. The account payload's insurance-number decryption moves into
its own helper, the import joins the zod one, and two comments lose a
wrapped line, so no baseline has to rise.
2026-10-29 09:42:31 +01:00
Marc-André Bombeck 07cf9bba3e fix(measurements): hold the dense fold back while an account's rollups rebuild
The full rollup backfill refills each day's figures from the raw
readings, and the dense fold deletes those readings once it has their
hourly means. On a large account the backfill can run longer than the
ten minutes the fold's boot job waits, and a year it reached after the
fold had taken it would compute its days from hourly means: the day
count, minimum and maximum of every folded day would change.

The consolidation walk now asks a per-account deferUser hook before it
reads an account, and both entries of the dense fold (the per-account
boot job and the nightly drain) pass isRollupFullBackfillPending, which
looks for a queued or running full fold of the account in pg-boss. The
account is folded on a later run, once its rollup tier is rebuilt.
2026-10-29 09:28:31 +01:00
Marc-André Bombeck d7409b38d7 fix(i18n): address the French reader as "vous" throughout
The French bundle mixed the familiar and the formal address: about a
third of the strings said "tu", "ton" or "Réessaie", the rest "vous",
sometimes both on one screen and once in one string.

Every string that addressed the reader as "tu" now uses "vous" with the
matching conjugation, possessives and agreement; placeholders, plural
branches and punctuation are unchanged. The Coach prompts a person
sends address the Coach the same way, the casual "Salut" greeting of
the nudges becomes "Bonjour", and "après que" takes the indicative.
2026-10-29 09:22:57 +01:00
Marc-André Bombeck 919d64947c feat(mental-health): name the Dutch and Belgian crisis lines
A Dutch reader whose screener answer raised the safety path saw only
the international set, because no Dutch set existed.

The Netherlands gets 113 Zelfmoordpreventie (113, the free 0800-0113
and the chat on 113.nl), Belgium the Zelfmoordlijn (1813,
zelfmoord1813.be), both behind 112. The app's one Dutch bundle is read
in both countries and the account keeps no country, so a bare "nl"
lands on a combined set that names each line with its country, the way
the German and Portuguese sets already do; an explicit nl-NL, nl-BE or
region "be" gets its own country's set.
2026-10-29 09:22:00 +01:00
Marc-André Bombeck 6f60812132 fix(ai): accept every app language on the chat and status requests
The Coach chat, the document chat and the fenced chat declared their
locale field as de/en, so a body naming French, Korean, Brazilian
Portuguese or any other shipped language was refused with 422 before
resolveServerLocale, which carries every locale, ever saw it. The same
pair sat on the profile update, so an account could not store any other
language there either, and the stored language is what the cookie-blind
background work (the Coach nudge, notification texts) writes in.

All four now take z.enum(locales), and the published contract for the
metric, insight and biomarker status queries and the profile update
lists every UI locale instead of two.
2026-10-29 09:19:56 +01:00
Marc-André Bombeck 65fe5c34e3 fix(dashboard): welcome a new account instead of welcoming it back
The header always said "welcome back", including to an account the
setup flow had created a few minutes earlier.

/api/auth/me now publishes the account's createdAt, and the greeting
welcomes an account in its first day ("Welcome, name.") and welcomes
back every other one, including an account whose older server does not
send the field. Until the account is known, during the SSR pass and the
hydration render, the line is the time-of-day greeting alone, so
nothing claims "back" before the client knows it is true.
2026-10-29 09:13:48 +01:00
Marc-André Bombeck 107db1bb55 feat(admin): let an admin delete another account through the grace window
There was no way for an admin to remove someone else's account; the
users list only offered the undo for a deletion the account itself or a
Guardian had asked for.

DELETE /api/admin/users/{id} takes the account's own deletion path,
now shared as requestAccountDeletion: with a grace window the account
is locked at once (credentials revoked, record untouched) and erased by
the purge job when the window ends, and the existing undo gives it back
until then; with ACCOUNT_DELETION_GRACE_DAYS=0 it is erased in the
request. The route is cookie-only behind requireAdmin and the
recent-proof gate, takes the typed DELETE_ACCOUNT confirmation, and
refuses the caller's own account, a managed profile (its Guardians
delete it), an account already pending deletion, the last admin who
can sign in and the last Guardian of a managed profile, the last three
decided under their locks inside the transaction. Both the admin and
the account get an audit row.

The users list says who asked for a pending deletion, now including an
admin, and flags managed profiles. Each other account's row carries a
delete button whose confirmation names the purge date, or says the
erasure is immediate when the server keeps no window.
2026-10-29 09:10:26 +01:00
Marc-André Bombeck ef141f457b test(i18n): lower the version-stamp baseline for the provider
The locale effect and its dated comment left the i18n provider with the
previous commit, so the ratchet records one stamp fewer.
2026-10-29 09:09:35 +01:00
haritowa 0b32b567c0 docs(ai): record the live check of the GPT-6 rungs and the retired 5.x line
From a HealthLog container with its stored ChatGPT sign-in, gpt-6.1-sol,
gpt-6-sol and gpt-6-luna each answered text and read a lab-report image,
and the account's own model list offered them with image input.
gpt-6.1-sol refuses effort none and names low as its lowest. The spec said
the gpt-6 rungs had never been probed live, and still listed gpt-5.4,
gpt-5.4 mini, gpt-5.3-codex and gpt-5.2 as accepted, all of which OpenAI
has retired for ChatGPT sign-in, with the exact refusal the backend sends.

Also note why a fixed chain goes stale: once GPT-5.5 retired, every model
on the chain shipped at the time was retired with it.
2026-10-29 09:09:23 +01:00
haritowa 69d46f0add test(ai): pin the walk past a retired model on the default Codex list
OpenAI retired the 5.x line from Codex for ChatGPT sign-in in steps: GPT-5.2
and GPT-5.3-Codex on 2026-05-26, GPT-5.4 and GPT-5.4 mini on 2026-08-31,
and GPT-5.5 on 2026-10-14. The backend answers a retired model with 400
model_not_found and "not supported when using Codex with a ChatGPT
account".

Pin that exact refusal as a reason to try the next slug, pin that an
account refused the head of the default list walks on to the next rung
and caches it, and keep the long-retired models off the default list.
2026-10-29 09:09:02 +01:00
haritowa acf58923f7 test(ai): pin the step-up when gpt-6.1-sol refuses effort none
gpt-6.1-sol, a rung on the default list, answers reasoning "off"
(effort none) with 400 unsupported_value and lists low as its lowest
effort (seen 2026-10-24). The client must stay on the slug, retry with
low and remember it, not walk on as if the model were retired.
2026-10-29 09:08:32 +01:00
Marc-André Bombeck bf0142a626 fix(ai): give background work the High room on a model that always reasons
Background jobs ask for medium reasoning at most, and status cards ask for
none. A model that cannot be switched down (GLM behind a vLLM gateway, for
instance) ignores both, spends the whole room thinking and returns only
reasoning, so the daily briefing and the status cards fail as
reasoning_only and the chain pauses. The Coach on High works because it
leaves 12k tokens to think in.

The chain runner now marks every call of a job walk as unattended, in one
place rather than at each call site. The gateway and local clients give
such a call at least the High thinking room once its model has answered
with reasoning alone. The room has to ride on the #1267 repeat with none:
one call may send two requests, and a once-a-day job meets an expired
lesson every morning, so a room that waited for the unswitchable lesson
would never reach the first briefing of the day. It is a ceiling, not a
charge: a model that does stop at none never touches it. Calls a person is
reading, the Coach included, keep the room of their own level, and the
requested effort is unchanged.

The room calculation both clients shared is now one function.

Reported by @Tio61 in #1276.
2026-10-29 09:07:37 +01:00
Marc-André Bombeck 9eed407ccc feat(measurements): fold Google Health and Oura heart rate like the exports
The dense hourly fold only took Apple Health and Health Connect samples,
so heart rate from the provider syncs that store one row per sample
stayed raw forever. One self-hosted account held about 25 million
Google Health pulse rows after a full-history backfill, and every
reader and fold that falls back to the raw table paid for it (#1297).

The fold now walks a map of sources to the dense types each stores
sample by sample: every dense type for Apple Health and Health Connect,
heart rate for Google Health and Oura. Their daily SpO2 values are no
intraday stream and stay as they are; Fitbit does not sync intraday
heart rate, and WHOOP, Withings and Polar write dense types only once
per recovery, night or spot check. Each source folds into its own
hourly stats rows, and the DAY rollup is still taken from the raw
samples before they go.

The fold deletes the samples it averages, and the provider syncs write
first-write-wins on the externalId, so a backfill or full sync would
have put every folded sample back. Google Health would even have
re-keyed an hourly row into a sample that fell on its anchor through
the natural-key rescue. The folded_window rule now has one hour rule
per fold source, and both syncs drop the samples of an hour the fold
already took before they write. Recent samples cost no lookup.

The boot discovery of the fold probes each account and source for the
first raw sample it would take instead of grouping every such row, and
the one-time fold repair stays on the two sources it was written for.
2026-10-29 09:06:17 +01:00
Marc-André Bombeck 2490a2d218 fix(i18n): mirror the language to the account only when signed in
The provider sent PUT /api/auth/me/locale on mount and on every switch,
signed in or not. Signed out the route answers 401, the once-per-tab
marker was never set, and every signed-out page load repeated the
refused write.

The mirror moves out of the provider into LocaleServerSyncBridge, which
watches the account payload without fetching it and writes only once an
account is known. The choice made on the sign-in page therefore reaches
the account right after signing in, and the marker is keyed on the
account as well as the language, so another account signing in on the
same tab is written for too. The browser test stub no longer treats the
PUT as ambient, which is what lets its test see a stray write.
2026-10-29 09:00:04 +01:00
Marc-André Bombeck bbef4eea99 feat(auth): let a single-sign-on account confirm it is you at its provider
An account created through OIDC holds no password, passkey or second
factor, so the recent-proof gate in front of token minting, MCP
connections, exports and the admin reset had nothing to ask it for and
the web could only say "sign in again" (#1296).

GET /api/auth/oidc/reproof sends the browser back to the configured
provider with prompt=login and max_age=0, its own state, nonce and PKCE
verifier, and the signed-in account and session sealed into the state
cookie. The shared callback branches on that intent: it refuses unless
the browser still carries the bound session (checked before the code is
redeemed), the ID token names the account's linked issuer and subject,
and auth_time lies inside the gate's five-minute window. It then stamps
Session.reproofAt exactly as a password re-proof does, creates no
session, and returns to the page it left with ?reproof=done|failed.

recentProofMethods offers "oidc" only to an account without a second
factor whose identity is linked while a provider is configured, and the
fresh-factor gate never lists it. GET /api/auth/me/mfa publishes the
list as proofMethods, so the passkey card offers what the account can
actually give, and register-options takes { method: "oidc" } on a fresh
provider re-proof, so such an account can add a passkey as a second way
to confirm. Both re-proof dialogs offer the provider and a toast reports
the outcome on return.

The callback's missing-state branch and the passkey card's enrollment
flow move into their own functions to keep both handlers under the size
ratchet.
2026-10-29 08:56:51 +01:00
Marc-André Bombeck e2998183c8 fix(measurements): delete a blood-pressure reading as one
Systolic, diastolic and the cuff's pulse are three rows with no link
column, and the single DELETE tombstoned only the row it was given, so
deleting a reading's systolic left its diastolic and pulse behind as half
a reading. The bulk delete had the same gap.

A reading is the rows with the same owner, the same source and the
identical measuredAt: that is how the entry form, the MCP write, a
Withings measure group and a HealthKit correlation all store one, and how
the FHIR read pairs a diastolic onto its systolic. The looser five-minute
pairing of the chart reads is right for drawing a point and wrong for
taking rows away.

Deleting a systolic or diastolic now tombstones the rest of its reading in
the same transaction with the same deletedAt, each row with its own
syncVersion bump so paired clients see every tombstone. The restore route
brings back the rows deleted together with a named half (identical
deletedAt), so the undo, which names only the row that was tapped, puts
the whole reading back, from the web list and from any client that calls
it the same way. A pulse is still deleted on its own, and a row deleted at
another moment stays deleted. The response shapes do not change; the
OpenAPI descriptions say what the counts now include.
2026-10-29 08:56:19 +01:00
Marc-André Bombeck 1d8e2e0ee3 fix(rollups): fold the full backfill a year at a time under its own budget
On an account with tens of millions of readings the boot full backfill
never finished. It ran four aggregates over the whole five-year window,
each held to the connection's 60 s statement limit, so every attempt
was cancelled with 57014, the reading extremes added by 0417 stayed
NULL, and the readers that need them fell back to the raw table and
timed out as well (#1298).

The full backfill now folds the window one UTC calendar year per pass,
newest first, and every statement runs under its own five-minute limit,
the shape the pulse day-mean rebuild already uses. Calendar-year cuts
write exactly the buckets one pass writes: day, month and year buckets
fall inside one window and a week across New Year is folded whole by
both. Each window commits on its own, so a run that reaches its job
budget hands the remaining years to a follow-up job instead of failing
and starting over on the retry.

The boot discovery read every live reading in the window to find
uncovered days, the same scan that kept the fold from finishing. It
now walks the days per account and type off the live index, so its
cost follows the number of days with readings, and runs under the same
limit. The full backfill code moves to its own module.
2026-10-29 08:52:36 +01:00
Marc-André Bombeck d88313e929 fix(labs): move document lab values filed under their confirmation day
Before the confirm step dated an undated value by its document, a lab
value confirmed from a report that prints its collection date only in the
header was stored with takenAt set to the moment of confirmation. The fix
covers new confirms; the values already filed that way stay on the wrong
day until something moves them.

A one-shot maintenance job, sent at boot only while there is something to
repair, moves exactly those values to the document's report date (else its
filing date) at noon UTC, the same rule the confirm step now uses. A value
qualifies only when the chain is certain: written by the document confirm
path, named by exactly one approved observation fact, takenAt still within
five seconds of its own createdAt, no calendar date of its own in the
decrypted fact, and a document day that differs from the day the value is
filed under in the owner's zone. A value re-dated since, one really taken
on its confirmation day, or one whose document has no date is left alone.
A moved value no longer sits at its creation moment, so the pass converges
and a second run changes nothing.

The job definition lives in a new registry file beside maintenance.ts so
that file stays under the size ratchet.
2026-10-29 08:49:40 +01:00
Marc-André Bombeck cb80296a2a Merge pull request #1293 from haritowa/documents-fact-date-from-document 2026-10-29 08:37:56 +01:00
Marc-André Bombeck 35a0ceb12c Merge pull request #1294 from MBombeck/fix/throttle-test-clock
fix(auth): read a remembered browser's expiry on the throttle's clock
2026-10-26 15:36:37 +01:00
Marc-André Bombeck b180d09fae fix(auth): read a remembered browser's expiry on the throttle's clock
knownSignInSource compared a trusted device's expiry with new Date()
while the throttle around it times its waits with Date.now(). The test
mocks Date.now() only, so it set the device to expire 30 days after its
fixed clock, which the real date passed on 2026-10-26 at noon UTC; from
then on the remembered browser read as expired and the test failed on
every run. One clock decides both now, and the test's expiry is fixed
against it. A further case pins that an expired device gets no
exemption.
2026-10-26 15:08:06 +01:00
Marc-André Bombeck 460a4fa8f9 Merge pull request #1291 from MBombeck/release/v1.46.1
Release v1.46.1
2026-10-26 08:28:30 +01:00
Marc-André Bombeck 444ff66086 refactor(charts): decide in the overlays hook where a chart's labels go
The narrow-chart rule lived in HealthChart's body and grew it past its
size baseline. The overlays hook, which already sizes the room beside
the plot, now takes the chart's width, keeps no room on a narrow chart
and marks each target zone whose label sits inside it; the x axis
helper works out its own tick layout. The tiered series unit test mocks
the lookup for readings older than the rollups and covers the head the
read puts in front of them.
2026-10-26 07:59:58 +01:00
Marc-André Bombeck f4cb15d823 chore(release): v1.46.1 2026-10-26 07:47:25 +01:00
Marc-André Bombeck 13860422cf fix(series): show a record's history older than the rollups on the All range
A range longer than a year reads its series from the week or month
rollups, which are kept for the last five years. When the rollups held
any rows the read returned them alone, so on a record that goes back
further the All range started where the rollups start: a weight history
from 2015 charted from late 2021. The readings older than the first
rollup day are folded live at the tier's grain and lead the series; the
head ends where the first tier bucket begins, so no month is counted
twice. One indexed lookup when there are none.
2026-10-26 07:46:50 +01:00
Marc-André Bombeck 467d5dcb48 fix(charts): give the plot its width back on a phone
1.46 moved the average's and the target zones' labels beside the plot,
in a right margin sized to the longest label. On a phone that margin
took about a third of the plot's width, and the legend, wrapping into
the narrower space, took more of its height: a pulse chart drew at
160 x 66 px instead of 232 x 108. Below 520 px the labels sit inside
the plot again, the average's at the top left as before and a zone's
at the top right of its band, and the plot keeps no room beside it.

The x ticks assumed an 80 px date label. A 90-day chart's month starts
sit about 78 px apart on a phone, so the month grid never fit and the
axis showed one date. The label width now comes from a date in the
reader's format (10/01/2026 draws at 58 px). A yearly grid is labelled
with the year alone, narrow enough for one tick a year, and with the
year it marks when its point falls in the December before.

The y axis shows the decimals its tick step needs: 57, 58, 59 bpm
instead of 57.0, 58.0, 59.0.
2026-10-26 07:42:27 +01:00
dependabot[bot] 0497d60a89 build(deps): bump docker/login-action from 4.6.0 to 4.7.0 (#1289)
Bumps [docker/login-action](https://github.com/docker/login-action) from 4.6.0 to 4.7.0.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/dbcb813823bdd20940b903addbd779551569679f...81c6f93d4bc991e510b342a00b419b3bb4efc5c6)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-version: 4.7.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-26 01:32:51 +00:00
haritowa 4ca7573f90 test(documents): pin an undated lab value's date through the confirm route
Against a real Postgres and the real confirm route: an undated value takes
the report date over the filing date, a value's own date is kept, an
impossible own date counts as none, and a document without a report date
dates the value by its filing date.
2026-10-26 01:08:43 +00:00
haritowa 58141cb48b fix(documents): date an undated lab value by its document
A lab report usually prints the collection date once, in its header. The
extraction puts that date on the document (reportDate) and leaves each
value's own date empty, and the confirm step filed every such value under
the moment it was confirmed, so an old report showed up as today's
results.

The confirm route now reads the document's report date and filing date
with the document it already loads, and an undated value takes the report
date, else the filing date, else now: the order the day view places a
document by.

A stated date, a lab value's or a condition's onset, now counts only when
it names a real calendar day. 2021-02-30 used to be stored as 2 March, and
2021-13-01 became an invalid date that failed the whole confirm.
2026-10-26 01:08:43 +00:00
Marc-André Bombeck eceb81ff16 Merge pull request #1283 from MBombeck/release/v1.46.0
Release v1.46.0
2026-10-26 02:00:56 +01:00
Marc-André Bombeck b7b34c95d8 test(backup): attach stdin to the dump forwarder only when feeding a file
The forwarder ran every tool as docker exec -i with its own standard
input piped through, also for pg_dump, which reads none. With -i and an
input already at its end the docker client may close the stream before
all output has arrived; one CI run saw the second of three nightly dumps
fail where every local run and the two runs before passed. Attach stdin
only for a file fed to pg_restore, and put the run's error into the
assertion so a failure says why.
2026-10-26 01:26:54 +01:00
Marc-André Bombeck 085864e63a fix(medications): classify a plan's cadence the same for every range
realisedFamily probed for the closest two slots up to range.to only, so
the answer moved with the range. A Saturday + Sunday plan started the
weekend before the autumn DST change, read around its Saturday dose: the
±24 h range ends at 06:00 UTC on the Sunday, whose 08:00 slot is at 07:00
UTC after the change. Cut off, the closest slots were six days apart
(weekly); the wide re-mint saw the 25 h gap (daily). The two passes
disagreed, no band survived, and the dose was filed as unscheduled, so a
second backdated dose within a minute came back as the first one (200).
Probe the same reach on both sides of the range's middle.
2026-10-26 00:47:24 +01:00
Marc-André Bombeck 5765059737 fix(medications): end today's intake list at the next local midnight
The list ended 24 hours after local midnight. On the 25-hour day of an
autumn DST change that left the last hour out, so a dose due then could
not be offered from it; on the 23-hour spring day it reached into the
next day. Take the day's bounds from getUserTodayBounds, as the dashboard
tile does. Anchor the health-status test's history to local noon: now
minus whole days is still today on the long day.
2026-10-26 00:05:21 +01:00
Marc-André Bombeck 9501d540d0 fix(backup): create a directory one level at a time
Node's recursive mkdir retries ENOENT/EEXIST forever below /proc on Linux,
inside one native call, so neither a timer nor a test timeout runs again.
A DB_BACKUP_DIR or GEOLITE2_DIR pointing there froze the process; the
database-backup integration file, which uses such a path for its
unwritable case, never finished on a Linux runner. Walk from the nearest
existing parent with plain mkdir instead.
2026-10-25 23:27:12 +01:00
Marc-André Bombeck 65414be936 test(ci): rebalance the integration shards on this release's timings
The new backup, dump and deletion files were unknown to the duration
sequencer and gathered in one shard, which ran past its job timeout
while the other three finished in under fifteen minutes.
2026-10-25 22:08:22 +01:00
Marc-André Bombeck fa9218053c chore(release): carry the service worker's fallback version to 1.46.0 2026-10-25 21:57:38 +01:00
Marc-André Bombeck f6c62f6f39 chore(release): v1.46.0 2026-10-25 21:10:00 +01:00
Marc-André Bombeck c4b300ad78 fix(account): stop calling a delete with a grace window permanent
With the deletion grace window on, the confirm dialog still asked to
delete the account permanently and its button said so, while the body
promised fourteen days and an admin undo. The title and the button now
match the window, and the success message that names the erase date stays
long enough to read before the sign-in page.
2026-10-25 20:48:30 +01:00
Marc-André Bombeck 789beff176 Merge remote-tracking branch 'origin/release/v1.46.0' into fix/v146-e2e-locale 2026-10-25 20:33:35 +01:00
Marc-André Bombeck 9b1e27f200 fix(i18n): keep the backups page explainer on one line
The new account-copy explainer under the Backups heading ran to 99-113
characters in German, Spanish, French, Italian, Polish and Portuguese and
wrapped to a second line at desktop width, so that page's header sat
20 px taller than every other settings and admin page. The German run of
the settings/admin header measurement failed on it. The Korean text was
two sentences in a one-sentence slot.

Each is now one sentence that fits the 1280 px column; English and Dutch
already did and stay as they are.
2026-10-25 20:16:48 +01:00
Marc-André Bombeck 78de978dcd fix(recovery): check a pasted token key against its slow fingerprint
The kit's check compared keyed SHA-256 digests of the pasted value and the
configured key, which code scanning still reads as a fast hash of a
password. It now compares the scrypt fingerprints in constant time.
2026-10-25 19:58:40 +01:00
Marc-André Bombeck 3939c9c0a5 Merge main (the dependabot updates the release already carries) 2026-10-25 19:35:04 +01:00
Marc-André Bombeck ae0b8d604a fix(recovery): fingerprint a chosen secret with scrypt, not a fast hash
API_TOKEN_HMAC_KEY can be a phrase, and its fingerprint is shown on the
admin page and written into dump manifests in the bucket; a fast hash
there lets a guessed phrase be checked offline. Text secrets now use a
scrypt fingerprint; the random encryption keys keep theirs. The kit's
check of a pasted token key compares keyed digests that never leave the
process.
2026-10-25 19:09:27 +01:00
Marc-André Bombeck 135d5baf28 fix(medications): return a medication's schedules in one order everywhere
The list reads ordered schedules by their window and id, but the detail,
create, update, compliance, dose-history, GLP-1 and efficacy reads took
them in whatever order Postgres found them, so two schedules created in
the same millisecond could swap places between two answers. Every read
now uses MEDICATION_SCHEDULE_ORDER, and the guard checks every
schedules include, not only the list reads.
2026-10-25 19:09:27 +01:00
Marc-André Bombeck a42b29a160 perf(ai): read the deletion lock with the usage level, not on its own
The capability gate's check for an account pending deletion added a
point read of users to every request that resolves AI capabilities, one
statement over the budget on /api/auth/me, the dashboard snapshot and the
batch ingest. The usage-level read already loads the record's row once
per request; it now carries the deletion lock too.
2026-10-25 18:54:33 +01:00
Marc-André Bombeck 52a80957f9 test(jobs): record the erasure and readiness queues in the registration golden 2026-10-25 18:45:42 +01:00
Marc-André Bombeck bcb31c80db Merge the release with the security review fixes into the data-safety review fixes 2026-10-25 18:36:54 +01:00
Marc-André Bombeck 722c6d1db5 Merge remote-tracking branch 'origin/release/v1.46.0' into fix/v146-sec-review 2026-10-25 18:19:39 +01:00
Marc-André Bombeck b240d8e829 docs(api): regenerate the OpenAPI document for account.deletion.no_guardian 2026-10-25 18:08:38 +01:00
Marc-André Bombeck d2b8da7839 refactor: keep the size, cast and error-code guards where they were
The fixes above grew six files over 800 lines and three long functions, added
casts in tests and one error code the catalogue did not list. The claimable
profile check moves into its own module, the guardian count is reused by the
revoke path, the lifecycle fallback lives beside the lifecycle rules, the
key-needs list is its own component, the coach sweep wraps its filters with
ownedByActiveAccount, comments are tightened, and the test fixtures are typed
instead of cast. The enumeration guard reads a write keyed by one handed id
(id: runId, id: job.data.jobId) as scoped, so those need no marker.
account.deletion.no_guardian joins the catalogue; two baselines go down.
2026-10-25 18:06:32 +01:00
Marc-André Bombeck f63d5d5819 fix(ai-runs): do not count time in the document reader against a run's deadline
A background AI run's deadline is armed in model time plus a fixed margin.
Time spent waiting in the document reader's queue, and the read itself, came
out of that margin, so a person's lab scan or document read could be timed
out by the reaper because other work held the reader. When the reader grants
the slot it now moves the run's deadline by the wait plus the most the read
may take, and gives back the unused part when the read ends, never past the
job's own expiry and only while the run is running. The interactive queue
wait stays within the margin, which covers the run until the slot is
granted.
2026-10-25 18:02:45 +01:00
Marc-André Bombeck 42940c3a98 fix(jobs): the encryption backfill still runs for an account pending deletion
The per-account free-text encryption backfill seals what is already stored
and changes nothing the record holds, which is why its module is exempt from
the frozen-account rule; the new pending-deletion gate dropped its per-user
jobs anyway. A queue can now say it acts for pending accounts
(actsForPendingAccounts), and this one does.

Also types the ingest route test's token fixture to the row it now reads,
and drops an unused variable from the deletion grace test.
2026-10-25 17:51:13 +01:00
Marc-André Bombeck 698312863d test(labs): check the labs gate per read, and only the labs module's
The guard asked whether a file that reads the lab table mentions the gate
anywhere, so one ungated read in a file with a gated one passed, and
whenModuleOn for any module counted as the labs gate. It now walks the syntax
tree: each read must sit in a function that asks the labs module, or in a
private helper whose every caller in the file does, or be listed by file and
function with its reason. whenModuleOn counts only with "labs".
2026-10-25 17:44:31 +01:00
Marc-André Bombeck 70df2009d9 fix(deploy-webhook): keep DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP's meaning, add a signature-only flag
DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP had been changed to mean signed requests
only, so an operator who set it for a sender that adds a timestamp but cannot
sign would have had every deploy notification refused after the upgrade. It
means the secret header plus a fresh timestamp again. Signature-only, the
setting that stops a replay, is DEPLOY_WEBHOOK_REQUIRE_SIGNATURE, on the
compose whitelist, in the env manifest and examples, and in the deploy
guide.
2026-10-25 17:44:31 +01:00
Marc-André Bombeck d87718407e fix(http): let operators name their proxy, and survive a bad TRUST_PROXY_HOPS
Forwarding headers were believed from any connection on a private address.
Behind Docker Desktop, OrbStack and docker-proxy every connection to a
published port arrives from the gateway, a private address, so a caller
reaching the port directly could again choose the client address that rate
limits, the audit log and sign-in notices record. TRUSTED_PROXY_CIDRS
(comma-separated CIDRs) now names the proxies: when set, only those peers may
report a client address. The private-range default stays when it is unset,
the boot log states the rule in force in one line, and the reverse-proxy
guide says what to set for a proxy on the same host, on a Docker network, or
on a public address (which, unlisted, puts all clients in one bucket).

An invalid TRUST_PROXY_HOPS threw on every request that resolved the client
address, so every route answered 500, the health probe included. It now
resolves no address, warns once, and is named at boot.
2026-10-25 17:44:31 +01:00
Marc-André Bombeck 0b830e6b02 fix(http): exempt only a Bearer request without a session cookie from the cross-site check
The cross-site write check let through any request that carried an
Authorization header, while the caller is resolved from the session cookie
before any header. A browser attaches Basic or Negotiate credentials on its
own once a proxy or host asked for them, so a cookie-authenticated write from
a sibling subdomain could pass. Only Authorization: Bearer is exempt now, and
only when no session cookie rides along; a request carrying both is checked
as the cookie request it resolves to.

The share-link passphrase POST under /api/c/ is exempt by path: its
credential is the token in the path and the passphrase in the body, and from
the shared view's no-referrer page a browser without Sec-Fetch-Site sends
Origin: null, which was refused.
2026-10-25 17:44:31 +01:00
Marc-André Bombeck 5a875d2840 fix(auth): issue a Secure session cookie under the __Host- prefix
A cookie named healthlog_session can be set by any host under the same
registrable domain, so a page on a sibling subdomain could plant a session of
its choosing in a visitor's browser. Wherever the session cookie is Secure it
is now issued as __Host-healthlog_session, which a browser accepts only from
this exact host. Plain-HTTP installs, which cannot hold a __Host- cookie,
keep the old name.

Nobody is signed out: the old name is still read after the new one, a
session found under it is re-issued under the new name and the old cookie
expired, and a browser holding the new cookie is never answered from the old
one. Every reader and writer goes through one module, which the proxy, the
native-caller check, the native handoff and the OIDC callback use; the
session-surface guard follows the new reader.
2026-10-25 17:44:31 +01:00
Marc-André Bombeck d3257a1b6b fix(documents): tell people when the document reader is busy, and attribute its work
A read a person started now runs as interactive work of their account, and
the document jobs as background work of theirs, so the reader's queue can
order and cap them. When the reader is busy or stopped before it answered,
a lab scan, label read or document read fails with aiRuns.readerBusy (503,
nothing read or charged) instead of a misleading unreadable-file error, and
the index and summary jobs throw so the queue runs them again rather than
recording an outcome about the document.
2026-10-25 17:38:25 +01:00
Marc-André Bombeck 4d970561fa fix(documents): decode thumbnails in the reader child, share it fairly, judge exits by cause
Three defects in the bounded document reader.

Image uploads were decoded into their thumbnails in the server process. A
header check is all that stood between a decoder and an allocation the file
chooses, and the GIF check showed how that fails. The image is now decoded in
the same child process PDFs run in, under its memory ceiling and time limit;
the header check stays in front of it.

The reader's one slot went to whoever asked first, and a waiting item waited
without end, so a few heavy PDFs from one account held everyone's lab scans
behind them. The slot now goes to a read a person is waiting on before
background work, to accounts in turn, to a few waiting items per account, and
an item that waits past its lane's deadline is answered busy, which callers
treat as retryable.

Any child that ended without an answer counted as a document past its limits,
so a kernel kill caused by the container's overall memory marked a good PDF
as permanently failed. Only a bound the reader measured (the sampled memory,
the time limit, the child's heap cap) is final now; any other end is run once
more and then answered interrupted, which the thumbnail job retries within its
ordinary attempts.
2026-10-25 17:38:25 +01:00
Marc-André Bombeck e6c709876d fix(auth): the ingest and idempotency Bearer resolvers refuse a pending account
Both resolve a Bearer token outside resolveBearerToken and checked only
revoked and expiry. The deletion revokes every token in its own transaction,
so only a token issued in that same instant could get through, but the rule
should hold everywhere a token is resolved: both now refuse an account
pending deletion.
2026-10-25 17:33:16 +01:00
Marc-André Bombeck 69c5c65376 fix(data-wipe): say what undoing a wipe does not bring back
A restore of the copy a data wipe took replaces the record with that copy,
so anything added after the wipe is gone, and the wipe ended integration
connections, sharing in both directions, Guardian roles and share links,
none of which are in a copy. Neither the person's confirmation nor the
admin's restore dialog said so. Both do now, in every locale.
2026-10-25 17:32:33 +01:00
Marc-André Bombeck 2b79c01952 fix(offhost): judge the bucket's rules for database/ against this instance's folder
The dumps now live under database/<host of APP_URL>/, so the rule check reads
a sample key in that folder: a rule scoped to this instance's folder counts,
one scoped to another instance's does not.
2026-10-25 17:30:58 +01:00
Marc-André Bombeck a7691223fd fix(db-backup): keep instances apart in a shared bucket, and no decrypted dump behind
Off-host dumps were named by their time alone. Two instances sharing a bucket
(nothing forbids it, and it was safe for the account objects) overwrote each
other's dump at the same second of the same default schedule, and
latest --from-bucket restored whichever instance's dump was newest. Each
instance now uploads under database/<host of APP_URL>/, the object key (and so
the encryption's associated data) carries it, and the restore takes only this
instance's dumps: it refuses another's and names the instances it found,
unless --instance=<host> or the dump's full key says which one on purpose.

A restore killed outright (out of memory, docker kill) left its .restore- or
.download- directory, a whole database decrypted, in the backups directory,
where nothing removed it. A restore now removes them before it starts and on
SIGINT and SIGTERM, and the nightly retention sweeps any older than twelve
hours.
2026-10-25 17:30:29 +01:00
Marc-André Bombeck 6051488b65 fix(restore-drill): a drill that read nothing back is no pass
A drill that stopped on its time budget before the first object recorded
ok with zero accounts checked, and the readiness tile said passed. Such a
run now records its time and counts and no verdict, so the last pass or
failure stands, and readiness calls it attention (nothing_checked) in every
locale.

The upsert that keeps a pass and a failure apart was stubbed in the drill's
tests and tested nowhere; it now has an integration test read back through
the readiness rule. The drill test that claimed to keep a failed verdict
when keeping it fails is named for what it checks.
2026-10-25 17:26:40 +01:00
Marc-André Bombeck a857fd0264 fix(backup-restore): a restored pre-wipe copy withdraws only its own wipe's purge
Restoring the copy a data wipe took withdrew every held data_wiped purge of
the account. With two wipes in one window, restoring the first copy also
withdrew the second wipe's purge, and the off-host copies of what the person
entered between the wipes, and wiped, stayed until the bucket's rule removed
them. Only the request written after this copy and before the next one goes
now. The withdrawal had no test; it has one against Postgres.
2026-10-25 17:26:39 +01:00
Marc-André Bombeck 2bb030b656 fix(offhost): keep the bucket's last lifecycle answer through a failed read
A lifecycle read that timed out once was cached as unknown for ten minutes,
which kept every key an off-host object ever needed in the recovery kit: the
kit turned due, the admins were pushed attention, and the next good read
pushed recovered. A failed read now stands on the last answer the bucket gave,
for up to a day.
2026-10-25 17:23:51 +01:00
dependabot[bot] 68a363bd48 build(deps): bump the production-weekly group across 1 directory with 8 updates (#1286)
Bumps the production-weekly group with 8 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [@aws-sdk/client-s3](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-s3) | `3.1152.0` | `3.1155.0` |
| [@aws-sdk/lib-storage](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/lib/lib-storage) | `3.1152.0` | `3.1155.0` |
| [@napi-rs/canvas](https://github.com/Brooooooklyn/canvas) | `1.0.11` | `1.0.13` |
| [@tanstack/react-query](https://github.com/TanStack/query/tree/HEAD/packages/react-query) | `5.104.4` | `5.104.5` |
| [@tanstack/react-virtual](https://github.com/TanStack/virtual/tree/HEAD/packages/react-virtual) | `3.14.21` | `3.14.22` |
| [jose](https://github.com/panva/jose) | `6.2.12` | `6.2.13` |
| [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react) | `1.59.0` | `1.61.0` |
| [nodemailer](https://github.com/nodemailer/nodemailer) | `10.1.7` | `10.2.1` |



Updates `@aws-sdk/client-s3` from 3.1152.0 to 3.1155.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-s3/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1155.0/clients/client-s3)

Updates `@aws-sdk/lib-storage` from 3.1152.0 to 3.1155.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/lib/lib-storage/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1155.0/lib/lib-storage)

Updates `@napi-rs/canvas` from 1.0.11 to 1.0.13
- [Release notes](https://github.com/Brooooooklyn/canvas/releases)
- [Changelog](https://github.com/Brooooooklyn/canvas/blob/main/CHANGELOG.md)
- [Commits](https://github.com/Brooooooklyn/canvas/compare/v1.0.11...v1.0.13)

Updates `@tanstack/react-query` from 5.104.4 to 5.104.5
- [Release notes](https://github.com/TanStack/query/releases)
- [Changelog](https://github.com/TanStack/query/blob/main/packages/react-query/CHANGELOG.md)
- [Commits](https://github.com/TanStack/query/commits/@tanstack/react-query@5.104.5/packages/react-query)

Updates `@tanstack/react-virtual` from 3.14.21 to 3.14.22
- [Release notes](https://github.com/TanStack/virtual/releases)
- [Changelog](https://github.com/TanStack/virtual/blob/main/packages/react-virtual/CHANGELOG.md)
- [Commits](https://github.com/TanStack/virtual/commits/@tanstack/react-virtual@3.14.22/packages/react-virtual)

Updates `jose` from 6.2.12 to 6.2.13
- [Release notes](https://github.com/panva/jose/releases)
- [Changelog](https://github.com/panva/jose/blob/main/CHANGELOG.md)
- [Commits](https://github.com/panva/jose/compare/v6.2.12...v6.2.13)

Updates `lucide-react` from 1.59.0 to 1.61.0
- [Release notes](https://github.com/lucide-icons/lucide/releases)
- [Commits](https://github.com/lucide-icons/lucide/commits/1.61.0/packages/lucide-react)

Updates `nodemailer` from 10.1.7 to 10.2.1
- [Release notes](https://github.com/nodemailer/nodemailer/releases)
- [Changelog](https://github.com/nodemailer/nodemailer/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodemailer/nodemailer/compare/v10.1.7...v10.2.1)

---
updated-dependencies:
- dependency-name: "@aws-sdk/client-s3"
  dependency-version: 3.1155.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production-weekly
- dependency-name: "@aws-sdk/lib-storage"
  dependency-version: 3.1155.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production-weekly
- dependency-name: "@napi-rs/canvas"
  dependency-version: 1.0.13
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production-weekly
- dependency-name: "@tanstack/react-query"
  dependency-version: 5.104.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production-weekly
- dependency-name: "@tanstack/react-virtual"
  dependency-version: 3.14.22
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production-weekly
- dependency-name: jose
  dependency-version: 6.2.13
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production-weekly
- dependency-name: lucide-react
  dependency-version: 1.61.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production-weekly
- dependency-name: nodemailer
  dependency-version: 10.2.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production-weekly
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-25 16:23:27 +00:00
Marc-André Bombeck db4d5928fd docs(ops): name what the v1.46 migrations drop and what a rollback to v1.45 does
The notes said the other migrations only add columns and one concurrent
index. 0487 drops four app_settings columns the previous image reads on
nearly every request, and 0488 builds its index on users without
CONCURRENTLY. The rollback step now names the columns to hand back, and the
notes say what v1.45 does with accounts pending deletion and held wipe
purges, and how to settle them before going back.
2026-10-25 17:23:03 +01:00
Marc-André Bombeck 756885eec0 fix(db-backup): take the pre-migration dump when pending migrations cannot be counted
Any error counting pending migrations (a connect or statement timeout, a
database still starting up) exited the step with "no dump before migrating",
and the migration that ran a second later went ahead without its way back.
An unknown count now takes the dump; a database that cannot be reached at
all then fails the dump, and the boot stops naming the switch that skips it.
2026-10-25 17:22:33 +01:00
Marc-André Bombeck 6f83e7e685 fix(db-backup): age pre-migration dumps out with the nightly ones
Pre-migration dumps were kept by count alone, so an install that updates
rarely kept its last two, and every account deleted since, indefinitely,
while the docs and the backups card stated the nightly retention as the
retention of deleted accounts. A pre-migration dump older than the oldest
nightly dump kept now goes, except the newest one, the way back from the
latest update, which stays until the next update. The card, the disaster
recovery guide and its variable table say so.
2026-10-25 17:21:52 +01:00
Marc-André Bombeck 26a9efdf52 fix(recovery): the database dumps count in the keys the backups still need
The recovery kit, "Keys the backups still need" and safeToDropRetiredKeys
read only the stored copies and the off-host account objects. After a
rotation the page called the old key safe to drop while every dump taken
before it, the pre-migration dumps that are the way back from an update
among them, could only be restored with that key: db-restore refuses a dump
whose active key is missing.

A dump now counts for the key its rows were written under, the manifest's
active key: a dump on this server while it is kept (read from the manifests),
an off-host one until the bucket's rule for database/ expires it, or
indefinitely when that rule cannot be read. The nightly run records the key
on its ledger row (migration 0489, database_backup_runs.active_key_id). The
encryption view and the kit list the dumps per key, in every locale, and the
rotation and backup runbooks say so.
2026-10-25 17:20:16 +01:00
dependabot[bot] fe5ab2459a build(deps): bump the codeql-action group with 3 updates (#1285)
Bumps the codeql-action group with 3 updates: [github/codeql-action/init](https://github.com/github/codeql-action), [github/codeql-action/analyze](https://github.com/github/codeql-action) and [github/codeql-action/upload-sarif](https://github.com/github/codeql-action).


Updates `github/codeql-action/init` from 4.39.0 to 4.39.1
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee...9b3254085494b55a0f7c4c2b3d2d52cc712ce370)

Updates `github/codeql-action/analyze` from 4.39.0 to 4.39.1
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee...9b3254085494b55a0f7c4c2b3d2d52cc712ce370)

Updates `github/codeql-action/upload-sarif` from 4.39.0 to 4.39.1
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee...9b3254085494b55a0f7c4c2b3d2d52cc712ce370)

---
updated-dependencies:
- dependency-name: github/codeql-action/init
  dependency-version: 4.39.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: codeql-action
- dependency-name: github/codeql-action/analyze
  dependency-version: 4.39.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: codeql-action
- dependency-name: github/codeql-action/upload-sarif
  dependency-version: 4.39.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: codeql-action
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-25 16:15:53 +00:00
Marc-André Bombeck 40c310f0ad fix(account-deletion): hold frozen accounts out of the work the guard missed
The enumeration guard read any query that named userId as scoped, including
an orderBy tiebreak, so the nightly recovery, strain and stress score jobs
grouped every account's measurements, wrote scores into frozen records and
spent cap slots on them. It also looked only at findMany and groupBy under
lib/jobs.

The guard now reads a scope from a query's filter only (orderBy, by,
distinct, select and include are dropped first), covers count, aggregate,
updateMany, deleteMany and executeRaw, and walks the boot discoveries and
sweeps that live beside their domain: the rollup and mood rollup discovery,
the Fitbit jobs, the cumulative record rederivation, the intake slot dedup,
the inventory expiry and the climb backfill. What it then found is filtered
(the score cohorts through excludePendingOwners, which filters before the
cap; Fitbit sync, backfill and sleep repair; coach proposal lapsing and
reminder nagging; expired coach reminders; snooze clearing; inventory
expiry) or says why it needn't be (retention and expiry sweeps over rows,
writes scoped by id).

Jobs queued before a deletion ran anyway, since handlers act on the userId
they carry without asking again. The binding of every queue now completes a
job whose payload names an account pending deletion without loading its
handler (pending-deletion-gate). The silent medication intake push, which
does not go through the dispatcher, refuses a pending account itself.
2026-10-25 17:13:09 +01:00
Marc-André Bombeck 8783182b1e build(deps): keep @aws-sdk/client-s3 on the same release as lib-storage 2026-10-25 17:06:17 +01:00
Marc-André Bombeck 328268d894 build(deps): bump the production-weekly group and codeql-action
@aws-sdk/client-s3 and lib-storage 3.1155.0, @napi-rs/canvas 1.0.13,
@tanstack/react-virtual 3.14.22, jose 6.2.13, lucide-react 1.61.0,
nodemailer 10.2.1, and github/codeql-action 4.39.1.
2026-10-25 17:05:54 +01:00
Marc-André Bombeck 763cb48273 fix(recovery): readiness leaves accounts pending deletion out
The weekly and off-host passes skip an account pending deletion on purpose,
and the readiness read counted it anyway: within days of any deletion the
weekly copy read failing and the off-host copies stale, every admin was
pushed a problem and then a recovery after the purge, and a real failure
hid behind a signal already red. The read now filters with ACTIVE_ACCOUNT,
and the enumeration guard covers the file.
2026-10-25 17:03:10 +01:00
Marc-André Bombeck be78517500 fix(managed-profiles): pending deletion closes handovers and the Guardian count
A handover link stayed claimable after its profile was deleted: the claim
gave the profile credentials that every sign-in then refused, and the purge
erased the account someone had just taken over. Locking an account for
deletion now revokes the open links for that profile and the links that
account minted, and creating, previewing and claiming a link refuse a profile
pending deletion, the claim under the record lock.

The last-Guardian rule counted Guardians pending deletion and profiles
pending deletion. A Guardian who had deleted their only profile could not
delete their own account; two Guardians sharing a profile could both leave,
and the second one's erasure was then refused on every run. The rule now
counts only Guardians whose account is not pending deletion, ignores profiles
pending deletion, and is decided under the record lock for account deletion,
data wipe, revoking and renouncing a Guardian. An admin's undo of a managed
profile with no such Guardian left is refused (409
account.deletion.no_guardian).
2026-10-25 17:02:32 +01:00
Marc-André Bombeck b37e4f5df7 fix(admin): the last-admin rule counts only admins who can sign in
Demoting an admin counted every admin, pending deletion included, so after
one admin deleted their account the other could step down and leave the
instance with no admin who can sign in or undo the deletion. Both the
demotion and an admin's own deletion now count the other admins not pending
deletion, inside the transaction that makes the change, under one roster
lock, so two at the same moment cannot each count the other.
2026-10-25 17:02:32 +01:00
Marc-André Bombeck 4137aa546e fix(account-deletion): one failing erasure no longer holds up the others
The hourly purge rethrew any error that was not the Guardian refusal, so the
oldest due account, failing on every run, stopped every later erasure and the
sweep of expired pre-wipe copies for good.

Each account is now its own try: a failure is counted, reported through the
worker error path with its first error, and told to the admins once per
request (user.account.erasure_failed), and the run goes on. The pre-wipe
sweep and the off-host kick run in a finally. Every account the run starts is
marked in backup_pass_attempts under the purge's queue name and the mark
closes only on erasure or when the account is no longer due, so an account
that failed or was refused goes behind every other due account next time.
The run stops starting accounts once its job budget is spent, since fifty
erasures of up to two minutes each did not fit the queue's expiry.
2026-10-25 16:56:57 +01:00
Marc-André Bombeck 933be858c5 fix(images): size a GIF by its largest frame, not only its logical screen
The header check before a thumbnail decode read only the GIF logical screen.
A decoder sizes its buffer from the frame's image descriptor, so a file with a
small screen and a huge frame passed the pixel cap and allocated the full
frame. Walk every block, read each image descriptor, size the image by the
largest of screen and frames, and refuse a file that ends inside a header or
descriptor, carries an unknown block, or has no frame.
2026-10-25 16:53:31 +01:00
Marc-André Bombeck 664b46d2c6 fix(db-backup): read the off-host leg of the dump on its own
A run that kept its dump on the volume and failed the upload is the newest
good dump and the newest failure at the same instant. Compared with ">", the
card showed no failure and readiness said fresh, night after night, while no
dump left the host.

The health read now carries the off-host leg separately: when the newest dump
reached the bucket, and the newest one that did not. One pure reading of it
(databaseDumpProblems) feeds both the backups card and the readiness model.
A configured bucket that missed the newest dump is attention (offhost_failed)
with the upload's error, and failing once the newest dump there is older than
36 hours (offhost_stale) or none ever arrived (offhost_never). The card shows
the refused upload and when the bucket last got a dump. A failure at the
instant of the newest dump for another reason, retention throwing, is now
shown too.
2026-10-25 16:53:24 +01:00
Marc-André Bombeck 0085915a56 Merge remote-tracking branch 'origin/release/v1.46.0' into fix/v146-move-safety 2026-10-25 16:49:06 +01:00
Marc-André Bombeck 4887ed8071 fix(db-backup): name the archive to pg_restore --list instead of streaming it
pg_restore reads the table of contents and exits; the read stream piped into
it stayed open on the dump. The long-lived worker checks one dump a night, so
every dump retention deleted stayed allocated until the worker restarted and
the backups volume filled. runTool no longer takes a stdin stream at all.
2026-10-25 16:47:26 +01:00
Marc-André Bombeck e8c96eea88 docs: say what deletion keeps and for how long, and drop stale claims
The reproductive-health page promised that deleting all data or the
account erases at once. Both wait for the grace window now, the nightly
dumps keep a record until they age out, and the old pgdata volume is a
full copy until it is removed; the page says so and how to make deletion
final. The README, the getting-started note on majors, the move guide and
the Unraid and Portainer templates catch up with Postgres 18, the backups
volume and the recovery kit. The demo seed's spread bound leaves room for
the seed's own one-in-150 spread.
2026-10-25 16:47:00 +01:00
Marc-André Bombeck 41f2f5ac6c fix(db): leave a Postgres 16 volume alone while the database runs on it
Going back to Postgres 16 after a move that stopped half-way left the
moving marker, and the next start emptied the live 16 database and
started a second server on its data directory. db-upgrade now waits for
the database service and does nothing unless it runs Postgres 18.

Using 16 again after a finished move and then updating started the app
on the move-day copy without a word. The marker now records the old
database's last checkpoint, and a later start refuses when it moved on;
the guide says how to move the current database instead. The disk check
leaves room for the first pre-migration dump, and the move check seeds
the old database with v1.45's own migrations.
2026-10-25 16:45:19 +01:00
Marc-André Bombeck 104bb35919 test(dr): read the no-volume start log from a file
grep -q stops at the first match, and with pipefail the SIGPIPE that then
ends docker logs read as a miss, so the drill failed on a line that was
there. The Dockerfile notes that the dump directory keeps nothing without
a mount.
2026-10-25 16:35:01 +01:00
Marc-André Bombeck 6b9b9a3f8d test(documents): type the index fixtures instead of casting them away
The resource-limit cases and two older mocks now pass typed values, so the
file carries fewer as-never casts than before; the outcome list's comment
is a line shorter to keep its file at its length. Both ratchet baselines
are written down, the source size one also for offhost-backup.ts, which
had shrunk without its number following.
2026-10-25 16:24:15 +01:00
Marc-André Bombeck 430468932c docs(ops): rollback list, how admins hear about backups, GlitchTip's place
The fourth rollback step had been joined onto the paragraph after the
list; it is a list item again, with the way back to the data after it.
The disaster recovery page now says how the server tells admins about
its copies: the recovery readiness card, the recovery kit and the hourly
Backup health notification. The support page put GlitchTip under Admin,
General; the card is under Admin, Integrations, as the deploy runbook
says. The pre-migration dump notes say that without the backups volume
no dump is taken, also where the image's own directory is writable.
2026-10-25 16:01:46 +01:00
Marc-André Bombeck c7040ccdaf docs(backup): a personal export is refused when it lists a document
The restore refuses a file that carries any document as metadata only
(findIncompleteDocument), whatever the account holds; the page said it
refused when the account held a document.
2026-10-25 16:00:49 +01:00
Marc-André Bombeck aebd7ea612 docs(deploy): describe the deploy webhook's signature and strict mode
The compose comment still called DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP a
requirement for a fresh timestamp header, which the route stopped
accepting in strict mode once requests could be signed, and the env
manifest named only the secret header and lacked the strict switch. Both
now say what the route checks: the secret header, which has no replay
protection, or X-Deploy-Webhook-Signature over the timestamp and body,
and that the strict setting takes signed requests only. The development
example says the same and points at the deploy runbook instead of a page
that no longer exists.
2026-10-25 16:00:35 +01:00
Marc-André Bombeck 877db9a5b7 docs(proxy): say TRUST_CF_CONNECTING_IP needs a Cloudflare-only origin
The flag makes the app believe CF-Connecting-IP from every connection,
before the peer check and whatever TRUST_PROXY_HOPS says, which is safe
only when nothing but Cloudflare can reach the origin. The reverse-proxy
page now says so with the ways to get there, the compose comment and both
example files carry the same condition, and the variable is in the env
manifest. The page's list of peers whose forwarding headers are read now
matches the code: loopback, 0.0.0.0/8, private, CGNAT, link-local,
unique-local and site-local, the reserved blocks and multicast. Those last
two stay trusted, since no internet host can connect from them; a test
pins every listed range and the Cloudflare precedence.
2026-10-25 16:00:35 +01:00
Marc-André Bombeck 8471e0edc9 fix(documents): say a PDF past the reader's limits is too large to read
A text read that crossed the isolated reader's memory or time bound came
back as a plain read error, which the index job records as decrypt-error,
so the document said its stored file could not be opened. The local
extractor now answers resource-limit for PdfResourceLimitError, the index
records it under that name, and the detail view says the PDF is too large
or complex to read here, in every locale. Like the thumbnail's refusal of
the same bytes it is final: nothing retries it on its own.
2026-10-25 16:00:35 +01:00
Marc-André Bombeck 66e92f36fe feat(recovery): judge the bucket's rule for database dumps in readiness
Only the backups page said when the bucket's lifecycle rules left
database/ out, so every dump, and every deleted account inside it, stayed
in the bucket forever without anyone being told. Readiness gains a
dump_retention signal that reads the same cached lifecycle probe: attention
when dumps go off-host and no rule covers database/, ok with the expiry
when one does, quiet when the bucket will not say, inactive without a
bucket or without off-host dumps. The hourly evaluation pushes it to the
admins like every other signal.
2026-10-25 16:00:35 +01:00
Marc-André Bombeck 351e7d1fe0 fix(backup): keep a dump locally only where it survives the container
The image creates DB_BACKUP_DIR owned by the app user, so an install that
runs it without the backups volume could write there, and every nightly and
pre-migration dump landed in the container's own layer. The next update
threw them away while the admin page, the boot line and the readiness card
called them kept on this server.

A dump directory now counts as kept only when it survives the container:
inside a container (Docker, Podman, Kubernetes, a container cgroup or an
overlay root) the directory must sit on a mount of its own that is not a
tmpfs, read from /proc/self/mountinfo with a device-number fallback;
outside one, a writable directory is a disk. Otherwise no dump is written
there: the nightly run uploads off-host only and records not_persistent,
the pre-migration dump is skipped with a warning, the boot line says the
directory is inside the container, the backups page stops promising local
dumps and ignores local-only ledger rows, and readiness fails with no
bucket and notes the bucket-only setup otherwise.

The Unraid template gains a path for the dumps, the Unraid and TrueNAS
pages say why it is needed, and the restore drill first runs the stack
without the volume and checks that nothing is kept or claimed.
2026-10-25 16:00:34 +01:00
Marc-André Bombeck fb4ab29aad Merge remote-tracking branch 'origin/release/v1.46.0' into feat/v146-grace 2026-10-25 15:28:23 +01:00
Marc-André Bombeck 1b39d93a8a docs(auth): say that credentials from before a deletion answer 401
The deletion revokes every session, refresh token and API token, so they
answer like any revoked credential; only new sign-ins get the 403
auth.account.deletion_pending, through the shared login response.
2026-10-25 15:21:10 +01:00
Marc-André Bombeck 9db5e755e6 Merge the release into the deletion grace work
The translation files merge key by key (no key changed on both sides), the
backups list carries both the database dump and the pre-wipe copy window,
and the backup-restore doc keeps the shorter rewrite plus the dump in the
bucket and a lifecycle rule that covers database/.
2026-10-25 15:18:46 +01:00
Marc-André Bombeck 8678a3edc4 test(recovery): keep the weekly-copy push test to its one signal
With the real dump health wired in, an empty test database also reports
a missing dump. The file follows the weekly copy alone; the dump signal
is judged in the readiness unit tests.
2026-10-25 15:16:14 +01:00
Marc-André Bombeck da9d941a24 Merge the release with the security fixes into the backup health work 2026-10-25 15:03:17 +01:00
Marc-André Bombeck 13e62bbb92 docs(coach): keep the labs block note to its old length 2026-10-25 14:50:05 +01:00
Marc-André Bombeck e88dcd2c53 Merge the release into the backup health work and read the real dump health
The readiness model now takes the database dump's state from
getDatabaseBackupHealth, and the dump test reaches the off-host key id
where it moved.
2026-10-25 14:40:31 +01:00
Marc-André Bombeck c25e9fa4e9 refactor: drop an unused test reset for the public-peer warning 2026-10-25 14:28:32 +01:00
Marc-André Bombeck d902125371 feat(account): tell admins about a refused erasure, and what undo leaves revoked
When the grace window ends for an account that has become the last
active guardian of a managed profile, the purge job refuses to erase it
and the account stays locked. Until now only the worker error log said
so. Every admin now gets one localized notification naming the account
and the ways out (another guardian, deleting the profile, or undoing
the deletion). An audit row on the account records that it was reported,
so the hourly retries stay quiet; a deletion undone and asked for again
is reported afresh.

The undo confirmation in the admin users list now says that sign-in
works again but API tokens, share links and trusted devices stay revoked
and have to be set up again (for a managed profile: its share links and
tokens).
2026-10-25 14:28:15 +01:00
Marc-André Bombeck 694befd635 fix(admin): say what actually ages a stored account copy out
The backups page stated "Retention: 90 days", from a constant whose
comment said it matched a backup-prune job. There is no such job: nothing
deletes a stored copy by age. The weekly copy is replaced in place, one
per account; the copy taken before "Delete all data" is deleted by the
account-deletion purge when the grace window ends; an uploaded copy stays
until its account is erased.

The list now carries preWipeCopyDays (the grace window, 0 when no such
copy is taken) instead of retentionDays, and the page says exactly the
three facts above, in every language.

Also drops a re-export of the JSON import row limit nothing imports.
2026-10-25 14:28:07 +01:00
Marc-André Bombeck 459b5baa9d fix(backup): read ECG traces a strip at a time
The shared backup reader kept documents without their stored bytes but
kept every ECG strip whole, trace included. A long watch history is
hundreds of strips of tens to hundreds of kilobytes each, so an upload,
preview or restore of such a copy held the whole history in memory.

The first read now keeps each strip without its trace (a short stand-in,
after the key it was written under is recorded and its form checked),
and forEachEcgTrace reads the traces again one strip at a time. The ECG
restore writes each strip from that second read, as the document restore
already did, and refuses a file whose two reads disagree on the count.

The upload memory test gains an ECG-heavy file: 150 strips, about 200 MB.
The old reader held 197 MB of it and failed the 48 MB bound; the new one
holds about 1 MB, also under a 524 MB heap.
2026-10-25 14:27:56 +01:00
Marc-André Bombeck 0a053d6884 feat(admin): name a switched-off dump and a newly listed kit key plainly
A switched-off scheduled dump is attention now, sent once and never
reminded, with how to turn it back on (DB_BACKUP_ENABLED). Dumps are on
by default, so off is a choice that leaves no copy able to rebuild the
host. An install that never wrote a dump and has nowhere to write one
stays quiet.

The out-of-date kit notice says what changed: the kit now also lists a
key the last confirmation did not cover, for example API_TOKEN_HMAC_KEY
on an instance upgraded from the one-key step, and that nothing broke.

The French kit and readiness strings use vous, the form the rest of the
French bundle uses.
2026-10-25 14:09:41 +01:00
Marc-André Bombeck e79ea08b13 Merge remote-tracking branch 'origin/release/v1.46.0' into fix/v146-security 2026-10-25 14:07:01 +01:00
Marc-André Bombeck 21aa791c3a 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
2026-10-25 14:05:23 +01:00
Marc-André Bombeck 2f19ca1f73 test(ratchet): write back the smaller sizes the fixes left
apiHandler's request wrapper fell under the 200-line mark, and the
reminder-check route and the Coach snapshot shrank by a line or two.
2026-10-25 13:52:04 +01:00
Marc-André Bombeck f7030feba7 docs(ops): say that the pre-upgrade volume keeps data erasure never reaches
The Postgres major-upgrade guide suggested removing the old pgdata volume
"when you are sure". That volume is a full copy of the database as it stood
before the move: account deletions, trash purges and key rotations after it
never reach it. The guide now says so and makes removing it part of the
move.
2026-10-25 13:52:04 +01:00
Marc-André Bombeck a20897b305 fix(monitoring): sign the GlitchTip relay in and strip Prisma call arguments
The browser error relay forwarded anything posted to it into the operator's
GlitchTip without a session, limited only per address, so anyone could
fill the incident list. Separately, a Prisma client error prints the
failed call into its message and stack, with the arguments object (a
reading's value, a note's text) when no source map is at hand, and the
server sent that to GlitchTip and the logs after a credential scrub that
does not look for it.

The relay now needs a signed-in session and is limited per account; errors
on pages before sign-in go unreported. The central redaction cuts a Prisma
call's printed arguments, keeping the call, a reason line that names a
field, and the stack frames, and the GlitchTip sender applies the
redaction to every event at the egress boundary, whichever caller built it.
2026-10-25 13:52:04 +01:00
Marc-André Bombeck f2a9b0fc0e fix(deploy-webhook): accept signed requests and stop calling a bare timestamp replay protection
The deploy-status webhook authenticated with a static secret in a header,
and the timestamp the docs called replay protection was covered by nothing:
whoever captures one request holds the secret and can send a fresh request
with any content. The strict setting, documented as closing the replay
window completely, only made that timestamp mandatory.

A sender can now sign instead: X-Deploy-Webhook-Signature is the
HMAC-SHA256 of the timestamp and the exact body under the shared secret,
with the timestamp within five minutes, and the secret never travels. The
strict setting accepts only signed requests. The secret header still works
by default for senders that cannot sign, and the docs describe its
timestamp as the staleness hint it is.
2026-10-25 13:52:04 +01:00
Marc-André Bombeck 807d483b89 fix(egress): check every redirect hop and close the reserved-range gaps
safeFetch with requirePublicHost vetted the first URL and pinned DNS
answers in the connector, but Node never looks up an IP literal, so a
followed redirect to a literal private or metadata address was dialled
unchecked. No caller follows redirects to a user-chosen host today; the
floor did not hold for the next one that would.

With requirePublicHost, redirects are now followed one hop at a time and
every location goes through isPublicUrl before it is dialled, at most five
hops. isPublicIp also refuses the benchmarking, protocol-assignment,
multicast, reserved and broadcast IPv4 ranges, site-local and multicast
IPv6 and the IPv4-translated spelling of a private address, and isPublicUrl
strips a trailing dot before the hostname denylist.
2026-10-25 13:52:04 +01:00
Marc-André Bombeck e021adde40 fix(notifications): limit quick answers per account, junk in a bucket of its own
The quick-answer route charged one per-address bucket before it read the
token. Where the client address cannot be read, every caller shares that
bucket, so two junk requests a minute switched off every person's
notification quick answers.

The token is an HMAC and is now checked first, which costs one hash and no
database read. A genuine token is limited per account; anything else is
limited per address in a separate bucket that genuine answers never draw
from.
2026-10-25 13:52:04 +01:00
Marc-André Bombeck cd96cb3e60 fix(proxy): keep API paths ending in a file extension behind the proxy
The proxy matcher left out every path ending in an image or text extension
so static files skip it. The exclusion was not anchored, so an API path
whose last segment the caller chooses (a custom tag name ending in .png)
skipped the proxy too, and with it the demo-mode write refusal, the
worker-only refusal and the security headers. Authentication was unaffected.

The extension exclusion now applies outside /api/ only.
2026-10-25 13:52:04 +01:00
Marc-André Bombeck 0039e112dd fix(mcp): fence medication and lab names in tool results
The MCP server instructions promise that the person's free text comes back
wrapped in USER_TEXT markers, but get_medication_schedule returned
medication names and doses as plain strings, and get_labs returned analyte,
panel and unit names that can come straight out of an uploaded document.
Text written to read like an instruction reached the connecting client's
model unmarked. The marker scrub also removed only the exact uppercase
spelling, so a lower-case or spaced marker survived inside fenced text.

Both tools now fence those fields, and the scrub removes a marker in any
case and with any spacing inside the brackets.
2026-10-25 13:52:04 +01:00
Marc-André Bombeck e3598985f5 fix(ingest): answer a scoped token the same for unknown and out-of-scope names
A token scoped to one medication got 404 for a name that matched none of
the owner's medications and 403 for one that matched outside its scope, so
it could confirm which medications exist by name. Its idempotency replay
also ran before the scope check and returned any of the owner's doses
stored under the key.

A token without the wildcard now gets the same 403 for both, the replay is
looked up only after the scope check, and a key already spent on another
medication answers 409 without that dose. The wildcard token keeps its 404.
2026-10-25 13:52:04 +01:00
Marc-André Bombeck 989381b3f6 fix(share): stop serving shared documents once the documents module is off
A share link gated its report on the doctorReport module but listed and
served its attached documents unconditionally, although the comment beside
the gate said the documents kept a gate of their own. An owner who switched
the documents module off still had every attached file decrypted for anyone
holding the link and its passphrase.

The share page lists no document while the module is off, and the document
route answers 404, the same as for an unknown document, before it looks the
membership up.
2026-10-25 13:52:04 +01:00
Marc-André Bombeck d1fcbca63b fix(mcp): name the host an authorization goes to on the consent page
Dynamic client registration is open by specification and the client names
itself, so anyone could register under a well-known assistant's name with a
callback on a host of their own and send a signed-in person the authorize
link. The consent page showed the borrowed name and "Unverified
application", never where the code would be sent.

Every consent page now says where the access goes: the callback's host, or
an application on this device for a loopback callback, and the Allow button
names the host. For a self-registered client whose callback leaves the
device, a warning that names the host leads the page, above the
self-reported name. Registration stays open: the hosted assistants register
this way with their own https callback.
2026-10-25 13:52:04 +01:00
Marc-André Bombeck 77ace106b3 fix(http): refuse state changes a browser sends from another site
The session cookie is SameSite=Lax, and SameSite compares the registrable
domain, so a page on a sibling subdomain of the instance could send a
CORS-simple POST with the person's cookie attached. safeJson accepted any
Content-Type containing "application/json", so a text/plain body that
mentions it in a parameter, which needs no preflight, was parsed as the
person's own request: readings added, entries deleted, integrations
disconnected, a delegate invited within the re-proof window.

apiHandler now refuses a POST, PUT, PATCH or DELETE before authentication
when the browser marks it Sec-Fetch-Site same-site or cross-site, or, from
a browser that sends no fetch metadata, when its Origin names a host other
than the one the request went to or APP_URL. A request with an
Authorization header, or with neither header (the phone app, scripts,
webhook senders), is unaffected, as are the webhook and URL-credential
routes that authenticate with their own secret. The JSON media type is
compared exactly, with parameters allowed, in safeJson and in every route
that branched on a substring of it.
2026-10-25 13:52:04 +01:00
Marc-André Bombeck fa50bcd1cb fix(ai): keep lab values out of every model prompt while Labs is off
The Coach snapshot attached its labs block regardless of the module, a
decision from before Labs could be switched off, while the snapshot's own
prelude says a disabled domain never reaches the model. With Labs off, the
Coach prompt, its get_labs tool, the daily briefing's flagged-marker block,
the biomarker status note, the reaction line to a new panel and the MCP
lab_trend_brief prompt all still read lab values for a model.

Each of those reads now asks the labs module first and reads nothing when it
is off: the Coach tool answers module_disabled, the briefing omits the
block, the status note neither writes nor serves text, the reaction line
gets no value, and the MCP prompt refuses like get_labs and the doctor-visit
prompt already did. The data routes stay as they are. A structural guard
holds every lab-table reader under the model-input paths to the gate or to
a listed reason its rows never reach a model.
2026-10-25 13:52:04 +01:00
Marc-André Bombeck 6fbfe71955 fix(auth): stop exempting a previously used address from the sign-in throttle
The per-account password throttle skipped every attempt that came from an
address one of the account's sessions or device logins had used in the last
thirty days. An address is the easiest thing about a request to share or
claim: in a household or an office the guesser has the owner's address, and
a direct caller could name it in a forwarding header. Either way the
throttle switched off and guessing was bounded only by the hash cost.

A remembered browser (the trusted-device cookie) and a phone presenting the
device id of one of the account's logins still pass; everything else gets
the growing wait.
2026-10-25 13:52:04 +01:00
Marc-André Bombeck d6d0f7e73f fix(http): read forwarding headers only from a connection a proxy could make
The client-IP resolver trusted one forwarding hop by default and read the
rightmost X-Forwarded-For entry. Next fills that header with the socket's
address only when the request carries none, so a caller reaching the
published port directly could send its own and choose a fresh address per
request: every per-IP limit (sign-in, second factor, passkey, share unlock,
webhooks) became per-request, and sessions, the audit log and sign-in
notices recorded the chosen address.

The server now writes each connection's own peer address into an internal
header from Node's http.server.request.start diagnostics channel, installed
at boot and overwriting anything a caller sent under that name. The resolver
reads forwarding headers only when that peer is loopback, private,
unique-local or CGNAT, where a reverse proxy sits; a public peer is its own
client address, and a log line says once that its forwarding headers were
ignored. With TRUST_PROXY_HOPS=0 the peer is used instead of no address, so
a directly exposed host no longer shares one bucket across all anonymous
callers. Proxies on the same host, Docker network or tailnet need no change.
2026-10-25 13:52:04 +01:00
Marc-André Bombeck ba22932101 fix(documents): read a JPEG's size past fill bytes and refuse an unreadable one
The thumbnail's header walk took the byte after any 0xFF as a marker. A
single 0xFF fill byte after the start-of-image marker, which the JPEG
standard allows and every decoder skips, threw it off; it read no size, and
an unread size let the image through to a decode that allocates the full
bitmap. A small JPEG declaring 20000 x 20000 pixels allocated 1.6 GB in the
web process before the post-decode check could see it.

The avatar upload already had a walker that skips fill bytes. Both now use
one reader (lib/image-dimensions) that follows the marker rules: fill bytes,
the length-less markers, and no size when a scan or the end of the image
comes before a frame header. The thumbnail treats an unreadable size as a
refusal (size-unreadable) for every format it decodes, and the backfill
counts that refusal as final.
2026-10-25 13:52:04 +01:00
Marc-André Bombeck 518ce2fd37 fix(documents): read and render PDFs in a bounded child process
pdf.js decodes an image at the size the file declares, whatever size the
page is drawn at, and the server opened documents without an image limit.
A small upload declaring a very large image made the web process allocate
gigabytes until the container was killed, for every user at once, and the
thumbnail job ran the same document again after each restart. An image
whose soft mask declares the size does the same past pdf.js' own limit, and
nothing bounded how long a text read could take.

pdf.js is now opened with maxImageSize (20 million pixels, which keeps one
image inside the reader's ceiling and admits a 300 dpi A3 scan), and every
read and render runs in a child process, one at a time: its V8 heap is
capped, its resident memory is sampled and the child killed past 640 MB, on
Linux it volunteers to the out-of-memory killer first, a time limit ends a
read that runs on, and it starts with an almost empty environment. The
loader and the rendering moved into pdf-engine.mjs, which the child runs
with plain Node and which is traced into the standalone image.

A document that crosses a bound answers resource-limit. The thumbnail job
records it as a final failure, so the backfill does not queue it again.
2026-10-25 13:52:04 +01:00
Marc-André Bombeck 0c5f29303b docs(backup): say what account copies and the JSON import can do
Several surfaces promised more than the per-account copies deliver:

- The full-backup export card said it had the same shape as the weekly
  copy. It is a portable export with documents as metadata only; it now
  says so, and that it cannot recreate an account on another server.
- The export page offered to "import from a backup". The JSON import adds
  measurements and mood entries; it is now named that way, and its help
  states the 16 MB and 10,000-entries-per-list limits from the constants
  the route enforces.
- The weekly copy is labelled an account rollback on this server, and the
  backups page says a copy cannot rebuild a lost server. The upload says
  the account must exist here. Two restore refusals pointed to a
  "disaster-recovery" copy; they now name the copies that exist.
- The instance-settings restore option was described as being for
  rebuilding a host.

docs/ops/backup-restore.md is rewritten short and accurate: what each
copy is, the deletion grace window, that database dumps keep a deleted
account until they age out, how to restore one account, and a link to
the disaster-recovery procedure for a lost server.
2026-10-25 13:51:15 +01:00
Marc-André Bombeck 63574b98a6 feat(account): show the deletion grace window and its undo
The deletion dialogs, the managed-profile delete dialog and the Data &
Privacy page read the window from the privacy summary and say what
happens: the account is locked at once, erased after the stated number
of days, and an admin of the server can undo it until then; a wipe keeps
a copy an admin can restore for the same time. Copies in the server's
backups age out after their retention period, and the text says that
too. With the window set to 0 they keep the immediate wording.

The sign-in page names the erasure date when a sign-in, a passkey or the
single sign-on callback is refused for an account pending deletion. The
admin users list shows a pending account with its date and an "Undo
deletion" action, and the backups list labels the copy taken before a
wipe. Every new string is translated in all nine languages.
2026-10-25 13:51:01 +01:00
Marc-André Bombeck a69666fa16 feat(account): hold account deletion and data wipe for a grace window
Deleting an account, a guardian deleting a managed profile, and "Delete
all data" were final the moment they were confirmed: the account row
cascaded into every table and every stored copy, and the off-host objects
were purged in the same breath. Nothing short of rolling the whole
instance back could return one account.

ACCOUNT_DELETION_GRACE_DAYS (14 by default, at most 30, 0 erases at once
as before) now sets a window:

- Account and managed-profile deletion lock the account (migration 0488:
  deletion_requested_at, deletion_purge_at, deletion_requested_by_id).
  Every session, refresh token, API token, connector, share link and
  step-up elevation is revoked in the same transaction, and the record,
  its stored copies and its off-host objects are left as they are.
- Sign-in is refused with 403 auth.account.deletion_pending and the
  erasure date, after the credential was proved and before a second
  factor is asked for. The session and Bearer resolvers and the acting-
  account resolver refuse a pending account too, so a credential minted
  in the moment before the lock is useless.
- POST /api/admin/users/{id}/undo-deletion (cookie-only, audit-logged)
  clears the lock. The admin users list reports the pending state.
- The hourly account-deletion-purge job erases accounts whose window has
  ended, exactly as the immediate deletion did, with the off-host purge
  request committed alongside. The Guardian invariant is decided again;
  a refused erasure stays pending and is reported.
- A wipe first stores the whole record as a PRE_WIPE_<ms> copy through
  the writer the weekly copy uses, keeps it out of the wipe, and holds
  its off-host purge back with a new not_before on the request. The
  ordinary restore brings the record back; restoring that copy withdraws
  the held purge. The purge job deletes the copy when its window ends.

Background work skips accounts pending deletion through one shared
predicate (src/lib/account-deletion/active-account.ts): every reminder,
digest, Coach, sync, backfill, rollup, score and backup enumeration
filters with it, and account-deletion-enumeration-guard.test.ts fails on
a query in background work that neither filters nor says why it need
not. Notification delivery and the AI capability loader refuse a pending
account as chokepoints behind it, and the Withings, WHOOP and Telegram
webhooks and the notification quick actions ignore one.

The privacy summary publishes the window. Two pieces of the reminder tick
and the off-host pass moved into modules of their own to keep the files
inside their size ratchet.
2026-10-25 13:50:14 +01:00
Marc-André Bombeck adc96fde3e Merge remote-tracking branch 'origin/release/v1.46.0' into feat/v146-dump 2026-10-25 13:49:51 +01:00
Marc-André Bombeck 726a73fcad refactor(admin): drop an unused kit type re-export 2026-10-25 13:49:22 +01:00
Marc-André Bombeck 4ffa3352bd ci: restore the drill from the bucket after the host is lost
The drill now runs an S3-compatible bucket beside the stack (Versity S3
Gateway v1.8.0, pinned by digest; the MinIO images are no longer published
on Docker Hub or quay.io) on a network and a volume of its own, so it
outlives the stack as a real bucket does. The app takes the dump through the
scheduled job and uploads it there; the drill waits for a run that both
kept and uploaded it, destroys every volume of the stack, the local backups
included, and restores on a new stack with only the bucket and the keys:
`docker compose run --rm db-restore latest --from-bucket`. The independent
row count reads the manifest from the bucket with the restore's own reader.

DRILL_SOURCE=local keeps the earlier path (dump files copied off the host,
then `db-restore latest`), and CI runs both. A new mutation,
wrong-backup-key, restores with a different BACKUP_ENCRYPTION_KEY and must
be refused. Ports are free ones picked at start.
2026-10-25 13:43:23 +01:00
Marc-André Bombeck 30a2ba2102 docs(ops): restore from the bucket after a host loss, and state backup retention
The new-server steps lead with `db-restore latest --from-bucket`, the copy
that survives when the host and its local dumps are gone, and keep the
restore from downloaded files beside it. Both backup pages now say that the
database dumps are not purged per account: a deleted account stays in them
until they age out, which is the retention period an operator states to the
people using the instance.
2026-10-25 13:43:23 +01:00
Marc-André Bombeck faf6dfbaef ci: wait for the published port in the database move check
The check failed once with "Can't reach database server at
127.0.0.1:55416": the container reported healthy before its published port
forwarded, and the migrate on the host ran in that gap. It now waits until
the port answers, retries a migrate that could not connect, both within
thirty seconds, and fails with Prisma's own output otherwise. The port is a
free one picked at start unless CHECK_DB_PORT names it, so the check cannot
collide with another stack on the same machine.
2026-10-25 13:43:23 +01:00
Marc-André Bombeck e825a4c4d3 feat(admin): say how long dumps keep deleted accounts, and check the bucket rule for database/
A database dump is the whole database at one moment, so an account deleted
since stays in the dumps taken before until they age out. The database row
on the backups card now says so with the numbers that decide it: how many
nightly dumps this server keeps, and after how many days the bucket's
lifecycle rule removes the off-host copies.

The lifecycle probe used to answer with the shortest expiry of any enabled
rule, whatever its prefix, so a rule scoped to the account copies read as
covering the dumps too. It now also answers for `database/` alone, counting
only rules whose filter takes in a dump's key (prefix, legacy prefix, an
And filter; a tag filter never matches untagged objects), and the card
warns when no rule covers that prefix: dumps there, and the deleted accounts
in them, would never be removed. The reading moved into its own module.
2026-10-25 13:43:23 +01:00
Marc-André Bombeck 82db3de30a feat(backup): restore a dump straight from the off-host bucket
A lost host takes its local dumps with it, and the copy left is the one in
the bucket. `docker compose run --rm db-restore latest --from-bucket` now
lists `database/` in the bucket the BACKUP_S3_* variables name, takes the
newest dump whose manifest is there too (or the one named), downloads both
with the S3 client the image already ships, and continues as for any
off-host copy: opened with BACKUP_ENCRYPTION_KEY, checked against the
manifest, restored into an empty database. `list --from-bucket` shows what
is there. The sealed download is removed once it is opened.

The image build now proves the S3 client resolves from the shared module's
place in the tree, so the restore cannot lose it unnoticed.
2026-10-25 13:43:23 +01:00
Marc-André Bombeck ed51211e82 docs(self-hosting): describe the recovery kit and the readiness push
The key backup sections become "Save the recovery kit": which keys the kit lists and why, the fingerprint of the token key, and the readiness card with its Backup health notification.
2026-10-25 13:32:43 +01:00
Marc-André Bombeck 7dab9cc5e9 feat(admin): judge every backup signal and push changes to admins
Every backup failure was a pull, on a page nobody opens until the day it
matters: the weekly copy stopped for 46 days and showed an ordinary row,
a failed monthly drill was on the failing-jobs card three days in thirty
(job-failures.ts reads 72 hours), and an off-host bucket that was never
set up showed only on the backups page.

One readiness model (src/lib/recovery/readiness-model.ts) judges six
signals as ok, attention or failing, with a reason code, the time it is
about and where to fix it:

- database dump: failing once the newest good dump is older than 36 h
  while dumps are on, or when it can be kept nowhere; read through a
  provider that reports "not available" until the dump lands;
- weekly account copies: worst account decides, attention past 9 days,
  failing past 14; an account without a copy ages from its creation;
- off-host copies: attention when any account is two nights behind;
- restore drill: failing until a later drill passes, with no time
  window; the drill now keeps its verdict in restore_drill_outcomes;
- off-host setup: attention 7 days after the first admin exists;
- recovery kit: attention when a needed key is not confirmed, by id,
  7 days after setup or as soon as a confirmed kit is out of date.

A "Recovery readiness" card shows them on the admin overview and the
backups page. An hourly job (recovery-readiness, :33) sends each change
to every admin through the localised admin path, under a new
admin-only BACKUP_HEALTH event that the channel preferences route: once
when a signal turns to attention or failing, once a day while it stays
failing, once when it recovers. Each notice is claimed with a
compare-and-set before it is sent, so two workers never send the same
one, and nothing is claimed while no admin exists.

The backups page calls the weekly copy stale at the same instant as the
model (past nine days rather than past ten).
2026-10-25 13:32:43 +01:00
Marc-André Bombeck d3c672cc3f feat(admin): replace the key backup step with a recovery kit
The step confirmed one key, the active application key. A restore needs
more: off-host copies are sealed under BACKUP_ENCRYPTION_KEY, a copy
written before a rotation needs the retired application key it was
written under, and every session, token and share link hangs off
API_TOKEN_HMAC_KEY. An operator could do everything the step asked and
still be unable to read a single backup.

The recovery kit lists every key a restore of this server needs, by id
and fingerprint, never key material:

- each id in ENCRYPTION_KEYS, plus any retired id a stored copy
  (innerKeyIds) or an off-host object inside the bucket's retention
  (OffhostBackupKeyUse) still lists, also once the server dropped it;
- BACKUP_ENCRYPTION_KEY and each BACKUP_ENCRYPTION_PREVIOUS_KEYS entry,
  named by the id their objects record;
- API_TOKEN_HMAC_KEY.

Confirming records the set the admin was shown and refuses a stale one
with 409 recovery.kit.stale. The kit is due again as soon as a key it
lists is not in that set (a rotation, a new backup key, a re-keyed
install); a key that merely drops out does not make it due. "Check my
copy" now checks any key in the kit.

Migration 0487 carries a confirmation given under the old step forward
as a kit holding that key, so that part stays confirmed, and drops the
four app_settings columns. It also creates the two tables the readiness
evaluation uses (restore_drill_outcomes, recovery_readiness_notices).

The routes move to /api/admin/recovery-kit; the reader of the keys the
backups still need moves out of the encryption status route into
src/lib/recovery/backup-key-needs.ts so the kit and the status view
share one definition.
2026-10-25 13:32:29 +01:00
Marc-André Bombeck 1a4289545e ci: rebuild a lost server from its dump in a compose drill
scripts/compose-dr-drill.sh plays docs/ops/disaster-recovery.md against
real containers: it builds the image from the checkout, starts the bundled
compose stack with fixed throwaway keys, seeds the demo account and a
Guardian with a managed profile (through the app's own code), writes a
reading with an encrypted note through the API, and waits for the
scheduled database-backup job to take the dump. It then keeps only the
dump files, destroys every volume, starts a new stack, restores with
`docker compose run --rm db-restore latest`, counts every table against
the manifest apart from the restore, and boots the app: health, the demo
login, the note read back, a document opened, the Guardian still seeing
the profile.

DRILL_MUTATION breaks one step on purpose (wrong-key, skip-table,
non-empty-target), and the drill fails on each. The workflow runs it on
pull requests that touch the compose file, the image, the entrypoint or
the dump and restore code, nightly, and by hand.
2026-10-25 13:05:47 +01:00
Marc-André Bombeck ca644647a0 docs(ops): describe disaster recovery from the database dump
docs/ops/disaster-recovery.md says what is dumped and where, that local
dumps are unencrypted and why, which keys a restore needs and what each
one costs when missing, how to restore onto a new server or back to the
state before a migration, and how the procedure is tested. The deploy
runbook and the getting-started guide now describe the dump the
entrypoint takes before migrating, and the off-host account copy page
points at the dump for rebuilding a server.
2026-10-25 12:56:33 +01:00
Marc-André Bombeck 3646502a86 feat(admin): show the newest database dump on the backups page
GET /api/admin/backups now carries `database`: the newest good dump (when,
how big, kept on the server, off-host or both), the newest failed run,
whether this server can write its backup directory, and whether off-host
copies are configured. The backups card leads with one row built from it,
with a notice when the last run failed and when dumps can only go
off-host or cannot be taken at all. The route stays an unpublished admin
console route, so the OpenAPI document does not change.
2026-10-25 12:56:33 +01:00
Marc-André Bombeck e665f17f52 feat(backup): dump before migrating, and restore a dump into an empty database
The image now carries postgresql18-client (pg_dump and pg_restore 18.6)
and a /var/lib/healthlog/backups directory owned by the app user, which
the bundled compose file backs with a new `backups` volume for `app` and
the commented `app-worker`; a fresh named volume inherits that owner.

Before `migrate deploy`, when migrations are pending on a database that
has applied some, the entrypoint writes healthlog-<time>-pre-migration.dump
with its manifest and keeps the last two. A failed dump on a writable
directory stops the boot and names DB_BACKUP_BEFORE_MIGRATE=false; without
a writable directory it warns and continues.

`docker compose run --rm db-restore <file|latest|list>` runs
scripts/db-restore.mjs in the app image with plain node. It opens an
off-host .dump.enc with BACKUP_ENCRYPTION_KEY (or a previous key), checks
the archive against the manifest's SHA-256, compares the keys in the
environment with the manifest's fingerprints and refuses on a different
active encryption key unless --ignore-key-mismatch, refuses a database
that holds any table, restores in one transaction, runs ANALYZE, and
compares every table's row count with the manifest, emptying the database
again on a difference.

DB_BACKUP_ENABLED, DB_BACKUP_DIR, DB_BACKUP_SCHEDULE, DB_BACKUP_KEEP and
DB_BACKUP_BEFORE_MIGRATE join the compose whitelist, both example files
and the env manifest.
2026-10-25 12:56:33 +01:00
Marc-André Bombeck 6d3a1582fa feat(backup): take a nightly whole-database dump with a manifest
The per-account copies roll one account back on the same server; nothing
the app made could rebuild a lost one. A scheduled job now runs pg_dump
over the whole database (every schema, pg-boss included) into
DB_BACKUP_DIR as healthlog-<UTC time>-daily.dump, with a manifest beside
it: app version and build, newest migration, Postgres version, size and
SHA-256, exact row counts of every table, and the fingerprints of the
keys the database needs.

The counts read the same exported snapshot pg_dump reads, so a dump
taken while the app writes still matches its manifest. The archive is
written as .partial, listed back with pg_restore --list, and renamed
after its manifest; a failed run leaves nothing named like a good dump.
Retention removes only files this job wrote, oldest first, and never
the newest good one (DB_BACKUP_KEEP, default 7; two pre-migration dumps).

With the off-host bucket configured, each dump and its manifest go up
under database/ in the envelope the account copies use, streamed. Every
run lands in the new database_backup_runs ledger (migration 0486), and
getDatabaseBackupHealth() answers the last good dump, the last failure,
whether the directory is writable and whether off-host is configured.
The boot readiness summary says where dumps go, or that none can be kept.

The dump, naming, retention and restore rules live in one plain ESM
module so the image's own scripts can share them without tsx.
2026-10-25 12:56:33 +01:00
Marc-André Bombeck 2440d740a9 Merge remote-tracking branch 'origin/release/v1.46.0' into fix/pr1284-labs-schema 2026-10-25 12:40:19 +01:00
Marc-André Bombeck a895f16e08 Merge #1284: declare the labs availability metadata in get_labs
The tool module was split into one file per tool on this branch, so the
change lands in tools/get-labs.ts, and the advertised tool list snapshot
gains the two declared fields.
2026-10-25 12:32:41 +01:00
Usama Muhammad 93fb0f68d8 fix(mcp): declare labs availability metadata
(cherry picked from commit c1a5861fdcbfe374fcbb5362eed3717a5bd9b15d)
2026-10-25 22:29:12 +11:00
Marc-André Bombeck ec8191d18f Merge remote-tracking branch 'origin/release/v1.46.0' into fix/v146-doc-findings 2026-10-25 12:26:24 +01:00
Marc-André Bombeck 41c6eea2a1 fix(backup): stream every large section of an uploaded backup
The admin upload streamed only the measurements while it checked a file,
so a document-heavy account copy was held whole in memory and an upload of
a few hundred megabytes could take the process down.

The sections a reader takes an element at a time are now named once
(BACKUP_STREAMED_SECTIONS in streamed-backup.ts): measurements, intake
events, mood entries, documents and ECG recordings. The upload reads its
file through readStreamedBackup like the restore and the preview, and the
restore drill and the restore script use the same key set.

A behavioural test pushes a 271 MB document-heavy file through the real
upload route and fails when the heap grows with the file. The stream guard
now asserts that every reader goes through the shared reader or key set
and that no file declares its own.
2026-10-25 12:25:15 +01:00
Marc-André Bombeck a641a615c3 docs: point the Strava and Dawarich notes at their site guides
Both files had drifted copies of the docs-site guides, which carry the same
sections and more, and nothing in the app, the scripts, the other docs or
the site links to them. Each is now a short pointer to its guide.
2026-10-25 12:12:15 +01:00
Marc-André Bombeck 12f8265fdb docs: correct claims and anchors the code no longer backs
Fix two dead anchors (the MCP trust model, the README quick start) and the
statements that no longer match the app: a guardian can connect a managed
profile's services, the environment module is on by default and reaches the
air-quality host too, its readable location columns were dropped in 0409,
settings sections carry their current names, the Withings card lives on
/settings/integrations, the TrueNAS start period matches the image, the
deploy check names HEALTHLOG_PROD_URL, and the docs index describes what
self-hosting/ and integrations/ hold.
2026-10-25 12:12:15 +01:00
Marc-André Bombeck f85035a8ec docs(coach): say what the labs block reads since it covers the whole record
The labs block reads the whole record, or up to the lookback limit, not just
recent readings, and a named read also comes back empty when no marker
matches. The header of the labs snapshot and the note at its call site
still described the earlier twelve-month window.
2026-10-25 12:12:15 +01:00
Marc-André Bombeck 72d90282d5 fix(settings): open the docs pages themselves, not their old addresses
The integration runbooks, the Garmin note, the guardian note on a managed
record's connections, the backups admin link and the README's self-hosting
link all pointed at retired docs paths that only resolve through redirects
(or not at all, on a host without them). They now go to the final guide
pages, and the settings tests assert the new addresses.
2026-10-25 12:12:15 +01:00
Marc-André Bombeck a57d2ffe83 test(fhir): pin the search parameters the CapabilityStatement declares
The statement already declares `date` and `code` on Observation beside
paging on every search; the route test now asserts that list per resource,
so a parameter the search honours cannot drop out of it unnoticed.
2026-10-25 12:12:14 +01:00
Marc-André Bombeck d6bb7bc905 docs(api): describe the legacy integration-status fields as still served
The published contract said the nine health-shaped fields that predate
`health` were removed in v1.44, but every status read still sends them:
older native builds decode them, the current one falls back to them against
a server without `health`, and the web card's copied diagnostics read the
failure streak. They stay deprecated, without a removal release.
2026-10-25 12:12:14 +01:00
Marc-André Bombeck 856eb036f8 fix(documents): point the on-device scan hint at the Labs switch
The hint shown when a text-only provider needs on-device text recognition
sent people to the AI settings, where no such switch exists. It now names
the switch by its label under Settings → Modules → Labs in every locale and
links straight to that card. Inside somebody else's record the link is
withheld, since Settings there is the caller's own and closed; the sentence
stays.
2026-10-25 12:12:14 +01:00
Marc-André Bombeck 953c64f7a4 Merge remote-tracking branch 'origin/release/v1.46.0' into fix/demo-bp-story 2026-10-25 11:49:03 +01:00
Marc-André Bombeck 28340e7c66 fix(coach): restore every step a turn records on reload
The turn records up to 64 steps while the read path kept only the first
48, so the tail of a long answer's step list vanished on reload. Both ends
now read one MAX_TURN_STEPS constant, and a unit plus an integration test
round-trip a full 64-step answer through the stored provenance.
2026-10-25 11:42:37 +01:00
Marc-André Bombeck d839fbdb11 fix(demo): lift the busy-stretch blood pressure far enough to read
The blood-pressure chart scales one axis to fit systolic and diastolic, so
the 12 mmHg the busy stretch added came out as a nearly flat line on a 60 to
140 axis, even with the 7-day trend on.

The stretch now climbs over two days to about 144/91, holds for some ten
days, and comes back down over the following days after ramipril goes from
5 to 10 mg. A dose change and a family-practice visit on the day it turns
carry that step, and the medication now reads 10 mg. The day-to-day walk is
quieter, with a little reading scatter on top, so the 7-day mean draws an
arc rather than jitter.

Two limits keep the rest of the page honest. The lift stops short of
149 mmHg: one more and the padded axis rounds out to 50 to 200 and flattens
the rise again. And the stretch stays well under half of the last 30 days:
the usual range is the median ± 3 MAD of that window, and a longer stretch
moved the median onto it and widened the band to about 87 to 181 mmHg.

The stored texts that describe the last weeks mention the stretch and the
dose increase. The seed-shape test holds the larger swing, the ceiling, the
smaller day-to-day steps, the median of the window and the dose change on
the turn.
2026-10-25 11:40:04 +01:00
Marc-André Bombeck 27c8533f40 docs: describe the Postgres 18 move and the versions HealthLog runs on 2026-10-25 11:18:14 +01:00
Marc-André Bombeck d4d91994e8 feat(db): move the bundled database to Postgres 18
The compose file runs Postgres 18. An install from v1.45 or earlier keeps
its Postgres 16 data in `pgdata`, which 18 cannot open, so a one-shot
`db-upgrade` service moves it on the first start: it starts Postgres 16 on
the old volume inside its own container, streams a dump into 18 in one
transaction, compares every table's row count, gathers planner statistics
and marks the old volume as moved. The app waits for it to succeed.

The old volume is never changed apart from the marker, so the way back is
the previous image and volume lines. A move cut short leaves a "moving"
marker and runs again from scratch; an emptied new volume after a finished
move is refused rather than refilled with the old state.

scripts/compose-db-upgrade-check.sh plays all of it against real
containers from the v1.45.0 compose file, and runs in CI on every change
to the compose file. Pull requests run the integration suite and e2e on
Postgres 18; the nightly integration run keeps 16 covered for installs
that bring their own database.
2026-10-25 11:18:14 +01:00
Marc-André Bombeck 6b3c1cc433 docs(ops): gather planner statistics after restoring into the new major 2026-10-25 10:54:50 +01:00
Marc-André Bombeck b597627a39 test(mental-health): press an answer once the step's question holds the focus
The keys followed each other without waiting for the step to move the focus,
so on a slow run Tab started from the previous answer and the number chose
nothing.
2026-10-25 09:24:19 +01:00
Marc-André Bombeck 4853e9cee6 fix(mental-health): focus the first question once its wording has loaded
Each step focuses its question, but the first step's question only exists
once the language's wording chunk has arrived; the focus ran while the step
still showed its loading state and was never moved again, so a keyboard or
screen reader user started on the page body.
2026-10-25 09:24:19 +01:00
Marc-André Bombeck 9ff35efb47 test(e2e): measure the Coach button's clearance once the page has settled
Scrolling to the end can start a section's load, and its skeleton sat under
the button for a moment on the Insights page in landscape. The check now
scrolls to the end and measures again until the last line is in place;
content that stays under the button still fails it.
2026-10-25 08:45:25 +01:00
Marc-André Bombeck eadf4c0f2b docs(ops): stop a separate worker container before the Postgres dump too 2026-10-25 08:04:28 +01:00
Marc-André Bombeck 78bc7183bd fix(migrations): leave an index another instance is still building alone
A concurrent build that is still running is INVALID with its migration not
yet finished, exactly like a failed one. With the web and worker roles in
two containers, the second boot dropped the index the first was building,
which cancelled the first one's migration and left it failed until someone
resolved it by hand.

The check and the drop now run in one transaction that first takes Prisma's
migration lock without waiting and does nothing when another instance holds
it; an index Postgres reports as being built is never dropped. One
transaction also keeps lock_timeout on the statement it bounds behind a
transaction pooler, and the applied-migrations lookup quotes the schema, so
a mixed-case schema no longer reads as having none applied.
2026-10-25 08:04:27 +01:00
Marc-André Bombeck 1ddd072a08 test(series): settle a same-rank, same-instant tie in the BP pick parity
Two sources off every ladder now read at the very same instant on thirty
days: on fifteen the name decides, on the other fifteen the first name's
systolic is under the floor and the second takes the day. A dedicated
case asserts which source each of those days shows, under a UTC and a
Berlin session; the parity cases compare them with the reference as
well. Dropping the name from the window's order turns 26 of 59 cases
red on Postgres 16 and 18.
2026-10-25 07:16:44 +01:00
Marc-André Bombeck 6982f60f4d test(series): seed the unanalysed guard's blood pressure at a fixed hour
The guard seeded each day's pair six hours before the clock, with the
second source ten minutes later. Run between 05:50 and 06:00 UTC, the
newest day's second pair landed just past midnight, alone on a UTC day
of its own; the pick rightly chose that source for that day, the old
DISTINCT ON statement chose the same, and the assertion that every
series row is the first source failed for those ten minutes of the day,
on every Postgres major.

The pairs now sit at 10:00 and 10:10 UTC from yesterday back, whatever
the clock. With the clock pinned to 05:55 UTC the old fixture fails the
three series shapes and the new one passes, on 16 and 18 alike.
2026-10-25 07:16:44 +01:00
Marc-André Bombeck ca77d507f6 test(rollups): compare migration 0372's untouched columns right after it ran
A later case in the file folds the rollups again, and a fold sums in the
order its plan reads, so the second fold's sums could differ from the
first's in the last bit; the comparison read the table after that fold.
2026-10-25 06:51:51 +01:00
Marc-André Bombeck f66e7e9a93 test(backup): start the follow-up run strictly after the retry's last write
An account counts as done for a run when it succeeded at or after the run's
start, and the follow-up run's start taken with new Date() could share the
millisecond of the retry's last ledger write.
2026-10-25 06:43:51 +01:00
Marc-André Bombeck 8aab59cdc1 Merge branch 'fix/analytics-live-regression' into fix/review-meds-docs 2026-10-25 06:28:23 +01:00
Marc-André Bombeck 4e79098cc0 perf(measurements): fold per-source days without joining a regrouping back
Three readers grouped the same rows twice and joined the second grouping
back to the first: the live buckets joined each hourly-mean day's hour
means to the per-source days, the all-time extremes joined the day
weights the same way, and the doctor report's dense buckets joined each
bucket's latest reading and hour means to the bucket aggregates. On a
measurements table without planner statistics (a new install, or an
import under the autoanalyze threshold) the planner estimated one row on
each side and ran every one of those joins as a nested loop that re-ran
the inner grouping for each outer row.

On ten years of readings over six types (51,000 rows, no statistics) the
dense buckets and the every-type live buckets ran into the 60 s statement
timeout and the all-time extremes took 11 s. With statistics all three
answered in 0.1 to 0.2 s.

Each is now one chain of folds over one scan. The live buckets fold the
readings per local hour and the hours per local day, carrying the day of
hours beside the counts. The all-time extremes take the day weights from
the same windowed rows they count, the shape the pre-fold remainder
already has. The dense buckets carry each bucket's latest reading and each
hour's count and mean on the rows as window values, and one grouping folds
them all. Same fixture without statistics: 0.2 s, 0.1 s and 0.1 s, the same
as with them.

A parity suite runs the joined statements as the reference against the
readers across ladders, zones with whole, half-hour and negative offsets,
grains, overlapping and off-ladder sources, glucose contexts and device
types, tombstones and both DST changes; sums and means agree to
floating-point summation order, everything else exactly. The
unanalysed-table guard now runs the three readers under EXPLAIN ANALYZE:
the joins measured 200 to 3,000 row visits per row stored, the folds about
10.

The dense-bucket loader drops under the 200-line function cap, so its
baseline entry goes.
2026-10-25 06:27:44 +01:00
Marc-André Bombeck 6d338760d8 perf(series): pick the blood-pressure source with one window over one scan
The blood-pressure series chose each UTC day's source with DISTINCT ON
over the day's floor-passing systolic rows and joined the choices back
to the readings. That is the shape the analytics pick had, with the
same weakness: on a measurements table without planner statistics the
planner estimated one row on each side and ran the join as a nested
loop that rescanned the pick for every reading.

The ten-year series over 6,000 freshly written rows from one source
took 0.57 s for the statement alone, 0.86 s with a second source on
every third day; the plan visited about 4,900 rows per row stored, so
the cost grows with the square of the history.

The pick is now one window over one scan: the day's rows ordered
systolic first, then by ladder rank, then by source name, and the first
row's source taken only when it is a systolic one. The scan starts at
the window's first UTC day, so the pick still sees the whole edge day,
and a day without a floor-passing systolic still shows nothing. Both
fixtures above read in about 10 ms, with or without statistics.

A parity suite runs the old statement as the reference against the new
one across overlapping and off-ladder sources, half readings, sub-floor
rows, tombstones, both DST changes, window starts on and either side of
UTC midnight, and four session zones. The unanalysed-table guard now
covers the series' ten-year, quarter and week reads.
2026-10-25 05:57:31 +01:00
Marc-André Bombeck 2b8eb2c1c2 perf(analytics): pick the canonical source with one window over one scan
The canonical-source pick grouped the window to (type, day, source),
chose each day's source with DISTINCT ON, and joined the choices back to
the rows. Whether that join ran as a hash join or as a nested loop that
rescanned the whole pick for every row was the planner's call, made from
table statistics. On a measurements table without them (a new install,
or an import still under the autoanalyze threshold) it estimated one row
on each side and took the nested loop.

Binding the request's clock as a parameter instead of reading NOW()
flipped which side of that loop the pick landed on: the analytics read
over 6,000 freshly written blood-pressure rows went from 1.6 s to 8.4 s,
and the demo seed's dashboard headline to 20 s. On a ten-year account
without statistics the 90-day read took 4 s with either clock and the
all-time read ran past two minutes.

The pick is now one window function over one scan: FIRST_VALUE of the
source per (type, UTC day) in ladder order, keeping the rows of that
source. Same rows, same order of preference, so every day keeps its
source; the parity suite against the DISTINCT ON statement holds that.
The analytics read on the fixture above takes about 0.1 s, the headline
0.4 s. On the analysed ten-year account the 90-day and all-time reads
are as fast as the join was, and they no longer depend on statistics.

The rows carry one extra column, canonical_source; every reader selects
its columns by name.

A new integration test runs every reader shape under EXPLAIN ANALYZE on
a table with no statistics and bounds the rows the plan produces or
discards per row seeded. The statement count never moved, so a statement
budget could not see this; the join measured 200 to 8,600 row visits
per row seeded and the window stays at 4.
2026-10-25 05:46:08 +01:00
Marc-André Bombeck 4d4a83fb7d chore(bundle): measure the Recently deleted page's first load
The page is new in this release and had no baseline, which the budget check
refuses; 290.8 KB gz as the CI build measured it.
2026-10-25 05:03:54 +01:00
Marc-André Bombeck 240a135b88 refactor(ai): keep one name for one completion's ceiling
A status run is one completion, so its ceiling was the completion ceiling
under a second name. The Local client also re-exported a JSON-mode helper
nobody imported from it.
2026-10-25 04:50:25 +01:00
Marc-André Bombeck 8fee138256 fix(backup): type the alias order as Prisma's mutable order list 2026-10-25 03:35:14 +01:00
Marc-André Bombeck d07f7f5bbf Merge branch 'docs/postgres-major-upgrade' into fix/review-meds-docs 2026-10-25 03:19:55 +01:00
Marc-André Bombeck 2556da645b test(jobs): wait for the expired import's retry instead of a fixed window
The retry is fetched about a second and a half after the overrun ends, later
on a loaded machine, and the twelve-second window turned that lateness into a
red run. Both cases now wait for the state they assert, bounded by a
deadline, and read the call count once the retry's own run has finished.
2026-10-25 03:19:54 +01:00
Marc-André Bombeck ffd06ce289 docs(ops): describe the move to a newer Postgres major
The compose file stays on 16. Changing only the image line stops the stack:
the 18 image refuses a data directory from an older major and expects its
volume one level up. The runbook walks the dump and restore into a fresh
volume, keeps the old one as the way back, and is linked from the update
section and the compose pin.
2026-10-25 03:06:37 +01:00
Marc-André Bombeck 84064ea045 Merge branch 'fix/bound-sync-lab-reads' into fix/review-meds-docs 2026-10-25 03:02:57 +01:00
Marc-André Bombeck 698ed87c22 test(parity): a reading or writing delegate is not offered End
End needs MANAGE over medications, so the course flag now reads false for a
delegate who may only read or write.
2026-10-25 03:02:57 +01:00
Marc-André Bombeck 3853ce7f1a fix(backup): order the source aliases a deleted document's keys are read from
The backup export is totally ordered so two exports of the same account are
byte-identical; the alias relation the import-key ledger reads was the one
nested read without an order.
2026-10-25 03:02:56 +01:00
Marc-André Bombeck fd299cad61 fix(labs): plan every report read against a schedule
The lab scan runs only in the background worker, which hands it the
run's deadline, but its body still took the schedule as optional and
planned a missing one without a bound: a twenty-page report as forty
model calls. Nothing calls it that way today; the signature allowed the
next caller to, and so did every read planner beneath it.

The scan and the planners (maxReadsFor, planTextReads, planVisionReads,
planInboundTextReads, planInboundReads) now require a schedule. A run
with room for every read says so with an unbounded one; a request uses
SYNCHRONOUS_READ_SCHEDULE. No route, wire or response changes.
2026-10-25 02:58:35 +01:00
Marc-André Bombeck 474fc08649 fix(documents): bound the lab staging an index request continues into
The index route without Prefer: respond-async, which the iPhone app
calls, continues into the lab staging with no run schedule. The staging
then planned a long report's text as up to twenty reads with a retry
each: forty model calls inside one HTTP request, the same class the
extract route was bounded against.

The staging now plans a missing schedule as one request does
(SYNCHRONOUS_READ_SCHEDULE, at most three reads): a longer report has
its text shared out between them, and what does not fit is left out and
reported as extractionIncomplete on the document. The request bound
moves into ocr-plan beside the schedule it bounds, so the extract run
and the staging share one definition. Background runs and the automatic
index job keep planning against their own deadline.
2026-10-25 02:52:10 +01:00
Marc-André Bombeck 2b27b4b501 chore(ratchet): keep the version-stamp baseline in the order the writer emits 2026-10-25 02:45:31 +01:00
Marc-André Bombeck 048407756f Merge branch 'chore/pg18-check' into fix/review-meds-docs 2026-10-25 02:45:24 +01:00
Marc-André Bombeck d6c2760415 Merge remote-tracking branch 'origin/release/v1.46.0' into fix/review-meds-docs
# Conflicts:
#	src/__tests__/baselines/source-function-lines.json
#	src/__tests__/baselines/version-stamp-comments.json
#	src/__tests__/sharing-surface-guard.test.ts
2026-10-25 02:45:23 +01:00
Marc-André Bombeck 4390614463 fix(migrations): rebuild an index a failed concurrent build left invalid
A CREATE INDEX CONCURRENTLY that fails half-way leaves an INVALID index,
and the migration re-run after migrate resolve --rolled-back finds it
through IF NOT EXISTS and does nothing: recorded applied, with an index
the planner never uses. 0477 has this shape.

The boot now drops an INVALID index that a not-yet-applied migration
builds concurrently before it runs migrate deploy, with a bounded lock
wait, so the re-run builds it again. An INVALID index whose migration is
recorded applied is named on stderr with the rebuild that mends it. The
deploy runbook says both.
2026-10-25 02:42:09 +01:00
Marc-André Bombeck b2c99e5c8d fix(migrations): bound the lock wait of 0475 and 0480, and of every file after
0475 and 0480 added their columns with a bare ALTER TABLE, which waits
for its table lock behind every open transaction while every write to
the table queues behind it. Both now run in a DO block with a five
second lock_timeout and carry the boot's retry marker; the effect is
the same and both stay safe to run again.

Neither has shipped in a release. A database that already applied them
is unaffected: prisma migrate deploy and migrate status ignore the
checksum of an applied migration (checked against a database migrated
with the old files), so nothing is re-run and nothing is reported.

The lock-timeout guard now refuses, from 0475 on, any statement that
takes a table lock outside such a block.
2026-10-25 02:42:09 +01:00
Marc-André Bombeck 205d8f257c fix(backup): carry the import ledger, and the keys of deleted imports
The backup carried neither the import ledger nor the tombstones of
deleted documents, so after a restore onto a fresh instance a token
importer stored every imported document the person had deleted on
purpose again.

The document filing section now carries documentImportKeys: the
ledger's rows plus the own and alias keys of the documents waiting in
Recently deleted, which still do not travel as documents. The restore
writes them back, minus any key a restored live document holds, and
counts them in the restore summary and the preview. DocumentImportKey
moves from the excluded models to the two-ended ones, and the round
trip, the column audit and the parity goldens carry a ledger row.
2026-10-25 02:42:09 +01:00
Marc-André Bombeck 988af10a34 fix(documents): keep module-off accounts from filling the index retry boot
The boot discovery for the documents 0479 marked took the first thousand
by id and skipped those whose account has the documents module off.
Such an account keeps its marks, so with enough of them it held every
place of every boot and the accounts after it never got their retry.

The marks are walked by id past the last row read, an account found
with the module off is left out of the query from then on, and the
boot stops at a thousand documents enqueued rather than read.

Refs #1277
2026-10-25 02:14:17 +01:00
Marc-André Bombeck ea3bdf42b7 fix(documents): settle an index backfill read once and ask the wire before each
The index-all backfill billed the read and wrote the index in one try:
an index write that failed after the read was settled again in the
catch, handing the reservation back twice. The read is settled once,
and a failed write charges nothing back.

The backfill also checked consent and the operator's switches once, at
the pick, then read up to two hundred documents. The wire is asked
again before every read; a refusal hands that document's reservation
back and ends the walk with no-consent or unavailable.
2026-10-25 02:10:17 +01:00
Marc-André Bombeck da4056ce05 fix(documents): key an undated fact on its report's date in the merge
The extraction merged facts across reads on a key whose only date was
the fact's own effective date. Facts without one collapsed across reads
of different reports, so the same value on two days staged once. An
undated fact is now keyed on its read's report date, falling back to
the document's, as the lab scan keys a row.

Refs #1257
2026-10-25 02:03:27 +01:00
Marc-André Bombeck 41acdbfd54 test(integration): let the suite run against another Postgres major
INTEGRATION_PG_IMAGE overrides the container image, so the same suite can
prove a major the compose file does not ship yet.
2026-10-25 02:00:57 +01:00
Marc-André Bombeck 875a2a98d6 Merge remote-tracking branch 'origin/release/v1.46.0' into fix/ai-chain-review 2026-10-25 01:11:30 +02:00
Marc-André Bombeck a172e377c1 fix(ai): close the notes' windows on a fresh Codex sign-in
A save of a person's provider settings and of the operator's admin AI
settings forget the health ledger's record and close the status notes'
negative windows. The two Codex sign-ins, the operator's central one and a
person's own, forgot the ledger record only, so a fixed connection still
had every note wait out the window the refused one earned, up to two hours.

The central sign-in now closes every record's windows, since every account
may fall back on that connection, and a person's own sign-in closes theirs.
Proven against the real rows in the Codex sign-in integration suite.

Refs #1279
2026-10-25 01:03:50 +02:00
Marc-André Bombeck c9481b672c fix(documents): bound an extraction that runs inside the request
Without Prefer: respond-async the extract route ran the read with no
schedule, so a twenty-page report read a page at a time, with a retry
each, could make forty model calls in one HTTP request. The iPhone app
sends no Prefer header and reads the document detail from the answer,
so the request keeps its 200 and its body.

An extraction without a run deadline now plans at most three reads (six
calls): a longer document has its pages or text shared out between
them, and what does not fit is reported as extractionIncomplete on the
document. The background run keeps reading it a page at a time.

Refs #1257
2026-10-25 01:01:13 +02:00
Marc-André Bombeck 958d230b9f fix(documents): keep an incomplete extraction on the document
A long document read in parts can end with a part cut at the provider's
output limit, a part never read, or more facts than one review takes.
That was told once, as a notice when the run finished; a review opened
later looked complete.

InboundDocument.extractionIncomplete (migration 0483) is written with
the facts by both stagings, the extract route or run and lab
auto-staging. The document detail publishes it and the review shows the
same note whenever it is opened. The disaster-recovery backup carries
it; an older file restores it as false.

Refs #1257
2026-10-25 01:01:13 +02:00
Marc-André Bombeck 8b47d033e7 fix(ai): learn a refused reasoning field set by the entry itself
A gateway or local entry can carry its own reasoning setting (#1126), sent
on every call that has no level of its own. An endpoint that refuses
reasoning_effort answered each of those calls with a 400 that was never
learned, because only the call's own level counted as sent: every status
note, every summary and every other level-less call failed, for as long as
the setting stood.

The refusal is now learned whichever level put the field on the wire, by
the same path as any refused field: the call is asked once more without it
inside its two-request budget, later calls send no field for that endpoint
and model, a model seen reasoning unasked keeps its thinking room, and one
ai.provider.reasoning_field_refused line says so per lesson instead of a
failure per call.

Refs #1279
2026-10-25 01:01:02 +02:00
Marc-André Bombeck 05cb5cf7e9 fix(insights): stop a briefing's model calls once its bound gives up
The nightly warm, the forced warm and the morning refresh bound a
comprehensive generation and abort it when the bound fires, but the abort
only reached the generator's final check before the cache write. The
provider calls never saw it: the call in flight ran on detached, and a
reply that landed after the bound could still start the JSON-shape,
grounding or screen retry, each a full generation nobody was waiting for.

runBriefingCompletion now takes the caller's signal, hands it to the
provider call, and reserves and sends nothing once it has fired. The
generator passes it at all five of its calls (the generation, its three
retries and the phrasing re-roll). The on-demand route passes none on
purpose: it finishes and writes its briefing after the browser stopped
waiting.

Refs #1279
2026-10-25 00:54:00 +02:00
Marc-André Bombeck 0671cb6e0a chore: hold the size and version-stamp ratchets
Tighten the warm-up comments in the summaries slice and the comprehensive
aggregator so neither file grows past its line baseline, drop a release
opener from the sharing-surface note, and record the version stamp that
left the settings section list.
2026-10-25 00:51:33 +02:00
Marc-André Bombeck 0f9ee08a48 test(sync): give the module-gate double the mirror walk's token lookup
The walk now asks the token table first; the route test's Prisma double
answers it with no switched token.
2026-10-25 00:51:32 +02:00
Marc-André Bombeck cc284c4307 fix(jobs): log a failed preload report instead of swallowing it
The sweep keeps going when reporting a load failure itself throws, and
now says so on stderr; the error-level guard refuses a silent catch in
src/lib/jobs. The preload test reads the worker source through
readSource with anchors.
2026-10-25 00:51:32 +02:00
Marc-André Bombeck 2ae23f4ca8 refactor(settings): remove the preview-feature opt-ins
The per-account preview switch was kept for the topic overview pages,
which were dropped before they shipped. Its one flag was stored, served
and backed up, and read by nothing; the column-reader audit listed it as
a pending consumer that will not come.

Removed completely:

- `/api/settings/preview-features` (GET, PATCH) is retired: it answers
  410 Gone with `meta.errorCode: route.retired` and `removedIn: 1.46.0`,
  and the contract publishes it under Retired. The route, its validation
  schema, the `PreviewFeatures` OpenAPI schemas and the settings module
  are deleted.
- Migration 0485 drops `users.preview_features_json` under a bounded lock
  wait, with the retry marker.
- The backup no longer writes the field. A file from v1.44 or v1.45 that
  carries it still restores: the schema accepts it and no codec reads it,
  so nothing is refused or written for it. The backup-parity goldens
  lose the field and apply one setting fewer.
- The data-wipe plan entry and the sharing-surface actor entry go with
  the route (frozen count 273 -> 272).

No client build reads the route: neither iOS repository nor any local
checkout references it.
2026-10-25 00:44:15 +02:00
Marc-André Bombeck 402a4a546c fix(admin): lift the bench when the operator fixes the shared provider
The health ledger records failures per account and provider, and since
background work leaves out a benched provider, an operator who fixed the
instance-wide provider in the admin AI settings still saw every account's
background work sit out the pause the broken setup had earned, up to a day.
Only a person's own provider settings and the Codex sign-ins forgot the
record.

A successful save of the admin AI settings now forgets what the ledger
recorded for admin-openai on every account and closes the status notes'
negative windows, the same reset a person's own save makes for theirs
(clearStatusNegativeWindows without an account covers every record). The
ledger comments that still said a benched row only deprioritises now say
what it does for background work.

Refs #1279
2026-10-25 00:42:20 +02:00
Marc-André Bombeck f1163d5efb fix(insights): do not count a status run the bench sent nothing for
A status run whose whole chain was benched for background work sent no
request, yet the caller opened the note's negative window as for any
failure and raised its failure streak. The streak doubles the window up to
two hours, so a note kept waiting long after its provider recovered.

AllProvidersFailedError now says when no hop sent anything (sentNothing:
every provider benched, or refused by the operator cost cap). The status
chokepoint then holds the note for the first window without touching the
streak (holdStatusNoteWhileBenched), and the caller's own failure write
lands inside that window and neither moves nor counts it. A run that
reached a provider counts as before.

Refs #1279
2026-10-25 00:40:23 +02:00
Marc-André Bombeck 5cfaf4869d fix(medications): give back the doses an End took when the End is undone
End course tombstones the pending placeholders at or after its instant.
Moving or removing the end clears the instant, but the tombstoned rows
kept their unique slots, so the today projector treated them as present
and minted nothing: the later dose of that day was neither listed,
reminded nor missed.

The window's one writer now revives the placeholders the End stamped
with its instant once that instant is gone, when the slot is inside the
courses as they stand and no live row holds it.

Refs #1280
2026-10-25 00:37:35 +02:00
Marc-André Bombeck f3b9329fb2 fix(sync): mark the read caches stale after a provider backfill
`afterProviderBackfill` refolded the rollups over the touched range and
re-warmed the status cards, but never marked the analytics caches stale,
so after a Fitbit or Google Health history backfill the dashboard and
analytics reads served the pre-backfill body until its TTL ran out. The
per-write tail has always done this through `afterMeasurementMutation`.

The backfill tail now calls `invalidateUserMeasurements` with
`touchedFrom` set to the first touched day, after the fold and also when
the fold fails, since the rows changed either way.
2026-10-25 00:35:40 +02:00
Marc-André Bombeck c6bd27f06f fix(jobs): load every job handler once at worker boot and report failures
Since handlers load on their queue's first job, a module the image
cannot load no longer fails the worker's boot. The worker comes up
healthy, the queue burns its retries when it first has work, and a
one-shot job (a restore, an import tail) ends failed with nobody told at
deploy time.

After binding every queue the worker now runs `preloadHandlers` in the
background, without awaiting it, so readiness is unchanged. It loads
each handler once through the same memoised loader its queue is bound
to, so a module that loads stays loaded for the first job. Each failure
is reported on the `job.run.failed` line at error, with the queue's name
and `reason: "handler_load_failed"`, plus the worker error report
(stderr and GlitchTip). A summary line on stderr names every queue that
failed. The sweep never throws, and a failing report does not stop it.

docs/ops/deploy.md says what to look for after a deploy.
2026-10-25 00:35:36 +02:00
Marc-André Bombeck f7b89f96e1 perf(sync): walk the mirror domain from the switched tokens
`/api/sync/changes` read the `measurementMirror` domain with one query
that ordered every token-linked reading by the joined token's
`mirror_changed_at`. No index on `measurements` can serve that order, so
every poll read and sorted every row of every token ever switched, even
when nothing had changed past the client's watermark.

The walk now reads the account's tokens with `mirror_changed_at` at or
past the watermark first. Nearly every poll finds none and stops there.
Otherwise it walks each change stamp's readings by
`(ingest_token_id, id)`, the order the index holds, with tokens that
share a stamp walked together so rows interleave by id as before. Rows,
order, page boundaries and the cursor are unchanged; the wire contract
is untouched.

EXPLAIN (ANALYZE, BUFFERS) on a seeded Postgres 16: one account, a
token with 300,000 readings switched at T0, a second token with 50
readings switched at T1, 900,000 untokened readings, page size 200.

  steady-state poll (watermark past every switch)
    before: 53 ms, Merge Join over ~300,000 index rows, 10,827 buffers
    after:  0.07 ms token lookup + 0.02 ms resume probe, 10 buffers
  first page after the switch
    before: 51 ms, top-N heapsort over ~300,000 rows, 10,948 buffers
    after:  0.08 ms + 0.17 ms index scan on (ingest_token_id, id)
  resume in the middle of the big token
    before: 53 ms
    after:  0.02 ms + 0.20 ms, Index Cond on (ingest_token_id, id)

A new integration file pages both forms side by side (tied stamps, a
watermark inside a tie, soft-deleted rows, another account's token,
page sizes from 1 to 100) and asserts identical pages, and that a poll
with nothing switched past its watermark reads no reading at all.
2026-10-25 00:35:10 +02:00
Marc-André Bombeck da2cd62876 fix(rollups): register read-path warm-ups with the background registry
The mood rollup warm-up on the dashboard summary was started with a bare
`void`, so `settleBackgroundTasks()` never saw it. On a stale account the
warm-up started a full recompute that could still be writing when the
statement counter closed its window, and the hot-route budget read 30
against 28 depending on where it landed.

Every read-path start of that shape now goes through
`trackBackgroundTask(runDetached(...))`: the measurement and mood rollup
warm-ups (seven call sites), the dashboard's today-briefing warm, and the
two delegated-access bookkeeping writes in the record guard. A guard
refuses a bare `void` on any of them.

`trackBackgroundTask` now wraps its handle in `Promise.resolve`: a test
double that returns nothing used to leave `undefined` in the set, and
settle spun on it at full CPU.

The budget fixture mints the mood rollups in its seed, as it already did
for the measurement rollups. Eight serial runs of
query-budget-hot-routes read the same counts every time (dashboard
summary 28, snapshot 99, day 47, health-status 59, correlations 43).
2026-10-25 00:33:55 +02:00
Marc-André Bombeck b0ea3b1654 fix(ai): charge what failed hops spent when a later hop answers
A chain whose first provider reasoned without answering and whose second
one answered billed only the answer: result.tokensUsed is the answering
call's alone, and the failed hop's spend, already reported on its
FallbackHop, was dropped by every caller that reconciles the day's ledger.

The raw runners now report tokensSpent on success too, the sum the failed
hops before the answer reported. The briefing, status, Coach (no-tools path
and every tool-loop round) and medication-extract reconciles charge it on
top of the answer. On failure, the Coach turn ledger's close keeps what the
failed chain reported spending instead of refunding everything, and the
medication-extract and document-chat routes charge it too, as the status
and briefing paths already did. Document indexing resolves a single provider
and already charged its failed call.

Refs #1279
2026-10-25 00:33:18 +02:00
Marc-André Bombeck 186fc30fcc test(insights): build the benched re-run request without casts
The benched-provider case for "Re-run analysis" passed its ledger row and
its request through `as never`, which the test-realism ratchet counts. It
now seeds a complete provider_health row and sends a real NextRequest.

Refs #1279
2026-10-25 00:32:02 +02:00
Marc-André Bombeck f0b6854a91 fix(medications): offer a new course only on a day the start accepts
canStartCourse answered true on the day a course was ended with End
course, while a course starting that day shares its last day and the
start refused it with 422 overlap. The start is now offered from the
day after the last course's end, the first day validation and the
engine accept.

A course ended at an instant is over for every rule that asks about
today, whatever the zone it is read from: after a move west the course
rows read CURRENT while the medication's own status read ENDED. The
course rows, the coach summary, the overlap rule and the previous-end
read take the instant-aware reading.

Refs #1280
2026-10-25 00:26:55 +02:00
Marc-André Bombeck 8b66f647cb fix(compliance): carry endedAt into the streak and per-window timelines
calculateCompliance and buildTimelineForWindow built the cadence
engine's context field by field and left out endedAt, so a dose slot
after End course counted as missed in the streak and the timeline while
the rate left it out.

Both now take the context from one builder, endedAt is required on
ComplianceMedicationContext and CadenceEngineContext, and the course
anchor guard holds every builder of either type to naming it.

Refs #1280
2026-10-25 00:26:55 +02:00
Marc-André Bombeck 8fd1d543a6 fix(medications): let a manager end a course and serve End only to who may press it
POST /api/medications/{id}/courses/end resolved the caller with
requireAuth(), so a delegate managing the record got 403 while the
medication PUT that used to carry the end date admits MANAGE, and the
reads still served canEndCourse true. The action now resolves the record
at MANAGE over medications and files the previous end (C4) with the
count of tombstoned placeholders.

The medication reads narrow canEndCourse and canStartCourse to the
caller: End needs MANAGE, starting a course stays owner-only. The list
cell stays per record and the narrowing runs after it. Create and edit
answers pass the grant they were resolved under instead of null.

Registered in the sharing surface guard, the MANAGE handler matrix and
the activity feed's verb lines.

Refs #1280
2026-10-25 00:26:55 +02:00
Marc-André Bombeck bdfcea5e7c fix(insights): let "Re-run analysis" reach a benched provider
The on-demand briefing (POST /api/insights/generate) runs through the
briefing chokepoint, which named its chain walk "job" for the background
share of the day's ceiling. Since background work leaves out a provider the
health ledger benched, a person pressing "Re-run analysis" got no request at
all: the call failed with a connection refusal that never happened, and a
refused credential was never reported back.

The chain runners now take the walk apart from the budget surface: a call
can be rationed as a job and still have a person waiting on it
(interactive). runBriefingCompletion takes the flag and the on-demand
route's calls (the first pass and the grounding retry) pass it, so they keep
the job share and walk a benched provider at the tail, as background-bench
promises for interactive walks. Every other job-surface caller is a worker
or is enqueued; the status, narrative and warm routes only read and queue,
and document reads and lab scans resolve a single provider without a chain.

Refs #1279
2026-10-25 00:24:51 +02:00
Marc-André Bombeck b90980f2a2 fix(ai): keep a cut answer from a model never seen reasoning
A reply that ran into the token ceiling with unlabelled text was read as
reasoning the server did not separate whenever the ceiling held room for
reasoning, which is every call that carries a level, Off included. A model
that does not reason at all (llama3.1) lost its own truncated answer: the
call failed as reasoning_only, the briefing never reached its JSON-shape
retry, the Coach named the wrong error, the failure counted toward the
bench, and the client learned to ask with "none" for an hour.

Such text is now reasoning only once the endpoint and model have shown that
they reason: a reasoning field or a think tag in an earlier reply, or a
lesson that says so (knownReasoner). A sighting on a request that asked to
reason speaks only for requests that ask too. Otherwise the text is the
answer, cut off, with finishReason "length".

A lone closing </think> now splits a reply only where the chat templates put
it, at the start of a line, or anywhere once the model is known to reason. An
ordinary answer that mentions the tag in a sentence stays whole.

Two expectations encoded the old rule and move: the Coach loop case whose
first reply is unlabelled template-opened text from a model never seen
reasoning, and the Local case that read a never-seen model's cut text as a
thought. The streamed think-block splitting moved into local-reasoning.ts.

Refs #1276
2026-10-25 00:20:35 +02:00
Marc-André Bombeck 6e4d26c0ce test(ai): find the wire clients that dial through the request budget
The call-timeout guard found its wire clients by a direct safeFetch call.
The Local and the OpenAI-compatible gateway clients now send through the
call's request budget (hops.fetch), so the guard looked at two clients and
failed its floor. It finds both spellings again.

Refs #1279
2026-10-25 00:17:10 +02:00
Marc-André Bombeck 73cee35917 fix(ai): bound one completion call to two requests
A gateway that refuses reasoning_effort outright (LiteLLM in front of
Ollama: "does not support parameters: ['reasoning_effort']") in front of a
model that reasons without answering sent requests until something outside
stopped it. The answerless rule learned to ask with "none", the refusal of
that "none" was learned as "send no field" over it, the next request came
back answerless again, and each lesson undid the other.

Each call of the Local and the OpenAI-compatible gateway clients now carries
one request budget through every repeat (request-hops.ts): at most two
requests, after which the call ends with the failure it has. A structural
guard holds both clients to sending only through the budget and to handing
it to every repeat.

The refusal is now learned as what it is: an endpoint that refuses the
field, in front of a model already seen reasoning unasked, keeps both facts
(FIELD_REFUSED_REASONS_REGARDLESS), so it is never asked with "none" again
and keeps its thinking room. An answerless reply from an endpoint that
refuses the field is final. When the refused "none" was the repeat of an
answerless reply, the call fails as reasoning_only, which is what it ran
into. The refusal handling of both clients lives in wire-refusal.ts.

A status run that hits its bound now aborts the call it gave up on instead
of leaving it running detached.

Refs #1279
2026-10-25 00:11:42 +02:00
Marc-André Bombeck 3984ceb01c Merge remote-tracking branch 'origin/release/v1.46.0' into fix/status-parity-flake 2026-10-24 23:16:47 +02:00
Marc-André Bombeck 595a883440 fix(ai): write the provider health ledger on the app's clock
The ledger stamped last_failure_at, last_ok_at, next_retry_at and
updated_at with the database's NOW(), while every reader compares them
with the app's clock: the runner's skip hints and the background bench,
the admin provider-health card, the server-health tri-state and the
support report. A backoff or pause therefore sat wherever the skew
between the two clocks put it. With the app clock pinned, a five-minute
backoff lasted until the database's clock plus five minutes, months away,
or was over before it began.

Both writes now bind the instant they took as a parameter
(`$N::timestamp`, the naive UTC columns), the same one-clock rule the
summary reads follow. The escalating pause arithmetic is unchanged.

The pause integration test pins the app clock years behind and ahead of
the database's and checks the stored instants, a backoff that lifts after
five minutes, a pause that benches background work for its full length,
and a success the server health reads as recent.

Refs #1279
2026-10-24 23:16:24 +02:00
Marc-André Bombeck dbe05c958c test(insights): hold the health ledger still in the status parity harness
The batch golden lost a card's request about one run in eight. When the
batched call fails, every pending card falls back to its own call at once,
and each failure is recorded on the provider health ledger without being
awaited. Background work leaves a provider out from its third failure in a
row on, so a card that read the ledger after two of its siblings' writes
had landed was benched and never reached the model. Which card, if any,
was scheduling.

The stored notes were the same either way, so only the request list moved.
The parity harness now runs on a ledger that records and benches nothing;
the bench keeps its own tests.

Refs #1279
2026-10-24 23:06:00 +02:00
Marc-André Bombeck a35ff5f5ea Merge remote-tracking branch 'origin/release/v1.46.0' into fix/end-medication-contributor-base
# Conflicts:
#	src/__tests__/baselines/source-file-lines.json
#	src/__tests__/baselines/source-function-lines.json
2026-10-24 22:39:30 +02:00
Marc-André Bombeck ffe41941f1 test(contract): seed a deleted document so Recently deleted answers with a row
The GET contract run requires every list operation to answer at least
once with a row, and the new list of deleted documents had nothing to
list. The seed now ingests a second document and tombstones it the way
the delete route does.
2026-10-24 22:39:19 +02:00
Marc-André Bombeck 8649e0413f test(parity): add the course end fields to the medication route snapshots
The medication reads now carry `endedAt` and `canEndCourse`, and every
course its `endedAt`; the row dumps carry the new columns. Additions only.

Refs #1280
2026-10-24 22:37:43 +02:00
Marc-André Bombeck fd2c20dffa test(modules): list the course end route with its sibling course routes
Ending the running course writes the same course rows the other course
routes do, so it follows the same data-layer reasoning: the medications
module gates the surface, not the row store.

Refs #1280
2026-10-24 22:28:20 +02:00
Marc-André Bombeck 3df06b137d chore(ratchet): lower the size baselines the course end shrank
Refs #1280
2026-10-24 22:25:18 +02:00
Marc-André Bombeck 275367f6e9 feat(medications): end a course now through its own action
"End course" now posts to `POST /api/medications/{id}/courses/end`, which
ends the running course at that moment: its last day becomes today on the
person's clock and the instant is stored as `endedAt`, so a dose later today
is not due, not reminded and never missed, while one taken earlier still
counts. Pending placeholders already minted for later slots are tombstoned.
A medication without dates gets the course its window describes, ended now.

End stays its own verb instead of `PUT { endsOn }`: an end date set through
the PUT or the course editor keeps meaning the whole day, and the PUT keeps
accepting it on any course, since moving an end is a legitimate edit.

The list and detail reads serve `canEndCourse`, true exactly while a course
is running, and the action refuses with 409 `medications.course.notRunning`
when it is false, so no client can reopen an ended course or invent a
one-day course from a planned one by ending it. The web controls render End
from the served flag; the contributed tests keep their cases against it.

Refs #1280
2026-10-24 22:24:15 +02:00
Marc-André Bombeck 23a67f61e6 feat(medications): let a course end at an instant
A course's end date is a calendar day and counts in full: the doses of that
day stay due, reminded and expected until local midnight. That is right for
an end date set in the editor and wrong for "End course", whose dialog
promises that nothing more is due.

A course now carries `endedAt` (migration 0482, additive, nullable), the
instant it was ended on its `endsOn` day, projected onto the medication next
to `startsOn` / `endsOn` by the one course writer. From that instant:

- the schedule engine emits no slot, so a dose later that day is not due,
  not reminded, not projected onto the today list and never expected by
  compliance, while a dose earlier that day stays inside the course;
- the course reads ENDED (`courseStatusAt` / `courseStatusOn`), so the dose
  actions, `remindersActive` and the reminder gate stop at once, follow-ups
  of an earlier dose included;
- every engine context builder, compliance context, dose history, Coach
  snapshot, doctor report and the adherence storyline read it, through one
  select and one picker for the medication window.

Any write that moves a course's `endsOn` clears the instant, so an end date
chosen in the editor keeps meaning the whole day. Backups carry it in both
purposes and a restore projects it again. `courseStatusAt` moves next to the
other course rules.

Refs #1280
2026-10-24 22:20:06 +02:00
Marc-André Bombeck a773d839d4 Merge pull request #1281
End a medication course on the person's day, as a date, and offer End
only while the course is running (#1280).
2026-10-24 21:32:39 +02:00
Marc-André Bombeck 3f17b40092 Merge remote-tracking branch 'origin/release/v1.46.0' into fix/answerless-retry-bound
# Conflicts:
#	src/__tests__/baselines/source-file-lines.json
#	src/__tests__/baselines/source-function-lines.json
2026-10-24 21:31:24 +02:00
Marc-André Bombeck f6342ebca3 test(jobs): simulate an hour of background work against a model that never answers
Drives the status-card path the way an idle instance runs it: a data sync
every minute dirtying five cards, the production enqueue with its queue
name, singleton key and retry options, the queue's consumer, the card's
single call and fallback, the status chokepoint, the chain walker with the
health ledger's in-memory twin, the gateway client and the note store's
negative windows. Only the wire, pg-boss, the clock and the unrelated
Postgres reads are faked; the wire answers every request with reasoning
and finish_reason "length".

Against the code before this series the hour counted 151 requests, 150 of
them sent with "none", none charged to the day's ledger, and 300 when the
reasoning lesson did not survive from one job to the next (the pattern
behind the reported log). Now it counts six, charges all of them, sends
"none" once, and logs the pause once per window.

Refs #1279
2026-10-24 21:27:58 +02:00
Marc-André Bombeck ed9261264a test(jobs): hold every model-bound queue to bounded, backed-off retries
pg-boss gives a send that names no retry options the queue's defaults:
two retries, at once, without a backoff. A job that reached a model and
failed then ran again immediately, twice, against the provider that had
just failed it. Two model-bound queues still relied on those defaults:
the Coach memory refresh send and the nudge cron. Both now ask for the
same two retries, a minute apart and backed off.

ai-job-retry-bound-guard holds the rule: every send to a queue whose jobs
reach a model names retryLimit (at most 3) and, unless it is 0,
retryBackoff with a retryDelay of at least 30 s; every such cron carries
the same in its schedule options. The queues come from the model-run
sites the expiry guard already keeps complete (now a shared helper), plus
the job files that call a provider directly, which the new guard keeps
complete itself. A planted case runs the checker over sources shaped like
the tree before this change. The queue golden records the nudge cron's
new options.

Refs #1279
2026-10-24 21:22:08 +02:00
Marc-André Bombeck 86c2cb3360 fix(documents): stop the index-all walk when the provider keeps failing
The content-index backfill capped a run at 200 documents, but counted
only the ones it indexed. Against a provider that failed every call (a
model that reasons and never answers) the cap was never reached, so the
walk transcribed every un-indexed document in turn, one provider call
each, until the job's time ran out after more than two hours, and the
refunded failures never reached the day's token ceiling.

The cap now counts provider calls, the walk stops with reason
provider-failing after three failed calls in a row (one document the
model cannot read is still walked past), and a failed read charges what
its reply reported spending. The per-document read moves into its own
function.

Refs #1279
2026-10-24 21:22:01 +02:00
Marc-André Bombeck 17c1518b35 fix(insights): back a failing status note off, and let no path skip the wait
A status note whose generation stalled or failed opened a fixed five-minute
negative window, and only the read path honoured it. The ingest
invalidator counted only a stored note as fresh, so every data sync
re-enqueued each note whose last generation had just failed, and the
worker generated it again whatever the window said. With a model that
never answers, every sync bought another round of calls per note.

The window now grows with the generations in a row that ended without a
note (failure_streak, migration 0480): five minutes, doubling per failure,
up to two hours. A written or re-dated note resets it. A failure inside an
open window neither moves nor counts it.

runStatusCompletion waits the window out before any provider work,
whichever path selected the note again (a sync, a warm pass, a queued
job), and the ingest invalidator no longer enqueues a note inside its
window. A derived-score assessment that fails now opens the same window.
The person's own regenerate closes the record's windows first, and saving
the AI provider settings closes them and forgets the provider's health
rows, so a fixed setup does not sit out a pause the old one earned.

The window code moves to status-negative-window.ts; the status parity
golden records that the mood note screened earlier in the same minute is
no longer generated again inside its window.

Refs #1279
2026-10-24 21:08:45 +02:00
Marc-André Bombeck 94658e9af2 Merge remote-tracking branch 'origin/release/v1.46.0' into feat/documents-recently-deleted 2026-10-24 20:57:49 +02:00
Marc-André Bombeck 52fa5a06b1 feat(documents): let the picker bring back a deleted document
The document picker showed a document deleted in HealthLog as "Deleted
in HealthLog" and would not let it be ticked, so a person who deleted an
imported document by mistake met a dead end exactly where they would look
for it again.

Such a row can now be ticked, and the import sends revive: true for it.
A document still in Recently deleted is restored through the shared
restore, nothing is fetched, and the row reads "Restored from Recently
deleted". One the purge already removed is fetched and stored again, and
only once it is stored does the ledger forget its key, so a failed fetch
leaves the deletion standing. Without revive the route answers "deleted"
as before, and the token upload has no such switch: a Paperless workflow
or the import script re-sending a deleted document is still refused,
whether it is in Recently deleted or long purged.

The search names the document a deleted key still points at (documentId,
null once purged), so the row can say "In Recently deleted" rather than
"Deleted in HealthLog". The outcome gains "restored", sent only in answer
to revive. The row logic moves to picker-rows.ts.

Refs #1275
2026-10-24 20:55:19 +02:00
Marc-André Bombeck 4752115d7d feat(documents): add Recently deleted for deleted documents
Deleting a document only stamps a tombstone and the purge removes it
after 30 days, but the only way back was the undo toast, gone a few
seconds after the delete. Recently deleted lists the deleted documents
of the record with type, filing date, where an import came from, when
each was deleted and how long it has left, and offers Restore, Restore
all and Delete now.

GET /api/documents/inbound/deleted lists them (owner-only, capped at 200
with the list-cap meta). POST /api/documents/inbound/{id}/purge removes
one deleted document for good through the same function the nightly
purge runs, so its import keys and aliases still move into the ledger
and an importer re-sending it is still answered "deleted"; a live
document answers 409 documents.inbound.notDeleted. The usage read gains
deletedCount, 0 under a grant. The restore route and bulk restore
share one restore, which now also clears ledger entries for the
document's own keys.

The vault header shows a quiet "Recently deleted (n)" link only while
something is in it, and the delete toasts and confirmations say that
deleted documents stay there for 30 days. The purge's queue facts and
grace window move to document-purge-queue.ts so the worker registry
still loads none of the work.

Refs #1275
2026-10-24 20:51:35 +02:00
Marc-André Bombeck ba4774e48a fix(ai): keep background work off a benched provider and charge failed replies
The health ledger already counted every failed call and paused a provider
that kept failing, with a pause that grows. A paused provider only moved to
the tail of the chain, though, and the tail is still walked: a chain whose
only entry answered every call with reasoning and no answer was called by
every background job regardless.

A walk for background work (surface "job") now leaves out every provider
the ledger has benched until its window lifts; the local floor only once
it is paused, past a brief backoff. One ai.chain.background_paused line
per window says so. A person's own call still reaches the provider at the
tail and reports its own error. The ledger is in Postgres, so the bench
holds across workers and restarts.

The status, briefing and document-index paths refunded a failed call in
full, so background spend on replies without an answer never reached the
day's token ceiling. A failed chain now carries the tokens its hops
reported (tokensSpent) and those paths charge them.

The two clients share one answerless step (askAgainUnreasoned), which
keeps both inside the source size ratchet; its baselines go down.

Refs #1279
2026-10-24 20:42:42 +02:00
Marc-André Bombeck 79cd9ab493 fix(ai): stop asking a model with none once none did not help
The #1267 rule asks a reply that reasoned without answering once more with
reasoning_effort "none", and asks every later call that way for an hour.
That is what Gemma 4 on Ollama needs. gpt-oss behind vLLM accepts only
low / medium / high and ignores "none", and a GLM chat template reads an
effort it does not know as its maximum, so for those models the repeat and
every later "none" were wasted requests or longer thoughts.

When a reply sent as "none" comes back without an answer as well, the
Local and gateway clients now learn that too: later calls go out as the
call asks, with the thinking room #1276 keeps, and an answerless reply is
final. One call costs two requests at most and one after that, and the
log gets one line for each lesson instead of one per call.

A failed answerless call now carries what it cost on its error, and a
repeat that answers reports both requests, so the day's token ledger can
charge them. The learned lessons are pinned on globalThis, so the Coach's
route handlers and the worker's jobs share one copy per process instead
of learning the same thing twice.

Refs #1279
2026-10-24 20:24:50 +02:00
Marc-André Bombeck b716ee590b test: fold the two one-pdf.js guards into one
Two guards with the same file name froze the same failure from two sides:
one stopped a runtime import of pdf-parse, the other pinned one pdfjs-dist
in package.json, the lockfile and the checkout, and allowed only the shared
loader to import it. They are one guard now. It strips comments before it
matches, so prose naming a package does not count, walks every runtime
extension and skips __mocks__ as well as __tests__, treats a type-only
import of pdfjs-dist outside the loader as an import, and keeps the import
shapes each side's self-test proved, including the near-miss name that must
not match.

Refs #1277
2026-10-24 20:16:07 +02:00
Marc-André Bombeck 1fd34e4d74 test(documents): keep one pair of one-process order tests
Two pairs of tests ran a text read and a rasterization in one process,
one file per order. The pdfjs-one-version pair stays; the other pair and
its shared helper go. The kept pair now first asserts that nothing in the
process has claimed the pdf.js worker yet, so a file that lands in a shared
process fails instead of passing on an order it never ran, and it mocks the
failure signal the text read can emit.

Refs #1277
2026-10-24 20:16:02 +02:00
Marc-André Bombeck 540c6bfaeb refactor(documents): read the text layer through the one shared loader
The text read and the rasterizer each opened pdf.js on their own: the text
read with bare options, the rasterizer through src/lib/documents/pdfjs.ts
with the standard font, CMap and wasm directories. The loader now serves
both, so a CID font with a predefined CMap is mapped to characters on the
text path too, and there is one set of options to keep in step.

The loader is typed with pdf.js' own declarations instead of a hand-written
shape. That drops isEvalSupported, which pdf.js 6 no longer has and which did
nothing. Only the render slice the DOM-typed declarations cannot describe
(the native canvas factory) keeps a named local type in the rasterizer.

The run joiner with its named steps moves into the loader as joinTextRuns,
and readPdfText is its one page walk; the second layout copy that mimicked
the previous library's tabs and blank lines is gone. extractPdfText keeps
its outcomes and page cap on top of it, and a failed read still says what
pdf.js threw (documents.local_extract.failed). A test draws a word in two
fonts into a real PDF and reads it back whole.

Refs #1277
2026-10-24 20:16:01 +02:00
Marc-André Bombeck d3621617c6 Merge pull request #1278
Read a PDF's text with the app's own pdfjs (#1277).
2026-10-24 20:10:04 +02:00
haritowa 5e8fb126a3 refactor(medications): hand the lifecycle controls the medication
LifecycleManageBody takes the medication (id, name, active, course
status) instead of one prop per field. The call site in
medication-detail-tabs.tsx shrinks to one line, so the course status
reaches the controls without growing a file the size ratchet holds.
2026-10-24 17:53:18 +00:00
Marc-André Bombeck 1ea14e9100 fix(documents): index once more the PDFs a failed local read left out
While two pdf.js versions broke each other, a local text read after a
page render failed and was recorded as decrypt-error, and a scanned PDF
that had to be rasterized for the provider was recorded as raster-failed.
Neither has a content index, and on an instance without an AI provider
nothing offers to index it again.

Migration 0479 clears the attempt time of exactly those PDFs and keeps
their reason, a state no index attempt leaves behind. A new boot
discovery, document-index-retry, enqueues the upload's auto-index job for
each such row, so it runs the same provider-first, local-fallback decision
under the same consent rules; a deferred import stays local because its
row says so. The attempt writes its time back, so every document drops out
after one try, and one that really cannot be read records its reason again
and stays settled. An account with the documents module off keeps its
marks until the module is on.

An integration test seeds both kinds of damage, runs each migration's
statement and only what a boot runs on its own, and sees a preview and an
index appear, while a blank page, a broken ciphertext and a file that is
not a PDF settle after one retry and are not found by the next boot.

Refs #1277
2026-10-24 19:50:20 +02:00
Marc-André Bombeck 98fb6d81c0 fix(documents): render previews that failed to rasterize once more
While two pdf.js versions broke each other, every PDF preview rendered
after a local text read failed and was recorded as raster-failed, which
the thumbnail backfill treats as final. Migration 0478 deletes those
failure rows, so the boot-time thumbnail backfill finds each document
again and renders it once. A stored failure cannot tell the clash from a
page that really renders blank; such a page fails once more and settles
with one attempt. Only raster-failed is cleared: the clash surfaced as a
failed rasterization, which the thumbnail path maps to that reason alone.

Refs #1277
2026-10-24 19:50:19 +02:00
Marc-André Bombeck 8e216a8d4c test: fail when a second pdf.js could load in the server
The lockfile must resolve exactly one pdfjs-dist, the version package.json
pins, and no package may depend on it except the app; every lockfile line
naming pdfjs-dist is either understood or reported. The checkout must
resolve that version from the working directory, and no production module
may import pdfjs-dist except src/lib/documents/pdfjs.ts, nor pdf-parse at
all. Run against the previous lockfile it reports the 5.4.296 dependency
and the old override; an extra import in the rasterizer fails it as well.

Refs #1277
2026-10-24 19:35:21 +02:00
Marc-André Bombeck 30c96ff591 chore(deps): drop pdf-parse and the second pdfjs-dist it carried
Nothing imports pdf-parse any more, so its pdfjs-dist 5.4.296 and the
older @napi-rs/canvas that came with it leave the lockfile. The override
that kept that transitive copy past a renderer advisory has nothing left
to move and goes too. The standalone tracing stops naming pdf-parse; its
pdfjs-dist glob now matches the one copy the app pins. The Dockerfile and
canvas guard comments describe the second copy as history.

Refs #1277
2026-10-24 19:32:20 +02:00
Marc-André Bombeck 0b1757ff6f fix(documents): read the text layer through the same pdf.js that renders
Under Node, pdf.js runs its worker in the server process and keeps it on
a global, and whichever copy of pdf.js opens a document first owns that
global until a restart. The local text read went through pdf-parse, which
pins its own pdfjs-dist 5.4.296; the rasterizer uses the app's 6.4.299. A
text read first made every later rasterization fail with "The API version
does not match the Worker version" (thumbnails raster-failed, the vision
read of a scan 422), and a rasterization first made every later local text
read fail, which left the document out of the search index with nothing in
the logs.

src/lib/documents/pdfjs.ts is now the one place pdf.js is loaded and
opened, with one set of options (asset directories, no eval, no system
fonts). The text read walks the pages with getTextContent and lays the
text out the way pdf-parse did with the options it was called with: line
breaks, tabs between far-apart items, a blank line after each page, the
200-page cap. Text extracted from the PDF fixtures and from generated lab
tables, encrypted files and doctor reports is identical before and after.

A failed text read now emits documents.local_extract.failed with the
error pdf.js threw, and annotates the index event with it, instead of
returning a bare error.

The report tests read PDF text through the same reader. Two tests, one per
order, run a text read and a rasterization in one process; both failed
before this change.

Refs #1277
2026-10-24 19:32:13 +02:00
haritowa fa20b1fe3a refactor(medications): give End its own component
End and its confirmation now live in EndCourseControl, which renders only
while the course runs. When End stops being offered, the open dialog and
its state unmount with it, so the state no longer has to be reset during
render.
2026-10-24 17:04:26 +00:00
haritowa 9b8c763901 refactor(medications): tidy the End guard after review
The course-status prop uses the shared CourseStatus type instead of a
spelled-out union, and the End confirmation's open state is dropped when
End stops being offered, so a dialog closed by a refetch cannot pop up
by itself once a later course offers End again.
2026-10-24 16:57:21 +00:00
haritowa 92911283be fix(medications): offer "End" only for a course that is running
Ending sends today as the end of the medication's latest course. While
the PUT was refused this did nothing; now that it succeeds, pressing End
on a course that had already ended reopened it up to today (doses due
again, the days between counted as missed), and on a course that had not
started yet it became a one-day course today.

LifecycleManageBody now takes the server-resolved courseStatus and shows
End only while the course is CURRENT (a medication with no dates is
CURRENT). Pausing stays available whatever the course.
2026-10-24 16:57:20 +00:00
haritowa 19ae91f80a fix(medications): end a course on the person's day, as a date
"End medication" sent new Date().toISOString(), a full instant, against
an endsOn that only accepts YYYY-MM-DD, so every press answered 422 and
the toast said the medication could not be ended. The instant's date was
also the UTC one, a day off around midnight for anyone not on UTC.

The button now sends the person's calendar day in their timezone
(userDayKey), the same day key the medication card uses.
2026-10-24 16:57:20 +00:00
Marc-André Bombeck 567cb19afc Merge remote-tracking branch 'origin/release/v1.46.0' into fix/coach-reasoning-gateway 2026-10-24 18:48:45 +02:00
Marc-André Bombeck 7697ac8970 refactor(ai): move reasoning helpers and the Coach error resolver into their own files
The reasoning separation grew three files past their size ratchet. The Local
client's reasoning collector and the new separation helpers move to
local-reasoning.ts, the gateway client's thinking room and reply separation
become two private methods, and the Coach's error-code resolver moves out of
chat-bubble.tsx into coach-error-key.ts, still re-exported from
message-thread.tsx. Code motion apart from comments that said when a branch
was added rather than what it does. The baselines record the shorter files.

Refs #1276
2026-10-24 18:47:04 +02:00
Marc-André Bombeck 1191e8416a test(labs): expect the minimum's thinking room on wires that cannot promise off
The lab-scan read asks every provider for no reasoning. On an
OpenAI-compatible gateway or a local server that "none" is a request the
model behind it may ignore, so these wires now add the low thinking budget on
top, the same rule the file already holds for a model that always thinks:
the answer keeps its full allowance either way.

Refs #1276
2026-10-24 18:47:03 +02:00
Marc-André Bombeck a61fe1c715 fix(demo): give the short-window blood pressure a shape to show
Every 90-day series in the seed is a mean-reverting walk, and around a
settled target that is flat noise. A 7-day trend line over the last 30
days of blood pressure drew a straight line, which is the view the demo
exists to show.

A busy stretch three to four weeks back now lifts blood pressure and
pulse and lets them settle again, the mood dips with it, and weekend
readings sit a touch higher. Blood pressure also pulls back towards its
target harder, so a seed no longer ends several mmHg above the band its
stored texts describe; today's reading settles on the days after the
stretch. The two stored texts that summarise the last weeks mention the
stretch.

An integration test runs the real seed and holds the 7-day mean to a
visible swing and today's reading to the settled side of it.
2026-10-24 18:28:19 +02:00
Marc-André Bombeck 31e107b96a fix(coach): say so when the model answered with reasoning alone
When every provider in the chain answered a Coach round with reasoning and
no reply, even after being asked once more without reasoning, the turn ended
with "could not reach a provider, please try again in a moment". The
provider was reached, and trying again in a moment changes nothing for a
model that cannot stop reasoning.

Such a chain now ends with its own code, coach.provider.reasoning_only, and
the drawer says the model only reasoned and never wrote an answer, and
suggests another model. The choice between credential_expired, rate_limited,
reasoning_only and unavailable moved into one function beside the other turn
error rules, where it is tested on its own. An un-wrapped reasoning-only
error maps the same way. Clients that do not know the code keep showing the
generic provider message.

Refs #1276
2026-10-24 18:20:41 +02:00
Marc-André Bombeck c9673883b1 fix(ai): run gateway tool calls whatever finish reason comes beside them
The Coach loop runs a round's tool calls only when the round finished with
"tool_calls". The Anthropic and Codex clients already derive that from the
calls themselves; the OpenAI-compatible client copied the server's
finish_reason, and vLLM, Ollama and LiteLLM can report "stop" beside a
parsed tool call. The loop then took the round as the answer, the call never
ran, and what the person saw was whatever text came with it.

A reply that carries tool calls now reports "tool_calls", as on the other
wires.

Refs #1276
2026-10-24 18:20:35 +02:00
Marc-André Bombeck 652dda050c fix(ai): keep a model's reasoning out of the answer
A gateway or local server in front of a model that always reasons (GLM
behind vLLM, for one) accepts reasoning_effort "none" and ignores it. Two
things went wrong with such a model.

Once it answered a round with reasoning alone, the #1267 lesson asked every
later call with "none", and the token ceiling followed the wire: "none" meant
no thinking room, so a Coach round got its bare 600-token answer budget. The
model spent that thinking and stopped at finish_reason "length" before it
reached a tool call or an answer, for the hour the lesson holds. A turn set
to Off had the same ceiling from the start.

Where the server did not separate the reasoning (no reasoning parser, or one
that falls back to content), the reply's content was the thought itself: an
inline <think> block, the text before a lone </think> when the chat template
opened the block in the prompt, or unlabelled text cut off at the ceiling.
The gateway client passed all of it through as the answer; the Local client
split a leading <think> block, and only for callers that asked to reason.

On these wires the ceiling now follows the call's level instead of the wire
effort: Off keeps the low room, a call sent as "none" keeps its own level's
room, and a call without a level gets the low room once the model has shown
it reasons unasked. It is a ceiling, not a charge; a model that does switch
off never touches it. Both clients now take reasoning out of content for
every caller: a leading or a lone closing think tag, and a reply that filled
a ceiling sized for thinking with unlabelled text and no reasoning field
beside it. What comes out goes to the reasoning channel when the caller asked
for it and nowhere else. A reply left without an answer takes the #1267 path
and ends as reasoning_only, never as the thought.

The loop test drives a Coach turn through the gateway client onto an
upstream that answers like the reported wire capture, with and without a
reasoning parser.

Refs #1276
2026-10-24 18:20:18 +02:00
Marc-André Bombeck 087d0011fc fix(deps): raise the floors for body-parser, fast-uri and undici 7
body-parser 2.3.0 (CVE-2026-111181, high) and fast-uri 3.1.8
(CVE-2026-110180, CVE-2026-110306) arrive under the MCP SDK on a
production path. undici 7.30.0 (CVE-2026-109869, CVE-2026-109841,
CVE-2026-109924) is the development-only copy under shadcn; the app's
own undici 8 is already past the fix. Each bound moves the flagged copy
and nothing else.
2026-10-24 18:11:21 +02:00
Marc-André Bombeck 6748a5b843 fix(demo): seed each reading with the source that writes it
The 90-day core series (weight, blood pressure, pulse, body fat, resting
heart rate, sleep, steps) were all tagged MANUAL. The measurements list
badges every row that is not MANUAL, so the newest weight and
blood-pressure rows carried no source while Settings > Sources named
Withings for those metrics (from the older history rows). The body
composition series claimed Apple Health for fat mass, fat-free mass,
muscle, water, bone and visceral fat, none of which HealthKit has.

Weight, body fat, blood pressure and its pulse are now WITHINGS with no
device type, the way the Withings sync writes them; resting heart rate
and the sleep aggregate are APPLE_HEALTH from the watch; steps are the
iOS daily total under its `stats:` key, so the legacy-step consolidation
leaves them alone. The Withings body-composition components move to
WITHINGS; lean body mass and BMI stay APPLE_HEALTH from the scale. The
waist stays MANUAL, since nothing syncs a tape measure.

An integration test runs the real seed and holds every Withings and
Apple Health row against the type maps those integrations ingest
through, and pins a device source on the newest reading of each synced
vital.
2026-10-24 13:11:07 +02:00
Marc-André Bombeck 2b9315ad8b fix(demo): stamp the baked briefing so the readers serve it
Every briefing reader serves `dailyBriefing` only when the payload's
`briefingGeneratedAt` falls on the reader's today. The demo seed wrote the
comprehensive payload without that stamp and without the language tag, so
on a fresh seed the advisor read returned no briefing, the dashboard
snapshot stayed "preparing", and a warm was enqueued that can only fail
against the placeholder key. The /insights card sat empty while the hero
said "Updated today".

The seed now writes the cache the way a generation does: the payload
stamped through `withBriefingGeneratedAt`, the cache stamp from the same
instant, and the `en` tag. The baked paragraph drops its "Good morning"
opener, since the hero already greets by the hour.

The hero's freshness caption dates the subtitle, so it now shows only
when the subtitle is a briefing. The cache stamp also moves when a warm
keeps yesterday's text, which the read then withholds; the caption then
sat under the generic fallback sentence.

`seed()` is exported behind a main-module guard, and an integration test
runs the real seed and reads its output back through the advisor read,
the dashboard snapshot, the status cards and the period narratives.
2026-10-24 13:07:46 +02:00
Marc-André Bombeck 59a6ee9d05 fix(insights): keep a stat value and its unit on one line
With the day panel docked beside the blood-pressure page (1280 px and up),
each half of the stat card is a narrow column, and its four cells still
went side by side because the step followed the window's width. Every cell
came out narrower than "112 mmHg": the systolic values broke onto two
lines, the two-digit diastolic ones next to them did not.

The stat strip now joins a value to its unit with a no-break space, and
each series is a container whose cells go four-up only when the series
itself has the room: from 28rem for a half-card column, from 36rem for a
full-width strip (where the 640 px window step put it, so the longest unit
written, mL/(kg·min), keeps a cell it fits in). The loading shell follows
the same steps. Every page with a stat strip (pulse, weight, the
HealthKit, lab and custom-metric pages) shares the component.

A component test pins the no-break pair and the container steps; the
day-view spec measures every blood-pressure value beside the docked day
at 1280 px and fails on the old markup.
2026-10-24 12:18:39 +02:00
Marc-André Bombeck 9cc77c79fc fix(analytics): cut every summary window on the request's clock
The summaries slice and the comprehensive aggregator took their JS windows
(the DAY-bucket bounds, the rollup slope anchors, lastSeen staleness) from
the process clock and their SQL windows (avg7, avg30, the month before, the
median, the 90-day canonical pick, the cold-path slope evidence) from
NOW(), the database's clock. One response mixed two clocks, so a reading
on a window's edge fell in or out with whichever host answered, and with
the process clock pinned the analytics parity walk changed its avg7 with
the hour it ran at.

Each read now takes one `now` and binds it into its statements
(`request-clock-sql.ts`): `$N::timestamp`, which keeps the driver's
offset-free UTC string as written in any session zone. The canonical-pick
helper takes its window as an instant plus an interval instead of reading
NOW(); the series and calendar readers anchor it on their own `since`, the
BP-in-target read on its 365-day start. The window-mean and live-slope
columns the two aggregates each spelled out now come from one builder.

The streak's local day series read its bounds through `::timestamptz`,
which follows the session zone: outside a UTC session today's reading fell
out of the streak. It reads them as UTC first.

The parity walks for analytics, the dashboard summary and the
comprehensive overview pin a fixed day instead of today: their records are
laid out from the anchor day, so the chronotype's work and free nights and
the weight-by-weekday points moved with the weekday the walk ran on. The
snapshots are unchanged. A new suite pins the clock to four times of day,
far from the database clock, with readings ten minutes either side of
every edge, on both read paths, in a UTC and an Auckland session.
2026-10-24 12:14:33 +02:00
Marc-André Bombeck cf7302b50a Merge remote-tracking branch 'origin/release/v1.46.0' into feat/v146-ai
# Conflicts:
#	src/__tests__/baselines/source-file-lines.json
2026-10-24 11:29:56 +02:00
Marc-André Bombeck a1ee2ac003 fix(documents): keep document reads inside their job's expiry
Since the lab staging reads a long report in parts, one auto-index could
make the transcription plus up to twenty reads of two calls each at the
person's response timeout, hours past pg-boss's fifteen-minute default,
which failed the job and retried it beside the reads still running. The
staging now takes the run's deadline as a schedule and plans only the
reads that fit before it. The index job is long, with an expiry above the
calls that run before the plan can say no, a stop on jobDeadline and a
lock per document; the document run hands the same staging its own
deadline, which moves the run's expiry as the reads are planned. The
document summary, one call and its reasoning repeat, gets an expiry above
that worst case.

ocrReadCallMs in the read core is the one per-call timeout both deadlines
divide by. The expiry guard now names every importer of the document and
lab read entry points with the queues that reach it.

The runtime guard's planted cases stripped every file's comments again on
each rescan, which took over a second alone and timed out under a full
parallel run; the stripped text is now memoised.

Refs #1274
2026-10-24 11:28:57 +02:00
Marc-André Bombeck 8b6b3bccce Merge remote-tracking branch 'origin/release/v1.46.0' into fix/trend-slope-span 2026-10-24 11:18:12 +02:00
Marc-André Bombeck c15ff8401b fix(analytics): withhold a trend slope when the window's readings cover too few days
A least-squares slope per day over readings minutes apart explodes: three
blood-pressure readings in one sitting, the only ones of the week, fitted
thousands of mmHg per day, and the dashboard tile printed seven days of it
as the 7-day trend. The 30-day fallback had the same flaw.

A window now earns a slope only when its readings fall on at least three
UTC days whose first and last lie at least half the window apart (4 days
for the 7-day window, 15 for the 30-day one). The rule lives once in
window-confidence.ts and gates every path that publishes a windowed trend:
trendSlope (summarize and its callers), the rollup composition and the
live REGR_SLOPE fallback of the summaries slice and the comprehensive
aggregator, and the per-bucket slope of folded week, month and year
buckets. Slope, direction and confidence are present or absent together.
The fit itself stays over individual readings, as the window means do.

The parity snapshots change only where the fixture falls short of the
rule: a weekly body-fat series has two days in its 7-day window, and a
ten-day oxygen-saturation series spans nine days of its 30-day window.
2026-10-24 11:13:25 +02:00
Marc-André Bombeck 96eeec6080 Merge origin/release/v1.46.0 into feat/v146-ai 2026-10-24 11:03:44 +02:00
Marc-André Bombeck 9460fb2b51 Merge remote-tracking branch 'origin/release/v1.46.0' into fix/v146-tablet-left-strip 2026-10-24 11:01:44 +02:00
Marc-André Bombeck ccd7ee38c9 fix(shell): let the rail take the left inset and mirror its account band
An iPad in a Safari tab laid the app shell out 10 px in from the left edge
in portrait. The shell padded that inset, so a strip of page colour ran
beside the rail from the logo band to the account row, and the rail's band
borders stopped short of the edge.

The desktop shell no longer pads the sides. The rail runs to the window's
edge and takes the left inset inside its own background, widening by it so
its content keeps its 4rem / 16rem; the logo band, the footer group and the
account band reach back over the inset so their lines meet the edge. On the
right the top bar and main already run to the edge. The phone shell keeps
padding both sides. The side insets are read through --safe-area-left and
--safe-area-right, so a test can set one.

The account band takes the logo band's height (4rem) with the avatar
centred in it, instead of 61 to 69 px of padding with more room below the
avatar than above; the home-indicator inset stays under the band.

tablet-shell.spec.ts sets a 10 px side inset and a 20 px home indicator and
checks that the rail owns the window's left column of pixels, that its band
lines start at x = 0, that nothing in it sits inside the inset, and that the
account band mirrors the logo band. shell-viewport.test.ts holds the routing.
2026-10-24 11:00:56 +02:00
Marc-André Bombeck 02d3d8b7bb fix(insights): let slow-model status notes and AI jobs outlast their calls
A status run (a metric card, the batched assessment, a derived score, a
workout line, a Coach memory pass) is one model call plus the one repeat
the Local and gateway clients send when a thinking model answered with
reasoning alone, each with the person's response timeout. The run's outer
bound was a single call, so the repeat was cut off, and the open card
stopped polling after 48 s, so a note from a model that needs minutes
never reached the page. The bound is now the per-call timeout times
STATUS_MODEL_CALLS, and the card's poll ceiling follows the published
response timeout (fast for the first 48 s, every 15 s after), only while
the route reports the note in flight; the fallback text stays meanwhile.
The status queue is exclusive, so the longer poll cannot queue a second
generation beside a running one.

The morning refresh is one comprehensive generation of up to five calls
in a row, with no outer bound and pg-boss's fifteen-minute expiry, so a
large setting had pg-boss fail and retry it while it was still running.
It now runs behind the same bound as the warms, and the job carries an
expiry derived from that worst case. The per-metric note, the workout line
and the Coach memory refresh get expiries derived the same way.

queue-runtime.ts gains a bounded class for these jobs: model time read off
model-time.ts, an expiry above it, carried on every send. A new guard
names every caller of runStatusCompletion and generateComprehensiveInsight
with the queues that reach it, and holds each such queue to bounded above
its runs or long under a run lock.

Refs #1274
2026-10-24 11:00:32 +02:00
Marc-André Bombeck 6259c56a85 Merge remote-tracking branch 'origin/release/v1.46.0' into feat/v146-truenas 2026-10-24 10:35:46 +02:00
Marc-André Bombeck 5a8f59e0c0 fix(documents): say when an extraction stopped short instead of blaming the copy
The document review now names the two outcomes the extraction can report
since it reads a long document in parts. A read that stopped at the
provider's output limit before a single fact closed shows its own
sentence (try a model that allows longer answers) rather than the
generic "Couldn't read the document". A run that staged facts but had a
part cut or not reached opens the review as before and adds a warning
that some values may be missing, so the list is checked against the
document. Both strings are in every locale bundle.

Refs #1257
2026-10-24 10:34:37 +02:00
Marc-André Bombeck 5a7bd95c7b fix(documents): read a long document through the lab scan's read core
Extracting lab values from a stored document still sent the whole text
in one call. A multi-page report produced more JSON than one call's
output ceiling, the answer was cut mid-fact, the parse failed twice, and
the read came back as 422 "Couldn't read the document. Try a clearer
copy." The lab scan had already learned to read the same report a page
or a part at a time, but each surface carried its own copy of the read,
so the document path never got it. It also ran under the shared 60 s
timeout, dropped every fact past 120 (or every fact when one did not
match the schema), and charged a flat reservation.

The read now lives once, in lib/documents/ocr-read.ts and ocr-plan.ts:
the per-read limits, the corrective retry, the salvage of a cut
answer's complete rows, the wire re-check and budget reservation before
each read, and the planning of a PDF a page at a time and text in parts
at line boundaries. The lab scan and the document extraction (the
extract route, its background run and the lab auto-staging) both read
through it, and the background run plans its reads on the same deadline
as a lab scan. The per-read budget moves to a shared metered budget.

The document path validates the answer fact by fact, keeps the lab
scan's row cap (400), and says why nothing came back: 422
documents.inbound.outputLimit when the reads stopped at the output
limit, 429 when the budget refused them, extractFailed only for answers
that were not the schema. A run whose reads were cut or not reached
reports `incomplete` on its result.

A guard holds the class: the output ceiling is set only in the core,
both surfaces read through runOcrReads, both runs share the deadline,
and a lab read and a document read go out under the same limits.

Refs #1257
2026-10-24 10:34:31 +02:00
Marc-André Bombeck 8d9386d73a docs(truenas): cover the Community train app, its backups, and when the catalog needs a pull request
The TrueNAS page now walks through the catalog install field by field, how
to generate the two secrets, where TrueNAS keeps the key and why that is not
a backup, a dump and restore from the postgres container shell, and updates
with forward-only migrations. The listing is marked as pending review, and
the install-via-YAML values stay for the time until it is live.

The deploy notes list what needs a matching truenas/apps pull request in the
same release cycle: a new or stricter required variable, a port or health
check change, a Postgres major or extension, and any other compose-shape
change.
2026-10-24 10:32:17 +02:00
Marc-André Bombeck c1c2ba766c fix(encryption): point the TrueNAS key backup step at the catalog app's Encryption Key field
The TrueNAS app in the Community train takes the key in its own form field,
not in an environment list. The step now names that field and keeps the
environment-variable wording for an app installed via YAML.
2026-10-24 10:32:16 +02:00
Marc-André Bombeck 8787d71d1c fix(ai): recognise the gpt-6 model family in every model gate
The ChatGPT Codex backend and the OpenAI API serve gpt-6-sol, gpt-6-luna,
gpt-6-astra and gpt-6.1-sol, but the reasoning, Chat Completions and vision
gates only knew the gpt-5 line. A gpt-6 model got no reasoning control and
no real "off", went out on the legacy wire with max_tokens and a
temperature that reasoning models refuse, and was denied image input.

The three gates now read one classifier, openai-model-family.ts, so a new
generation is taught in one place. Every GPT from 5.1 on, the gpt-6 line
included, is the family that takes effort "none"; every GPT from 5 on reads
images. The upstream catalog (codex-rs/models-manager/models.json) lists
image input for every gpt-6 slug and the same reasoning presets as the
gpt-5.6 line; the presets omit "none" for gpt-5.5 too, which accepts it.

The Codex slug ladder moves into codex-slug-chain.ts and gains gpt-6-sol,
gpt-6.1-sol and gpt-6-luna. gpt-5.6-terra stays first as the slug verified
against the backend; the catalog names gpt-6-sol as its upgrade, and a
rotation retires a whole line at once, so the gpt-6 rungs follow before
terra's own siblings. The ladder test now holds every rung to a recognised
reasoning family with a real off and image input. The OpenAI presets offer
gpt-6.1-sol and gpt-6-luna.
2026-10-24 10:11:16 +02:00
Marc-André Bombeck 3d47c1e6b4 test(guards): hold the whole regenerate route to its two model stages
The browser's abort for a forced regenerate waits three calls at the
response timeout, fewer than the five the background generator may make.
That is correct: the route runs its own pipeline, refuses an unparseable
reply or a screened payload instead of retrying, and never reaches the
background generator, so its longest run is the generation, the
grounding retry and the one reasoning repeat.

The guard only read two of the pipeline's files, so a retry stage added
anywhere else on the route would have lengthened the run past the
browser's abort unnoticed. It now sweeps the route and the whole
briefing-request folder for stages and for any call into the background
generator, and the advisor states why its count is the shorter one.

Refs #1274
2026-10-24 09:29:38 +02:00
Marc-André Bombeck 552510c654 fix(insights): keep polling for the briefing while the warm can land
The briefing read polls while the warm it enqueued runs, and it stopped
after ten polls (225 s) whatever the response timeout. A briefing that
landed later never reached the open page, which kept the empty card, or
the previous attempt's "timed out" hint, on screen until a reload.

The read's poll ceiling now follows the published response timeout over
the generation's sequential calls, never less per call than the
briefing's 180 s default, plus one interval for the queue to pick the
warm up. A settling regenerate and the overview body keep their ten
polls. A test walks the poll schedule against the warm's own bound for
every setting, and the client-abort guard holds the read to the derived
ceiling.

Refs #1274
2026-10-24 09:23:47 +02:00
Marc-André Bombeck 73e98c6ef5 fix(insights): give the briefing warm every call of its generation
The out-of-band warm the Insights page relies on bounded one
comprehensive generation at the account's response timeout plus 30 s.
A generation is up to five model calls in a row: the generation, the
JSON retry, the grounding retry, the outbound-screen repair, and the one
repeat a thinking model gets when it answered with reasoning alone. Each
call honoured the setting, but a local model needing 300 s per answer
under a 600 s setting met the 630 s bound in the middle of its first
corrective retry, and the briefing was dropped.

The bound is now the per-call timeout times COMPREHENSIVE_MODEL_CALLS
plus the headroom, for the nightly pass and the forced warm alike. A
test drives the real warm, generator, chain runner, Local client,
safeFetch and pinned dispatcher against a loopback server that answers
after 300 s; under the old bound it reports a timeout on the third
answer.

The response-timeout guard now names every outer bound around model
work and holds both briefing generators' stages to the call counts the
outer waits multiply by.

Refs #1274
2026-10-24 09:23:20 +02:00
Marc-André Bombeck 810a949d2b fix(ai): move the Codex slug ladder onto the gpt-5.6 line
The ChatGPT Codex backend now rejects every slug from gpt-5.5 down, so a
deploy without a CODEX_MODEL_FALLBACK_CHAIN override walked the whole
default ladder into "all configured Codex slugs were rejected". The
default now starts at gpt-5.6-terra, with its luna and sol siblings and
gpt-5.5 as the last rung.

The gpt-6 line stays out of the default: the reasoning, sampling and
vision gates do not recognise that family yet, and a test now holds every
default slug to a family the reasoning gate knows.
2026-10-24 08:06:33 +02:00
Marc-André Bombeck 685b46fa04 fix(ssr): keep a server render's query cache from outliving the request
Every server render left its request reachable for ten minutes after the
response was sent. Providers builds its QueryClient once per render, and
the app's defaults give queries a ten-minute gcTime. On the server each
entry the render built, disabled observers included, started a timer of
that length, and a Node timer keeps the async context it was created in.
That context is the request's: the response, the React Flight stream and
the compressed body. A heap snapshot after a run of unauthenticated
probes showed one 600 s query-core timer per request and the retained
Flight chunks, ServerResponses and gzip buffers all hanging from them:
about 150 KB of heap and 90 KB of buffers per not-found render.

TanStack Query already defaults gcTime to Infinity on the server for this
reason; an explicit value from the defaults or a hook overrides it.
AppQueryClient folds Infinity back in at defaultQueryOptions, the merge
point every observer, cache build and hydration passes through, so the
server cache goes with the client at the end of the request. The browser
keeps its configured lifetimes.

The retention test reproduces the chain without a server: a response
object held only by an AsyncLocalStorage store must be collectable after
the render's client is dropped, with a positive control on a plain client
that must keep it alive. A guard keeps client modules from building a
QueryClient themselves and allows only option-less clients elsewhere.
2026-10-24 02:12:31 +02:00
Marc-André Bombeck 3e8846929c test(guards): load no module graph inside a guard's test body
step-up-elevation-guard timed out under load in three separate gates
and passed every time on its own. One of its tests imported
@/lib/auth/step-up inside the test body to read the fresh-factor set,
and that module reaches the database client and the generated Prisma
client: the import alone ran against the test timeout, and how long it
took depended on the machine, not on the code under test.

The fresh-factor definitions (StepUpMethod, FRESH_FACTOR_METHODS,
isFreshFactorMethod) move to step-up-factors.ts, which imports nothing;
the guard imports it statically and the test is synchronous. Two more
guards did the same: next-image-optimizer-guard loaded next.config and
its plugins per test, and proxy-relative-redirect-guard loaded the
redirect helper with next/server. Both import statically now.

guard-dynamic-import-guard.test.ts parses every file of the guard
project with the TypeScript parser and fails on any import() call, so
a guard that needs a value imports it with the file or reads the
source; a test that must load late belongs to the isolated project.
2026-10-23 22:51:51 +02:00
Marc-André Bombeck 2357807706 feat(import): bring the account's own mood vocabulary along
A full backup carries the account's own mood tags, rated factors and
tag groups, and the JSON import left them out: a mood entry imported
into another account landed without every link to a tag the person had
made.

Before the entries are written, every key they or the file name is
resolved once. A catalogue tag or one the account owns is used as it
is; an own tag whose label the file states differently is kept, never
overwritten, and named in keptMoodTagKeys (keptMoodCategoryKeys for a
group). A tag the file defines and the account lacks is created in its
group, the group created the same way, within the account's caps of 50
tags and 12 groups. A key another account of the server holds comes
over under a key derived from the account and the file's key, so a
second import of the same file finds the copy instead of minting
another. The links are written through that resolution, own rated
factors included, inside the entry's transaction; anything unresolved
is still dropped and named. The import card names kept keys in all
nine locales.

The field-coverage guard now holds the backup's customMoodTags and
customMoodTagCategories to the import schema. The round trip moves a
backup to another server (the same seeded catalogue, none of the
account's rows), imports it into a fresh account and compares the
re-export, own tags, group and rated factor included; a second case
imports into another account of the same server, and a third keeps a
renamed own tag.
2026-10-23 22:41:36 +02:00
Marc-André Bombeck d1545cf554 refactor(jobs): keep the lane's hold grace private to the lane 2026-10-23 22:13:00 +02:00
Marc-André Bombeck 111047491e fix(medications): end every medication and intake read on the row id
Postgres returns rows that tie on an ORDER BY in no particular order,
and not the same one twice. The medication reads ordered by columns two
rows share all the time: every read that hands schedules on included
them with no order at all, medications were listed by createdAt alone
(one import writes many in a millisecond), intakes by scheduledFor (a
twice-daily medication puts every morning dose on one instant), and
"the latest intake" lookups by takenAt. A client read the same account
in a different order from one request to the next, and a latest-intake
lookup picked either of two rows.

Every findMany / findFirst on a medication table, nested includes
included, now ends its order on the id in the direction of its last key,
and schedules are read by window, then id (MEDICATION_SCHEDULE_ORDER, the
order the backup already wrote). medication-read-order-guard.test.ts
holds the tree to it. medication-read-order.test.ts writes tying rows in
the opposite order to the one each read promises, through the medication
list and today's intakes, and fails without the tie-breakers.

The parity suite's own state reader had the same gap: it sorts rows by
content with the ids blanked, and rows that tie then kept whatever order
the unordered read returned, so their <id:N> labels swapped under a
loaded run. Its reads are ordered by id, which for a cuid is the order
the rows were written in.

The JSON import's schema left the route, so its entry in the inline
schema strictness register goes; the schema module says why its objects
are not strict.
2026-10-23 21:54:25 +02:00
Marc-André Bombeck 7936ab9a57 feat(admin): say what a queued restore is waiting for
A restore never runs beside a backup pass or another large job: the
heavy lane puts its delivery back in the queue for a minute at a time.
The job's own row says queued for the whole wait, and the console said
the restore starts "as soon as the background worker picks it up",
which read as a stuck worker while a backup ran.

The status routes now ask the queue. A delivery the lane put back sits
in created with a start_after ahead of now, and the active row of a
heavy queue is what holds the lane. The job view carries
waiting: "backup" | "busy" | null, and the console says it waits for
the running backup, or for another large job, and since when it has
been queued, in all nine locales. A queue that cannot be asked answers
no wait and is reported at warn.

The job view and its reads move to backup-restore-view.ts, which keeps
the worker module under its size baseline.
2026-10-23 21:43:55 +02:00
Marc-André Bombeck 6a87810dbf fix(jobs): take the heavy lane from a holder that outlived its expiry
A hold ended only when its handler settled. pg-boss fails a job at its
expiry and fires its abort signal but does not stop the handler, so a
backup that hung held the lane for the life of the process: every heavy
job, a restore an operator started among them, went back to the queue
every minute from then on, and the only trace was one warn line after
two hours.

A hold now carries the longest expiry of the jobs it runs. A job that
finds the lane held past that expiry and one deferral of grace reports
job.heavy_lane.holder_expired at error and takes the lane; the old
handler, should it ever settle, releases only a hold that is still its
own.
2026-10-23 21:43:55 +02:00
Marc-André Bombeck a840e30fac feat(import): carry every field the app's own files write
The JSON import read six measurement fields and six mood fields while
the full backup and the JSON export carry twice that. A sleep night came
back as stageless rows, a range reading as a single value, an ECG alert
without its verdict, and a mood entry without its note, its level-A
values, its zone, its tag and factor links or its day context.

The import now reads valueMin/valueMax, sleepStage,
rhythmClassification and deviceType on a reading, and note, a1 to a5,
tz, factors, structuredTags and context on a mood entry. Each mood entry
is written with its links and context in one transaction, the way the
bulk mood ingest writes one. A link to a tag the importing account does
not have is dropped and named in droppedTagKeys / droppedFactorKeys, and
the import card says which. The schema moves to
src/lib/import/json/schema.ts so the guards can read it.

json-import-field-coverage.test.ts runs the JSON export and both backup
variants on an account with every column filled in: every field they
write is either read by the import or named with the reason it is not
(row ids, source, deletion records, ciphertext, sync bookkeeping), and a
reason for a field no exporter writes fails as stale. The round trip
imports each file into a fresh account and compares the re-export field
for field. The published contract is held to the runtime schema.
2026-10-23 21:33:06 +02:00
Marc-André Bombeck 43002d37e8 Merge remote-tracking branch 'origin/release/v1.46.0' into feat/v146-ui 2026-10-23 21:12:51 +02:00
Marc-André Bombeck a306a8617a test(e2e): give a queued restore longer than one lane deferral
The restore shares the heavy lane with the backup pass, and a restore
that finds the lane taken goes back to the queue for 60 seconds. The
journey waited exactly 60 seconds for it. On a loaded machine the pass
the journey had just started was still finishing its tail when the
restore came in, so the restore was deferred and started one second
after the wait ran out. The wait now holds one deferral and a second
yield, and the test timeout makes room for it.
2026-10-23 20:58:30 +02:00
Marc-André Bombeck ce0b6a27bf test(e2e): scan the command palette once it has finished opening
Both palette scans ran axe as soon as the dialog was visible. The
dialog fades in, and a scan that landed mid-fade read every colour
blended with the page behind it: on a loaded machine the selected
option's text measured 4.47:1 against the 4.5:1 floor. The scans now
wait, bounded, for the finite animations to finish first.
2026-10-23 20:56:41 +02:00
Marc-André Bombeck 7405e41119 test(e2e): read the glucose journey's list filtered to glucose
The journey counted glucose rows on the unfiltered list, which shows
the account's newest page of every type. Other specs keep writing
readings to the same account, so on a database a few runs old the
reading typed for yesterday fell off that page and the count read one
short. The list now opens with `?type=BLOOD_GLUCOSE`, so it holds
exactly the rows the spec owns.
2026-10-23 20:40:31 +02:00
haritowa ce6c752864 refactor(documents): name the steps of the page text joiner
pageText worked on raw text-matrix slots and bare numbers. Each step now
has a name: positionOf reads where a run starts and ends and its font
size, startsNewLine says when the baseline moved by more than half a font
size, and leavesWordGap says when the gap is wider than a tenth of the
font size. The output is the same on every test PDF.
2026-10-23 18:37:23 +00:00
haritowa 2c5e9a80b9 refactor(documents): type the text read with pdfjs' own declarations
extractPdfText cast the dynamic import to a hand-written shape
(`as unknown as`), so the compiler could not check the call against
pdfjs. It now uses pdfjs' own types: PDFDocumentLoadingTask, TextItem and
TextMarkedContent, all type-only imports that are erased at build.

That check found a dead option at once: pdfjs 6 has no isEvalSupported.
The option went away with the font-program eval path it used to switch
off, so it did nothing here; it is removed.
2026-10-23 18:35:17 +00:00
haritowa 85bef667dd test(documents): read files the way the realism ratchet expects
The test-realism ratchet counts a test that calls readFileSync and names
a source file. The guard now reads source through readSourceText, and the
two one-process tests no longer name their twin file in a comment. Their
doc comments are rewritten in plainer words.
2026-10-23 17:56:38 +00:00
Marc-André Bombeck 879473305c Merge remote-tracking branch 'origin/release/v1.46.0' into feat/v146-ui
# Conflicts:
#	src/__tests__/baselines/source-function-lines.json
#	src/__tests__/baselines/version-stamp-comments.json
2026-10-23 19:54:08 +02:00
Marc-André Bombeck 803ef73a4d Merge remote-tracking branch 'origin/release/v1.46.0' into feat/v146-tablet 2026-10-23 19:53:46 +02:00
Marc-André Bombeck fd34ee13b9 fix(charts): round y ticks, calendar x ticks and labels beside the plot
The value chart's axes and labels, as drawn on a 90-day blood pressure
view on a tablet:

- Y ticks were whatever Recharts split the padded domain into, so the
  labels read 57, 83, 109. The domain is now widened to a 1/2/5 step
  and every tick is handed to the axis, evenly spaced and all drawn.
- X ticks were every n-th point, so their dates drifted with the data.
  They now fall on a calendar grid (days, Mondays, two weeks, months,
  quarters, years), each on the point nearest the grid line, and the
  step is the smallest whose labels fit the measured plot width.
- The "your average" label sat inside the plot over the readings, and a
  target zone's label was clipped to its first letter. Both now sit
  beside the plot, in a right margin sized to the longest label.

A browser test draws the axes with Recharts and pins the tick values,
the Monday grid, and both labels outside the plot clear of every dot.
2026-10-23 19:53:28 +02:00
Marc-André Bombeck d5ca594bd7 fix(charts): mark only the chart's own point days in the day rug
On an index or band axis the rug placed every day that holds anything
by interpolating it between the two nearest points. A sparse series
beside a daily one squeezed a week of other days into one gap, and the
dots ran together into bars. A day now gets a dot on those axes only
when it is one of the chart's points; a time axis keeps every day of
its span.
2026-10-23 19:53:28 +02:00
Marc-André Bombeck 4bf4631b6a test(e2e): check the shell's bottom edge under a home-indicator inset
With a 20 px inset set through --safe-area-bottom, at iPad sizes in both
orientations and in Split View: the last row of pixels belongs to the
rail and main, or to the bar, never to the page behind the shell; the
rail runs to the edge while its account row stops above the inset; the
root carries the content colour; and the end of the page clears the
inset, the bar and the Coach button.

The Coach button is also checked against the last line of the nested
layouts (an Insights metric page, a values list, the Insights overview,
two Settings sections) at inset 0 and 20, with an ai block that makes it
render.
2026-10-23 19:44:52 +02:00
Marc-André Bombeck d2be2b181c fix(shell): read the home-indicator inset through one property
The rail, main, the bottom bar and the Coach button each read
env(safe-area-inset-bottom) on their own. They now read
--safe-area-bottom, which is that env() value, so the four always agree
and a test can set an inset that no desktop browser reports.

The app shell's root also states the content colour itself instead of
leaving it to propagate from the body. A WKWebView browser on iPadOS
(Edge, Chrome, Firefox) can keep the strip over the home indicator out
of the page's layout and fill it with the colour it takes from the root;
that strip now meets main in the same colour.
2026-10-23 19:44:52 +02:00
haritowa 5090a6fdc1 chore(deps): say what the pdf-parse copy of pdfjs is still for
The override comment said pdf-parse's pdfjs 5.4.296 drives local text
extraction. Extraction now reads with the app's own pdfjs; only the tests
still use pdf-parse, which has to stay on pdfjs 5 because its teardown
calls an API pdfjs 6 removed. The range itself is unchanged.
2026-10-23 17:31:25 +00:00
Marc-André Bombeck f55a86a08c Merge remote-tracking branch 'origin/release/v1.46.0' into feat/v146-server
# Conflicts:
#	src/__tests__/baselines/source-function-lines.json
2026-10-23 19:28:58 +02:00
haritowa 275121391b test(documents): keep pdf-parse out of the server's runtime
A structural guard fails when any runtime file under src/ imports
pdf-parse again, which would bring its second pdfjs back into the server
process. The next.config comment on the tracing includes now describes the
text path as it is, and the extractor no longer turns an empty
end-of-line run into a blank line of its own.
2026-10-23 17:28:28 +00:00
haritowa bb762135df fix(documents): read a PDF's text with the app's own pdfjs
Local text extraction went through pdf-parse, which pins its own
pdfjs-dist (5.4.296) beside the app's (6.4.299), and both copies take
their worker from globalThis.pdfjsWorker. Whichever ran first in a process
broke the other: after a text extraction every raster failed with "The
API version "6.4.299" does not match the Worker version "5.4.296""
(thumbnails raster-failed, vision reads of a PDF 422 extractFailed), and
after a raster the text extraction failed and the local content index
stayed empty without a trace.

extractPdfText now reads the text layer with getTextContent on the
pdfjs-dist the rasterizer uses, so only one pdfjs runs in the server.
Pointing pdf-parse at 6.4 instead is not enough: its destroy() calls
PDFDocumentProxy.destroy(), which pdfjs 6 no longer has, so every
document would skip its teardown. Two tests run text and raster in one
process, each order in its own file.
2026-10-23 17:28:28 +00:00
Marc-André Bombeck d3385478a7 feat(settings): switch Apple Health write-back per measurement token
Each measurement token row carries a switch, the mint form an opt-in, and
the card a notice when readings exist that no token reaches. Copy in all
nine locales. The endpoint catalogue moves to its own file and the
revoked list out of the token card.
2026-10-23 19:28:05 +02:00
Marc-André Bombeck d6774b770d feat(openapi): publish the Apple Health write-back contract
Declare appleHealthMirror on measurement rows and sync upserts, the list
filter, the measurementMirror sync domain, the token setting on the list,
the mint and the new PATCH, and the unattributed-readings summary.
2026-10-23 19:28:05 +02:00
Marc-André Bombeck bfa6873d96 feat(tokens): add a per-token Apple Health setting
PATCH /api/tokens/{id} sets mirrorToAppleHealth on a measurement token:
cookie session only, the caller's own tokens only, revoked ones included,
any other token refused with tokens.mirror.not_measurement_token. It
stamps mirrorChangedAt and audits only when the value changes. The mint
accepts the setting (off by default), the token list returns it, and
GET /api/tokens/measurements counts the EXTERNAL readings no token
reaches.
2026-10-23 19:28:05 +02:00
Marc-André Bombeck aab2e0ae7d feat(sync): re-deliver Apple Health write-back changes
Measurement upserts in /api/sync/changes carry appleHealthMirror. A new
measurementMirror domain delivers { id, appleHealthMirror } for the live
readings of every token whose setting changed past the cursor, keyed on
(token.mirrorChangedAt, measurement.id). Switching the setting writes the
token row only, so the readings keep their updatedAt and syncVersion and
nothing keyed on them recomputes. The domain is additive inside the v2
cursor and is left out of the retention expiry, since its watermark is a
setting's age, not a deletion's.
2026-10-23 19:28:04 +02:00
Marc-André Bombeck 8d10e5ab94 feat(measurements): resolve each reading's Apple Health write-back
A reading sent with a measurements:write token keeps its EXTERNAL source
and now points at the token that carried it (measurements.ingest_token_id).
Every stored row answers a server-resolved appleHealthMirror: true for
WITHINGS, IMPORT and MANUAL, the token's choice for EXTERNAL, false
otherwise. The plain list accepts appleHealthMirror=true so a client can
page exactly those rows.

Migration 0476 adds the columns and links existing EXTERNAL readings only
where one measurement token provably sent them; 0477 builds the index
concurrently. Backups carry the link and a restore keeps it only for a
token the account still holds. A guard holds every EXTERNAL writer to
setting the link.
2026-10-23 19:27:52 +02:00
Marc-André Bombeck 9d1147a052 fix(dashboard): fetch a snapshot restored from the offline copy on first mount
The offline persister restores the last dashboard snapshot at boot and
marks it invalidated so the first mount refetches it, but the snapshot
cell read refetchOnMount: false and never did. A page reloaded right
after a write, before the persister had flushed the newer snapshot,
painted the pre-write dashboard until the two-minute poll; the weight
just entered had no tile. A cell with live data still reads as it is on
remount; a restored, invalidated one fetches.
2026-10-23 19:25:30 +02:00
Marc-André Bombeck 0470e4e850 test(guards): read the stripper parity sweep through the shared source index
A test that reads source by path counts against the realism ratchet;
the sweep reads every file through readSourceText like the other
guards, which also shares the reads with them.
2026-10-23 18:54:32 +02:00
Marc-André Bombeck 4748091636 Merge branch 'release/v1.46.0' into feat/v146-ui 2026-10-23 18:30:00 +02:00
Marc-André Bombeck 5273a8d899 test(e2e): give the vault specs their own accounts, and clear every bucket at setup
The vault's layout is account state. documents.spec read the default
while the layout journey in v142-qol turned it on the shared account,
so which one failed depended on the order they ran in. Each now runs on
a vault account of its own; the vault fixture seeds the same corpus
under ids tagged per account, so two vaults never claim one row.

Global setup clears every rate-limit bucket keyed on a fixture account
or on the loopback addresses the suite calls from, not only the login
and share buckets: the check-in, write, export and import buckets
outlive a run by up to an hour, and a second run on the same database
met the first run's counts. A developer's own accounts are left alone.
The layout journey waits for its own write rather than whichever layout
write answers first.
2026-10-23 18:29:54 +02:00
Marc-André Bombeck 0e7a186677 test(ratchet): lower the shell and rail baselines
sidebar-nav.tsx drops under 800 lines with its scroll region in a module
of its own, and AuthShell and SidebarNav are shorter.
2026-10-23 18:24:30 +02:00
Marc-André Bombeck 9cd2236295 Merge remote-tracking branch 'origin/release/v1.46.0' into feat/v146-tablet 2026-10-23 18:23:55 +02:00
Marc-André Bombeck 798747f987 test(e2e): walk the app shell at every tablet size
tablet-shell.spec.ts runs on WebKit with an iPad profile (webkit-tablet,
in the WebKit job) and on Chromium with an Android tablet profile
(chromium-tablet, in the shards), across iPad mini to iPad Pro 13 in
both orientations, Safari's bars, Split View and Slide Over. It checks
that the shell fills the window and leaves the document nothing to
scroll, that every rail destination scrolls into view on top and takes a
tap, that the scroll cue follows the overflow, that main reaches its
last line clear of the bottom bar, that the page scrolls again after a
sheet and a dialog close, and that a tab strip leaves the vertical pan
to the page.

ui-standards records the shell and rail rules.
2026-10-23 18:23:49 +02:00
Marc-André Bombeck 22976337cb fix(tabs): leave the vertical pan to the page
touch-pan-x on the tab strip refused a vertical pan that began on it, so
a swipe up or down over a tab strip scrolled nothing. The strip already
pans sideways through its own overflow.
2026-10-23 18:23:49 +02:00
Marc-André Bombeck 1f4c1bd2f0 fix(rail): keep every destination reachable under a finger
A touch tablet keeps the rail's 44 px rows, so in landscape three to
seven destinations sit below the fold, and the list has no scrollbar at
rest. The edge fade alone did not read as "swipe here", and a swipe that
reached an end chained to the document, which held the next one.

The list region moves into its own module. Under a coarse pointer it
contains its overscroll, keeps a focused row clear of a new scroll cue,
and shows that cue at an edge with more destinations; a tap scrolls the
list most of a screen on. The cue stays out of the focus order and the
accessibility tree: the keyboard and screen readers already reach every
row. The rail takes the home indicator's inset so the account row stays
clear of it. A mouse sees no change.
2026-10-23 18:23:48 +02:00
Marc-André Bombeck 6ad61cdd1f fix(shell): take the window's edges and keep the document still
The authenticated shell was sized by h-dvh. On iPadOS Safari that value
and the area the browser lays out disagree after a rotation and under the
floating toolbars: a short value left a strip of page colour under the
shell with the last card cut off above it, a tall one let the document
scroll by the difference and take the swipes meant for main.

The shell now takes the window's edges (absolute inset-0 against the
initial containing block), and the document under it gets no scroll and
no rubber band (data-scroll="shell": overflow hidden, overscroll none,
height 100% so the box survives a dialog's scroll lock making the body
relative). Not fixed: a fixed box re-rasterises every page in a layer of
its own and traps the bottom bar's z-index.

The desktop shell's main now clears the home indicator of an installed
app, and the page frame clears the Coach button there with pb-24: the
button sits 5.5rem up, so pb-20 left the last 8 px under it. The
sub-shell grid floor follows the new padding.
2026-10-23 18:23:48 +02:00
Marc-André Bombeck 3b6e0bf0fb fix(i18n): write the locale to the account once per tab, not on every page
Every page load mounts the locale provider, and its mount effect sent
PUT /api/auth/me/locale with a value that had not changed. Every
authenticated write is charged to the account's shared write bucket, so
each page view spent one write. The mount write now happens once per tab
session and locale; a switch still writes at once.
2026-10-23 18:09:15 +02:00
Marc-André Bombeck c0d387025d test(guards): strip comments the way the compiler reads them, in every guard
Forty-two guard files carried their own comment stripper, block comments
first in one non-greedy pass, so a /* inside a line comment opened a
block that hid the code after it, and the stricter copies hid code
behind a /* inside a string, which every e2e route glob has. They all
read through the shared stripComments now, and it scans the way the
parser does: strings, template literals and regular expressions keep
every character, a comment inside a template expression goes, and line
numbers are kept. A parity test strips every source file both ways,
with the scanner and with the TypeScript parser, and fails on the first
that differs.

No guard reports anything new. Each one fails when the stripper returns
nothing; three of them passed on an empty file and assert that the code
they ban is read at all now.
2026-10-23 18:09:10 +02:00
Marc-André Bombeck 584f799da3 test: drop the timings of test files that no longer exist
Thirteen of the table's files had been deleted or renamed since it was
written. The entries were dead weight; nothing reads a timing for a file
that is not there.
2026-10-23 17:54:22 +02:00
Marc-André Bombeck 3d26cee566 fix(import): take the app's own full backup and JSON export back
The JSON import refused the app's own files: the full backup writes an
empty note, tag list or glucose context as null and the JSON export as
an empty string, and the import schema read either as a value of the
wrong type. The file is now read in the exporter's terms before it is
validated: the export's envelope is opened, every null or empty field
is treated as absent, a backup row carrying a deletion is left out and
counted as skipped, and a unit the app converts on every other way in
is converted to the stored one.

The round trip also showed the portable backup dropping what a reading
itself says: glucose context, sleep stage, range bounds, rhythm and
device. They ride the portable file now, and a portable restore puts
them back. Sync bookkeeping stays out.

An integration test exports an account three ways (full backup, JSON
export, measurement CSV), imports each into a fresh account and compares
what the import carries.
2026-10-23 17:33:58 +02:00
Marc-André Bombeck 8a11b7bfc6 fix(charts): measure axis labels at the size they are drawn under reduced motion
The reduced-motion backstop gives every element a near-zero transition,
and the default transition property is all, so the font size Recharts
writes into its measurement span started a transition and the read in
the same task returned the previous size. Labels were sized at whatever
the last one had, or at the inherited 16 px: the last date label moved
in from the edge and value labels that fit were dropped as colliding,
differently from one visit to the next. Nothing in the measurement host
is ever seen, so nothing in it transitions now. A browser test pins the
read under reduced motion and without it.
2026-10-23 17:21:06 +02:00
Marc-André Bombeck a0dce10c1a fix(activity): word the refusals that now reach a delegate's feed
The custom-metric create and update refusals and the medication layout
refusal are filed through auditLog now, so a delegate's refused request
reaches the owner's activity feed, where it read as the generic change.
Each gets its own sentence in every bundle; the refusal sentences live in
their own function. The audit write guard states its floor, and the
schedule-reader guard names the medication create and edit, which answer
through medicationDetail.
2026-10-23 17:19:21 +02:00
Marc-André Bombeck fb9cd9b965 refactor(ai): move the stored-key opener and the Anthropic host check out of provider.ts
Both are self-contained; provider.ts stays within its size baseline.
2026-10-23 17:19:21 +02:00
Marc-André Bombeck c82ed69549 Merge remote-tracking branch 'origin/release/v1.46.0' into feat/v146-server
# Conflicts:
#	src/__tests__/baselines/source-file-lines.json
2026-10-23 17:18:53 +02:00
Marc-André Bombeck 51cba52751 fix(medications): answer writes with the shapes the contract declares
The create, its mirror replay and the edit answered the written row
without the read-model fields the declared resource carries (reminder
state, stock and runway, actionability, course status, composition,
links), while the detail read answered them. They answer the read model
now, built from the row they just wrote (medicationDetail), so a client
reads back exactly what GET /api/medications/{id} would say.

A dose write that converged onto an existing slot answered the slot
upsert's narrow row, without userId, medicationId, updatedAt,
autoRecorded, autoMissed or attributionSource. The upsert reads and
returns the whole event row, the same shape a write that inserted its own
answers. The parity walks drop their contract skips and are re-pinned.
2026-10-23 17:10:02 +02:00
Marc-André Bombeck 521b85bfbd fix(openapi): declare the mirror replay 200 of a medication create
A mirror create re-posting a pair the caller already holds has always
answered the existing medication with 200, as the description says; only
the 201 was declared.
2026-10-23 17:10:02 +02:00
Marc-André Bombeck 55a6287069 fix(audit): file every refused request through auditLog()
Twelve routes wrote their <surface>.validation-failed row with a bare
prisma.auditLog.create, so they skipped the two decisions auditLog makes:
an owner's own refused request stays on the wide event
(meta.rejected_attempt) instead of becoming a row, and a delegate's names
the delegate as the actor. The bulk medication and mood ingests, the
layout, overlay, device, health-sync, metric and ingest routes all go
through auditLog now, and a structural guard keeps raw writes to the one
writer and the briefing failure marker. The unit tests assert the auditLog
call, and the bulk walks are re-pinned without the owner's refusal row.
2026-10-23 16:54:57 +02:00
Marc-André Bombeck 89b6189206 test(measurements): call the fire-and-forget mock with a starter function 2026-10-23 16:54:56 +02:00
Marc-André Bombeck cf37f833e8 fix(insights): keep the briefing blocks of a reply that validates
The briefing prompt asks for dailyBriefing, trendAnnotations and
storyboardAnnotations beside the insight fields, and the briefing card,
the dashboard snapshot and the today read consume them. insightResultSchema
was a strict object, so it dropped exactly those blocks from a reply that
validated, while a reply that failed validation kept them. It is a loose
object now: the named fields are checked and the rest of the reply stays.
2026-10-23 16:44:50 +02:00
Marc-André Bombeck b02caed221 fix(ai): treat a stored provider key that cannot be opened as no key
A key id retired from ENCRYPTION_KEYS, or a corrupt row, made decrypt throw
inside the provider builders, and a briefing generation answered 500. An
unreadable key now drops its entry from the chain like a missing one, with a
warning on the event, so the generation answers the no-provider 422
(ai.provider.none). The connection test reads it as no stored key. The
parity walk holds that case to the contract again.
2026-10-23 16:44:50 +02:00
Marc-André Bombeck 551fbbbdec Merge branch 'release/v1.46.0' into feat/v146-ui 2026-10-23 16:44:26 +02:00
Marc-André Bombeck 976b2ba0bc fix(workouts): read the stored route sample shape for splits and the GPX export
Both ingests store each route point's sample as { t, speedMs?, hr? }, while
computeSplits parsed every sample as a bare instant and the GPX export
skipped anything that was not a string. Splits were therefore null for
every ingested track, and an exported track carried no times. One reader,
routeSampleInstant, takes either shape. The workout detail walk is
re-pinned: its ingested run now answers its first kilometre split.
2026-10-23 16:37:58 +02:00
Marc-André Bombeck 06d55afc46 fix(deps): raise the ip-address floor to 10.7.4
10.7.3 and earlier take exponential time on a crafted Address6 zone
identifier. The copy under the MCP SDK's rate limiter reaches runtime, and
the one under the registry client resolves to the same version now.
2026-10-23 16:33:05 +02:00
Marc-André Bombeck 4e484c3dd2 test(e2e): clear the account's write bucket when the setup flow resets
Every authenticated write is charged to the shared per-account bucket,
and the locale sweep drives one account through eighteen setup runs in
well under a minute, so its last case met the write ceiling. The reset
already cleared the flow's own buckets; it now clears that one too.
2026-10-23 16:32:50 +02:00
Marc-André Bombeck b4166cdc84 fix(logging): keep detached work from renaming the request's wide event
Work a request starts and does not wait for runs inside the request's
context, so an annotate({ action }) from it landed on the request's event
whenever it ran before the event was emitted. The measurement tail's
status re-warm logged a hand-entered reading as
insights.status.invalidate.skipped on some requests and as
measurement.create on others.

runDetached starts such work so that its action is dropped for the event
it was detached from; meta, warnings and the auth it reads stay as they
were, and an event the work opens for itself keeps its own action.
fireAndForget now takes a function and starts it detached, and every
trackBackgroundTask handle on a request path is built with runDetached,
which a structural guard holds.
2026-10-23 16:32:10 +02:00
Marc-André Bombeck cfa5fe537b Merge remote-tracking branch 'origin/release/v1.46.0' into feat/v146-server 2026-10-23 16:15:37 +02:00
Marc-André Bombeck 419d47c862 test(e2e): hand the shared account back on the default vault layout
The layout journey left the shared account on the stacked arrangement,
and the month-grid tests that read the default flowing one failed
whenever they ran after it.
2026-10-23 16:15:36 +02:00
Marc-André Bombeck 453bf6c6f3 test(guards): let the file-size floor follow the ratchet down
The splits on this line took the files over 800 lines from 115 to 96,
under the liveness floor of 100. The floor guards the matcher, so it
moves to 80, below the real count.
2026-10-23 15:54:10 +02:00
Marc-André Bombeck 18ff14ba1a fix(openapi): declare the body-cap 413 and the outbound-screen 502 of a briefing generation
Both have always been answered; neither was declared. The parity walk now
checks them against the contract instead of skipping it.
2026-10-23 15:53:59 +02:00
Marc-André Bombeck 31048e5e63 test: tighten the size and version-stamp baselines after the insights moves 2026-10-23 15:45:16 +02:00
Marc-André Bombeck af15be4708 Merge branch 'feat/v146-server-insights' into feat/v146-server 2026-10-23 15:44:47 +02:00
Marc-André Bombeck 1a5d79f24e refactor(insights): move the briefing read and generation out of their route 2026-10-23 15:44:04 +02:00
Marc-André Bombeck c22c302ebe test(analytics): read the default body where it now lives 2026-10-23 15:41:54 +02:00
Marc-André Bombeck db623ef8bc test(insights): pin the briefing read and generation before moving them 2026-10-23 15:31:36 +02:00
Marc-André Bombeck 356d947ab0 refactor(dashboard): move the summary builder out of its route 2026-10-23 15:25:48 +02:00
Marc-André Bombeck 98875debf4 refactor(insights): move the comprehensive overview builder out of its route 2026-10-23 15:25:20 +02:00
Marc-André Bombeck 1b32217844 test(dashboard): pin the summary before moving its builder 2026-10-23 15:20:27 +02:00
Marc-André Bombeck af5cf1ace2 refactor(auth): move the SSO callback's account resolution and sign-in endings out of its route 2026-10-23 15:17:27 +02:00
Marc-André Bombeck d76497ec13 test(auth): pin the SSO callback before moving its sign-in resolution 2026-10-23 15:13:25 +02:00
Marc-André Bombeck 91a5464aa3 refactor(documents): move the vault upload reading and the list read out of their route 2026-10-23 15:10:26 +02:00
Marc-André Bombeck 2e13b7754e test(insights): pin the comprehensive overview before moving its builder 2026-10-23 15:05:10 +02:00
Marc-André Bombeck 69527d926d test(documents): pin the vault upload and list before moving them 2026-10-23 15:04:39 +02:00
Marc-André Bombeck b75219d704 fix(openapi): declare the duplicate flags and the missing-link 404 of a document upload
A duplicate upload has always answered meta.duplicate (and meta.deleted for
a deleted source key) beside the row, as the description says, but the
envelope schema declared neither. An episode or visit id that is not the
caller's has always answered 404, which no response declared.
2026-10-23 15:04:37 +02:00
Marc-André Bombeck de96891ff5 refactor(import): move the CSV import writer out of its route 2026-10-23 15:00:35 +02:00
Marc-André Bombeck 94ad53756e test(import): pin the CSV import before moving its writer 2026-10-23 14:58:14 +02:00
Marc-André Bombeck 5d9a9107fa test(import): record the status re-warm tail in the JSON import walk
Run for real it is detached from the request but inside its context, so
its annotate lands on the request's wide event on some runs and misses it
on others.
2026-10-23 14:58:14 +02:00
Marc-André Bombeck 7f06e28c37 test(kit): let callRoute send a raw text body 2026-10-23 14:58:13 +02:00
Marc-André Bombeck c6238cbe14 test: tighten the size and version-stamp baselines after the medication moves 2026-10-23 14:52:43 +02:00
Marc-André Bombeck 40eb1255b7 Merge branch 'feat/v146-server-meds2' into feat/v146-server
# Conflicts:
#	src/__tests__/external-id-stability-guard.test.ts
2026-10-23 14:52:03 +02:00
Marc-André Bombeck 11a7124d12 refactor(import): move the JSON import writers out of their route 2026-10-23 14:51:51 +02:00
Marc-André Bombeck a1ab7136f5 test(medications): reach the actions of moved handler bodies in the activity inventory
The inventory reads a delegated route and its direct imports. With the medication handlers moved into lib/medications, the lifecycle reconciliation a dose write files was two levels from its route and dropped out of the walk. A route's handler-body modules now count at the route's own level, listed in one map whose every entry must be a direct import of its route.
2026-10-23 14:49:42 +02:00
Marc-André Bombeck ed12625ab6 test(import): pin the JSON import before moving its writers 2026-10-23 14:48:51 +02:00
Marc-André Bombeck 24af254249 refactor(medications): move the bulk dose ingest out of its route
POST /api/medications/intake/bulk keeps authentication, its own bulk bucket, the body with its size and schema refusals, the payload schema and the HTTP mapping. The ingest, one 846-line function, moves to lib/medications/intake-bulk/ in the order it always ran: the ownership read, the Apple mirror gate and the batch pre-reads (ingest.ts), each entry's refusals, site, write bookkeeping and P2002 answer (entry.ts), its slot attribution (entry-attribution.ts) and its slot, replay, probe or standalone write (entry-write.ts), then the audit row, the dose days, the snooze, the tails, the arrival record and the checkpoint. The two identical canonical-slot write blocks of the loop are one function now. The route falls from 1044 to 231 lines; no function in the new modules is over 70.

The parity walk recorded before the move (medication-intake-bulk.parity) is unchanged. The per-entry external-id markers name the module that classifies each entry, the batch-surface and key-scope legs read the moved code, and the intake-tracking allowlist and the dense-read guard follow it.
2026-10-23 14:47:57 +02:00
Marc-André Bombeck 7a21796b63 docs: keep block openers out of line comments in server sources
A '/*' inside a line comment opens a block for every guard that strips
block comments first, and the code after it disappears from that guard's
view. Four server comments and one new one did this; reword them.
2026-10-23 14:47:00 +02:00
Marc-André Bombeck ad27e5b89d refactor(sync): move the delta feed's domain pages out of its route 2026-10-23 14:45:26 +02:00
Marc-André Bombeck 1b5e586a06 test(medications): strip line comments first in the intake tracking guard
The guard stripped block comments before line comments, so a '/*' inside a
line comment opened a block that swallowed the code after it. The delta
feed's intake read sat behind such a comment and was never seen. The
shared stripper handles that order; with it the feed shows up, and it is
listed: it mirrors recorded rows and computes no rate.
2026-10-23 14:45:08 +02:00
Marc-André Bombeck 2a7833d7dc test(medications): pin the bulk dose ingest before it moves
One walk through POST /api/medications/intake/bulk: a mixed batch with one entry per per-entry outcome (unknown and record-only medications, unstable keys, band, pinned and refused pins, named-slot skips, pending echoes, a standalone dose with site, override and key and its replay, a disallowed site, Apple doses with an in-batch repeat and an extra dose on a held slot, a skip converging onto the standalone dose), the same batch again, a background and a backfill sync with the arrival rows and checkpoint they write, the batch refusals, and the callers the route admits and refuses.
2026-10-23 14:42:32 +02:00
Marc-André Bombeck 29620ffa49 test(sync): pin the delta feed before moving its domain pages 2026-10-23 14:40:37 +02:00
Marc-André Bombeck 2fcc2bdab2 refactor(medications): move the today list, the compliance buckets and the status update out of their route
GET and POST /api/medications/intake keep authentication, the query and body parsing with their refusals, the 404 and the HTTP mapping. The today list moves to lib/medications/intake-today.ts and the cached compliance buckets to intake-compliance.ts. The status update, one 432-line callback, moves to lib/medications/update-intake-status/ in the order it always ran: the event lookup, the record-only rule, the delegated refusals and the injection site (admission.ts), the take in place or onto another slot (take.ts), then the skip or snooze, the audit row, the rollups and the tails (update-intake-status.ts). The route falls from 634 to 149 lines; no function in the new modules is over 70.

The parity walk recorded before the move (medication-intake-status.parity) is unchanged. The sharing and activity-verb guards read the status-update modules beside the route, and the dense-read guard keeps the moved code in scope.
2026-10-23 14:37:47 +02:00
Marc-André Bombeck 3e9ed8e2e3 test(mood): name the raced-upsert winner read the bulk ingest now carries 2026-10-23 14:37:20 +02:00
Marc-André Bombeck c40d728b47 Merge branch 'release/v1.46.0' into feat/v146-server
# Conflicts:
#	src/__tests__/baselines/source-function-lines.json
#	src/__tests__/baselines/version-stamp-comments.json
2026-10-23 14:35:19 +02:00
Marc-André Bombeck 704b51ca02 test: size the dense pulse seed's transactions for a loaded host
Each month of the million-row seed runs in an interactive transaction with
an explicit timeout. The batch form ran under Prisma's 5 s default and a
month took 9.9 s at a high load average, expiring the transaction before
the heap assertion ran.
2026-10-23 14:35:01 +02:00
Marc-André Bombeck 315ee0ff98 refactor(mood): move the bulk ingest out of its route 2026-10-23 14:34:36 +02:00
Marc-André Bombeck 6555acefe1 Merge branch 'release/v1.46.0' into feat/v146-ui 2026-10-23 14:33:21 +02:00
Marc-André Bombeck baa1ba018d test(medications): pin the today list, the compliance buckets and the status update before they move
One walk through GET and POST /api/medications/intake: today's list with its projection and every status a row reads as, the compliance buckets cold and cached, a take in place, a take that re-binds to another slot, a pin and a pin that is no slot, a skip, both snoozes, the not-found answers, the record-only rule both ways, the injection-site refusal and an allowed site, the schema refusals, and a delegate marking an open dose and refused a snooze and a correction.
2026-10-23 14:32:52 +02:00
Marc-André Bombeck 74e08e6906 test(mood): pin the bulk mood ingest before moving it into a service 2026-10-23 14:30:19 +02:00
Marc-André Bombeck c4ee519012 refactor(medications): move the per-medication dose write and history out of their route
POST and GET /api/medications/{id}/intake keep authentication, the intake and ownership guards, the body and query parsing with their refusals, the delegated outcome-change refusal and the HTTP mapping. The dose write, one 607-line function, moves to lib/medications/record-intake/ in the order it always ran: the injection-site check and the slot attribution (attribution.ts), the replay and dedup answers and the slot, probe or standalone write (write.ts), then the inventory, the audit row, the rollup, the lifecycle and the tails (record-intake.ts). The history list moves to list-intake-events.ts. The route falls from 767 to 200 lines; no function in the new modules is over 75.

The parity walk recorded before the move (medication-intake.parity) is unchanged. The activity-verb guard now reads the awaited audit actions of a delegable write's handler modules beside the route, for this route and for the medication create that moved before it, and holds each module to its route's imports; the sharing guard, the intake-tracking allowlist and the dense-read guard follow the moved code.
2026-10-23 14:29:58 +02:00
Marc-André Bombeck af53d3d6f0 test(medications): pin the per-medication dose write and history before they move
One walk through POST and GET /api/medications/{id}/intake: band, standalone, named-slot, pinned and backfilled doses, skips onto slots and onto a standalone dose, the dedup window, both idempotency forms and their replays, the delegated refusal on both write paths, the injection-site and pin refusals, a one-shot dose, the schema refusals, and the history list in its filters. The parity step can now rewrite an id carried inside a longer string, and a refusal's unawaited audit row is waited for on the real timer so the pinned clock does not move.
2026-10-23 14:21:24 +02:00
Marc-André Bombeck a60111cc9d Merge branch 'feat/v146-ui-browser' into feat/v146-ui
# Conflicts:
#	src/__tests__/baselines/test-source-reads.json
#	src/__tests__/baselines/version-stamp-comments.json
2026-10-23 14:20:08 +02:00
Marc-André Bombeck 8a6c6bfdd7 Merge branch 'feat/v146-ui-e2e' into feat/v146-ui 2026-10-23 14:19:29 +02:00
Marc-André Bombeck da937718bc refactor(measurements): move the series readers out of their route
GET /api/measurements/series keeps authentication, the query and its
refusal; readSeries (lib/measurements/series/read-series.ts) picks the
reader in the old order (sleep nights, paired blood pressure, the bucketed
read of a dense kind, the raw rows) and adds the direction, change,
reference lines and stat strip. The kinds, units and point shape live in
kinds.ts. The route falls from 752 to 95 lines.

The parity walk recorded before the move is unchanged. The guards on the
verdict resolver, the spread, the pulse day mean and the unit literals read
the new files, the last two with their scope widened to the series folder.
2026-10-23 14:13:52 +02:00
Marc-André Bombeck 870c8e053a Merge branch 'release/v1.46.0' into feat/v146-integrations 2026-10-23 14:13:32 +02:00
Marc-André Bombeck 967fa2c56b refactor(medications): move the medication read, edit and delete out of their route
GET, PUT and DELETE /api/medications/{id} keep authentication, the ownership guard, the body and the HTTP mapping. The read moves to lib/medications/read-medication.ts; the edit, one 886-line callback, moves to lib/medications/update-medication/, split along its stages in the order they always ran: the preflight checks and refusals, the schedule normalisation, the transaction (era archive, tombstones, the row update, the pause era, the course write) and the commit tail with its audit row. The delete moves to delete-medication.ts. The route falls from 1270 to 100 lines; no function in the new modules is over 115.

The parity walk recorded before the move (medication-detail.parity) is unchanged. The sharing guard reads the edit and delete modules for the handler body, the intake-tracking allowlists name the modules that now read the schedule and the open slots, the published-DTO leg reads the read module, and the dense-read guard keeps the moved code in scope.
2026-10-23 14:10:17 +02:00
Marc-André Bombeck b2f6c37c37 test(kit): pin parity walks to today where a route reads the database clock
Some read routes cut their windows in SQL against the database's now(),
which a pinned Date cannot reach; a walk pinned to a fixed date then reads
less of its record every day the calendar moves on. relativeToAnchor writes
every instant and day key of a snapshot relative to the start of the
current UTC day, so a walk can lay its record out from today and still read
the same snapshot on any day.

The series walk uses it with the clock unpinned; the analytics walk pins
the clock to noon of today, keeps its account in UTC and is re-recorded in
that form (its earlier snapshots proved the builder move before this
change).
2026-10-23 14:09:41 +02:00
Marc-André Bombeck 8feeaed473 test(measurements): pin the measurement series before moving its readers
A parity walk over GET /api/measurements/series through every reader it chooses between: the plain and ladder-picked rows, pulse with its spread, the paired blood pressure (two sources and a corrupt systolic), the sleep nights, glucose raw and day-bucketed, a dense stream bucketed per hour, a refused query and a delegate in another language. The readers cut their windows against the database clock, so the record is laid out from the start of the current UTC day and the snapshot holds instants and day keys relative to it.
2026-10-23 14:05:55 +02:00
Marc-André Bombeck 53bc663cbf test(security): follow a fresh-proof request into the hook that sends it
The backups section now hands its flow to the download, restore and
upload hooks. Each caller entry may name the file that sends the
request; the component still owns the flow and the dialog and must
import that hook and hand it the flow.
2026-10-23 14:04:03 +02:00
Marc-André Bombeck ad9e0d8732 test(day): read a chart and its same-named folder as one
The HealthChart split moved its layers, tooltip and day footer into
health-chart/, so the guard read the composer as a date-axis chart with
no doors. A component and the folder of its name are now one entry, a
tooltip action handed whole to the tooltip counts as wired, and the mount
floor counts the dashboard's one builder mount for its seven charts.
Drop two return-type exports nothing imports.
2026-10-23 14:01:17 +02:00
Marc-André Bombeck 817a922df5 refactor(workouts): move the workout detail read and the manual delete out of their route
GET keeps authentication, the module gate and the AI capability, and hands
the rest to readWorkoutDetail (lib/workouts/detail.ts): the stored row, the
canonical winner of its cluster, the enrichment reads and the body, as
named stages. DELETE keeps authentication and the HTTP answers; the
ownership check, the synced-source refusal and the transactional removal
with its records, audit, cache and detection tail move to
lib/workouts/delete-manual-workout.ts.

The parity walk recorded before the move is unchanged; the guards that name
the canonical-identity reads and the track readers point at the service.
2026-10-23 14:00:28 +02:00
Marc-André Bombeck 38baafcdd7 fix(openapi): admit the bare-instant route samples older tracks carry
A track stored before the object form holds one ISO instant per point, and the detail answers it as stored. The route-sample declaration accepts both forms.
2026-10-23 14:00:05 +02:00
Marc-André Bombeck e8510ae167 test(medications): pin the medication read, edit and delete before they move
One walk through GET, PUT and DELETE /api/medications/{id}: the read with stock, composition and courses, every edit branch the handler tells apart (schedule replace and echo, the grace bridge, pause and resume, the category rules, the record-only rule and both tracking transitions, the as-needed and one-shot refusals, the course refusals, a composition change), the refusals, and delegated access at each level. The parity step can now wait for a write the route does not await and read state outside the medication tables.
2026-10-23 13:59:41 +02:00
Marc-André Bombeck 32dec72564 test(workouts): pin the workout detail and delete before moving them into services
A parity walk over GET and DELETE /api/workouts/{id} on workouts landed through the batch ingest: the canonical row, the compact body, a hidden twin, a ride, another account's row, a missing one and the module switched off; a synced, a missing, another account's and a hand-entered workout with its record for the delete.
2026-10-23 13:57:18 +02:00
Marc-André Bombeck ab15096a6a fix(openapi): declare the route samples a workout detail answers
WorkoutRouteGeometry.sampleTimestamps was declared as an array of strings. Both track ingests (the workout batch and the Apple Health export) store one object per point, { t, speedMs?, hr? }, and the detail answers them as stored. The response is unchanged; the contract now says what it holds.
2026-10-23 13:57:17 +02:00
Marc-André Bombeck 51835fe1d0 refactor(analytics): move the default body builder out of its route
The route keeps authentication, the slice refusal, the summaries slice, the
cache read and the headers. buildAnalyticsResponse (446 lines) moves to
lib/analytics/response/default-body.ts and calls one module per block, in
its old order: the blood-pressure in-target windows, the glucose summaries
and clinical panel, the correlation hypotheses with their stored patterns,
the Health Score with its Rest Mode framing, and the sleep stages. The
route falls from 761 to 155 lines.

The parity walk recorded before the move is unchanged; the guards on the
glucose window and the bounded reads follow the code.
2026-10-23 13:52:57 +02:00
Marc-André Bombeck d24c544e98 test(analytics): pin the analytics response before moving its builder
A parity walk over GET /api/analytics on an account with a full record
(record-fixture.ts, reusable by the other read routes): an unknown slice,
the summaries slice and the default body cold and warm, the modules off, a
second language with a personal blood-pressure target, an empty account and
an anonymous call. Background work a request leaves running is settled
before the next one. The blood-pressure fast path's rollup row counts are
left out of the pinned wide event: they follow the rollup tier's coverage,
not the code, while the answer they fed stays pinned in the body.
2026-10-23 13:49:24 +02:00
Marc-André Bombeck c2a63307ed test: point guards at the split provider modules
The page-walk exception for the Google Health list walk follows
forEachDataPointPage into data-points.ts, and the workout provenance guard
reads the raw Polar and Strava sport labels where the mappers now live.
The write-step helper's tests assert the result it returns, including a
failed fold and a caller that re-warms the status cards itself.
2026-10-23 13:44:21 +02:00
Marc-André Bombeck 905d4b167c test(insights): write the status goldens with sorted keys
The goldens held their fields in the order the code assigned them. They are now written with every object's keys sorted, so the text depends only on content and diffs and compresses well; the parsed content of every golden is unchanged. They carry no clock reading or generated id: the clock is pinned and the ids are fixed.
2026-10-23 13:42:58 +02:00
Marc-André Bombeck 4794cdec88 refactor(insights): call normalizeLocale by its own name in the status routes
The status routes kept their old resolve*StatusLocale names as import aliases when the per-card locale resolvers were folded into normalizeLocale. They call it directly now.
2026-10-23 13:41:47 +02:00
Marc-André Bombeck 3f8c2a98dd fix(measurements): answer the day drill-down without the sealed note
The dayKey drill-down of GET /api/measurements answered the stored rows as
they were, notesEncrypted ciphertext included and the note itself unread.
It now selects the contract's columns by name and shapes each row like the
plain page: the note decrypted onto notes, the ciphertext never on the wire.

A route test walks the measurement reads over rows with a sealed note
(the page, the drill-down, the day-grouped list, an aggregate, the detail)
and fails on any sealed field; it failed on the drill-down before this
change. The parity walk drops its contract exemption for the drill-down.
2026-10-23 13:41:11 +02:00
Marc-André Bombeck 7b9737c7b1 test(kit): refuse a sealed field in any response the route kit drives
callRoute now fails a call whose JSON body carries a key ending in
Encrypted, or bytes serialised as an index-keyed object or a Buffer, on
every route it drives and regardless of the contract exemption. A sealed
column is decrypted or dropped before a response is built; one that leaks
is a defect, whatever the contract says. The kit's own test proves the
check finds both shapes and leaves an hour map alone.
2026-10-23 13:41:11 +02:00
Marc-André Bombeck 8b3c095df5 refactor(fitbit): keep the Fitbit jobs inside the Fitbit folder
The backfill, the sleep repair, the OAuth-state cleanup and the sync
handlers move from lib/jobs into lib/fitbit/jobs, beside the queue facts
and definitions the registry already spreads. Their tests move with them.
Removing the integration is now the folder, its routes, the registry
spread and the drain's two cases.

The updatedAt backfill guard also walks provider jobs folders, so the
moved backfill stays covered.
2026-10-23 13:33:11 +02:00
Marc-André Bombeck 82562a8df9 chore(knip): reach the browser test helper
The next/navigation shim is reached only through the browser project's
alias, and the helper's render module is a .tsx file under tests/,
which the project globs did not include.
2026-10-23 13:31:29 +02:00
Marc-André Bombeck 7e54b6be81 refactor(integrations): split the provider clients into wire types, endpoints and mappers
Oura, Polar, WHOOP and Strava each keep the transport and endpoints in
`client.ts` and gain `wire-types.ts` (the record shapes) and `map.ts` (the
pure record-to-row mappers, no I/O). Google Health's 1,600-line client
becomes `budget.ts` (slice budget and outcome tally), `request.ts` (error
detail, retry, rate limits), `data-points.ts` (the paginated walker) and a
small `client.ts` for the profile and roll-up reads; its `export * from
"./mappers"` facade is gone and its importers name the module that defines
what they use.

The code moved verbatim; the mapper goldens written before the move and
the provider integration tests pass unchanged.
2026-10-23 13:30:10 +02:00
Marc-André Bombeck 5eafed8dd5 refactor(admin): split the backups section
The section becomes a composer over backups/: the restore dialog with its
preview, the refusal, skip and schedule notices, the restore jobs and
their request, the upload flow, the run and download actions, and the
two row layouts sharing one action pair. The upload total is a pure
function and tested as one. The read-failure guard's 250-character
window had stopped reaching the restore preview's own failure lines; the
preview now sits in a smaller component, the guard sees it, and it is
listed as a deliberate exception with its reason.
2026-10-23 13:20:48 +02:00
Marc-André Bombeck 591824044b docs(testing): describe the critical journeys and their accounts 2026-10-23 13:19:47 +02:00
Marc-André Bombeck 232c3db6ef docs(testing): describe the browser project and its helper
Say which claims belong in *.browser.test.tsx, how to run the project
alone, and what tests/browser/ provides. The browser install guard now
names the browser project beside the hero-geometry check, since both
launch the headless shell the CI slices install.
2026-10-23 13:16:56 +02:00
Marc-André Bombeck 36fe53bbaa test: write the ratchet baselines back after the medication and widget moves 2026-10-23 13:16:46 +02:00
Marc-André Bombeck 392b2c47a9 test(mental-health): drive the check-in wizard in a real browser
One question at a time in the source order, Back keeping the answer,
the number keys and arrows, the review with a way back to every answer,
the optional PHQ-9 follow-up, a failed submit, and the note over an
unvalidated translation staying inside the viewport on every step. The
switch to the English original is carried in the answers, and the full
path from the overview posts the instrument, the reader's language and
the item scores. Questionnaire text loads through the production chunk
loader.

The wizard's markup cases are removed from the surface suite; the
loading-frame case stays there, since a browser loads the chunk before
a test can look. The browser project inlines an empty process.env for
Next's client modules.
2026-10-23 13:16:02 +02:00
Marc-André Bombeck c9917d2de0 refactor(settings): split the managed record settings forms
Each family form moves into managed-record/ beside a small kit (the
coercion helpers, the form footer and the switch row); the section keeps
the record-scoped read, the save and the family switch.
2026-10-23 13:13:55 +02:00
Marc-André Bombeck 041c33ec55 test(e2e): wait for hover transitions to settle before the check-in axe scans
The scan waited a fixed 300 ms after moving the pointer away. It now waits
until no CSS transition is running, which is the state the wait stood for.
2026-10-23 13:13:48 +02:00
Marc-André Bombeck 4e0e3019ad test: drop an auth-guard helper nothing reads 2026-10-23 13:13:06 +02:00
Marc-André Bombeck de970ac079 test(e2e): restore a person's full backup into their recreated account
The backup journey restored a snapshot over the account it came from. A
fifth act now follows the runbook's new-host path: a second account writes a
reading and a mood entry and downloads its full backup from the export card,
the account is recreated empty under the same id, the admin uploads the file
in the backups console and restores it, and the person signs in to the same
rows under the same ids. The restore wait is shared by both restores.
2026-10-23 13:12:45 +02:00
Marc-André Bombeck e626f2123f refactor(integrations): run one write step after every provider sync
`integrations/sync-kit/after-write.ts` is the tail every provider write
ends in: the arrivals go out, the touched days are refolded, the read
caches are marked stale back to the earliest touched reading, and the
touched status cards are re-warmed (through `afterMeasurementMutation`,
shared with the interactive routes); Withings also evicts the dashboard
snapshot. A full-history backfill refolds its whole touched range once
through `afterProviderBackfill`.

Withings, WHOOP, Polar, Nightscout, Google Health, Fitbit and Oura drop
their hand-written tails, and no provider module imports the comprehensive
insight generator any more; a guard holds the provider folders to that.
Differences from the copies: the status cards are re-warmed even when the
fold failed, and the caches are marked stale back to the earliest touched
reading instead of wholesale, so older closed blocks survive a sync.

Several provider unit tests mocked an export the old tails never reached
(their failures were caught and only logged); the mocks now name what the
code calls.
2026-10-23 13:12:23 +02:00
Marc-André Bombeck 7c1ed25915 refactor(documents): split the document detail sheet
The sheet becomes a composer over detail/: the original-file preview and
download controls, the detail read with its writes, the AI layer with its
gate and review-first apply, and the field, meta and footer sections.
Guards that read the sheet's source now read the detail folder.
2026-10-23 13:11:20 +02:00
Marc-André Bombeck b161ed3bd2 test(workouts): drive the manual workout form in a real browser
Pick a sport, type a duration and distance, press Save and read the
batch body: miles asked of an imperial reader arrive in metres, a blank
start counts back from now, a retry after a failed save reuses the
form's external id, and an edit re-posts the stored instants and the
fields the form does not show. Field errors, the pending state and the
phone layout are measured in the page.

The markup and edit-render cases are deleted from the old suite, which
keeps the save and delete helper checks as manual-workout-save.test.ts.
2026-10-23 13:09:01 +02:00
Marc-André Bombeck 3ab85f85f9 test(measurements): drive the measurement form in a real browser
Type into the form, press Save and read the request body off the fetch
boundary: blood pressure as one batch, a weight typed in lb stored in
kg, a decimal comma, glucose typed in mmol/L stored in mg/dL with its
meal context, a sleep typed as 7:30 stored as 450 minutes. Native
required validation, the pending state, a refused write's localised
message, the module-off type list, the last-used type and the menu's
Reset are covered too.

The unit-label and glucose-unit markup tests are deleted; their source
pins are replaced by the wire assertions. The module-types file keeps
its pure ownership checks and drops its render.

The browser helper gains a Date-only clock pin, clears localStorage
between tests, carries an envelope's meta, and answers the providers'
locale mirror without counting it as the component's request.
2026-10-23 13:07:29 +02:00
Marc-André Bombeck 34a93b883f test(e2e): a settings change persists on the server
The time-format choice is changed through the account settings and read
back after a reload, in a second browser context that shares only the
session cookie, and on /api/auth/me, on the settings journey's own account.
2026-10-23 13:04:04 +02:00
Marc-André Bombeck ac77e52e77 refactor(measurements): hand both row layouts one row context
The table and the card list each took nine props per row and passed
them on to the shared row actions. One MeasurementRowContext carries
them, and rowState derives a row's grouping, selection and drill-down
state in one place for both layouts.
2026-10-23 13:03:39 +02:00
Marc-André Bombeck 0e9daa51f9 test(e2e): read a WHO-5 check-in word for word in German and Brazilian Portuguese
The check-in journeys anchor on data attributes so they read the same in
every language, which leaves the words themselves unchecked. Two journeys on
the wellbeing account now read the stem, the first and last item and the
answer labels in German (the validated WHO translation, reproduced exactly)
and in Brazilian Portuguese (the translation written for the app, with its
note), submit, and check the score on screen against the assessment the
server stored, with its language and kind of wording.
2026-10-23 13:02:32 +02:00
Marc-André Bombeck cc36f46f10 test(filters): drive the URL filter rail in a real browser
A FilterBar over useUrlFilterSync now proves the contract end to end:
a facet picked from the open menu pushes a history entry, typed input
replaces the current one, a pill's clear and Reset leave a bare URL,
two writes in a row both land, Back and an edited address re-seed the
pills, and the open menu rests inside the viewport under its pill.

The markup tests for FilterBar and useUrlFilterSync are deleted; the
popper-placement source pin is replaced by the measured menu position.
The browser project re-bundles its dependencies on every run so a
cache warmed before a new file existed cannot load a second React.
2026-10-23 13:00:19 +02:00
Marc-André Bombeck 0bf6a54be0 test(e2e): import lab results from a CSV on an account of its own
The import journey ran on the shared account in both projects, so one
project's duplicate verdict could rest on the other's import. It now runs on
the labs journey's account, emptied first, checks that the dry run writes
nothing and the import writes exactly the accepted row, and finds the result
on the labs page before the second run.
2026-10-23 13:00:00 +02:00
Marc-André Bombeck d7f0d88d59 refactor(checkups): split the checkups section into form, card and postpone sheet
The checkups file held two components of about 620 lines each. Their
parts move to components/measurement-reminders/checkups/:

- reminder-form: the type groups, cadence presets, the form state and
  its mapping to and from a reminder (pure)
- reminder-form-sheet: the create / edit sheet and its cadence fields
- checkup-card: the card in both views, its menu, details, skip and
  snooze lines, next / last lines and action row
- postpone-sheet: the postpone chips and date, the honest skip and its
  confirmation, and the calendar-day offsets

vorsorge-section.tsx keeps the list, the value capture and the visit
filing. Markup and behaviour are unchanged; the guards name the modules
that now hold what they check.
2026-10-23 12:59:49 +02:00
Marc-André Bombeck f185855d76 test(e2e): log a mood at a chosen time against the real server
The mood timestamp spec answered the POST and the list read itself. It now
runs on the mood journey's account: the instant typed into the form is the
instant stored, and the entry is in the list, also after a reload.
2026-10-23 12:58:39 +02:00
Marc-André Bombeck 7445b69842 test(e2e): add a weight through the dashboard against the real server
The add-measurement flow answered its own POST and list read, so it proved
that the list renders a row the test typed in. It now runs on the
measurement journey's account, emptied first, and checks that the reading
the server stored is the one entered and that the dashboard tile, the weight
chart's data table and the day view all show it.
2026-10-23 12:58:36 +02:00
Marc-André Bombeck b6f45c0d88 refactor(integrations): move every provider OAuth client onto one factory
`oauth-kit/client.ts` builds a provider's consent URL, code exchange,
refresh and deauthorization from a declaration of what differs: endpoints
and scope, client authentication (form or HTTP Basic, with or without the
client id beside it), the fields a grant adds, how an answer is classified
and the refusal named, the error type, and where the token sits in the
body. The seven hand-written copies of the redirect URI, authorization URL,
token post, Basic header and PKCE pair are gone; each provider keeps an
`oauth.ts` with its declaration.

Shared behaviour is now uniform: a 2xx without a token object (an empty or
non-JSON body) is a transient `empty_token_body` failure for every
provider. WHOOP, Strava and Google Health used to return null, and
Withings threw an unclassified SyntaxError; the OAuth wire golden records
exactly these lines changing and nothing else. Oura's live/legacy endpoint
fallback, Google's invalid_grant lift, Strava's Fault summary and Fitbit's
errorType are declared per provider.
2026-10-23 12:57:47 +02:00
Marc-André Bombeck 518a381ff9 test(e2e): sign in with a password and a passkey, and sign out, against the real server
The login spec stubbed the login route to prove the form shows an error. It
now spends a real 401, and three journeys run on the sign-in account of
their project: a password sign-in that lands on the dashboard as that
account, a sign-out whose old cookie is refused afterwards (with a positive
control before it), and a passkey registered in settings through Chromium's
virtual authenticator that signs the account back in.
2026-10-23 12:55:19 +02:00
Marc-André Bombeck ca1cceb3a9 test(segmented): drive the control in a real browser
Roving focus, Home/End, wrap, toggle mode, disabled and the chosen
segment's fill are now asserted through key presses and computed styles
instead of markup greps. The static render test is deleted; its two
source sweeps move to segmented-single-implementation.test.ts.

The browser setup sets the default theme class so colour tokens
resolve, and pre-bundles the browser files' dependencies so a cold
cache does not reload the page mid-run.
2026-10-23 12:54:24 +02:00
Marc-André Bombeck 25b5833b11 refactor(settings): split the dashboard layout section into draft, lists and hero settings
DashboardLayoutSection was a 620-line component. Its parts move to
components/settings/dashboard-layout/:

- layout-model: the reorder and hero-kind helpers and the label maps
- use-dashboard-layout-draft: the tokened read, the draft, save and
  reset (with the snapshot and digest refresh) and the draft edits
- hero-settings-fieldset: the top card's switch, lead and highlights
- widget-lists: the sortable web list, the native-only group and rows

The section composes them. Markup and behaviour are unchanged.
2026-10-23 12:52:36 +02:00
Marc-André Bombeck b826642853 test(e2e): tag the unmocked release journeys @critical and hold them to the real API
Backup and restore, the adherence journey, delegated writes and the offline
page already run against the real server. They carry the @critical tag now,
and a structural guard refuses any /api interception in a file that carries
it, directly or through an imported helper, with a floor on the tagged count.

Global setup seeds one account per critical journey and per project, so the
journeys still to come can run at both widths without writing into each
other or into the shared fixture account. pnpm e2e:critical runs the set.
2026-10-23 12:52:12 +02:00
Marc-André Bombeck 3d1692204b test: add a browser Vitest project for interactive components
Run *.browser.test.tsx in headless Chromium through the Playwright
provider. The shared helper in tests/browser/ mounts the app's own
providers, stubs only the fetch boundary, and shims next/navigation
onto the page's real location and history.
2026-10-23 12:50:42 +02:00
Marc-André Bombeck 0e0e661cf0 Merge branch 'feat/v146-server-insights' into feat/v146-server 2026-10-23 12:48:08 +02:00
Marc-André Bombeck f942389a4b Merge branch 'feat/v146-server-meds' into feat/v146-server 2026-10-23 12:41:47 +02:00
Marc-André Bombeck 58215f3338 refactor(medications): split the dose-history ledger into its hook, row and dialogs
The ledger file held two components of 404 and 314 lines. It now reads:

- dose-history/use-dose-history-ledger: the window read, summary, day
  groups and the optimistic mark, plus the owner's edits (toggle, pin,
  release, delete) and one reconcile step after every write
- dose-history/ledger-row-item: the row and, apart from it, the owner's
  row menu
- dose-history/ledger-summary-chart and ledger-confirms

dose-history-ledger.tsx keeps the ledger layout. Markup and behaviour
are unchanged; the guards name the modules that now hold what they
check.
2026-10-23 12:41:46 +02:00
Marc-André Bombeck 3a4e97733d refactor(medications): move the medication create out of its route
POST /api/medications keeps authentication, the body, the validation
answer and the HTTP mapping; the create (the mirrored-medication dedupe and
cap, the one-shot shape refusal, the initial course, the nested schedules,
the insert and its tail) moves verbatim to lib/medications/create-medication.ts
and answers created, existing or refused. The route falls from 541 to 94
lines. The parity walk recorded before the move is unchanged, and the
sharing guard reads the service beside the route for the handler body.
2026-10-23 12:41:46 +02:00
Marc-André Bombeck 069e07b510 Merge branch 'release/v1.46.0' into feat/v146-server
The size baselines take the lower value of the two sides per entry, and every ratchet baseline is written back to what the tree measures now (file and function sizes, version stamps, source reads, import cycles, route body parsing).
2026-10-23 12:39:42 +02:00
Marc-André Bombeck 1a84cf27a8 Merge branch 'release/v1.46.0' into feat/v146-integrations
# Conflicts:
#	src/__tests__/baselines/source-file-lines.json
#	src/__tests__/baselines/source-function-lines.json
2026-10-23 12:39:13 +02:00
Marc-André Bombeck 9725ba394d refactor(measurements): move the list modes and the hand-entry writes out of their route
GET /api/measurements keeps authentication and the query refusal and hands
the validated query to measurementListResponse (lib/measurements/list),
which tries the list modes in their old order. POST keeps authentication,
the body, the bridge-source refusal and the three validation answers; the
single and combined writes with their shared tail move to
lib/measurements/create/create-readings.ts, the named sleep-stage write to
create-sleep-stage.ts. The route falls from 938 to 274 lines.

The parity walk recorded before the move is unchanged; the guards that name
the measurement writers, the write tail and the bounded reads follow the
code.
2026-10-23 12:38:27 +02:00
Marc-André Bombeck 4807a70c1d refactor(jobs): declare every queue in a typed registry and load handlers on first job
The five hand-written registrars (about 5,500 lines, one 997-line function)
are replaced by `jobs/registry/`: one `defineJob` entry per queue with its
cron, schedule send options, policy, work options, lock, lane and a handler
loader. `registerJobGroup` creates, schedules, retires and binds each group
in the order the registrars used; the worker boot walks the groups and runs
their boot discovery.

Handlers load on their queue's first job. The facts a declaration needs at
boot (names, crons, concurrency, send options) moved out of the heavy job
modules into light `*-queue.ts` siblings, and their importers point there,
so loading the registry evaluates 288 modules instead of the 1,164 the
registrars pulled in. The formerly inline handlers moved verbatim into
handler modules under `jobs/reminder/`.

Fitbit's queues live in `fitbit/jobs/definitions.ts` and enter the registry
through one spread, so removing the integration later is that folder and
that line.

`tests/integration/queue-registration-parity.test.ts` proves pg-boss holds
exactly what the registrars left (queues, schedules, bindings, byte for
byte) and loads every handler once. The queue tests and the runtime guard
assert on the registry objects instead of reading registrar source.
2026-10-23 12:36:09 +02:00
Marc-André Bombeck 3eb5aa9364 refactor(documents): split the vault page into filters, library, selection and detail
DocumentsView carried 970 lines of state before its first element. The
state moves to components/documents/vault/:

- selection-history: the `?doc=` history entry helpers (pure, tested)
- use-vault-filters: URL filters, the debounced search and the `/` key
- use-vault-library: usage, the paged list and its processing poll,
  layout, uploads and their intake paths, the corpus-index affordance
- use-vault-filter-choices: conditions, procedures, visits, years
- use-vault-selection and use-vault-bulk: the selection with ranges and
  Escape, the confirmed delete, re-filing, linking and the bulk share
- use-vault-detail: the detail sheet driven by `?doc=`, its close and
  the check that the close took
- vault-page-actions, vault-drop-overlay

The page composes them in about 300 lines; markup and behaviour are
unchanged, and the guards follow the code they watch.
2026-10-23 12:35:46 +02:00
Marc-André Bombeck 05b9697848 Merge branch 'feat/v146-server-status' into feat/v146-server 2026-10-23 11:47:13 +02:00
Marc-André Bombeck 8d48733c6a test(measurements): pin the measurement list and create before moving them into services
A parity walk over GET and POST /api/measurements: every create arm (single, combined, named sleep stage) with its duplicate and refusal, a bridge token, a delegated write, and every list mode. Sealed bytes are recorded by length, since each write carries a fresh nonce.
2026-10-23 11:47:09 +02:00
Marc-André Bombeck f7051ee15f fix(openapi): declare what the measurement list and create answers carry
The sleep list rows of GET /api/measurements carry the main night's stage
minutes, time in bed, time awake and awakenings, and the aggregate buckets
carry the bucket's lowest and highest reading; none of these were in the
contract. The create answer's union tried the plain row before the named
sleep-stage result, so a contract check read a named-stage answer as a plain
row and dropped its status. The responses are unchanged; the contract now
says what they hold.
2026-10-23 11:47:08 +02:00
Marc-André Bombeck a76258f985 refactor(insights): keep the card temperature private to the pipeline 2026-10-23 11:44:48 +02:00
Marc-André Bombeck 8bb58d8760 test(medications): pin the medication list and create before the create moves 2026-10-23 11:44:26 +02:00
Marc-André Bombeck c1052c4f17 refactor(dashboard): move the widgets layout logic out of its route
The route keeps authentication, the body, the base token, the layout schema
(the inline-schema inventory reads it there), the conflict mapping and the
responses. The rest moves verbatim into lib/dashboard/widgets/, one module per
stage: the layout read and the availability probe for GET, the unknown-id
drop, the refusal report and the stored-field merge for PUT, and the locked
reset for DELETE. The route is 317 lines, down from 513; its longest
function is 85 lines, down from 199.

The parity walk recorded before the move (dashboard-widgets.parity) is
unchanged. The tombstone-read guard now also scopes lib/dashboard/widgets/,
so the moved HRV probes stay policed.
2026-10-23 11:43:10 +02:00
Marc-André Bombeck 3c0063edb2 refactor(measurements): split the measurement list into view state, data, rows and sheet
MeasurementList was a 1,586-line component with fourteen pieces of
state. Its parts move to components/measurements/list/:

- list-model: the row type, page size, labels and formatting helpers
- use-measurement-list-filters: URL-owned filters, the value-range
  draft, page, sort and the page-scoped selection
- use-measurement-list-data: the list read, the soft deletes with undo
  and the selection operations
- use-measurement-edit: the edit draft, its unit conversion both ways,
  the save and the discard guard
- use-measurement-row-display: how a row shows its reading, as a pure
  rule (rowDisplayFor) bound to the account
- measurement-filter-rail, measurement-table (one row component),
  measurement-card-list (one card component), row-actions shared by
  both layouts, measurement-edit-sheet, day-drill-down

measurement-list.tsx lays these out in about 330 lines. Markup and
behaviour are unchanged. The glucose edit checks call the conversion
functions instead of matching source, and the guards that named the
old file name the module that now holds what they check.
2026-10-23 11:42:43 +02:00
Marc-André Bombeck 62b050f711 refactor(medications): move the dose correction and undo out of their route
The PUT handler was one 390-line callback. The route keeps the record
resolver, the 404, the body and its schema refusal; the correction moves
verbatim into lib/medications/update-intake-event.ts, split along its
stages in the order they always ran: the start-day floor, the slot
attribution, the in-place edit or the move onto another slot, then the
audit row, the event and the tails. The undo moves into
delete-intake-event.ts. The route is 124 lines; no function in either
module is over 140.

The parity walk recorded before the move (medication-intake-event.parity)
is unchanged. The sharing guard's audit, tombstone and overwrite legs read
the route together with the modules it hands its bodies to, listed in one
map that each route must still import, and the moved-dose provenance leg
now also asserts the original source is carried.
2026-10-23 11:40:06 +02:00
Marc-André Bombeck 2bfc5aee4b test(dashboard): pin the widgets layout route before moving its logic
Record the parity walk for GET, PUT and DELETE /api/dashboard/widgets on the
current code: the first and the cached read, a full save with ids outside the
catalogue, a partial save that keeps stored fields and the per-chart range,
the schema, empty-body and base-token refusals, a save on the current token,
the recovery-off availability probe with and without an HRV series, a
delegate's read and refused writes, the reset, and an anonymous call.
2026-10-23 11:39:48 +02:00
Marc-André Bombeck 899b86f4d4 refactor(workouts): move the batch ingest out of its route
postBatch was a 700-line function. The route keeps authentication, the
per-account preference read, the body caps, the rate limit, the parse and
the bridge-source refusal; the ingest moves verbatim into
lib/workouts/batch-ingest/: the first pass (entries), the duplicate probe
and write plan with the manual-correction rules (plan), the transaction
that writes corrections, workouts, tracks and series (write), and the
orchestrator with the audit, record detection, wide event, cache and
arrivals (ingest). The route is 275 lines.

The parity walk recorded before the move is unchanged; the guards that
name the track writer, the Workout writer set and the per-entry
external-id check point at the service files.
2026-10-23 11:39:00 +02:00
Marc-André Bombeck dd52a8d92a test(workouts): pin the batch ingest before moving it into a service
A parity walk over POST /api/workouts/batch: a mixed first batch with every per-entry refusal and the cross-source twin, the same batch with an enrichment, a corrected hand entry, a kept track, both whole-batch refusals and a narrow bridge token. Responses, wide events, queue tails and stored workouts with their series and tracks are pinned.
2026-10-23 11:34:11 +02:00
Marc-André Bombeck 4c9dc2be5b test(medications): pin the dose correction and undo before they move 2026-10-23 11:33:51 +02:00
Marc-André Bombeck 7ea247e193 Merge branch 'feat/v146-server-parse' into feat/v146-server 2026-10-23 11:30:47 +02:00
Marc-André Bombeck 25c3c050ee refactor(measurements): move the batch ingest out of its route
postBatch was one 1255-line function. The route keeps authentication, the
rate limit, the body, the entry cap, the schema and the bridge-source
refusal; the ingest moves verbatim into lib/measurements/batch-ingest/, one
module per stage in the order they always ran: the first pass (prepare),
the measurement writes with their prefilters and set-based insert, the
hourly sums, the sync checkpoint, the audit and wide-event report, and the
cache and arrival tail. The route is 274 lines; no function in the folder
is over 200.

The parity walk recorded before the move (measurements-batch.parity) is
unchanged, as is the set-insert golden. The guards that read the route for
the per-entry external-id check, the plausibility validator, the write
tail, the dense-read bound and the unit audit now read the service files.
2026-10-23 11:30:46 +02:00
Marc-André Bombeck ca04ef01b3 refactor(api): read the remaining settings and admin route bodies through parseJsonBody
Admin, coach, consent, chart-overlay, device registration, insights
feedback and provider chain, ECG ingest, onboarding, search and the AI
provider settings move to parseJsonBody with the same message, error
code, status and wide-event action they answered with. A refusal whose
annotate meta is computed from the issues, or puts its keys in another
order than the helper would, keeps that annotate verbatim in onInvalid.

The routes that still call safeJson do something between the read and the
schema: an optimistic-concurrency token split off the body, a count cap
answered first, a refunded quota slot, a conditional read, a non-Zod
check, or the body handed to a service.
2026-10-23 11:29:23 +02:00
Marc-André Bombeck af14d0aa7e refactor(api): read the health-record route bodies through parseJsonBody
Labs, biomarkers, custom metrics, cycle, anamnesis, illness, measurement
reminders, the measurement edit, medication sub-routes, mood and mental
health assessments still spelled out safeJson, safeParse and the 422 by
hand. Each becomes one parseJsonBody call with the size cap, action,
issue count, error code and status it had. The audit rows and the
unstable-externalId breadcrumbs move into onInvalid, in the order they
ran; where a breadcrumb reads the body, the wide-event annotate moves with
it. A `body ?? {}` or a payload unwrapped from the body becomes `input`.

The medication ingest route reads through the helper too; its Bearer
resolution happens before the read and is unchanged.
2026-10-23 11:29:22 +02:00
Marc-André Bombeck f06df5fd71 test(api): count parseJsonBody refusals in the issue-list guard
A route that reads its body through parseJsonBody has no
`if (!parsed.success)` block, so the guard lost sight of every migrated
site: its floor of 200 failed at 139 and the token inventory reported the
coach message feedback entry as stale while that route still answers
`feedback.body.invalid` in `error`, now through the `message` option.

Each parseJsonBody call is now a site of its own, carrying the issue list
by construction unless it names a status other than 422, and its `message`
option is read as the refused string by the token sweep.
2026-10-23 11:25:36 +02:00
Marc-André Bombeck 5075536f7f refactor(charts): split HealthChart into its read, view, layers and header
HealthChart was a 1,929-line component. Its parts move to
components/charts/health-chart/:

- series-math: series names, day keys, moving average, personal
  baseline, annotation and marker placement (pure)
- use-health-chart-series: the fetch window, the batched-slice decision,
  the daily read and the data-ready latch
- use-health-chart-view: the visible slice, bucketing, the average and
  trend lines, the prior period, the visible-range stats and the y domain
- use-health-chart-overlays: value bands, target zones, baselines, the
  trend read-out, the spoken summary and the table columns
- chart-header, band-overlay, chart-tooltip-content
- chart-layers: the reference layers and series as plain functions that
  return the Recharts elements, so the plot's child list is unchanged

health-chart.tsx keeps the component and its path (chart-runtime still
imports it from there) and shrinks to 488 lines. Output is unchanged.
The guards pinned to the file follow the code into the folder, and the
baseline-gating check now calls the layer instead of matching source.
2026-10-23 11:25:16 +02:00
Marc-André Bombeck 17e0f47411 refactor(insights): move the feature window arithmetic to a leaf module so signals-of-day no longer imports the extractor 2026-10-23 11:23:32 +02:00
Marc-André Bombeck daaa44fd82 refactor(insights): read the mood bands from a leaf module so mood-patterns no longer imports its hub 2026-10-23 11:21:29 +02:00
Marc-André Bombeck 3df80389ab test(kit): record route parity snapshots, starting with the batch ingest
tests/kit/parity.ts pins what a route answers, stores and reports for a
fixed walk of cases, so the code behind it can move under a byte-for-byte
comparison: the response, the rows read back, the request's wide event and
the queue and arrival tails it asked for, with generated ids, server
timestamps and request ids replaced by stable placeholders.

The first walk covers POST /api/measurements/batch: a mixed first sync with
every per-entry refusal, the same sync again, a background day total, the
manual and HealthKit mirror merge, a backfill, both whole-batch refusals and
a narrow bridge token. The clock is pinned, so the snapshot holds on any day.
2026-10-23 11:19:46 +02:00
Marc-André Bombeck 6a58a7df78 refactor(insights): compose the biomarker card from the pipeline stages 2026-10-23 11:18:46 +02:00
Marc-André Bombeck 1a99b8f4f0 refactor(insights): compose the generic metric card from the pipeline stages 2026-10-23 11:17:06 +02:00
Marc-André Bombeck a812a902dd refactor(dashboard): build tiles and charts from shared card builders
Most strip tiles differ only in their series, label, unit and icon, and
most chart cards share the reveal report, the batched slice, the time
zone and the day doors. One metric-card and one chart builder carry the
shared props, so each registry entry states only what is its own. The
dashboard route's client code shrinks by 0.2 KB gz; rendering is
unchanged. The tile-destination guard now builds every strip tile from
the registry instead of reading ids out of the source.
2026-10-23 11:14:47 +02:00
Marc-André Bombeck d448e6c9c6 refactor(insights): move the medication card onto the pipeline with its own note codec 2026-10-23 11:14:00 +02:00
Marc-André Bombeck 08403a93b4 Merge branch 'feat/v146-server-tools' into feat/v146-server 2026-10-23 11:13:05 +02:00
Marc-André Bombeck 5b49fa6680 refactor(api): read route bodies through parseJsonBody
132 route handlers spelled out safeJson, safeParse and the 422 refusal by
hand in one of six shapes. Each block becomes one parseJsonBody call with
the same size cap, wide-event action, issue count, error code, message and
status it had, so every response is the one it was. The comments inside
the replaced refusal blocks go with them.

A ratchet (route-body-parse-ratchet) counts the route files that still call
safeJson themselves; the count may only fall.
2026-10-23 11:12:39 +02:00
Marc-André Bombeck 07e1e83ee8 feat(api): read and validate a JSON request body in one call
parseJsonBody composes safeJson, the Zod safeParse, the validation-failed wide-event action and the multi-issue 422 envelope, with the variations routes use (size cap, input derivation, issue count, error code, message, status, an onInvalid hook) as options. The refusals are the existing helpers' bytes, which the unit test compares directly.
2026-10-23 11:12:38 +02:00
Marc-André Bombeck 6f3ad9defa refactor(insights): reduce the mood card to its snapshot and its relation board 2026-10-23 11:11:13 +02:00
Marc-André Bombeck 87a35b670d refactor(insights): reduce the blood-pressure card to its snapshot 2026-10-23 11:08:35 +02:00
Marc-André Bombeck 0a88155aec Merge branch 'release/v1.46.0' into feat/v146-ui
# Conflicts:
#	src/__tests__/baselines/source-file-lines.json
#	src/__tests__/baselines/source-function-lines.json
2026-10-23 11:08:24 +02:00
Marc-André Bombeck 6d3cfbba05 refactor(insights): reduce the overview card to its snapshot 2026-10-23 11:06:42 +02:00
Marc-André Bombeck 49b1888009 refactor(insights): reduce the BMI card to its height check, input gate and snapshot 2026-10-23 11:06:41 +02:00
Marc-André Bombeck e25b6365fd refactor(dashboard): compose the page from a tile registry
The dashboard page held every tile and chart card in one 2,100-line
function: a 1,279-line inline block pushed 24 cards behind 36 show flags.
The cards now live in components/dashboard/tiles/, each with its layout
widget, its own visibility rule (data floor, module, resolved layout) and
its render; the layout toggle and order are applied once by the registry.

- use-dashboard-data: the snapshot or legacy reads, the layout, mood,
  thresholds, the batched chart series and the loading gate
- tile-context: summaries, display units, comparison deltas, freshness,
  directions and target bands, derived once per render
- strip-tiles / chart-tiles: the two registries and their builders
- dashboard-tiles: the strip, the chart row, their skeletons and the
  empty state

The page is a composer of about 170 lines. Rendering is unchanged. The
guards that read page-client.tsx read the whole registry through one
helper; the weight-direction, HRV freshness, BP-in-target comparison and
chart-reveal checks now build the real tiles instead of matching text.
2026-10-23 11:06:32 +02:00
Marc-André Bombeck b1400a8130 refactor(cycle,medications): break the gate and compliance import cycles
The module gate and the cycle gate imported each other for one pure
predicate. isCycleEnabled moves to lib/cycle/enabled.ts and both gates
import it from there.

The band minter read DOSE_WINDOW_DEFAULTS through the analytics/compliance
re-export barrel, which loads the compliance display and with it the
scheduling modules that import the minter. It now imports the constant from
the module that defines it. Runtime import cycles fall from 6 to 4.
2026-10-23 11:05:08 +02:00
Marc-André Bombeck de866107ad refactor(insights): reduce the weight card to its input gate and snapshot 2026-10-23 11:04:15 +02:00
Marc-André Bombeck ea01610a56 refactor(insights): run the status cards through one staged pipeline, starting with pulse 2026-10-23 11:04:14 +02:00
Marc-André Bombeck 78ac2c5aeb refactor(sharing): read and judge grants from a leaf module
lib/sharing/grants.ts held both the grant transitions and the predicates
every reader asks (state, level, scope, the live grant for a pair). The
transitions reach into the managed-profile lifecycle and the OAuth connect
target, and both of those read a live grant, so grants, lifecycle and
connect-target formed an import cycle.

The predicates, findActiveGrant and touchGrantUsage move verbatim to
lib/sharing/grant-access.ts, which imports only the database client and
the scope vocabulary. Importers and the mocks that replaced those
functions point at the new module. Runtime import cycles fall from 7 to 6.
2026-10-23 11:02:10 +02:00
Marc-André Bombeck e84b80ee3c refactor(boot): register the offline-geo notice from its own helper
Keeps register() under the function-size cap; the test pins that register() still calls it.
2026-10-23 11:01:47 +02:00
Marc-André Bombeck 5a8c736017 chore: keep the v1.45 size baselines for the navigation the topics preview had split
Reverting the topics preview puts sidebar-nav, bottom-nav and the
/api/auth/me handler back to their v1.45 code; their baselines return to
the v1.45 values, nothing grows beyond them.
2026-10-23 11:00:45 +02:00
Marc-André Bombeck 0139c22ae3 refactor(geo): move the offline-geo admin notice out of the resolver
auditLog resolves login locations through lib/geo.ts, and the resolver
imported the notification dispatcher for the offline-database notice. The
dispatcher's collaborators write audit rows, so audit, geo, the dispatcher,
channel state, delivery identity, managed-profile lifecycle, the OAuth
connect target and the grants formed one strongly connected cluster.

The notice now lives in lib/notifications/offline-geo-notice.ts. The
resolver keeps deciding when it runs (first public-IP lookup of a process,
again after a runtime database fetch) through a slot the notice module
fills at boot from instrumentation.ts. Runtime import cycles fall from 13
to 7.
2026-10-23 10:57:00 +02:00
Marc-André Bombeck df6e182e0b test(integrations): pin what each provider mapper makes of sanitised wire fixtures 2026-10-23 10:56:03 +02:00
Marc-André Bombeck ee1561496b refactor(auth): move the request guards out of api-handler into lib/auth/guards
api-handler.ts held two modules: the route wrapper and thirteen guards. The
guards now live in lib/auth/guards/, one module per family (the caller,
the acting-account carrier, the record modes, the cookie-only helpers, the
fresh-factor and recent-proof gates, the Bearer elevation, MFA management).
api-handler.ts keeps the wrapper and re-exports every guard, so it stays the
one public entry and no route changes its import.

The bodies move verbatim; only the version prefixes of moved comments are
dropped. The structural guards that read the auth layer now find the module
that declares a guard by name (helpers/auth-guards.ts) instead of a fixed
path, and a new guard holds the entry: nothing outside the folder but
api-handler.ts imports a guard module.
2026-10-23 10:54:23 +02:00
Marc-André Bombeck faeebfc63a refactor(coach): run each Coach tool from a module of its own under tools/run 2026-10-23 10:54:19 +02:00
Marc-André Bombeck 4308682c96 test(insights): pin every status card's prompts, results and stored notes in golden files 2026-10-23 10:52:49 +02:00
Marc-André Bombeck e375a0e0ba refactor(mcp): give every read tool a module of its own under mcp/tools 2026-10-23 10:49:40 +02:00
Marc-André Bombeck 0182f91092 Revert "feat(topics): compute topic overviews and topic pages on the server"
This reverts commit cc04e0944d.
2026-10-23 10:46:55 +02:00
Marc-André Bombeck c1911e71e3 Revert "feat(topics): add the Topics preview area, its navigation entry and its switch"
This reverts commit c68e833dcc.
2026-10-23 10:46:54 +02:00
Marc-André Bombeck ce0c2a3f3f Revert "test(e2e): walk the Topics preview and sweep its pages at 390 px and with axe"
This reverts commit 957c2f279e.
2026-10-23 10:46:43 +02:00
Marc-André Bombeck b2ebcb198d test(integrations): pin what every OAuth client sends and makes of each token-endpoint answer 2026-10-23 10:46:42 +02:00
Marc-André Bombeck 5c6a1cbb36 test(jobs): pin every queue, schedule and handler binding the worker registers 2026-10-23 10:44:53 +02:00
Marc-André Bombeck b085bb8771 test(mcp,coach): pin the advertised tool lists and the Coach dispatch table 2026-10-23 10:42:58 +02:00
Marc-André Bombeck 9beaa8f640 build(deps): bump the AWS SDK S3 packages to 3.1153.0 and react-query to 5.104.5 2026-10-23 10:37:41 +02:00
Marc-André Bombeck 9de64cae3e Merge branch 'release/v1.46.0' into feat/v146-questionnaires
# Conflicts:
#	src/__tests__/baselines/source-file-lines.json
#	src/__tests__/baselines/source-function-lines.json
2026-10-23 10:20:34 +02:00
Marc-André Bombeck e94b7f828c fix(mental-health): keep the unvalidated-translation note on screen with every step
On a phone, arriving on a step scrolled to the question and left the note
and the switch to the English original at the top of the page, out of
view. They now sit at the top of every step's card, above the stem, and
the step scrolls its top block (note, stem, question) into view as one,
so a block already on screen does not move and focusing the question
finds it visible. The review step carries the note the same way.

The phone e2e run asserts the note is in the viewport on the first and a
later step.
2026-10-23 08:26:24 +02:00
Marc-André Bombeck 957c2f279e test(e2e): walk the Topics preview and sweep its pages at 390 px and with axe
A journey on its own account, seeded with 40 days of heart and sleep,
weight, blood pressure, a short run of steps and mood entries, and lab
markers with and without a trend: switched off there is no More tile
and no sidebar entry, the pages leave for Insights and the routes
answer 404; switched on in Settings the More sheet leads with the
Topics tile and the sidebar carries the entry below Insights; every
offered topic renders, with the pair that has 14 paired days stated
and the pairs short of it saying so.

The phone sweep opens every topic page with the preview on, and the
axe matrix scans the overview and two topics in both themes. The
Settings sweep gains the Preview features page.
2026-10-23 08:19:01 +02:00
Marc-André Bombeck c68e833dcc feat(topics): add the Topics preview area, its navigation entry and its switch
/topics shows a tile per topic the record holds data for, and names the
empty topics and the module-hidden ones once each. /topics/<topic>
shows the topic today, its signals side by side over 7, 30, 90 days or
a year against their usual range, how they move together, their trends,
its blocks, the single-metric pages behind it and what it is missing.
Every topic links into the metric pages, which stay as they are, and
each page ends on a neutral note that this is a preview.

Settings > System > Preview features is listed again and switches the
area on for the person reading. While it is on, the sidebar carries a
Topics entry with a Beta badge directly below Insights and the phone's
More sheet leads with a Topics tile; the bottom bar does not change.
The pages leave for Insights when the switch is off.

The charts load through the shared chart runtime. The sidebar rows move
into their own module and the More sheet tile into its own component,
so neither shell file grows; the lab trend wording is shared between the
marker page and the metabolism topic. Copy in all nine locales.
2026-10-23 08:18:53 +02:00
Marc-André Bombeck cc04e0944d feat(topics): compute topic overviews and topic pages on the server
GET /api/topics answers one entry per topic the record holds data for:
a headline value, a rule-based sentence and a 30-day sparkline.
GET /api/topics/{topic} answers a topic's signals with their usual
ranges, side-by-side series for a chosen window, the relationships
between its signals, 90-day trends, what it holds nothing for yet, and
its blocks (training load, lab markers with their trend, screener
trends, the cycle day).

Every figure comes from reads the metric pages already make: the local
daily values, the reconstructed nights, the mood score, computeLabTrend,
the training load and the screener trends. No model is involved. A
relationship says nothing below 14 paired days and is only called a
pattern at p < 0.05; a lab trend needs three readings spanning a year.

The preview is the viewer's own opt-in: with it off both routes answer
404 topics.preview_off. Topics, signals and blocks follow the record's
module switches and the sections the viewer's grant opens; the routes
read across sections, so a scoped grant is refused like the timeline.
/api/auth/me publishes the caller's opt-ins as previewFeatures.
2026-10-23 08:18:43 +02:00
Marc-André Bombeck 14d0184b63 fix(ui): give the neutral score pill a label colour that passes AA
The neutral tone set muted text on the secondary fill, which measures
3.6:1 in the dark theme. The secondary surface's own foreground reads
the same in light and passes in dark.
2026-10-23 08:18:33 +02:00
Marc-André Bombeck 39092aa936 test(e2e): take the check-in in the reader's language
For German, Dutch, Korean and Brazilian Portuguese, the note and the
switch to the English original appear on an unvalidated translation and
nowhere else, the question carries the language it is written in, and
the result of an unvalidated translation says so. The switch shows the
English original and the result then carries no note, and a Brazilian
PHQ-9 with item 9 above zero leads with the crisis card. Each screen is
scanned with axe, at desktop and phone width.
2026-10-23 08:14:32 +02:00
Marc-André Bombeck 14273051db feat(doctor-report): mark screener totals answered in an unvalidated translation
The report data counts, per screener score type, how many of the
window's results were answered in a translation that has not been
validated (`screenerUnvalidatedTranslations`, read only when a screener
total is in the report). The PDF marks such a row with an asterisk and
says under the vitals table how many of how many read as orientation;
the clinician share view says the same under the row.

To keep the aggregator and the PDF section from growing, the wellness
summary and the per-context glucose rows move into helpers; the size
ratchets only fall.
2026-10-23 08:14:32 +02:00
Marc-André Bombeck 02cc0631fa feat(mental-health): record which wording a check-in was answered in
An administration now stores `wording`: `validated` (the published
validated version of the reader's language, the original for English),
`unvalidated_translation` (HealthLog's own translation) or `original_en`
(the English original, chosen by a reader of another language). The
client sends only `englishOriginal`; the server resolves the rest from
the wording registry, so a client cannot claim a validated wording the
registry does not hold. `locale` keeps the reader's language, which
still selects the crisis resources.

Additive migration 0475: a nullable column with no backfill, since which
text earlier rows showed depended on releases since replaced. Every read
returns the field, the OpenAPI contract carries it and `englishOriginal`,
and the disaster-recovery backup carries and restores it (the parity
goldens gain the column; their pinned timestamps move by one step
because the fill writes one more column).

The PHQ-9 item-9 crisis signpost is answered from the answers alone and
is proven in every wording through the real route.
2026-10-23 08:14:15 +02:00
Marc-André Bombeck 3843705296 feat(mental-health): keep questionnaire wording in code, one cited file per language
The PHQ-9, GAD-7, WHO-5 and SCI now have text in every app language
instead of falling back to the English original. Each instrument and
language is one file under src/lib/mental-health/wording/ that declares
what it is: a published validated translation reproduced word for word,
with its source and terms in `source`, or a translation written for
HealthLog, faithful to the English and on the same scale.

Validated versions added: the Pfizer PHQ-9 for France, the Netherlands,
Korea and Brazil, the Pfizer GAD-7 for the Netherlands, Korea and
Brazil, the PRU Dutch WHO-5, the Korean WHO-5 validation (J Korean Geriatr Psychiatry 2010) and
the Polish SCI validation (Sci Rep 2026). The SCI in the other
languages and the WHO-5 in Brazilian Portuguese carry HealthLog's own
translation; each file names any validated version found and why it is
not used.

Where the text is unvalidated, the check-in shows a note that the result
is orientation and a one-tap switch to the English original. Each
language loads as its own chunk when a questionnaire surface needs it.

The item text, answers, stems and the PHQ-9 follow-up leave every message
bundle, so a translator never sees them; the i18n guard now requires that
no bundle carries them, and the registry test requires every instrument
in every language with the item count and anchor scores the scoring
expects. CONTRIBUTING explains where the wording lives and how to propose
a validated version.
2026-10-23 08:13:56 +02:00
Marc-André Bombeck cf61bd1c85 Merge pull request #1264 from MBombeck/release/v1.45.0
Release v1.45.0
2026-10-23 03:26:55 +02:00
Marc-André Bombeck 8c43ff7cf9 fix(bench): move the heap snapshot and read the commit without a shell 2026-10-23 02:50:02 +02:00
Marc-André Bombeck 3b2ebc1081 docs(changelog): filters keep the address they were given 2026-10-23 02:06:12 +02:00
Marc-André Bombeck d8e4cf6000 fix(url): write page state into the query string through one serialised helper
Next 16.4 adopts a native history write a render later. A second write
inside that gap was undone when the router adopted the first, and a
write computed from useSearchParams started from the URL before the
last one. Clearing the vault's filters could therefore leave
?encounter= behind: the debounced search wrote the filters it had
rendered back over the clear.

@/lib/url-state is now the one way a page writes its own query: each
write starts from the live address bar, at most one waits for the
router at a time, and later updates queue on top of it and go together
once the router has it (or after a short grace). useUrlSearch reads the
same live value and reports each adoption. The filter hook (documents,
measurements, mood), the open document, the day layer, the timeline's
zoom and range, the medication tabs, the AI provider choice, the
Coach's ?c= and the one-shot parameter strips all go through it; the
filter changes merge into the live filters. A guard refuses raw
history writes and router writes built from the rendered params
outside the helper.

The timeline spec now waits for the values menu to hand focus back
before scrolling to the last row; it measured mid-animation and the
focus return scrolled the page back.
2026-10-23 02:04:21 +02:00
Marc-André Bombeck 333dfe8746 fix(backup): give every export read a total order
Several export reads sorted only by a column two rows can share (a
creation time, a day, a name), and a few to-many reads (medication
schedules, mood tag links, cycle and illness symptom links) had no order
at all. Tied rows then came back in whatever order they sat on disk, so
two exports of one account could differ, and the parity goldens flapped
once the fixture's child rows shared their pinned timestamps.

Each read now ends its order in the model's primary key: the existing
sort key plus `id`, or the remaining key columns of a compound key
(nutrient `source`, hidden tag `moodTagId`, link `symptomId` /
`moodTagId` / `documentId`). Schedules sort by window start, then id.
The restore re-export reads through the same modules, so it inherits
the order.

The parity goldens are unchanged: they already hold the tie-broken
order, so no regeneration was needed and they still pin the output of
the pre-split exporter.

A new integration test runs both purposes, built and streamed, through
a client that records every findMany and every nested to-many read, and
fails on any whose order does not cover the primary key (keys pinned by
an equality in `where`, or by the foreign key to the parent, count as
covered). A second case writes the same tied rows in forward and
reverse physical order and requires byte-identical files.
2026-10-23 00:28:38 +02:00
Marc-André Bombeck b09a6d2df8 test: fail a test whose code caught and logged a missing mock export
A module mock that leaves out an export the code under test reaches throws
at the call, and where that code catches and logs (a best-effort tail, a
fail-soft read) the test went on green while the path it meant to cover
never ran. The Apple Health import tests mocked the rollup module without
its fold window, so the import tail's rollup fold and cache invalidation
never ran in any of them; four route test files mocked the logging context
or the rollups without what the measurement tail reaches.

Both setup files now watch the console and fail the test in which such a
line was logged. The ECG import test keeps the real rollup module and
stubs only the fold, and the four unit mocks gain what they lacked.
2026-10-22 23:15:43 +02:00
Marc-André Bombeck 2844088251 test(integration): seed the large pulse account a month at a time
The heap test for the pulse day-mean rebuild wrote its million readings in
one INSERT ... SELECT, which outran the 60-second statement timeout on CI's
shared Postgres. The fixture is now written a month per statement, each in
its own transaction with the timeout lifted for that transaction only, and
ANALYZE the same way; the code under test keeps the timeout, and the
production-heap assertions are unchanged. With the timeout at 4 seconds the
old seed is cancelled with 57014 and the new one passes.
2026-10-22 22:56:05 +02:00
Marc-André Bombeck 294be53883 fix(admin): keep a sideways-scrolling table from widening the page on a phone
The login overview's IP button gained screen-reader text, which is
absolutely positioned. Inside a scroll container that is not positioned,
its containing block lies outside the container, so the span escaped the
container's clipping and every row panned past the edge widened the page:
the admin page scrolled sideways at 390 px. Every overflow-x-auto wrapper
around a table is now relative (the admin sections, the settings token
tables and the Coach result table), and a guard holds that.
2026-10-22 22:52:48 +02:00
Marc-André Bombeck dda4c57f5a docs(changelog): the maintenance job and today's mood dimensions 2026-10-22 22:05:24 +02:00
Marc-André Bombeck c10446f0d7 Merge branch 'release/v1.45.0' into fix/v145-review-data 2026-10-22 21:48:34 +02:00
Marc-André Bombeck a787cad7f9 chore(deps): take @tanstack/react-query 5.104.4, knip 6.42 and shadcn 4.22.1
The three updates the newest dependency pull requests carry, pinned the way each already was: exact for the query client, a caret floor for the two tools (knip ^6.42.0 resolves to 6.42.1). The dead-code gate is clean under the new knip.
2026-10-22 21:48:27 +02:00
Marc-André Bombeck 2ed863f877 fix(jobs): report the measurements bloat as whole percent, and keep finished runs finished
The nightly measurements check reported its bloat factor, a ratio such as
1.3, as a job fact. A fact is a non-negative whole number, so the outcome
validation refused it on every run, after the vacuum and the reindex had
already done their work, and the run failed. It now reports bloat_percent
(130), and the duration goes through the same rounding.

A malformed fact no longer fails a run whose work is done: runJob drops it
from the result and emits job.outcome.fact_invalid at error level with the
queue and the key, where failing would have made pg-boss retry finished
work. A failed outcome is unchanged. wholeFact and percentFact are the
intended way to report a measured value or a ratio, and a guard reads
every outcome producer and refuses a fact that is a division or names a
ratio unless it is rounded on purpose. The other producers were checked:
their numeric facts are counts and integer millisecond durations.

Refs #1271
2026-10-22 21:43:08 +02:00
Marc-André Bombeck 67135b2ce8 fix(charts): measure chart text in the app font again
Recharts measures every label in a span the chart runtime parks under
<html>, beside <body>, and the host copies body's classes to pick up the
app font. With next/font/google the font stack variable came from one of
those classes; since Inter is self-hosted, globals.css sets --font-sans on
body itself, so the host outside it measured in the default font and every
tick was sized for text the chart did not draw. The rule now sets the stack
on the measurement host too, and the dashboard's strings measure the same
inside the host as under <body> again.
2026-10-22 21:35:01 +02:00
Marc-André Bombeck 70c1077121 perf(mood): write a rollup fold in one statement per chunk
The full mood fold upserted every day bucket with a statement of its own,
so the read that refreshed the tier paid one statement per day of mood
history. The dashboard summary in the hot-route budget fixture did exactly
that: 57 to 59 single-row inserts out of its 85 to 87 statements, a count
that moved with the calendar the fixture is dated against, which is why it
crossed its budget of 84 on CI without a new query in the route. The fold
now writes each chunk of up to 500 buckets as one multi-row upsert, and the
route's budget goes to the measured 28.
2026-10-22 21:31:28 +02:00
Marc-André Bombeck bafda973ee refactor(insights): read the mood block's daily points in a module of their own
The rollup-or-fallback read of the per-day mood points moves out of features.ts into mood-daily-points.ts, unchanged, which keeps features.ts under its size baseline after the calendar-day ages.
2026-10-22 21:31:27 +02:00
Marc-André Bombeck 239319a762 fix(mood): count the mood block's ages in calendar days, and fold today in every zone
The Coach's mood block gave its newest and oldest entry ages as elapsed
hours rounded to days. A point from the rollup tier sits at its day
label's UTC midnight, so from noon UTC on this morning's entry read as a
day old; the integration run failed at 19:02 UTC for that reason. The ages
and the span are now whole days between the entries' own day labels and
the reader's today.

Pinning the clock to the edges of the Berlin day showed a second gap: the
open-ended rollup fold stopped at now, and a day label runs up to fourteen
hours ahead of UTC, so between a reader's local midnight and UTC midnight
the fold left their today out and the tier called yesterday the newest
day. The open-ended fold now reaches a day past now.
2026-10-22 21:23:19 +02:00
Marc-André Bombeck 5dab1488d7 fix(ai): let deferring dose sentences through and pair continuations with a named planned dose
The number-free dose shapes added for this release blocked harmless
replies: a trailing negation the pattern never reached ("Erhöhe dein
Insulin heute nicht", "Verhoog je insuline vandaag niet zelf"), a
Spanish subjunctive after "antes de que", a question ("Increase your
dose? Not without your doctor."), a clinician-gated condition, and
Polish "dose" of caffeine, sugar or exercise. These shapes now stand
down in a sentence that asks, negates, restricts ("nur wenn", "alleen
als") or names a clinician; shapes with a stated amount still block.
The Korean trial pattern stands down the same way, which also clears a
pharmacist question it already blocked.

The continuation exemption fell back to the whole plan when a sentence
named a drug the plan does not carry, so "Take Eliquis 5 mg as before."
and swapped doses passed. Every stated amount must now sit beside one
medication on the plan, named, at exactly that dose.

A false-positive corpus (at least 15 harmless replies per language)
sits beside the positive one. The Brazil crisis set's comment now says
what reaches it.
2026-10-22 21:19:01 +02:00
Marc-André Bombeck 8a9bcffaa2 build(deps): raise the dompurify floor to 3.4.17
GHSA-cg2g-xh64-v4q7 (critical, a SAFE_FOR_TEMPLATES bypass) and GHSA-mrv2-3pqr-j5jr (high, an IN_PLACE hook that removes a parent) cover dompurify up to 3.4.16, which jspdf and jspdf-autotable pull in. jspdf 4.2.1 is the newest release and still allows that range, so the existing override moves its floor. The doctor-report PDF renders as before (unit and integration suites).
2026-10-22 21:16:31 +02:00
Marc-André Bombeck bf12b9840c chore: drop an unused guard helper and a type re-export nothing imports
sourcesContaining in the guard helpers and the DeferredRows type re-export from full-backup-payload had no importer, which the dead-code gate reports. isDeferredRows stays re-exported; the backup job's test mocks it there.
2026-10-22 21:15:38 +02:00
Marc-André Bombeck 9da0e0c405 chore(release): v1.45.0 2026-10-22 20:51:30 +02:00
Marc-André Bombeck 32b71a7df9 Merge branch 'release/v1.45.0' into fix/v145-sheet-motion
# Conflicts:
#	src/__tests__/baselines/source-file-lines.json
#	src/__tests__/baselines/source-function-lines.json
#	src/__tests__/baselines/version-stamp-comments.json
2026-10-22 20:51:07 +02:00
Marc-André Bombeck 6d23cca4a3 fix(day): keep focus where the person moved it when the day's heading mounts late
Opening the conversations beside a docked day dropped focus to the page
in most runs. A day opened on load asks for its heading to take focus;
when the heading had not mounted yet the request waited, and it was
honoured whenever the heading appeared, even after the person had moved
on and opened the conversations. Focus jumped into the folding day and
fell to the page as it went inert. A waiting request is now honoured
only while focus is still where it was asked from, never inside an inert
panel, and it ends with the day.

The timeline range check measured the popover during its slide-in and
read it up to 8 px too high; it now waits for the entry animation.

The label-in-name fixes move their name composition into small helpers
(score ring link, IP copy button, sortable header, period marker, the
day's title focus), so every function ends at or below its old length
and the size baselines only fall.
2026-10-22 20:50:07 +02:00
Marc-André Bombeck 562ff8a277 fix(a11y): name every control by the text it shows
axe 4.14 checks WCAG 2.5.3 (label in name), and the app frame failed it
on every page: the account link, the user menu and the day strip carried
an aria-label that left out their visible text. The same class sat in
the coverage meter, the score ring, medication links, instrument cards,
filter pills with a value set, cycle calendar days (named by an ISO
key), admin user and login rows, a few sort and action buttons, and the
French, Spanish, Italian and Polish weekday chips, whose abbreviations
were not part of the day names.

Controls with visible text are now named by it, with any extra purpose
added as screen-reader text; the select pill and calendar days spell
out a name that contains what they show. The day and conversation
strips keep their action as the tooltip.
2026-10-22 20:25:48 +02:00
Marc-André Bombeck 811fcf5509 fix(backup): restore row stamps and carry sealed labels, course notes and marker context as ciphertext
A disaster-recovery restore wrote these rows stamped with the restore's own
time: the medication's updatedAt (moved again by the course window), the
category assignment, custom category labels, courses and composition, the
mood day context and tag links, illness and cycle symptom links, nutrient
days, and a flare's updatedAt after the parent pointer pass. The recovery
file now carries each stamp (additive fields: linkCreatedAt on a mood
entry, symptomLinkCreatedAt on a cycle day log, createdAt on an illness
symptom, categoryUpdatedAt on a medication, updatedAt where missing) and
the restore writes it back. A file without them restores as before.

A custom category label, a course note and a marker's context rode the
recovery file as readable text and were sealed again under the active key.
They now ride as ciphertext (labelEncrypted, noteEncrypted,
contextEncrypted) and come back as the same bytes; a file that carries the
readable value instead is sealed on the way in, as before. The portable
file keeps the readable values. The rotation script and the
encrypted-column writer guard already cover all three columns.

The parity golden copies change for exactly this: the new fields in the
recovery file, the restored stamps in place of <now>, and the three values
coming back under the key they were sealed with. The legacy readable coach
title and custom metric note are still sealed on restore, since the row
holds no ciphertext to carry. The round-trip column audit no longer
excludes the four link stamps.
2026-10-22 19:57:47 +02:00
Marc-André Bombeck 038f8b10cd feat(mental-health): one check-in pattern for every questionnaire, worded as published
Every instrument now runs the same wizard: the form's own question stem
above each item (the PHQ-9 and GAD-7 stems were missing), the item's
exact answer options as one radio group in source order, progress, Back,
number-key answers, and a review step that lists every answer, lets any
of them be changed and is the only step that submits. The result leads
with the crisis card when it applies, then shows the score on the
instrument's scale, the band, what the band means, the change since the
last check-in against the meaningful-change threshold, and the next
step. Scoring is untouched.

The wording was read against the published versions. German follows the
PHQ-D and Löwe's GAD-7 verbatim, including the follow-up question in its
published formal address. Spanish, Italian and Polish carry the official
PHQ-9 and GAD-7 translations, French the official GAD-7, and the GAD-7
gets its own answer set because the Italian forms word it differently.
Where a version could not be matched to a published form (the French
PHQ-9, every Korean form) the language now shows the English original.
Each locale list names its sources, and spot checks pin the corrected
wording.
2026-10-22 19:28:29 +02:00
Marc-André Bombeck 6c4c097517 fix(workouts): start the windowed workout read after the plain reads beside it
The windowed reader is async, so a Promise.all that listed it first held a rejected promise of it whenever a later element threw while the array was built, and nobody handled that rejection. It now comes last in each of those arrays.
2026-10-22 19:15:58 +02:00
Marc-André Bombeck cec7ce2ad5 docs(api): regenerate the OpenAPI contract for the family-history backup fields
The backup payload schema the admin restore routes publish now declares notesEncrypted and deletedAt on a family-history entry, which the disaster-recovery file carries since the previous change.
2026-10-22 19:10:55 +02:00
Marc-André Bombeck 760f261a5f test(guards): count fixed-path reads through readSourceText in the source-read ratchet
The ratchet on tests that read a source file by path matched readFileSync
and readFile only, so a guard that called readSourceText with a fixed path,
directly or through a local read(rel) wrapper, read the file without the
anchors readSource demands and was never counted. The classifier now counts
both shapes, and leaves alone a readSource call with anchors and a sweep that
feeds the reader from a walk.

The twelve guards that read a fixed file that way now go through readSource
with an anchor each read asserts on, so the baseline stays where it was.
2026-10-22 19:08:15 +02:00
Marc-André Bombeck 298515d156 ci: fail the mutation floors step, and scale nightly property runs by a factor
The floors step piped scripts/mutation-floors.mjs into tee without
pipefail, so it took tee's exit status and a module under its floor could
never fail the run. It now runs under bash with pipefail, as do the other
steps that pipe into tee, and a guard reads every workflow and fails on a
tee pipe without it. The other piped steps were checked: each either
validates what the pipe produced or pipes a command that cannot fail.

The nightly runs set FC_NUM_RUNS, an absolute count that replaced every
property's own: the database backup round trip, which states twelve cases
for its cost, ran a hundred times against a timeout sized for twelve.
FC_RUNS_FACTOR now multiplies each property's count (five for the unit
suite, four for integration), propertyTimeout grows a test's timeout by
the same factor, and the backup round trip uses it.
2026-10-22 19:04:20 +02:00
Marc-André Bombeck 422adf2986 test(measurements): pin that a stats re-post with a new source version, device or sleep stage still writes
The unchanged-stats shortcut skips the write when every overwritten column matches the stored row. The kit test covered a new value and a new device; it now also re-posts a step total that only names a new HealthKit source version and a sleep row that only changes its stage, and checks each is written with its syncVersion bumped and the tail started.
2026-10-22 19:00:54 +02:00
Marc-André Bombeck 3b2c2c45f3 fix(backup): carry deleted family-history entries in the disaster-recovery file
Every record with a tombstone (allergies, labs, conditions, vaccinations,
life events, measurements and the rest) rides the disaster-recovery file
deleted rows included, so a restore gives back the account as it was. The
family history read only live entries for both files, so a restore lost
its deletions, and it carried the note as readable text that the restore
sealed again under the active key.

The disaster-recovery slice now carries every entry with its deletion and
the note as the ciphertext at rest, and the restore writes both back
verbatim; the portable file keeps the readable live entries. The parity
golden copies change for exactly that: the deleted entry, its note's key
and the shifted ids after it.
2026-10-22 18:59:07 +02:00
Marc-André Bombeck faedcbba93 test(backup): pin seeded timestamps, tombstones, self-references and key ids
The parity harness normalised any timestamp written during the run to
<now>, and the seeded rows' createdAt and updatedAt were themselves written
during the run, so a restore that dropped a row's createdAt still matched
its golden copy. Every run-time timestamp of the seeded account is now
pinned to a fixed historical value, and the seeded snapshot is asserted to
hold none, so a lost createdAt shows as <now> against a historical value.

The account also carries a tombstone of every model that keeps them, a
flare under its condition and a superseded profile fact, so the second
restore pass resolves real pointers. The fixture is sealed under the
legacy key and a second key is active from the export on; sealed values
render as the key id they name and the text they open to, so a value the
restore re-encrypts instead of carrying over differs.

The golden copies are written from the backup code as it stood before the
export and restore moved into per-domain modules (64fa65b46), and the
current code matches them.
2026-10-22 18:55:56 +02:00
Marc-André Bombeck 6ab1f2d8a2 docs(deploy): order a split deploy for the job-queue schema upgrade in v1.45
A web-only process opens its queue connection without migrating and refuses a job-queue schema older than its own, so in a web/worker split the new worker has to start, and upgrade the schema from 45 to 46, before the new web containers. The v1.45.0 notes say so, name the one additive migration, state that a single-container install needs no action, and point a forked compose file at the new stop grace period.
2026-10-22 18:44:44 +02:00
Marc-André Bombeck 7fda400a8c fix(i18n): serve English for an unreadable catalog, refuse to boot without it
The server reads messages/<locale>.json from disk on first use. A missing
or corrupt file threw, and since the matchers iterate every locale, one bad
non-English catalog turned every request that touched them into a 500.
That locale is now served the English catalog, and the failure is logged
once as i18n.catalog.fallback with the locale and the reason. English is
the fallback itself: a process whose messages/en.json is missing or does
not parse refuses to start, with the file and the reason in the message.

The image build checks the catalogs twice with
scripts/verify-message-catalogs.mjs: in the builder against the locale
list in the source, and in the runner stage against the copies the image
carries.
2026-10-22 18:43:03 +02:00
Marc-André Bombeck ac31130bc0 fix(cache): mark measurement caches stale after every bulk write
Closed analytics blocks survive a write that says it reached no further
back, so a writer that never invalidated left them on their pre-write
values. The Apple Health and Health Connect imports, the CSV and JSON
imports, the score engines, the WHOOP webhook, the Withings activity, sleep
and ECG legs, the Oura history backfill and the sleep-segment sweep wrote
measurements without touching the read caches; a long-history import
showed the old all-time figures until the entries aged out.

The shared post-mutation tail now marks the caches stale back to the
oldest row it is handed, and the Withings and Oura legs that hand-rolled
the same tail run through it. The two export imports share one tail that
refolds the rollups, marks the caches stale back to the oldest sample
written (all of them when none was) and re-warms the status assessments.
A sweep that tombstones a sleep segment drops every cached block.
2026-10-22 18:37:25 +02:00
Marc-André Bombeck 49a999ab94 test: tighten the version-stamp baseline and sweep instrument wording through the source helper
The wizard drops its slug alias to stay inside its function-size
baseline, the bare-t() sweep reads the tree through readSourceFolder,
and the version-stamp baseline takes the lower counts the dose-screen
and questionnaire changes left.
2026-10-22 18:31:05 +02:00
Marc-André Bombeck c15c1d18f3 fix(coach): read one lab marker's history in full
With an analyte, the Coach's labs block read the newest 4,000 lab rows of
every marker and matched the asked-for one in memory afterwards. An account
with that many newer readings of other markers lost the marker's older
readings, so its count and first date named a shorter history than the
record holds, or the marker vanished. The match now runs in SQL before the
row cap, on the biomarker's name for a linked row and the row's own name
otherwise, and the history compares units with sameLabUnit, so a reading
stored as "MG/DL" beside "mg/dL" stays in the series.
2026-10-22 18:22:36 +02:00
Marc-André Bombeck e78d5f86a6 fix(i18n): say what the Coach reads of older lab results when the lookback has no limit
The no-limit option promised every lab result, and the German detail
sentence ended on a dangling clause. The Coach keeps the latest value
of the most recently measured markers and, for a marker asked about,
its five previous results with older ones summarised per year for ten
years; years before that are only counted. Every language now says
that in complete sentences, with the two numbers pinned to the lab
history constants.
2026-10-22 18:21:58 +02:00
Marc-André Bombeck 466dfb3785 fix(workouts): resolve duplicate clusters the same way on every surface
The cross-source picker anchors each duplicate cluster on its first start,
so which cluster a workout lands in depends on the workouts around it. The
detail route still read the old fixed five-minute clock slot, and every
other reader handed the picker only the rows of its own range. The detail
of a twin that straddled a slot boundary declared itself canonical while
the list hid it, and runs at 09:58, 10:02 and 10:06 were grouped one way by
a read starting at 09:55 and another by a read starting at 10:00.

Every reader that decides which workout is canonical (list, detail and its
previous session, activity calendar, day view, doctor report, Coach,
briefing and correlation blocks, strain, per-sport context, the activity
insight's history, the batch write) now reads through one windowed reader.
It reads a duplicate window past the end of the range and reaches back
until every sport in the range starts at a workout that is an anchor
whatever came before it, then picks over everything read and keeps the
rows that start inside the range. The per-sport baseline and the insight
history leave out the whole cluster of the current session instead of the
row alone, which left its twin behind as a session of its own. The batch
write clusters its rows together with the stored workouts around them and
drops only a row that loses to another row of the same batch.

A property test checks the windowed read against a whole-history read for
random dense histories and ranges; route tests pin list, detail, deep link,
previous session and the batch write against the database.
2026-10-22 18:21:02 +02:00
Marc-André Bombeck 3354631a85 fix(ai): screen numberless halving and dose changes, and hold continuations to the plan
The outbound screen let a halving with no number through in every
language but Dutch ("Reduce your dose by half.", "Reduza a dose pela
metade.", "Tome meia dose."), let a bare change imperative through
("Zmniejsz dawkę.", "복용량을 늘리세요.") and did not know the Polish
spelling of the generic stems, so "Odstaw ramipryl." passed. Each
language's bank now carries a halving and fraction shape and a bare
change shape on a medication noun, and the stems include the Polish
-pryl, -statyn and -glutyd.

The continuation exemption trusted "as before" on any surface that
passed no schedule, so "Take 40 mg as before." went out to people who
had never taken 40 mg. The screen now takes the dose plan (each active
medication with its dose) instead of a bare list of numbers, and
exempts a stated amount only when it is the planned dose of the
medication the sentence names. Without a plan no stated amount is
exempt.
2026-10-22 18:20:02 +02:00
Marc-André Bombeck 47ac2c6889 fix(support): redact host names under Brazilian, Portuguese and other app-region TLDs
The support report's host-name step only knew a short list of top-level
domains, so saude.minhacasa.br:3000 or vida.example.com.br reached a
report meant for a public issue unredacted, as did hosts under .pt, .be,
.lu, .li, the Nordic codes and the English-speaking ccTLDs. The list now
covers the country codes of every app language's home regions, pinned
by a test against the shipped locales.
2026-10-22 18:09:22 +02:00
Marc-André Bombeck 38941c55d8 fix(mental-health): stop sending Portuguese readers in Portugal to Brazil-only crisis lines
The app ships one Portuguese bundle, and pt-PT, pt-AO and bare pt all
fold into pt-BR, so a stored pt-BR does not mean Brazil. A positive
PHQ-9 item 9 still answered it with SAMU 192 as the emergency number
and CVV 188 as the only line, neither of which connects from Portugal,
under a comment claiming pt-PT kept the international set.

With no region signal, Portuguese now gets the DE_AT_CH treatment: 112
first, the worldwide directory, then CVV and SAMU labelled Brazil and
SNS 24 (808 24 24 24, option 4) and SOS Voz Amiga labelled Portugal.
An explicit pt-PT tag gets the Portugal set and br the Brazil set.
2026-10-22 18:08:31 +02:00
Marc-André Bombeck 5448a43651 fix(mental-health): show unvalidated languages the English questionnaire wording
The PHQ-9, GAD-7 and WHO-5 are not validated in Dutch or Brazilian
Portuguese, and the SCI is validated in English only, yet the check-in
wizard and the answer breakdown read the item text, answer options and
stems through t() in the reader's language. Dutch and Brazilian
Portuguese readers saw unvalidated translations of every item,
including PHQ-9 item 9, under a note saying the questions were in
English.

Every surface now resolves instrument wording through instrumentText,
which returns the English source for a language outside the
instrument's validated set. The unvalidated wording is removed from
the bundles; the locale-parity check accepts exactly those keys as
absent and fails when a bundle carries one, and a structural test
keeps item text out of bare t() calls. The PHQ-9 follow-up question
regains its original wording. CONTRIBUTING states the rule for
translators.
2026-10-22 18:05:16 +02:00
Marc-André Bombeck 3decab47a0 fix(i18n): name dengue and tuberculosis in Brazilian Portuguese 2026-10-22 17:45:43 +02:00
Marc-André Bombeck d600d6f9d2 Merge pull request #1266 from asmala/feat/vaccines-dengue-tuberculosis 2026-10-22 17:45:16 +02:00
Marc-André Bombeck a3bf826fe7 Merge branch 'main' into feat/vaccines-dengue-tuberculosis 2026-10-22 17:27:17 +02:00
Marc-André Bombeck f38954f2ae test(e2e): read the reduced-motion sheet before its first frame
The check sampled getAnimations() once after the sheet showed, which passes or fails on whether WebKit drew a frame in between. It now reads the sheet and backdrop the moment they are inserted, the worst case, and is deterministic: it fails every time against the old classes. The other measurements in the spec already wait on polls, rendered frames or layout.
2026-10-22 17:02:27 +02:00
Marc-André Bombeck d6ed165ac6 fix(ui): open the bottom sheet without any slide under reduced motion
data-[state=open]:animate-in carries an attribute selector and outranked motion-reduce:animate-none, so the slide stayed, only cut to 0.01 ms by the global reduced-motion rule. It still existed and ran until the next drawn frame. The none is important now, on the sheet and the backdrop.
2026-10-22 17:02:26 +02:00
Marc-André Bombeck cdad3057cf test(guards): carry main's sizes into the ratchet baselines
Every raised entry is main's own number for a file this branch had not touched since the fork, so no value is above both sides; the version-stamp counts that fell are recorded lower.
2026-10-22 17:02:26 +02:00
Marc-André Bombeck 9c8972bc96 Merge origin/main (v1.44.2) into fix/v145-sheet-motion
next stays on main's 16.4.2 (the RCE and Server Actions advisories) with its two companions; release's newer dev tools and its own additions are kept. The lockfile is release's, re-resolved. pt-BR, which main does not have, takes the one key main added.
2026-10-22 16:43:28 +02:00
Marc-André Bombeck 8e7788132a Merge pull request #1270 from MBombeck/release/v1.44.2
Release v1.44.2
2026-10-22 16:07:20 +02:00
Marc-André Bombeck abb79ee6ae fix(ai): let the accented-word safety and grounding patterns match
JavaScript's \b is an ASCII word boundary, so next to an accented
letter it never matches. That left patterns, or alternatives of them,
dead: the French "en bonne santé", "à cause de", "causé" and
"continuez à", "probabilité" and "probabilità" in the French and
Italian risk banks, the Dutch "één procent", the German "Ärzte", and
the Polish "przedział", "średnia" and the verb endings in "-ą". A \w*
stem cannot run through "ł" either, so "doprowadziło do" missed its
causal pattern, and the Polish adult pattern was spelled "dorosli"
without the "ś".

Those boundaries now use lookarounds that count the Latin letters
with diacritics as word letters (WORD_START / WORD_END in the shared
screen module, the same class inline in the regex literals), the
Polish stems take accented letters, and "dorośli" is spelled both ways.

Two guards keep the class out: every pattern of every locale's screen
and population-norm bank has to match at least one realistic sentence,
so a dead pattern fails the suite, and no pattern may put \b next to
an accented letter. Both fail on the old patterns.
2026-10-22 15:57:08 +02:00
Marc-André Bombeck 9497b62b20 test(e2e): let the spring-back drag see a rendered frame before release
WebKit in the CI container can go over a second without rendering a frame while events and timers run on; the trace shows the More sheet still sliding in after its animation reported finished. The sheet paints once per frame, so a drag released inside such a stall was never drawn. The spring-back check now holds the finger for two rendered frames, and the settle helper waits for two frames too.
2026-10-22 15:35:42 +02:00
Marc-André Bombeck a0e97a4e54 fix(ui): attach the bottom sheet's touch listeners when the sheet mounts
The non-passive touchmove listeners on the sheet and its backdrop were bound in an effect that ran while the portaled sheet was still unmounted, and never ran again, so they were never attached. The mounted element is now state, and the listeners follow it.
2026-10-22 15:35:41 +02:00
Marc-André Bombeck b89cb6a29f test(guards): drop the raised size baselines and record the lower ones
Undoes the raise from the pt-BR tables commit. After the screen banks,
population-norm patterns, insights footers, settings labels and
Telegram words moved into their own modules, every file that grew is
below its old line count, outbound-screen.ts leaves the over-800 list
and buildDoseChangeClass the over-200 list.
2026-10-22 15:26:38 +02:00
Marc-André Bombeck dcb2598bb2 refactor(ai): keep each language's outbound-screen banks in its own module
outbound-screen.ts held every locale's dose, risk, causal, continuation
and change-stem patterns plus the shared units and slot builder, and
grew with each language. The banks now live in
outbound-screen/locales/<locale>.ts, one per language, each exporting
the same LocaleBank shape; the units, target-dose shape, object slot
and generic-name stems are in outbound-screen/shared.ts. The screen
composes them per locale and keeps the matching logic, the
continuation rule and the general rationale.

No pattern changed: compiling the old and the new module and comparing
every bank of every locale, with and without a medication-name list,
gives the same regular expressions (108 of 108 identical), and the
screen tests still block and pass the same sentences in every locale.
The screen is 457 lines instead of 1,597, and buildDoseChangeClass is
a four-line composition.
2026-10-22 15:26:37 +02:00
Marc-André Bombeck 996cc15b50 ci(e2e): run the Chromium shards in the Playwright image too
Installing Chromium's system packages from apt stalled a shard for 29
minutes, the same way WebKit's did. The shards now use the image the
WebKit job already runs in, whose tag follows the lockfile's
playwright-core, so no browser or system package is installed at run
time.
2026-10-22 15:22:03 +02:00
Marc-André Bombeck 8c11bb8385 refactor(coach): keep the population-norm banks in one module per locale
The reference-sentence exemption's patterns move out of
coach-prose-grounding.ts into population-norms/<locale>.ts, composed
into the same Record the check already reads. The patterns themselves
are unchanged. A test feeds every locale one reference sentence the
English bank cannot match, so a module that is not wired fails.
2026-10-22 15:21:10 +02:00
Marc-André Bombeck f987efd9f3 refactor(insights): move the insights reply-language footers into their own module
The per-locale footer table is prompt text, not prompt logic; it moves beside the generator, which drops back under its size baseline after pt-BR joined the table.
2026-10-22 15:19:33 +02:00
Marc-André Bombeck 50a6247177 refactor(settings): move the managed-record option lists and label keys into a data module
The Coach metric and cluster options and the label keys of the managed-record settings forms are data; they now sit beside the forms instead of inside the component file, which drops back under its size baseline after the locale picker started reading the locale list.
2026-10-22 15:18:58 +02:00
Marc-André Bombeck 945e998516 refactor(telegram): keep the bare help and greeting words in a per-locale table
The two word lists lived inside the webhook handler and had no entry
for Dutch or Korean. They move to their own module, keyed by locale so
the compiler names a language that has none, and gain "hulp", "hoi",
"goedemorgen", "도움말", "도움", "안녕" and "안녕하세요" beside the
Portuguese words. The bot still matches the union on the folded text.
2026-10-22 15:16:42 +02:00
Marc-André Bombeck 6c15a66cde feat(mental-health): show Brazil's crisis line to pt-BR readers
A Brazilian Portuguese reader who answered PHQ-9 item 9 saw the
international set: 112 and two directories. They now see CVV (Centro de
Valorização da Vida), 188 by phone and the chat at cvv.org.br, free and
answering around the clock, with SAMU's 192 as the emergency number.
Both were checked against cvv.org.br and the Ministry of Health's SAMU
page. A bare "pt" or "pt-PT" keeps the international set, because 188
does not connect from Portugal.

The CVV label is translated in every bundle, and a test pins that every
resource of every locale's set resolves its label in that locale.
2026-10-22 15:15:48 +02:00
Marc-André Bombeck 36a1485b91 style: format the database helper test 2026-10-22 15:08:14 +02:00
Marc-André Bombeck 2d16183b58 feat(i18n): let translators edit the catalogues in the browser with Fink
Add an inlang project so Fink opens the repository directly:
English as the source, the nine shipped locales, and the
message-format plugin pinned to 4.5.5 reading messages/{locale}.json.
That plugin keeps the nested keys and {placeholder} tokens as they
are; the i18next and next-intl plugins Fink also supports either
rewrite numeric-keyed objects as arrays or misread quotes around a
placeholder as ICU escapes.

Checked against these files with the inlang CLI and SDK: all nine
locales load with no errors and no findings, a load and save without
edits leaves every file byte-identical, and an edit changes exactly
the edited lines. inlang's working files beside settings.json ignore
themselves in git and are kept out of the format check.

CONTRIBUTING gains the browser workflow (fork, push, pull request),
what a reviewer checks, and that machine translation is a draft a
reader of the language goes through before the pull request; the
new-locale steps now name the real locale list. A test keeps the
project's locales equal to the shipped ones and the plugin pinned.
2026-10-22 15:03:16 +02:00
Marc-André Bombeck dc9beaefb7 fix(i18n): spell the Korean singular forms the way English does
Korean has no singular plural category, so the two ...One strings are
never chosen at run time, but they carried {count} where English writes
the number. They now read "1일 후" and "검사 수치 1개", which leaves no
placeholder that English lacks.
2026-10-22 15:02:15 +02:00
Marc-André Bombeck 1bc5df17bc test(guards): write back the lower source-read counts
Since the guards read the source tree through the shared index, 35 of
them no longer read a source file by path, and the realism ratchet
failed until the lower numbers were recorded.
2026-10-22 14:57:59 +02:00
Marc-André Bombeck 10bd2e5d94 test(guards): raise the size baselines for the pt-BR tables
The outbound screen, the prose-grounding check and the insights and
Coach prompt files keep one table per locale on purpose: the dual-bank
rule (the reader's bank plus English) reads in one place, and every
other locale lives there. A ninth locale adds its rows, mostly to the
dose, risk and causal banks of outbound-screen.ts and its
buildDoseChangeClass table (+134 and +40 lines). The managed-profile
settings section and the Telegram handler grow by an import and the
Portuguese bare words.
2026-10-22 14:57:33 +02:00
Marc-André Bombeck 51f7d38b3c test(i18n): give the per-locale copy guards their pt-BR vocabulary
The delete-confirmation, sleep-debt, mood-prognosis, MCP-token and
AI-consent guards match each locale against its own phrases, and the
sharing-boundary guard against the word for account; pt-BR now has
them. The locale-count floors read nine, and the managed-profile picker
test counts the locale list instead of a literal.
2026-10-22 14:57:32 +02:00
Marc-André Bombeck 71123e5fae test(i18n): hold every catalogue to the English placeholders
Key parity let 38 pt-BR strings through that renamed or dropped a
{placeholder}: a renamed one renders as a raw token, a dropped one
loses what the sentence was for. Every non-English bundle now has to
use exactly the placeholders of the English string, with one
allowance: a singular plural form may spell out or add {count}.
2026-10-22 14:57:09 +02:00
Marc-André Bombeck 5c2f91463c fix(i18n): state the glucose band edges in the five locales that left them out
Since the explainer started naming the fasting bands in the reader's
glucose unit, the English, Dutch and Korean text states where normal,
prediabetes and diabetes begin and that one high reading is not a
diagnosis. The German, French, Spanish, Italian and Polish text kept
the older sentence without the bands or the caveat. They now carry
both, with the same placeholders as English.
2026-10-22 14:57:00 +02:00
Marc-André Bombeck fed49f9b76 fix(i18n): translate the last English leftovers and a stray backslash in pt-BR
Two badge and plan strings still carried the English text, and the
Coach plans description escaped its quotes twice, so the reader saw
\"se… então…\" with literal backslashes.
2026-10-22 14:57:00 +02:00
Marc-André Bombeck 34ee0ea6e8 fix(i18n): separate the intake from the amount in the pt-BR medication wording
"Dose" stood for both the amount of a medication and the act of taking
it, so "Editar dose" could mean either, and the mechanical toma-to-dose
pass had turned two questions into "Com que frequência você dose?".
Where English says intake, the Brazilian strings now say "tomada"
(tomadas registradas, histórico de tomadas, editar tomada); "dose"
stays for the amount and where English itself says dose.
2026-10-22 14:50:52 +02:00
Marc-André Bombeck 4df28c5f80 feat(i18n): fill the pt-BR keys added since the translation was drafted
411 English keys had no Brazilian Portuguese string yet: the 135 that
were already missing when the translation was opened, and everything
v1.44 and v1.45 added since (the activity calendar, source provenance,
the lab and medication CSV import, the doctor-report workout and
medication-effect sections, the segmented medication-effect view, lab
trends and OCR outcomes, the mental-health trend lines, and the new
settings, integration and error strings). The bundle now has exactly
the English key set.

Terminology follows the existing catalogue: medicamento, exame,
treino, faixa de referência, chave de acesso, você.
2026-10-22 14:50:52 +02:00
Marc-André Bombeck 4e47c52aed fix(i18n): match the pt-BR wording and placeholders to the English source
38 strings used other placeholders than English: {when} for {date},
{provider} for {name}, a {unit} English dropped since, and the
integration-card, document-archive and Apple Health freshness lines lost
{since}, {list}, {relative} or {time} altogether, so the reader saw a
label where the sentence belonged. Most of those lines had been
translated from the key name ("Plano limitado", "Erros do provedor")
rather than from the English text; the whole of those sections is
translated from the source now, along with the Dawarich, glucose
reference, setup, re-confirmation and connector-token lines that had
the same problem, and the 19 strings whose English changed after the
translation was drafted.

The Telegram help documented "tomado <nome>", but the bot only accepts
"taken <name>"; it now shows the command the bot understands.
2026-10-22 14:50:28 +02:00
Marc-André Bombeck 3577da6966 fix(coach): narrow the pt-BR population-norm patterns
A sentence the grounding check reads as population guidance has its
numbers exempted from the evidence check. Two of the pt-BR patterns
matched sentences about the reader's own readings: "(entre|de) ...
geralmente" fired on "Sua pressão de 158/97 geralmente fica...", and a
singular "médico ... recomenda" with a 30-character gap fired on "Seu
médico recomenda manter a pressão em 158/97". Either let an invented
figure through unchecked.

Every pt-BR entry now needs a population subject or a reference noun:
plural guideline, doctor or specialist subjects next to their verb,
plural adults needing something, and "a/o <metric> normal" without a
possessive. Portuguese month names join the written-date pass so "21 de
julho" is not graded as a figure, and quilos, por cento and passos
join the unit words.

Negative cases prove the reader's-own sentences are checked again;
positive cases keep the reference sentences exempt.
2026-10-22 14:50:17 +02:00
Marc-André Bombeck 68b428e3f5 feat(ai): screen Brazilian Portuguese model output for dose, risk and causal claims
The outbound screen runs the reader's bank plus English. For a pt-BR
reader that meant English only, so "Pare de tomar a metformina",
"Dobre a dose de insulina" or "Seu risco é de cerca de 12%" passed every
surface unchanged.

Add the pt-BR banks the other locales carry: the dose-change class
(change, take, skip, stop, halve, double and one-more imperatives in
the você form, with the medication nouns, drug classes and stop words a
Brazilian reply uses), the target-dose patterns, the risk-figure and
risk-engine patterns, the causal phrases, and the continuation and
change-stem pair that keeps "continue tomando conforme prescrito"
exempt. UI joins the insulin units, and the count words and dosage
forms gain the Portuguese spellings.

Tests pin each class blocking in pt-BR and the restatements, negated
warnings, referrals, past changes and dietary advice passing.
2026-10-22 14:50:10 +02:00
Marc-André Bombeck 929d83b3b6 test(e2e): record the whole drag instead of one fixed-time sample 2026-10-22 14:50:01 +02:00
Marc-André Bombeck 764794496c feat(i18n): register Brazilian Portuguese as a locale
Add pt-BR to the locale list and everything that hangs off it: the
client loader, Intl and date-fns mapping, the reply-language footers of
the Coach and insights prompts (English body, pt-BR footer, the same
path Korean and Dutch take), the citation-coverage normative words, the
no-key fallback copy, the AUDIT-C standard-drink line, country labels,
period search, CSV yes/no cells and the Telegram bare-word aliases.

The locale enums in the extract, managed-profile and record-settings
schemas now derive from the locale list instead of repeating it, and
the five notification and login-alert routes share one isLocale guard
instead of hand-rolled chains, so a later locale is one edit there.
A language tag is matched exactly and case-insensitively before its
primary subtag, so pt-BR and pt-PT browsers land on the Portuguese
bundle rather than English, and AUDIT-C no longer cuts a region tag to
two letters.

PHQ-9, GAD-7 and WHO-5 are not marked validated for pt-BR: the wording
has not been read against the published Brazilian forms, so readers get
the English items plus the existing note.
2026-10-22 14:50:00 +02:00
Marc-André Bombeck 6cf69bbf1c test(insights): pin the clock in the two-service workout collapse case
The case built its sessions from the real clock: one copy a day before
now, the other 30 s later. The cross-source workout picker groups sessions
into fixed five-minute slots of the epoch, so whenever the suite ran in
the last 30 s of a slot the two copies landed in different slots and
counted twice. It had nothing to do with day boundaries or time zones: the
outcome followed the minute of the wall clock alone.

The case now pins Date to an instant well inside a slot. With the pin at
12:04:45Z or 23:59:45Z the case failed under UTC, Europe/Berlin and
Pacific/Kiritimati alike; at the committed 12:02:00Z it passes under all
three.

The production code reads no clock here (the block takes `now` from its
caller). What the flake exposed is the fixed slot grid itself: two copies
of one session that straddle a slot edge are not collapsed. That is a
change to the picker every workout surface shares, so it is left for a
change of its own.
2026-10-22 14:49:55 +02:00
Marc-André Bombeck 1619b2406a fix(i18n): reshape the Brazilian Portuguese catalogue onto the English key structure
The pt-BR bundle carried 420 top-level sections that duplicated the
nested settings and onboarding keys (1,835 strings, of which 538 differed
from the nested copy), wrapped thresholds, gettingStarted and trendHints
in one-element arrays, and kept 48 keys English no longer has.

Rebuild it in en.json's order and shape: unwrap the arrays, drop the
stray top-level duplicates in favour of the nested copies (which match
the terminology of the rest of the catalogue), and drop keys English
does not have. No wording changes.
2026-10-22 14:41:17 +02:00
Marc-André Bombeck 402daa2753 Merge pull request #1251 from dareoon/feat/i18n-pt-br 2026-10-22 14:19:17 +02:00
Marc-André Bombeck 7363bcd5d3 ci(e2e): trust the checkout in the WebKit container; measure the day button once it settles 2026-10-22 14:16:08 +02:00
Marc-André Bombeck e18cd086fd ci(e2e): run the WebKit spec in the Playwright image, keep the shards on Chromium
Installing WebKit's system packages in every shard stalled one shard for
29 minutes on apt and left another without time for its tests. The
bottom-sheet spec now runs in its own job inside Microsoft's Playwright
image, whose tag follows the playwright-core version the lockfile
resolves; the shards install Chromium only and skip the WebKit project.
The e2e gate answers for the shards and the WebKit job.
2026-10-22 13:44:10 +02:00
Marc-André Bombeck 98a083c86e test(guards): scan once in the threshold and timezone guards
Both guards timed out at five seconds under a loaded unit suite. The
timezone formatter guard grepped the whole of src, the generated client
included, once per scan; it now reads the tree once without the generated
client and keeps a file only when a plain substring says a pattern could
match. The threshold literal guard parsed every client file once per rule
and twice per run; each file is parsed once, each rule's hits are found
once, and a file is parsed for a rule only when it carries one of the
rule's markers. A sample that the markers would skip fails the matcher
case, and a planted literal still fails the allowlist case.

Measured alone: the timezone guard 5.5 s to 0.45 s (its floor case 1.9 s
to 2 ms), the threshold guard 4.6 s to 0.54 s (its non-empty case 1.1 s to
0 ms, reusing the hits it already found).
2026-10-22 13:02:59 +02:00
Marc-André Bombeck 8fc4941200 docs(changelog): local thinking models and the regenerate timeout
Refs #1267
2026-10-22 13:02:03 +02:00
Marc-André Bombeck 9e2a8229ec feat(settings): say that the daily briefing reasons at its own depth
The provider's reasoning select reaches neither the Coach nor the
background jobs: the daily briefing thinks at Medium at most, lower under
the operator's cap, and not at all on the Frugal usage level. A line under
the select now says so, in every locale. Behaviour is unchanged.

Refs #1267
2026-10-22 13:01:20 +02:00
Marc-André Bombeck 6d250d092d fix(insights): wait as long as the response timeout allows in the browser
The regenerate button aborted its inline POST after a fixed 45 s, so on a
slow local model every regenerate was cut while the server still had
minutes left on the person's setting. It now uses clientAbortMs over the
route's sequential model calls, never less per call than the briefing's
own 180 s default, because the published value cannot say whether the
person set it.

A save of the about-me self-context derives the clarifying questions
inline and ran under the 15 s request default, which reported a failed
save while the server went on to commit it. All six save sites now take
their abort from the same setting. Every other browser request that waits
on a model either has no client timeout or only queues the work.

Refs #1267
2026-10-22 13:01:20 +02:00
Marc-André Bombeck 221960af88 fix(ai): ask a thinking model again when it answered with reasoning alone
Ollama serves thinking models such as Gemma 4 on /v1/chat/completions with
the thought in a separate `reasoning` field. A long briefing can come back
with empty `content`: the thought used the whole token budget, or the
answer itself was parsed into `reasoning`. The background briefing always
asks to reason, so every briefing on such a model ended in the empty-reply
sentinel, which the chain logged as status 0 / cause "network" in the same
second the server logged 200, and the read path showed as a timeout.

The Local client and the OpenAI-compatible gateway now treat a reply with
reasoning and no answer as a lesson: the endpoint and model are remembered
in the reasoning dialect cache as answering only unreasoned, the call is
asked once more with reasoning_effort "none", and later calls ask that way
until the lesson expires. A test drives the real client, safeFetch and the
pinned dispatcher against a loopback server that answers the way Ollama
serves Gemma 4, and reproduces the reported log line without the fix.

Failed hops now carry what the call ran into: `failure` (timeout, aborted,
refused, reset, socket, parse, empty_response, reasoning_only, ...) and a
code-shaped `code` on ai.chain.link_failed, closed vocabulary only. The
cause only says "timeout" for a deadline, and the briefing failure marker
records the kind so the empty state suggests a longer timeout only when
time ran out.

Refs #1267
2026-10-22 13:01:10 +02:00
Marc-André Bombeck bb3ee6b855 fix(safe-fetch): leave the deadline to the caller's signal
The pinned dispatchers kept undici's own 300 s header and body timers. A
local model with the response timeout set above five minutes answers a
non-streamed completion only once it is done, so its headers could take
longer than 300 s and undici dropped a call the caller still allowed; the
failure read as a bare network error. Every safeFetch call carries its own
AbortSignal.timeout, so that signal is now the one deadline.

Refs #1267
2026-10-22 13:01:10 +02:00
Janne Asmala 8ea0ba5297 feat(vaccinations): Add dengue and tuberculosis to the catalogue
Dengue: Two doses three months apart and no booster. It's listed with the
travel vaccines.

Tuberculosis: One dose at birth and no booster. It's listed under childhood,
since that's where it appears on most vaccination cards.

Both cite the relevant WHO position paper. The names are translated in
all eight languages, and the OpenAPI spec is regenerated to include the
two new vaccines.
2026-10-22 18:58:15 +08:00
Marc-André Bombeck f08277e595 Merge pull request #1265 from MBombeck/fix/v1441-next-security
Release v1.44.1
2026-10-22 12:26:44 +02:00
Marc-André Bombeck ed04103eb6 chore(release): v1.44.2 2026-10-22 12:05:28 +02:00
Marc-André Bombeck 803d90c6c4 Merge branch 'fix/1261-own-targets' into release/v1.44.2 2026-10-22 12:04:29 +02:00
Marc-André Bombeck ac9693fac9 Merge branch 'feat/bottom-sheet' into release/v1.44.2 2026-10-22 12:04:28 +02:00
Marc-André Bombeck 03b3f6a3ba docs: record the history fixes and what 16.4.2 measured 2026-10-22 11:32:57 +02:00
Marc-André Bombeck aae7bce078 test(security): write the next/image import matchers as literals 2026-10-22 11:32:57 +02:00
Marc-André Bombeck 6cd8ffa526 fix(documents): follow the address bar for the open document
The sheet compared useSearchParams with the last ?doc= it had seen.
Next 16.4 applies its copy of a pushState after the write, and a Back
pressed in between leaves useSearchParams reading no document on both
sides, so the sheet saw no change and stayed open. The sheet now reads
the query from window.location through useSyncExternalStore, re-read on
popstate and on every render, and the selection and close paths build
their URLs from the same live value.
2026-10-22 11:32:57 +02:00
Marc-André Bombeck c496e390be fix(day): hold a step until the layer shows the previous one
A step writes ?day= with replaceState, past Next's router, which adopts
the write a render later. Since 16.4 that render can land after a second
quick step and write the first step's URL back, so [ then ] pressed in a
row ended a day short. A step that finds the layer still showing another
day than the URL now waits, and publishOpenDay makes it in one write as
soon as the layer catches up. Never past today still holds for the sum.
2026-10-22 11:32:56 +02:00
Marc-André Bombeck 08675a4f13 Merge branch 'release/v1.45.0' into feat/v145-issues 2026-10-22 11:00:45 +02:00
Marc-André Bombeck 7bbe64ad1a refactor(healthkit): fold the per-type arrival tally into the ledger module
The batch route counted arrivals inline and grew past its size baseline
with the new-sample rule. tallyHealthKitArrivals now owns the fold, the
route hands it each landed entry and hour with whether it brought a new
value, and the size baselines move down to what the route is now.

Refs #1259
2026-10-22 11:00:06 +02:00
Marc-André Bombeck 190a83c513 feat(coach): read lab results over the whole record
The Coach read lab results from the last twelve months only, even with
the lookback set to its widest option, so a slow change in LDL or PSA
across years stayed out of reach. Labs are sparse, so they are no
longer held to the one-year window that bounds the dense readings: they
reach back over the whole record, and a lookback limit the person set
still applies.

The snapshot block keeps the latest value per marker, now over that
wider window, at its old size: it shares the prompt's size cap and is
never shed. get_labs with an analyte adds each matching marker's
history, summarised so it stays bounded: the newest five earlier
readings in full and older ones as one mean / min / max / count per
year, ten years at most, older years counted. With a heavy fixture
(30 markers, quarterly for 20 years) the overview stays at 4.3k
characters and a named LDL read grows from 0.2k to 1.2k characters for
80 readings. The MCP get_labs read and the lab resource follow, since
they run the same tool.

The widest lookback option no longer reads "All": it names what it
reads, twelve months in detail and every lab result, in every locale.

Refs #1263
2026-10-22 10:50:56 +02:00
Marc-André Bombeck 6a2247e422 fix(healthkit): count a type as arrived only when the batch brought something new
A batch that carried a type with duplicates only moved its
lastReceivedAt, lastTrigger and lastBackgroundAt, so a type whose live
delivery had stalled kept reading as freshly arrived for as long as a
replay, a retry or an older client re-sent its old samples.

The arrival ledger now moves those stamps only for a type the batch
brought at least one new or changed row for. An unchanged stats:
re-post and an hour posted again with its stored value count as
duplicates here; the wire keeps answering updated for them. A
duplicates-only type stamps the new last_seen_duplicate_at column on
its existing row and nothing else. The intake bulk route's dose entry
follows the same rule through the shared recorder. lastSyncedAt and
healthKitLastSyncedAt are unchanged.

GET /api/integrations/healthkit serves the stamp additively as
metricFreshness[].lastSeenDuplicateAt, and the Apple Health card names a
re-send that came after the last arrival.

Refs #1259
2026-10-22 10:34:45 +02:00
Marc-André Bombeck 743a1e742f test(guards): stop the slowest guards repeating their own work
Each of these asked the same question several times or did the expensive
part before the cheap one: the threshold guard parsed every client file
once per rule and twice per run, the i18n reverse check rebuilt its scan
three times and compared every leaf with every literal key, the step-up
caller guard stripped comments and searched every route path in every
file per case, the plausibility guard ran its writer sweep three times,
and the cycle verdict guard parsed every client file to find out whether
it mentions a cycle. Each now works a result out once, and a file is
parsed or searched only when a substring says it can match. A planted
violation still fails the threshold, plausibility, cycle verdict and
backup plan guards.
2026-10-22 10:30:59 +02:00
Marc-André Bombeck 4dc3bd46bf test(guards): read the source tree once per worker through a shared index
walkSourceFiles now lists each indexed folder (src, e2e, prisma) once
per worker and filters it by prefix, readSource and readSourceFolder read
through the same memoised file cache, and readSourceText is the cached
read for guards that read files themselves. The guards project shares a
module graph per worker, so a file is read once however many guards ask
for it. sourcesContaining adds the substring pre-filter for sweeps that
look for a few markers. Every guard over 1.5 s now reads through it;
anything outside the indexed folders is read from disk as before.
2026-10-22 10:30:50 +02:00
Marc-André Bombeck ff49bbb5fc fix(insights): shade the SpO2 chart from the own target when one is set
The oxygen saturation page drew a fixed clinical band, so a person with
a lower target of their own saw their normal range shaded as a warning.
The page now reads the resolved range from /api/user/thresholds and
draws it; without a target the clinical bands stay as they were.

Refs #1261
2026-10-22 10:23:15 +02:00
Marc-André Bombeck 90106d88c8 fix(dashboard): shade blood pressure, pulse and body fat from the own target
The dashboard bands took only the weight target into account. Blood
pressure, resting pulse and body fat were resolved from age and sex
alone, so a target set on /targets left the chart zones and the tile
ranges on the standard band.

buildDashboardBands now reads the stored targets blob and routes every
override through getEffectiveRange, the same resolver the targets page
and the doctor report use. A metric without an override keeps exactly
the band it drew before. The snapshot's targetBands carry the resolved
values, so a native client reading them needs no change; the field
descriptions now say the own target wins.

Refs #1261
2026-10-22 10:23:14 +02:00
Marc-André Bombeck 72e6fd6620 test(e2e): drive the bottom sheet on WebKit with an iPhone profile
A webkit-iphone project runs one spec: drag, spring back, flick, scroll before drag, a slider sideways, the three closes, focus return, the page's scroll position and pull-to-refresh. CI installs WebKit beside Chromium.
2026-10-22 10:20:30 +02:00
Marc-André Bombeck 55211c0e69 fix(pwa): never arm pull-to-refresh while a sheet holds the page
The listeners sit on window, so a drag inside an open sheet on the dashboard or the insights page armed the refresh behind it. They stand down while the scroll lock marks the body.
2026-10-22 10:20:30 +02:00
Marc-André Bombeck 1cc1cda7aa feat(ui): make every bottom sheet draggable
One bottom sheet on top of the Radix dialog, so focus, Escape, the
backdrop, aria and the page scroll lock stay where they were. The sheet
follows a touch with a transform, the backdrop lightens with it, and a
release past a quarter of the height or a flick down closes it; anything
less springs back. Grip and header always drag; content only from its
very top, and sideways gestures never count.

Every `side="bottom"` sheet and ResponsiveSheet on a phone render through
it: the More sheet, the capture and form sheets, the Coach drawer on a
phone and the day. The day opens on the new preview step and pulls up to
90 dvh, its footer held on the bottom edge. Focus now returns to whatever
opened a sheet, not only to a Radix trigger. The command palette fills
the window and keeps no grip or drag.
2026-10-22 10:20:28 +02:00
Marc-André Bombeck 601d5ea447 test(guards): walk the tree once in the timezone formatter guard
Each of the guard's scans started its own grep over the whole of src,
the generated client included, five times per run, and the floor case
timed out at five seconds under a loaded unit suite. The tree is read
once now, without the generated client, and a file is kept only when a
plain substring says a pattern could match; each scan's result is
reused by the cases that ask about it. The file runs in 1.9 s instead of
6.5 s alone, and the floor case in 2 ms instead of 2.3 s.
2026-10-22 10:19:11 +02:00
Marc-André Bombeck 703c92c776 test(integration): take the clock and the probe budget out of two verdict tests
The grant expiry case gave the grant 400 ms and slept 500: on a loaded
runner accepting, signing in and switching took longer than the grant
lived, and the grant expired before it was accepted. The grant now runs
30 seconds and the case moves the clock the resolver reads to the
instant the grant names, so the row still does not change and nothing
sleeps.

The key check's start-up probe is bounded at ten seconds and two per
query. Under load a probe query ran past two seconds and the check
answered inconclusive where the data says mismatch. The verdict tests
now run the check with time to finish; the cases that test the bounds
pass their own budget or sampler and keep them.
2026-10-22 10:14:03 +02:00
Marc-André Bombeck 0cefac9d68 Merge branch 'release/v1.45.0' into feat/v145-backup
# Conflicts:
#	src/__tests__/baselines/source-file-lines.json
#	src/__tests__/baselines/source-function-lines.json
#	src/__tests__/baselines/version-stamp-comments.json
2026-10-22 09:59:37 +02:00
Marc-André Bombeck 6dfb7eada6 Merge branch 'release/v1.45.0' into feat/v145-runtime 2026-10-22 09:41:54 +02:00
Marc-André Bombeck 3524d7c289 test(integration): lower the hot-route statement budgets to the measured counts
/api/auth/me 18 to 13, dashboard snapshot 103 to 99, summary 86 to 84,
the 365- and 3650-day pulse series 6 to 5, the day view 48 to 47,
insights health status 60 to 59, correlations 44 to 43 and a 500-row
batch ingest 31 to 28.
2026-10-22 09:34:42 +02:00
Marc-André Bombeck a451a114a2 docs(bench): record the boot profile and the runtime results
The profile section in docs/contributing/benchmarks.md says how to run
scripts/bench/boot-profile.ts, what the profile of the v1.44 runtime
found (the worker graph as one 753 ms block, a live heap of about
165 MB of which the query compiler holds 50 and the catalogs 33) and what
changed. scripts/bench/results/v1.45.0-*.md hold the before and after
numbers per area, from back-to-back runs on the same machine. The size
baselines take the lowered numbers.
2026-10-22 09:10:14 +02:00
Marc-André Bombeck 83661319d2 refactor(cache): keep the closed-block key helpers in a leaf module
Readers that tag a cell as a closed block no longer import the
invalidation module, which several tests replace wholesale. The cache
route test counts the slice's pre-fold cell apart from the route's two
cells, and the provenance test reads the shared pick's ORDER BY over its
grouped rows.
2026-10-22 09:09:44 +02:00
Marc-André Bombeck 4c86b68363 perf(import): skip the update of a re-imported row that is already stored
Re-importing an Apple Health export the account already holds sent one
UPDATE per row the insert skipped, about two million statements for a
large export, nearly all writing back what was stored. One read per chunk
now finds the rows whose stored values equal what the update would write;
they count as updated as before, without the statement. A key the chunk
carries twice is written in order as before. The natural-key rescue moves
into the same module.
2026-10-22 09:09:44 +02:00
Marc-André Bombeck f50b6b6637 perf(series): read a dense window's rows once for buckets and figures
A long-window read of a dense series sent two statements over the same
canonical rows, the day or hour buckets and the strip's figures, so a
ten-year pulse read picked the canonical source per day and scanned the
readings twice. Both aggregates now run in one statement over the window
materialised once, with the same SQL each had (dense-series-read.ts).

An integration test keeps the two old statements as the reference and
requires the same buckets and figures for pulse (two ranked sources),
glucose and oxygen saturation, day and hour grains, in UTC,
Europe/Berlin, Asia/Kolkata and America/New_York, and the same empty
answer for a type without readings.
2026-10-22 09:09:42 +02:00
Marc-André Bombeck 8f1f4dc691 build: keep the test harness out of the image and the font bindings out of the export list 2026-10-22 08:38:04 +02:00
Marc-André Bombeck 3a4dc16e1d test(guards): read each file and the schema once in the backup plan check
The two-ended checks ask every declared file about every model and each
of its relations, and read the schema and the files again for each
question. With the backup split into more files that took two seconds a
case and timed out on a loaded runner; the reads are memoised now.
2026-10-22 08:36:35 +02:00
Marc-André Bombeck 5902401ab8 chore(release): v1.44.1 2026-10-22 08:25:49 +02:00
Marc-André Bombeck 1fa33f2aee fix(security): turn the image optimizer off
Nothing renders through next/image, so /_next/image only offered a route
that fetches, decodes and re-encodes whatever local path a request names.
With images.unoptimized Next answers it with 404 before reading a
parameter. A guard fails when next/image is imported while the optimizer
is off, or when the optimizer returns with SVG input allowed.
2026-10-22 08:25:49 +02:00
Marc-André Bombeck 44c2973a64 fix(deps): update next to 16.4.2 and nodemailer to 10.1.7
Next 16.4.2 carries the fixes for GHSA-673q-mjm6-r5xr and
GHSA-2464-fhr4-w9xw (unauthenticated remote code execution) and
GHSA-p3m5-59mp-jv5r (denial of service through Server Actions).
eslint-config-next and @next/bundle-analyzer move with it, both pinned
exactly. nodemailer 10.1.7 fixes the address-parser advisories.

The 16.4.0 ignore in the Dependabot config is gone: the history journeys
it was guarding pass on 16.4.2 with the day-step fix, and the note says
what was measured.
2026-10-22 08:25:48 +02:00
Marc-André Bombeck 76af868064 fix(day): step the open day from the URL, not from the last rendered day
A step read the day the layer had last published, which reaches the
shortcut and the header arrows through useSearchParams, a render and an
effect. Next 16.4 lets that chain trail a replaceState by more than a key
repeat, so two quick presses of ] both stepped from the same day and one
was lost. stepDay now reads ?day= from window.location, which the step
itself writes, and refuses a step past today on that value too.
2026-10-22 08:25:47 +02:00
Marc-André Bombeck 4fc6b0e386 Merge branch 'release/v1.45.0' into feat/v145-backup 2026-10-22 08:12:22 +02:00
Marc-André Bombeck 6f20ae4a8d test(backup): hold each schema slice's own rules at their edges
The whole-payload tests meet a slice's refinements only by accident. Each
pattern, bound and two-ended free-text rule now has the value just inside
and just outside it, with the issue it raises: the day keys, a long day's
time away, the Coach's table and trail caps, a life event's title and a
symptom's label, a profile fact's readable value and validity. The backup
validation mutation score goes from 25.5 to 41.6 %; the floor follows.
2026-10-22 08:12:00 +02:00
Marc-André Bombeck 12749f6839 refactor(backup): give each domain its own slice of the payload schema
The element schemas move out of validations/backup.ts into one
<domain>-backup-schema.ts per domain, beside its export and restore, with
the shared primitives in backup-schema-kit.ts. validations/backup.ts now
composes the slices into backupPayloadSchema in the same key order, with
the same defaults and the checks that span sections, so the parsed
payload, every validation message and the OpenAPI contract are unchanged.

The lazy-schema guard now covers the slices too: a slice is reached only
through the payload schema or another slice, never from a domain's export
or restore. The external-id guard carries the payload's exemption to its
slices, and mutation testing scores the slices with the payload schema.
2026-10-22 08:02:14 +02:00
Marc-André Bombeck 5a9a6ce78b Merge branch 'release/v1.45.0' into feat/v145-deps 2026-10-22 07:54:27 +02:00
Marc-André Bombeck 322652a86e refactor(backup): build the export from per-domain slices
buildFullBackupPayload now asks every domain for its slice at once and
lays the slices out in the file's key order (backup-export-sections.ts),
merging the manifest where it first appears. The measurement, medication,
mood, instance-settings and nutrient readers and serialisers move out of
the payload builder into their domain modules; the records section keeps
its composition and manifest, while labs, illness, allergies, family
history, workouts and documents each export from their own module. The
measurement restore half moves to measurements-restore.ts so the export
graph does not pull in the restore's dependencies.

The file, its counts and their order are unchanged; the golden copies
still match. The measurement completeness guard now reads the domain
module and also checks that every carried column is written back.
2026-10-22 07:46:02 +02:00
Marc-André Bombeck 508e19f190 test(backup): fill the parity account by key order and match audit rows by copy
A table read without an order comes back in whatever order its last
updates left on disk, so the column fill could pick a different row of a
two-row table from run to run. The audit rows of a restore are now found
by the copy's id instead of by time, since the database clock and the
test's clock do not agree to the millisecond. The golden copies are
written again from the code before the restore and export moved.
2026-10-22 07:45:19 +02:00
Marc-André Bombeck 5199df56db test(jobs): restate the backfill lane's expiry behaviour for pg-boss 12.38
Up to 12.37 pg-boss retried a job whose handler outlived its expiry in
the same worker beside the run it had given up on, and the lane test
pinned that as an executable statement. 12.38 fails the job and fires
the signal as before, but the worker fetches nothing more until the
handler returns, and the retry starts after it. The test now pins that:
one run at a time, the retry starting after the overrun ends, and the
lane's handler importing once per delivery with never two at once. The
lane's lock stays, as the guard for a second worker or instance.
2026-10-22 07:33:01 +02:00
Marc-André Bombeck 5d3794e734 build(fonts): self-host Inter so the build fetches nothing
next/font/google downloads Inter from fonts.googleapis.com and
fonts.gstatic.com while the app builds, and when that request fails the
build fails with it, which is what broke the arm64 image build and
several CI jobs. The files it downloaded now live in src/app/fonts/inter
under the SIL Open Font License, and next/font/local declares them.

They are the same bytes: Inter 4.001, one variable file per script
subset (Cyrillic and its extension, Greek and its extension,
Vietnamese, Latin Extended, Latin), each used for weights 400 to 700.
The declarations rebuild the stylesheet Google answered, the twenty-eight
faces with Google's unicode ranges in Google's subset order, swap
display, and only the Latin file preloaded. The local loader emits one
unicode range per call, so each subset is its own call under the
family Inter.

The Arial fallback face and the font-sans stack move into globals.css
with the metric overrides next/font/google shipped for Inter; the local
loader would have computed slightly different ones from the file.

A production build with every outbound request sent to a dead proxy
completes, where the same build with next/font/google fails on the
fonts.googleapis.com request. The content security policy already
allowed fonts from self only. A guard refuses a next/font/google import
anywhere under src.
2026-10-22 07:31:16 +02:00
Marc-André Bombeck eda90eeb56 fix(strava): read a dead refresh token from Strava's own fault shape
Strava's token endpoint does not answer RFC 6749 errors. A refresh
token it no longer honours, after the athlete revoked the app or the
token was rotated away, is a 400 with the API's generic Fault:
{"message":"Bad Request","errors":[{"resource":"RefreshToken",
"field":"refresh_token","code":"invalid"}]}. The classifier looked for
invalid_grant, which never arrives, so the failure was booked
persistent and the connection never asked for a reconnect.

The token error now carries the fault reduced to its resource, field
and code (or an RFC error code, should one arrive), and the classifier
lifts a 400 naming RefreshToken to reauth_required, from the error and
from its message after a retry loses the prototype. Every other refusal
is classified by its status as before, including the faults that name
the Application.

Strava documents the Fault and Error model but not these values; the
body is the one several unrelated client libraries captured from live
traffic between 2023 and 2026.
2026-10-22 07:31:15 +02:00
Marc-André Bombeck a57fa8335a style(mcp): tighten the metric index note 2026-10-22 07:25:08 +02:00
Marc-André Bombeck f8a88d1361 perf(auth): read the operator settings once per request
An app boot's /api/auth/me read the AppSettings singleton six times, each
reader with its own few columns: the MFA policy, the Assistant switches,
the module availability, the provider presence, the AI usage bounds and
the reasoning controls. They now take their columns from one read,
memoised for the request and read afresh inside a background job like the
readers themselves. No answer changes.
2026-10-22 07:25:00 +02:00
Marc-André Bombeck e00943ff7f perf(day): read the record sections six at a time
The day view started about twenty-five section reads at once beside the
readings, scores and movement, more than the pool holds, so a
neighbouring request waited for a connection. The sections now run six at
a time and come back in the same order.
2026-10-22 07:25:00 +02:00
Marc-André Bombeck f5d6710670 perf(analytics): keep closed blocks of past readings cached across syncs
Every sync marked all of an account's analytics cells stale, so the slice
re-read the pre-fold remainder (every reading older than five years,
folded per source and day, the slice's largest statement on a long
history) and the day view re-read its past history blocks, although a
sync of today's readings cannot change either.

A cache key can now name the instant its value's readings end before
(closedBlockKeyPart). invalidateUserMeasurements takes touchedFrom, the
earliest measuredAt a write created, changed or removed, and leaves such
blocks in place when it is not earlier; without it every block goes as
before. The batch route passes it from the identities it wrote. The
pre-fold remainder moves into its own module and is cached as a closed
block; the day view's past history blocks carry their end, with a day of
margin for a night across midnight.

The batch route's two up-front reads move into batch-stored-rows.ts with
the once-per-batch key rule they share.
2026-10-22 07:24:59 +02:00
Marc-André Bombeck b6e9eb98f7 test(integration): size the all-time count case to its measured cost
The rollup parity case writes over half a million readings and folds them
into DAY buckets: about 35 s on a CI runner alone. Run beside two other heavy
files on the same Postgres it took 1.7 times as long and crossed the 60 s
default. Nothing in the case waits on a lock: its advisory locks are scoped
to the worker's own database, and the measurement with the database capped
at two CPUs (14.6 s alone, 25.3 s beside the sharing matrix and the restore
memory test) shows plain contention. The case gets a 150 s budget, twice the
contended estimate. HEALTHLOG_INTEGRATION_PG_CPUS caps the database
container so a CI runner's contention can be reproduced locally.
2026-10-22 07:18:57 +02:00
Marc-André Bombeck 7248347188 test(integration): release the held lock when the migration has met it
The 0412 lock-retry case let go of the lock on a 2.5 s timer. On a loaded
runner the migrate CLI took longer than that to start, so the first attempt
found the lock already free, applied cleanly and never produced the 55P03
the case is about. The lock is now released once the attempt is seen queued
behind it in pg_stat_activity and gone again, which is the moment its own
timeout fired, and the case fails outright if no attempt ever queued. It
holds with the CLI start delayed by four seconds.
2026-10-22 07:18:56 +02:00
Marc-André Bombeck d396945f44 fix(api): publish the backup upload
POST /api/admin/backups/upload stores a backup file as a new copy, the
copy whose id the published restore takes, and it refuses a file for
reasons an operator has to be able to look up: a key this server does
not hold (backup.key.missing, with the key ids), an owner account that
does not exist here, a newer schema, a copy too large to store
(backup.upload.too_large). It was exempted from the contract as console
plumbing while its two siblings were published for exactly that kind
of refusal.

It is registered with the three bodies it reads (raw JSON, raw gzip, a
small multipart form) and every status it answers; the summary is the
three identity fields plus one integer count per class, so a class the
backup format gains later does not break a decoder. The round-trip
test calls it through the contract now.
2026-10-22 07:08:02 +02:00
Marc-André Bombeck 579789ec38 fix(api): declare the 400 and 415 the single-value profile writes answer
The six /api/auth/me scalar writes (unit preference, glucose unit,
automatic document reading, local lab OCR, workout routes, the central
Codex switch) read their body through safeJson with a 1 KB cap, so each
answers 415 for a body that is not JSON by content type and 400 for one
that does not parse, beside the 413 they already declared. Their
descriptions said so and the responses did not. All three now come from
one block.

The modules and disable-coach route tests had been skipping the
contract check over a 400 and a 409 the registry already declares; the
skips are gone.
2026-10-22 07:07:46 +02:00
Marc-André Bombeck 5ca3994523 fix(api): declare the FHIR refusals as the OperationOutcomes they are
A /api/fhir/* handler answers its own refusals as FHIR: a bad search
parameter is a 400 OperationOutcome, the hourly fhir bucket a 429
OperationOutcome (issue code throttled), both served as
application/fhir+json, the same way the 404 on a read already was. The
contract declared no 400 and the generic JSON 429 for the 429, and no
403 for the doctorReport module gate, which every data route runs.

The searches now declare the 400, every data route the 403
module.disabled envelope and the FHIR 429 with the rate-limit headers
apiHandler attaches to it. The module gate keeps answering the JSON
envelope: it is the errorCode every client already branches on for a
disabled module, and it runs before the FHIR face does. The route tests
drop their skips and assert the 429's content type and Retry-After.
2026-10-22 07:07:34 +02:00
Marc-André Bombeck c272324c1e fix(api): declare the auth gate's 403 on every operation behind it
requireAuth answers 403 auth.scope.insufficient to a narrow Bearer token
on a route that names no scope it holds, and requireAdmin answers 403
auth.admin.required to a session that is not an administrator's. Both
run before any route body, and the contract only said so in the prose
of the shared 401, so a client generated from it had no typed branch
for either. The route kit accepted the scope refusal on any operation
that declared a 401 for the same reason.

The 403 is added to the assembled path table rather than to
stdResponses. That set is spread into most operations, about three
hundred of them carry a 403 of their own, and most spread the set after
it, so a shared 403 there would have overwritten every module-off and
sharing refusal in the document. withScopeRefusal adds the shared
response where an operation has none and prefixes an operation's own
403 with the gate's code, keeping the sharing sentence last. Operations
that take no credential, and the three surfaces that authenticate by a
credential of their own, are left as they were.

The kit no longer tolerates the refusal on an operation's behalf, and a
guard holds every operation behind the gate to declaring it.
2026-10-22 07:07:19 +02:00
Marc-André Bombeck 4f2af7c544 test(release): hold the image's Prisma CLI install to the app's versions
The guard pinned the npm-installed dotenv in the Dockerfile to a literal,
which is how that install came to trail the app tree by a patch without
anything noticing. It now reads the Prisma and dotenv versions from
package.json and asks the Dockerfile for the same ones, engines included.
2026-10-22 07:06:51 +02:00
Marc-André Bombeck 901cd884e8 perf(measurements): skip unchanged stats: re-posts in the batch route
The phone re-sends the same daily totals on every sync. Each went through
the reconciler, about ten round trips in its own transaction, to write the
same values back, and as an update it started the DAY refold, the rollup
jobs, PR detection and a cache stale for the account.

One read over the batch's stats: ids now finds the rows whose live stored
row already holds every value the update would write; those are reported
updated exactly as before, per entry and in the summary, and nothing is
written or started for them. A changed column, a tombstoned row, a key
that occurs twice in the batch or a value the plausibility gate refuses
still goes through the reconciler.
2026-10-22 07:01:04 +02:00
Marc-André Bombeck 850988c832 perf(analytics): group before the canonical-source pick, and run without JIT
The shared canonical-source CTE sorted every raw reading in its window by
a CASE expression no index can supply; on the benchmark account the pulse
pick over ten years sorted 260,000 rows and spilled to disk. Its order
depends only on (type, source), so the pick now groups the window to its
distinct (type, day, source) triples first and runs DISTINCT ON over
those. The picked source per day is the same by construction; an
integration test runs the old statement beside the new one over a fixture
with overlapping sources, sources off the ladder, tombstones, empty days
and readings either side of midnight, under default and reordered ladders,
for every reader shape, and requires identical rows.

Every app session now sets jit=off. Postgres compiled the larger folds to
machine code, which took 100 to 140 ms of a 220 ms statement and saved
nothing for statements that run once and are bound by reading rows. JIT
never changes a result.
2026-10-22 07:01:04 +02:00
Marc-André Bombeck b5f5a5a580 perf(i18n): build the every-locale match indexes on first use
Seven matchers built their index over every shipped catalog at module
load (the GLP-1 tag map, the nutrient and metric names of the MCP reads,
the SNOMED and LOINC lab names, the Coach's health words, the lab-name
pattern), so loading the worker graph read all eight catalogs at boot.
Each index is now built the first time it is asked for.

The boot profile records the change: the longest busy stretch of the
main thread at boot fell from 753 ms to 241 ms, the live heap after five
idle minutes from 164 to 152 MB, and the catalog chunk sources (two of
9.7 MB) are gone.
2026-10-22 07:01:03 +02:00
Marc-André Bombeck e7b31fe8e0 refactor(backup): run the restore as ordered steps from per-domain modules
restoreBackup becomes an orchestrator: the checks before the first delete
move to restore-preflight.ts, and the transaction runs the steps listed in
backup-domains.ts in dependency order, emptying the account first and then
rebuilding it domain by domain. The inline restores for measurements,
medications, mood, labs, illness, allergies, family history, workouts,
nutrient days, documents, the instance settings and the cleared
notification channels move into one module per domain, beside the
existing section modules. Cross-domain references travel through one
typed context instead of local variables.

The order of every write, the refusals and their audit rows, the skip
report, the cleared counts and the reported phases are unchanged; the
golden copies of the restored account still match. Guards that pinned
the restore by path now point at the domain files.
2026-10-22 06:59:54 +02:00
Marc-André Bombeck 96062d198a chore(deps): keep vite on the 8.1 line its advisories were fixed on
Re-resolving Vitest 5.0.5 had carried vite from 8.1.5 to 8.3.4, two
minors nobody asked for: 8.2 resolves the root through real paths,
which moves file ids in a symlinked worktree, and 8.3 warns on every
run about config-loader features the test configs use. 8.1.6 carries
the same five fixes as a backport, so the lockfile takes that and the
floor in the workspace file stays as it is.
2026-10-22 06:58:12 +02:00
Marc-André Bombeck 01c935261b chore(deps): take nodemailer 10.1.7 for the address parser advisories
GHSA-8c8m-p3m8-cjg8 and two sibling advisories against the address
parser are fixed in 10.1.4; 10.1.5 to 10.1.7 are linear-time and
encoding fixes on the same line. The email channel hands the parser a
recipient address each account sets for itself.
2026-10-22 06:53:16 +02:00
Marc-André Bombeck 73362dc0ae chore(deps): take the weekly production and development updates
Production: the AWS SDK S3 client and lib-storage 3.1152.0, Prisma
(client, adapter-pg, CLI) 7.10.1, TanStack Query 5.104.3 and Virtual
3.14.21, dotenv 18.0.8, jspdf-autotable 5.0.9, lucide-react 1.59.0,
nodemailer 10.1.2, pg-boss 12.38.0, React and React DOM 19.3.1 (still
exact) and undici 8.12.

React 19.3.1 and undici 8.12.2 are security releases. Prisma 7.10.1
stops a nested interactive transaction from leaking its id into a
later top-level one.

pg-boss moves its schema from 45 to 46; the worker applies the
migration on start and the index builds continue in the background.
Its list and delete helpers now page and batch, which touches only the
queue retirement at boot, and that is idempotent. The retention test
pins the installed version, so it moves to 12.38.0 after re-checking
every source line it asserts.

Development: @axe-core/playwright 4.14.0, Playwright 1.64.1, Vitest and
its coverage provider 5.0.5, shadcn 4.22.0. The Stryker runner patch
still applies; it touches the runner's own name join, and 5.0.5 changes
nothing in how Vitest builds a test's full name. axe-core 4.14 turns on
label-content-name-mismatch for the WCAG 2.1 A tag the a11y specs run.

The S3 client is held on the same release as lib-storage rather than
the newest one its range allows, so the two share one SDK core.

Vite gets a floor at 8.1.6 for GHSA-587h-7wmj-h5pm and four sibling
advisories against its dev server and bundled launch-editor. It arrives
only under Vitest and never reaches the image.

The image's separate Prisma CLI install moves with the app tree to
7.10.1, and its dotenv to 18.0.8, which it had trailed by a patch.
2026-10-22 06:53:04 +02:00
Marc-André Bombeck dcf49dd14e refactor(backup): let the account-settings domain own the User column verdicts
The SETTING verdicts are now the keys of the codec table, so a column
becomes a setting by getting a codec and nothing else has to name it. The
verdicts for every other User column move beside it into
account-settings-classes.ts, and the restore half into
account-settings-restore.ts. The backup plan keeps the model verdicts.

The classification guard now also refuses a column that has both a codec
and another verdict, since the composed table would quietly carry it as a
setting.
2026-10-22 06:50:58 +02:00
Marc-André Bombeck 66f8b18cf8 Merge branch 'release/v1.45.0' into feat/v145-backup 2026-10-22 06:42:29 +02:00
Marc-André Bombeck a859adb59c Merge branch 'release/v1.45.0' into feat/v145-runtime 2026-10-22 06:41:30 +02:00
Marc-André Bombeck a20cf17539 fix(jobs): let running jobs finish on stop, and give the worker four pg-boss sockets
Next's signal handler closed the server and called process.exit, which
won the race against boss.stop, so a running job was cut off and stayed
active until its expiry. The shutdown handler now holds process.exit until
the boss has stopped, at most 30 s plus a margin, and compose gives the
container 45 s to stop.

A process that runs the worker gets four pg-boss connections instead of
two for its pollers, cron, supervision and job writes; a web-only producer
keeps two. Both come out of the same per-process budget.
2026-10-22 06:41:17 +02:00
Marc-André Bombeck 27e83f1a83 feat(observability): guard the container's memory, not only the heap
The heap monitor's tick now also reads the cgroup working set (usage minus
reclaimable page cache) against the container limit. At 85 % it writes one
runtime.memory.pressure line with the work in flight, and the heavy lane
sends new heavy jobs back to the queue for a minute and asks a sliced
holder to give way, for at most fifteen minutes per episode. Heap lines
carry the working set and the limit. Outside a cgroup with a limit nothing
changes.
2026-10-22 06:41:17 +02:00
Marc-André Bombeck 2ccac728f4 perf(i18n): read server catalogs from disk, one locale at a time
The eight catalogs were static JSON imports, so every server bundle
carried the whole eight-locale chunk as a source string and parsed all of
them: 32 parsed copies and two 9.7 MB chunk sources in an idle process.
The server now reads messages/<locale>.json the first time a translator,
the SSR cache or a matcher asks for that locale, and keeps one parsed copy
per process on globalThis. allMessages keeps its shape; its entries load on
first read. The image copies messages/ next to the server.

foldLabel moves to a leaf module, so the client medication map no longer
imports the catalog index.
2026-10-22 06:41:16 +02:00
Marc-André Bombeck 7e932c4740 perf(boot): watch the loop from the first line and load the worker in steps
The event-loop monitor starts first in register(), so loading the
database client and the worker graph is inside its view, and a stall line
now carries the process uptime, the startup phase, the boot step running
at the time and the oldest work in flight. Each boot step is timed, and
the boot ends with one boot.steps line.

The five registrars and the lifecycle helpers are imported one at a time
after boss.start(), with a turn of the event loop in between, instead of
all at once when the worker module loads. The longest stall is then the
largest registrar graph rather than the sum of all five.
2026-10-22 06:41:16 +02:00
Marc-André Bombeck 63e8630fcb perf(bench): profile a production boot and the idle heap
scripts/bench/boot-profile.ts starts the production build with every role
under the production heap ceiling against the seeded benchmark database,
and records the time to a healthy probe and to a ready worker, every
event-loop stall the process logs, the heap lines, the full collections
from --trace-gc, the main thread's busy stretches during the boot with
their self time by source module (resolved through the chunk source maps),
and one heap snapshot after an idle wait, summarised by node type,
constructor, largest strings and largest retained subtrees.

The baseline on the current runtime: a 753 ms busy stretch at 0.7 s, most
of it the worker graph, and a live heap of 164 to 172 MB after five idle
minutes, of which the query compiler holds about 50 MB and the eight
locale catalogs about 33 MB (32 parsed copies and two 9.7 MB chunk
sources).
2026-10-22 06:41:15 +02:00
Marc-André Bombeck 64fa65b460 test(backup): pin the file and the restored account against golden copies
Seed one account as far as the schema allows (a row of every two-ended
model, every column of those rows and of the account row, the instance
settings, a sharing grant), export it both ways and restore each file
into a fresh account, over the live account, and from a version-1 file
in the old shapes. The files, counts, parsed payload, preview, restore
outcome, reported phases, audit rows, wide-event fields and every row the
account holds afterwards are compared with golden copies after ids,
ciphertext and run-time timestamps are normalised. Refusals, skip reports
and the schema's issue list for malformed files are pinned the same way.

The seed moves out of the round-trip test into a shared fixture module
so both files fill the same account.
2026-10-22 06:22:30 +02:00
Marc-André Bombeck 0f6d668df1 fix(workouts): match cross-source duplicates by a start window, not a clock slot
Two copies of one session were collapsed only when both starts fell in the
same fixed five-minute clock slot, so a pair thirty seconds apart that
straddled a slot boundary (08:04:50 and 08:05:20) stayed as two workouts in
the list, the batch write, the calendar, strain and the Coach.

Workouts of one user and sport are now clustered by start time: a workout
joins the open cluster while it starts less than five minutes after the
cluster's first start. Wherever no pair within five minutes straddles a slot
boundary the clusters are exactly the old slots, which a parity test checks
against the previous picker on thousands of generated inputs, strain impulses
included. A property test shows any offset under five minutes now collapses,
five minutes or more never does, and a uniform shift of every start keeps the
picked set.
2026-10-22 06:16:54 +02:00
Marc-André Bombeck d98920415e docs(contributing): how the test suites are organised and written
docs/contributing/testing.md says which suite a test belongs in and why,
how to write a route test with the kit and what its contract check proves,
how the settings round trip and the integration database work, how the
shards are balanced and the timings regenerated, when to write a property
test, how the mutation floors are kept, and the flaky-test policy with the
quarantine format. Linked from the docs index.
2026-10-22 06:09:43 +02:00
Marc-André Bombeck f40cdd3dc3 test(guards): lower the realism and version-stamp baselines
The mocked route tests moved onto the kit take the ratchets down: route
tests mocking @/lib/db wholesale 321 to 274, route tests stubbing
apiHandler 138 to 133, as never casts in tests 6 014 to 5 496, and version
stamp comments 10 634 to 10 592 with the deleted files.
2026-10-22 06:09:11 +02:00
Marc-André Bombeck ae2636c9e5 Merge branch 'release/v1.45.0' into feat/v145-testkit
# Conflicts:
#	src/app/api/tokens/__tests__/route.test.ts
2026-10-22 06:08:47 +02:00
Marc-André Bombeck f5d4dd2ec6 test: mutation testing on the core libraries with per-module floors
Stryker runs against encryption at rest, medication compliance, the rollups,
source ranking, unit conversion and backup validation, with the unit tests
that live next to them (vitest.mutation.config.mts). It is too slow for a
pull request and runs nightly and on demand (.github/workflows/mutation.yml).
scripts/mutation-floors.mjs reads the JSON report and fails when a module
scores below its floor in stryker.floors.json, so one well-tested module
cannot hide a weak one. The floors sit two points below the first measured
scores: crypto 46.9 %, compliance 66.8 %, rollups 41.7 %, source ranking
57.0 %, units 87.4 %, backup validation 25.5 %.

@stryker-mutator/vitest-runner 10.0.0 predates Vitest 5, which names a test
"describe > test" where the runner still built "describe test". Every test
inside a describe block was filtered out of the mutant runs, so nearly every
mutant survived (5.5 % across the set). A pnpm patch builds the name the way
Vitest 5 does; the Docker dependency stage copies the patches directory,
which the frozen install needs.
2026-10-22 06:08:20 +02:00
Marc-André Bombeck 1915f91526 test(insights): pin the clock in the two-service workout collapse case
The case built its sessions from the real clock: one copy a day before
now, the other 30 s later. The cross-source workout picker groups sessions
into fixed five-minute slots of the epoch, so whenever the suite ran in
the last 30 s of a slot the two copies landed in different slots and
counted twice. It had nothing to do with day boundaries or time zones: the
outcome followed the minute of the wall clock alone.

The case now pins Date to an instant well inside a slot. With the pin at
12:04:45Z or 23:59:45Z the case failed under UTC, Europe/Berlin and
Pacific/Kiritimati alike; at the committed 12:02:00Z it passes under all
three.

The production code reads no clock here (the block takes `now` from its
caller). What the flake exposed is the fixed slot grid itself: two copies
of one session that straddle a slot edge are not collapsed. That is a
change to the picker every workout surface shares, so it is left for a
change of its own.
2026-10-22 05:58:45 +02:00
Marc-André Bombeck 9bdd0b1a60 perf(bench): record the baseline on the v1.44.0 runtime
One run of scripts/bench/run.ts against the seeded account (1,266,549
measurement rows, anchor 2026-10-22), 20 measured requests per row after 3
warm-up requests, web-only production build, 792 MB heap ceiling, Postgres
16.15. The commit adds only lint rules, guards, tests and scripts on top of
v1.44.0, so the server code measured is the v1.44.0 code.

Cold (caches evicted before each request), p95 and statements per request:
/api/auth/me 17 ms / 19, dashboard summary 233 ms / 31, dashboard snapshot
2253 ms / 158, pulse series 30 d 59 ms / 8, 365 d 311 ms / 8, 3650 d
2118 ms / 8, day view 78 ms / 49, insights health status 140 ms / 68,
correlations 105 ms / 45. Warm, the snapshot is 24 ms / 13 and the 3650-day
series still 1506 ms, since that read is not cached. The 5,000-row batch
ingest took 1722 ms and 424 statements over ten requests.

The machine was shared with other work during the run (load average 8 to
20), so latency is indicative; the statement counts do not depend on it.
The results directory is written by the script and left out of prettier,
so a re-run diffs cleanly.
2026-10-22 05:55:38 +02:00
Marc-André Bombeck d539eee90b test(integration): expect the worker's own database in the environment check
The environment check pinned the database a worker talks to as the single
shared one. Each worker now runs against its own clone, so the check names
the clone and asserts it is not the migrated template the clones copy.
2026-10-22 05:48:05 +02:00
Marc-André Bombeck d010ed2301 ci: a nightly run that shuffles both suites and tries new property inputs
Once a night both suites run with files and tests in a random order, seeded
and printed, so order dependence surfaces on purpose rather than on somebody's
pull request; a failure replays with --sequence.seed. The same run gives every
fast-check property a fresh seed and five times the cases. The integration
reports are merged into a regenerated timing file and uploaded for a
maintainer to commit when the shards drift apart. Not a gate.
2026-10-22 05:39:55 +02:00
Marc-André Bombeck e9f89bbebf ci: shard both suites by measured duration and split the unit suite
Vitest's --shard cut the file list into slices of equal length, which gave
the integration shards 954 s to 1 449 s. A sequencer now deals files out by
their measured duration, plus a fixed per-file cost for import and setup,
longest first to the shard with the least work, and starts the longest files
first inside a shard. Timings live in tests/durations/*.json and are
regenerated from Vitest JSON reports with scripts/test-durations.mjs; every
CI shard uploads its report.

Integration shards run three workers each, one cloned database per worker.
The unit suite runs as three projects decided per file from its source:
guards and unit share a module graph inside a worker, isolated keeps a fresh
one for every file that mocks, spies, stubs or resets. In CI it is three
duration-balanced shards next to a job for lint, format, types and the
OpenAPI drift check.

The required check names stay as they were. "Lint, Typecheck & Test" is now
the job that answers for the static checks and every unit shard, the way
"integration" already answers for its shards. The browser-install guard
recognises a direct, flags-only vitest run as a run of the unit suite.
2026-10-22 05:39:35 +02:00
Marc-André Bombeck d640eea32a test(integration): hold statement budgets on the hot routes
tests/integration/query-counter.ts counts the statements the app sends
while a function runs, by wrapping Client.prototype.query on the copy of pg
the Prisma adapter itself resolves (so a second pg in node_modules cannot
leave it reading zero; a window with no statement throws). Background tasks
are settled before the window closes, so fire-and-forget writes count with
the request that started them.

query-budget-hot-routes.test.ts seeds a small account (four months of the
main types with overlapping sources, stats rows, sleep, glucose, a
medication, workouts and mood), warms each route once, evicts the user's
caches and counts the next call. Budgets, measured on this fixture:
/api/auth/me 18, dashboard summary 86, dashboard snapshot 103, pulse series
30/365/3650 days 6 each, day view 48, insights health status 60,
correlations 44, a 500-row batch ingest 31. A failure prints every
statement grouped by text.

Proof: one extra `SELECT 1` before every handler in apiHandler failed all
ten budgets by exactly one (18 → 19, 86 → 87, … 31 → 32), which also shows
the counts are deterministic; with the probe removed all ten pass.
2026-10-22 05:38:45 +02:00
Marc-André Bombeck 375ca12b05 perf(bench): add a large-account seed and a request benchmark
scripts/bench/seed-large-account.ts (moved from scripts/, extended) writes
one synthetic account of ten years: 1,265,561 measurement rows across the
main types and five sources (Apple Health dense spot rows, Withings blood
pressure and scale, a weekly manual weight, three years of Oura nightly
values, 90 days of Nightscout CGM), `stats:` and per-sample cumulative rows,
nightly sleep stages, 3,020 workouts, 1,095 mood entries and three
medications with 3,285 intake events. Everything goes in through set-based
INSERT … SELECT generate_series; values and timestamps are functions of the
row index and `--anchor`, so a run is reproducible. The rollup tier is
folded with recomputeUserRollups, as the boot backfill does, and the tables
are analysed. About 80 s on the Mac mini.

scripts/bench/run.ts boots the production build once with every role (so
the queues exist), then measures against a web-only process under the
production heap ceiling: p50, p95 and max latency and SQL statements per
request (pg_stat_statements, read once nothing is in flight and the count
holds still) for /api/auth/me, the dashboard summary and snapshot, the pulse
series over 30, 365 and 3650 days, the day view, insights health status and
correlations, each cold (caches evicted by a one-row manual-sync ingest
before every request) and warm, plus a 5,000-row batch ingest as ten
requests of 500. It writes JSON and a markdown table.

docs/contributing/benchmarks.md describes the setup against a throwaway
Postgres in Docker with the production pg_stat_statements settings.
2026-10-22 05:38:36 +02:00
Marc-André Bombeck 28f9b1948b test: a flaky-test quarantine with an expiry the guard enforces
tests/quarantine/quarantine.json is the only way to take a flaky unit or
integration test out of a run. An entry names the file, the full test name,
the issue, the reason, the day it was added and the day it expires, at most
14 days later. Both setup files skip a listed test while its entry is live,
reported as skipped with its issue rather than retried into green. Past its
expiry date the test runs again, and quarantine-guard.test.ts fails until the
entry is removed; it also fails on an entry naming a file or test that does
not exist. The list starts empty.
2026-10-22 05:33:09 +02:00
Marc-André Bombeck 4f5adb4fae fix(units): show zero rather than negative zero after a unit conversion
A converted value that rounds to zero from below came out as -0, and
Intl.NumberFormat prints that as "-0": 0 °F typed and shown back read
"-0 °F", and a body-temperature deviation of a few hundredths below zero read
"-0.0 °F". The rounding now returns plain zero. Found by the new unit
round-trip properties, which check that every value a person can type in
their own unit is shown back exactly as typed.
2026-10-22 05:32:49 +02:00
Marc-André Bombeck 4ff3f3eb5d test: property tests for zones, statistics, cadence, crypto and backups
fast-check properties with one shared configuration (tests/property/): a
fixed seed on every pull request, a fresh one and more cases at night through
FC_SEED and FC_NUM_RUNS, and a printed seed with the shrunk counterexample on
failure.

- Time zones: wall clocks agree with Intl in twenty awkward zones, a wall
  clock maps back to an instant showing it, a skipped hour resolves forward
  on the same day, every instant lies inside the window of the day it is
  filed under, and day keys shift and count as inverses.
- Statistics: every median in the tree says the same number, medians and
  spreads obey order, shift and scale, a least-squares fit recovers an exact
  line and composes from stored sums exactly, and the segmented regression
  recovers a noiseless interrupted line.
- Cadence and compliance: weekly and daily schedules expect exactly what a
  day-by-day oracle expects, in any zone and on transition days, and the
  rate stays within bounds and never falls when a missed dose is taken.
- Encryption: every codec opens what it sealed under the key it names,
  rotation moves the key id and nothing else, and a tampered value never
  opens to anything but the sealed bytes.
- Backups: randomly generated accounts come back from a disaster-recovery
  restore with an identical canonical dump of every generated table.
2026-10-22 05:32:48 +02:00
Marc-André Bombeck 8b89b65f25 build(deps): add fast-check and Stryker as development dependencies
fast-check 4.10.2 for property tests; @stryker-mutator/core and the Vitest
runner 10.0.0 for mutation testing of the core libraries. Exact versions,
each published more than two weeks ago; the audit reports nothing new.
2026-10-22 05:32:37 +02:00
Marc-André Bombeck bc3522aabb test(integration): move medication sub-route tests onto Postgres
Phase config, cadence, side effects, inventory, intake purge, bulk delete and
the import status run through the real handlers against the database. The
advisory-lock ordering the mocked tests read off call order is shown with a
real concurrent transaction holding the inventory lock, cache eviction through
the next list read with a control that the cached list would otherwise stay
stale, and soft deletes on the tombstone and sync counter they leave.
2026-10-22 05:32:22 +02:00
Marc-André Bombeck 43b86e46da test(integration): move notification channel and workout route tests onto Postgres
The channel settings routes, their test-message routes, the reminder
thresholds and the workout detail, delete and batch-scope tests run through
the real handlers against the database. Only the far ends are faked (the
relay, the ntfy server, the Telegram Bot API, SMTP), and the tests assert what
those received. The private-origin source guard stays a unit test and now
points at the two files that carry its runtime proof.
2026-10-22 05:32:22 +02:00
Marc-André Bombeck ff9ef0883b test(integration): move the mood tag route tests onto Postgres
The effective tag read, hide and show, custom tags and groups, and the tag
layout are tested through the real handlers on stored rows instead of a
mocked Prisma client: the migration-seeded catalogue served after a truncate,
owner scoping that leaves another account's tag byte-identical, the caps,
sealed labels, soft delete against purge, a deleted group re-homing its tags,
and the layout's optimistic token from 200 through 409 to a successful retry.
2026-10-22 05:32:21 +02:00
Marc-André Bombeck 26df61f638 test(integration): drive the Strava sync against Postgres
The Strava writer had eight mocked test files and no test against a
database. strava-sync.test.ts runs the real sync behind a fake Strava API at
the safeFetch boundary: field mapping, the after= watermark, idempotent
re-sync on the (user, source, external id) key, in-place overwrites that keep
what only the detail read carries, a cursor that never moves backwards or
past a row that failed to write, the paged backfill with its resume point
and requeue, token refresh with both rotated tokens stored sealed, and the
integration ledger after transient and permanent failures. The sync, the
disconnect and the credential routes are covered through the kit.

The mocked cases that asserted call counts and mock ordering are removed;
the pure retry-after parsing stays a unit test.
2026-10-22 05:32:09 +02:00
Marc-André Bombeck c0c0fdb421 test(integration): move the account preference route tests onto Postgres
Fourteen route tests for the /api/auth/me preference family replaced Prisma
with a mock and asserted the calls it received. Their claims are now shown
through the real handler on the stored row and the response: validation
refusals with their codes, normalisation and defaults on read, optimistic
lock conflicts against a real second writer, rate limits driven to their
first refusal in the Postgres limiter, audit rows read back, cache entries
evicted, and the jobs a change queues observed on a real pg-boss queue.

Assertions that were only about the shape of a Prisma call are replaced by
the behaviour they stood for, and two that described a state only a mock
can reach (a missing column) by the stored value that state resolves to.
2026-10-22 05:31:59 +02:00
Marc-André Bombeck 09f088e8a7 fix(auth): record a self sign-out as one in the session revoke audit
DELETE /api/auth/me/sessions/{id} takes the session's public handle, an HMAC
of the row id, and compared that handle with the caller's row id to decide
whether the caller signed out the session it was using. The two never match,
so every revoke was audited and annotated with self: false, including the
ones where a person ended their own session. The handle is compared with the
handle of the caller's session now.
2026-10-22 05:31:51 +02:00
Marc-André Bombeck f353f55212 test(integration): sessions, devices, security activity and FHIR against Postgres
The mocked route tests for the session list and revoke, the device list and
revoke, the security-activity feed and the FHIR surface move onto the kit.
Each claim is now shown on the stored rows and on the next request: a
revoked session, device login or token is refused when it is presented again,
another account's session or device answers 404 and stays signed in, the
current-session and current-device flags follow the credential that anchors
them, and no row id, full IP or push token reaches a response.

The FHIR read set, covered against a database only by the scope check until
now, is driven with seeded readings and medications: every resource carries
the seeded values, another account's rows never appear, a fhir:read token
reaches every data route and an unrelated narrow token is refused, the module
gate closes all eleven data routes, and $everything serves the same entries
as the document builder with references that resolve inside the response.
The module-gate inventory now names the new file as its behavioural proof.
2026-10-22 05:31:09 +02:00
Marc-André Bombeck b179e34f58 test(integration): round-trip every account setting through the API and a backup
One table holds an entry per write route, and every User column the backup
classifies as a setting appears in exactly one entry; a new setting column
fails the inventory case until it has one. For each entry the file sets a
non-default value through its write route, reads it back through its read
route and through /api/auth/me where that publishes it, and checks the stored
column moved off what a fresh account holds.

The backup leg runs through the routes too: the portable full backup a
person downloads, the operator's upload, and the restore into an account
recreated under the same id. Every setting must read back identically on the
restored account, through the same routes and the account payload, and every
stored column must come back with its value.
2026-10-22 05:30:40 +02:00
Marc-André Bombeck 3d837cdd77 test(kit): call route handlers through the real API kit against Postgres
tests/kit/ holds typed fixture factories and callRoute(handler, options).
The factories write through the application's Prisma client, so a fixture has
the shape production stores and the compiler checks it; sessions and Bearer
tokens are minted by createSession and issueApiToken, so a fixture cannot
drift into a shape authentication no longer accepts.

callRoute builds a real NextRequest, fills the next/headers stand-in from the
auth fixture and runs the exported handler, apiHandler included. Every call is
also a contract check: the status must be declared for that path and method in
the OpenAPI registry, a JSON body must parse with the registered schema, and a
key the schema does not declare fails the call. The scope gate's generic 403,
documented on the shared 401, is the one answer accepted without its own
entry. kit-contract.test.ts proves each refusal can fire.

The token list and revoke move onto the kit. The mocked unit test asserted
the list's where clause and proved the API switch is not consulted by reading
api-handler.ts for a string; the kit test authenticates a Bearer token while
the switch is off, revokes it, and shows it refused on the next request.
2026-10-22 05:30:31 +02:00
Marc-André Bombeck a6787cfc03 test(integration): run files in parallel, one cloned database per worker
Global setup migrates one template database and reads what a fresh install
holds: its schemas, its tables and the rows the migrations seed. Each Vitest
worker clones the template on its first file with CREATE DATABASE ... TEMPLATE
and keeps the clone for the rest of the run, so files run in parallel without
seeing each other's rows, advisory locks or queue schema. Schemas created at
runtime (pg-boss, migration probes) are dropped when the next file starts.

The truncate list is the catalogue now, every table in public except the
migration bookkeeping, instead of a hand-kept list of 42 of 166 tables. The
seeded catalogues are put back after each truncate in foreign-key order, so a
test starts from what a fresh install has. The settings singleton is the one
seeded table a test starts without, declared with its reason.

next/headers and the boot compatibility probe are mocked once in the setup
file. Two lock-wait probes now look at their own database only, since other
workers' backends share pg_stat_activity.
2026-10-22 05:30:00 +02:00
Marc-André Bombeck 45ea61261e build(bundle): gate every page's first load and the distinct client code
The gate summed every emitted chunk once and watched five pages. The sum
counts a module once per chunk group it lands in, so it rose whenever a
page got lighter by moving code into on-demand chunks, and it needed a
raise in most releases while no reader downloaded more; and a heavy import
into a page outside the five went unseen.

scripts/check-bundle-budget.mjs now measures:

- the first load of every page: rootMainFiles plus the page's
  client-reference chunks, each file counted once by content hash;
- p50, p95 and max of those first loads across pages;
- the distinct client code: every chunk evaluated in a node:vm sandbox
  with a stub TURBOPACK array, each module factory counted once by its
  module id (not its text: each chunk is minified on its own, so copies of
  one module differ in mangled names), catalogs excluded, gzipped once.

The summed aggregate is still printed but no longer gated. The catalog,
per-catalog and one-Recharts checks are unchanged. `--write-baselines`
rewrites the per-page baselines, `--dir` measures another build directory,
`--json` writes the report.

bundle-budget.json is re-based from the v1.44.0 build (local arm64): 110
pages, first load p50 336, p95 437, max 451 KB gz; distinct client code
1904 KB gz in 1224 modules; summed aggregate 3875 KB for comparison. Every
page has a baseline with the same 20 KB allowance; caps are p95 447, max
461 and distinct code 1930 KB. A page without a baseline fails until one
is written in the same change.

Proof: importing zod into the weight insights page measured
/insights/weight at 427.8 KB gz, 88.8 KB over its 339 KB baseline, and the
gate failed; the old gate passed the same build, because the page was not
watched and the summed aggregate did not move (3875 KB). The import was
removed again.
2026-10-22 04:36:21 +02:00
Marc-André Bombeck f64b49cf55 test(guards): freeze runtime import cycles and re-export-only modules
The runtime import graph (static value imports and re-exports, side-effect
imports, dynamic import() with a literal; `import type` erased) is built
with the TypeScript compiler's module resolution under the `@/*` mapping,
over every production module. No new dependency.

- Cycles: 13, counted one per back edge of a sorted depth-first walk (the
  number `madge --circular` reports), in 7 strongly connected components:
  the audit/geo/notifications/managed-profiles cluster (10 modules), the
  compliance facade cluster (9), command palette ↔ store ↔ shortcuts,
  modules ↔ cycle gate, boss-instance ↔ boss-ready, mood aggregates ↔
  patterns, features ↔ signals-of-day. A new component or a component that
  draws in another module fails whatever the count says; a lower count or a
  shrunk component fails until written back. The target is zero.
- Re-export-only modules (every statement an `export … from`): 16 listed;
  a new one fails.

Proof: two planted modules importing each other failed with
"lib/zz-probe/a.ts <-> lib/zz-probe/b.ts", a planted `export * from "./a"`
module failed the re-export ratchet, and a baseline raised to 14 cycles
failed with the write-back message until RATCHET_WRITE=1 restored it.
2026-10-22 04:29:57 +02:00
Marc-André Bombeck 2604c3e3c6 test(guards): ratchet source size, test realism and version-stamp comments
Add a ratchet mechanism (helpers/ratchet.ts): per-key counts against a JSON
baseline under src/__tests__/baselines/. A new key or a higher count fails;
a lower count fails until it is written back with
`RATCHET_WRITE=1 pnpm vitest run <guard>`, which only ever lowers numbers.
Raising one is a hand edit in a reviewed change. The baselines are
machine-written, so prettier leaves them alone.

helpers/source-metrics.ts measures the production tree through the
TypeScript compiler: the file list (dot directories included, tests and
generated code out), line counts, every function node with a stable
"outer > inner" name and its length, and the runtime import graph.

Guards and their baselines, measured on this tree:

- source-size-ratchet: 129 files over 800 lines and 415 functions over 200
  lines (catalog and data files exempt, each named with its reason; slack
  25 and 10 lines before a shrink must be written back).
- test-realism-ratchet: 321 route tests mocking @/lib/db wholesale, 138
  stubbing @/lib/api-handler, 6014 `as never` casts in 570 test files, and
  400 test files that read a source file by path (reads through readSource
  are not counted).
- version-stamp-ratchet: 10634 comment lines in 2985 files that open with a
  release version; none may be added.

Proof each can fail: a planted 953-line file with a 253-line function failed
both size ratchets, and one line appended to app/page-client.tsx failed the
file ratchet (2326 → 2327); a planted route test with vi.mock("@/lib/db")
and one `as never` failed both of those ratchets; a planted comment
"// v1.45.0 — probe stamp" failed the stamp ratchet. Raising one as-never
baseline entry by 5 failed with the write-back message, and RATCHET_WRITE=1
restored the file byte for byte. All probes were removed again.
2026-10-22 04:29:47 +02:00
Marc-André Bombeck f3d7ab31e2 test(guards): make path-pinned source reads fail when their subject moves
A test that reads a source file by path and asserts an absence
(`expect(source).not.toMatch(…)`) stays green after a split: the code it
watched moved next door and the old path no longer holds it. A missing file
throws; a file that kept its name but lost its contents does not.

- `readSource(path, anchors)` in helpers/source-files.ts reads one file and
  requires every anchor (a string or pattern the watched code carries) to
  match at least once; it throws on a missing file, a lost anchor, or a read
  without an anchor.
- `readSourceFolder(dir, { floor })` is the folder-scan form for a subject
  that was split into several files.
- The twelve reads that were asserted only negatively now go through
  `readSource` with an anchor: the batch route, the integration-sync
  registrar, the dashboard client, the mood insights client, the sleep
  overview and sleep page, the sidebar, the baseline form, the account
  section (two guards), api-handler's requireAuth and the Google Health sync.
- The two schema parsers the wipe and enum guards share now refuse a parse
  of the real schema that finds fewer than 50 models (and 10 enums), so a
  moved file or a drifted block regex cannot leave those guards comparing
  an allowlist against nothing. Synthetic schemas a test writes stay exempt.

Proof: renaming `setSaving` in the baseline form made the converted
confirm-failure guard throw "the file no longer contains setSaving"; the
helper's own test plants a missing file, a lost string anchor, a lost regex
anchor, an empty anchor list and an under-floor folder, each of which throws.
2026-10-22 04:29:35 +02:00
Marc-André Bombeck c86c7d1a79 refactor(lint): enforce the lib → components and client → server boundaries
Add the in-repo rule `healthlog/import-boundaries`:

- a file under src/lib may not import src/components or src/app (alias,
  relative, re-export and dynamic forms, type-only included);
- a "use client" file may not make a value import of the database client,
  the crypto module, the Prisma client entry, the job queue, pg, pg-boss or
  next/headers. Type-only imports and the Prisma enums entry stay legal.

The four lib files that still import upward are listed in the rule's allow
map with a reason each; a listed file that stops importing upward is
reported, so the map can only shrink.

Five of the nine upward imports are gone by moving pure modules to their
layer, without behaviour change:

- supply-runway estimate → src/lib/medications/supply-runway.ts
  (read by the low-stock job and the medication list read);
- weekday BYDAY tokens → src/lib/medications/scheduling/weekday-tokens.ts
  (read by the medication extraction prompt);
- settings section slugs → src/lib/settings/section-slugs.ts
  (read by the record-settings classification);
- the command palette index → src/components/command-palette/ (it is a
  client index built from component registries).

Proof the rule can fail: an import of @/components/ui/card appended to
src/lib/medications/supply-runway.ts reported libUpward, and a planted
"use client" file importing @/lib/db reported clientServerOnly; both were
removed again.
2026-10-22 04:15:11 +02:00
Marc-André Bombeck fdd0f50130 Merge pull request #1262 from MBombeck/release/v1.44.0
Release v1.44.0
2026-10-22 03:03:28 +02:00
Marc-André Bombeck 28abde334e fix(insights): keep the source comparison range on a 390 px phone
Three spelled-out range labels beside the card title pushed /insights/sources
past the viewport. Use the charts' short day labels and keep the long form as
the segment title. The route sweep resolves the new module pages and drops
mood from the arrangement pages, whose old address now redirects to them.
2026-10-22 02:31:56 +02:00
Marc-André Bombeck 104f4cc7bd test(e2e): assert the guardian's connect controls on a managed profile
A MANAGE grant on a managed profile now connects its OAuth providers: each
of the six cards offers one connect and says whose record it is for, while
Fitbit and Nightscout stay status-only. A view or view-and-add grant still
gets no integration surface and a 403 from the connect route.
2026-10-22 02:31:56 +02:00
Marc-André Bombeck 11cd7fbeb6 fix(integrations): name a managed profile without a display name by its username
The connect cards read the display name alone, so a profile created without
one rendered "Connect for" with nobody after it. Use the account label the
switcher and the banner use.
2026-10-22 02:31:56 +02:00
Marc-André Bombeck 25d7a01d9d test(e2e): expect the two HRV rows in the standard report set
The standard template carries SDNN and RMSSD since the wearable leaves
landed, and the cardio group has fifteen leaves. Assert the untouched
mobility group at zero instead.
2026-10-22 02:31:55 +02:00
Marc-André Bombeck c26f21fb4f test(e2e): import the insights cache cipher statically in the AI-optional fixture
A dynamic import() of app code skips the runner's @/ path mapping for that
module's own imports, so sealing the seeded briefing failed with
"Cannot find module '@/lib/crypto'".
2026-10-22 02:31:55 +02:00
Marc-André Bombeck 695bb1b10a docs(changelog): note the lighter metric pages 2026-10-22 01:44:00 +02:00
Marc-André Bombeck 173646c613 chore(bundle): raise the aggregate budget to 3900 on the measured v1.44 build
The release branch measured 3875 KB gz without the lazy catalogs
against the 3755 cap (main 3737). About 15 KB is new client code; the
rest is the aggregate's per-chunk-group double count of the shared
confirmation dialog and of the two new routes' own chunks. The summed
first load of the 108 pages fell from 39998 to 35895 KB gz with the
metric pages off Zod; the watched routes keep their baselines.
2026-10-22 01:37:14 +02:00
Marc-André Bombeck 04c9139ca6 perf(settings): load only the link card on the arrangement pages
The arrangement page imported its link to the module's own page from the
module pages file, which also holds the labs, mood and injection-site
settings, so every arrangement page shipped all three. The card lives in
its own module.
2026-10-22 01:36:06 +02:00
Marc-André Bombeck ebdb63f0d8 perf(insights): keep Zod off the first load of the metric pages
The value domain read the plausibility band out of the measurement
validation module, so every metric sub-page that asks whether a metric
can be negative or is integer-valued loaded the measurement schemas and
Zod with them. The band and its check live in a Zod-free module now,
re-exported by the validation module for the server writers, and the
values list checks its segment against the exhaustive type label map
instead of the Zod enum. About fifty metric routes drop roughly 88 KB
gzip from their first load. The client-route guard walks a metric page
and the values list too.
2026-10-22 01:36:06 +02:00
Marc-André Bombeck 41c906e862 test(encryption): drop the insights cache step from the backfill summaries
The free-text backfill no longer has an insights cache step since the
readable cached-text column was dropped, so its summary carries no
insightsCachesMigrated count. The expected summaries follow.
2026-10-22 01:19:01 +02:00
Marc-André Bombeck 75f9ef1507 test(workouts): opt the account in before expecting a stored track
GPS tracks are kept only after the account turns storeWorkoutRoutes on.
The two route-storing cases opt in first, and a new case proves that an
account which has not opted in keeps the workout, answers it as
inserted, and stores no track.
2026-10-22 01:15:01 +02:00
Marc-André Bombeck f4acf0e246 test(openapi): call the provenance reads and fill the list layout in the GET contract run
The two provenance reads take required query parameters and had no
entry, so the run could not call them. Give them a day and a range on
the steps, seed a second steps source for the last week so the pick and
the comparison have two sources to explain, and save a labs list order
so the module list layout read answers with a row.
2026-10-22 01:15:01 +02:00
Marc-André Bombeck 832a9da98a test(backup): give the activity goals a valid fill in the settings round trip
The restore admits activity goals key by key against the goals schema,
so the generic { roundTrip } JSON fill was refused as an unknown goal
rather than carried. Fill the column with real goals and pin the
per-goal admission (valid goals kept, unknown keys and out-of-bounds
goals named) in the unit suite.
2026-10-22 01:12:17 +02:00
Marc-André Bombeck 455e92c5a5 chore(release): v1.44.0 2026-10-22 00:46:34 +02:00
Marc-André Bombeck ae479c86cf Merge branch 'feat/v144-review2' into release/v1.44.0 2026-10-22 00:46:09 +02:00
Marc-André Bombeck e96a5f834f test(pulse): drop the MCP changepoint exemption from the day-mean guard
detect_changepoints no longer averages bucket means itself; it hands
them to the shared level-shift detector, so the file has no in-memory
mean left for the guard to list.
2026-10-22 00:42:19 +02:00
Marc-André Bombeck 663b8756a6 docs(import): say why lab CSV duplicates are cut on the read zone's day
A wall-clock time read in another zone on a retry is a different
instant, so neither an exact-instant match nor the zone's day catches
it near midnight, and widening the match would drop a genuine reading
of the same value on the neighbouring day. The earlier import's zone is
not stored, so the comment states the rule that never loses a reading.
2026-10-22 00:40:08 +02:00
Marc-André Bombeck 13039eb430 fix(import): write an imported medication and its category together
The medication CSV import created the medication and then set its
category in a second write. A crash between the two left a medication
without a category, and a retry skipped it as a duplicate, so it never
got one. Both writes now share one transaction.
2026-10-22 00:39:59 +02:00
Marc-André Bombeck bd811eb753 test(crypto): follow a pushed payload array into the write that sends it
The lab CSV import builds its rows into an array and writes each chunk
with one createMany({ data: toWrite }). The ciphertext-writer scan only
read payload literals at the call site, so it never saw the encrypted
note that write carries; the column stayed covered only by the name
scan. The scan now follows an identifier payload into the literals the
same file pushes onto it, and asserts it sees the chunked note write.
2026-10-22 00:38:03 +02:00
Marc-André Bombeck 08ea2b6620 fix(import): keep a failing audit write from replacing the import's answer
The CSV import wrote its audit row in a finally block, so an audit write
that threw discarded whatever the import had settled on: the result of
a finished import, the 413 of a file over the cap, or the error of one
cut off part way. The failure is now noted on the wide event and the
import answers as it would have.
2026-10-22 00:35:35 +02:00
Marc-André Bombeck e91957c017 fix(labs): word a three-year projection horizon as the cap it is
The beyond-horizon line said the range held "for more than {years}
years, the span your readings cover", but the horizon is capped at three
years, so a ten-year history was told its readings spanned three. The
trend now says whether the cap or the span set the horizon
(`horizonCapped`), and the card words each case on its own in every
locale.
2026-10-22 00:32:13 +02:00
Marc-André Bombeck 6330908619 fix(mcp): run detect_changepoints on the insights level-shift detector
The tool kept its own CUSUM with a plain standard-deviation bar, so the
per-metric floors and the drift rejection the health-status scan gained
never reached it, nor the medication-effect note that reads it: a 0.6 kg
weight step or a steady climb still came back as a dated shift. The tool
now segments the series with the shared detector, recursing on each side
of a shift with the same bar and the metric's floors. The output shape
is unchanged.
2026-10-22 00:29:19 +02:00
Marc-André Bombeck 4fbbdb12c2 fix(fhir): page _offset as far as a series reaches
The 100000 ceiling refused offsets a correctly paging client reaches on
its own: an Observation series with a heart-rate history runs well past
a hundred thousand rows, and the next link the server handed out led
straight into a 400. The 500 the ceiling was added for came from an
offset such as 1e20 that no longer floors to an exact integer and that
the database cannot read as an OFFSET. The refusal now sits at the
largest exact integer, and a next link never carries an offset the
server would refuse.
2026-10-22 00:27:02 +02:00
Marc-André Bombeck 29d6029999 fix(workouts): read a day with activity data and no workout as rest
The strain run writes no impulse row on a day without a scorable workout
or active energy, so a rest day for someone without daily active energy
was never rated: fitness and fatigue held for months instead of
decaying, and the figures named the last stored day while the coverage
count ended there too.

A day without a stored impulse is now classified from the underlying
data: a workout on the day leaves it unrated, steps, heart rate or
active energy without a workout make it rest, and no data at all leaves
it unrated. The series runs to the day the strain run scores, so a
stretch without data after the last rated day shows in the coverage
count, and the card names the last rated day when it lies further back
than the day before.
2026-10-22 00:25:29 +02:00
Marc-André Bombeck 60bef2dee8 Merge branch 'feat/v144-review2' into release/v1.44.0 2026-10-22 00:11:15 +02:00
Marc-André Bombeck 0918451e02 fix(settings): unlist Preview features until the topic pages ship
The switch was stored and shown, but nothing reads the topics flag
yet, so the page promised a change it could not make. The section,
its nav entry, its loader and its copy are gone, so its palette
entry goes with them; /settings/preview is no longer a route. The
stored flag, /api/settings/preview-features and its backup round-trip
stay, and the column's pending-consumer note says why.
2026-10-22 00:09:40 +02:00
Marc-André Bombeck 704822c010 test(sharing): say which v1.44 routes joined the record list
The note named the activity goals among the three new readable routes.
They are the calendar read and the two source provenance reads; the
goals route stays owner-only.
2026-10-22 00:02:24 +02:00
Marc-André Bombeck 176f21ab78 fix(fhir): bound _offset and answer a larger one with 400
An offset beyond what the database can skip reached Prisma unchecked
and answered 500. Every search route now refuses an _offset above
100000 with an invalid OperationOutcome.
2026-10-22 00:01:27 +02:00
Marc-André Bombeck 896ba233da fix(import): refuse unknown query parameters on the CSV imports
The lab, medication and measurement CSV routes ignored parameters they
did not know, so a misspelt dryrun=1 fell back to a real import and
wrote the file the caller meant only to check. Their query schemas are
strict now: an unknown parameter answers 422 before anything is read
or a rate bucket is spent.
2026-10-21 23:56:16 +02:00
Marc-André Bombeck f8c17ee9bb fix(import): write lab CSV rows one chunk at a time and audit cut-off imports
Every accepted row was its own insert, so a 20 000-row file meant
20 000 round trips and an import cut off part way left an arbitrary
prefix behind. Each 200-row chunk is now one createMany: it lands whole
or not at all, and uploading the same file again skips the written
chunks as duplicates. The importers report each committed write, and
the audit row is written in a finally block, marked partial with the
rows written when the import did not finish.
2026-10-21 23:49:23 +02:00
Marc-André Bombeck b828870e1b fix(import): cut lab CSV duplicate days in the zone the file is read in
The rows were parsed in the query's timezone while the duplicate keys
used the account's, so two draws on separate days of the file's zone
could collapse into one, or one day split in two.
2026-10-21 23:42:34 +02:00
Marc-André Bombeck bb6982aea2 feat(report): name the analysed window of each medication effect
The effect is fitted on up to 90 days before a start and 180 after
it, which can lie outside the report period. The PDF notes and the
clinician view now state the fitted window for every effect shown.
2026-10-21 23:37:09 +02:00
Marc-André Bombeck cfe5e2d3c6 fix(report): release the medication effect only with the medication list
The effect block names every medication it describes and its
confounders, so a report or share link that withheld the medication
list still carried the names. The aggregator now loads the block only
when both leaves are admitted, the clinician view checks the list leaf
as well, and the leaf label says it needs the list.
2026-10-21 23:31:58 +02:00
Marc-André Bombeck 733b00ee1a fix(strain): count one source per session and per day
Two rows of one workout slot from sources off the ladder were both
kept by the picker and their impulses added. Only one source's rows
now count per slot, a source off the ladder ranking below every
listed one. The day's active energy, the strain fallback signal,
summed every source; it now goes through the activeEnergy source pick
and keeps one source's total.
2026-10-21 23:25:45 +02:00
Marc-André Bombeck cfd7c18d48 fix(medications): widen the medication-effect intervals to a week of lag
The automatic Newey-West rule gives a lag of 3 on 30 to 270 daily
values, which leaves most of a physiological series' carry-over out of
the intervals. The lag is now at least 7. Reference values are
recomputed with statsmodels at the new default, and the lag-3 values
stay pinned with an explicit lag. A fit whose residual autocorrelation
exceeds 0.5 is flagged, and the card, the clinician view and the PDF
say the ranges may still be too narrow.
2026-10-21 23:20:28 +02:00
Marc-André Bombeck 99dfd68aad fix(workouts): hold training load through unscored days
A day without a stored training impulse was read as a rest day, so a
watch left off or a nightly run that had not happened yet pulled
fitness and fatigue toward zero. Such a day is now not rated: the
averages hold through it, the series marks it, and the card says how
many of the last 42 days were rated. A scored day of 0 stays rest. The
figures describe the last stored day rather than always yesterday.
2026-10-21 23:09:09 +02:00
Marc-André Bombeck 2723ac5b98 fix(insights): keep plateaus and drifts out of the level-shift scan
Each scanned vital gets a minimum level change and a spread floor at
about its clinical and device resolution, so a VO2 max estimate that
steps by 0.3 or a clean 2 bpm change no longer reads as a shift. A
series a straight line explains at least as well as a step is a drift,
not a dated shift. The magnitude is capped at 10 so the flattest series
cannot win the ranking.
2026-10-21 23:03:03 +02:00
Marc-André Bombeck e696d6e526 test(e2e): match the activity calendar on data attributes, not wording
The card carries data-period and each goal row and goal field a
data-goal-key, so the spec no longer depends on the English labels.
2026-10-21 22:51:06 +02:00
Marc-André Bombeck 89b30ebdee fix(activity): never rate today's partial day in the activity calendar
Today is shown as in_progress instead of being placed against whole
days, and a running period is compared over its completed days only:
the total through yesterday against the usual rate times the days
completed. The period total and goal progress still count today. The
comparison gains completedDays and completedValue.
2026-10-21 22:47:07 +02:00
Marc-André Bombeck e482f76508 fix(labs): stop projecting lab trends from stale or short series
A marker whose newest reading is more than a year old is now reported
as of that reading (projection "stale"), with no exit date and no
already-outside verdict. The projection horizon is capped at the span
the readings cover, so a line is never carried further ahead than the
data reaches back. The detail read keeps the newest 2000 readings
rather than the oldest.
2026-10-21 22:40:43 +02:00
Marc-André Bombeck 5f563623d7 fix(db): make the activity goals column migration idempotent 2026-10-21 22:34:26 +02:00
Marc-André Bombeck 67d04630c3 Merge branch 'feat/v144-analysis' into release/v1.44.0 2026-10-21 22:27:59 +02:00
Marc-André Bombeck 6320c54edd feat(insights): look for level shifts in thirteen vitals over 180 days
The health-status read scanned five vitals over 90 days for a dated,
sustained level shift. It now scans every vital with a stable daily level
(adding RMSSD, diastolic pressure, pulse, body fat, respiratory rate,
oxygen saturation, walking heart rate and VO2 max) over 180 days, reads one
value per day per metric, leaves a metric unread when its module is off,
and reports how many days stand on each side of the break. The Coach keeps
its five-metric, 90-day scan unchanged.
2026-10-21 22:27:20 +02:00
Marc-André Bombeck aa899a9d54 Merge branch 'feat/v144-userneeds' into release/v1.44.0
# Conflicts:
#	src/__tests__/sharing-surface-guard.test.ts
#	src/__tests__/workout-canonical-identity-guard.test.ts
2026-10-21 22:24:01 +02:00
Marc-André Bombeck 121588e3dc feat(settings): map and check a lab or medication CSV before importing
Settings > Export & Import gains a card that reads the head of the file,
suggests the column for each field from its header in eight languages,
suggests the separator, decimal mark and date order, and lets every
choice be corrected. The check lists each refused row by line and the
markers the names land on; the import button names how many rows it
will write. Templates for both kinds download from the sheet.
2026-10-21 22:19:16 +02:00
Marc-André Bombeck 4e71cf4dc2 Merge branch 'feat/v144-report' into release/v1.44.0 2026-10-21 22:18:50 +02:00
Marc-André Bombeck a84fb4b467 feat(import): import lab results and medications from CSV
Two streamed endpoints read a spreadsheet record by record with a
column mapping from the query: separator, decimal mark, date order and
zone are stated, never guessed per row. Lab rows resolve to markers the
way scanned reports do, convert units where the conversion is known and
keep what the file said; medication rows map name, dose, daily times,
as-needed and a dated window, checked against the create schema, with
reminders off unless asked for. A dry run writes nothing, a re-upload
only finds duplicates, and every refused row comes back with its line
and reason.
2026-10-21 22:17:59 +02:00
Marc-André Bombeck 9be720146c feat(workouts): show fitness, fatigue and form from the stored training impulse
The first page of the workout list now carries the fitness-fatigue model
over the day-total heart-rate impulse the nightly strain run already
stores: fitness as the 42-day and fatigue as the 7-day exponentially
weighted average, form as their difference. A day without a stored impulse
counts as rest. It needs 28 days and three training days, flags the first
six weeks as warming up, and reads in the person's own units only. The
workouts page shows the three figures above the list.
2026-10-21 22:13:28 +02:00
Marc-André Bombeck d8fcf814b2 fix(docker): a boot storm cannot flip the healthcheck to unhealthy
/api/health gives the database two seconds and answers 503 past that.
With three retries, a pool saturated for a minute after the start period
could mark the container unhealthy and take it out of the proxy's
routing. The compose healthcheck and the image's HEALTHCHECK now allow
five misses (two and a half minutes) and end wget after four seconds,
inside Docker's five, so a stalled request is one clean miss. A test
holds both to these bounds and to each other.
2026-10-21 22:03:48 +02:00
Marc-André Bombeck 59c3c38379 docs(ui-standards): public wording, one spacing scale, no fragment-redirect claim
The UI standards drop working shorthand, state the spacing scale once in
section 2 (section 13 had listed an older scale without 2.5 and 3.5) and
use no em dashes. The v1.3 to v1.4 migration note no longer claims that
/settings#anchor URLs redirect to their section: the fragment never
reaches the server. A test holds both documents to this.
2026-10-21 22:02:55 +02:00
Marc-André Bombeck f428b14ce9 fix(i18n): future tense for the Topics preview, settled German wording
The Topics preview describes what it will add, the workout-route detail
says off for new accounts, and German reads 'Zurück zu den Modulen',
'Unter Module ändern', 'Unter Anordnung öffnen' and 'Erfassung' for
the Tracking group. Strings that name the Insights area call it
Insights; generic uses of Auswertung stay.
2026-10-21 22:01:50 +02:00
Marc-André Bombeck ac149460ba docs(home-assistant): one room, intervals that divide the hour, current keys
The room-climate guide said both to create one automation per room and
to track only one; it now recommends the bedroom and says why a second
room cannot work (no room name, same-minute pushes collide on 409). The
blueprint offers 5, 10, 15, 20, 30 or 60 minutes, so the push lands on
the stated minutes, and it and the guide's examples use the triggers,
conditions and actions keys of Home Assistant 2024.10 and later. A test
holds all three.
2026-10-21 22:00:41 +02:00
Marc-André Bombeck e8f34315d4 docs(mcp): the switch is in Modules, the MCP page shows its state 2026-10-21 21:59:46 +02:00
Marc-André Bombeck 5c64192afc feat(mental-health): measure screener change against the meaningful-change threshold
The assessments list now carries, per PHQ-9, GAD-7 and WHO-5, the latest
total against the previous and the first administration, classified
against the change studies treat as meaningful: 5 points on the PHQ-9,
4 on the GAD-7, 10 on the WHO-5 percentage scale. A smaller change reads
as within the threshold rather than better or worse. The instrument
detail states each change and whether it reaches that size, in neutral
words. The read takes totals and dates only, never an answer.
2026-10-21 21:59:42 +02:00
Marc-André Bombeck 0229b69981 fix(coach): bring back the in-context provider and model switch
The Coach overlay had become a read-only line with a link to Settings >
AI, which took away switching the model mid-conversation. The provider
and model selects are back; they write through the same routes and the
same stored preference as Settings > AI, so there is still one setting,
and the link to Settings > AI stays below them.
2026-10-21 21:59:25 +02:00
Marc-André Bombeck 9cfc72fe1f fix(settings): channel switches act on tap again, beside their form
The Telegram, ntfy, webhook and email cards had folded the enable
switch into the form, so turning a channel off took a Save as well.
The switch saves on tap again, optimistically with a rollback, and sits
outside the form; the form's Save covers the channel's fields and sends
the stored enable state unchanged.

The save-model guard now splits a card into save units, each form and
the rest of the card, and refuses a unit that mixes both models: an
instant switch inside the form its Save submits, or a Save outside any
form next to one, still fails. A form shows its refusal inline only,
no longer as a toast on top.
2026-10-21 21:58:22 +02:00
Marc-André Bombeck c94766efc3 feat(measurements): show where a metric's values come from
A read-only view per metric and UTC day: every source with readings, its
day value, its place in the person's source priority, and which source
the charts use with the reason. A second card lines two sources up over
30, 90 or 365 days. The pick is explained from the rank the canonical
SQL returns, so the page cannot disagree with the charts. Linked from
the metric pages and from Settings > Source priority.
2026-10-21 21:57:05 +02:00
Marc-André Bombeck 6f1232c733 fix(activity): format calendar day keys on their calendar date
The week label and each day's accessible name went through the
zone-aware formatters, which show a noon-UTC day key as the next day
east of UTC+12. Read them in UTC like every other stated date.
2026-10-21 21:57:04 +02:00
Marc-André Bombeck 77619f8aa5 feat(fhir): serve Observations as a time series, code RMSSD and workouts
GET /api/fhir/Observation keeps its snapshot answer when no date is
sent. A date range (FHIR prefixes eq/gt/ge/lt/le, UTC when no offset)
switches it to the time series: every reading in the range plus
workouts, newest first, paged in the database through one UNION id
query and a count, so a page costs what it holds. Series ids are the
row's own (m-<id>, w-<id>) and read back through /Observation/{id}.
The series replays the same saved report selection as the snapshot,
pairs systolic and diastolic recorded at one instant into the
blood-pressure panel, leaves sleep stage segments out, and reads an
hourly heart-rate bucket as a mean over its hour. A code parameter
narrows either answer; a malformed date is a 400 OperationOutcome.

A workout is an activity Observation after the Physical Activity IG:
LOINC 73985-4 with the sport as its value and duration (55411-3) and
energy (41981-2) as components. RMSSD gets its own registry coding,
text-only because LOINC has no RMSSD term, so it never borrows the SDNN
code. The capability statement lists the two new parameters.
2026-10-21 21:56:16 +02:00
Marc-André Bombeck 9e2a0681b2 feat(doctor-report): add sleep, HRV, steps, workouts and the medication effect
The standard report template now carries sleep duration, heart-rate
variability as two labelled rows (SDNN and RMSSD), daily steps, a
per-sport workout summary and the change around each medication start.
Each still rides its module, and the template is still only applied by
the button that names it; saved selections and share links are
untouched.

Two structured leaves join the catalogue: WORKOUTS (activity group,
workouts module) and MEDICATION_EFFECT (medications group). The workout
block reads narrow rows through the canonical-workout picker and sums
them per sport. The effect block reuses the efficacy loader with the
report's end as its horizon, carries a target only when that target's
own measurement leaf is admitted, and is capped at eight medications.

In the PDF the wearable series chart at half width, two to a row, in
the units the table prints; the workout and effect blocks are compact
tables with short notes. The clinician view renders both blocks from
the same payload. RMSSD joins the dense day-bucket path so long windows
stay aggregated.
2026-10-21 21:55:57 +02:00
Marc-André Bombeck 709d28523c fix(settings): forward moved anchors on hash changes too, prove targets exist
A same-page link to a moved anchor changes only the hash, so the
arrival check never forwarded it; the shell now also listens for
hashchange. The legacy-routes guard walks the component tree of every
forward target's page and fails when the anchor it forwards to is not
rendered there.
2026-10-21 21:52:22 +02:00
Marc-André Bombeck 2ba6214a65 feat(labs): show each marker's slope and where the line would leave the range
The biomarker detail read now carries a trend: the least-squares line
through every numeric reading of the marker (all stored in its unit), with
the slope's 95 % interval and p-value. It needs three readings over at
least a year. The line is placed against the marker's band, and an exit
date is given only when the interval excludes zero, the line is inside the
band today and reaches a bound within three years. The detail page words
it as a straight-line projection, not a forecast.
2026-10-21 21:51:36 +02:00
Marc-André Bombeck 1cf0582603 feat(medications): show the change at the start as a segmented fit
The efficacy tab keeps its before/after comparison and gains, per metric
target, the level change and the change in weekly trend at the start,
each with a 95 % interval. The fit covers up to 90 days before and 180
after the start and ends before the first dose change. After the start,
days are left out when fewer than 80 % of the doses of the preceding 14
days were taken (cadence-aware, from the dose ledger) or when the
medication was paused or between courses. Other medications that
started, stopped, changed dose or paused inside the window are listed.
Below 14 days with readings across 21 days on either side the view says
what is missing instead of showing numbers.

The efficacy response gains a nullable segmented block per target.
2026-10-21 21:50:27 +02:00
Marc-André Bombeck b25ee09f7d fix(settings): keep moved checkup and session cards reachable in context
The checkups page links to the checkup reminders under Notifications
and the screening hints under the health profile, the two cards that
left its settings in this release. Data & privacy gets a row pointing
at sessions and devices under Security.
2026-10-21 21:50:13 +02:00
Marc-André Bombeck 90c9a4e2d0 fix(settings): list layout and preview switch serve the person, atomically
Both routes resolved through bare requireAuth, which refuses a session
acting for another record, so the list view controls and the preview
switch failed in exactly the context they are used in. They are the
person's own presentation and now resolve through requireActorAuth,
admitted on the actor-surface list with their reasons.

The layout PUT and the preview PATCH read and write the stored blob
under a row lock in one transaction, so two saves at once no longer
start from the same value and drop each other's change.
2026-10-21 21:47:39 +02:00
Marc-André Bombeck cb8ed92d78 feat(stats): add segmented regression with Newey-West intervals
A four-parameter interrupted time series fit (pre-trend, level change,
slope change) by OLS, with a Bartlett-kernel HAC covariance, the n/(n-k)
small-sample factor and Student t intervals. The tests pin the
coefficients, standard errors and intervals to independently computed
reference values on contiguous and gapped series.
2026-10-21 21:44:29 +02:00
Marc-André Bombeck 557865acca test(measurements): golden comparison of set-based batch insert and reconciler
Posts one batch for two accounts with the same stored rows, once as the
route runs and once with the set-based insert forced onto its fallback,
and asserts identical per-entry outcomes and stored rows across fresh
samples, a stored duplicate, a tombstone, a stats: overwrite, an
external id twice in the batch and a natural-key collision.
2026-10-21 21:43:14 +02:00
Marc-André Bombeck a9700bfa62 test(oura): grant the workout and heartrate scopes in the callback fixture
The fixture granted the scopes Oura was asked for before workouts and
intraday heart rate joined; a full grant now names those two as well.
2026-10-21 21:40:06 +02:00
Marc-André Bombeck 211053d900 fix(migrations): idempotent 0440, refill emptied insights cache, deploy note
0440 adds its columns with ADD COLUMN IF NOT EXISTS like 0424 and
0432; a structural test holds every migration from 0422 on to that
form. 0430 clears insights_cached_at where no sealed payload is left,
so the nightly pregenerate refills an account whose only copy was the
readable column instead of keeping an empty card fresh.

The deploy runbook notes that 0430 drops a users column: an old process
fails on its next full user read, so stop before starting rather than
running old and new side by side.
2026-10-21 21:40:06 +02:00
Marc-André Bombeck faa3ef867b feat(insights): correlate intake, alcohol days, training, rings, State of Mind and room climate
The discovery matrix gains the families people log but could not test:
water, caffeine and up to four varying micronutrients from the nutrient
log, alcohol days from the mood tag, workout minutes (one per session) and
the day's training impulse, exercise, stand and mindful minutes, State of
Mind as an outcome, and the overnight room climate against that night's
sleep only. Every channel runs through the same seasonal adjustment,
effective-n p-value, Benjamini-Hochberg control and effect-size floor, is
read only while its module is on, and drops out below the paired-day floor.

State of Mind, mindful minutes and alcohol days are statistics for the
person: the Coach, the period narrative and the assessment cards drop every
pair that touches them, after the shared scan so the remaining pairs keep
the statistics the correlations page shows. A guard walks the import
closure of the analysis modules and refuses any path to a provider.
2026-10-21 21:39:15 +02:00
Marc-André Bombeck 5eb7caebae feat(activity): add an activity calendar with personal baseline and goals
A week or month of steps, active minutes, workouts by sport and training
load, each day placed against the person's own spread over the 84 days
before the period and each period against the median of the twelve weeks
or six months before it. Optional weekly and monthly goals live on the
account (activity_goals_json), travel with the backup and are cleared by
a data wipe. The calendar sits on the workouts page; the steps page links
to it. Computed server-side without AI.
2026-10-21 21:38:32 +02:00
Marc-André Bombeck b795b3b1f3 fix(jobs): enqueue during boot waits for the boss instead of dropping
The server answers before pg-boss is bound, and a producer rebinds in
the background after losing its connection. A request in either window
read the boss as null and skipped its enqueue: OAuth callbacks lost the
first sync and the history backfill, measurement and mood writes lost
their rollup recompute, WHOOP webhook deliveries were dropped.

whenBossReady() waits for the boss while a start is under way (boot or
reconnect), bounded at 60 s and logged when it gives up, and answers
null at once when nothing is coming. enqueueWhenBossReady() sends at
once through a bound boss and otherwise defers the send to the
background, so the request is not held for the boot.
2026-10-21 21:37:21 +02:00
Marc-André Bombeck e0eaed2251 fix(integrations): one provider account per record, borrowed apps return
A guardian finishing a connect for a managed profile could attach their
own provider account to the profile. Every OAuth callback (Withings,
WHOOP, Fitbit, Google Health, Strava, Polar, Oura) now refuses a
provider account another record already holds with reason
already_linked, shown as its own message on the integrations page.
WHOOP's owner_conflict tag becomes already_linked as well.

Oura keeps the account id of a grant (personal_info, migration 0441) so
the check has something to compare; existing connections fill it at
their next connect.

A disconnect leaves Withings webhook subscriptions and the Strava app
authorisation in place while another record holds the same account,
and the Withings webhook routes an account held twice to the record
that linked it first instead of whichever row a lookup returned.

The provider app copied from a guardian onto a managed profile is now
marked (migration 0442) and removed again on disconnect or when that
guardian no longer holds a live MANAGE grant: revoke, renounce,
removal, handover, account deletion and data wipe. Each removal is
audited as integration.client_app.removed.
2026-10-21 21:18:33 +02:00
Marc-André Bombeck 30248efaf1 fix(workouts): count a session synced twice once in strain, briefing and day view
The training impulse summed every stored workout row of the day, and the
briefing's workout tally and the day view's workout section listed every
row, while the workout list collapses a session recorded by the watch and
synced again through a second service. All three now run the same
cross-source picker. The strain engine keeps the heart-rate series of a
dropped twin when the picked row has none, so the collapse never turns a
scored session into an unscored one.
2026-10-21 21:12:23 +02:00
Marc-André Bombeck e1b70537cc Merge branch 'feat/v144-settings' into release/v1.44.0
# Conflicts:
#	src/components/settings/privacy-section.tsx
2026-10-21 20:51:39 +02:00
Marc-André Bombeck 21cfd46988 Merge branch 'feat/v144-integrations' into release/v1.44.0 2026-10-21 20:51:29 +02:00
Marc-André Bombeck ccd84e6d01 Merge branch 'feat/v144-housekeeping' into release/v1.44.0 2026-10-21 20:51:29 +02:00
Marc-André Bombeck 81e1f4cad1 fix(workouts): keep tracks and climb for accounts that already had them
Migration 0434 turns the route opt-in on for every account that already
holds a stored workout track, so its tracks keep arriving after the
upgrade; only accounts without one start off.

Workouts stored before this release with a track but no elevation get
their metres climbed written into Workout.elevationM by a one-time
background pass. It runs detached at worker boot, reads one track at a
time with a pause between batches of 100, and keeps its position in
app_settings, so a restart resumes and a finished pass ends at once.

The "starting" health reason is bounded: after five minutes the probe
says plain degraded and the process logs one line, and the time the
background role took to come up is logged either way. The compose
healthcheck's ten-minute start period already keeps those probes from
counting; its comment now says so.
2026-10-21 20:51:13 +02:00
Marc-André Bombeck 846b0e5df0 perf(measurements): insert fresh batch samples set-based; answer health while starting
POST /api/measurements/batch ran one interactive reconciler transaction
per fresh sample: a savepoint, three advisory locks, two candidate reads
and the insert, about ten round trips. A 500-entry upload of new samples
was 500 of those, the 2 to 15 second outliers. Fresh samples now land
through INSERT ... ON CONFLICT DO NOTHING under the same per-account
advisory lock. Both measurement identities are full unique indexes, so
the statement inserts exactly what the reconciler would have inserted,
and every skipped row still goes through the reconciler for its own
verdict. Only rows whose external and natural keys each occur once in
the batch are buffered, and the buffer is flushed before any other row
is reconciled, so the order of effects stays the sequential one. The
contract and every per-entry status are unchanged; a golden test runs
each batch through both paths against the same table.

/api/health took seconds after a start because register() awaited the
whole pg-boss worker start (queues, schedules, boot discovery) before
the server took a request. The worker, or a web process's producer, now
starts in the background after the blocking boot checks; the probe
answers at once with reason "starting" until it is up, and gives the
database two seconds before calling it not answering.
2026-10-21 20:42:06 +02:00
Marc-André Bombeck cc8ea3dfbb feat(measurements): accept indoor CO2, temperature and humidity
Room climate from a home automation system gets three types of its own:
INDOOR_CO2 (ppm), INDOOR_TEMPERATURE (celsius, shown in Fahrenheit for
imperial readers) and INDOOR_HUMIDITY (percent). They arrive through the
existing ingest token (measurements:write, source EXTERNAL), sit in the
environment cluster beside daylight, and the daily rollup carries their
mean, low and high like any spot reading. They never mix with the body
temperatures or with the outdoor environment channels.

The Home Assistant page documents the three types, a rest_command that
sends a room as one request, a single-room automation, and an
importable blueprint (docs/integrations/blueprints) that skips sensors
reporting unknown or non-numeric states, converts Fahrenheit, and
stamps readings to the minute so a retried push is refused rather than
written twice.
2026-10-21 20:26:15 +02:00
Marc-André Bombeck b76826a5f8 test(workouts): call the route opt-in handlers with their real arity 2026-10-21 20:20:10 +02:00
Marc-André Bombeck b045703493 feat(apple-health): ingest the activity rings, mindfulness, State of Mind and AFib burden
Six Apple Health signals leave the deferred list and ride
POST /api/measurements/batch and the export.zip import end to end:
EXERCISE_MINUTES, STAND_MINUTES and STAND_HOURS (the exercise and stand
rings), MINDFUL_MINUTES, STATE_OF_MIND_VALENCE (iOS 18 State of Mind,
-1..1) and AFIB_BURDEN (AFib History, percent of the week). The ring
types and mindful minutes are cumulative, so the daily rollup is their
sum; AFib burden is scaled strictly from Apple's fraction and a value
above 1 is refused rather than guessed at. The archive import takes
mindful minutes from the session's dates and skips idle stand hours.
Every registry carries them: units, ranges, labels in all locales,
categories, chart tokens, report catalogue (mindful minutes and the
valence fenced as sensitive), signal registry with FHIR codings, and
MCP exposure for the three ring types only.

Workout GPS tracks are now an opt-in, off by default
(PATCH /api/auth/me/workout-routes, a switch on Data & Privacy). While
it is off, the workout batch route and the export import keep every
workout and drop its track with the same per-entry status. With it on,
the import reads the referenced GPX files from the archive one bounded
member at a time, thins each to the point cap and stores it sealed.
The climb a track carries is written to Workout.elevationM at write
time, so the workout insight no longer opens the track, and the AI
egress guard now freezes the set of files that read route geometry.
2026-10-21 20:14:20 +02:00
Marc-André Bombeck ae601de16f Merge branch 'feat/v144-oura-depth' into feat/v144-integrations 2026-10-21 19:56:43 +02:00
Marc-André Bombeck 3d4b68f7a6 test(oura): pin the backfill's slice, resume, rate-limit and scope paths 2026-10-21 19:56:16 +02:00
Marc-André Bombeck bce9ad1c25 feat(oura): backfill the full history in resumable slices
Every Oura collection, workouts and heart rate included, is now read
back to the start of the account's history by a backfill that runs
through the shared integration lane in five-minute slices. Each
collection walks from the day the backfill began toward the past one
window at a time (ninety days, thirty for sleep, seven for heart rate)
and the progress note on the user row records after every window where
it got to, so a slice cut short by its budget, the lane or a rate limit
resumes there. A 429 ends the slice and the next one waits for the
Retry-After Oura named. Activity and sleep are walked first to learn
where history starts; the rest stop a week before it.

Heart rate streams a page at a time and keeps only the days it touched,
so memory stays flat however long the history is. A collection the
grant does not cover is a scope note, not a failure; the first slice of
the next chain asks again, and a connect clears the completion stamp so
a newly granted scope is read without walking anything twice.

The two columns arrive empty, so every existing connection is offered
the backfill at the next boot without reconnecting. Before anything
else the backfill retires the WALKING_RUNNING_DISTANCE rows earlier
syncs wrote from the equivalent walking distance and refolds their days;
an account that kept such rows after disconnecting is offered too.
2026-10-21 19:53:57 +02:00
Marc-André Bombeck dead4096c9 test(google-health): pin that the backfill tracks every collection the sync reads 2026-10-21 19:52:05 +02:00
Marc-André Bombeck ba2d7af40e test(integration): connect Oura for a managed profile against real Postgres 2026-10-21 19:51:38 +02:00
Marc-André Bombeck 74ce17b7de feat(integrations): let a guardian connect a managed profile's providers
A guardian acting on a managed profile can now connect and disconnect its
Withings, WHOOP, Oura, Polar, Strava and Google Health from the profile's
integration cards, and each card says whose record it connects for. The
connect starts with a fenced fetch that mints a state naming the profile;
the provider's callback lands tokens and data there only after checking
that the browser belongs to a guardian with a live MANAGE grant on it.
Without a provider app of its own, the profile borrows the guardian's,
with an audit row. Withings and Google Health callbacks read the session
directly, since a provider redirect cannot carry the record-session header.
2026-10-21 19:49:07 +02:00
Marc-André Bombeck 65e0cba0bf refactor(integrations): start and end every OAuth connection through the kit
Minting the state, building the consent URL and dropping a grant each had a
copy per provider route. The routes keep their auth, rate limits and
redirects and hand the rest to one implementation, which the managed-profile
routes reuse.
2026-10-21 19:49:06 +02:00
Marc-André Bombeck 131b48cc49 fix(i18n): one German word per thing across Settings
Anbieter instead of Provider, Standard instead of Default, Gesundheitsscore
for every spelling of the score, and Insights where the area was called
Auswertungen. The Coach's rate-limit notice no longer shows English in the
German interface. A guard reads the Settings namespaces of the German
catalog and fails on the retired spellings.
2026-10-21 19:48:29 +02:00
Marc-André Bombeck ec240bc07a test(settings): follow the moved pages in e2e and cover the phone navigation
Point the header, overscroll and timezone specs at the pages that hold
those cards now, add the module pages and Preview features to the
one-piece sweep, and add a 390 px journey through the grouped section
sheet. Unit coverage for the Preview features card.
2026-10-21 19:44:07 +02:00
Marc-André Bombeck 71dbef1742 feat(settings): nine groups, module pages and one switch per state
Settings now reads as nine groups: Account, Tracking, Arrangement,
Notifications, Health profile, AI, Connections, Data and System. On a
phone one button names the current section and opens the grouped list,
replacing the strip of every section.

What moved, every old address still working:
- Sessions, trusted devices and security activity sit under Security;
  the privacy page anchors forward there.
- Notifications holds every reminder kind: the Coach nudges and the
  checkup reminder switches joined, and a card names the reminders set
  where their subject lives (dose times, cycle reminders).
- Arrangement holds order and view only. Mood tags, the labs biomarker
  catalog and on-device text recognition, and the injection sites live on
  module pages under /settings/modules/<id>, linked from each Modules row
  and from the module's own wrench. /settings/layout/mood redirects.
- The timezone saves on change in the Display card, with a one-tap offer
  of the device zone.
- Screening hints sit beside the life circumstances they come from.
- System gains Preview features with the Topics switch.

Duplicates: the Coach and MCP pages show their state and link to the one
switch in Modules (turning MCP off keeps tokens), the Coach overlay shows
the provider and model with a link to Settings > AI instead of a second
set of selects, and the privacy page drops its copy of the export links.

A guard walks every historical /settings address through the redirects
and every forwarded anchor to a page that renders.
2026-10-21 19:43:02 +02:00
Marc-André Bombeck 3ebef19ac3 fix(settings): one save model and one confirm per card
The Telegram, ntfy, webhook and email cards saved their enable switch on
tap and the rest of the form on Save; the switch is now part of the form
and the one Save sends it. The display selects confirm a change with a
toast. Destructive confirmations in Settings go through ConfirmButton or
ConfirmDialog with the destructive style. Two structural guards hold both
rules: a card may not mix instant and explicit saves, and no settings file
reaches for a raw AlertDialog without a written reason.
2026-10-21 19:42:53 +02:00
Marc-André Bombeck d44af06467 docs: publish the UI standards in docs/contributing
The binding UI rulebook (card anatomy, spacing scale, text colour,
tokens, primitive mapping, enforcement) lived outside the repository,
so a contributor could follow a code comment to a file they could not
open. It now lives at docs/contributing/ui-standards.md, every comment
that cited it points there, and the docs index and ui-guidelines.md
say which document wins when they disagree.
2026-10-21 19:37:22 +02:00
Marc-André Bombeck 07f9615dba feat(ops): name 1536 MB as the supported memory floor
The getting-started requirements now say a host needs 3 GB of RAM, 4 GB
recommended, and drop the advice to lower APP_MEMORY_LIMIT to 1g on a
2 GB host. The compose and .env example comments say the same.

At boot the app reads its cgroup memory limit (v2, then v1) and logs one
line when it is below MEMORY_FLOOR_MB. The support report adds the
container limit and a note below the floor, and Admin > System status
shows "below the supported floor" next to the existing low-heap flag.
The app keeps running either way.
2026-10-21 19:36:06 +02:00
Marc-André Bombeck 7585994697 feat(oura): import workouts and intraday heart rate
Workouts from /v2/usercollection/workout land as Workout rows keyed on
the Oura id, with the activity mapped onto the canonical sport set and
the raw label, intensity and recording source kept in metadata. Heart
rate from /v2/usercollection/heartrate lands as PULSE, streamed page by
page and written in bounded batches, first write wins per instant. The
hourly window for heart rate is hours, not days: six by default,
reaching back to the newest reading after a gap, at most 72.

Both collections sit behind their own scopes (workout, heartrate). A
grant from before they were requested answers 401 naming the scope; that
is recorded as a scope note on the card, never as a failure or a
reconnect. Collection refusals carry Retry-After on a 429.
2026-10-21 19:35:47 +02:00
Marc-André Bombeck 9941db1549 refactor(insights): drop the readable cached-text column
Every write has gone to insights_cached_text_encrypted since 0411, and
the boot-time free-text backfill sealed the rows written before it.
Migration 0430 drops insights_cached_text and the backfill index that
served it. A host that skipped the intervening releases loses only a
cache of model output; the next warm writes a fresh, sealed payload.

The cipher module now reads and writes the sealed column only, the
backfill job loses its insights step, and consent withdrawal, the wipe
plan, the backup plan and the session projection follow the schema.
Backups never carried the column, and restore ignores unknown account
settings; a round-trip test now proves an old backup naming the field
still restores cleanly. The access guard pins exact counts for where
the sealed name may appear. Fixtures, the demo seed and the demo
migration script seed the sealed column instead of plaintext.
2026-10-21 19:24:21 +02:00
Marc-André Bombeck 9a3523a498 fix(oura): stop importing the equivalent walking distance as distance
Oura's equivalent_walking_distance is the distance a walk of the day's
effort would have covered, not distance travelled. Written as
WALKING_RUNNING_DISTANCE it counted a bike ride or a yoga hour as
kilometres walked. The daily activity mapper now writes steps and active
energy only.
2026-10-21 19:23:34 +02:00
Marc-André Bombeck b60fa0123b test(integrations): freeze that every OAuth provider refreshes through the kit 2026-10-21 19:13:37 +02:00
Marc-André Bombeck f9ed07551f refactor(polar): read the token through the shared OAuth lifecycle 2026-10-21 19:13:36 +02:00
Marc-André Bombeck a12174bcaa fix(strava): serialise the token refresh like the other providers
Strava rotates its refresh token on every use. The sync relied on a
compare-and-swap alone, so two overlapping runs could both spend the same
token; the refresh now runs under the per-user lock.
2026-10-21 19:13:35 +02:00
Marc-André Bombeck b710cb732b fix(google-health): refresh tokens under the shared refresh lock
Two walks that both met an expired token refreshed side by side; the kit
lets the second take the first one's token.
2026-10-21 19:13:35 +02:00
Marc-André Bombeck 6319a1be32 fix(withings): refresh tokens through the shared OAuth lifecycle
The status route refreshed an expiring token on its own, without the lock
the sync takes, so a status read and a sync could spend the same one-time
refresh token and park a healthy connection at reconnect. Both now go
through the kit.
2026-10-21 19:13:34 +02:00
Marc-André Bombeck 0592dfc074 refactor(whoop): refresh tokens through the shared OAuth lifecycle
The rotated pair is now written only over the refresh token it spent, so
a reconnect that lands during a refresh keeps its new grant.
2026-10-21 19:13:34 +02:00
Marc-André Bombeck 2331faa36c refactor(oura): refresh tokens through the shared OAuth lifecycle 2026-10-21 19:13:33 +02:00
Marc-André Bombeck 23f6fe4c6e feat(integrations): add one token lifecycle for every OAuth provider
A provider describes where its grant lives and how its token endpoint is
called; the kit reads the grant, refreshes it under the per-user advisory
lock, re-reads under the lock so a peer's refresh or a reconnect wins, and
writes back only over the refresh token it spent. Retry-After is read in
one place, both forms, with a cap.
2026-10-21 19:13:16 +02:00
Marc-André Bombeck 93198017d6 feat(settings): keep list order and preview opt-ins on the server
Add per-user columns for the labs, illness and checkups list presentation
and for preview-feature opt-ins, each with a GET plus a preserve-when-absent
write under /api/settings. The list hook now reads and writes the server
value and carries a browser-stored order up once when the server holds
none, so an existing arrangement follows the person to every device instead
of resetting.
2026-10-21 19:11:23 +02:00
Marc-André Bombeck 45da8ae971 Merge pull request #1260 from MBombeck/release/v1.43.11
Release v1.43.11
2026-10-21 17:54:10 +02:00
Marc-André Bombeck aaf826290f test(e2e): follow the checklist's visit link to the visits tab 2026-10-21 17:27:53 +02:00
Marc-André Bombeck 2ec62e9d66 chore(notifications): drop the unused re-export of the defaults input type 2026-10-21 17:01:30 +02:00
Marc-André Bombeck ce2a909ed5 fix(ai): write no key-removal audit for a slot that held no key 2026-10-21 16:50:14 +02:00
Marc-André Bombeck a3f5f998cf fix(report): end the visit offer on the record's day from the server
The preparation window now carries to, the booked visit's own day on
the record's calendar, and the report offer uses it. Cutting the end in
the reader's zone was wrong for a guardian viewing a managed profile in
another zone.
2026-10-21 16:50:14 +02:00
Marc-André Bombeck fdecefd1d3 fix(hrv): match an RMSSD twin on both unique identities in 0420 and the restore
Migration 0420 looked for an RMSSD twin by externalId only. A Fitbit or
Google Health SDNN row with no externalId, or another one, on the same
night as an RMSSD row would have broken the measured-at unique key and
aborted the block at boot. The twin check now matches either identity,
a row left behind by a live twin is tombstoned, and an RMSSD tombstone
that would make a live row look twinned is removed so the row moves.
The backup restore applies the same rule and no longer counts a
tombstone as a twin.
2026-10-21 16:50:13 +02:00
Marc-André Bombeck 4dd70ee60c fix(withings): give up a sleep night that keeps failing, and fail the leg only on recent nights
A night Withings answered with an error on every read kept the
failed-first slot and failed the sleep leg on every run, holding the
connection in an error state and paging for as long as the night stayed
in the window. Failed reads now count per night and a night is given up
after three, like an unstaged one (a full sync still reads it). A
failed read of a night older than 48 hours, while the rest synced, is a
warning instead of a leg failure.
2026-10-21 16:50:13 +02:00
Marc-André Bombeck d24b24473a chore(release): v1.43.11 2026-10-21 16:26:43 +02:00
Marc-André Bombeck bc2d237530 fix(visits): anchor the notable window at the next visit's preparation
Without from, GET /api/day/notable started at the last completed visit
of any kind, while the web passes the next planned visit's
preparation.from, so a client that omits from read a different list for
the same appointment. Without from the window now starts at that
preparation day (anchor nextVisit, documented in OpenAPI); with nothing
booked it keeps the last completed visit or the 90-day fallback.
2026-10-21 16:25:26 +02:00
Marc-André Bombeck 6bdd6f1c58 fix(withings): keep the sleep nights read around a failing one
One failing per-night stage read threw out of the loop, so the nights
already read and the summary vitals were never written. A failed read
now leaves the others to be written, books the leg failed afterwards
and marks the night to be read first next run. A night that answers
without a stage three times is no longer read on every run; a full sync
still reads it. The per-night record lives on the connection (migration
0421) and is reset on reconnect.
2026-10-21 16:25:26 +02:00
Marc-André Bombeck a04a8352f5 fix(withings): keep a brand-18 activity day no other source delivered
Skipping every day Health Mate holds from another app (brand 18) lost
the steps of an account whose phone steps only Health Mate reads. Such a
day is now skipped only when another connected source (not Withings,
not a hand entry) has steps on that local day; otherwise it is kept.
2026-10-21 16:25:26 +02:00
Marc-André Bombeck bfb8711f1d test(apple-health): order the own-sample rows deterministically
Rows of one type came back in insertion order only by chance, so the
three-weight case failed intermittently. Sort by type, time and value.
2026-10-21 16:25:25 +02:00
Marc-André Bombeck 8e2e4f658b fix(backup): restore pre-0420 Fitbit and Google Health HRV as RMSSD
A backup written before migration 0420 files Fitbit and Google Health
HRV as HEART_RATE_VARIABILITY, and the restore wrote it back verbatim,
undoing the migration for the account (and able to duplicate an
HRV_RMSSD row of the same night, since type is part of the unique key).
The restore now applies the same move: those rows come back as
HRV_RMSSD, a legacy row whose RMSSD twin the file also holds is skipped,
and SDNN personal records set by them are left out as the migration
deleted them.
2026-10-21 16:25:25 +02:00
Marc-André Bombeck 65c296d430 fix(ai): audit setting and removing a stored provider key
Removing a provider key, like setting one, left no audit row, unlike
every other credential change. Each key slot a PATCH touches now writes
user.ai-provider.key.set or .remove with the slot and never the value.
2026-10-21 15:07:06 +02:00
Marc-André Bombeck e94257928a fix(notifications): fall back to the shared defaults on an older payload
A preferences response cached by a build before eventDefaults existed
drew every default-on event as off in the matrix until the next refetch.
The no-row rule moves to a client-safe module the server read still
wraps, and the matrix applies it when the map is absent.
2026-10-21 15:07:05 +02:00
Marc-André Bombeck 88200a9273 fix(report): cut the visit offer's end day on the record's calendar
The offer took its start from the server's preparation window, cut on
the record's calendar, and its end from the appointment's day in the
browser's zone. In a browser in another zone a late-evening or
early-morning visit moved the end a day against the start. Both ends now
use the record's zone.
2026-10-21 15:07:05 +02:00
Marc-André Bombeck 03cacc996c fix(profile): one display-name bound, checked after trimming
The setup flow capped a display name at 50 characters while the profile
took 80, and the profile checked its minimum before trimming, so a name
of only spaces was stored as an empty string. Every surface now reads
one bound (80), the profile trims before validating, and only spaces
means no name on both paths: the profile clears it to null, the setup
flow leaves it unset.
2026-10-21 15:06:57 +02:00
Marc-André Bombeck a264dee030 fix(settings): keep the end-access button while the grant list loads
The button for a grant that sign-out-everywhere left standing rendered
only once the grant list (which carries the audit window its
confirmation names) had loaded, so while it loaded, and for good when
the read failed, the row explained the access but offered no way to end
it. The button now shows disabled until the list arrives, and a failed
read draws the standard error row with a retry.
2026-10-21 15:06:47 +02:00
Marc-André Bombeck b53434daa0 fix(fitbit): show the retired card only to an account that had Fitbit
After the Web API turndown every account still saw a Fitbit card that
can no longer connect. The status entry now publishes
retirement.offered: false once the API is gone for an account with no
Fitbit connection, no Fitbit app of its own and no Fitbit data, and the
connections panel leaves the card out. Accounts that had Fitbit keep it
with the retirement notice.
2026-10-21 15:06:40 +02:00
Marc-André Bombeck bf3830203f fix(hrv): keep RMSSD on the display surfaces for an account with no SDNN
Fitbit and Google Health HRV is filed as RMSSD since migration 0420, so
an account whose only HRV comes from them lost it wherever a display
surface read SDNN alone. The dashboard HRV tile keeps its RMSSD fallback
with Recovery off while the account has no SDNN, and its toggle stays
offered for either estimator. A briefing HRV signal counts as measured
today when either series has a reading. The doctor report already files
RMSSD as its own ungated leaf; a guard now holds that. Readiness, HRV
balance and stress stay on SDNN; a test pins that an RMSSD-only account
renormalises over the other components.
2026-10-21 14:32:56 +02:00
Marc-André Bombeck 8d16799479 Merge branch 'fix/v14311-visits' into release/v1.43.11 2026-10-21 14:13:52 +02:00
Marc-André Bombeck eabfeaef08 Merge branch 'fix/v14311-settings' into release/v1.43.11
# Conflicts:
#	messages/de.json
#	messages/en.json
#	messages/es.json
#	messages/fr.json
#	messages/it.json
#	messages/ko.json
#	messages/nl.json
#	messages/pl.json
2026-10-21 14:13:51 +02:00
Marc-André Bombeck eaaff23858 Merge branch 'fix/v14311-integ' into release/v1.43.11 2026-10-21 14:13:37 +02:00
Marc-André Bombeck 1213a12c47 Merge branch 'fix/ocr-limits' into release/v1.43.11 2026-10-21 14:13:36 +02:00
Marc-André Bombeck c456a236a7 fix(analytics): rank Health Connect and the hand-entered sources on the ladders
A source a ladder does not name ranks 90, below every source it does.
Five had no place anywhere: a weight typed as a reply to a Telegram
reminder lost its day to a WHOOP strap's estimate, one an assistant
logged over MCP lost to a Fitbit estimate, and a Health Connect day
lost to a hand-typed count.

Health Connect now sits right after Apple Health on the ladders for
what it imports; EXTERNAL, MCP and TELEGRAM sit right after MANUAL, in
the alphabetical order they already broke ties in, so MANUAL still
outranks the person's own bridge. Relative to each other the sources
the ladders already named keep their order, and saved ladders gain
the new entries at their end as before.
2026-10-21 13:07:34 +02:00
Marc-André Bombeck d02fba6509 fix(withings): skip activity days Health Mate holds from another app
Getactivity marks a day that came from outside Withings with brand 18
("external"): an Apple Health or Google Fit day written into Health
Mate. Imported under WITHINGS it was a second copy of data HealthLog
reads from that app's own integration, and on a day it was the only
Withings row it could outrank a native source lower on the ladder.
Such days are now skipped at ingest and counted on the wide event.
2026-10-21 12:59:44 +02:00
Marc-André Bombeck c1921d1dd1 fix(withings): read the sleep stages night by night
Sleep v2 Get answers only the first 24 hours after its startdate. The
sync asked for the whole 30-day window, so it got the night a month
back and nothing since: every night's stages arrived when the sliding
window reached it, weeks late.

The summary now runs first and lists the nights; each night's stages
are read over its own span (at most 24 hours). Every run re-reads the
nights that ended, or that Withings changed, in the last 48 hours, and
fills older nights that hold no stage row yet, eight per run with a
rotation so a night without stages cannot hold up the rest; a full
sync reads them all. Without the night list the last 24 hours are read
as one slice and nothing is swept, since a slice can cut a night.

A summary failure used to be a warning beside a green leg. It is now
the sleep leg's failure, recorded after the stages land.
2026-10-21 12:59:43 +02:00
Marc-André Bombeck 3b1cdd7a1f fix(strava): keep a rate-limited run's progress and a summary-only row's calories
A 429 in the middle of the activity walk threw out of it before the
upsert, so everything the run had read was discarded and the next run
started over. The walk now ends where the limit hit: what was read
lands, the cursor moves only as far as an oldest-first walk proves,
the failure is booked transient, and the run reports the wait Strava's
X-RateLimit / X-ReadRateLimit headers name (next quarter hour, or
midnight UTC once the daily count is spent). The backfill waits that
long before its next run. `walkPages` learns an `interrupted` page for
this, reported truncated like a walk cut off at its ceiling.

An update mapped from the summary alone (detail budget spent, or the
detail read failed) wrote a null over the calories an earlier detail
read had stored, and over its heart rate and description too. Those
detail-only fields are now kept unless this run read the detail.
2026-10-21 12:51:19 +02:00
Marc-André Bombeck ba1271275f feat(fitbit): stop at the Web API turndown and point to Google Health
Google turns the legacy Fitbit Web API off on 30 October 2026 (support
ended on 30 September). From that day the hourly poll, the backfill,
the sleep repair and the token refresh make no call, the connect, sync
and test routes refuse with `provider_retired`, and the connection
reads as not connected to the health read and the watchdog, so the
shutdown pages nobody and asks nobody to reconnect. The connection row
and everything it imported stay.

The status envelope publishes `retirement` on the Fitbit entry (the
day, whether it has come, the successor), and the Fitbit card shows
when the connection stops, how to move to Google Health (same devices,
one more consent, tokens cannot be carried over) and that the data
stays; sync and connect grey out after the day. Both integrations can
run side by side meanwhile: every type Fitbit writes is laddered with
Fitbit just above Google Health, so a day both deliver counts once.

The test setups pin the turndown off, so the Fitbit suites do not turn
red on the day; the turndown's own tests flip it.
2026-10-21 12:46:14 +02:00
Marc-André Bombeck 2914832ed8 fix(visits): keep the appointment rail action's wire tokens for the shipped app
The shipped iOS app routes the rail by intent and resolves the label
from its own catalogue; a new `visit.view` intent would leave its tap on
an appointment inert. The upcoming-visit action keeps `checkup.view`
and `daily.action.viewCheckups` on the wire and only its `href` moves
to the visit. The web card names what that link opens: "View
appointment", or "View appointments" for the visits list.
2026-10-21 12:38:08 +02:00
Marc-André Bombeck 27a59c29fd fix(records): let an emergency select go back to not recorded
The blood type, organ donor and advance directive selects had no empty
option, and the save left out an empty select, so a value once chosen
could be changed but never removed. Each select now offers "Not
recorded", and a select the person changed rides the save, as null when
it was cleared; an untouched one stays out of the request.
2026-10-21 12:36:15 +02:00
Marc-André Bombeck 1e606834d8 fix(settings): let the display name change after the setup flow
The setup flow asks for the name the greeting and the reports use, and
the profile route has always accepted it, but no settings surface offered
the field and /api/auth/me did not publish it. A name chosen on the
first day stayed for good.

The account payload now carries `displayName`, the profile card shows the
field beside the username and saves it with the rest of the profile, and
an emptied field clears the name.
2026-10-21 12:36:15 +02:00
Marc-André Bombeck 6037ebd3d6 fix(settings): let a saved AI API key be removed
Every personal provider form showed a stored key and offered no way to
take it back, since saving with an empty key field keeps the stored one.
The OpenAI, Anthropic, local model and gateway forms now carry a
confirmed "Remove API key" while a key is stored. It sends the existing
PATCH /api/user/ai-provider with that one key as null, which clears the
column and leaves the provider choice, model and the other keys alone.
2026-10-21 12:36:14 +02:00
Marc-André Bombeck 41cf584a65 fix(settings): say what the AI provider picker does
The select on the AI provider card read "Primary provider" under a card
that said it held the provider HealthLog asks first. It only picks which
provider's key and model the form below edits, and saves nothing; the
order of the fallback chain decides who answers first. A person who
picked a provider there expected it to answer first and it did not.

The picker and the card description now say what they do and point at
the chain for the order.
2026-10-21 12:36:14 +02:00
Marc-André Bombeck 2bb3783b14 fix(settings): report the outcome of quiet settings actions
Adding or removing a passkey or a security key only redrew the list.
Disconnecting an integration and revoking a share link ran from a dialog
that had already closed, so a refused request left everything as it was
without a word. The injection-site exclusions, the local OCR switch and
the preventive-care reminder switches saved, or did not, in silence.

Each now reports through the outcome toast and, where the card has no
inline error line, the shared error toast with its error id.
2026-10-21 12:36:14 +02:00
Marc-André Bombeck b6e3b47bec fix(settings): never leave a card saving after a failed request
Six settings cards raised their own saving flag, awaited a raw fetch and
lowered the flag on the next line. A request that never reached the
server rejected instead, the line never ran, and the card kept its
control disabled until a reload: the Telegram form, the mood reminder,
the low-stock threshold, the lock-screen detail switch, the Coach nudges
and the profile form.

A network failure now falls through to the card's own error line, and the
flag is lowered in a finally. A guard holds every hand-rolled pending
flag under settings to that shape.
2026-10-21 12:36:14 +02:00
Marc-André Bombeck 6a9543b4f8 fix(settings): confirm before ending a grant from the sessions card
After sign-out-everywhere the sessions card lists the access grants it
left standing, each with an end button that revoked on the tap. It is the
same act the access card confirms, permanent for the other account, so it
now asks the same question with the same disclosure, audit window
included, and the row names the account the way the access card does.
2026-10-21 12:36:14 +02:00
Marc-André Bombeck 99fec79177 fix(visits): start the visit preparation and its report at the matching visit
Both surfaces read the start of their window from `preparation` and say
why it starts there: "Since your last visit with <practice> on <date>",
"Since your last <specialty> visit on <date>", "Since your last visit
of the same kind (<kind>) on <date>", or "The last 90 days" with the
reason that no earlier visit matched. The visits tab no longer picks
the last visit of any kind from the past list.

The report panel's appointment offer now sets both dates, the start
from the server and the end on the appointment's day, instead of the
end alone beside whatever start the panel held. Both stay editable.
2026-10-21 12:32:26 +02:00
Marc-André Bombeck 402cf1a35a feat(visits): resolve where the window before a booked visit starts
The visit preparation and the doctor report offered for an appointment
started at the last visit of any kind. An internist one week and a
dermatologist the next gave the dermatologist a one-week window of
nothing it is about.

The list read now publishes `preparation` on every booked upcoming
visit: the first day of the window, the rule that chose it and the
earlier visit it is the day of. First match wins over the visits that
took place before it: the same practitioner, else a practitioner of
the same specialty (free text, compared without case or spacing), else
the same kind for routine, preventive and therapy visits, else the last
90 days. One query of past visits per page, independent of the list's
own filters. Null on every other row; absent on the detail and on
write responses.
2026-10-21 12:32:12 +02:00
Marc-André Bombeck 4c50f48b26 fix(labs): name a failed re-check as an error and close the last budget gaps
A re-check or reservation that failed itself stopped the scan as
"unavailable", and the review screen said AI had been turned off. It now
stops as stoppedBy error with its own message in every locale, and the
failure is reported as one: an error signal and the worker error
reporter. A scan that failed that way before any row answers 500
aiRuns.failed.

The overspend booked before a further read is now net of the cached
share, as the settlement books it. A reservation that names no known
owner stops the scan instead of taking the user plan's larger cap.

A page's text layer is cut at the last whole line; only a page whose
first line alone runs past the cap keeps part of it, cut at a space or
hard, rather than collapsing to the bare cut mark, and an endless line is
no longer read past the cap. The unused per-call text limit is gone. The
run result's description names every outcome a stopped scan can have.
2026-10-21 12:28:32 +02:00
Marc-André Bombeck 1fc0268b3d fix(polar): clear each leg on its own success, read a lone 403 as refused
The workouts leg recorded its failures under its own leg but left the
success to the vitals leg. A leg-scoped success clears only its own
leg, so one workouts failure stayed on the row for good and kept the
connection in error however healthy the exercises read became. Each
leg now records its own success.

AccessLink documents its 403 as missing consent for that data. A 403
on one collection while a sibling succeeded with the same token was
classified reauth_required and put a working connection behind a
reconnect prompt no reconnect clears. Like Oura's scope refusals, such
a collection (or the exercises read, once the vitals leg proved the
token) is now noted on the wide event and not counted as a failure. A
403 on every collection, or any 401, stays one.
2026-10-21 12:23:57 +02:00
Marc-André Bombeck 0dcafeeb70 fix(visits): open an upcoming appointment where it lives
Visits are the second tab of `/checkups`, and the bare path opens the
first one, the preventive-care list, which never shows an appointment.
Every surface that listed an upcoming appointment linked it there: the
dashboard's next-appointment line, the Today rail item, the Today
overview fact and the appointment's push notification. A tap on the
appointment landed on the list it is not on.

Each now opens the visit itself (`/checkups?visit=<id>`), or the visits
tab (`/checkups?view=visits`) for a line naming several appointments.
The checkups page reads `view` to pick a tab. The rail item stops
borrowing the check-up item's action: it carries its own label
("View appointment") and intent `visit.view`. The appointment push names
its visit through the producer's `metadata.url` override; a check-up's
push keeps the checkups list.

The same mis-route sat on three links that lead to visits without
being upcoming: a condition's visit rows, the address book's back link
and the onboarding and timeline prompts to add a visit.

`visit-link-guard.test.ts` keeps the visit paths in the builder and
counts every bare `/checkups` per file, each with the reason it names
the checkups list.
2026-10-21 12:22:36 +02:00
Marc-André Bombeck 78bacb0d86 refactor(visits): build every link to a visit in one place
Seven surfaces spelled `/checkups?visit=<id>` by hand, some encoding
the id and some not. They now share `visitPath()` from
`src/lib/encounters/visit-links.ts`, the place the next fix extends.
No behaviour change: a cuid encodes to itself.
2026-10-21 12:22:22 +02:00
Marc-André Bombeck a66c54a1f1 feat(insights): scan the nightly RMSSD series for correlations and illness
HRV_RMSSD was read nowhere HRV is analysed: the correlation scan and the
illness engine only knew the SDNN series, so a strap or ring wearer's
HRV never took part in either. RMSSD is now its own outcome channel
(owned by the recovery module, like the summary tile) and its own
illness scan type with a drop read as adverse. It is not merged into
the SDNN channel: the estimators differ in scale, and one series of
both would correlate the change of device rather than the body.
2026-10-21 12:13:39 +02:00
Marc-André Bombeck 2e653b7aa0 fix(settings): name the act on the token revoke confirmation
The confirm button in the API-token and MCP-token revoke dialogs read
"Revoked", the status word the admin overview shows for a token that is
already gone, and the phone layout's trigger carried the dialog title,
question mark included. Both now say "Revoke token".
2026-10-21 12:02:20 +02:00
Marc-André Bombeck 9a8b95cfd0 fix(ui): let ConfirmButton's dialog wear the destructive style
ConfirmButton took a `destructive` prop and dropped it on the way to its
dialog, so every delete, revoke and remove built on it confirmed with a
neutral primary button while ConfirmDialog confirmed the same kind of act
in red. The prop is forwarded now, and the call sites whose act takes
something away pass it. A guard holds every confirmation whose slot names
a destroying act to the destructive style; resets stay neutral.
2026-10-21 12:02:13 +02:00
Marc-André Bombeck dc704b5e63 fix(notifications): show the mood reminder's real default in the matrix
The /notifications matrix read the constant default table, where the
mood reminder is off, while delivery derives it from the reminder card.
An account receiving the reminder saw every one of its channel switches
off.

The no-row default now lives in one resolver that delivery and the
preferences read both use. GET /api/notifications/preferences publishes
it as `eventDefaults`, the matrix renders a switch without a stored row
from that map, and the reminder card refreshes the matrix when it flips.
2026-10-21 12:01:53 +02:00
Marc-André Bombeck c5d2053e5a fix(hrv): file Fitbit and Google Health RMSSD as HRV_RMSSD
Fitbit's `dailyRmssd` and Google Health's daily HRV average are both
RMSSD, but the mappers wrote them into HEART_RATE_VARIABILITY, the SDNN
series. The `hrv` ladder ranks both above Apple Health, so an account
with an Apple Watch and a Fitbit had a series that switched estimator
from day to day. The mappers now write HRV_RMSSD, and the `hrvRmssd`
ladder carries both sources where they sat on the SDNN one.

Migration 0420 moves the rows already written (tombstones included,
with a sync-version bump so the change reaches the phone), drops their
SDNN rollup buckets and the RMSSD buckets of every account whose rows
moved, so the boot-time rollup discovery folds those accounts again,
and deletes the SDNN personal records those rows had set. One DO
block, so the move and its cleanup land together.
2026-10-21 12:00:20 +02:00
Marc-André Bombeck e897549dff fix(ai): scrub fence markers until none can re-form
The shared data/instruction fence removed embedded markers in a single
pass. Removing a marker joins the text either side of it, so a marker
split around another one came back whole after the scrub:
`<<<DOCUMENT_TEXT_E<<<DOCUMENT_TEXT_END>>>ND>>>` became
`<<<DOCUMENT_TEXT_END>>>`, and the same split around any other marker
worked too. Content could close its own fence wherever the helper is used:
the Coach snapshot and self-report, MCP tool results, document reads and
lab scans.

The scrub now repeats until a pass changes nothing, which proves no marker
is left. Content nested deeper than eight levels has every run of three or
more angle brackets cut to two, after which no marker can exist or
re-form. Ordinary text, angle brackets included, is unchanged.
2026-10-21 11:51:22 +02:00
Marc-André Bombeck d21d29572d fix(whoop): keep the night's sleep when its recovery is deleted
The v2 recovery webhook carries the id of the associated sleep, and a
recovery's rows are keyed off that same sleep id. The delete branch
matched on the `<id>:` prefix, so a `recovery.deleted` event also
soft-deleted every sleep row of that night, and a `sleep.deleted`
event took the recovery with it. Recovery now names its exact ids
(pinned against the mapper's field tags); sleep keeps the prefix minus
those ids.

The targeted refresh for `recovery.updated` passed the same sleep UUID
into `/v2/cycle/{cycleId}/recovery`, which answered 404 and booked a
sync failure on every recovery webhook. It now reads the sleep for its
`cycle_id` first.
2026-10-21 11:42:53 +02:00
Marc-André Bombeck bfc7991935 fix(labs): treat a PDF's text layer as a fenced hint and keep the day's counter current
The text layer went to the model as the authority for digits. It is not
one: a scanned PDF often carries an invisible OCR layer with its own
mistakes, a subset font can map to the wrong characters, and a crafted
PDF can carry invisible text that differs from what the reviewer sees.
The prompt now calls it a hint only, makes the image authoritative, forbids
adding a value the image does not show, and asks for a low value
confidence where the two disagree. The layer is fenced with the document
text markers, which it cannot close, and so is text mode's OCR'd text.
Each page's layer is capped while it is collected, and a read shares its
text budget out per page, cut at a line and marked, so a long first page
no longer pushes the later ones out.

A further read now first books what the reads so far cost beyond their
reservation, then its own ceiling, both against the cap of the owner the
route judged the first read by, so the counter the next read is judged
against never lags. The wire is re-checked before every read, the first
included. A re-check or reservation that itself fails stops the scan with
the pages read rather than failing it, and a scan stopped before any row
answers with the stop's reason (budget, or the wire's own refusal) instead
of an unreadable page.
2026-10-21 11:33:37 +02:00
Marc-André Bombeck 79d905f089 fix(labs): reserve and re-check each page of a scan, and read digits from the text layer
The route reserved one read's budget and checked the wire once, and a
20-page report then ran up to 40 calls for many minutes on that single
admission. Every read after the first now reserves its own share of the
day's budget and asks the wire again (aiEgressRefusal) before it is sent;
when either refuses, the scan stops there, keeps the pages already read
and names the rest with stoppedBy budget or unavailable. The run settles
against everything it reserved, with the provider's reported usage net of
its cached share, and a scan that fails after several calls is charged
for them instead of refunded in full.

A rendered PDF page now goes out with its own text layer, read by pdfjs
beside the image, so a digitally produced report's digits, decimal
separators and units come from its characters rather than from a 1600 px
picture. Splitting the PDF into single-page documents would need a PDF
writer the project does not ship; the text layer needs none and helps
every provider. A scanned page has no text layer and reads as before.

Codex's cut reply keeps the text it streamed (on a non-enumerable
property, so no log or event serialises it), and its closed rows are
salvaged like every other provider's. The merge folds a reading only into
one an earlier page listed, never two rows of the same page, and the
printed range is part of the key. OCR'd text the deadline cannot split
small enough for one read is refused up front as labs.ocr.tooLarge.
2026-10-21 11:10:02 +02:00
Marc-André Bombeck 6376710254 fix(labs): say on the review screen what a scan could not read
The review screen now names a read that stopped at the provider's output
limit (the pages it happened on, or the report when it was one read),
pages the provider never answered for, and a report with more readings
than one scan carries, beside the existing page note. A scan that
produced no reading because every read hit the output limit says so
instead of asking for a clearer photo.

A scanned name or result longer than 120 characters, or a unit longer
than 40, now marks its field on the row instead of failing the whole
save with a 422; the commit's bounds are shared with the screen.
2026-10-21 10:43:04 +02:00
Marc-André Bombeck 10cfdf8b66 fix(labs): read a lab report page by page and keep a cut-off read's rows
A scan sent the whole report to one call under an 8000-token ceiling and
validated the reply against a 100-row envelope. A long multi-panel report
lost everything either way: the reply stopped mid-row and failed to parse,
which the person saw as "try a clearer photo", or it held more than 100
rows and the schema refused all of them. One malformed row refused the lot
as well. Reasoning models made it worse, because the call sent no
reasoning setting and OpenAI, Codex and Claude's 5 generation reason by
default against the same output allowance.

A PDF of more than one page is now rendered and read a page per call on
every provider (up to 20 pages), as many reads as the run's deadline
allows, and the pages are merged in order with a reading printed twice
listed once. A photo, a one-page PDF on a provider that reads PDFs, and
OCR'd text up to 4000 characters stay one read; longer text is split at
line boundaries. Each read carries an explicit reasoning off, so a model
that can stop reasoning does and one that cannot gets its minimum budget
on top of the answer's. A reply cut at the output limit is detected from
the finish reason (Codex's incomplete stream included), keeps the rows
that closed before the cut and is named in the result instead of retried;
when no read produced a row the run fails as labs.ocr.outputLimit. A
provider failure after the first page keeps the pages already read.
Rows are validated one by one, the row cap is 400 for the scan and the
commit, and a report past it keeps its first 400 and says how many it
had. The run settles what the provider reported it spent rather than the
fixed reservation, so a page-by-page read still counts against the day's
budget in full.
2026-10-21 10:40:45 +02:00
Marc-André Bombeck 421aacf457 Merge pull request #1256 from MBombeck/release/v1.43.10
Release v1.43.10
2026-10-21 10:04:15 +02:00
Marc-André Bombeck 766b413871 fix(ui): keep a disabled chosen segment readable and the range rows at 44 px
A faded primary fill reads about 2:1 in light mode, so a disabled chosen
segment swaps to the token pair Button uses instead of the opacity. The
small range rows keep 44 px under a mouse as well, as the chart tabs had,
and the changelog names the fill by what it is.
2026-10-21 09:36:05 +02:00
Marc-André Bombeck 3c4e07df94 chore(release): v1.43.10 2026-10-21 09:33:05 +02:00
Marc-André Bombeck f9796858e6 fix(ui): give the segmented control back the chart tabs' look
The shared control introduced a muted track with the chosen segment
raised on the background colour. In a chart header it read heavier than
the card around it, larger type and wider segments on a grey bar, and it
dropped the primary fill the range tabs had carried.

Segmented now draws the chart tabs' former look everywhere: no track, the
chosen segment filled in the primary colour, the others plain until
hovered. A small size keeps the range rows in chart headers at the former
type and padding; the touch floor of 44 px on a coarse pointer stays.
2026-10-21 09:32:58 +02:00
Marc-André Bombeck c1fb59fe39 Merge pull request #1255 from MBombeck/release/v1.43.9
Release v1.43.9
2026-10-21 01:51:27 +02:00
Marc-André Bombeck bba33f1387 chore(release): v1.43.9 2026-10-21 01:23:43 +02:00
Marc-André Bombeck 4bb2096eea Merge branch 'fix/v1439-oura' into release/v1.43.9 2026-10-21 01:17:39 +02:00
Marc-André Bombeck 897fb10c5b Merge branch 'fix/v1439-jobs' into release/v1.43.9 2026-10-21 01:17:39 +02:00
Marc-André Bombeck 192b6593a4 fix(oauth): claim a signed callback only once its code can be exchanged
The signed-state callbacks (Oura, Polar, Strava) claimed the state before
the session, code and issuer checks. A denied consent comes back without a
code, so it spent the claim on a `nocode` error page, and allowing on the
same authorize URL afterwards never exchanged the valid code: the browser
was sent to the stored error page instead. Every check that refuses a
callback without touching the code now runs first; the claim still sits
strictly before the exchange.

Expired claims were swept only when a callback completed, so a quiet
instance kept its last rows. The daily OAuth-state cleanup at 03:20 now
sweeps them as well.
2026-10-21 01:17:20 +02:00
Marc-André Bombeck 28c8406924 fix(ai): park a revoked Codex family and stop at a disconnect mid-refresh
Under the refresh lock, with the row still holding the token just spent,
no peer rotated it, so refresh_token_reused is the issuer refusing the
whole family: it now parks the sign-in instead of being asked again
after every cooldown. Outside the lock it stays a lost race. A refresh
whose compare-and-set write finds the connection gone (the person
disconnected Codex meanwhile) now refuses with reconnect-required
instead of using the rotated token once more.
2026-10-21 01:16:51 +02:00
Marc-André Bombeck b997d4e673 Merge branch 'fix/v1439-jobs' into release/v1.43.9
# Conflicts:
#	src/lib/integrations/oauth-refresh.ts
2026-10-21 00:07:07 +02:00
Marc-André Bombeck 769d0334a7 fix(rollups): match the keyed refold's days in UTC whatever the session zone
The keyed refold's day filter and delete compared the naive UTC
measured_at and bucket_start with a timestamptz[] parameter, which goes
through the session time zone; in any zone but UTC neither matched and
the refold left its buckets stale. Both now bind timestamp[] literals.
Keys are refolded in (type, day) order in chunks of 500, and the
rollup-recompute queue describes the runs of buckets a job now covers.
2026-10-21 00:06:21 +02:00
Marc-André Bombeck 1be2c27b27 fix(ai): serialize Codex refreshes so a lost race never parks a live sign-in
Two callers refreshing one Codex token raced: the loser heard
refresh_token_reused, read it as a dead grant and parked the connection
before the winner wrote its rotated tokens, leaving fresh tokens beside
an expired status. Refreshes now share one flight per process and queue
on the per-credential advisory lock across processes, re-read the row
inside it, use a token a peer already rotated, write the rotated tokens
compare-and-set on the spent one together with connected, and re-read
after a refusal before parking. refresh_token_reused no longer counts as
a dead grant, and neither does a bare 403 without an OAuth error code:
an edge challenge answers a valid grant so, and it keeps the ledger's
timed cooldown instead of parking until the next sign-in.
2026-10-21 00:06:21 +02:00
Marc-André Bombeck 31e0957903 Merge branch 'fix/v1439-oura' into release/v1.43.9 2026-10-21 00:05:57 +02:00
Marc-André Bombeck 6224409678 fix(oura): request the stress scope and exchange a repeated callback once
Resilience sits behind Oura's `stress` scope, not `heart_health`, so a
connection that granted every scope HealthLog asked for still answered 401
on `daily_resilience`. `src/lib/oura/scope.ts` now maps every collection the
sync reads to the scope the live API enforces and is the one source for the
authorize request, the per-collection permission detection, the reconnect
hint and the settings card. The request asks for `stress` as well.

Oura names a missing scope in its 401 body ("Token is not authorized access
stress scope."), while a dead token reads differently. A collection refusal
now carries that scope: it spends no token refresh, never counts toward the
reconnect threshold and marks only the collections it names. A 403 (a lapsed
membership) is a persistent failure rather than a reconnect. The callback
also records what the token response says was granted, so an unticked
permission shows on the card before the first sync.

A browser can deliver an OAuth callback twice with the same code. Both
requests exchanged it, and Oura answers a reused code by revoking the token
the first exchange issued. Every connect callback (Oura, Polar, Strava,
WHOOP, Withings, Fitbit, Google Health) now claims its state in
`oauth_callback_claims` before the exchange: one request exchanges, the
duplicate waits for it and redirects to the same page without touching any
token. The ledger flows claim inside the transaction that consumes their
state row. A failed exchange settles with its error page, and a new connect
mints a new state.

Oura token refreshes are serialised per user with an advisory lock, so
overlapping syncs no longer spend the same single-use refresh token, and the
write stays conditional so a reconnect's new grant is never overwritten by a
refresh of the old one.
2026-10-21 00:05:22 +02:00
Marc-André Bombeck 23bc58660d test(integration): keep two cases independent of file order and random ids
The backup schedule case assumed the shared database had no queue schema,
but files that drive the queue create it and one leaves a finished backup
run behind, so the case failed whenever it ran after them. It now clears
that queue's history first. The delegate refusal case scanned the raw
response for a digit run that the random request id can contain; the id
is taken out before the scan.
2026-10-21 00:03:08 +02:00
Marc-André Bombeck b4618f2ec3 Merge branch 'fix/v1439' into release/v1.43.9 2026-10-20 22:49:33 +02:00
Marc-André Bombeck 7bbd7c3811 fix(insights): read the all-time extremes from the tier only where it matches
The DAY tier is not recomputed on every path that removes readings: a
hard delete with no rollup recompute leaves a bucket counting readings
that are gone, and the extremes then repeated it. Those figures are what
the Coach snapshot checks its coarse history against, so they must stay
true to the live record.

A type is now folded from the tier only when the tier accounts for the
live record day by day: the same days, and per day the same reading
count. The live side is a count per day served by an index-only scan of
the live index, so the check reads no measurement rows. A type that
fails it is read raw, as before.
2026-10-20 22:48:24 +02:00
Marc-André Bombeck fed16a4c43 Merge branch 'fix/demo-dashboard' into release/v1.43.9 2026-10-20 22:43:38 +02:00
Marc-André Bombeck ba57c7ba97 Merge branch 'fix/v1439-jobs' into release/v1.43.9 2026-10-20 22:43:38 +02:00
Marc-André Bombeck 226942f722 Merge pull request #1254 from MBombeck/docs/readme-screenshots
docs(readme): current screenshots
2026-10-20 22:23:57 +02:00
Marc-André Bombeck 52c6c8dc9b fix(demo): keep the demo dashboard to the measurement charts
The shared demo account carries onboarding answers, so the setup
checklist stayed on its dashboard for every visitor although none of its
rows can be done there. Under DEMO_MODE the dashboard leaves it out, as
the shell already does with the tour.

The demo seed hides the mood and medication cards, achievements, recent
workouts and check-ups, opens weight, BMI and blood pressure on 90 days,
and resting heart rate on 30 with its 7-day trend and trend line.
2026-10-20 21:08:16 +02:00
Marc-André Bombeck 79072438de docs(readme): show the current app, the timeline and the day panel
Replace the June dashboard and the old gallery with current screens: the
dashboard, the timeline, the day panel, resting heart rate, medications,
labs and the coach. The resting heart rate view stays and is extended to
the same 16:10 frame as the others.
2026-10-20 21:04:05 +02:00
Marc-André Bombeck 652dd6943b test(auth): read the password hash off the stored row in proof tests
The proof gates now read whether an account has a password from its own
row rather than from the per-request user, so the suites that drive
them mock that read instead of carrying the hash on the session user.
2026-10-20 20:56:08 +02:00
Marc-André Bombeck 63a3e354c8 perf(boot): load route modules on first request, not all at start
Next preloads every route and page module right after the server
starts, about 670 here. The load runs on the event loop, so every
container start showed one stall of several seconds, the first
requests waited behind it, and the heap stood at about 430 MB before
the first sign-in, most of it code for routes an instance rarely
serves. With preloadEntriesOnStart off, each module loads on its
route's first request. The same image started locally at 185 MB of
heap instead of 428, with no stall, and a route's first request paid
30 to 60 ms once.
2026-10-20 20:46:34 +02:00
Marc-André Bombeck 7759729aca fix(proxy): refuse Server Action calls the app never makes
HealthLog defines no Server Actions, so no bundle of it, current or
stale, sends a Next-Action header. The requests that do arrive come from
the nightly scan probing that surface, and Next answered each with an
unstructured "Failed to find Server Action" at error level. The proxy
now refuses them with a 404 and one structured warn line, and a guard
keeps "use server" out of the tree so the refusal stays safe.
2026-10-20 20:29:46 +02:00
Marc-André Bombeck 0534123d0d perf(integrations): read the status envelope in one round
The provider rows, the unified connection health and the per-metric
freshness were awaited one after another although none depends on the
others, so a cold first visit to the settings card paid their latencies
end to end. They now run in one round.
2026-10-20 20:28:21 +02:00
Marc-André Bombeck 05ae8b1e99 perf(dashboard): count all-time readings from the DAY rollups
The summary's all-time aggregate grouped every live reading of the
account by type: one index entry per reading, millions on an account
with a watch, two to four seconds whenever they were not in memory and
the slowest read of the request on every cache miss. The count now sums
the DAY buckets that already hold a per-day count of every source,
counts live only the readings before the fold window and any type not
folded yet, and takes lastSeenAt from the newest reading the
latest-reading read already returns. Parity with the live count is
pinned against a real Postgres.
2026-10-20 20:27:32 +02:00
Marc-André Bombeck 73f2004c54 feat(observability): break the heap sample down by V8 space
The heap sample named old_space alone, so a heap that stood well above
it could not be explained from the log: a buffer past V8's large-object
threshold lives in large_object_space, compiled code and bytecode in the
code and trusted spaces. The sampled and pressure lines now carry the
used size of every space, with any space the runtime reports that is
not named summed into other_spaces_mb, so the fields account for the
whole used heap whatever the Node version.
2026-10-20 20:27:24 +02:00
Marc-André Bombeck c6c51be280 perf(jobs): stop the geo backfill from retrying the same rows hourly
An audit row whose address no database can place stayed without a
location, so every hourly pass looked it up again for its whole 30-day
window, and finding it walked every audit row of those 30 days through
the created_at index.

Migration 0418 adds audit_logs.geo_attempted_at and a partial index on
the rows still missing a location or carrier. Every row a pass looks at
is stamped, and a row tried less than seven days ago is not picked
again, which still lets a range a later GeoLite2 release learns resolve.
The candidate read goes through the partial index, which holds only the
backlog.
2026-10-20 20:26:30 +02:00
Marc-André Bombeck 1cfae83578 perf(insights): fold the all-time extremes from the DAY rollup tier
readAllTimeExtremes read every raw reading of weight, blood pressure and
pulse twice on each call, a sequential pass over the measurements table
that made it the largest read on the database. The DAY tier already
holds the same per-source day fold except for the extremes of the
readings themselves: min_value and max_value widen to a row's own
intra-hour range.

Migration 0417 adds reading_min_value and reading_max_value to
measurement_rollups. The writer fills them from the same aggregate, and
the boot re-fold refills existing rows through the arm that already
watches sum_xy. The reader folds the tier for whole UTC days inside the
fold window and the raw rows for the rest: older days, the partial day
a lookback floor falls in, and any type whose tier rows lack the new
columns. The raw arm reads narrow columns once instead of twice.

An integration test compares the result with the previous query on a
seeded history with two sources per day, hourly pulse statistics with
their own range, readings older than the window, midnight edges and
deletions, and shows the tier is the path that answers.
2026-10-20 20:22:19 +02:00
Marc-André Bombeck f01f53a1cc perf(rollups): refold the buckets of every provider sync in one pass
Every provider sync and the shared mutation tail refolded its touched
(type, day) buckets one at a time: an aggregate, a transaction and three
queue sends per day. They now hand the whole set to
recomputeBucketsForKeys, which leaves the DAY tier exactly as the per-key
hook did (pinned by a parity test against it) at the cost of one
aggregate per chunk of keys and one coarse job per run of buckets.
2026-10-20 20:20:00 +02:00
Marc-André Bombeck 8cdb53cb0e perf(withings): write a measure sync in sets and refold only what changed
A manual full sync re-reads the whole history, and nearly all of it is
already stored. Each reading cost a findFirst plus an update, and every
re-read day was marked dirty, so the run refolded the DAY tier and queued
three coarse recomputes for every day of the account: about thirty
seconds of database work behind 1.4 s of Withings calls.

The measures leg now reads the stored rows of the window once, skips the
unchanged ones, updates the changed or tombstoned ones and inserts the
new ones in batches. Only inserted or changed readings count as touched.
recomputeBucketsForKeys refolds exactly the touched DAY buckets in one
aggregate and one transaction, and queues one coarse job per run of
adjacent buckets instead of three per day.
2026-10-20 20:13:13 +02:00
Marc-André Bombeck 567a564913 perf(auth): resolve the caller to a narrow user projection
The session lookup and the Bearer resolver loaded the whole users row on
every authenticated request, including the profile picture bytes, the
cached briefing text and every stored integration credential. Both now
select SESSION_USER_SELECT, a named allowlist of the small columns the
request path reads, and so does the owner load for a switched record.

The few routes that need a credential or a large column read it
themselves at the moment of use: the factor verifiers load the password
hash and TOTP secret, the profile response loads the insurance number,
and the snapshot read loads the cached briefing text. A structural guard
holds the lookups to the projection and keeps every Bytes and
secret-shaped column out of it.
2026-10-20 20:07:25 +02:00
Marc-André Bombeck 07d70cd275 fix(ai): park a Codex sign-in the issuer refused until the next sign-in
A dead refresh token was asked again on every hop of every run: the
proactive refresh failed and was swallowed, the request 401'd, the on-401
refresh failed again, and the nightly warm repeated that for the briefing
and every card. A refusal that proves the grant dead now flips the
connection to the existing expired state (compare-and-set on the refused
token, one signal per transition), so the chain, the presence probe and
the AI capabilities stop counting it. A new device-code sign-in clears the
refused credential's ledger rows so the fresh one resumes in place.
2026-10-20 20:04:21 +02:00
Marc-André Bombeck 628f37bc62 fix(apple-health): show a written-back type's newest reading on its row
A reading entered here goes to Apple Health as well, and the quiet
verdict already counted it, but the row still showed the newest reading
that arrived from the phone, so a type entered two minutes ago read one
day old. The server now resolves lastSeenAt to the newer of the two for
the types the app writes back, and the row and the verdict agree.

Refs #1247
2026-10-20 19:47:07 +02:00
Marc-André Bombeck a0e725556b chore(deps): raise the hono floor to 4.13.11 2026-10-20 19:45:11 +02:00
Marc-André Bombeck 5502342ec5 Merge pull request #1253 from MBombeck/release/v1.43.8
Release v1.43.8
2026-10-20 18:03:28 +02:00
Marc-André Bombeck f573969428 fix(ui): give unchosen segments the tab trigger's readable dark tone
The muted foreground on the muted track reads 3.6:1 in the dark theme,
below WCAG AA. Tabs already solved this with the tab-inactive token;
Segmented now uses the same treatment, so both controls look alike.
2026-10-20 17:37:17 +02:00
Marc-André Bombeck b0e67a8831 chore(release): v1.43.8 2026-10-20 17:19:51 +02:00
Marc-André Bombeck d1922da0e1 chore(bundle): re-measure the client budget after the shared primitives 2026-10-20 17:19:25 +02:00
Marc-André Bombeck 72ce7a0ead Merge branch 'fix/v1438-r2' into release/v1.43.8 2026-10-20 17:08:00 +02:00
Marc-André Bombeck c72b81fa66 test(api): read every published list with a row in it
The GET contract run counted 40 operations whose bodies held only empty
arrays, and an empty array parses against any item schema. The seed now
links the visit, lab, condition, document and dose to each other, adds a
procedure, a second and third lab reading, nutrients, a category, the
list order, courses, a rescheduled medication, a history fact, Coach
preferences, a plan and a fact, a reminder event, an invite, a delegate's
visit, a sign-in, a personal record, a trusted device, a marker alias, a
symptom on a cycle and an illness day, part-way import jobs and a chat
about the document. The run requires every operation to answer at least
once with a filled list, apart from eleven named exceptions, and a floor
of filled operations. The first finding is fixed: the document chat list
returns fenced, attachments and documentTitle, now declared.
2026-10-20 17:02:44 +02:00
Marc-André Bombeck 6182d45354 fix(i18n): print server-built sentences in the reader's notation
The decimal lint covered components and app only, and sentences for
people are built in src/lib too: the weekday weight correlation printed
"0.8 kg" into a German sentence, and the no-key fallback and the period
narrative's fallback spelled fractions with a full stop. Those three use
the new formatDecimal from format-locale with the reader's locale. The
rule now covers src/lib and src/hooks; text for the model, operator log
lines, SVG data, zone checks and wire values a client re-formats carry a
reasoned directive.
2026-10-20 16:52:08 +02:00
Marc-André Bombeck c8649faddf test(ui): sharpen the page-title and empty-state guards
The H1 guard matched the raw text, so seven files sat on its allowlist
only for mentioning an H1 in a comment; it strips comments first now and
the entries are gone. The empty-state ratchet counted against a ceiling,
which let a new copy in wherever an old one left, and missed the no...Yet
keys: it freezes the remaining sites by file and key, recognises no...Yet
and nothing... keys, and the two it missed (the insight status card and
the screener card) render EmptyState.
2026-10-20 16:45:11 +02:00
Marc-André Bombeck 7ed953a029 fix(ui): route the hand-built segment rows through Segmented
The narrative period, the lab result type, both admin filters, the chart
range tabs, the mood chart's own copy of them, the sleep-stage window and
the re-authentication method each spelled a segmented control by hand,
with their own sizes and fills. They render Segmented now; the range tabs
keep their long names as a hover title, and the admin filters gain an
accessible group name in every language. A test refuses a new button row
whose aria-pressed compares a state to each button's value, and names
the rows that remain with why they are not one choice on one track.
2026-10-20 16:43:59 +02:00
Marc-André Bombeck 160da978e6 fix(insights): keep grid tiles on one baseline when a title wraps
Removing truncate from the grid tiles let a long title take a second
line and push its value and sparkline below the neighbours'. TileHeader
takes reserveLines={2}, a fixed two-line row with a clamp and the full
title in title, as the dashboard trend strip already does. The vitals
tiles, their building state and skeleton, the device score tiles and the
resilience tile use it, and a test holds those files to it.
2026-10-20 16:38:29 +02:00
Marc-André Bombeck 024620a7f1 fix(ui): give a wrapped card title room between its lines
CardTitle wraps but kept leading-none, so the two lines of a broken
compound touched. It is leading-tight now; a caller's own leading still
wins.
2026-10-20 16:38:29 +02:00
Marc-André Bombeck 54d215d377 chore: drop the schema modules' unused constant re-exports
The client-safe constants moved to zod-free modules and every caller
imports them from there; the re-exports from the schema modules had no
reader left.
2026-10-20 16:35:04 +02:00
Marc-André Bombeck a37d45ab3f fix(insights): take the crosstab units from the shared unit table
The cycle, mood-tag and mood-factor boards each kept a table of unit
words, and the German cycle board printed S/min where every other screen
says bpm. A board row now names the measurement it reads; MetricCrosstab
resolves the unit through measurementUnitLabel after the reader's unit
preference, and the callers use the same reading for their detail line.
A rating or score shows no unit word, as everywhere else. The boards' own
unit keys are gone from all bundles, and unit-label.test.ts refuses a
component that keeps a unit table again. The pair label wraps instead of
being cut off.
2026-10-20 16:16:13 +02:00
Marc-André Bombeck 5185edca74 fix(rate-limit): fail the shared write ceiling open and skip reads over POST
The shared write bucket threw on any SQL error, so a lock on rate_limits
answered every authenticated write with 500. It is a loop cutter, not an
access check: when the bucket cannot be read the write now goes ahead with
a warn signal. The login and other auth-surface limits call the limiter
directly and still fail closed. requireAuth now passes readOverPost to the
meter, the draft-only document assist declares it, and the three chat
routes, which meter their turns with buckets of their own, declare that
instead of being counted twice.
2026-10-20 16:09:28 +02:00
Marc-André Bombeck 408ae35b7c fix(migrations): retry a marked migration that lost a deadlock
A marked migration's DO block holds the locks it takes until it commits,
so an app transaction taking two of its tables in the other order can
deadlock with it. The block then rolled back whole, exactly as on a lock
timeout, so the boot retries 40P01 for marked migrations as it does 55P03.
Corrects 0415's note that the drops hold their lock for an instant.
2026-10-20 16:06:10 +02:00
Marc-André Bombeck 2b81b4f23e fix(migrations): retire the measurements prefix index without waiting at boot
0416 ran DROP INDEX CONCURRENTLY at boot, which waits without bound for
every transaction on the table; a pg_dump or a backfill outlasted the
start period and the next boot stopped at P3009. The migration now tries
a plain drop with a two-second lock wait and tolerates the timeout, and
the nightly measurement maintenance drops what is left concurrently under
a bounded wait, retrying the next night. A fresh or shadow database takes
the lock at once, so the history still ends where the schema does. The
guard refuses a concurrent drop in a migration and ties every retired
index to a tolerant one.
2026-10-20 16:06:10 +02:00
Marc-André Bombeck a275a5bb65 fix(records): show a deleted practitioner on the live visits that name it
The web record and the doctor report named the practitioner of a live
visit after the address-book entry was deleted; the MCP read, the search
and the appointment reminder did not. All of them now name it, the entry
stays out of the address book and the pickers, and a portable backup
carries it as a tombstone instead of dropping the visit's reference.
2026-10-20 16:01:13 +02:00
Marc-André Bombeck ec2c067e60 fix(retention): keep a tombstone while a live record still points at it
The nightly purge nulled every non-cascading key into a purged row, so a
live visit lost the practitioner it was with, a live dose its visit, a
symptom its condition. Each purge entry now lists the keys that do not
cascade, and a tombstone stays while a live row holds one. The register
test derives those keys from the schema's relations, so a new nulling key
into a purged model fails until it is listed.
2026-10-20 16:01:13 +02:00
Marc-André Bombeck 4a0db28713 Merge branch 'fix/v1438-api' into release/v1.43.8 2026-10-20 15:39:39 +02:00
Marc-André Bombeck c292c4a39e test(api): fix the bulk-route fixture and stub the write ceiling where the limiter is mocked 2026-10-20 15:36:13 +02:00
Marc-André Bombeck 44f3181998 fix(sharing): re-read a Guardian's grant under the record lock
Renouncing a Guardian grant read the grant before taking the managed
record's lock. When the Guardian's own account deletion held the lock
first and ended the grant, the count under the lock then refused with
"add another Guardian" for a grant that was no longer active. The grant
is now re-read under the lock, so the renunciation answers that it has
already ended (or is gone) instead.

The race test accepts the already-ended answer beside 200 and 404, since
which side wins depends only on timing.
2026-10-20 15:36:13 +02:00
Marc-André Bombeck fed7090702 Merge branch 'fix/v1438-ui2' into release/v1.43.8 2026-10-20 15:23:22 +02:00
Marc-André Bombeck 6019ecbd4d fix(ui): show mood tags and document titles in full
Mood tags are the person's own words and were cut to one line in the list;
they wrap now. A document title wraps to two lines with hyphenation and
carries its full text in title past that; the filename line keeps its
truncation, which already shows the full name on hover.
2026-10-20 15:17:38 +02:00
Marc-André Bombeck 3d494697f7 fix(health-score): wrap a long pillar name instead of cutting it
"Koerperfettverteilung" ended in an ellipsis in the score panel. The label
now wraps inside its fixed track with hyphenation, so the row grows a line
and the fill bar keeps its width.

The geometry check claimed to measure every locale but rendered each one
with the English fallback catalog, so no German name was ever laid out.
It now primes every catalog and asserts that a name is never cut, takes at
most two lines and never widens its track.
2026-10-20 15:17:37 +02:00
Marc-André Bombeck e312d99162 fix(dashboard): reserve two label lines on trend tiles instead of truncating
The strip tile pinned its label to one line so the value rows share a
baseline, and cut anything longer: uppercase compounds and glucose
contexts ("HERZFREQUENZVARIABILITAET", "GLUKOSE, VOR DEM SCHLAFEN")
ended in an ellipsis. The heading row now reserves two lines at a fixed
height, so the baseline promise holds and the label wraps at a syllable;
a label that would need a third line clamps with its full text in title.
The skeleton reserves the same height.
2026-10-20 15:17:37 +02:00
Marc-André Bombeck 7af2e3d29a fix(ui): let tile and card titles wrap instead of cutting them
Seven tiles passed titleClassName="truncate" to TileHeader, so German
compounds ended in an ellipsis on a narrow tile ("Erholungs-..."). The
title is the only thing naming the figure. TileHeader now owns the wrap
(min-w-0, break-words, hyphens-auto from the document lang) and has no
class override left to truncate with; CardTitle carries the same
break-words/hyphens-auto. A guard refuses truncate or nowrap on any
CardTitle or TileHeader in the tree.
2026-10-20 15:17:37 +02:00
Marc-André Bombeck c245a3f2af fix(sleep): list only the stages the window shows in the composition legend
The legend lists every mounted bar, filled or not, so a week of staged
nights still showed the green "Asleep" swatch for a band that appears in
no column. Each stage band now mounts only when the visible window holds
minutes for it, as the nap band already did. Columns are unchanged: an
empty band drew nothing.
2026-10-20 15:09:47 +02:00
Marc-André Bombeck 74244efbe1 fix(targets): label the systolic bar on the blood-pressure target card
The card stacks two range bars for blood pressure. The diastolic bar
carried its label; the systolic one had none and no key to render, so the
pair read as one unnamed bar above a "Diastolic" one. Both bars now name
their reading (new key targets.systolic in every locale).
2026-10-20 15:07:51 +02:00
Marc-André Bombeck a934e948d8 fix(units): show every measurement unit in the reader's language
The vitals tiles printed the canonical storage token beside the value, so
a German screen read "15,4 breaths/min". The list view had a private map
for a few count types; every other path passed the token through.

useUnitDisplay().unitFor now resolves the token through one module
(lib/measurements/unit-label): word tokens map to bundle keys, "celsius"
to its symbol, kind markers (score, rating, count, event, level, ratio) to
nothing. The list, the day view and the vitals tiles all read it. A guard
walks every measurement type and every display source and refuses a unit
that is neither a shared symbol nor resolved through the bundle.
2026-10-20 15:07:11 +02:00
Marc-André Bombeck c73c6a7b1e refactor(api): drop the list query re-exports nothing imports 2026-10-20 14:17:36 +02:00
Marc-André Bombeck 534523c462 test(guards): let the deprecation registry name the route it retires 2026-10-20 14:13:44 +02:00
Marc-André Bombeck 1d9758e3e8 Merge branch 'fix/v1438-db' into release/v1.43.8 2026-10-20 14:09:44 +02:00
Marc-André Bombeck 08987e2cb2 Merge branch 'fix/v1438-ui' into release/v1.43.8 2026-10-20 14:09:44 +02:00
Marc-André Bombeck d78bcf1937 fix(mcp): answer a failed tool with a code, not the exception text
A tool that threw was answered by the SDK with error.message, so a
database failure reached the model and the chat as Prisma's prose: the
query, the column names, sometimes a value from the record. The tool call
now returns a fixed sentence, the code mcp.tool.failed and the request id;
the exception goes to the wide event, through its redaction, or to stderr
on the stdio entrypoint.
2026-10-20 14:06:44 +02:00
Marc-André Bombeck c402e23656 fix(mcp): say when a tool's list was cut
get_ecg_recordings stopped at 200, get_visits at 50, the booked
appointments in get_preventive_care at 25, and search at its per-source
scan, all without a word, so a model read the last row as the oldest there
is. capped() reads through listPage with the same semantics as the REST
list cap: one row past the limit, truncated true only when that row exists.

The ECG and visit results carry truncated and limit, the preventive-care
result carries appointmentsTruncated and appointmentsLimit, and search
carries truncated with the sources that were cut, on every page. Every
capped read orders by its id as a tiebreaker; list-page-guard now checks
capped() reads too, and accepts a distinct read ordered by its own key.
2026-10-20 14:06:43 +02:00
Marc-André Bombeck d0677adac9 perf(bundle): keep zod out of the documents and Coach client routes
Both routes reached the schema library through a constant: the list of
document kinds, the list and bulk limits, the attachment cap, the Coach
data clusters and preference defaults, the conversation title length,
the scope source list and the report leaf order. Each sat in a schema
module, so importing the number shipped the library. The constants now
live in zod-free modules (lib/documents/inbound-constants,
lib/ai/coach/prefs-constants, lib/ai/coach/scope-sources,
lib/report-selection/catalogue) and the schema modules re-export them
for server code; the Coach page checks its scope parameter against the
closed list instead of parsing it. A guard walks each route's static
client graph with the TypeScript parser and fails on a zod import.

Two guards follow earlier commits in this branch: the fetch-limit
allowlist names the shared document picker sheet, and the card density
test states a floor on the cards it finds.
2026-10-20 14:04:06 +02:00
Marc-André Bombeck 08d731fca9 fix(api): read a deprecation day through the date-only helper 2026-10-20 13:57:50 +02:00
Marc-André Bombeck 9fda88586c feat(api): date deprecations and log which builds still call them
/api/feature-flags answered Deprecation: true and nothing else: a client
learned the route was going, never when, and the log could not tell which
builds still called it. deprecated() (src/lib/http/deprecation.ts) applies
one reviewed notice per route: Deprecation as an RFC 9745 date, Sunset as
an RFC 8594 HTTP-date, the successor Link, and a wide-event annotation
with the client build read from the User-Agent.

feature-flags was deprecated on 2026-09-24 (v1.39.0); its sunset is
2027-03-31. The headers are declared on the operation, and a test holds
every notice to a deprecated operation that answers through the helper.
2026-10-20 13:57:11 +02:00
Marc-André Bombeck 39d23dbb7d fix(api): meter every authenticated write where the caller is resolved
169 of 364 mutating handlers met no rate limit, every PATCH and DELETE on
an [id] among them. The shared per-account write bucket (300 a minute,
keyed on the acting account) was called by hand in a dozen creates; it is
now charged by the identity resolvers for any POST, PUT, PATCH or DELETE,
once per request, so a route is metered by resolving its caller.

The bulk ingest routes the native client syncs through in bursts
(measurements, workouts, intake, mood and cycle bulk, nutrients, the ECG
sweep) declare writeBucket: "own" and keep their dedicated ceilings, so a
large HealthKit backfill cannot lock the person out of logging a dose. A
read-over-POST stays free.

write-rate-limit-guard fails on any mutating route that neither resolves
its caller through a metering resolver nor calls a limiter, unless it is
on a short reasoned exception list; an opt-out must be on its own reviewed
list and call its own limiter.
2026-10-20 13:53:54 +02:00
Marc-André Bombeck 3e9594b3b8 test(guards): read whole calls in the Bearer scope guard
The guard's matchers stopped at the first closing parenthesis and knew one
spelling of a Bearer lookup. requireRecordAuth(need(), "x", { scope: X })
read as naming no scope, a findFirst or findUniqueOrThrow by tokenHash was
invisible, and the /mcp route sat on the allowlist only because a comment
named the resolver.

Calls are now parsed whole, comments stripped, with the first requireAuth
argument read as the scope and any later options ignored. A Bearer
resolution is the shared resolver, the MCP edge's wrapper, any apiToken
find whose arguments name tokenHash, or a raw SELECT by token_hash. The
stricter matcher found one resolver the guard never saw: the local stdio
MCP entrypoint, now on the allowlist with its reason. Planted cases prove
each matcher fires.
2026-10-20 13:53:54 +02:00
Marc-André Bombeck 3bae93256d chore(settings): retire the unused IntegrationStatusPill
Three status pills paint a connection state: HealthBadge on the shared
integration card, the notification channel badge, and
IntegrationStatusPill. The first two describe different state models
and stay. The third had no production caller since the integration
cards moved to HealthBadge; it goes with its test and the nine strings
only it used. formatRelative, which two cards still import, moves to its
own module.
2026-10-20 13:53:51 +02:00
Marc-André Bombeck cc190c4710 refactor(documents): one DocumentPickerSheet behind both vault pickers
Linking documents to an illness episode and choosing documents for a
share link carried the same sheet as two copies: the same endpoint and
query key, the same debounced search, skeleton, error and empty states
and toggle rows. DocumentPickerSheet owns that body; each picker keeps
what a toggle means (an immediate link write, or a capped local choice
posted with the share link).
2026-10-20 13:50:40 +02:00
Marc-André Bombeck 3cc3829fba fix(ui): card density is a Card prop, and compact is compact on desktop
Nineteen cards spelled the compact tier by hand as gap-2 py-3 md:py-4,
which left the base md:gap-6 in place: a compact card opened a 24 px
gap between header and body on a desktop. Card takes density="compact"
now and pins the gap on both breakpoints; every hand-written compact
card, the priority card, the document card and the sample briefing
move onto it, and a test refuses the class list on a Card.

The precision guard on the steps page now reads rendered text only:
the page header's grid template (minmax(0,1fr)) matched its pattern.
2026-10-20 13:49:25 +02:00
Marc-André Bombeck 6885377f35 fix(ui): four hand-rolled page titles go through PageHeader
The insights sub-page shell (text-xl sm:text-2xl semibold), the
medication detail, the check-in screen and its result (semibold) each
read a step lighter than every other page title. They now render
through PageHeader, which gains a focusable heading with a visible ring
for screens that move focus to their title, and an accessory slot
beside the H1 that stays outside its accessible name. A structural test
lists where a different H1 is the point (heroes, sign-in, error and
prose pages, the first-run steps) and refuses any other.
2026-10-20 13:40:40 +02:00
Marc-André Bombeck 9d44d1c47f feat(lint): no-raw-card-shell, and the card shells it found
UI standards §1 asks every surface to compose Card; the new
healthlog/no-raw-card-shell rule refuses bg-card + border + rounded-xl
on one literal under src/components and src/app. The files where a
shell is the point (shell primitives, the hero, popovers, the chart
frame its skeleton and error state share, table frames, the sign-in and
first-run screens) are listed with their reason in eslint.config.mjs;
there is no inline escape. The shells that were drift now compose a
primitive: the notification matrix's phone cards (Card with a real
header instead of a p-4 box with a hand-rolled one), the achievements
stat tiles (TrendTile), the recent achievements card (Card, its view
all link an InlineAction), the cycle chart and insights loading
skeletons (Card).
2026-10-20 13:37:00 +02:00
Marc-André Bombeck eebe4019af fix(insights): ring tiles use TileHeader
The wellness score tiles and the cycle ring tile drew their own icon
and title row instead of TileHeader; the skeleton of the score tile was
a hand-rolled card shell and now composes Card.
2026-10-20 13:36:59 +02:00
Marc-André Bombeck f2af0c9650 refactor(insights): one CrossLinkCard for the card-sized page pointers
The VO2 max pointer on the pulse page and the ECG pointer on the heart
pages were two hand-rolled link cards; the ECG one had no md:p-6 step
and sat narrower than every card beside it. CrossLinkCard composes Card
(TileHeader, one meta line, the call to action) inside the link that
carries the focus ring.
2026-10-20 13:36:58 +02:00
Marc-André Bombeck e7ee416e19 refactor(ui): move 62 hand-written empty states onto EmptyState
The admin sections, the cycle, illness, medication, mood, records and
settings cards, the pickers and dialogs said nothing-here-yet in a
hand-written muted paragraph in five sizes and three alignments. List
and section states now render through EmptyState (plain, compact; the
three with a next step carry it as the action), and the six chart
bodies through ChartEmptyState at their former height. The thirteen
that stay are a field without a value or a hint inside an editor, not
a list state. The ratchet ceiling drops from 75 to 13.
2026-10-20 13:31:02 +02:00
Marc-André Bombeck 8db7c71b0e fix(api): one helper for list pages, exact cut signal, total order
listPage (src/lib/http/list-query.ts) reads one row past the limit and
cuts it back, on top of the existing list-cap signal rather than beside
it. Every capped list now gets its page there, and every read passed to
it orders by something ending in the row id, so rows that share a sort
value cannot repeat or vanish at a page boundary.

- practitioners, vaccinations and encounters were cut at their take
  with no signal; they now answer meta.truncated and accept offset, and
  their web readers walk the pages instead of stopping at the first
- symptoms/events reported hasMore on a page that was exactly full; it
  is now true only when a row past the page exists
- the document list's hand-rolled limit + 1 goes through the helper
- security-activity answers an out-of-range limit with the 422 its
  published schema already promised instead of clamping it

list-page-guard binds every apiListSuccess route to listPage, forbids
hand-cut lists and length-derived hasMore, and requires the id
tiebreaker in every listPage read.
2026-10-20 13:29:14 +02:00
Marc-André Bombeck fe021c0ffd test(ui): freeze the hand-written empty states with a ratchet
Seventy-five surfaces say nothing-here-yet in a hand-written muted
paragraph instead of EmptyState. The ratchet counts them, fails on a new
copy, and fails when a site moves over until its ceiling is lowered.
2026-10-20 13:28:09 +02:00
Marc-André Bombeck a97abe64b9 refactor(insights): one TrendTile frame for the trend tiles
The dashboard KPI tile, the derived-insight sparkline tile, both
skeletons and the three still-building states of the vitals board each
spelled out the same tile frame by hand, and some clipped their content
while others did not. TrendTile owns the frame; the vitals board's
three building states share one BuildingTile. The rendered classes are
unchanged.
2026-10-20 13:26:47 +02:00
Marc-André Bombeck 3aadc03424 refactor(insights): one MetricCrosstab for the three crosstab boards
The mood tag, rated mood factor and cycle phase boards were three
copies of the same list: pair label, neutral signed delta, confidence
chip, detail line, optional dismissal. MetricCrosstab owns that shape;
each board keeps its labels, unit conversion and number formatting.
The cycle board's own confidence strings were identical to the shared
ones and are gone.
2026-10-20 13:24:01 +02:00
Marc-André Bombeck ee2bc8019c feat(ui): ScorePill for every grade chip
Confidence levels, the weekly score gain, target bands and lab range
flags were drawn by hand in two looks: a /15 wash without border in the
mood boards, a /10 wash with a /40 border on the correlation and trend
cards, and medium confidence was blue on one card and amber on the
next. They now render through one ScorePill (rounded, xs, soft wash,
tone success / info / warning / destructive / neutral, optional tabular
figures) with one confidence scale. The target band keeps its own
AA-tuned tokens in the shared anatomy. A structural test refuses a new
hand-rolled grade chip.
2026-10-20 13:24:00 +02:00
Marc-André Bombeck f6cf7c5e6b fix(docker): give the health check room for a slow migration
The entrypoint migrates before the server listens, and a migration that
waits out a held lock retries for up to about eight minutes, while the
health check marked the container unhealthy after two. The start period
is now ten minutes in the compose file and the image; the first answering
probe still ends it, so a normal boot reports healthy as before.
2026-10-20 13:19:48 +02:00
Marc-André Bombeck 03f62c330b chore(schema): drop the stale pending-consumer markers on LifeEvent
The timeline lane, the day view and the life-events service read every
LifeEvent column; the column-reader sweep counts all six as consumed once
the markers are gone.
2026-10-20 13:18:13 +02:00
Marc-André Bombeck 566fd8c6b3 feat(jobs): clean expired sessions, tokens and challenges nightly
Sessions, refresh tokens, access tokens, trusted devices and the
second-factor and passkey challenges were never removed after they
expired, so the sign-in tables were mostly dead rows.

auth-ledger-cleanup deletes a session, token or trusted device 30 days
after it expired, which keeps every row the login throttle's 30-day
known-address check and refresh reuse detection can still read; a revoked
token without an expiry stays listed. Challenges go a day after expiry.
Security activity reads the audit log and loses nothing.
2026-10-20 13:17:41 +02:00
Marc-André Bombeck 8e07cb179f feat(jobs): give every soft-delete model a tombstone retention
Twenty of the twenty-three soft-delete models were never purged: a deleted
lab result, visit, allergy or cycle stayed in the database, in every backup
and in every key rotation for as long as the instance ran.

soft-delete-retention.ts holds one entry per model that has a deletedAt
column, derived from the schema by a guard with a counter-check. The sync
domains and the document vault keep their purges; every other model is
purged by a new nightly soft-delete-purge job after the sync feed's
75-day horizon, per account under the restore lock and in bounded batches.
A cycle start folded into a live cycle stays while that cycle does, and a
removed coach fact stays for the coach message retention, since stored
trails are withheld against it.
2026-10-20 13:17:41 +02:00
Marc-André Bombeck 2265763160 test(api): parse every published GET against its schema
The operation list is derived from the registry: every path with a get
whose 2xx publishes an application/json schema. Each is called through its
route module as one seeded account and the body is parsed with that schema;
a missing required field and a field the parse strips both fail. Operations
that need an external service sit in a reasoned exception list, and a floor
on the number of checked operations keeps the set from shrinking quietly.
2026-10-20 13:16:22 +02:00
Marc-André Bombeck aecf6f91b1 fix(api): publish what the GET handlers send
A response parsed against its own registered schema disagreed in thirteen
operations. Where the handler was right, the schema now says so; where the
handler had drifted from its siblings, it follows them.

Schema:
- measurements list and detail declare aggregationProvenance
- series-batch and sleep night stages are partial records: only the
  requested types and the recorded stages are present
- medications declare deliveryForm, the injection-site fields, externalId,
  externalSource, the low-stock bookkeeping, schedules[].doseWindows and
  nextDueScheduleId on the list
- notification prefs declare coach.nudgeAiComposed and measurementReminder
- mood insights correlations declare suppressed
- record settings profile.unitPreference is nullable, as stored
- insights/comprehensive describes the computed overview it returns rather
  than the model digest fields it never sends

Handler:
- GET /api/mood-entries/{id} shares the list projection and now carries
  tagKeys and ratedFactors
- GET and PATCH /api/custom-metrics/{id} carry latest and entryCount
- /api/analytics no longer leaks anomalyCount through the per-night sleep
  summary and the glucose meal-context split
2026-10-20 13:16:22 +02:00
Marc-André Bombeck ab3f21c58c refactor(ui): one Segmented control instead of four
The timeline's Segmented, the document sources' SegmentedChoice, the
medication detail's SegmentedToggle and the icon-only ViewToggle were
four implementations of the same control in two looks, two at 36 px on
a phone. ui/segmented.tsx replaces all four, plus the hand-rolled range
rows above the lab and custom metric charts, the mood dimension window
and the inventory quantity mode: a muted track with the chosen segment
raised, radio semantics with roving focus by default, pressed-button
semantics for view switches and form sheets, icon-only segments, and
44 px per segment on a coarse pointer. A test holds the definition to
that one module.
2026-10-20 13:15:29 +02:00
Marc-André Bombeck c849619bee fix(settings): one action row on the Dawarich card
The connected card showed the home country field and a second row of
buttons (Choose home, Save) above the card's own row of details, menu
and primary action. The home editor now opens from the card menu, or
from a prompt while the home place or country is missing, and carries
its own Cancel and Save only while it is open.
2026-10-20 13:15:29 +02:00
Marc-André Bombeck ff583104a8 fix(ui): wrap lab names and token names between words
The lab list wrapped a long analyte name with overflow-wrap: anywhere,
which cut a word that would have fit on the next line in the middle
(Hämo|globin). It now wraps between words and hyphenates in the page
language, cutting only a word wider than the column. Token and client
names in the API and MCP settings used break-all for the same reason
and now wrap the same way; addresses and codes keep break-all.
2026-10-20 13:15:28 +02:00
Marc-André Bombeck 03aec3ec12 perf(db): index every acting foreign key and drop prefix indexes
Twenty-one foreign keys with ON DELETE CASCADE or SET NULL had no index
leading with their column, so deleting a parent scanned the child table
once per row; medication_intake_events.medication_id was the one an
account delete paid for. Seventeen small-table indexes and the measurements
(user_id, type, measured_at) index repeated the leading columns of another
index and only cost writes.

Migration 0415 adds and drops the small-table ones in one DO block under a
bounded lock wait; 0416 drops the measurements index concurrently, in a
file of its own. An integration test holds the schema to both rules from
the catalog, each with a counter-check, and compares the plans of the
user-and-type query shapes on a seeded table with and without the dropped
index. The lock-timeout guard now also requires a CONCURRENTLY statement
to stand alone in its file.
2026-10-20 13:07:24 +02:00
Marc-André Bombeck 60be63bcd1 feat(ui): InlineAction for small text actions, with a 44 px touch floor
Thirty-three hand-rolled underlined text links and buttons (Open
schedule, Select all, View all, Retry, Learn more, the device-code copy
action, ...) carried three different focus rings, none on half of them,
and a 16 px target on a phone. They now render through one InlineAction
primitive: tone primary / muted / foreground, size xs / sm, a focus ring,
and min-h-11 on a coarse pointer so the desktop layout does not move. A
structural test refuses a new hand-rolled copy.
2026-10-20 13:03:15 +02:00
Marc-André Bombeck ce8074ad08 fix(ui): keep a wrapped label's colon on its last word
The label primitive was an inline flex box, so its ::after colon was a
flex item of its own: when a long field name wrapped in a narrow column,
the colon floated beside the whole text block instead of following the
last word. The label now lays out inline and the colon wraps with the
text. Call sites that set flex themselves keep their own layout.
2026-10-20 13:03:12 +02:00
Marc-André Bombeck 41de0ca126 refactor(medications): drop the compliance rollup table that nothing reads
medication_compliance_rollups lost its last reader when compliance moved
to the schedule-anchored computation, yet every dose write still upserted
it, a boot backfill refolded it, and the wipe and backup plans carried it.

The per-dose hook keeps the one derived row that still hangs off a dose
day, the supplement nutrients, under dose-day-refresh. The pending-row
mints (today projection, reminder) no longer call it, since an untaken
dose adds no nutrients. The import job finishes without its per-day
recompute phase; its progress keeps touchedDays and rollupProcessed on
the wire, inert and documented as deprecated. The backfill queue is
retired on boot and migration 0414 drops the table under a bounded lock
wait.
2026-10-20 13:01:45 +02:00
Marc-André Bombeck 537bad4212 fix(ui): print every displayed decimal in the reader's notation
toFixed() prints a full stop in every language and a locale-less
toLocaleString() follows the browser, so a German reader saw 37.5 next
to 70,5 on the same screen. Fever in the illness log, the cycle phase
crosstab, custom metric values, nutrient day totals, the mood chart and
heatmap tooltips, the mood takeaways, the elevation chart axis, the BP
30-day average, the host metrics clock and the encryption row counts now
go through useFormatters().

The new healthlog/no-unlocalised-decimal rule refuses toFixed(),
toPrecision(), a locale-less toLocaleString() or Intl constructor, and a
quotient rendered straight into text under src/components and src/app.
Rounding through Number(...) stays legal; SVG path data and number-input
values carry a reasoned next-line directive.
2026-10-20 12:55:07 +02:00
Marc-André Bombeck 56daa57350 refactor(format): one byte formatter for every surface
The document vault and the admin area each carried a formatBytes; the
same backup read 1.25 MB in one and 1.2 MB in the other, and only the
vault knew gigabytes. The vault rule (one decimal below 100, up to GB,
digits in the reader's locale) now lives in src/lib/format/bytes.ts and
takes the locale, so the server-rendered share page uses it too. A test
holds the definition to that one module.
2026-10-20 12:55:06 +02:00
Marc-André Bombeck b90c82cba3 fix(migrate): run migrate deploy in a UTC session
Prisma ignores PGTZ, so a database whose default zone is not UTC ran every
migration in that zone, and a migration mixing timestamp and timestamptz
wrote shifted values. The boot wrapper now appends -c TimeZone=UTC to the
options parameter of DATABASE_URL for its Prisma calls, honouring
DATABASE_SESSION_OPTIONS_DISABLED like the app does.
2026-10-20 12:48:17 +02:00
Marc-André Bombeck 67cc56f707 Merge pull request #1252 from MBombeck/release/v1.43.7
release: v1.43.7
2026-10-20 11:38:37 +02:00
Marc-André Bombeck 1e3eed23ab chore(release): v1.43.7 2026-10-20 11:14:20 +02:00
Marc-André Bombeck 71fa8ad7b3 Merge branch 'fix/v1437-r3' into release/v1.43.7 2026-10-20 11:13:27 +02:00
Marc-André Bombeck 5a6c78efe5 fix(jobs): give the full rollup fold an expiry, a budget and a lock
The fold was declared short on the strength of its 60-second
statements, but four aggregates and four persist transactions of up
to 120 s come to about thirteen minutes at worst under the defaults,
and nothing bounds them once an operator lifts the statement timeout.
Past pg-boss's fifteen-minute default the retry folded the account a
second time. The send now carries an hour's expiry, the fold stops
between granularities on the job's budget, and the binding holds a
per-account lockedPass outside the lane. The runtime guard now requires
every heavy queue to be declared long.
2026-10-20 11:06:17 +02:00
Marc-André Bombeck 7e6ed676e0 docs(insights): describe the ECG section's failure row as it is
The page comment still said the overview section unmounts silently on
a read error; it shows a QueryErrorRow, which is what the UI standards
ask of a supplementary block. The page keeps the full error card.
2026-10-20 11:03:18 +02:00
Marc-André Bombeck bd00845be3 fix(strava): clear the backfill markers when the connection is removed
Disconnecting Strava, or deleting its credentials, cleared the tokens
and the athlete but kept stravaBackfillCompletedAt and
stravaBackfillResumeBefore. A later connection inherited them: a
completed marker kept its history from being read, and a resume point
started its first walk below the previous account's oldest activity.
Both paths now clear one shared set of columns.
2026-10-20 11:02:50 +02:00
Marc-André Bombeck 742705eadd docs(audit): say why two import refusals stay audit rows
The export imports log a refused upload as a signal. The admin import
and the medication intake import kickoff keep audit rows on purpose:
one is an administrator acting on another account, the other is shown
to the record's owner in the activity feed.
2026-10-20 11:01:19 +02:00
Marc-André Bombeck 9a5c2b8ec1 fix(api): keep data.truncated on the account activity feed, deprecated
v1.43.6 shipped truncated as a required field of the activity
response's data, and moving it to meta alone would break a client
built against that in a patch release. data.truncated is back with the
same value as meta.truncated and is marked deprecated in the spec; the
web client keeps reading meta.
2026-10-20 11:00:25 +02:00
Marc-André Bombeck 2be262d574 refactor(api): drop apiPostList, which no capped list reads
Every capped route is read with GET, so the POST reader had no caller
and failed the dead-code gate. The list-cap guard now binds a capped
route to apiGetList alone, which is the reader that exists.
2026-10-20 10:58:03 +02:00
Marc-André Bombeck b08d527045 test(auth): mint session fixtures in the shape the lookup accepts
The session time-zone test stored sessions under readable secrets that
the cookie shape check now refuses before the query, so it tested the
refusal instead of the expiry. A shared helper mints hls_ plus 32 hex
bytes, and the two route tests that set a session cookie use it too.
2026-10-20 10:56:55 +02:00
Marc-André Bombeck 4f02004146 test(guards): derive the rounding guard's directories from the connection keys
The guard kept its own provider list and its own comment stripper next
to the shared PROVIDER_DIRS and stripComments helpers, so a new
integration could slip past it. It now uses both, plus the file
importers, which are not a connection.
2026-10-20 10:55:42 +02:00
Marc-André Bombeck c50007ef61 fix(day): read the notable history through the fenced cache path
The comparison history blocks were read with cache.get and stored with
cache.set, outside the single-flight slot that an invalidation fences.
A read that spanned a write cached the days from before it for the
block's whole TTL, and two concurrent reads of a block both queried.
Each block now goes through cache.wrap; the blocks missing at the time
still share one read of their span.
2026-10-20 10:55:09 +02:00
Marc-André Bombeck 19732b5aa1 fix(jobs): let a sliced import give way to a waiting heavy job and warn on long lane waits
A job sent back by the heavy lane now leaves word that the lane is
wanted. The holder, and only the holder, sees it through laneWanted();
the Google Health backfill asks it where its slice budget already
stops resumable walks, after the first page, ends the slice there and
queues the rest past the deferred job's return. Holders that cannot
stop part-way run to their end as before.

The way back to the queue now returns when the oldest deferred job was
first queued; a job that has stood in line for two hours is reported
once per holder as job.heavy_lane.waiting_long at warn, so a lane held
for hours reaches the alerts.
2026-10-20 10:52:54 +02:00
Marc-André Bombeck 9c89503019 fix(migrations): bound 0412's lock wait where it takes effect and retry a lock timeout on boot
migrate deploy runs a migration file statement by statement, so the
top-level SET LOCAL lock_timeout in 0412 did nothing: the migration
waited as long as the lock was held, with every write to measurements
queued behind it, and a file that failed half-way stayed half-applied.
0412 now takes its locks inside one DO block with a statement-scoped
timeout, which rolls back whole, after an idempotent CREATE OR REPLACE.

The entrypoint runs migrate deploy through scripts/migrate-deploy.mjs.
A 55P03 failure of a migration marked -- @retry-on-lock-timeout is
marked rolled back and retried after 15/30/60/120/240 s, read from
Prisma's report or, after P3009, from the stored row. Every other
failure stays fatal. A guard holds migrations to the shape that makes
the marker true, and 0411/0413 add their columns with IF NOT EXISTS so
a manual re-run is safe too.
2026-10-20 10:45:14 +02:00
Marc-André Bombeck 47f20b773d fix(auth): answer 503 for every session lookup failure that is not about the value
The catch kept a denylist of "store unreachable" signals and folded
everything else into a missing session. The pool's own connect timeout
carries no code, and 53300, 53200 and 55P03 are about the store, so all
four answered 401 and signed the client out. The shape check already
refuses crafted values before the query; the catch now keeps only a
short allowlist of input refusals (SQLSTATE class 22, Prisma
validation) as null and raises 503 for the rest.
2026-10-20 10:36:16 +02:00
Marc-André Bombeck 2b8defcbd2 test(insights): mock the list reader the ECG and rhythm reads now go through 2026-10-20 10:22:26 +02:00
Marc-André Bombeck e5b7eebe64 Merge branch 'fix/v1437-r2-jobs' into release/v1.43.7 2026-10-20 10:17:01 +02:00
Marc-André Bombeck 86eb743a69 Merge branch 'fix/v1437-r2-errors' into release/v1.43.7
# Conflicts:
#	src/components/charts/health-chart.tsx
#	src/components/insights/ecg-cross-link.tsx
#	src/components/insights/ecg-section.tsx
#	src/components/insights/rhythm-events-card.tsx
2026-10-20 10:17:01 +02:00
Marc-André Bombeck cd0bbec66a Merge branch 'fix/v1437-r2-data' into release/v1.43.7 2026-10-20 10:11:09 +02:00
Marc-André Bombeck 3e6a33df35 fix(i18n): one sentence each for the read-failure lines
The ended-session and spent-budget lines each ran to two sentences; the
action sits on the button, so each is one sentence now. The sign-in
label's key is common.signIn, matching what it says. The French strings
added in this release use vous like the rest of the bundle, and the
Korean sleep-score gap labels take the polite form the bundle uses.
2026-10-20 10:10:57 +02:00
Marc-André Bombeck 923249dbf6 fix(ui): draw every failed read through a read-failure primitive
Around thirty surfaces drew a failed read their own way: a bare
destructive paragraph with no retry, a muted line saying the data was
still being learned, a skeleton that never ended, an error card nested
in a tile, or nothing at all. None of them told an ended session from a
refusal. They now use QueryErrorRow or QueryErrorCard with the query's
error and a retry, inset where a card already frames them, and keep
their card and title where the page would otherwise jump. Hydration and
caffeine now fail the same way.

The recovery page no longer points to Integrations while Oura's first
resilience level is simply on its way.

A guard reads every read hook's isError branch and refuses hand-rolled
markup there, and refuses a framed error row inside a card.
2026-10-20 10:10:49 +02:00
Marc-André Bombeck a1b56f0b75 fix(visits): keep the since-last-visit card on a failed read
A failed read replaced the card and its title with a bare row, so the
section changed height and the reader lost what had failed. The card now
keeps its header and shows the error inside. The visit card's header
moves onto TileHeader instead of its own icon and title row.
2026-10-20 10:10:36 +02:00
Marc-André Bombeck 3fbd16a0a6 fix(ui): let the chart error card decide from the error too
ChartErrorState took no error, so a chart whose read was refused or
whose session had ended offered a retry that could not help. It now
takes the error and branches the way QueryErrorCard and QueryErrorRow
do, the chunk boundary hands it the error it caught, and the error-prop
guard covers it.

QueryErrorRow gains an inset form for a failure inside a card body, and
its frame takes the md:p-6 step. QueryErrorCard drops its fixed px-6 for
the card's own inset and both icons move onto the size scale.
2026-10-20 10:10:31 +02:00
Marc-André Bombeck 985372f711 fix(insights): count failed fetches toward a bounded poll's ceiling
The status and advisor polls counted attempts on dataUpdateCount, which a
failed fetch does not move, while the last good payload stayed in place and
still said preparing. A request that kept failing polled an open page for
as long as it stayed open. Attempts now include the failed fetches.

The poll inventory moves from a comment into a guard held to every
refetchInterval in the tree; four polls were missing from the comment.
2026-10-20 10:06:11 +02:00
Marc-André Bombeck dc5120b59a feat(lists): read and show the list cap on every capped list
Twelve routes answer a capped list with meta.truncated / meta.limit on
the envelope, and the web client read only data, so no surface said a
list had stopped short. apiGetList / apiPostList are now the one client
reader of the cap and ListCapNotice the one line that shows it; every
capped list on the web reads its cap and shows the notice under the list,
the paged readers (side effects, illness episodes) stop on the route's
answer instead of a full-page guess, and the ECG list moves its five
readers onto one hook. The record activity feed sent its cap twice, in
data and on the envelope; it rides the envelope alone now. A guard
derives the capped routes from the tree and binds each to a client that
reads its cap.
2026-10-20 10:04:11 +02:00
Marc-André Bombeck b12bece491 fix(import): log a refused upload as a signal, not an audit row
An upload refused for its size, a broken stream or a stopped worker is an
operational outcome, not something done to the account. The admin import
keeps its audit rows, since an admin acting on another account is.
2026-10-20 10:03:30 +02:00
Marc-André Bombeck 689796db33 fix(integrations): log a failed sync as a signal, not an audit row
The audit log is the trail of what a person or an admin did to an account.
Every failed provider sync wrote a row there, burying that trail, and kept
the provider's error message in plain text while the status row seals it.
A failed sync now emits an integrations.sync.failed warning with the same
counts and no message.
2026-10-20 10:03:30 +02:00
Marc-André Bombeck be79603d53 chore(guards): give the linear-fit guard a match floor and keep SQL out of the i18n scan
The linear-fit guard floored only its walk; it now also requires the
implementation's call sites it expects. The per-type readings query
aliased its driving relation t, which the i18n call-site scanner read as a
translation call; it is mt now, as in readLatestEver.
2026-10-20 10:01:30 +02:00
Marc-André Bombeck 537a73d3b2 refactor(measurements): drop the in-memory range aggregation nothing calls
It bucketed by UTC day and was described as the route's fallback; no route
has called it since the aggregation moved into SQL.
2026-10-20 09:58:03 +02:00
Marc-André Bombeck d91eb905fe refactor(compliance): require the zone a dose window is read in
The UTC reading of a window's HH:mm was right only for a UTC user, and the
one production caller always passes the zone.
2026-10-20 09:58:03 +02:00
Marc-André Bombeck 2f32a4458c fix(environment): end the nightly window on the person's today
The default lookback ended on the UTC date, so east of UTC the local today
stayed out of the window until the next night's run.
2026-10-20 09:58:02 +02:00
Marc-André Bombeck 2372fee376 fix(coach): count the daily question limit from the person's midnight
The limit on clarifying questions reset at UTC midnight, which is the middle
of the night in Europe and the afternoon on the American west coast. It now
counts from the start of the person's own day.
2026-10-20 09:58:02 +02:00
Marc-André Bombeck 6e54f33901 refactor(i18n): ask the catalogue for a sentence in one helper
`t()` answers a missing key with the key, so "does this code have a
sentence" was a comparison written inline at every site that wanted a
code's sentence or a fallback, twenty-odd times in several spellings.
translateOrNull, translateCodeOrNull and translateCode in
src/lib/i18n/translate-code.ts now hold that probe, every site uses
them, and a guard refuses the inline comparison anywhere else.
2026-10-20 09:53:31 +02:00
Marc-André Bombeck f4262e2cc6 test(guards): see a squared two-argument distance as a hand-rolled spread 2026-10-20 09:51:13 +02:00
Marc-André Bombeck 5cbe52cada test(guards): count the allowed sites per file instead of exempting the file
An exempted file covered every bare formatter or clock read written into it
later. Each entry now states how many sites it holds, and a file with one
more fails like any other.
2026-10-20 09:51:12 +02:00
Marc-André Bombeck 9b05d8c215 test(guards): derive the provider directories from the connection keys 2026-10-20 09:51:12 +02:00
Marc-André Bombeck 5fdddaec0a test(guards): strip comments through the shared scanner 2026-10-20 09:51:12 +02:00
Marc-André Bombeck 071d23537b fix(insights): read the mood fallback over the rollup's window
While the mood rollup tier is still empty the feature block falls back to
raw entries, and that fallback read one year where the rollup read five,
so the same block described a different history until the tier caught up.
2026-10-20 09:48:30 +02:00
Marc-André Bombeck 2f7f04ecab fix(auth): answer a malformed session cookie as no session
The session lookup turned every query error into a 503, so a cookie
holding a NUL byte reached the legacy id path, Postgres refused the
value, and an anonymous caller could produce server errors at will.

A cookie that cannot be a secret or a legacy id is now answered as no
session before the database is asked, and only an unreachable store
(connection errors, timeouts, an exhausted pool) is a 503. Any other
query error is answered like a value that matches nothing.
2026-10-20 09:48:23 +02:00
Marc-André Bombeck c300574a8e fix(api): read the error code before the status
A read-failure primitive decided from the status alone, so a 401 from a
step-up or re-proof gate would have read as an ended session and sent
the reader to sign-in. The code now decides first and the status is the
fallback for codes it does not know.

The two invitation routes answer an unknown name or e-mail with
sharing.invite.no_account. Both have a second 404 with a different next
step, and since every error now carries a code the clients match on it
instead of on the status. The status defaults move into a module the
client can import, and the contract text no longer says these arms
carry no code.
2026-10-20 09:48:15 +02:00
Marc-André Bombeck 5fd4333017 fix(insights): read snapshots by time and per type, not by a shared row count
The pulse and per-metric status snapshots read the latest 365 rows, which
for a dense pulse stream is a few hours, so their 30-day figures were
computed over part of one day; they now read at least the last 30 days.
The general overview read the newest 5000 rows across every type, so a
dense stream crowded the other types out within days; it now reads the
latest 365 of each type with one index descent per type. The sleep summary
in the analytics slice read one year of stage rows, so its all-time count
and its year-ago average fell short; it now walks the whole history in
chunks.
2026-10-20 09:47:20 +02:00
Marc-André Bombeck bfd8639178 test(cache): prove analytics coalescing per cell, not by the bucket total
The analytics bucket holds two cells per account since the summaries slice
got its own: the default body and the slice it composes. A cold mount
builds each once, so the bucket-wide miss counter reads 2, and the test
read that as a second builder run. It now counts builder runs per key, one
each under ten concurrent reads, and two each after a write invalidates
both.
2026-10-20 09:47:19 +02:00
Marc-André Bombeck 5d1d6aa176 perf(day): cache the notable history in fixed blocks
Each cell ran from a 90-day block boundary to today, so paging back through
days left a nearly full copy of the two years per boundary in an unweighted
bucket, and a cold day long ago read everything from its window to today.
The history is now cached in 90-day blocks that end before today (the block
holding today ends at today and carries it in its key), a window is put
together from the blocks it touches, and the blocks it misses are read in
one query. A cell holds at most one block of days per type.
2026-10-20 09:47:03 +02:00
Marc-André Bombeck b402c65f6a fix(demo): seal the baked insights cache and carry it across a migration
The demo seed wrote the cached insights payload into the readable column,
and the edge migration copied only that column, so a migrated demo lost
any sealed payload. The seed now seals it the way the app does and the
migration copies insights_cached_text_encrypted beside it.
2026-10-20 09:46:48 +02:00
Marc-André Bombeck f0c20151a9 perf(reads): ask the latest reading per type with one probe per type
The briefing's last-seen read and the MCP search presence probe grouped
every live row of the account to learn the newest instant or whether a
type exists at all. Both now use readLatestEver, one index lookup per
type. The latest-per-type guard also catches the Prisma spelling: a
groupBy by type with nothing but _max, or no aggregate, and no window.
2026-10-20 09:45:51 +02:00
Marc-André Bombeck 6eb7e508df test(measurements): a re-import that changes nothing keeps its syncVersion
The second import of the same export writes every column of the step row
as it already was (compared column by column), so the update is a no-op
and migration 0412 keeps updated_at and sync_version. The test expected the
version to move on every write; it now pins that it never goes backwards
and stays where the first import left it.
2026-10-20 09:45:19 +02:00
Marc-André Bombeck 05503e2b82 refactor(stats): one least-squares fit for every trend line
Six readers fitted a line by hand, four in the determinant form that
loses digits on a large x axis, and the two charts carried the same trend
function twice. linearFit / fitFromSums in lib/stats is now the one
implementation (centred sums, so a fit from points and one composed from
stored accumulators are the same arithmetic), trendLineByTime serves both
charts with the same two-decimal output, and a guard keeps hand-rolled
slopes out of the tree.
2026-10-20 09:43:47 +02:00
Marc-André Bombeck 0057b03699 refactor(openapi): derive the http code family from the status table
The catalogue listed the status defaults a second time by hand. It now
reads them from DEFAULT_ERROR_CODES, the table the handler applies.
2026-10-20 09:39:50 +02:00
Marc-André Bombeck c145843aa3 fix(insights): let a read's ApiError reach the failure primitives
Five queryFns caught the ApiError and threw a plain Error with a
translated line, so the read-failure primitives always saw a generic
failure: an ended session offered Retry instead of Sign in, and a
refused share drew an error card. The queryFns now pass the error
through; the message lives on the primitive. The ECG list also says a
failed read instead of rendering nothing.

The guard now also reads every queryFn and refuses a catch that
re-throws a plain Error.
2026-10-20 09:38:57 +02:00
Marc-André Bombeck c782fa060a fix(migrations): bound 0412's lock wait on measurements
CREATE TRIGGER waits for an autovacuum that is preventing wraparound, and
every write to the table queues behind the waiting request, the running
release's included. SET LOCAL lock_timeout fails the migration after ten
seconds instead, and the file rolls back whole.
2026-10-20 09:38:29 +02:00
Marc-André Bombeck a7f1e72bad fix(maintenance): leave vacuuming measurements to autovacuum
Migration 0412 starts autovacuum on the table at 5 % dead rows, and the
nightly check vacuumed at 10 %, a point autovacuum had always passed first.
Two triggers for one task only hid which of them did it. The nightly check
now measures and changes nothing: it warns when the dead rows reach four
times autovacuum's threshold, which means autovacuum is not keeping up, and
when the heap has outgrown its live rows. The operator's run keeps its
steps.
2026-10-20 09:38:29 +02:00
Marc-André Bombeck 0fe9d5d224 fix(import): round Health Connect values through importValue
The Health Connect import stored its unit factors' binary noise as is
(grams to kilograms, millis to minutes, the day sums) and rounded the
pulse means and nutrients privately. Every measurement value now takes
importValue at the write boundary, and importValue takes a precision for
the two places that need another one. The rounding guard now covers the
import directory and the scaled Math.round form.
2026-10-20 09:35:11 +02:00
Marc-André Bombeck d770f81278 fix(integrations): end a page walk on an empty cursor
WHOOP and Oura answer the last page with an empty next_token as well as
with null. The walk followed the empty string as a cursor, the client sent
no token, read the first page a second time and the walk ended as a repeat,
so the records came in twice and the backfill stayed incomplete for good.
An empty cursor now ends the walk, for every provider that walks through it.
2026-10-20 09:34:21 +02:00
Marc-André Bombeck 34eea153a8 fix(strava): resume an unfinished backfill where the last run stopped
One run reads at most forty pages of a hundred activities. A longer history
ended as incomplete with no note of how far it got, so every boot read the
same newest pages again, spent up to 190 of the instance's 200 requests per
fifteen minutes on them, and never reached the older activities.

The run now records the start of the oldest activity it read and sends the
next run for after the rate window; that run asks Strava for the activities
before it. A run that gets no further records nothing and sends nothing. The
place is cleared when the backfill completes.
2026-10-20 09:33:15 +02:00
Marc-André Bombeck 456976175b test(sql): allow the lane's clock read against pg-boss's timestamptz column 2026-10-20 09:32:34 +02:00
Marc-André Bombeck 29c5b5b83b fix(crypto): one read order for a sealed column beside its readable one
Notes, workout tracks and the cached insights payload each read their
ciphertext and legacy column their own way: the cache read the readable
column first, and the two that read the ciphertext first disagreed on a
value that does not open. readSealedOrLegacy fixes the order (ciphertext
whenever present, the readable column only without one, never as a stand-in
for a failed open) and makes each caller state what a failed open means.
2026-10-20 09:29:03 +02:00
Marc-André Bombeck 63bbf0931c fix(sleep): one day total for every sleep reader, and the latest night is a night
A day's sleep is the main night plus its naps (daySleepMinutes). The
series, the sleep score and the composition already read it that way; the
sleep list, the CSV export and the correlation sleep channel read the main
night alone, so a day of naps listed as 0 h and an average over the list
counted it as a night without sleep. The list now carries the day total in
value and the main night alone in the new mainAsleepMinutes field; the nap
caption reads as included.

GET /api/sleep/night without a date answered the latest day holding any
sleep, so a nap after the last night left the hypnogram with no night to
draw. It now walks back to the latest night with a main sleep; nap days
stay reachable by date.

The SQL night page bounded a night by its asleep sessions only, so an
in-bed-only session of the same night fell outside a chunked read that the
one-night read counted. The span now covers every session of the night.
2026-10-20 09:28:40 +02:00
Marc-André Bombeck bfeac46f06 fix(jobs): send a heavy job back to its queue instead of waiting in the handler
pg-boss counts a job's expiry from the fetch and ends the handler at it, so
a job that waited for the heavy lane spent its expiry in line: a long import
let the weekly backup expire through every retry, and an upload that waited
past its own expiry was failed, stopped counting as in use and lost its
staged file to the sweep. A job that finds the lane taken now returns to
created with start_after a minute ahead, at the same attempt, in one
statement, and runs when the lane is free.

The full-history integration drain joins the lane, and the guard checks that
every heavy queue's policy lets a job go back to created.
2026-10-20 09:25:52 +02:00
Marc-André Bombeck 3004f55875 fix(correlations): keep a chronic condition's logged days in the symptom channel
Excluding chronic episodes from the zero-fill also dropped them from the
read, so their day-logs never reached the series and an account with only
a chronic condition had no symptom channel at all. The read now takes
every in-window episode; only the ones that can trigger Rest Mode carry a
zero-filled span, and every logged day counts.
2026-10-20 09:16:43 +02:00
Marc-André Bombeck 67e2563c63 fix(insights): keep deleted mood entries and readings out of status snapshots
Four per-metric status snapshots read mood context without the tombstone
filter, and the blood-pressure snapshot read measurements the same way, so
a deleted entry kept shaping the text written about it. The breathing
screen and the HRV widget probe had the same gap.

One reader now serves the shared mood context, and a guard derives the
soft-deleting models from the schema and fails on any unfiltered read in
the analysis, insight and model-input directories.
2026-10-20 09:15:31 +02:00
Marc-André Bombeck fea1d1e0b5 chore: strip planning tags from comments and enable the rule
Output of `node scripts/strip-planning-tags.mjs --write`: 3,157 comment
lines in 1,308 files lose the work-item identifier at their head. Release
versions and issue references stay; nothing outside comment text
changes, which the script checks per file against a comment-free
printout. healthlog/no-planning-tags is enabled at error level with this
commit, so the tree it lands on is clean.
2026-10-20 09:08:40 +02:00
Marc-André Bombeck 892a819060 chore(lint): add a sweep and a rule for planning tags in comments
Comments used to open with the identifier of the work item that produced
them. The identifiers point into notes that never ship, so a reader meets
something that looks like a reference and leads nowhere.

eslint-plugins/healthlog/planning-tags.js defines the shape once: a head
of tag codes and planning words before an em dash, optionally after a
release version. The version and any issue reference stay; the tag goes.
A line without a version must carry a code that is neither domain
vocabulary (B12, PM2.5, UTF-8) nor used in the file's code.

scripts/strip-planning-tags.mjs locates comments with the TypeScript
parser, rewrites only their text, and refuses any file whose
comment-free printout changes. The healthlog/no-planning-tags rule uses
the same matcher, with an autofix; it is enabled together with the sweep.
2026-10-20 08:52:23 +02:00
Marc-André Bombeck b50b5fcd21 chore(deps): drop four dependencies nothing imports
react-hook-form, @hookform/resolvers, next-themes and @vitejs/plugin-react
have no import anywhere in src/, scripts/, tests/, e2e/ or the build and
test configs; knip only stayed quiet because each sat on its ignore list.
Forms keep a controlled draft and validate with the shared Zod schemas,
and the theme bootstrap is hand-written. The ignore entries, the comments
that still named the form library, and the guideline text go with them.
2026-10-20 08:39:06 +02:00
Marc-André Bombeck 9b93247ae0 test: prime only the EN catalog in the unit setup
The setup file runs once per test file and imported all eight message
catalogs, several hundred kilobytes each, which made setup the largest
share of a component-suite run. It now primes EN, the fallback floor of
the t() chain. A test that renders another locale primes it at module
scope through primeLocales(), which loads the catalog via the production
loader in a file-level beforeAll.
2026-10-20 08:38:08 +02:00
Marc-André Bombeck 5cfd129b02 Merge branch 'fix/v1437-gh-adopt' into release/v1.43.7 2026-10-20 08:18:05 +02:00
Marc-André Bombeck dee6a27ae4 Merge branch 'fix/v1437-data' into release/v1.43.7 2026-10-20 08:12:32 +02:00
Marc-André Bombeck 6e970076c7 test(insights): give the cache access guard a floor on its matches 2026-10-20 07:51:18 +02:00
Marc-André Bombeck 43da1739b2 fix(audit): drop the widget-layout breadcrumb that can no longer write
The layout write resolves the caller, so its refusal is always the
caller's own and auditLog never files it; the call, its dedup and its
tests go, and the refusal stays on the wide event. The series route
tests now pin the breadcrumb call instead of a ledger row the caller's
own request no longer writes.
2026-10-20 07:51:15 +02:00
Marc-André Bombeck 00dfc5a0c0 feat(insights): seal the cached AI insights payload at rest
users.insights_cached_text held the model's briefing, readings and
recommendations about the account in the clear. Migration 0411 adds
insights_cached_text_encrypted (AES-256-GCM, binary layout, purpose-bound
label) and a partial discovery index; the free-text encryption backfill
seals the existing rows one at a time, fail-closed, carrying updatedAt and
guarded on the value it read. Every reader, writer and clear goes through
cached-text-cipher.ts, and a guard keeps the column names there. The
column joins the encrypted-column registry and the key-rotation script,
stays out of backups as operational, and is reset by the data wipe.
2026-10-20 07:30:00 +02:00
Marc-André Bombeck eae2138f3e fix(audit): route the widget-layout refusal through auditLog
The dashboard layout route wrote its validation breadcrumb with a bare
auditLog.create, so it skipped the delegated-only rule and kept filing
the caller's own refusals.
2026-10-20 07:29:53 +02:00
Marc-André Bombeck 97d1052dd1 feat(api): say when a list was cut at its ceiling
A list that stops at its own take and says nothing lets the last row read
as the oldest there is. Capped lists now fetch one row past the limit and
answer meta.truncated and meta.limit on the success envelope, so data keeps
its shape. Applied to the capped lists without a total count: rhythm
events, ECG recordings, sharing grants and activity, illness episodes, side
effects, personal records, security activity, cycles, family history,
allergies and mental-health assessments. The activity feed no longer
reports a list that is exactly full as cut.
2026-10-20 06:53:17 +02:00
Marc-André Bombeck 2e92069c16 test(guards): name the list walk's exception instead of pending entries
The page-walk guard flagged the backfill progress codec's page > 0 check, a
validity test rather than a ceiling, and missed the list walk's own loop
(pageCount - skipped < maxPages). The matcher now reads a counter offset and
ignores a literal bound of 0 or 1. Strava and the rollup chunks walk through
walkPages, so their pending entries go. The dataPoints.list walk stays outside
as a named exception with a match allowance: it is bounded by progress rather
than by a cursor ceiling, streams pages, stops on a slice budget and keeps its
place, and its backfill decides with a sliced verdict.
2026-10-20 06:49:47 +02:00
Marc-André Bombeck 5c1b9889ec fix(google-health): page dailyRollUp chunks through walkPages
Each rollup chunk kept its own token loop with a hundred-page bound. It now
walks through walkPages: a token handed out twice ends the chunk at once
instead of re-reading the same page up to the bound, and a chunk cut off is
reported on the wide event as well as through the client outcome.
2026-10-20 06:49:45 +02:00
Marc-André Bombeck 93b3f36d96 fix(strava): walk activity pages through walkPages and gate the backfill on it
The activity loop stopped at its page ceiling and returned what it had read,
and the backfill stamped the account complete over it: a history longer than
forty pages was cut off for good. The walk now runs through walkPages, which
reports a walk cut off at its ceiling, and the backfill reads backfillVerdict
like the WHOOP and Fitbit backfills: a truncated walk leaves the marker unset
without a retry, and a connection waiting for re-authorisation returns
without walking.
2026-10-20 06:49:44 +02:00
Marc-André Bombeck 415da581f8 fix(client): bound the staged-attachment poll and list every client poll
The Coach polled a staged document every 2.5 s until its content index
existed, which for a format that is never indexed meant for as long as the
Coach stayed open. It now stops with the same upload processing window the
documents list uses. Every client poll, its interval and its stop
condition is listed beside the dashboard cadence constant.
2026-10-20 06:49:23 +02:00
Marc-André Bombeck 9bc95713cb fix(client): keep server modules out of the browser bundle
The side-effect taxonomy imported its enums from the Prisma client entry,
and the recurrence engine the medication wizard previews schedules with
imported the logging context, which brings node:async_hooks, node:crypto
and node:os along. The taxonomy reads the enums entry now, and a rule that
does not parse is reported through an observer the server registers, the
pattern the source-priority parser already uses. A guard walks the import
graph from every client component and fails on a server-only module.
2026-10-20 06:49:17 +02:00
Marc-André Bombeck 4979805d11 fix(audit): keep refusals and job telemetry out of audit_logs
A *.validation-failed row was written on every malformed request, the
caller's own included, and the PR detection enqueue wrote one per batch.
Neither is a security event and nothing reads them. auditLog now files a
refused attempt only when a delegate made it, for the owner's activity
feed; otherwise it rides the wide event as meta.rejected_attempt. The PR
detection enqueue moves to annotate().
2026-10-20 06:49:03 +02:00
Marc-André Bombeck 5eaa813b34 fix(google-health): round imported values through importValue
The mapper kept its own parseFloat(n.toFixed(2)), which rounds a half at
the third decimal down whenever the double sits just below it: a weight of
72 345 g stored as 72.34 kg, a distance of 1 000 005 mm as 1000 m. The
shared importValue rounds on the decimal the value prints as. The rounding
guard drops its last pending entry and also catches a round2 declared as a
const.
2026-10-20 06:27:58 +02:00
Marc-André Bombeck f0449574f8 i18n(nl): follow the session, sleep-rhythm and recovery copy 2026-10-20 06:20:43 +02:00
Marc-André Bombeck 45c027aee2 Merge branch 'fix/v1437-review' into release/v1.43.7
# Conflicts:
#	src/app/api/analytics/route.ts
2026-10-20 06:20:42 +02:00
Marc-André Bombeck 40cf10ba20 test: bring fixtures in line with the nap rule and the recovery read
Correlation fixtures wrote minute-long midday sleeps, which the nap
rule now reads as naps; they write nights. The analytics test mocks the
user read the empty recovery reason makes, and the control-byte guard
asserts the size of its sweep.
2026-10-20 06:06:25 +02:00
Marc-André Bombeck c2133203fb test(google-health): hold the parked walk to the consecutive-attempt rule
A day that brings one more page is progress, so the walk parks again
and counts afresh; only a day that ends at the same edge ends it.
2026-10-20 06:06:24 +02:00
Marc-André Bombeck ca937ba87a test(logging): type the probe handler's request argument 2026-10-20 06:05:56 +02:00
Marc-André Bombeck 1420641746 i18n(nl): add the last-entered-here freshness label 2026-10-20 05:58:27 +02:00
Marc-André Bombeck ea933b91cc Merge branch 'fix/v1437-jobs' into release/v1.43.7 2026-10-20 05:58:02 +02:00
Marc-André Bombeck 72214c0208 fix(logging): carry the route template in http.route
http.route held the concrete pathname, so every record id minted a route of
its own and grouping by route answered nothing. apiHandler recovers the
template from the handler's params and puts it there; http.path keeps the
concrete path, and path-secret redaction still runs on both.
2026-10-20 05:57:20 +02:00
Marc-André Bombeck fbbb535832 feat(api): give every error envelope a meta.errorCode
Roughly half of the refusals answered prose alone, so a client could branch
on the code for one 404 and had to fall back to the status for the next.
apiHandler now completes every error envelope with the status's generic
http.* code when the route named none; a route's own code still wins. The
family joins the published catalogue, and the profile form only shows a
code's sentence when the code has one.
2026-10-20 05:53:37 +02:00
Marc-André Bombeck c7f69fc212 fix(auth): answer 503 when the session store cannot be read
The cookie resolvers folded a failed query into null, so a database restart
answered 401 and every client discarded a good credential. The lookup now
raises SessionStoreUnavailableError, which reaches the wire as 503 with
auth.session.unavailable. Logout still clears the cookie on a failed lookup
and records a warning.
2026-10-20 05:43:58 +02:00
Marc-André Bombeck 9af22f2ab2 fix(account): give account deletion the wipe's transaction budget
The cascade behind user.delete removes every row the account owns, so on a
large record it outlived Prisma's 5 s interactive default and rolled back
with a 500 on every attempt. The deletion stays atomic with the Guardian
check and now carries the same budget as the data wipe.
2026-10-20 05:38:44 +02:00
Marc-André Bombeck f6e0d72193 Merge branch 'fix/v1437-reads' into release/v1.43.7 2026-10-20 05:35:06 +02:00
Marc-André Bombeck 5720b70e8e test(guards): drop the removed chip ledger from the course-anchor builders
The cadence chips no longer build a recurrence context of their own; they tally the compliance ledger, whose adapter is already on the list.
2026-10-20 05:33:21 +02:00
Marc-André Bombeck a6a106f763 docs(api): regenerate the OpenAPI contract 2026-10-20 05:14:07 +02:00
Marc-André Bombeck 248b5dfd6b fix(sleep-score): publish the rhythm floor and name it in the few-nights note
The note hard-coded "of 3" in every locale. SleepScoreValue now carries
rhythmMinNights, and the sheet reads the floor from it.
2026-10-20 05:14:03 +02:00
Marc-André Bombeck 4a8bf1963b fix(measurements): keep unchanged updates on their page and vacuum by threshold
`updated_at` sits in the sync-feed index, so every update that moved it
wrote a new row version on another page and an entry in every index.
Provider re-syncs rewrite many unchanged rows, which grew the table to
several times its live size and sent each unchanged row down the sync
feed again. Migration 0412 adds a row trigger that keeps `updated_at` and
`sync_version` when an update changes nothing else, so such an update is
heap-only and the feed stays silent; it sets `fillfactor = 85` and a 5 %
autovacuum threshold. The maintenance queue gains a nightly check that
vacuums when dead rows pass a tenth of the live ones and warns when the
heap is more than three times what its live rows need, which only a
`VACUUM FULL` in a window gives back. The admin card keeps showing the
operator's runs only.
2026-10-20 05:06:25 +02:00
Marc-André Bombeck 483c84b42e fix(integrations): report a page walk that stops short instead of ending quietly
The WHOOP, Oura and Withings list walks and the Fitbit exercise walk each
kept a page loop that, at its ceiling or on a cursor that did not advance,
returned what it had read and said nothing, and the backfill around it
stamped the history complete. `walkPages` keeps a ceiling but reports a
walk that stops with a page pending, or on a cursor it already followed,
as truncated. A backfill reads that through one verdict: a failure
retries, a truncated collection leaves the marker unset without a retry,
a clean run stamps it. The Fitbit exercise walk may read a hundred pages
during a backfill instead of twelve. A guard keeps page-counting loops out
of the provider directories, with Google Health and Strava listed until
their next change.
2026-10-20 05:06:21 +02:00
Marc-André Bombeck 9fe6f98fdb refactor(workouts): render workout dates through useFormatters
The workout list, the workout detail header and the dashboard's recent
workouts tile each rebuilt their own date formatter from the shared
cache, with a private copy of the current-year check, and so ignored
the reader's date-order preference. They now use useFormatters(), and
the copies are gone.

The timezone formatter guard now refuses any two-argument
getDateTimeFormat call in src/components, so a component cannot build
its own display formatter again. The medication card's day bucketing,
the one other such call, reads userDayKey instead.
2026-10-20 05:01:37 +02:00
Marc-André Bombeck 862361a224 fix(recovery): publish why the recovery page is empty from the server
The page worked out its empty note on the client from the integrations
status, so every client had to repeat the rule. The analytics summaries
slice now carries `recoveryEmpty` (general, oura_permission or
oura_no_resilience, null when any recovery type has a reading), decided
once in resolveRecoveryEmpty from the gated summaries and the stored
Oura grant. The page renders that value only, and its block list is
typed to the server's recovery types so the two cannot drift.

Each reason now reads as one sentence in every locale, and every reason
offers the same way on, the integrations settings.
2026-10-20 05:01:18 +02:00
Marc-André Bombeck feb8058233 fix(medications): tally the cadence chips from the compliance ledger
`/api/medications/[id]/cadence` computed its adherence chip from a
private copy of the compliance ledger that ignored pause eras, courses
and auto-missed doses, and read only the chart's window of intakes. A
medication paused for a week read 77 % there and 100 % on the detail
page. The chips now tally `trailingComplianceLedger`, the ledger the
compliance payload builds, over the same window, with the medication's
pauses and courses; the copy is removed and a guard freezes the set of
files that build dose rows.
2026-10-20 04:37:44 +02:00
Marc-André Bombeck e355c6120f fix(workouts): one canonical-workout picker with one source order
Three pickers decided which of two overlapping workouts to keep: the
read-time picker the list, the detail page and the Coach use, a
write-time batch picker with its own 90-second window, and an unwired
field-merge picker. They used different source orders (the `steps`
ladder, or a separate workout default when no order was saved), so the
batch route could keep a row the list then ranked below another.

The batch route now runs the read-time picker over the rows it is about
to insert, with the ladder from `workoutSourceLadder`, and reports what
it hides as duplicates. A session HealthKit hands over twice under two
ids is stored once, as the list already showed it. The write-time and
the unwired picker are removed.
2026-10-20 04:37:39 +02:00
Marc-André Bombeck 2861075676 fix(mood): bound the mood series fallback to the rollup window
Without mood rollups the series walked every mood entry the account ever
logged, while the rollup path reads the trailing five years. The live
fallback now reads the same window.
2026-10-20 04:37:15 +02:00
Marc-André Bombeck 9ffcdea312 fix(insights): keep chronic conditions out of the symptom series fill
The symptom-severity series fills healthy days with zero across each
illness episode's span. A chronic condition has no end, so its span
covered the whole window and the correlation engine read a near-constant
run of zeros. Only episodes that can put the account into Rest Mode
(`REST_MODE_EPISODE_WHERE`) carry a span now.
2026-10-20 04:37:08 +02:00
Marc-André Bombeck ac86199808 fix(stats): one standard deviation with its kind named at the call site
About a dozen hand-rolled spreads each picked population or sample
variance without saying so, and the two surfaces a person compares
picked differently: the series API reported the population SD of a
blood-pressure series, the MCP tools and the AI payload the sample SD of
the same readings, about 22 % apart at three readings.

`lib/stats/spread` holds `variance` and `standardDeviation` with an
explicit `population | sample` kind and `READING_SPREAD`, the kind every
surface uses to report the spread of a person's readings. The series
route and the feature payload both report it; the spreads that feed a
verdict (the normal-swing checks, the changepoint threshold) and the
clinical glucose SD stay the sample estimate, now said out loud. A guard
fails on a new hand-rolled spread and on the two surfaces drifting apart.
2026-10-20 04:36:54 +02:00
Marc-André Bombeck 7535235b82 test(guards): give the new guards a floor on what they find
The zone-shift and single-Pearson guards now assert that their matchers
find the correct shapes before concluding nothing else matches, and the
dense-read guard lists the streak's per-day existence probe with the
reason it stays bounded.
2026-10-20 04:36:25 +02:00
Marc-André Bombeck 0ea8880923 chore: drop planning tags from comments the review touched 2026-10-20 04:23:58 +02:00
Marc-André Bombeck b4333d65ff test: refuse raw control bytes in source files
Three files carried invisible control bytes in string literals, one of
them a production join key. They are escapes now, the same strings, and
a guard fails on any raw byte below 0x20 other than tab, newline and
carriage return.
2026-10-20 04:23:46 +02:00
Marc-André Bombeck ad9c9cd0cd fix(day): head the since-last-visit card with TileHeader at the compact density
The card hand-built an uppercase h3 and ran a density of its own. It
now takes TileHeader size="sm" (which gains an h3 heading for a card
under a section that owns the h2) and the compact card rhythm.
2026-10-20 04:23:32 +02:00
Marc-André Bombeck b7e48e933c fix(sleep): read a night of any age and the whole series range
The night view read a year of stage rows whatever date it was asked
for, so an older night came back with no main session while a year of
rows sat in the heap; it now reads a day and a half either side of the
night, and the latest night from the latest asleep row. A day of naps
alone answers with its naps and main null.

The series cut "All" to a year for sleep without saying so. Postgres
now names the nights and the rows are reconstructed ninety nights at a
time, so the range reaches back as far as the history does.

The metric status read the latest 2190 stage rows, a few weeks on a
stage-rich watch, starting mid-night; it now reads the latest 365
nights the same way.
2026-10-20 04:23:10 +02:00
Marc-André Bombeck 3da72136ba refactor(measurements): move the list modes out of the route and page every night
The 1500-line list route carried the day-grouped list, the sleep list
and its SQL night page, each with its own copy of the window-total
paging. They now live in src/lib/measurements/list/ with one paging
helper, and the route dispatches.

The sleep list gives every page night a row. A night whose
reconstruction held no main sleep (a day of naps alone) used to be
dropped while meta.total still counted it, which left short pages; its
row now carries 0 minutes and its naps. The SQL night cut splices the
asleep stages from ASLEEP_STAGES and folds rows into hour buckets
before the window functions, which cannot move a session boundary and
sorts a handful of rows per night instead of every stage. A drill-down
reads its night the way the night view does.

groupBy=day refuses valueMin / valueMax with 422: a value range filters
single readings, and the rows promise whole days.

An integration test runs the SQL page and reconstructSleepSessions over
one set of awkward shapes and holds them to the same nights, and the
list, the night view and the series to one answer per day. The unit
test of the day-grouped branch now pins only the branch; what the rows
hold is pinned against Postgres.
2026-10-20 04:22:53 +02:00
Marc-André Bombeck 6328117bd2 fix(sleep): let a zero-minute granular row leave the bare night alone
A REM row of no length counted as the granular partition and stripped
the bare aggregate beside it, so a six-hour night read as no sleep while
the SQL page cut of the list counted it as a night.
2026-10-20 04:22:39 +02:00
Marc-André Bombeck b79bd8370d fix(heap): report again at 95 % and leave a report on an out-of-memory exit
The pressure report re-armed only after the heap fell under 70 % and
half an hour passed, so a heap that climbed to its limit soon after an
85 % report died with only that older report on record. A 95 % tier
now writes the same report at once, whatever the cooldown, and re-arms
under 85 %.

The image sets NODE_OPTIONS so Node writes its diagnostic report to
stderr when the heap runs out, with environment variables and network
interfaces excluded. The heap-diagnostics and scaling docs say how to
keep the flags when overriding NODE_OPTIONS.

Heap figures share one megabytes helper with the support report.
2026-10-20 03:42:26 +02:00
Marc-André Bombeck a22bf91311 fix(insights): report every correlation through the significance gate
The /insights envelope (weight x BP, mood x BP, mood x weight, mood x
pulse, BP medication continuity) and the AI feature payload computed
their coefficients with an ungated Pearson that banded |r| above five
pairs, so five days of noise sloping the right way read as a strong
correlation there while every other surface refused the same points.
Both now use `significantPearsonCorrelation` (n >= 20 and p < 0.05,
through the engine's exact p-value), and the ungated function is gone.
The allowlist guard that tracked its remaining callers becomes a guard
that there is one Pearson implementation.
2026-10-20 03:41:54 +02:00
Marc-André Bombeck 3cf67d05f9 fix(ui): let the read-failure primitives decide by the error
About forty compact rows and seventy cards still offered a retry on a
spent read budget and drew an error card on a refusal, while a third
primitive (ReadFailureRow) did the right thing at eight places.

QueryErrorRow and QueryErrorCard now take the query's `error` and read
it through readFailureKind, which moves to src/lib/api next to
ApiError: a refusal (403) draws the surface's refused state or nothing,
a spent budget (429) says so without a retry, an ended session (401)
says so and offers sign-in instead of going blank, and only a real
failure offers the retry. ReadFailureRow is gone and every site passes
its error; a guard test fails on a site that does not.

The coach read strip keeps its card in the error state and holds one
body height across loading, failure and a one-line read.
2026-10-20 03:38:57 +02:00
Marc-André Bombeck 04855b1a67 perf(day): read the day view's comparison history once while paging
Every day view compares its day with the two years before it to find
extremes, so stepping from one day to the next read the same two years
of readings again. The history is now cached per record, zone, source
ladder and readable type set, starting on a 90-day block boundary so
neighbouring days share one cell, and each day takes its own slice cut
at local day keys, which is exactly the window a live read would see.
Measurement writes sweep the cell with the rest of the analytics bucket.
A lookback floor inside the window and a window reaching past today
still read live, and a type the modules switch off is never read.
2026-10-20 03:36:44 +02:00
Marc-André Bombeck 0497685db0 fix(apple-health): count readings entered here before calling a type quiet (#1247)
Weight, blood pressure and blood glucose are written back to the phone
while their entry is bidirectional. A reading entered here goes to Apple
Health and never arrives back, so a person who keeps those readings here
saw the type go quiet after two weeks. The Apple Health status read now
adds `lastEnteredHereAt` to each written-back type, the newest reading
from any source other than Apple Health, and `stale` counts it; the card
shows it as "Last entered here". The value is resolved on the server so a
client renders it as sent.
2026-10-20 03:36:31 +02:00
Marc-André Bombeck 2b61762962 fix(integrations): round imported provider values through one normaliser
Fitbit, WHOOP, Oura, Polar and Withings each kept a private
`parseFloat(n.toFixed(2))`, which mis-rounds halves on the double below
the decimal. `importValue` keeps the same two places, rounds half away
from zero on the printed decimal and returns the nearest double, so a
value prints cleanly. A guard keeps `toFixed` and private `round2`
helpers out of the provider directories; the Google Health mapper is the
one listed exception until its next change.
2026-10-20 03:35:41 +02:00
Marc-André Bombeck e452dd62e2 fix(telegram): snooze a skipped dose to the end of the person's own day
The skip button set the rest-of-day snooze with `setHours(23, 59, …)` in
the process zone, which runs in UTC, so a skip after midnight UTC but
before local midnight east of it silenced the next local day's reminders
too, and one west of it ended the snooze hours early. The snooze now ends
at the last instant of the account's local day.
2026-10-20 03:35:36 +02:00
Marc-André Bombeck 1ee73908ae fix(jobs): run one heavy job at a time per process
Web and worker share one process and one heap limit, and the jobs that
hold a large working set (the Apple Health and Health Connect imports,
restore, the weekly and off-host backups, the restore drill, the rollup
backfill, the nightly status batch, the pre-generation walk and the
narrative walk) ran side by side whenever their schedules or an upload
met, so the peak was their sum. `heavyLane` queues them in the process,
first come first served. A waiter leaves the line when pg-boss expires it
and its retry takes the place; a one-shot whose input lives outside
pg-boss keeps its place. The runtime guard binds every heavy queue in the
lane, keeps `lockedPass` outside it, and allows the one-shot rule only
where the send options forbid a retry.
2026-10-20 03:35:23 +02:00
Marc-André Bombeck 6026a28fe4 fix(dashboard): key streak and compliance days in the person's own zone
`measured_at` and `scheduled_for` are naive UTC timestamps. The dashboard
streak and the medication-compliance backfill keyed them with a single
`AT TIME ZONE tz`, which reads the value as local time and shifts it the
wrong way: in Berlin a reading at 23:30 UTC counted for the day before
instead of the day after, so a streak could break or stretch near
midnight and the backfill recomputed the wrong day.

The streak's measurement days now come from `readActivityDayKeys`: one
existence probe per local day instead of a DISTINCT over a year of
readings. The DAY rollups cannot answer this because their buckets are
UTC days. The two compliance-rollup readers had no caller left and are
removed with the coverage probe that carried the same shift. A guard
fails on any single shift of a naive column into a bound zone.
2026-10-20 03:35:19 +02:00
Marc-André Bombeck d9e56482d9 perf(jobs): poll a queue only its cron feeds every 30 seconds
pg-boss polls each idle worker slot every two seconds, so the hundred-odd
bindings asked the database for work about fifty times a second while
most of them wait for one tick a day. `createAndWork` now gives a queue
that a registrar schedules and nothing else sends to a 30-second poll.
Scheduled queues that also take sends keep the default, because with one
job per fetch a slow poll would drain a burst at one job per interval;
the runtime guard holds that list to the send sites in the tree,
including sends through a variable.
2026-10-20 03:35:09 +02:00
Marc-André Bombeck 9c51dd0d30 perf(analytics): read the latest value per type with one probe per type
The latest canonical reading per type ran as `DISTINCT ON (type)` over
every live row of the account in five places (summaries slice warm and
cold path, comprehensive aggregator warm and cold path, targets). With no
skip scan in Postgres 16 that sorted the whole account to keep one row
per type. `readLatestCanonical` drives the read from the type list
instead: one index probe for each type's latest instant, then a closed
one-day window ranked by the source ladder. The closed window also keeps
the plan under `jit_above_cost`. The targets read reuses `readLatestEver`
with a floor.

The summaries slice was built inside three cache cells (the tile-strip
route, the thick analytics body, the dashboard snapshot). It now has one
cell, read through `readSummariesSlice`; composing callers read it
fresh-only so a refresh after a write cannot cache the pre-write slice
again, and the snapshot folds the water tile into a copy instead of the
cached object.
2026-10-20 03:35:03 +02:00
Marc-André Bombeck 0770ae3590 fix(jobs): retire the six per-metric status crons behind the nightly batch
The 02:00 batch builds every status card and falls each metric it cannot
cover back to that card's own path, so the 02:05 to 02:30 crons only
re-read cards the batch had just written. Their schedules and queues are
removed on an instance that still has them (`retireQueues`), and the
fresh-switch integration test drives the batch loop instead.
2026-10-20 03:34:51 +02:00
Marc-André Bombeck 37b65fa9c8 refactor(settings): take the backfill-ended type from its source
The integration status shape hand-copied the published entry with a looser
type field; it now imports the type the status routes publish.
2026-10-20 03:34:47 +02:00
Marc-André Bombeck 87554c09f3 fix(google-health): key a waiting retry on when it is due
A slice that found nothing left but a parked collection queued the next
slice a day out under its slice number. A boot during that day ran a fresh
first slice, met the same wait and queued its own retry under a different
slice number, so two chains walked the same history. The waiting retry now
goes under a key named after the instant it is due, read from the stored
note, so every slice that meets the same wait names the same job and the
queue keeps one. A retry already due stays an ordinary next slice.
2026-10-20 03:34:46 +02:00
Marc-André Bombeck 7c23a49708 fix(google-health): annotate an ended backfill in one fixed shape
The ended note carried the whole map of ended collections, dates and all,
which duplicated the slice report and gave dashboards no stable shape. It
now names the collection that just ended, its type and reason, whether the
end is final, and counts the ends so far with their types. It stays meta:
the job names the event's action once the slice is over, which would
replace an action set here.
2026-10-20 03:34:45 +02:00
Marc-André Bombeck bfbd88b30b refactor(google-health): read and write backfill progress through one schema
The backfillProgress column was read by four hand-rolled walkers over
untyped records and written inline in the sync core. One module now owns
the column: a Zod schema reads it, lenient per entry so a malformed cursor
or end is dropped on its own, and one function writes it. The sync core
keeps only the bookkeeping of a running backfill.
2026-10-20 03:34:30 +02:00
dareoon 12065bc0c7 feat(i18n): add Brazilian Portuguese support to medication and insight flows
Add pt-BR locale coverage across medication extraction, coach prose
grounding, no-key insight fallbacks, coach read dates, and lab reference
range parsing.

- Accept pt-BR natively in NaturalLanguageExtractorProps.
- Add Brazilian Portuguese population-norm framing patterns.
- Add pt-BR no-assessment fallback copy for all insight floors.
- Add pt-BR expected date coverage to the Coach read strip test.
- Add pt-BR lab range prose vocabulary.
- Keep existing locale behavior and safety-contract coverage unchanged.
2026-10-19 22:32:17 -03:00
Marc-André Bombeck 825650794c i18n(nl): add the keys v1.43.6 introduced after the catalogue was drafted 2026-10-20 01:49:43 +02:00
Marc-André Bombeck 435099dc4f fix(sleep): decide nap versus main sleep in one place
The sleep score treated a short daytime session as a nap while the
nap convention always took the longest session as the night, so a day
with only an afternoon nap showed that nap as the night in the list,
the night view and the dashboard, and as no night in the score.

The rule and its constants now live in sleep-night.ts. Each session
carries `daytimeNap`, the main night is the longest main sleep, and a
day of naps alone has no main night. reconstructSleepNights picks its
main session by the same rule, and the score reads that session.

Sessions are filed under the day their last row ends on over every
writer, the rule the SQL page cut of the sleep list can also apply.

The sleep pillar's regularity floor now counts the midpoints it uses,
not the nights.
2026-10-20 01:26:16 +02:00
Marc-André Bombeck acdea22e2b fix(google-health): count only retries that end at the same edge
A collection parked after a walk without progress is walked again a day
later. Its attempt count went up on every later end without progress, even
when the retry had reached further back before it stalled again, so a
history that kept moving in short steps was declared ended after two
attempts. The count now carries the edge it was parked at and starts over
at one when the new end lies elsewhere.
2026-10-20 00:55:53 +02:00
Marc-André Bombeck 63cf6b5516 build: budget the lazy message catalogs per catalog, not in the aggregate 2026-10-20 00:30:20 +02:00
Marc-André Bombeck d62b82d21b test(i18n): read the HRV label locales from the locale registry 2026-10-20 00:30:18 +02:00
Marc-André Bombeck 7be3714ac2 Merge pull request #1246 from robdegraaf/feat/i18n-nl 2026-10-20 00:14:49 +02:00
dareoon da3edbf729 chore(i18n): revisão de pt-BR e normalização de 'toma' para 'dose' 2026-10-19 18:28:51 -03:00
Marc-André Bombeck 8eb4ef3d56 Merge pull request #1248 from MBombeck/release/v1.43.6
release: v1.43.6
2026-10-19 21:21:10 +02:00
Marc-André Bombeck f6179a37af docs(changelog): describe the v1.43.6 review fixes 2026-10-19 20:57:48 +02:00
dareoon 1223476280 i18n(pt-BR): translate demoBanner 2026-10-19 15:34:27 -03:00
dareoon 29ac3af002 i18n(pt-BR): translate offlineBanner 2026-10-19 15:34:27 -03:00
dareoon 5d479491f8 i18n(pt-BR): translate notFound 2026-10-19 15:34:27 -03:00
dareoon 985c047a4c i18n(pt-BR): translate errorBoundary 2026-10-19 15:34:27 -03:00
Dareeon a5344b7c85 i18n(pt-BR): translate dashboard section 2026-10-19 15:25:27 -03:00
Marc-André Bombeck f05b2f71e7 Merge branch 'fix/v1436-oura' into release/v1.43.6 2026-10-19 20:20:29 +02:00
Marc-André Bombeck d833d39d7d Merge branch 'fix/v1436-gh' into release/v1.43.6 2026-10-19 20:20:28 +02:00
Marc-André Bombeck 3ff860591e Merge branch 'fix/v1436-ui' into release/v1.43.6 2026-10-19 20:20:28 +02:00
Marc-André Bombeck 1fef2c81d0 Merge branch 'fix/v1436-locks' into release/v1.43.6 2026-10-19 20:20:28 +02:00
Marc-André Bombeck 7ccb7e0acc Merge branch 'fix/v1436-heap' into release/v1.43.6 2026-10-19 20:20:27 +02:00
Marc-André Bombeck 5bb6ee1f42 ci: correct the pull-request sizes named in the long integration workflow 2026-10-19 20:10:24 +02:00
Marc-André Bombeck e4c78e1d0c fix(google-health): give a stalled walk a second attempt before it ends
A walk that stopped for lack of progress ended its data type for good, so
a passing fault in Google's paging closed that history until a reconnect.
A walk that had written nothing yet was also noted as ending today.

The first end without progress now parks the data type with its edge: it
is not finished, not published, and is walked again a day later. Once
nothing else is left, the next slice is queued for when it is due, and
the marker is never stamped over a parked type. A second end without
progress is final; a second attempt that moves on is an ordinary walk
again. A walk that stalls before writing anything records no end and
fails the slice as stuck.

Refs #1220
2026-10-19 20:10:12 +02:00
Marc-André Bombeck 9095b1037e fix(ui): draw nothing for a refused read and no retry for a spent budget
The failure rows added for supplementary reads offered a retry for any
error. The usual-range strip reads at manage level, so a viewer on a
read-only share saw a permanent failure with a retry that could never
succeed; the visit preparation reads measurements under visits that
need only the profile section, which a share can grant alone. A refused
read (401/403) now draws nothing, a spent read budget (429) says so
without a retry, and only a real failure keeps the retry row. The
cycle strip holds no cell for a refused calendar read.
2026-10-19 19:49:14 +02:00
Marc-André Bombeck bf84c0c11e fix(measurements): file sleep sessions by span, not by night label
The SQL page labels a sleep session by the last row of any writer; the
reconstruction labels it by its winning writer. A watch stopping before
midnight beside a phone whose in-bed span ran past it gave two days,
and matching the two by label dropped the night from the list while
the total still counted it. Each reconstructed session is now filed
under the page night whose span holds it and takes that night's label,
which the drill-down also asks by.

A sleep drill-down scans 36 hours either side of its day instead of the
whole history. Both day branches and the sleep list resolve the zone
through the same stored-zone resolver, so a drill-down always cuts the
days its list row summed. The list description states the real page
bounds: a day-sum page takes `limit` as asked, a sleep page caps at
1000 nights.

Refs #1245
2026-10-19 19:49:11 +02:00
Marc-André Bombeck b5285f0f65 test(recovery): settle the connection status before reading the empty note
The recovery page now holds its empty note until the integrations
status has loaded. The data-gating test rendered without that status
and saw the loading placeholder; it now seeds an account with no
integrations, the settled answer the general note belongs to.

Refs #1204
2026-10-19 19:46:03 +02:00
Dareeon 5a2ccb657c i18n(pt-BR): translate nav and auth sections 2026-10-19 14:05:46 -03:00
Dareeon 13dac2d89c i18n(pt-BR): translate doctorReport section 2026-10-19 14:01:06 -03:00
Marc-André Bombeck 44d8181cb4 docs(openapi): publish the sleep score value
The derived envelope types `value` as an open record, so the sleep
score's `rhythmNights` and per-sub-score `gap` were on the wire but in
no schema. `SleepScoreValue` joins `WellnessScoreValue` in the forced
components slot, the envelope's `value` description names it, and a
contract test holds the published field set to the one the engine
emits, for a scored rhythm and for one withheld for too few nights.

Refs #1204
2026-10-19 18:56:06 +02:00
Marc-André Bombeck d08e2b4e6d fix(observability): keep heap samples out of the admin log ring and harden the snapshot directory
runtime.heap.sampled is written every minute while the heap is above
70 % of its limit, which turned the 500-entry ring behind the admin log
page over in hours and pushed out the events an operator opens it for.
emitEvent takes a skipMemoryRing option; the sample sets it and still
reaches stdout and Loki, while the pressure and snapshot lines go
everywhere as before.

The snapshot directory was /tmp/healthlog-heap-<pid>, a predictable name
(pid 1 in most containers) that mkdirSync accepted when it already
existed or was a symlink. It now comes from mkdtempSync and is checked
with lstat to be a real directory owned by the process user and closed
to others before the snapshot is written.
2026-10-19 18:46:43 +02:00
Dareeon 50e901f807 i18n(pt-BR): add initial translation (common, apiErrors) 2026-10-19 13:46:00 -03:00
Marc-André Bombeck 3bdd53ab76 fix(mcp): answer GET and DELETE with 405 so clients stop reconnecting
The route is stateless and returns one buffered JSON response per call,
so it opens no server stream and holds no session. GET let the transport
open an event stream that the handler closed again at once; a client
read that as a dropped connection and reconnected about once a second,
each attempt costing a rate-limit slot and a module lookup. GET and
DELETE now answer 405 with Allow: POST after authentication, which is
what the transport specification asks of a server without that stream,
and an unauthenticated GET still gets the 401 challenge.
2026-10-19 18:34:37 +02:00
Marc-André Bombeck c016ec8aec fix(recovery): claim no Oura permission the server cannot prove
The empty recovery note told every connected Oura account without a
recorded permission gap that its permission covers resilience. The
server cannot know that: the scope note is cleared on each reconnect
and written only by a sync that reached Oura, and a missing health
block says nothing either way. The note now asks for a reconnect only
when the gap is recorded and otherwise says only that no level has
arrived, pointing at the Oura card. It also waits for the connection
status instead of showing the general note first and swapping it.

Refs #1204
2026-10-19 18:31:45 +02:00
Marc-André Bombeck 8c745fca8d fix(sleep): treat a short night as the night, only a daytime nap leaves the clock
Keeping a nap-only day off the sleep rhythm used three hours asleep as
the line, which also took every shorter night off the clock: someone
who sleeps two and a half hours a night (insomnia, a newborn, split
sleep) lost consistency, timing, chronotype and the health score's
regularity altogether. A session now leaves the clock only when it is
short AND centred in the daytime, outside 20:00 to 10:00 local time.
A short night keeps its place, a long daytime sleep after a night
shift stays a main sleep, and the withheld-timing note now reads that
the latest day had only a nap.

Refs #1204
2026-10-19 18:30:06 +02:00
Marc-André Bombeck 3a9f619001 chore(release): v1.43.6 2026-10-19 18:18:12 +02:00
Marc-André Bombeck 9d6bfe2624 Merge branch 'fix/v1436-gh' into release/v1.43.6 2026-10-19 17:26:51 +02:00
Marc-André Bombeck 8ce8065372 test(integration): size the multi-year backfill run from the environment
The multi-year Google Health backfill run walked three and four years of
heart rate on every pull request, some 3.7 million readings in over twenty
minutes, which filled most of its shard's time budget. What a run costs is
its pages, so a shorter history only helps with shorter slices.

HEALTHLOG_BACKFILL_TEST_DAYS now sets the size. Unset, the runs walk 120
and 160 days in 50-page slices with the faults at the same places within
them and the light collections already walked, about a tenth of the
pages. The heap growth bound applies to the full run only. The full three
and four years run nightly and on demand in integration-long.yml.

Refs #1220
2026-10-19 17:26:04 +02:00
Marc-André Bombeck f6b09f07cd feat(integrations): say where an imported Google Health history ends
The two status routes publish backfillEnded: each data type whose
backfill stopped short of the start of its history, with the instant it
reaches back to and whether the horizon or a walk that stopped moving
ended it. The integration card lists them in place of an import that
would otherwise read as still loading, in all seven locales.

Refs #1220
2026-10-19 17:25:57 +02:00
Marc-André Bombeck 72c259d85c fix(google-health): bound the backfill by progress and a horizon, not pages
A resumable collection counted the pages it had written across every
backfill attempt and stopped at its ceiling (10,000 for heart rate). A
watch that records heart rate every few seconds reaches that count two to
three years back, and since it never reset, every later slice stopped at
the same place, failed as stuck, and the backfill was never stamped
complete.

A walk is now stopped only when it stops moving: 20 pages in a row that
reach no further back than the pages before them end it. A history
backfill reads back at most GOOGLE_HEALTH_BACKFILL_HORIZON_YEARS (20 when
unset). A data type that ends either way counts as finished, and where and
why it ended is kept in backfillProgress past the completion stamp. An
oldest-first list is read whole, as before. The slice chain's ceiling is
derived from sixty days of back-to-back slices instead of the old page
count. An account left at the old ceiling continues from its edge with
the next slice.

Refs #1220
2026-10-19 17:25:48 +02:00
Marc-André Bombeck 0e6e50fc7b Merge branch 'fix/v1436-ui' into release/v1.43.6 2026-10-19 17:04:31 +02:00
Marc-André Bombeck 362afe2333 Merge branch 'fix/v1436-heap' into release/v1.43.6 2026-10-19 17:04:30 +02:00
Marc-André Bombeck 09c93cd547 Merge branch 'fix/v1436-oura' into release/v1.43.6 2026-10-19 17:04:29 +02:00
Marc-André Bombeck 378de45f88 Merge branch 'fix/v1436-locks' into release/v1.43.6 2026-10-19 17:04:29 +02:00
Marc-André Bombeck d953789ecc chore: spell the value-range separator as an escape
The template key in the measurement list carried a literal NUL byte,
which made tools treat the file as binary.
2026-10-19 16:43:54 +02:00
Marc-André Bombeck 38c106e358 fix(ui): draw no empty frame and say when a read failed
"Since the last visit" was wrapped in a card by the visits section, so
the card stayed on screen empty while the window loaded, when it held
nothing and when the read failed. The component now owns its card and
draws it only with a row; a failed read shows a compact error row.

The same shapes elsewhere: the caffeine card and the mood forecast no
longer open a loading card that most often collapses, and a failed read
on them, the mood tag catalogue, the usual-range strip, the cycle ring
and the measurement drill-down now says so with a retry instead of
passing for no data. The scores strip keeps the ring's cell on a failed
calendar read. The mental-health card no longer draws an empty action
slot for a viewer without the right to start.

The frame guard now also flags a frame whose only content sits behind a
condition.

Refs #1241
2026-10-19 16:43:52 +02:00
Marc-André Bombeck d00281f35a test: count neither a comment nor a zero offset as a pager
The client fetch-limit guard took any `offset` within three lines as a
pager. The day-grouped measurement list passed it with `limit` 5000
between a comment about pagination and `offset` pinned to "0", while it
drew a whole history on one page. Comment lines, trailing comments and
inline block comments no longer count, nor does an offset pinned to a
literal zero in any of the three spellings. The old list shape is a
test case and fails the guard.

Refs #1245
2026-10-19 16:43:50 +02:00
Marc-André Bombeck cfd6219981 fix(measurements): page day sums and sleep nights in SQL
The day-grouped list read the newest `limit` raw rows (the web asked
for 5000), folded them into days and returned every day at once: one
page of thousands of rows, and every day older than the 5000th raw row
missing without a word. The sleep list did the same at a year.

Both now pick the page in Postgres. The day mode selects the page's
local days with the distinct-day total from `COUNT(*) OVER ()` in the
same query, then folds only those days into per-source groups in SQL
and lets the source ladder choose among the groups, so no raw row
reaches the process. The sleep mode cuts sessions with the same gap
rule as the reconstruction, pages wake days the same way and reads only
the page's stage rows; the 365-day cut and `meta.windowFrom` are gone.
A sleep page holds at most 1000 nights, echoed in `meta.limit`.

The web list pages both modes at 25 like the per-sample list. The
`partial` day marker is dropped from the contract; no day is cut any
more.

Refs #1245
2026-10-19 16:43:39 +02:00
Marc-André Bombeck 16024397c8 feat(observability): log heap samples and a one-time pressure report
runtime.heap.sampled records heap, old space, RSS, external memory and
the work in flight every five minutes, every minute at or above 70 % of
the heap limit. At 85 % runtime.heap.pressure lists the oldest requests
and jobs in flight and the routes most of them share, once, re-armed
only after the heap falls below 70 % and thirty minutes pass. A
heap-limit crash leaves no other record of what the process was doing.

HEALTHLOG_HEAP_SNAPSHOT=1 adds one heap snapshot at that moment into
HEALTHLOG_DIAG_DIR (0700 directory, 0600 file), off by default.
docs/ops/heap-diagnostics.md warns that a snapshot holds decrypted
health data and keys and says how to delete it.
2026-10-19 16:00:53 +02:00
Marc-André Bombeck 5923dac8aa feat(observability): keep a bounded register of requests and jobs in flight
Every HTTP request the server accepts is registered through Node's
http.server.request.start diagnostics channel and removed when its
response closes, finished or aborted, so pages, RSC payloads and server
actions are covered alongside API routes. Jobs register around their
handler in observeJob. The register holds strings only, cuts the query
string on entry, caps at 2000 entries and counts what it refuses; route
labels are scrubbed and collapsed only when a report is written.
2026-10-19 16:00:45 +02:00
Marc-André Bombeck d38ceb4beb fix(sleep): say why consistency or timing has no value
A rhythm sub-score below three nights with a main sleep was dropped from
the blend and its row read "no data", although the nights were there;
since a nap-only day no longer has a place on the clock, a ring worn
for only a few nights reaches that state more often. Each withheld
consistency or timing sub-score now carries the reason
(`few_nights`, `no_main_sleep`) and the score value carries how many
nights the rhythm is built from, so the row reads "not enough nights
yet (2 of 3)" or "latest night had no main sleep" instead. Neither is
ever shown as 0.

Refs #1204
2026-10-19 15:15:46 +02:00
Marc-André Bombeck 1606099005 build(deps): keep the Next.js packages out of the weekly groups
A red Next bump inside the production group held the twelve harmless
updates next to it red as well. The three Next packages open as single
pull requests, so each is judged on its own checks.
2026-10-19 15:09:50 +02:00
Marc-André Bombeck 7b17ec819a chore: drop personal names from comments and test fixtures
Comments referred to the maintainer by name, and a test fixture carried a
full real name. Comments describe the behaviour; fixtures use placeholders.
2026-10-19 14:57:00 +02:00
Marc-André Bombeck 146fcac079 chore: write NUL as an escape instead of a raw byte
Raw NUL bytes in source files make grep and ripgrep treat the file as
binary and skip it, so sweeps silently miss it. The affected string and
regex literals use the escape now; behaviour is unchanged.
2026-10-19 14:57:00 +02:00
Marc-André Bombeck ff3559c169 fix(workouts): render workout dates in the profile timezone
The workout list, the workout header and the dashboard tile formatted
instants through the shared formatter cache without a timeZone, so they
followed the browser's zone. The year comparison in the list used the
browser's calendar as well. All three now take the profile zone, and the
timezone guard also matches getDateTimeFormat(locale, options) calls that
leave the zone out.
2026-10-19 14:56:53 +02:00
Rob de Graaf c6f61fad33 feat(i18n): add Dutch as an eighth end-to-end locale
Adds messages/nl.json (AI-initial, informal je/jij register, full key
parity with en.json) and registers nl everywhere the locale list lives:
config, loaders, Intl/date-fns mapping, zod enums, OpenAPI spec, scripts,
the managed-profile picker and the notification locale guards.

Dutch follows the Korean pattern for the AI surfaces (English body plus a
Dutch reply-language footer, no safety-contract matrix) and gets its own
banks in the outbound safety screen (dose, risk, causal, continuation),
the prose-grounding and citation-coverage tables, the reference-range
prose and the no-key fallback copy, each with Dutch test fixtures.

PHQ-9 / GAD-7 / WHO-5 are not marked validated for nl yet: the Dutch
wording has not been read against the published forms, so Dutch readers
get the English items plus the existing note until it has.
2026-10-19 11:06:55 +02:00
Marc-André Bombeck 40b1389970 fix(recovery): name what is missing on an Oura account's empty recovery page
An Oura ring fills the recovery page through its resilience level alone.
The empty note listed every source that could fill the page, so a
connected ring with no level read the same as no wearable at all. The
note now follows the server's view of the Oura connection: when the
grant leaves out the heart-health data the level sits behind, it asks
for one reconnect and links to the integrations settings; when the grant
covers it, it says Oura has not sent a level for this account yet.

Refs #1204
2026-10-19 10:53:59 +02:00
Marc-André Bombeck 7f2f473a58 fix(sleep): keep a nap-only day off the sleep rhythm
A day whose ring or watch recorded no night has a nap as its largest
session, and its afternoon midpoint sat hours from every other night.
One such day in a month pushed the midpoint spread past the 90-minute
full scale and read a regular rhythm as Consistency 0. A day's midpoint
now needs a main session of at least three hours asleep, the line
wearables draw between a nap and a main sleep. The day keeps its
minutes; it only has no place on the clock for consistency, timing,
chronotype and the health score's regularity.

Refs #1204
2026-10-19 10:25:30 +02:00
dependabot[bot] 01bf8fbb64 build(deps): bump the codeql-action group with 3 updates (#1242)
Bumps the codeql-action group with 3 updates: [github/codeql-action/init](https://github.com/github/codeql-action), [github/codeql-action/analyze](https://github.com/github/codeql-action) and [github/codeql-action/upload-sarif](https://github.com/github/codeql-action).


Updates `github/codeql-action/init` from 4.38.3 to 4.39.0
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/24c54180a607b1449ed407dd24f251e4e9147c8d...6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee)

Updates `github/codeql-action/analyze` from 4.38.3 to 4.39.0
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/24c54180a607b1449ed407dd24f251e4e9147c8d...6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee)

Updates `github/codeql-action/upload-sarif` from 4.38.3 to 4.39.0
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/24c54180a607b1449ed407dd24f251e4e9147c8d...6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee)

---
updated-dependencies:
- dependency-name: github/codeql-action/init
  dependency-version: 4.39.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: codeql-action
- dependency-name: github/codeql-action/analyze
  dependency-version: 4.39.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: codeql-action
- dependency-name: github/codeql-action/upload-sarif
  dependency-version: 4.39.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: codeql-action
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-18 16:35:04 +00:00
Marc-André Bombeck dac778dac7 Merge pull request #1240
Release v1.43.5
2026-10-18 13:51:27 +02:00
Marc-André Bombeck ade7a252f0 chore(release): v1.43.5 2026-10-18 13:11:47 +02:00
Marc-André Bombeck 947551cd8e fix(jobs): name the stage and condition when the tombstone purge fails
A failed run threw the bare Prisma error, which the support report showed
as "Error". The reason now carries the stage and the database condition
(statement_timeout, lock_timeout, pool_timeout, ...), and the report's
error class reads the SQLSTATE out of a Prisma message.

Refs #1239
2026-10-18 13:10:21 +02:00
Marc-André Bombeck 1d7077286f feat(admin): flag a heap limit below 700 MB with the setting to raise
The support report and the system status now say in one sentence when the
heap is the 1 GB-container size and point at APP_MEMORY_LIMIT.

Refs #1239
2026-10-18 13:10:20 +02:00
Marc-André Bombeck ac047854ba fix(backup): stream documents and ECG traces through backup and restore
The weekly copy read every stored document and ECG trace whole, then held
their base64, the section JSON and the preview copies beside them. A vault
of 190 MB peaked at 930 MB of heap and took a 524 MB process down on every
run. Documents now leave the database one at a time in 1.5 MB slices, the
traces a page at a time, every other section is written an element at a
time, and a sealed piece never exceeds its chunk size. The restore keeps
document bytes out of its first read and writes each document from a
second one. Same file format, byte for byte.

Refs #1239
2026-10-18 13:10:19 +02:00
Marc-André Bombeck 9db0f09ba5 Merge pull request #1238
Release v1.43.4
2026-10-17 12:07:08 +02:00
Marc-André Bombeck 5afa22b382 chore(release): v1.43.4 2026-10-17 11:37:20 +02:00
Marc-André Bombeck e8e3cf4cd3 Merge remote-tracking branch 'origin/main' into fix/v1434-illness-symptoms 2026-10-17 11:35:55 +02:00
Marc-André Bombeck 271e8d6ea8 Merge pull request #1236
Release v1.43.3
2026-10-17 11:09:58 +02:00
Marc-André Bombeck c1df60693b fix(symptoms): assign an occurrence to an illness by its link, not its time
The episode detail listed every symptom occurrence that fell inside the
episode's onset-to-recovery span as well as the ones filed against it, so a
long-running condition showed the symptoms of every illness overlapping it,
and picking an illness in the entry sheet changed nothing on the page. The
occurrence edit sheet carried no illness link at all, so once logged the
assignment could be neither seen nor changed.

- The episode card reads only the occurrences filed against the episode
  (`SymptomEvent.episodeId`), with a guard against any other.
- The edit sheet loads the link and edits it like the time and intensity;
  an unchanged link is not re-sent, so a link the list cannot show stays.
- One rule decides which episodes may take an occurrence
  (`isEpisodeLinkableAt`): an open one, or a resolved one whose span holds
  the occurrence's time. The server checks it against the time after the
  edit, and the entry and edit sheets offer exactly what it admits, so a
  backdated occurrence can be filed with an illness that has since ended.
- The illness page names the illness on each occurrence row.
- The day view opens a symptom's illness only while that episode exists.

The stored links were always written correctly, so no data repair is needed.

Refs #1237
2026-10-17 11:00:02 +02:00
Marc-André Bombeck d67032db96 test(e2e): wait for the document title before scanning the handover screens
The decision screen is reached by a client navigation, during which the old
route's title unmounts and the new one mounts from a suspended metadata
boundary that can reveal after the body has painted. Under load the scan ran
in that stretch and reported document-title. The helper now asserts the
title first, so a screen that never gets one still fails.

Refs #959
2026-10-17 10:43:59 +02:00
Marc-André Bombeck b254814bae test(e2e): walk the re-proof dialog when the rotation's step-up has aged out
An aged-out step-up no longer ends in a refusal sentence on the card: the
refusal names the proofs it takes and the shared re-proof dialog opens above
the pending confirmation. The journey now asserts that dialog, that
cancelling it sends nothing more and leaves the codes as they were, and that
proving the factor there runs the rotation once more and issues a fresh
batch. The proof's code waits for the step after the enrolment code, which
the replay floor would refuse.

Refs #959
2026-10-17 10:43:57 +02:00
Marc-André Bombeck 792fc89a93 fix(auth): count a passkey as the second factor on the managed-profile acts
An account whose only strong credential is a primary passkey was refused
auth.stepup.mfa_not_enrolled on every action behind requireFreshMfa, so it
could not create, edit, hand over or delete a managed profile or manage its
guardians, although a passkey sign-in or re-proof already stamps the
session the gate reads.

requireFreshMfa now defaults to the any-possession proof source: a passkey
makes the gate reachable, and a passkey re-proof through the dialog clears
it. The refusal offers passkey in meta.methods, never the password.

hasSecondFactorEnrolled keeps its narrow meaning on purpose. It decides
whether the conditional gates apply at all (account deletion, the data
reset, the password change, the export, offering manage access, the
recent-proof branches and the native Bearer rules), so none of those
change, and password sign-in still challenges only TOTP and security keys.
The MFA-management routes keep the second-factor source. The decision and
its limits are written beside FreshFactorProofSource.

Copy, the OpenAPI description and the sharing and key-rotation docs say
"second factor or passkey" where this gate applies.

Refs #959
2026-10-17 09:56:30 +02:00
Marc-André Bombeck d9b6061d28 chore(bundle): raise the aggregate budget for the re-proof dialog callers
The re-proof dialog now opens from every fresh-factor refusal, so the hook
and the dialog are imported by more on-demand chunks, which the aggregate
counts once each. The build measured 4977 KB gz against the 4975 cap; every
watched route stays inside its allowance. Loading the dialog through
next/dynamic measured 4979 and grew every watched route, so it is not kept.

Refs #959
2026-10-17 09:56:30 +02:00
dependabot[bot] 048ef0020e build(deps-dev): bump the development-weekly group across 1 directory with 8 updates (#1235)
Bumps the development-weekly group with 8 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [@testcontainers/postgresql](https://github.com/testcontainers/testcontainers-node) | `12.2.0` | `12.2.1` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `26.6.4` | `26.6.5` |
| [@vitest/coverage-v8](https://github.com/vitest-dev/vitest/tree/HEAD/packages/coverage-v8) | `5.0.3` | `5.0.4` |
| [knip](https://github.com/webpro-nl/knip/tree/HEAD/packages/knip) | `6.40.0` | `6.41.0` |
| [lefthook](https://github.com/evilmartians/lefthook) | `2.2.1` | `2.2.2` |
| [prettier](https://github.com/prettier/prettier) | `3.9.9` | `3.9.10` |
| [testcontainers](https://github.com/testcontainers/testcontainers-node) | `12.2.0` | `12.2.1` |
| [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) | `5.0.3` | `5.0.4` |



Updates `@testcontainers/postgresql` from 12.2.0 to 12.2.1
- [Release notes](https://github.com/testcontainers/testcontainers-node/releases)
- [Commits](https://github.com/testcontainers/testcontainers-node/compare/v12.2.0...v12.2.1)

Updates `@types/node` from 26.6.4 to 26.6.5
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `@vitest/coverage-v8` from 5.0.3 to 5.0.4
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v5.0.4/packages/coverage-v8)

Updates `knip` from 6.40.0 to 6.41.0
- [Release notes](https://github.com/webpro-nl/knip/releases)
- [Commits](https://github.com/webpro-nl/knip/commits/knip@6.41.0/packages/knip)

Updates `lefthook` from 2.2.1 to 2.2.2
- [Release notes](https://github.com/evilmartians/lefthook/releases)
- [Changelog](https://github.com/evilmartians/lefthook/blob/master/CHANGELOG.md)
- [Commits](https://github.com/evilmartians/lefthook/compare/v2.2.1...v2.2.2)

Updates `prettier` from 3.9.9 to 3.9.10
- [Release notes](https://github.com/prettier/prettier/releases)
- [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/prettier/compare/3.9.9...3.9.10)

Updates `testcontainers` from 12.2.0 to 12.2.1
- [Release notes](https://github.com/testcontainers/testcontainers-node/releases)
- [Commits](https://github.com/testcontainers/testcontainers-node/compare/v12.2.0...v12.2.1)

Updates `vitest` from 5.0.3 to 5.0.4
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v5.0.4/packages/vitest)

---
updated-dependencies:
- dependency-name: "@testcontainers/postgresql"
  dependency-version: 12.2.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development-weekly
- dependency-name: "@types/node"
  dependency-version: 26.6.5
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development-weekly
- dependency-name: "@vitest/coverage-v8"
  dependency-version: 5.0.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development-weekly
- dependency-name: knip
  dependency-version: 6.41.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development-weekly
- dependency-name: lefthook
  dependency-version: 2.2.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development-weekly
- dependency-name: prettier
  dependency-version: 3.9.10
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development-weekly
- dependency-name: testcontainers
  dependency-version: 12.2.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development-weekly
- dependency-name: vitest
  dependency-version: 5.0.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development-weekly
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-17 07:28:11 +00:00
Marc-André Bombeck bb53c7484f chore(release): v1.43.3 2026-10-17 09:17:59 +02:00
Marc-André Bombeck 99f0607a69 fix(auth): let every fresh-factor refusal open the re-proof dialog
A Guardian signed in through single sign-on with a second factor enrolled
could not hand a managed profile over once five minutes had passed since
sign-in: the handover answered 401 auth.stepup.required, and the panel
printed "Confirm your second factor, then try again" with no way to do so.

The same held for every action behind requireFreshMfa: creating, editing
and ending a managed profile, adding and removing a Guardian, disabling
the second factor, rotating recovery codes, removing a security key or a
passkey, changing the password, the data wipe, account deletion and the
key rotation. The recent-proof routes had the dialog; these had text.

- requireFreshMfa names the proofs that clear a stale stamp in
  meta.methods (the possession proofs the account holds, never a
  password, which stamps reproofAt only).
- The re-proof flow treats that refusal like auth.reproof.required:
  open the dialog, post the proof to /api/auth/reproof, send the request
  once more. The flow moves into a React-free module so it can be driven
  in tests; the hook binds it to the dialog.
- The managed-profile mutation hooks take a required proof guard, and
  every caller passes recentProof.run. The other surfaces wrap their
  requests in recentProof.run; disabling the second factor asks before
  its confirmation opens, so the code typed there is not spent twice.
- An account with nothing to re-prove in place (single sign-on only, no
  factor) is told to sign in again when adding a factor, instead of being
  offered a password it does not have.
- step-up-web-caller-guard freezes the gated route methods and fails when
  a web request to one of them runs outside the flow.

An account whose only second factor is the identity provider's is still
asked for an app factor on the managed-profile acts: the app cannot see
the provider's factor. Enrolling right after a sign-in needs no further
proof, and the confirmation stamps the session, so the act follows.

Refs #959
2026-10-17 09:17:03 +02:00
Marc-André Bombeck 59f910df5e Merge pull request #1234 from MBombeck/fix/v1432-consent
Release v1.43.2
2026-10-16 17:51:43 +02:00
Marc-André Bombeck 1a2853395b chore(release): v1.43.2 2026-10-16 17:11:58 +02:00
Marc-André Bombeck e2b7e7350e fix(consent): mint AI consent receipts only on an explicit agreement
The AI settings page posted to /api/consent/ai/web on every mount and the
server minted an ai_full receipt for it, so an account that had agreed to
nothing sent its health data to the operator's provider from background
jobs. Native builds synced a receipt the same way on every launch.

- Both grant routes now require `confirm: true`; anything else writes
  nothing and answers 400 consent.ai.confirmRequired.
- Remove the mount-time heal. A consent card names the operator's service
  and the data that would go there, with one Agree button, in Settings > AI
  and on the Insights overview while AI is closed by the missing consent
  alone.
- Publish the standing consent as `ai.consent` on /api/auth/me (status,
  since, recipients); the settings card renders active-since and the
  withdraw control from it.
- Existing receipts stay valid.
- Guard that no client grants outside a click handler, route tests for the
  confirmation, and an integration test that a fresh operator-key account
  gets no background AI text until it agrees.
2026-10-16 17:11:04 +02:00
Marc-André Bombeck bc5ea5a67b Merge pull request #1233 from MBombeck/fix/v1431
Release v1.43.1
2026-10-16 11:40:20 +02:00
Marc-André Bombeck d2fe55bc9d chore(release): v1.43.1 2026-10-16 11:01:44 +02:00
Marc-André Bombeck 2fb0ebfbf6 Merge remote-tracking branch 'origin/main' into fix/v1431 2026-10-16 11:01:29 +02:00
Marc-André Bombeck 0ee7e156d3 fix(report): print lab values with the precision they were recorded at
The doctor report PDF printed lab values and reference ranges with one
decimal and the share page with two at most, so a PSA of 0.034 read 0.0
or 0.03 and a TSH window of 0.27 read 0.3, while the labs page kept more.
The labs page itself cut values above 1 to two decimals.

One rule now decides every recorded number (src/lib/format/recorded-number):
a stored value keeps every digit it holds, minus binary float noise, and a
value converted from the unit its report printed keeps the precision of the
printed figure. The report payload carries sourceValue for that. The labs
page, its chart and history, the PDF lab table and footnote, the share
page, the day view and the OCR review all print through it, reference
bounds included. Doses on the PDF and the share page follow the same rule.

Measurement statistics in the PDF and on the share page now share one
per-row precision: up to three significant digits but never more decimals
than recorded, so a waist-to-height ratio of 0.52 no longer reads 0.5;
glucose keeps its unit's precision.

Refs #1228
2026-10-16 11:00:27 +02:00
Marc-André Bombeck f3c4fbff2c fix(illness): keep every episode reachable from the illness page
A recurring bout filed under its condition nests inside that condition's
card, but the list view drew no nested episodes, so an ended recurring
bout of an ended condition had no row anywhere and was found only through
search. A bout nested under an already nested parent was lost in either
view, and the page read only the list route's first page of 50, so the
oldest episodes dropped off the page, its settings and every picker.

Grouping moves into groupEpisodes: each episode nests under the highest
ancestor that shares its status, or is a card of its own, and a looping
parent chain leaves every member a card. Both views render the nested
episodes with their course and end date; the count heading says "flares"
only when they all are. useIllnessEpisodes reads page after page until a
short one.

Refs #1224
2026-10-16 11:00:26 +02:00
dependabot[bot] abc8ebd5c4 build(deps): bump the production-weekly group with 10 updates (#1231)
Bumps the production-weekly group with 10 updates:

| Package | From | To |
| --- | --- | --- |
| [@aws-sdk/client-s3](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-s3) | `3.1147.0` | `3.1150.0` |
| [@aws-sdk/lib-storage](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/lib/lib-storage) | `3.1147.0` | `3.1150.0` |
| [@napi-rs/canvas](https://github.com/Brooooooklyn/canvas) | `1.0.10` | `1.0.11` |
| [@tanstack/react-query](https://github.com/TanStack/query/tree/HEAD/packages/react-query) | `5.104.1` | `5.104.2` |
| [@tanstack/react-virtual](https://github.com/TanStack/virtual/tree/HEAD/packages/react-virtual) | `3.14.13` | `3.14.17` |
| [dotenv](https://github.com/motdotla/dotenv) | `18.0.6` | `18.0.7` |
| [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react) | `1.53.0` | `1.56.0` |
| [nodemailer](https://github.com/nodemailer/nodemailer) | `10.0.16` | `10.1.0` |
| [radix-ui](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/radix-ui) | `1.7.0` | `1.8.1` |
| [react-hook-form](https://github.com/react-hook-form/react-hook-form) | `7.89.0` | `7.90.0` |


Updates `@aws-sdk/client-s3` from 3.1147.0 to 3.1150.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-s3/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1150.0/clients/client-s3)

Updates `@aws-sdk/lib-storage` from 3.1147.0 to 3.1150.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/lib/lib-storage/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1150.0/lib/lib-storage)

Updates `@napi-rs/canvas` from 1.0.10 to 1.0.11
- [Release notes](https://github.com/Brooooooklyn/canvas/releases)
- [Changelog](https://github.com/Brooooooklyn/canvas/blob/main/CHANGELOG.md)
- [Commits](https://github.com/Brooooooklyn/canvas/compare/v1.0.10...v1.0.11)

Updates `@tanstack/react-query` from 5.104.1 to 5.104.2
- [Release notes](https://github.com/TanStack/query/releases)
- [Changelog](https://github.com/TanStack/query/blob/main/packages/react-query/CHANGELOG.md)
- [Commits](https://github.com/TanStack/query/commits/@tanstack/react-query@5.104.2/packages/react-query)

Updates `@tanstack/react-virtual` from 3.14.13 to 3.14.17
- [Release notes](https://github.com/TanStack/virtual/releases)
- [Changelog](https://github.com/TanStack/virtual/blob/main/packages/react-virtual/CHANGELOG.md)
- [Commits](https://github.com/TanStack/virtual/commits/@tanstack/react-virtual@3.14.17/packages/react-virtual)

Updates `dotenv` from 18.0.6 to 18.0.7
- [Changelog](https://github.com/motdotla/dotenv/blob/master/CHANGELOG.md)
- [Commits](https://github.com/motdotla/dotenv/compare/v18.0.6...v18.0.7)

Updates `lucide-react` from 1.53.0 to 1.56.0
- [Release notes](https://github.com/lucide-icons/lucide/releases)
- [Commits](https://github.com/lucide-icons/lucide/commits/1.56.0/packages/lucide-react)

Updates `nodemailer` from 10.0.16 to 10.1.0
- [Release notes](https://github.com/nodemailer/nodemailer/releases)
- [Changelog](https://github.com/nodemailer/nodemailer/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodemailer/nodemailer/compare/v10.0.16...v10.1.0)

Updates `radix-ui` from 1.7.0 to 1.8.1
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/radix-ui/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/radix-ui)

Updates `react-hook-form` from 7.89.0 to 7.90.0
- [Release notes](https://github.com/react-hook-form/react-hook-form/releases)
- [Changelog](https://github.com/react-hook-form/react-hook-form/blob/master/CHANGELOG.md)
- [Commits](https://github.com/react-hook-form/react-hook-form/compare/v7.89.0...v7.90.0)

---
updated-dependencies:
- dependency-name: "@aws-sdk/client-s3"
  dependency-version: 3.1150.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production-weekly
- dependency-name: "@aws-sdk/lib-storage"
  dependency-version: 3.1150.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production-weekly
- dependency-name: "@napi-rs/canvas"
  dependency-version: 1.0.11
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production-weekly
- dependency-name: "@tanstack/react-query"
  dependency-version: 5.104.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production-weekly
- dependency-name: "@tanstack/react-virtual"
  dependency-version: 3.14.17
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production-weekly
- dependency-name: dotenv
  dependency-version: 18.0.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production-weekly
- dependency-name: lucide-react
  dependency-version: 1.56.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production-weekly
- dependency-name: nodemailer
  dependency-version: 10.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production-weekly
- dependency-name: radix-ui
  dependency-version: 1.8.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production-weekly
- dependency-name: react-hook-form
  dependency-version: 7.90.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production-weekly
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-16 08:37:24 +00:00
dependabot[bot] 8c2222ccba build(deps): bump ossf/scorecard-action from 2.4.4 to 2.5.0 (#1230)
Bumps [ossf/scorecard-action](https://github.com/ossf/scorecard-action) from 2.4.4 to 2.5.0.
- [Release notes](https://github.com/ossf/scorecard-action/releases)
- [Changelog](https://github.com/ossf/scorecard-action/blob/main/RELEASE.md)
- [Commits](https://github.com/ossf/scorecard-action/compare/2d1146689b8cda280b9bc96326124645441f03bc...fa7117db83c52d787ae5a89f350999701505d78e)

---
updated-dependencies:
- dependency-name: ossf/scorecard-action
  dependency-version: 2.5.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-16 08:22:50 +00:00
dependabot[bot] cdd5cbdb61 build(deps): bump actions/download-artifact from 8.0.1 to 8.0.2 (#1229)
Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 8.0.1 to 8.0.2.
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c...9000827ccba6bdab643e8b6fd33ac0654aef8333)

---
updated-dependencies:
- dependency-name: actions/download-artifact
  dependency-version: 8.0.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-16 08:10:18 +00:00
Marc-André Bombeck 2f5c4659fd Merge pull request #1227 from MBombeck/release/v1.43.0
Release v1.43.0
2026-10-16 09:25:57 +02:00
Marc-André Bombeck d06ce36049 chore(release): v1.43.0 2026-10-16 08:46:13 +02:00
Marc-André Bombeck f543680ada fix(settings): keep the German nutrient card description within the length guideline 2026-10-16 08:42:43 +02:00
Marc-André Bombeck fb80b4bc62 fix(settings): say that the nutrient card's totals include the supplements taken 2026-10-16 08:42:13 +02:00
Marc-André Bombeck 53c53ccd76 Merge branch 'v143/ui390' into release/v1.43.0 2026-10-16 08:41:32 +02:00
Marc-André Bombeck 2f9628aa95 fix(links): name a related dose by its vaccine, not its catalogue slug 2026-10-16 08:32:49 +02:00
Marc-André Bombeck 27b173eb26 Merge branch 'release/v1.43.0' into v143/ui390 2026-10-16 08:03:29 +02:00
Marc-André Bombeck 69e2f18788 fix(ui): tidy the new record and settings surfaces at phone width
- Composition amounts show only the decimals they carry, so 100 µg no
  longer reads as 100.000 µg; the remove button stays on the amount row.
- AUDIT-C remove controls are icon-only, so they no longer spill out of
  the card or cover the earlier answer.
- Life-circumstance labels keep their colon when they wrap, checkbox
  groups sit in two columns and the weekly-minutes sentence ends.
- Link chips get a 44 px hit area for their remove control.
- The AI usage level reports a failed save with a sentence and the
  error id instead of a bare "Error".
- The Dawarich home-country and admin refresh-gap labels sit above
  their fields.
2026-10-16 08:03:29 +02:00
Marc-André Bombeck 0de5a3e5df docs(changelog): describe the merge preview as it works 2026-10-16 07:14:11 +02:00
Marc-André Bombeck b31b81fec6 docs: name the new sync-gap variable, the merge preview and the Dawarich schedule zone 2026-10-16 07:13:57 +02:00
Marc-André Bombeck 70de08792e docs(changelog): add v1.43.0 2026-10-16 07:01:36 +02:00
Marc-André Bombeck c6d57e7ccf Merge branch 'v143/bundle' into release/v1.43.0 2026-10-16 06:59:53 +02:00
Marc-André Bombeck 057fd3b49c perf(bundle): keep Zod off the first load of four eager routes
zod 4.6 grew the shared Zod chunk from 63 to 89 KB gz (the new schema
compiler and its memoizer ride along because every schema goes through
the `z` namespace), and the dashboard, measurements, mood and settings
routes each carried it for values that need no validation.

Move the measurement source list, the notes limit, the screening check
and the Coach source list into value-only modules the schemas are built
from, import the settings classifier and response check from their
leaves, and load the gate's managed settings and anamnesis sections on
demand. Re-base the four route baselines to the measured values and the
aggregate cap to 4975, with the reason in the budget comment.
2026-10-16 06:58:48 +02:00
Marc-André Bombeck a806676a71 Merge branch 'v143/fixdata' into release/v1.43.0 2026-10-16 06:22:02 +02:00
Marc-André Bombeck 852433255c test: follow the newest-entry read and the pulse card toast count 2026-10-16 06:05:08 +02:00
Marc-André Bombeck d3f6d2d7c7 Merge branch 'v143/fixui' into release/v1.43.0 2026-10-16 06:04:00 +02:00
Marc-André Bombeck f715190703 Merge branch 'release/v1.43.0' into v143/fixdata 2026-10-16 05:57:22 +02:00
Marc-André Bombeck 617b87a583 fix(measurements): sum both starts of the repeated fall-back hour into its slot 2026-10-16 05:57:19 +02:00
Marc-André Bombeck f360d221e1 fix(admin): record the pulse rebuild and let a stopped one run again
The rebuild after apply now writes its state beside the dry run
(queued, running, done with counts, failed); a run left queued or
running without a job reads as failed. The card shows it and offers a
new rebuild after a failure, which the route accepts once the method is
applied, without a new dry run.
2026-10-16 05:55:31 +02:00
Marc-André Bombeck beb7ef4ccc Merge branch 'release/v1.43.0' into v143/fixui 2026-10-16 05:55:14 +02:00
Marc-André Bombeck d6ea1c7973 Merge branch 'v143/loose' into release/v1.43.0 2026-10-16 05:50:52 +02:00
Marc-André Bombeck cc9d523bc8 fix(nutrients): recompute supplement rows when the module returns or the zone moves 2026-10-16 05:49:58 +02:00
Marc-André Bombeck abbb424d21 fix(targets): read the seven-day basis as one aggregate instead of the whole summary slice 2026-10-16 05:49:51 +02:00
Marc-André Bombeck 37645fc4bb fix(measurements): cut drain days in the zone the server publishes for the account 2026-10-16 05:48:13 +02:00
Marc-André Bombeck 527d177371 test(mood): keep the random request id out of the no-leak byte check 2026-10-16 05:48:00 +02:00
Marc-André Bombeck d2a806364b test(mcp): accept type-array unions in the strict output-schema check 2026-10-16 05:48:00 +02:00
Marc-André Bombeck 3c44f3d167 fix(auth): ask the provider for a fresh sign-in when claiming a profile 2026-10-16 05:45:59 +02:00
Marc-André Bombeck 23f008c8bb fix(anamnesis): serialise the AI switch writes so a PUT cannot resurrect a section 2026-10-16 05:45:06 +02:00
Marc-André Bombeck 13c74c4e92 test(targets): pin the resting pulse verdict below a default band as hold 2026-10-16 05:44:17 +02:00
Marc-André Bombeck 8ae60e2cb3 fix(coach): read free weekdays for a prompt only with the work section released 2026-10-16 05:42:15 +02:00
Marc-André Bombeck 120b0966b1 fix(ai-usage): never regenerate more often on frugal than on standard
The frugal sync gap is the larger of a day and the operator's sync gap,
so an instance gap above 24 hours no longer leaves frugal ahead of
standard.
2026-10-16 05:38:36 +02:00
Marc-André Bombeck a8b46b9ac0 docs(api): regenerate the OpenAPI contract 2026-10-16 05:37:18 +02:00
Marc-André Bombeck b99b70127d fix(anamnesis): one newest AUDIT-C entry, scored against the wording offered
The stored language is the wording the read route offers the account;
a request naming another wording, or a takenAt more than five minutes
ahead, is refused. Latest, offerDue, the Coach block and the doctor
report all read the newest entry whose answers open.

The old one-word alcohol pattern gives way to an AUDIT-C entry in the
Coach block, and the alcohol section lists it with a remove action.
2026-10-16 05:36:42 +02:00
Marc-André Bombeck 2fc74e76df fix(targets): place the target verdict on the dashboard's seven-day basis 2026-10-16 05:35:52 +02:00
Marc-André Bombeck 6776decaa0 test(guard): discover the resolved fields and demand a reader or a stated reason for each 2026-10-16 05:35:48 +02:00
Marc-André Bombeck da1d31e370 feat(labs): show and file a reading's related report and visit in its sheet 2026-10-16 05:34:08 +02:00
Marc-André Bombeck 7167ba8cdc fix(labs): pair the panel changes by marker, not by printed name 2026-10-16 05:32:13 +02:00
Marc-André Bombeck 8ba4d1b0d0 fix(nutrients): name the form an upper limit covers and phrase it per locale 2026-10-16 05:31:33 +02:00
Marc-André Bombeck d66d3969cd fix(records): show the screening hints when one applies without a smoking answer 2026-10-16 05:28:53 +02:00
Marc-André Bombeck e9a0b3d959 fix(dawarich): keep a boundary fragment from overwriting a complete day
Dawarich groups the timeline by the day in its own account zone and sums
only what lies inside the window. Reach a whole day past both ends of
each request and keep only the chunk's own dates, so the cut edge days
are discarded and chunks never overwrite each other.
2026-10-16 05:28:51 +02:00
Marc-André Bombeck c777819b33 fix(integrations): bound the Dawarich and Nightscout answers while reading 2026-10-16 05:28:07 +02:00
Marc-André Bombeck 1a7050d95d fix(backup): bring every relation link back from a portable restore
A portable file carries no id for a medication or a lab result, so the
restore mints fresh ones and the links that named the old ids were
dropped as having no end. Carry the source id as ref on both entries and
re-point the links through an old-to-new map on restore.
2026-10-16 05:25:50 +02:00
Marc-André Bombeck b3dcd428f6 fix(settings): stack the AI usage estimate on narrow screens and read readiness from the server 2026-10-16 05:23:59 +02:00
Marc-André Bombeck 86113848b5 fix(medications): refresh the episode and document details after filing a link 2026-10-16 05:23:06 +02:00
Marc-André Bombeck 0cb877e0a2 fix(targets): hold below a default band for load measures and above the band for one-sided measures 2026-10-16 05:22:26 +02:00
Marc-André Bombeck a17f85c050 docs(ops): add the recovery steps for an upgrade stopped at migration 0409 2026-10-16 03:57:46 +02:00
Marc-André Bombeck 1734f149d3 docs(openapi): deprecate the legacy integration status fields 2026-10-16 03:57:46 +02:00
Marc-André Bombeck adf0c7f4c2 refactor(validation): route every ISO instant through isoDateTime 2026-10-16 03:57:46 +02:00
Marc-André Bombeck 92b361c3c6 feat(validation): add isoDateTime with a refusal counter for instants without seconds 2026-10-16 03:57:45 +02:00
Marc-André Bombeck b1c19dcfa4 build(deps): move zod to 4.6.7 and lift the 4.5 hold 2026-10-16 03:44:55 +02:00
Marc-André Bombeck c508ce61e3 chore(bundle): raise the aggregate budget to 4860 on the measured v1.43 build 2026-10-16 03:41:58 +02:00
Marc-André Bombeck 4daad8f2fb Merge branch 'v143/life' into release/v1.43.0
# Conflicts:
#	docs/api/openapi.yaml
#	src/__tests__/contract-stub-inventory.test.ts
#	tests/integration/sharing-manage-handler-matrix.test.ts
2026-10-16 03:39:40 +02:00
Marc-André Bombeck df3136a2a1 feat(anamnesis): announce the life circumstances to clients 2026-10-16 03:27:31 +02:00
Marc-André Bombeck c39dca8fe4 Merge branch 'v143/intfix' into release/v1.43.0 2026-10-16 03:17:11 +02:00
Marc-André Bombeck ed3597adb2 Merge branch 'release/v1.43.0' into v143/intfix 2026-10-16 02:59:48 +02:00
Marc-André Bombeck fc4423c934 test(environment): stop asserting the readable location columns 0409 dropped
The free-text backfill no longer seals home, period or day locations, so its
summary has no counts for them, and a stored day has no readable lat left to
be null.
2026-10-16 02:59:48 +02:00
Marc-André Bombeck 87335d3080 test(managed-profiles): compare the guardian refusals without the request id
Every error envelope now carries a fresh meta.requestId. The unknown-id and
the not-yours refusal still have to match byte for byte on everything else;
compare them through the shared helper, as the other enumeration checks do.
2026-10-16 02:59:48 +02:00
Marc-André Bombeck f5ed62276e test(wipe): seed a column fenced by a CHECK list with a value it admits
The schema-driven seeder wrote a synthetic tag into every required text
column. The v1.43 tables fence some of them with CHECK (col IN (...)), so the
seed was refused before either wipe ran. Read each single-column IN-list check
from pg_constraint and seed its first admitted value.
2026-10-16 02:59:47 +02:00
Marc-André Bombeck 765f6c0e15 test(sharing): count both v1.43 admissions in the manage matrix
The biomarker merge and the supplement composition PUT each brought a strict
driver and an owned effect, and each branch raised the count from 88 to 89 on
its own. Together the inventory holds 90.
2026-10-16 02:59:47 +02:00
Marc-André Bombeck da860aeb3e Merge branch 'v143/dawarich' into release/v1.43.0
# Conflicts:
#	src/__tests__/contract-stub-inventory.test.ts
2026-10-16 02:51:02 +02:00
Marc-André Bombeck 8f59d1c5ab docs(api): describe the date-only valid-since day 2026-10-16 01:40:16 +02:00
Marc-André Bombeck c03f79afd5 test(dawarich): pin the movement block, the channels and the mocks; document the connection
Covers the day section, the model projection without the travel country and
the two correlation channels; the backup and correlation mocks learn the new
delegate. Adds the operator page for DAWARICH_PRIVATE_ORIGINS and regenerates
the API contract.
2026-10-16 01:38:05 +02:00
Marc-André Bombeck 4d2c8c2bbb test(anamnesis): drive the life circumstances, AUDIT-C and its backup through real Postgres and the manage matrix 2026-10-16 01:34:17 +02:00
Marc-André Bombeck 88aa5ccacd test(anamnesis): pin derived values, AUDIT-C wording and cut-offs, and the per-section prompt gate 2026-10-16 01:27:00 +02:00
Marc-André Bombeck 0ba2e36b79 test: compare the target cards without the route's direction verdict, and stop pinning the full feature set 2026-10-16 01:26:15 +02:00
Marc-André Bombeck e5860c9f41 docs(api): regenerate the OpenAPI document for the life circumstances 2026-10-16 01:25:35 +02:00
Marc-André Bombeck 73bb13a982 feat(anamnesis): soft-delete AUDIT-C in its route, admit both AUDIT-C verbs at manage level, and settle the guards 2026-10-16 01:24:56 +02:00
Marc-André Bombeck 3aa2a6fd26 Merge branch 'v143/pulse' into release/v1.43.0 2026-10-16 01:23:33 +02:00
Marc-André Bombeck b99bcaeec6 test(dawarich): cover the daily values, the refusals and the backup ends
A stub Dawarich instance on the loopback interface drives the shipped routes
and the sync against real SQL. Unit tests pin the parsers, the time-away
arithmetic, the origin grant, the travel runs, the places route's no-write
rule, the backup pair and the schema's no-location rule.
2026-10-16 01:23:26 +02:00
Marc-André Bombeck 0a0667fd1c test(admin): pin when the pulse day-mean card offers apply 2026-10-16 01:23:03 +02:00
Marc-André Bombeck b838ed4403 feat(dawarich): fetch daily movement values from a Dawarich instance
Connect, status, places, settings, test, sync and disconnect replace their
stubs. A nightly job (03:20) and the connect backfill read the timeline,
visits and residency endpoints through safeFetch and store one row of daily
values per local day: distances, places visited, minutes away from the home
place and the sealed country with its travel-day verdict. A private instance
needs its exact origin in DAWARICH_PRIVATE_ORIGINS.

The connection joins the shared ledger and the connection health model,
watched for errors only. The day carries a movement block, the model
projection drops the travel country, the two movement channels join the
correlation matrix under the environment module, travel days become trip
suggestions, and the days travel in the environment backup section.
2026-10-16 01:20:53 +02:00
Marc-André Bombeck 28a8737124 feat(anamnesis): life-circumstances card with AUDIT-C, screening hints and free-text take-over 2026-10-16 01:09:50 +02:00
Marc-André Bombeck 17d1c00aab docs(api): list the pulse day-mean maintenance error codes 2026-10-16 01:07:39 +02:00
Marc-André Bombeck 46e82efe22 test(pulse): prove the day-mean method across readers, the dry run's single write and the bounded heap 2026-10-16 01:07:24 +02:00
Marc-André Bombeck 9f17cef8e4 feat(admin): pulse day-mean dry run, apply step and admin card 2026-10-16 00:58:48 +02:00
Marc-André Bombeck db750ef246 feat(anamnesis): carry life circumstances into the coach context, the doctor report and FHIR 2026-10-16 00:58:34 +02:00
Marc-André Bombeck f2be772eaf feat(anamnesis): serve the life circumstances, record AUDIT-C, and keep both off the closed legacy lists 2026-10-16 00:54:38 +02:00
Marc-André Bombeck 877920f5f7 feat(pulse): weigh sparse hours in the pulse day mean by the stored method 2026-10-16 00:49:41 +02:00
Marc-André Bombeck 1e7fc08f57 chore: drop exports the composition and marker merge left unread 2026-10-16 00:37:02 +02:00
Marc-André Bombeck 0dd634c9c8 Merge branch 'v143/links' into release/v1.43.0
# Conflicts:
#	docs/api/openapi.yaml
#	src/__tests__/contract-stub-inventory.test.ts
#	src/app/api/medications/[id]/route.ts
#	src/components/medications/detail/medication-detail-tabs.tsx
#	src/lib/export/backup-plan.ts
2026-10-16 00:34:53 +02:00
Marc-André Bombeck 3fc37c9183 Merge branch 'v143/ios' into release/v1.43.0 2026-10-16 00:28:40 +02:00
Marc-André Bombeck 6146082824 Merge branch 'v143/supplements' into release/v1.43.0
# Conflicts:
#	docs/api/openapi.yaml
#	messages/de.json
#	messages/en.json
#	messages/es.json
#	messages/fr.json
#	messages/it.json
#	messages/ko.json
#	messages/pl.json
#	src/__tests__/sharing-surface-guard.test.ts
#	src/lib/ai/capabilities/types.ts
#	src/lib/export/backup-plan.ts
2026-10-16 00:28:29 +02:00
Marc-André Bombeck 1481e38fae feat(links): show related records and file the new pairs on the web
A shared related-records component renders related[] as rows that open the
server-resolved path, with how a derived relation runs. The medication
overview lists them and, in the account's own record, files its condition,
documents and visits; the visit sheet files medications and doses. The
condition page, the document sheet and the dose sheet list what their own
blocks do not already show. A dose's visit named by the form's field counts
as the same relation as the link table.
2026-10-16 00:25:04 +02:00
Marc-André Bombeck b7706d2ecc test(mood): read the patterns through the real aggregate, note days included 2026-10-16 00:22:23 +02:00
Marc-André Bombeck c5da5debb2 test(medications): hand the label upload a plain byte array 2026-10-16 00:19:34 +02:00
Marc-André Bombeck a0f6b89e92 feat(measurements): take hourly sums of summed types into the intraday profile and a backfill trigger that moves no live stamp; fix(backup): restore qualitative lab markers 2026-10-16 00:18:35 +02:00
Marc-André Bombeck b35da281aa test(sharing): drive the composition PUT through the manage matrix 2026-10-16 00:17:11 +02:00
Marc-André Bombeck 5c039a220b Merge branch 'v143/sso' into release/v1.43.0
# Conflicts:
#	docs/api/openapi.yaml
#	src/__tests__/contract-stub-inventory.test.ts
2026-10-15 23:59:03 +02:00
Marc-André Bombeck fb3fbd5860 test(medications): pin the upper-limit day and the history recompute on rows the route keeps 2026-10-15 23:58:06 +02:00
Marc-André Bombeck b4bc039d16 feat(links): close the relation matrix and publish related[] on six details
The link service gains medication and lab result sources and the five new
tables, each readable from both ends. POST/DELETE /api/medications/{id}/links
and /api/labs/{id}/links replace their 501 stubs; the visit links route also
files medications and doses, the dose links route its visit. A scan commit
with a documentId files each written result against its report.

GET on a lab result, visit, document, condition, medication and dose now
carries related[]: stored links, the condition a result shares a visit with,
and the medication whose efficacy target is the result's marker, each with
its web path. A kind is listed only when its module is on and the caller's
grant covers its section. relation-matrix.test.ts holds every one of the
fifteen pairs to a table, a reader or a written exception.
2026-10-15 23:57:34 +02:00
Marc-André Bombeck ffd46b017b feat(backup): carry the five relation link tables both ways
Lab result to document, visit to medication, visit to dose, medication to
document and medication to condition now ride the full backup in their own
section file and come back with their original filing instant. A link whose
end the restore did not write is dropped and reported under the new
relationLink skip kind. The tables leave the coverage register for the
two-ended list, guarded by relation-links-backup-completeness.test.ts and
seeded in the round trip.
2026-10-15 23:57:27 +02:00
Marc-André Bombeck 6e7db8d72c fix(oidc): send a claim's state refusal through its own browser-only redirect 2026-10-15 23:55:43 +02:00
Marc-André Bombeck 8ac19e422a docs(api): regenerate the contract for the backup's medication composition 2026-10-15 23:54:09 +02:00
Marc-André Bombeck fe687b0870 test(nutrients): pin that the batch still refuses the supplement-only codes 2026-10-15 23:52:50 +02:00
Marc-André Bombeck a2db048a4d test(medications): cover the composition, the supplement recompute, the label read and the routes end to end 2026-10-15 23:52:30 +02:00
Marc-André Bombeck 01fef691fa feat(web): edit a supplement's composition, read it from a label photo, and show nutrient sources with the upper limit 2026-10-15 23:48:43 +02:00
Marc-André Bombeck 937678e5ea test(logging): keep the handover token out of the single-sign-on claim's logs 2026-10-15 23:39:40 +02:00
Marc-André Bombeck 8c406c3f8b docs(sharing): document handing a record over, including through single sign-on 2026-10-15 23:39:39 +02:00
Marc-André Bombeck 79f7eb199b feat(insights): resolve direction verdicts, chart reference lines and mood patterns on the server 2026-10-15 23:39:11 +02:00
Marc-André Bombeck 9fd0cf264d test(claim): the handover works on a single-sign-on-only server, the password claim does not 2026-10-15 23:38:39 +02:00
Marc-André Bombeck 9d32a8acb0 feat(medications): let a delegate manage a composition, bypass the proxy for label uploads, and keep the guards current 2026-10-15 23:37:26 +02:00
Marc-André Bombeck 0ebb61343e feat(claim): offer the identity provider on the claim page and name the way in to guardians
The claim page shows the password form, a continue-with-provider button, or
both, as the preview names them, and explains a refused single-sign-on claim
when the callback sends the browser back. The guardian's handover panel says
how the person will sign in.
2026-10-15 23:34:38 +02:00
Marc-André Bombeck eb3cc4237f feat(medications): store a supplement's composition, read its label with the document AI, and carry it through backup and restore 2026-10-15 23:29:40 +02:00
Marc-André Bombeck e292db1fbd feat(notifications): give guardian and discreet deliveries the app root as tap target
Every visible delivery now carries a url. A managed-record delivery to a
guardian and a discreet one open "/", which names no event and no record;
only the silent intake sync push stays without one. The tap-target test walks
every event type across own, guardian and discreet delivery and the channels
that carry the absolute url. Document the forward-auth exemption for the
quick-answer route.
2026-10-15 23:25:51 +02:00
Marc-André Bombeck 98d64c2052 feat(nutrients): add DHA, EPA, input units and upper limits to the catalogue, and count supplement doses as a nutrient source 2026-10-15 23:19:45 +02:00
Marc-André Bombeck b91328d67e feat(records): publish an age- and sex-aware benchmark beside each personal record 2026-10-15 23:18:58 +02:00
Marc-André Bombeck 3fd6bd6081 test(claim): drive the single-sign-on claim against a mock identity provider
Check the link before the code is redeemed and refuse a live session first,
so a refused claim spends nothing at the provider.
2026-10-15 23:18:24 +02:00
Marc-André Bombeck 326619069b fix: route the AI usage and lab merge error toasts through the error-id helper 2026-10-15 23:16:52 +02:00
Marc-André Bombeck b562587c45 feat(cycle): name the start a period start folds in and the starts a removal gives back 2026-10-15 23:16:11 +02:00
Marc-André Bombeck 9eae352bb1 feat(claim): hand over a managed profile through single sign-on
POST /api/auth/claim/sso checks the handover link and the username and seals
the link id, the username and a per-start binding into an hl_claim cookie.
GET /api/auth/oidc/login?intent=claim carries the binding into the encrypted
OIDC state, and the callback binds the provider subject to the profile in the
claim transaction, refusing a subject or verified email another account
holds, a link that is no longer usable, and a live session.

The handover status and the claim preview name the redemption methods; a
single-sign-on-only server hands over through the identity provider, and only
the password claim stays refused there.
2026-10-15 23:12:59 +02:00
Marc-André Bombeck fd44a8381b Merge branch 'v143/ai' into release/v1.43.0
# Conflicts:
#	docs/api/openapi.yaml
#	messages/de.json
#	messages/en.json
#	messages/es.json
#	messages/fr.json
#	messages/it.json
#	messages/ko.json
#	messages/pl.json
#	src/__tests__/contract-stub-inventory.test.ts
2026-10-15 23:12:51 +02:00
Marc-André Bombeck 74fa4e10b6 Merge branch 'v143/labs' into release/v1.43.0 2026-10-15 23:06:51 +02:00
Marc-André Bombeck d45a0d5d8e docs(ai): describe the AI usage levels and where the sync gap is set
Refs #1217
2026-10-15 23:06:08 +02:00
Marc-André Bombeck ace847784f test(integration): pin the AI usage levels end to end
Against real Postgres: ai.usage on /api/auth/me with the measured use and
the estimate, the PUT and its ceiling refusal, an operator lowering the
ceiling over a stored choice, supplementLabel closing on frugal, and the
status invalidator holding a frugal account's sync to one regeneration per
scope a day while a hand entry after six hours goes through. The capability
wiring test now covers supplementLabel.

Refs #1217
2026-10-15 23:05:32 +02:00
Marc-André Bombeck e112e05a58 fix(labs): name a merge in the owner's activity feed and toast it through the outcome module
A merge by a record manager now reads as its own sentence in the owner's
activity feed, the merged toast goes through the written-outcome toast,
and the export test doubles carry the alias and merge tables.
2026-10-15 23:03:45 +02:00
Marc-André Bombeck 5dab12472f Merge branch 'v143/support' into release/v1.43.0 2026-10-15 23:01:52 +02:00
Marc-André Bombeck ec67663def feat(settings): choose the AI usage level, and set its bounds as an operator
Settings, AI gains one card with the three levels, each with what it does
and the server's estimate of tokens per day, labelled as one. Levels above
the operator's ceiling are shown disabled with the reason; a capped choice
is named and kept; a stored choice can return to the server default. The
automatic document read says when the frugal level rests it. Admin, Coach
gains the ceiling, the default and the sync gap, and the provider readout
the accounts per level. Strings in all seven languages.

The admin card's save confirms with a success toast like its neighbour
assistant-section; it is a settings write without counts, so it is pinned
in the success-affordance guard at one.

Refs #1217
2026-10-15 22:55:40 +02:00
Marc-André Bombeck 74dcc67ef7 feat(insights): let the usage level decide the background AI work
The level now sets, per record: the status-card regeneration gap after a
sync (frugal 24 h, standard the operator's gap, full 1 h) and after a hand
entry (frugal 6 h, otherwise 1 h); the status snapshot cap (10 000 compact
characters on frugal, 14 000 above); background reasoning for the briefing
and the period narratives (off on frugal); the daily briefing's form (a
short form on frugal that keeps every field and at most one
recommendation, the full contract on standard and full); and whether the
automatic document read may act (frugal reads on request only, the stored
switch is kept).

A deterministic simulation of a watch-synced day through the invalidator
counts 122 ingest-driven status generations on full (the v1.42 behaviour),
25 on standard and 2 on frugal, plus the nightly warm on each.

Refs #1217
2026-10-15 22:55:40 +02:00
Marc-André Bombeck e65ada2961 feat(labs): merge markers, edit their aliases and pick a marker on review
The marker list offers a merge with the server's preview: what moves, what
is converted, which names become aliases, and a refusal when a unit cannot
be converted; it says a merge cannot be undone. The marker page shows and
edits the other spellings and follows a merged marker to its target. Scan
and document review name the matched marker or offer its suggestions and a
new marker; the choice is remembered. The biomarkerMerge feature flag is
on.
2026-10-15 22:55:08 +02:00
Marc-André Bombeck 9c01e179ca test(integration): compare the oidc refusals without their request ids 2026-10-15 22:55:03 +02:00
Marc-André Bombeck 24c74bd3eb test(integration): keep the requestId out of the mcp refusal it does not carry 2026-10-15 22:54:17 +02:00
Marc-André Bombeck d3770b9010 feat(admin): set the AI usage bounds and count accounts per level
PUT /api/admin/settings/assistant-flags takes an optional aiUsage block:
the highest level an account may choose, the default for accounts without
a choice (422 ai.usage.defaultAboveCeiling when above the ceiling) and the
sync regeneration gap at standard (1..48 hours, null follows
AI_SYNC_REGEN_GAP_HOURS, else 6). Lowering the ceiling caps every account
above it at once and keeps their stored choice. The provider-health readout
counts accounts per effective level.

Refs #1217
2026-10-15 22:49:01 +02:00
Marc-André Bombeck 6f673a7b84 feat(ai): resolve an AI usage level per record and publish it as ai.usage
Three levels (frugal, standard, full) with an operator ceiling and default
and a per-account choice within them, resolved in the capability layer for
the record and published on GET /api/auth/me as ai.usage: the level in
effect, the stored choice, the bounds, why (user, default, admin_cap), the
three options with whether each is allowed and an estimate of tokens per
day scaled from the record's last seven full days of coach_usage, and the
measured mean. A delegate sees the level, never the owner's spend.

PUT /api/auth/me/ai-usage stores the account's own choice or null for the
default, refusing a level above the ceiling with 422 ai.usage.aboveCeiling
and meta.maxLevel. The new supplementLabel capability needs standard or
above; below it the capability is unavailable with the new usage_level
reason, refused with 403 ai.usage.levelTooLow and meta.requiredLevel.

Refs #1217
2026-10-15 22:49:01 +02:00
Marc-André Bombeck e573f28ae8 perf(coach): keep the system prompt stable across turns and rounds
The opener hint, the first-name clause, resolved follow-ups, the table rules
and the final-round line used to be written into the system prompt, so the
cached prefix (tools plus system) was rewritten on every turn and again on
the forced final round. They travel on the user side now: the per-turn lines
in the user message, the table rules as a note after the round that produced
the first table, the final-round reason as the last user line. The system
prompt depends on the locale, the Coach preferences and the about-me text
only.

Refs #1217
2026-10-15 22:49:00 +02:00
Marc-André Bombeck c2245d1d79 perf(insights): send snapshots as compact JSON and size the cap against it
The status cards, the briefing, the derived assessments and the Coach
snapshot drop the two-space indentation from the JSON they hand the model.
The content is unchanged (the hash gate hashes the object, so stored hashes
stay valid). MAX_SNAPSHOT_CHARS is now measured on the compact string: 14 000
for the status cards and the Coach, which every snapshot that fitted the old
24 000 pretty-printed characters still fits, so the model reads the same data
for fewer tokens.

Refs #1217
2026-10-15 22:49:00 +02:00
Marc-André Bombeck ae8b614dd0 feat(insights): choose the status regeneration gap by ingest source
A sync from a watch or an integration now waits the instance's sync gap
(AppSettings.aiSyncRegenGapHours, else AI_SYNC_REGEN_GAP_HOURS, else 6 h)
before it may regenerate a status card again; a hand entry keeps the hour.
Every caller of afterMeasurementMutation and invalidateStatusInsightsForTypes
names its source, so a new ingest path cannot skip the choice.

Refs #1217
2026-10-15 22:49:00 +02:00
Marc-André Bombeck 0ec7b80a9e style(api): format the request id envelope test 2026-10-15 22:42:05 +02:00
Marc-André Bombeck 703689069f docs(support): document error ids, the support info and the support report, and ask for them in bug reports 2026-10-15 22:37:18 +02:00
Marc-André Bombeck 6979928d73 chore(web): drop toast imports the error toast helper made unused 2026-10-15 22:36:34 +02:00
Marc-André Bombeck f7386742b4 feat(web): show the error id on server failures and add the support info and report surfaces 2026-10-15 22:31:51 +02:00
Marc-André Bombeck e5544913f6 test(labs): prove merges, aliases and their backup against real SQL
The merge moves every reading and every reader shows one series, a dry run
answers the same and writes nothing, conversions keep the printed value,
an unconvertible source refuses the whole merge, a chosen spelling resolves
on the next scan and on manual entry, and aliases and the merge ledger
survive both kinds of backup. The merge route joins the record-scoped
manage inventory with its driver.
2026-10-15 22:28:32 +02:00
Marc-André Bombeck 81be73fca8 feat(labs): merge markers, keep aliases and read lab series per marker
POST /api/biomarkers/{id}/merge folds markers into one in a transaction,
converting readings where the registry knows the units and refusing the
whole merge where it does not; a dry run answers the same result and
writes nothing. Aliases appear on the marker and are edited through the
marker's PUT. Scan and document review rows name the matched marker or
offer up to three, and a chosen marker is remembered as an alias. The
doctor report, FHIR and MCP group readings by marker. Aliases and the
merge ledger travel through backup and restore.
2026-10-15 22:22:40 +02:00
Marc-André Bombeck a3ce6af9a3 feat(labs): resolve marker names through aliases and catalog shorthand
A printed name now resolves to one of the account's markers by its own
name, then by an alias of the account, then by a catalog name for the same
analyte when the reading's unit fits. The catalog gains seeds for alkaline
phosphatase and total bilirubin and a checked list of common lab shorthand
in seven languages; names that mean two analytes stay out.
2026-10-15 22:22:29 +02:00
Marc-André Bombeck 800bebd45c chore(support): drop an unused lint directive 2026-10-15 22:18:29 +02:00
Marc-André Bombeck b873ce8bc8 feat(admin): fill the redacted support report with its redaction guard 2026-10-15 22:15:50 +02:00
Marc-André Bombeck 20a1ac4f7d test(links): prove a period search returns exactly that period
Parser cases for every accepted form in de, en, es, fr, it, pl and ko and
the near misses that stay text; builder cases for every family; and an
integration walk over three years of lab results, conditions, visits and
documents, with records either side of each boundary in the person's
zone, across every page.
2026-10-15 22:11:50 +02:00
Marc-André Bombeck e16c9fb717 feat(links): search a year or a month in the link pickers by its date window
A picker term that names a period ("2019", "03.2019", "2019-03",
"März 2019", "2019년 3월") is sent as the list route's from/to window
instead of q, newest first, on every family including the visit list,
which has no text search. Month names come from Intl for the UI locale,
folded for case, accents and umlaut spellings. Instant routes get the
period's bounds in the person's timezone; the document route gets the
period's calendar days. The sheet names the period it shows.
2026-10-15 22:11:49 +02:00
Marc-André Bombeck 1af0de5e87 feat(logging): keep warnings and errors in their own ring and find them by error id 2026-10-15 22:06:39 +02:00
Marc-André Bombeck 2fd62cc115 feat(api): carry the request id in meta.requestId on every error envelope 2026-10-15 22:05:06 +02:00
Marc-André Bombeck f75a2fe343 Merge branch 'v143/w0' into release/v1.43.0 2026-10-15 21:54:07 +02:00
Marc-André Bombeck 3786deb503 feat(api): publish the v1.43 contract with routes that answer 501 until filled
Every new endpoint of the contract exists, wrapped in apiHandler, with its
authentication, module gate and request validation, and answers
501 not_implemented until its behaviour lands. The OpenAPI document carries
the new operations and schemas and the additive response fields on the
changed routes. The four server feature flags are published as false.

contract-stub-inventory.test.ts registers the stubbed routes; each one
leaves the register with the change that fills it. The route, sharing,
session, security, module-gate and outcome inventories name the new routes
deliberately, and the error-code catalogue lists the codes the routes emit
today.
2026-10-15 21:49:02 +02:00
Marc-André Bombeck 6e72de1c35 feat(db): add the v1.43 schema and drop the readable location columns
Migrations 0402 to 0410: AI usage levels, biomarker aliases and merges,
medication ingredients (with the supplement label run kind), life
circumstance fact kinds, AI sections and AUDIT-C, Dawarich context, the
pulse day-mean method, intraday hour buckets, the readable location drop
and five relation link tables. Every new column carries its reader or a
pending-consumer marker.

0409 refuses to run while any home, location period or stored day still
holds a readable location, and names the counts. Locations are read from
the sealed copy only; the encryption backfill no longer handles them, and
the demo seed writes sealed locations only.

The new tables are backed up and wiped, the new sealed columns are in the
rotation registry with labels of their own, the AI usage level and the
life-circumstance confirmations travel with the backup, and the wellbeing
score reads the mental-health screeners only.
2026-10-15 21:48:57 +02:00
Marc-André Bombeck 0b64b80343 test(sharing): count the admitted biomarker delete in the fixture inventory 2026-10-15 21:31:50 +02:00
Marc-André Bombeck 97871f7b90 fix(http): mint the fresh request id through randomId
The proxy no longer calls crypto.randomUUID itself, so it leaves the
client-random-id exemption list; the request-id helper uses the shared
randomId, which also works without a secure context.
2026-10-15 21:31:50 +02:00
dependabot[bot] 037f44d473 chore(deps): bump actions/setup-node from 7.0.0 to 7.1.0 (#1209)
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 7.0.0 to 7.1.0.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/820762786026740c76f36085b0efc47a31fe5020...949feb2413d6458794dcd2491c4babbbce0c15c1)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-version: 7.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-15 18:57:46 +00:00
Marc-André Bombeck 9ee89f2455 fix(http): adopt an incoming x-request-id only when it is a plain token
The header is echoed on the response and written into every log line. Both
readers (the proxy and apiHandler) now accept it only when it matches
^[A-Za-z0-9-]{8,64}$ and otherwise use a fresh id.
2026-10-15 20:24:01 +02:00
Marc-André Bombeck f72ec02484 fix(labs): charge the shared record-write bucket on OCR commit
POST /api/labs/ocr/commit can write a whole report and mint markers, yet it
was the one lab write without a ceiling. It now charges the same per-account
record-write bucket as POST /api/labs and POST /api/biomarkers and answers
429 record_write.rate_limited once exhausted.
2026-10-15 20:24:01 +02:00
Marc-André Bombeck 5d6216a59f fix(labs): resolve biomarker delete through the record-scoped labs authority
DELETE /api/biomarkers/{id} used a bare requireAuth() while the PUT beside
it requires requireRecordAuth("manage", "labs"). The delete now uses the
same authority and, as a manage-level hard delete, files what it destroyed
(name, unit, reading count) through destroyedDetails.
2026-10-15 20:24:00 +02:00
Marc-André Bombeck b4dc0d5927 Merge branch 'chore/demo-seed-v1424' into release/v1.43.0 2026-10-15 20:18:02 +02:00
Marc-André Bombeck c57abfbd68 Merge pull request #1226 from MBombeck/docs/update-dump-advice
docs(self-hosting): take the dump before pulling
2026-10-15 20:15:46 +02:00
Marc-André Bombeck 0d6bbb942d docs(self-hosting): take the dump before pulling, not after the pending-migrations line 2026-10-15 19:08:17 +02:00
Marc-André Bombeck 89ccb71a12 chore(demo): show one row of six tiles with the weight chart below it 2026-10-15 18:49:51 +02:00
Marc-André Bombeck 9839bde4f6 chore(demo): let the dental check-up fall due on the seed day 2026-10-15 18:46:19 +02:00
Marc-André Bombeck 1fe995245b Merge pull request #1225 from MBombeck/release/v1.42.4
Release v1.42.4
2026-10-15 18:14:30 +02:00
Marc-André Bombeck 140fe7277a test(e2e): wait for the lab overview read and drop an unused picker type 2026-10-15 17:31:31 +02:00
Marc-André Bombeck b1c59b908c chore(release): v1.42.4 2026-10-15 16:43:39 +02:00
Marc-André Bombeck d0b9cd8b5d Merge branch 'v1424/review' into release/v1.42.4 2026-10-15 16:42:53 +02:00
Marc-André Bombeck 346825149d fix(integrations): send no recovery for an incident the first run recorded silently
The watchdog's first pass after a deploy records standing incidents as
announced without paging. Their end then sent a recovery note for
something the operator was never told about. Such incidents now carry
the epoch as their announcement time, which no real announcement can
have, and close without a note.
2026-10-15 16:42:41 +02:00
Marc-André Bombeck 6dd71f0103 fix(medications): keep scoped credentials out of the dose arrival record
The dose arrival record describes the phone's Apple Health sync. The
measurement batch already leaves it alone for a narrow, single-purpose
credential; the intake batch now does the same, so the record stays
honest should the route ever accept such a credential.
2026-10-15 16:38:12 +02:00
Marc-André Bombeck f665a42789 fix(medications): converge a same-day Apple dose onto the slot's take
The extra-dose rule split a dose that HealthLog already recorded: a tap
at 08:00 and the same dose arriving from Health at 08:20 became two
doses and consumed stock twice, and a dose edited in Health (a new
record, a new time) became a second dose next to the first. The rule
now follows the calendar and the record: a take on another local day
is another dose; on the same day, a take logged in HealthLog converges
with the incoming record, a new record that names the slot as its
scheduled time is the slot's dose recorded again, and only an
unscheduled record more than a minute from the held one is an extra
dose.
2026-10-15 16:38:12 +02:00
Marc-André Bombeck 1e07ecaaaa fix(google-health): keep the hourly sync inside its job's expiry
An account whose backfill slice held it made the hourly job wait up to
eight minutes, and the sync after the wait ran as long as it needed:
past the fifteen-minute expiry pg-boss failed the job and retried it
beside the run still going. The pass now spends the job's budget: the
lock wait is capped at what is left, the sync's walks stop when the
budget is spent and leave the rest of the window to the next hour (the
cycle is not stamped), and an account reached after that is skipped.
A stop on the budget is not counted as a failure to retry.
2026-10-15 16:38:12 +02:00
Marc-André Bombeck 66be8c7c2c feat(integrations): one watchdog message for a provider-wide incident
The watchdog sent one page per account, so a provider outage or an
expired app credential paged once for every connected account. A pass
now collects its messages and sends them per provider at its end: more
than three accounts of one provider opening (or closing) an incident in
the same pass become one message naming the count, the reasons with
their earliest start and the accounts, and one recovery note. Every
incident in it counts as announced.
2026-10-15 16:38:12 +02:00
Marc-André Bombeck 0e5afe76a4 fix(integrations): skip the ledger page when the watchdog announced it
A failure streak reaching the alert threshold paged the operator even
when the watchdog had already announced an open incident for the same
connection. The ledger now checks for that announced incident first,
stamps the streak as announced and sends nothing; the watchdog's
recovery note still ends it.
2026-10-15 16:38:12 +02:00
Marc-André Bombeck 3e95755c3b fix(integrations): page only for trouble an operator can act on
The watchdog opened an incident for any degraded state that lasted a
day, so an Apple Health account whose phone app stopped running read as
no_new_data for good and paged. Incidents now open for action_required,
for provider errors and for the watchdog's own unexpected silence; stale
or stalled data alone never pages. The first pass after a deploy, with
no watch row yet, records what stands as already announced instead of
paging every long-broken connection at once.
2026-10-15 16:38:12 +02:00
Marc-André Bombeck 1c05813b82 fix(google-health): keep the skip's place across backfill slices
A walk that reads without the edge bound passed over everything earlier
slices wrote on every slice, and once it crossed the edge the skipped
pages were counted against the walk's page ceiling. The skip now stays
counted for the whole walk, runs on the slice's clock and its own
three-minute limit, and saves the page token it stopped at (on a limit,
a rate limit or the slice budget) so the next slice re-reads that one
page and goes on. A saved place counts as progress only when it moves
forward; a token Google refuses, or one that no longer reaches the edge,
sends the walk back to the newest page as before.
2026-10-15 16:38:12 +02:00
Marc-André Bombeck 73b9870338 Merge branch 'v1424/w2' into release/v1.42.4 2026-10-15 15:42:46 +02:00
Marc-André Bombeck 17478b08aa test(healthkit): type the card fixture's arrival fields 2026-10-15 15:38:42 +02:00
Marc-André Bombeck 09072c9f62 test(healthkit): pin dose arrivals, the background date and migration 0401 over enum rows 2026-10-15 15:36:20 +02:00
Marc-André Bombeck 03b24461ad feat(healthkit): record Apple Health dose arrivals and the last background arrival per type
Migration 0401 turns healthkit_type_syncs.type into text so the ledger can
hold MEDICATION_DOSE, and adds last_background_at (seeded from rows whose
latest arrival was a background one). POST /api/medications/intake/bulk
takes syncTrigger and records APPLE_HEALTH dose arrivals; the Apple Health
status read serves a MEDICATION_DOSE freshness entry and lastBackgroundAt
on every entry with arrival facts.
2026-10-15 15:33:30 +02:00
Marc-André Bombeck 6313c0174a Merge branch 'v1424/readiness' into release/v1.42.4 2026-10-15 15:24:29 +02:00
Marc-André Bombeck f95c2bfd41 Merge branch 'v1424/cards' into release/v1.42.4 2026-10-15 15:22:12 +02:00
Marc-André Bombeck eedd91ec64 test(e2e): drive the integration cards through every health state
One mocked page carries a provider in each state, checked at desktop width
and at 390 px by data attributes, with the menu driven from the keyboard,
the copied diagnostics compared with the shown text, and an axe pass in both
themes. The solid destructive badge replaces the red-on-red wash, which
fell below AA.
2026-10-15 15:21:49 +02:00
Marc-André Bombeck 91ccd154b1 test(labs): register the overview route in the sharing and module-gate inventories 2026-10-15 15:19:46 +02:00
Marc-André Bombeck eab4d644f7 feat(ops): log pending migrations before migrate deploy and document a dump before every update 2026-10-15 15:12:57 +02:00
Marc-André Bombeck e1369996ee Merge branch 'v1424/oura-followups' into release/v1.42.4 2026-10-15 15:10:59 +02:00
Marc-André Bombeck 648bb0d985 fix(medications): walk intakes to the drug-level horizon, read every symptom in a window, and guard fixed client fetch limits 2026-10-15 15:10:53 +02:00
Marc-André Bombeck 73922c039e fix(insights): say what fills the recovery page when it is empty
The empty note told a connected Oura account to connect Oura. The page
charts the WHOOP and Polar recharge, strain and load readings and the
Oura resilience level; Oura's readiness is the recovery score linked
above. The note now says so.
2026-10-15 15:10:18 +02:00
Marc-André Bombeck 35a33ca173 fix(oura): skip rejected and deleted sleep periods
Oura returns detected periods the person dismissed (type rest) and ones
they deleted in the sleep collection. They were imported as sleep, with
their lowest heart rate, HRV and breathing rate, which added an extra
session to the day. They now map to nothing, and rows an earlier sync
stored for such a period are soft-deleted on the next sync through the
shared write tail.
2026-10-15 15:10:18 +02:00
Marc-André Bombeck d7588fe9bf fix(sleep-score): place each night's midpoint on its main session
A nap on the same wake day moved the night's latest instant to the end
of the nap and added its minutes, so the midpoint of a regular night
landed around noon. A few such days scored consistency at 0, and a nap
after the latest night scored timing at 0 as well. The night now carries
its main session (wake instant and asleep minutes), and the midpoint is
read from that alone. Sleep rhythm and chronotype read the same midpoint.
2026-10-15 15:10:18 +02:00
Marc-André Bombeck 1a5b09a73c feat(settings): one integration card for every connection
Every connection in Settings renders the same card from the server's health
field: a badge with one word, one sentence per reason, the newest data as a
meta line, a collapsed details block whose text is exactly what the copy
button copies, and an action row with the rare actions in a menu and at most
one primary button. The per-provider banners, the raw provider error line,
the token expiry, the failure counter and the button rows are gone; every
capability they carried is reachable from the sentence, the one button, the
menu or the details. The managed-profile view renders the same card,
read-only. A structural guard keeps the cards off the retired fields.
2026-10-15 15:06:03 +02:00
Marc-André Bombeck b17a250894 feat(record-settings): publish connection health for a managed record's integrations
The guardian's read of a managed record's connections now carries the same
server-resolved health the owner's own envelope does, loaded for the record
and never for the guardian. The legacy state stays on the wire for one
transition release.
2026-10-15 15:06:02 +02:00
Marc-André Bombeck 7c905249fb fix(medications): page the side-effect list and name every GLP-1 medication in the timeline 2026-10-15 15:05:51 +02:00
Marc-André Bombeck 53e60f43f1 feat(labs): list every marker from a server-side per-biomarker overview 2026-10-15 15:03:06 +02:00
Marc-André Bombeck 600145427b docs(measurements): describe the 10-minute heart-rate bucket in the batch route header 2026-10-15 14:58:15 +02:00
Marc-André Bombeck 8ebc6fe546 fix(integrations): give Oura and WHOOP a 48 h silence window
Their rings and straps upload only when the phone app is opened, so a day
without opening it is ordinary and must not page the operator. Wearables
whose cloud receives data on its own keep 24 h, Apple Health keeps 48 h,
and scales, cuffs and Strava never alert on silence.
2026-10-15 14:30:55 +02:00
Marc-André Bombeck 228106be5f Merge branch 'chore/demo-seed-v1423' into release/v1.42.4 2026-10-15 14:26:11 +02:00
Marc-André Bombeck a4942e631f Merge branch 'v1424/ghfallback' into release/v1.42.4 2026-10-15 12:45:23 +02:00
Marc-André Bombeck 1f9180fe6f test(google-health): drive a backfill to completion against a Google that refuses the edge filter
Also documents the fallback and its log fields.
2026-10-15 12:35:49 +02:00
Marc-André Bombeck ebc0168f53 test(google-health): pin the fallback when Google refuses the resume edge filter 2026-10-15 12:33:34 +02:00
Marc-André Bombeck e2d5bf121c fix(google-health): read past the resume edge when Google refuses its filter 2026-10-15 12:31:39 +02:00
Marc-André Bombeck de40db7ad0 Merge branch 'v1424/fixes' into release/v1.42.4 2026-10-15 12:23:55 +02:00
Marc-André Bombeck 7be05608f8 test: date fixtures that feed a clock-relative window relative to now
A fixture dated near the day it was written passes until the date slides
out of a window the route counts back from the clock. Five did: a snooze
until a fixed date that becomes the past in January, a prognosis read
against the wall clock over days seeded at a fixed date, sleep rows and a
weight streak read through windows that end now, and a sync token that
would expire on a fixed date. The e2e mood heatmap mock and a timeline
URL are dated from today as well.

A guard scans the integration and e2e trees for near-now absolute dates
within reach of a window parameter, with an allowlist for files whose
windows are fixed on both ends.
2026-10-15 12:23:09 +02:00
Marc-André Bombeck b96c114a45 Merge branch 'v1424/gh' into release/v1.42.4 2026-10-15 11:45:45 +02:00
Marc-André Bombeck c8194c4a86 test(google-health): drive a three-year heart-rate backfill through the drain to completion
1.58 million readings, paged by a generated fake of the list API that
honours the edge and lower-bound filters, with a server error, a timeout and
an hour-long rate limit injected. Pins that the backfill completes over
several slices, imports every reading exactly once without rewriting a row,
lets the hourly sync run between and beside slices, writes one log event per
slice with its requests and edges, and keeps the heap flat across the
history under the production heap ceiling.
2026-10-15 11:45:15 +02:00
Marc-André Bombeck 2d03eba587 fix(google-health): resume a backfill inside a collection and run it in short slices
A backfill noted progress only once a whole collection was read to the end.
A heart-rate history of years that one attempt could not finish (its budget,
a rate limit it could not wait out, a single failed request among thousands
of pages) started the next attempt from the newest page again, finished
nothing, and failed with "marker not stamped" on every attempt.

- Every spot-reading collection read page by page now records, after each
  page it writes, the oldest sample it has written (backfillProgress.cursors).
  The next attempt asks Google only for samples at or beyond that edge. The
  edge is held back once a page's write fails, so no rows are skipped.
- The backfill runs in slices of five minutes or 500 pages. A slice that
  finished a collection or moved an edge continues with the next one; only a
  slice that moved nothing fails, and its error names each unfinished
  collection and why the slice stopped. The slice ceiling is derived from the
  collections and the page ceilings of the resumable ones.
- A rate limit Google will not lift within a minute ends the slice instead of
  failing the walk, and the next slice starts five minutes later. Server
  errors, timeouts and dropped connections are retried three times.
- The heart-rate stream is read last, so the light collections finish in the
  first slice.
- The hourly sync waits up to eight minutes for a running slice instead of
  skipping the account for as long as the import runs.
- Each slice annotates googleHealth.backfill.slice with pages, elapsed time,
  stop reason, per-collection status and edges, and reports progress on the
  job.progress line.
2026-10-15 11:26:49 +02:00
Marc-André Bombeck 47deda9d76 Merge branch 'v1424/integrations' into release/v1.42.4 2026-10-15 11:26:37 +02:00
Marc-André Bombeck 73c4dda224 fix(visits): reach every lab result, condition, document and visit from the link pickers
The visit form's lab picker offered the newest 200 results, the condition
picker the newest 100 conditions, the document pickers the newest thousand
documents and the document sheet's visit picker the newest 100 visits.
On a long record everything older could not be linked.

Each picker now opens on the records around the date of the thing being
linked, pages older and newer from there, and searches through the list
route itself. Records already linked always show, from the labels the
record's own read carries and from what was picked in the sheet.

The lab list takes a q filter over analyte and panel, the condition list
from/to/q/offset/sortDir, and the visit list an offset.
2026-10-15 11:24:40 +02:00
Marc-André Bombeck f2646bc186 test(integrations): cover the health field on the status routes 2026-10-15 11:15:06 +02:00
Marc-André Bombeck 4a49784464 chore(demo): file the demo medications under categories, let the evening supplement record itself, and store two lab readings converted from SI units 2026-10-15 11:11:57 +02:00
Marc-André Bombeck 8162afc0e1 docs(api): publish the connection health state 2026-10-15 11:09:50 +02:00
Marc-André Bombeck c094f217fc feat(integrations): watch connections for silence and lasting trouble
An hourly and a daily pass flag a continuous source that delivered on
most of the previous 14 days and has gone quiet, and open one operator
incident per connection when a connection stays degraded or needs action
for more than a day. The incident is announced once through the existing
admin alert path, adopted rather than repeated when the sync ledger
already paged, and closed with one recovery note. Scales, cuffs and
workout logs are watched for errors only, never for silence.
2026-10-15 11:07:57 +02:00
Marc-André Bombeck 82d8960044 fix(jobs): run every admitted import inside its own background event
The admission drain ran each full-history import with no wide event in
scope, so every external call, warning and annotation the provider walk
recorded was dropped and a failing backfill left only its error message.
Each admitted run now opens a job.integration_backfill event with its kind,
identity, job id and slice, emitted when the run settles. The Health Connect
import worker gets the same treatment.

The external call list on one event is bounded so a long walk cannot hold
thousands of entries: failed and annotated calls keep the larger share,
the rest is counted in external_calls_omitted.
2026-10-15 11:07:28 +02:00
Marc-André Bombeck dad9a7778e fix(medications): keep an extra Apple Health dose off a slot that already holds one
A HealthKit dose event binds to a slot by window band, and a weekly
schedule's band reaches a day either side of its slot. An extra dose
logged the day after a scheduled one landed on that slot and overwrote it
last-write-wins: the slot's time changed and the extra dose was stored
nowhere. An external dose record whose band slot already carries a
different take is now recorded as its own unscheduled dose.

The dose history now lets the take stored on a slot claim it before an
extra dose stored on its own instant, instead of following the order the
rows were read in, so the extra dose reads as off-schedule.
2026-10-15 11:02:38 +02:00
Marc-André Bombeck d10179f9a3 feat(integrations): resolve one health state per connection
Fold the ledger state, liveness verdict, plan and scope gaps and the
provider reauth flag into ok / limited / degraded / action_required with a
reason code, the one action, the unavailable types and redacted
diagnostics. Keep the last ten sync attempts per connection for those
diagnostics, publish the state on the integrations envelope, every
per-provider status route and the Apple Health config read, and page the
operator once per failure streak instead of once a day.
2026-10-15 11:01:16 +02:00
Marc-André Bombeck cdd1e9c87f Merge pull request #1219 from MBombeck/fix/lab-dialog-overflow
fix(labs): keep the lab form's single column at the dialog width
2026-10-15 07:06:49 +02:00
Marc-André Bombeck 07d991adf7 fix(labs): keep the value and draw-date column inside the dialog body
The single-column grid used the implicit auto track, which grows to the
widest cell's min-content. The draw date's date and time inputs size
from the font, so with wider metrics that min-content passed the dialog
body (about 422 px against 398 px) and the value input ran 24 px into
the dialog's padding. An explicit grid-cols-1 track is minmax(0, 1fr)
and stays at the body's width; the date row already shrinks inside it.

Refs #1214
2026-10-14 21:01:13 +02:00
Marc-André Bombeck 056af17f62 Merge pull request #1218 from MBombeck/release/v1.42.3
Release v1.42.3
2026-10-14 20:33:50 +02:00
Marc-André Bombeck e0573069c5 test(sharing): seed the series weight relative to now so it stays in the 90-day window 2026-10-14 20:09:14 +02:00
Marc-André Bombeck 38ef4c7fa7 fix(labs): keep three significant digits for a lab value below one 2026-10-14 19:57:34 +02:00
Marc-André Bombeck 784ea4ca9d Merge branch 'v1423/review' into release/v1.42.3 2026-10-14 19:57:04 +02:00
Marc-André Bombeck fcced06fa7 fix(labs): never open a small lab value as zero in the edit sheet
The edit seed rounded every value to two decimals, so a TSH of 0.004 opened
as 0. Values at or above 1 keep the two-decimal display precision; below 1
the seed keeps three significant figures, written without exponent
notation.
2026-10-14 19:50:26 +02:00
Marc-André Bombeck 03e80c1a94 fix(labs): drop the printed range text when an edit retires the conversion
Changing the value of a converted reading cleared sourceValue and
sourceUnit but kept the printed range text, which is written in the
report's unit. With the unit gone every reader presented it as the marker
unit's window, beside numbers in another unit. The text now goes with the
conversion record (a whole-form echo of it does not bring it back); the
bounds, already converted into the marker's unit, stay.
2026-10-14 19:49:33 +02:00
Marc-André Bombeck 530d1a6be2 fix(oura): keep the scope note when a run never proved the token
The refresh-abort paths (no client credentials, refresh token lost to a
peer) and a run where no collection succeeded wrote an empty scope list,
clearing the reconnect hint until the next good run. Such a run learned
nothing about the grant, so the stored note now stands.
2026-10-14 19:47:37 +02:00
Marc-André Bombeck 55ddad3d6a fix(oura): read a 401 as a scope gap only on the scope-gated collections
A 401 on any collection next to a success was recorded as a scope gap, so a
real auth failure on sleep, readiness or activity was hidden from the
failure ledger and shown as a reconnect hint. Only spo2, vo2max, cardio_age
and resilience sit behind a scope beyond daily; a 401 anywhere else keeps
its normal failure handling, and a stored note naming another collection
no longer skips the token refresh.
2026-10-14 19:47:05 +02:00
Marc-André Bombeck c7164be0d3 fix(withings): keep the plan re-probe date on a run that only narrowed
A run that skipped remembered types and met one more refusal restamped the
leg's checkedAt, pushing the weekly re-probe of the remembered types out on
every such run. Only a re-probe, or a leg with nothing remembered yet, now
starts the week again.
2026-10-14 19:46:03 +02:00
Marc-André Bombeck 6feaf6849a fix(withings): keep weight and blood pressure when every request is refused
A run where Withings refused every requested type stored all of them, core
types included, as outside the plan for a week; later runs then requested
nothing and moved the watermark on with no readings, losing the gap. A
refusal is now remembered only from a run in which another type was
accepted. A run that read nothing keeps the watermark and reports that it
did not land, and the watermark also holds while plan memory skips a core
type (weight, blood pressure, pulse, free body composition).
2026-10-14 19:45:38 +02:00
Marc-André Bombeck 8ce6a83da5 fix(withings): never remember a type the plan probe could not resolve
When the request budget ran out mid-split, every subset not yet tried was
stored as refused, so free types (temperature, SpO2, body composition) could
be skipped for a week and named as withheld by the plan. Those types are now
unverified: not remembered, asked for again on the next run, and the measure
watermark holds until every type was read or proven refused.
2026-10-14 19:44:05 +02:00
Marc-André Bombeck e9be8e0a5f chore(bundle): raise the aggregate cap to 4790 on a measured delta 2026-10-14 19:42:40 +02:00
Marc-André Bombeck 787aafaee1 docs(changelog): list only the analytes the registry converts 2026-10-14 19:41:19 +02:00
Marc-André Bombeck 9d167069db chore(release): v1.42.3 2026-10-14 19:35:25 +02:00
Marc-André Bombeck f564c44753 chore(labs): drop the pending-consumer markers now that the readers have landed 2026-10-14 19:35:00 +02:00
Marc-André Bombeck 42b66b2228 Merge branch 'v1423/labs' into release/v1.42.3 2026-10-14 19:34:44 +02:00
Marc-André Bombeck 035ae37ee4 Merge branch 'v1423/integrations' into release/v1.42.3 2026-10-14 19:20:16 +02:00
Marc-André Bombeck ec57ae10b1 docs(labs): describe the registry listing by what uses it
Rewords a code comment that referred to the work by its planning step, and a
loop counter's comment, to say what the code does.
2026-10-14 19:20:01 +02:00
Marc-André Bombeck eabbe3e032 fix(labs): let the conversion badge in the scan review wrap
At 390 px the badge truncated, and the part it cut off was the value the
report printed. It now wraps inside the row like a sentence.
2026-10-14 19:19:44 +02:00
Marc-André Bombeck 65669e52d2 fix(withings): bind the plan-state timestamp instead of reading the database clock 2026-10-14 19:19:27 +02:00
Marc-André Bombeck 350fd1b767 fix(labs): name calcifediol as the source of the vitamin D factor
The note said the factor was taken from cholecalciferol (C27H44O2). Vitamin
D3 is C27H44O; C27H44O2 is 25-hydroxyvitamin D3 (calcifediol), the form the
assay reports and the one whose molar mass gives 1 ng/mL = 2.496 nmol/L. The
factor was right; the note now says what it rests on.
2026-10-14 19:19:15 +02:00
Marc-André Bombeck 07f09e1b61 fix(labs): store the catalog slug as a marker's analyte key
The matcher stored the registry entry's key, so LDL and HDL markers were
recorded as total-cholesterol. The key now names what the marker is (the
catalog slug, or a registry-only key for an analyte with no seed), and the
registry resolves ldl and hdl to the cholesterol entry for the conversion as
before. A key already written in the old form is rewritten by the same fill
step that brings a stale key in line with its name, the first time a reading
needs it. The OpenAPI descriptions say what the key is and that it follows
the marker's name and unit.
2026-10-14 19:18:49 +02:00
Marc-André Bombeck cd1449e63e fix(labs): keep a reading's conversion record unless its value actually changes
The single-reading edit cleared sourceValue and sourceUnit whenever the body
carried a value, and the web edit sheet always sends one, so a date or note
edit erased the "converted from" hint. It is now cleared only when the value
differs from the stored one. The sheet also opened a converted reading at its
stored precision (100.90191999999999); it now starts from the value as the lab
surfaces show it and leaves an untouched value out of the request, so the
stored full-precision number is not replaced by its rounded display.
2026-10-14 19:15:33 +02:00
Marc-André Bombeck 47a91d6922 fix(labs): never present a converted reading's printed range in the wrong unit
A converted reading's bounds move with its value, but the printed range text
stays in the unit the report used, and every reader preferred that text: the
list showed "Report range 3.9 to 5.5" beside 100.9 mg/dL, and the doctor
report and the FHIR referenceRange.text did the same. The text is still
stored verbatim. The range resolver now takes the reading's sourceUnit (as a
required field, so no reader can forget to select it) and, for a converted
reading, leaves the window's text empty so every surface states the converted
bounds in the marker's unit, and hands the printed text over separately with
its unit. The lab DTO carries it as printedReferenceText, and the note beside
a reading shows it as "as printed in mmol/L: ...".
2026-10-14 19:10:49 +02:00
Marc-André Bombeck 4793edb961 test(integrations): pin the Withings plan handling and the Oura scope gap; document the plan change 2026-10-14 19:08:22 +02:00
Marc-André Bombeck 9c5fa6d14a Merge branch 'v1423/fixes' into release/v1.42.3 2026-10-14 19:05:56 +02:00
Marc-André Bombeck 044f9008ba fix(oura): ask for the spo2 and heart_health scopes, explain a missing scope calmly
Oura's current authorization server enforces scopes per collection.
daily_spo2 needs spo2; vO2_max, daily_cardiovascular_age and
daily_resilience need heart_health. The authorize request now asks for
both.

A grant issued before keeps answering 401 on those four collections
while every other one succeeds. Such a 401 next to a success is now
recorded as a scope gap rather than a failure: it no longer counts
toward the reconnect threshold, it no longer forces a token refresh once
known, and the Oura card asks for one reconnect instead. The Withings
card names the readings the account's API plan withholds.
2026-10-14 19:01:02 +02:00
Marc-André Bombeck 8b18b1c9fe test(e2e): fail clearly on a medications POST body that is not JSON
The route stub parsed the body inside the un-awaited route callback, so
a malformed body threw there and the request hung. The parse error is
now recorded and the route is fulfilled; reading capture.postBody in
the spec throws with the parse error.
2026-10-14 18:59:33 +02:00
Marc-André Bombeck cc0313c955 fix(labs): re-derive a marker's analyte key when its name or unit changes
The key is a function of the marker's name and unit, but it was only ever
filled, never replaced, so a marker renamed from Glucose to Cholesterol kept
converting readings with the glucose factor. The marker edit now re-derives
it with the same match the creation paths use (clearing it when the new name
matches nothing), and the fill step a reading runs brings a stored key back in
line with the name, so a key written before this fix is corrected the first
time it matters. The scan review derives the key the same way as the commit.
2026-10-14 18:59:32 +02:00
Marc-André Bombeck 63882bf3ec fix(labs): give the draw date a full-width row
The lab form placed the draw date in the second column of a two-column
grid. DateTimeField sets its date and time halves side by side from sm
up, so the date half was squeezed until a date could only be set
through the calendar. The value and the date now stack in one column,
as in the measurement form, and the date can be typed again.

Refs #1214
2026-10-14 18:58:55 +02:00
Marc-André Bombeck cc507deafa fix(notifications): log every refused notification answer
A quick answer whose request never reached the route left no trace: a
redirect in front of the app turns ntfy's POST into a GET, which Next
answered 405 before any wide event, and the worker-only and demo-mode
refusals in the proxy wrote nothing either. The proxy now answers a
non-POST on the action path itself with 405 and Allow: POST, and every
refusal it makes there writes one warn line with a reason and the path
without its token. The route's rate-limit refusal carries a reason too.

The regression test sends the action ntfy publishes, in the shape ntfy
sends it, through the real proxy and route against Postgres.

Refs #1203
2026-10-14 18:57:06 +02:00
Marc-André Bombeck 9aa60fb2ff fix(illness): keep chronic conditions out of rest mode
A chronic ongoing condition has no end date, so counting it as rest mode
kept the account in it for good: Day N counted from an onset years back,
cadence nudges never resumed and every streak gap since the onset was
bridged. Only active episodes that are not CHRONIC_ONGOING (acute,
recurring, flare) now trigger rest mode, and Day N counts from the
earliest of those. The achievements streak freeze applies the same
filter to its own episode query.

Chronic conditions stay context: a coincident vital deviation is still
framed as explained by any active episode, and the Coach block and the
briefing still name them, with restMode false.

Refs #1216
2026-10-14 18:57:01 +02:00
Marc-André Bombeck 39b0cabb43 fix(withings): keep the sync inside the account's API plan
Withings split its public API into plans on 2026-10-12. A request naming
one meastype outside the free plan is refused whole with status 277, so
every measure sync stopped importing anything, and the 200..299 reauth
range parked the connection at error_reauth.

The measure, sleep-summary and ECG legs now drop the documented advanced
types on a 277, split the rest until the refused keys are isolated
(bounded), remember the refusal per connection and re-probe weekly. 277
is classified persistent and never enters the ledger; connections parked
over it are lifted by the fallback tick, and the held watermark lets the
next run read the whole gap.
2026-10-14 18:53:58 +02:00
BernhardBuckel 747e0964ad feat(labs): show a reading's conversion in the scan review and beside the value
Slice 3 of phase 3 of #1095. The scan review no longer treats every reading in
another unit as unwritable. The server tells it which analyte the matched marker
is (markerAnalyteKey on each extracted row); a row whose unit differs but can be
converted starts selected and carries a badge saying what Save will store and
what the report stated ("Saved as 100.9 mg/dL, converted from 5.6 mmol/L"). A
row that cannot be converted keeps the existing blocked badge and error. Save
still sends what the report printed; the server converts, as in slice 2.

The review's duplicate flag also recognises a re-scan of a reading that was
stored converted, by what the report printed, the same rule the commit applies.

A converted reading carries a quiet "converted from 5.6 mmol/L" on the lab list
(row and tile) and the history list (phone and table), so a number nobody typed
can always be traced back to what the report printed.

Strings in all seven locales.

Refs #1095
2026-10-11 20:41:08 +02:00
BernhardBuckel caa7a937fd feat(labs): convert a reading into its marker's unit when the pair is known
Slice 2 of phase 3 of #1095. A reading that stated a unit other than its
marker's was refused. When the marker's analyte is known and the registry has the
pair, the three write paths now convert it instead: POST /api/labs, the OCR
commit and the document commit.

The converted value is stored at full precision in the marker's unit, with what
the report printed in the existing source_value and source_unit columns. The
report's own reference window is in the printed unit, so it is converted with the
value; otherwise the verdict would compare a converted value with an unconverted
window. A pair the registry does not have is still refused as before (the 422
now says why), as is a marker whose analyte is unknown.

The marker's key is filled the first time a reading needs it. The same scan
committed again is still a duplicate: a converted row holds the number in the
marker's unit, so the duplicate check also matches on what the report printed.
Editing the value of a converted reading clears the conversion record, because
the number is then the person's own.

The lab result DTO and the OpenAPI schemas (labs and the OCR commit) carry
converted, sourceValue and sourceUnit.

Refs #1095
2026-10-11 20:28:00 +02:00
BernhardBuckel 08c69d4a5a test(labs): give the biomarker fixtures the new analyteKey field
BiomarkerDto gained analyteKey, and two component test fixtures typed as it
were missing the field, which the typecheck job rejects.
2026-10-11 20:16:31 +02:00
BernhardBuckel 52ea3b9c2f feat(labs): link a marker to its analyte for the unit conversion
Slice 1 of phase 3 of #1095. A Biomarker row keeps only its name, so nothing
said which analyte it was. This adds the explicit alias list in the catalog the
issue settled on, including the registry-only keys (urea, blood urea nitrogen,
uric acid, total calcium, total bilirubin), and sets biomarkers.analyte_key
(from migration 0385) from it.

matchAnalyteKey equals a marker's folded name against the localised names of the
catalog seeds the registry knows and against the alias list. It matches the
whole name only, so "Calcium (ionisiert)" is not "Calcium"; a name two analytes
claim matches neither; and the marker's own unit must be one the analyte reaches,
so a marker called "Glucose" in a unit the registry cannot convert stays
unlinked.

The key is set when a marker is created (the biomarker route, the mint used by
lab writes, the legacy backfill) and, for an existing marker, by
ensureAnalyteKey the first time a reading needs it. It never overwrites a key
that is set. There is no boot job for the existing markers: the conversion that
uses the key fills it on first use, and a sweep can be added later if wanted.

The key is returned on the biomarker API and in the OpenAPI schema.

Refs #1095
2026-10-11 20:04:43 +02:00
kilian c2be166f72 Merge remote-tracking branch 'origin/main' into feat/lab-analyte-key-fill 2026-10-11 19:48:56 +02:00
dependabot[bot] bc7f3bf209 chore(deps): bump the production-weekly group across 1 directory with 2 updates (#1207)
Bumps the production-weekly group with 2 updates in the / directory: [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react) and [nodemailer](https://github.com/nodemailer/nodemailer).


Updates `lucide-react` from 1.52.0 to 1.53.0
- [Release notes](https://github.com/lucide-icons/lucide/releases)
- [Commits](https://github.com/lucide-icons/lucide/commits/1.53.0/packages/lucide-react)

Updates `nodemailer` from 10.0.15 to 10.0.16
- [Release notes](https://github.com/nodemailer/nodemailer/releases)
- [Changelog](https://github.com/nodemailer/nodemailer/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodemailer/nodemailer/compare/v10.0.15...v10.0.16)

---
updated-dependencies:
- dependency-name: lucide-react
  dependency-version: 1.53.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production-weekly
- dependency-name: nodemailer
  dependency-version: 10.0.16
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production-weekly
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-11 17:17:03 +00:00
dependabot[bot] d748db0e10 chore(deps-dev): bump the development-weekly group with 3 updates (#1210)
Bumps the development-weekly group with 3 updates: [@playwright/test](https://github.com/microsoft/playwright), [lefthook](https://github.com/evilmartians/lefthook) and [shadcn](https://github.com/shadcn-ui/ui/tree/HEAD/packages/shadcn).


Updates `@playwright/test` from 1.63.0 to 1.64.0
- [Release notes](https://github.com/microsoft/playwright/releases)
- [Commits](https://github.com/microsoft/playwright/compare/v1.63.0...v1.64.0)

Updates `lefthook` from 2.1.17 to 2.2.1
- [Release notes](https://github.com/evilmartians/lefthook/releases)
- [Changelog](https://github.com/evilmartians/lefthook/blob/master/CHANGELOG.md)
- [Commits](https://github.com/evilmartians/lefthook/compare/v2.1.17...v2.2.1)

Updates `shadcn` from 4.21.3 to 4.21.4
- [Release notes](https://github.com/shadcn-ui/ui/releases)
- [Changelog](https://github.com/shadcn-ui/ui/blob/main/packages/shadcn/CHANGELOG.md)
- [Commits](https://github.com/shadcn-ui/ui/commits/shadcn@4.21.4/packages/shadcn)

---
updated-dependencies:
- dependency-name: "@playwright/test"
  dependency-version: 1.64.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development-weekly
- dependency-name: lefthook
  dependency-version: 2.2.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development-weekly
- dependency-name: shadcn
  dependency-version: 4.21.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development-weekly
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-11 16:48:18 +00:00
dependabot[bot] 3f08e3a97b chore(deps): bump actions/upload-artifact from 7.0.1 to 7.0.2 (#1208)
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 7.0.1 to 7.0.2.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/043fb46d1a93c77aae656e7c1c64a875d1fc6a0a...cf430e030ddbb5b0abf93d22962f4752f3646cd9)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: 7.0.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-11 16:35:07 +00:00
dependabot[bot] 0fed7f02e6 chore(deps): bump the codeql-action group with 3 updates (#1206)
Bumps the codeql-action group with 3 updates: [github/codeql-action/init](https://github.com/github/codeql-action), [github/codeql-action/analyze](https://github.com/github/codeql-action) and [github/codeql-action/upload-sarif](https://github.com/github/codeql-action).


Updates `github/codeql-action/init` from 4.38.2 to 4.38.3
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2...24c54180a607b1449ed407dd24f251e4e9147c8d)

Updates `github/codeql-action/analyze` from 4.38.2 to 4.38.3
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2...24c54180a607b1449ed407dd24f251e4e9147c8d)

Updates `github/codeql-action/upload-sarif` from 4.38.2 to 4.38.3
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2...24c54180a607b1449ed407dd24f251e4e9147c8d)

---
updated-dependencies:
- dependency-name: github/codeql-action/init
  dependency-version: 4.38.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: codeql-action
- dependency-name: github/codeql-action/analyze
  dependency-version: 4.38.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: codeql-action
- dependency-name: github/codeql-action/upload-sarif
  dependency-version: 4.38.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: codeql-action
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-11 16:19:49 +00:00
Marc-André Bombeck c6db4501f1 Merge pull request #1205 from MBombeck/release/v1.42.2
Release v1.42.2
2026-10-11 16:10:10 +02:00
Marc-André Bombeck b9471c3a62 docs(changelog): describe the fitted timeline window and the ended-course rule 2026-10-11 15:35:04 +02:00
Marc-André Bombeck e833bd0949 Merge branch 'v1422/review2' into release/v1.42.2 2026-10-11 15:33:51 +02:00
Marc-André Bombeck a54b47428e test(timeline): match the zoom each journey opened with
The day-strip journey opens /timeline?zoom=all, so closing the day
returns there, not to bare /timeline. An unreadable range now opens on
the default window, which for this record is three months.
2026-10-11 15:25:05 +02:00
Marc-André Bombeck ab104ab633 refactor(medications): spread the intake-tracking filter at each auto-take reader
The shared eligibility filter hid TRACKED_INTAKE_WHERE from the
structural guard that checks every schedule and intake reader honours
the switch. Each reader now spreads it itself.
2026-10-11 15:23:57 +02:00
Marc-André Bombeck 5bfdc9d04b fix(jobs): one bad medication never stops the auto-take pass
A row whose follow-up threw, or whose stored time zone the runtime
refuses, aborted the pass for every other person. Each medication now
runs on its own: a failure is named in a warning, counted, and the next
one runs; an unusable zone falls back to the default as in other jobs.
2026-10-11 15:19:08 +02:00
Marc-André Bombeck 88aec7edb6 fix(search): key the in-flight search by actor and record
Two tabs searching two different records (one's own and a shared one)
cancelled each other, because a new search aborted any earlier one by
the same actor. The slot is now held per actor and record.
2026-10-11 15:17:43 +02:00
Marc-André Bombeck 24f06884b3 fix(reminders): an ended course reads as not reminding
The worker expands no slot past endsOn, so it never reminds for a
course that has ended, but remindersActive still read true for it. The
reminder gate now includes the course end on the person's clock, so the
published value, the worker and the admin check make the same decision.
2026-10-11 15:16:54 +02:00
Marc-André Bombeck 1ba422b87c fix(backup): restart the auto-take floor at the restore
A portable file carries no removed doses, so a restored medication with
automatic recording on had the pass fill a dose the person deleted in
the days before the export. The restored floor is now the later of the
file's value and the restore.
2026-10-11 15:14:43 +02:00
Marc-André Bombeck 38900656a3 fix(audit): name automatic dose recording in medication audit rows
Switching autoMarkTaken on or off now appears in the medication.update
row with its previous value, and medication.create records whether the
medication starts with doses recorded automatically.
2026-10-11 15:11:27 +02:00
Marc-André Bombeck fe782493a1 fix(medications): a take bound to another slot never claims this one
The auto-take pass left a slot alone when any take fell inside its band,
so the morning dose pinned and taken at 19:00 also hid the 20:00 dose.
Only a take attributed to no other expected slot counts as nearby now.
2026-10-11 15:10:01 +02:00
Marc-André Bombeck dc86c42bef feat(timeline): fit the default window to the record
Without a zoom in the URL the window now follows the record: one that
starts inside the last three months opens on three months clamped to
its first entry (at least four weeks up to today), an older one with
entries there opens on three months, an older one without opens on
everything, and an empty one keeps the empty state. The choice is made
from the first answer before anything is drawn, so the page does not
jump between windows.
2026-10-11 15:08:57 +02:00
Marc-André Bombeck 0ff1340ff4 fix(medications): never backfill days an edit reopens under auto-take
A start moved earlier, an end moved later or removed, or a medication
made active again reopened past slots, and the auto-take pass filled up
to a week of them as taken. The course window writer and the
re-activation paths now move the auto-take floor to the moment of the
edit.
2026-10-11 15:07:06 +02:00
Marc-André Bombeck 23e0cc35af fix(timeline): open on everything when the default window is empty
Opened without a zoom, three months that hold nothing while the record
has older entries now show everything instead of a dead end, without
writing the zoom into the URL. A window named in the URL that is empty
offers "Show everything" when the record has entries before it. An
unreadable ?zoom= or range opens on the same default as no zoom.
2026-10-11 15:04:14 +02:00
Marc-André Bombeck a4cccc11ba fix(medications): keep an auto-taken rolling cadence moving
A rolling cadence yields only lastIntakeAt + N, so a slot the auto-take
pass never records (before the switch-on floor, past the catch-up
window, inside a pause, skipped or removed by the person) held it still
for good. Step the cadence forward by N past every such slot, keeping
its rhythm, and feed the stepped anchor to the card, dashboard and
schedule-tool next due so they show the slot the pass will record.
2026-10-11 15:00:37 +02:00
Marc-André Bombeck 0375c2adc6 test(reminders): drop the fixture fields the merge doubled 2026-10-11 14:49:43 +02:00
Marc-André Bombeck 6fe1a050a6 chore(release): v1.42.2 2026-10-11 14:42:03 +02:00
Marc-André Bombeck 765dbe1e7f Merge branch 'v1422/caption' into release/v1.42.2 2026-10-11 14:41:28 +02:00
Marc-André Bombeck 2f780df0b0 Merge branch 'v1422/autotaken' into release/v1.42.2 2026-10-11 14:41:28 +02:00
Marc-André Bombeck bfba7aec67 Merge branch 'v1422/review' into release/v1.42.2 2026-10-11 14:41:27 +02:00
Marc-André Bombeck 7ee72d0161 fix(search): admit the search POST at read level through a declared, frozen readOverPost 2026-10-11 14:21:56 +02:00
Marc-André Bombeck 4c598655b7 test(reminders): give the reminder fixtures the full gate row 2026-10-11 14:17:36 +02:00
Marc-André Bombeck 195fa28cc5 feat(medications): publish the resolved reminder decision as remindersActive 2026-10-11 14:09:22 +02:00
Marc-André Bombeck 1bf6108b37 docs(search): describe the command palette's record search 2026-10-11 14:00:31 +02:00
Marc-André Bombeck 3e2426187c test(checkups): pin that a linked visit older than the list's newest hundred is read and opened 2026-10-11 13:59:59 +02:00
Marc-André Bombeck 9a86a9427b fix(oura): name the issuer refusal on the integrations page 2026-10-11 13:59:05 +02:00
Marc-André Bombeck 5d7e0248b6 fix(search): probe one row past each cap so a source holding exactly the cap is not reported truncated 2026-10-11 13:57:43 +02:00
Marc-André Bombeck 55bfb0a439 feat(sync): carry the automatic-recording marker on intake upserts 2026-10-11 13:56:28 +02:00
Marc-André Bombeck 41049b3837 fix(search): bound the clinical kinds by the search budget, one search per actor, query in a POST body 2026-10-11 13:56:24 +02:00
Marc-André Bombeck e93b4f249f test: drop an unused constant 2026-10-11 13:55:46 +02:00
Marc-André Bombeck 2a626be92d style: format the auto-take changes 2026-10-11 13:53:40 +02:00
Marc-André Bombeck 7417d8f423 test(medications): prove the auto-take pass against Postgres 2026-10-11 13:53:35 +02:00
Marc-André Bombeck 5affa9142c test(medications): cover the auto-take pass 2026-10-11 13:52:22 +02:00
Marc-André Bombeck 4386ac820e test: register the auto-take job and field in the existing guards 2026-10-11 13:50:56 +02:00
Marc-André Bombeck 138792d555 docs(db): correct the build cost note on the noted-readings index migration 2026-10-11 13:50:30 +02:00
Marc-André Bombeck 3c6150e837 fix(admin): sum today's AI spend on each account's own local day 2026-10-11 13:50:17 +02:00
Marc-André Bombeck 67a55def44 docs(api): document the auto-mark-taken fields 2026-10-11 13:47:37 +02:00
Marc-André Bombeck c94e109bf0 feat(medications): record scheduled doses automatically 2026-10-11 13:46:16 +02:00
Marc-André Bombeck 5dbc6fcec1 fix(notifications): keep a quick answer single-use after its entry is purged and document the topic exposure 2026-10-11 13:46:12 +02:00
Marc-André Bombeck 4ff9aa7e39 fix(notifications): publish ntfy messages as JSON so non-ASCII labels need no header encoding 2026-10-11 13:43:35 +02:00
Marc-André Bombeck bc91a542e2 fix(google-health): count backfill progress only once it is persisted and cap the slice chain 2026-10-11 13:41:53 +02:00
Marc-André Bombeck 8cd6cf3c06 feat(timeline): open on three months unless the URL names a zoom 2026-10-11 13:40:19 +02:00
Marc-André Bombeck 5919a93c29 feat(medications): add the auto-mark-taken columns 2026-10-11 13:39:44 +02:00
Marc-André Bombeck b7eef0dff4 feat(charts): drop the explanatory line under day-linked charts 2026-10-11 13:39:20 +02:00
Marc-André Bombeck a4a3318de3 fix(mood): open the reminder's entry sheet only in the own record 2026-10-11 13:37:46 +02:00
Marc-André Bombeck 3c4e053dcd Merge branch 'v1422/fixes' into release/v1.42.2 2026-10-11 13:28:29 +02:00
Marc-André Bombeck 7a43131883 fix(db): track the time-zone pin's failure log as background work
The pin's SET on connect runs detached; its failure line now registers
with the background-task tracker like every other detached writer. The
coach thread test reads the new budget reset copy.
2026-10-11 13:18:08 +02:00
Marc-André Bombeck 576ff9a444 test(documents): pin that Open in new tab hands over the preview's bytes 2026-10-11 13:15:02 +02:00
Marc-André Bombeck dcb7a0bc09 fix(ai): count the daily AI budget on the account's own day
The token ledger keyed every day on UTC while the daily briefing and the
other per-day generations run on the account's local day, so east of UTC
the first generations after local midnight were refused against the
previous day's spent ceiling. The budget day is now the calendar day in
the account's time zone; each request still resolves it once and books
its reservation and reconcile on the same row. The medication extraction
reads relative phrases against the same local day, the operator's spend
card shows the operator's own day, and the limit copy says the budget
resets at midnight in the person's time zone.
2026-10-11 13:09:12 +02:00
Marc-André Bombeck 7d8ff0f2df fix(day): keep Escape in the date calendar from closing the whole day
On a phone the date's calendar is a sheet over the day's own sheet. In
the moment after it opens, before Radix has re-rendered both layers, the
day's sheet still holds the Escape listener, so an Escape aimed at the
calendar closed the day as well; the day-view spec caught it as a flake
on the phone viewport (4 of 10 runs locally). The day's sheet now ignores
an Escape that comes from inside another dialog, and the spec waits for
the layer handover instead of racing it.

The coach open-at-end spec read the thread's height as soon as an element
with the thread's slot was visible, but the empty state carries the same
slot. It now waits for the loaded thread's frame and all of its answers.
2026-10-11 13:09:12 +02:00
Marc-André Bombeck 334cb4e979 Merge branch 'v1422/search' into release/v1.42.2 2026-10-11 13:06:07 +02:00
Marc-André Bombeck a36e414895 feat(search): widen the clinical read for the in-app search and cover the palette end to end 2026-10-11 12:59:51 +02:00
Marc-André Bombeck 118494b91d test(rollups): stop the summary parity tests going red after midnight UTC
The two summary tests seeded readings counting back from the current
instant, a few more minutes earlier each, and compared the composed mean
with a plain AVG. Between midnight and about 01:15 UTC the earliest
minutes slid into the day before, a day gained a reading in another hour,
and for pulse, whose day is a mean of hourly means, the composed mean
left the plain AVG. The fixtures now count back from noon UTC yesterday,
and the expected mean is the one each type documents: AVG, or the mean
of hourly-mean days.
2026-10-11 12:54:37 +02:00
Marc-André Bombeck 11c55cc6f8 Merge branch 'v1422/notify' into release/v1.42.2 2026-10-11 12:47:15 +02:00
Marc-André Bombeck 8f99b81f5f test(logging): scrub the notification action token from logged paths 2026-10-11 12:46:43 +02:00
Marc-André Bombeck 274cf4900e test(notifications): pin the content preference in backups and the action error codes 2026-10-11 12:46:12 +02:00
Marc-André Bombeck 1b5225ba36 test(search): name the scan-cap mutation as it was run 2026-10-11 12:43:22 +02:00
Marc-André Bombeck 2dc313980b fix(google-health): let a progressing backfill slice end quietly and continue
Since the backfill resumes per collection, a slice that ends before the
whole history is in threw "backfill incomplete" so pg-boss retried it,
which logged job.run.failed at error and grew the queue's failure count
although nothing had failed. A slice that finished at least one more
collection now queues the next slice on the admission queue under its
own singleton key and completes. Only a slice that finished nothing new
still throws, so a backfill that is actually stuck keeps its error. The
import lock keys on the import identity without the slice number, and a
slice that finds the import already stamped walks nothing.
2026-10-11 12:42:58 +02:00
Marc-André Bombeck 1644acf443 feat(palette): list hits from the record under the local results 2026-10-11 12:39:54 +02:00
Marc-André Bombeck 12a8ab7c63 feat(documents): open a PDF or image original in a new browser tab
The document sheet gains an Open in new tab action for the types the
browser renders itself (PDF, JPEG, PNG, WebP, GIF), so a long report
gets the browser's own zoom and search instead of the small preview. It
opens the object URL the sheet already fetched through the app transport:
a plain navigation to the serve route carries no record-session
assertion and would be refused inside a shared record. Every other type
keeps the download only, and the serve route's matrix now pins SVG and
HTML to attachment as well.
2026-10-11 12:37:57 +02:00
Marc-André Bombeck fca1260b20 fix(oura): exchange and refresh tokens at Oura's current token endpoint
Apps from Oura's current developer portal receive codes from
moi.ouraring.com, and the documented api.ouraring.com/oauth/token answers
those codes with 400 invalid_request, so connecting failed and every
collection read came back 401. Token requests now go to
moi.ouraring.com/oauth/v2/ext/oauth-token first. A code works once, so the
documented endpoint is never asked first: a code whose callback names the
current server goes nowhere else, and only an older app's code or refresh
token gets one retry there after a 400, 401 or 404. The callback checks the
RFC 9207 iss parameter when present and refuses a foreign issuer.
2026-10-11 12:34:33 +02:00
Marc-André Bombeck 5435890275 test(notifications): cover tap targets, quick-answer tokens and the action route; document them 2026-10-11 12:33:02 +02:00
Marc-André Bombeck f65d25b0d0 fix(db): read the clock as UTC in raw SQL and prove it on a Berlin database
Every raw-SQL clock read against a timestamp column without a zone now
writes NOW() AT TIME ZONE 'UTC', so passkey and login challenges, rate
limits, provider pauses, coach usage stamps, tombstone and measurement
windows no longer pass through the session time zone even where the
per-connection pin cannot apply. The regression-window bounds stay naive
UTC timestamps instead of being turned into timestamptz. A structural
guard keeps bare NOW() / CURRENT_TIMESTAMP out of raw SQL outside a short
list of timestamptz targets. The integration test sets the database
default to Europe/Berlin and drives challenge claims, session expiry, the
native sign-in handoff freshness check and the rate-limit window through
the app's client, plus the challenge claim through an unpinned client.
Self-hosting docs describe the pin for an existing or shared Postgres.
2026-10-11 12:31:37 +02:00
Marc-André Bombeck 9b86ec2e10 test(search): cover the matcher, the scan bounds and the route 2026-10-11 12:29:41 +02:00
Marc-André Bombeck 40e66bf027 feat(notifications): add the content detail preference and the morning score 2026-10-11 12:27:53 +02:00
Marc-André Bombeck 1766ffa3d2 feat(search): search the record from one endpoint 2026-10-11 12:27:28 +02:00
Marc-André Bombeck f19dfc4572 feat(notifications): resolve a tap target per event and offer mood quick answers 2026-10-11 12:21:51 +02:00
Marc-André Bombeck 42ba471765 refactor(search): share the record matcher and give record candidates display data 2026-10-11 12:20:15 +02:00
Marc-André Bombeck cefc8c15c1 fix(db): pin every connection to UTC and say so at boot when it is not
Stored times are UTC in timestamp columns without a zone, and Postgres
reads now(), CURRENT_TIMESTAMP and offset-free timestamptz parameters
through the session time zone. On a database whose default is not UTC
every expiry check ran off by the offset. The Prisma pool, pg-boss, the
lock and the maintenance connections now send TimeZone=UTC as a startup
option, or SET it on connect when the startup options are disabled for
a pooler. A boot check reads SHOW TimeZone and warns on anything else.
2026-10-11 12:15:03 +02:00
Marc-André Bombeck 9aadb29876 Merge pull request #1199 from MBombeck/feat/strip-full-height
feat(shell): full-height docked strips, magnifier search, g p for the day
2026-10-10 20:42:22 +02:00
Marc-André Bombeck 39fc4e4da2 feat(shell): run the docked strips the full height and make them the only fold
The docked panels' strips now run from the top edge of the window to the
bottom, and the top bar ends left of them. The panel header no longer
carries a fold button in docked mode: the strip opens and closes the day
and, on /coach, the conversations; opening moves focus to the panel's
heading and closing returns it to the strip. The sheets keep a close
button, the day's now an X at the end of its header.

The command palette's door is a magnifier at every width, named Search,
with the shortcut in its tooltip. g p opens or closes the day like a
click on its strip, and below the docking width opens the sheet on the
last day or closes it; the shortcuts list shows it under Day.
2026-10-10 20:04:19 +02:00
Marc-André Bombeck 023115b05c fix(timeline): keep each mean's dot and value one unbreakable unit
The dot before a mean sat on the x-height middle, a little below the
text, and a wrapping line could leave it at the end of one line while its
value moved to the next. Each dot and value is now one inline-flex unit
that centres the dot on the text line and never wraps inside; the row
breaks only between units, a long name is cut short inside its own unit,
and the leading mean sign rides in the first unit. The legend and chip
dots no longer shrink beside long text.
2026-10-10 20:04:13 +02:00
Marc-André Bombeck b787bc3176 Merge pull request #1198 from MBombeck/release/v1.42.1
Release v1.42.1
2026-10-10 14:18:23 +02:00
Marc-André Bombeck 099ea514b1 chore(release): v1.42.1 2026-10-10 13:56:41 +02:00
Marc-André Bombeck 1afe0ee2e4 Merge pull request #1197 from MBombeck/fix/dashboard-top-card-gap
Keeps the gap under the dashboard's top card.
2026-10-10 13:54:47 +02:00
Marc-André Bombeck 020663d1f7 Merge branch 'main' into fix/dashboard-top-card-gap 2026-10-10 13:30:02 +02:00
Marc-André Bombeck 3ad82590c6 Merge pull request #1196 from MBombeck/fix/google-health-1194-1195
Refs #1194, #1195
2026-10-10 13:29:59 +02:00
Marc-André Bombeck 01aa4e5ba1 fix(dashboard): keep the gap under the top card 2026-10-10 13:21:59 +02:00
Marc-André Bombeck 46047e57c0 fix(google-health): keep long backfills out of reauth parking and settle overlapping sleep segments
A history backfill carried one access token through walks that outlive it.
After an hour every request answered 401, which classifies as a revoked
grant, so the account was parked at error_reauth with a refresh token that
was still valid, and every later run skipped it until a manual reconnect.
Running beside the hourly sync, the backfill also pushed the account over
Google's per-minute quota, and each 429 failed its whole collection.

- Walks take a token source that renews the access token before it expires
  and once more when Google refuses it mid-walk.
- A 429 is waited out (Retry-After, RetryInfo, else a doubling backoff) and
  the request repeated; it is never a reauth.
- error_reauth only short-circuits a run when the token endpoint refused the
  grant (needsReauth). A connection parked by a data request's 401 refreshes
  on its next run and clears itself when that run completes.
- One run per account at a time: syncs, the backfill and the sleep repair
  share an advisory lock. The hourly poll skips a held account, a manual
  sync answers 409 sync_in_progress, the backfill waits a bounded time.
- A backfill attempt records each collection it finishes cleanly
  (backfill_progress) and the next attempt resumes with the rest.
- The incremental watermark is stamped with the cycle's start, not its end.

Overlapping sleep segments that share a stage and an end share the natural
key (type, measuredAt, source, sleepStage). A re-score that moved one row
onto a sibling's slot threw P2002 and failed the sync on every run.

- A batch keeps one reading per natural key, chosen the same way each time
  (longer segment, then smaller externalId).
- A move that still collides is settled by reconcileExternalMeasurement,
  which retires the stale row, instead of failing the sync.
- Updates run before inserts, and the key-format rescue no longer re-keys a
  row this batch is moving.
- The sleep replace-by-window protects every session's fresh rows, so two
  overlapping sessions no longer tombstone and resurrect each other hourly.

Refs #1194, #1195
2026-10-10 12:59:25 +02:00
Marc-André Bombeck f0b4e902bb Merge pull request #1193 from MBombeck/fix/maintenance-shm
Refs #1192
2026-10-10 12:19:02 +02:00
Marc-André Bombeck a2ee79a746 fix(maintenance): run the measurement vacuum without a /dev/shm segment 2026-10-10 09:33:17 +02:00
dependabot[bot] e1eebebd1d chore(deps-dev): bump the development-weekly group across 1 directory with 3 updates (#1191)
Bumps the development-weekly group with 3 updates in the / directory: [@next/bundle-analyzer](https://github.com/vercel/next.js/tree/HEAD/packages/next-bundle-analyzer), [lefthook](https://github.com/evilmartians/lefthook) and [shadcn](https://github.com/shadcn-ui/ui/tree/HEAD/packages/shadcn).


Updates `@next/bundle-analyzer` from 16.3.3 to 16.3.8
- [Release notes](https://github.com/vercel/next.js/releases)
- [Commits](https://github.com/vercel/next.js/commits/v16.3.8/packages/next-bundle-analyzer)

Updates `lefthook` from 2.2.1 to 2.1.17
- [Release notes](https://github.com/evilmartians/lefthook/releases)
- [Changelog](https://github.com/evilmartians/lefthook/blob/master/CHANGELOG.md)
- [Commits](https://github.com/evilmartians/lefthook/compare/v2.2.1...v2.1.17)

Updates `shadcn` from 4.21.4 to 4.21.3
- [Release notes](https://github.com/shadcn-ui/ui/releases)
- [Changelog](https://github.com/shadcn-ui/ui/blob/main/packages/shadcn/CHANGELOG.md)
- [Commits](https://github.com/shadcn-ui/ui/commits/shadcn@4.21.3/packages/shadcn)

---
updated-dependencies:
- dependency-name: "@next/bundle-analyzer"
  dependency-version: 16.3.8
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development-weekly
- dependency-name: lefthook
  dependency-version: 2.1.17
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development-weekly
- dependency-name: shadcn
  dependency-version: 4.21.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development-weekly
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-10 02:07:07 +00:00
Marc-André Bombeck b10a1132e6 Merge pull request #1190 from MBombeck/deps/vitest-5
chore(deps): vitest 5
2026-10-10 03:37:09 +02:00
Marc-André Bombeck 91c358c56d Merge remote-tracking branch 'origin/main' into deps/vitest-5 2026-10-10 02:44:39 +02:00
dependabot[bot] c1309fba33 chore(deps): bump the production-weekly group with 2 updates (#1186)
Bumps the production-weekly group with 2 updates: [@aws-sdk/lib-storage](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/lib/lib-storage) and [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react).


Updates `@aws-sdk/lib-storage` from 3.1146.0 to 3.1147.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/lib/lib-storage/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1147.0/lib/lib-storage)

Updates `lucide-react` from 1.53.0 to 1.52.0
- [Release notes](https://github.com/lucide-icons/lucide/releases)
- [Commits](https://github.com/lucide-icons/lucide/commits/1.52.0/packages/lucide-react)

---
updated-dependencies:
- dependency-name: "@aws-sdk/lib-storage"
  dependency-version: 3.1147.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production-weekly
- dependency-name: lucide-react
  dependency-version: 1.52.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production-weekly
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-10 00:15:35 +00:00
Marc-André Bombeck 6b11739a51 Merge pull request #1187 from MBombeck/deps/dotenv-18
chore(deps): dotenv 18
2026-10-10 02:12:14 +02:00
Marc-André Bombeck e4879522c0 Merge pull request #1185 from MBombeck/deps/dev-2026-10-09
chore(deps): weekly development updates
2026-10-10 01:43:51 +02:00
Marc-André Bombeck 013a3fb86a chore(deps): move the test runner to vitest 5
vitest and @vitest/coverage-v8 move from 4.1 to 5.0.3 together. Neither
config uses an option the major removed or changed: no projects, so the
new setupFiles concatenation does not apply; no browser mode; and the
integration config's forks pool, serial files and isolation keep their
meaning. Node 22.12 and Vite 6.4 are the new floors, and both are met.
2026-10-10 01:15:10 +02:00
Marc-André Bombeck 369775e039 chore(deps): move dotenv to 18
The breaking changes in 18 do not reach this repository: nothing preloads
dotenv with -r, nothing reads a .env.vault, and both entry points in use
(the default export in prisma.config.ts and the side-effect dotenv/config
import in the one-shot scripts) keep their behaviour. dotenv/config stays
quiet by default.

What does change is the load message, which now goes to stderr. Prisma
reads prisma.config.ts on every migrate deploy at container start, so
both loads there pass quiet: true rather than adding two stderr lines to
each boot. The image's /opt/prisma-cli install, which the config resolves
dotenv from, moves to the same 18.0.6.
2026-10-10 01:08:57 +02:00
Marc-André Bombeck 6d2f22e922 Merge pull request #1184 from MBombeck/deps/production-2026-10-09
chore(deps): weekly production updates, next and zod held back
2026-10-10 01:00:02 +02:00
Marc-André Bombeck 1d782d88e2 chore(deps): take the weekly development updates
@testcontainers/postgresql 12.2.0, @types/node 26.6.4, @vitejs/plugin-react
6.1.2, knip 6.40, lefthook 2.2, pdfjs-dist 6.4.299 and shadcn 4.21.

pdfjs-dist is a development dependency that the image ships: the runner
hoists the pinned copy for document rendering. 6.4 asks for
@napi-rs/canvas ^1.0.10, which the production updates bring, so the store
keeps a single canvas build for it. eslint-config-next and
@next/bundle-analyzer stay on the 16.3 line with next.
2026-10-10 00:49:17 +02:00
Marc-André Bombeck ff0a81154c chore(deps): keep next on 16.3.8, 16.4.0 breaks history-driven URL state
With 16.4.0 a document sheet opened through ?doc= stays open after Back,
and the [ / ] day keys sometimes leave ?day= where it was. Three end-to-end
journeys fail 3 of 40 repeated runs on 16.4.0 and 0 of 80 on 16.3.8 with
every other update in place, and CI hit the same journeys on both
viewports. 16.3.8 already carries every published security fix, so
Dependabot skips 16.4.0 for next and the two packages that move with it.
2026-10-10 00:36:36 +02:00
Marc-André Bombeck c2ac1428e5 chore(deps): take the weekly production updates, zod held on 4.4
Next 16.4.0, pg-boss 12.37.1, the AWS SDK 3.1146, @napi-rs/canvas 1.0.10,
@node-rs/argon2 2.2.2, @simplewebauthn/server 14.0.3, TanStack Query and
Virtual, lucide-react, mmdb-lib, nodemailer, otpauth, pg, radix-ui,
react-day-picker, react-hook-form, tailwind-merge and yaml.

pg-boss moves its schema from 42 to 45 (three additive migrations, nullable
columns and one partial index, no table rewrite); the worker applies them on
start as before. The retention test pins the installed version, so it moves
to 12.37.1 after re-checking every source line it asserts.

zod stays on 4.4.3. 4.5 requires seconds in `z.iso.datetime()` and 4.6
rewrites nullable unions in the generated JSON Schema into `type` arrays,
which reshapes the OpenAPI document and the MCP tool schemas. Dependabot
skips zod 4.5 and later until that move is made on purpose.
2026-10-09 23:03:03 +02:00
Marc-André Bombeck 90050815eb Merge pull request #1178 from MBombeck/release/v1.42.0
Release v1.42.0
2026-10-09 20:46:02 +02:00
Marc-André Bombeck e6a2429df9 fix(documents): keep four tiles across a 1440 px window with the day strip docked 2026-10-09 20:21:10 +02:00
Marc-André Bombeck 3becc235ae docs(release): retake the timeline images with the lab count as its own chip 2026-10-09 19:55:11 +02:00
Marc-André Bombeck d5a153893c Merge v142/release-images into release/v1.42.0 2026-10-09 19:44:09 +02:00
Marc-André Bombeck 27781ef12c Merge v142/last-chips into release/v1.42.0 2026-10-09 19:44:09 +02:00
Marc-André Bombeck d068d6c9e6 fix(day,timeline): put an intake's dose in its name and set a lab day's count apart
Under "What happened" an intake now reads "Ramipril 5mg" on one line, as
the same medication does under Running that day, instead of the name over a
muted dose line. A lab day's analyte count moves from the run-on second part
to the entry's tag: a chip of its own in the selection bar, brackets in the
screen-reader line and the hover title, the muted line in the chronicle.
2026-10-09 18:55:34 +02:00
Marc-André Bombeck 8a5589c32b docs(changelog): bring the v1.42.0 day view and timeline entries to the shipped state 2026-10-09 18:46:21 +02:00
Marc-André Bombeck 6a27bdfc2d docs(release): retake the v1.42.0 release images with the panel strips
The dashboard and the Coach now show the fixed strips at the right edge
with the day docked beside them, and the search field in the top bar.
Adds the command palette searching for sleep and the Health Score
history page. The timeline keeps All with one row per medication, five
value lines and a selected quarter. Each in light and dark, from the
demo seed on a local instance.
2026-10-09 18:44:33 +02:00
Marc-André Bombeck 1319c63825 docs(release): retake the v1.42.0 release images on the final state
Day view docked beside the dashboard with its running line, values and
the five scores, and as a phone sheet; the timeline at All with one row
per medication, five value lines and a selected quarter; the phone
chronicle; the air quality and pollen card; the readiness inventory;
and the Coach beside the docked day with the new-chat button. Each in
light and dark, from the demo seed on a local instance.
2026-10-09 18:30:29 +02:00
Marc-André Bombeck 891c85a82b chore(demo): seed readiness and health-score history and a start for magnesium
The day view reads readiness from the nightly COMPUTED recovery row and
the health score from the record of each local day; neither job reaches
back, so a fresh seed showed both for today at most. Mint the rows the
jobs would have written over the window, and give magnesium the start
date its running line needs.
2026-10-09 18:30:29 +02:00
Marc-André Bombeck be51db56bb docs(release): add the v1.42.0 release images
Day view docked beside the dashboard and as a phone sheet, the timeline
at All with a selected quarter, the phone chronicle, the air quality and
pollen card, and the readiness inventory, each in light and dark. Taken
from the demo seed on a local instance.
2026-10-09 18:30:29 +02:00
Marc-André Bombeck 1199e009b2 chore(demo): carry ragweed well into October in the seeded pollen 2026-10-09 18:30:29 +02:00
Marc-André Bombeck 145d138c66 chore(demo): seed a multi-year history for the timeline and day view
The demo account carried 90 days, which leaves the timeline's All zoom
and the day view with nothing to show before last quarter. The seed now
writes about four and a half years before that window: blood pressure in
bursts with a silent quarter and a thin one, weekly weight, phone steps,
watch resting heart rate, HRV and sleep from the third year, yearly
blood panels with HbA1c, lipids and hemoglobin, a resolved chronic
condition and three infections, ramipril with a stated start, a dose
change and a sick-day pause, a finished antibiotic course, flu shots and
a Tdap booster, visits, life events at day, month and year precision, a
sealed home location with a holiday period, and the stored weather, air
quality and pollen for every day. The timeline module is switched on.

All of it is invented and relative to the seed run; rows are written in
chunks so the seed stays quick.
2026-10-09 18:30:29 +02:00
Marc-André Bombeck 96ee0c7821 test(dashboard): assert the top card switch on its region, not on the day's digest 2026-10-09 18:23:48 +02:00
Marc-André Bombeck 25d0839296 Merge the right-side panel strips, lazy day panel and calendar, and the layout write lock into release/v1.42.0 2026-10-09 18:22:48 +02:00
Marc-André Bombeck d8fb922228 feat(shell): give each docked panel a fixed strip at the right edge
From 1280 px the day and, on /coach, the conversations each keep a
narrow strip at the right edge, open or not. A click on the strip opens
its panel left of the strips and a second click closes it; the strip
never moves. Strips start below the top bar's band, side by side,
conversations first. Below 1600 px one panel is open at a time.

The day slides like the conversations (width, 200 ms, none under reduced
motion), stays open through a page change instead of folding and
reopening, and does not slide in on arrival. Its content loads when a day
first opens, so the scores, sections and calendar leave the shell's first
load: every watched route falls back under its unchanged ceiling. The
aggregate cap is re-measured for the chunk double count this causes.
2026-10-09 18:22:11 +02:00
Marc-André Bombeck 41a7f4daea perf(ui): load the date field's calendar when it first opens
react-day-picker and the seven date-fns locales rode the first load of
every page with a date field, the dashboard and the settings sections
among them. The calendar now lives in its own module that the popover
loads on first open; the locale map moves beside it.
2026-10-09 18:22:10 +02:00
Marc-André Bombeck 86fa57d8bf fix(dashboard): queue concurrent layout writes behind a row lock
Parallel PUTs to /api/dashboard/chart-overlay-prefs from one account ran
in Serializable transactions, which aborted all but one with a
serialization failure and answered 500. Lock the user row with FOR UPDATE
instead, so each writer waits for the previous commit and reads it. The
layout reset on DELETE /api/dashboard/widgets takes the same lock.
2026-10-09 17:42:00 +02:00
Marc-André Bombeck 09f84ef418 test(coach): give the live scenario runner room on a loaded machine 2026-10-09 17:33:19 +02:00
Marc-André Bombeck 018841e6d6 Merge the final display polish into release/v1.42.0 2026-10-09 17:25:50 +02:00
Marc-André Bombeck dcfec1f35d Merge the final display polish into release/v1.42.0 2026-10-09 17:25:35 +02:00
Marc-André Bombeck ac95c9c7dd fix(day): Back on the same page still closes the day
Keeping the day open across pages read every loss of ?day= as a page
change, so Back past the step that opened the day on the same page
reopened it at once. A loss of ?day= with the page unchanged is the
person leaving the day, and it closes it as before; only a change of
page carries the open day along.
2026-10-09 17:16:10 +02:00
Marc-André Bombeck fbcf099718 test(analytics): expect the latest pulse as its latest day's value
The paged read still has to see every row; the latest pulse is now the
value of its latest local day, computed in the test through the same
day-mean helper.
2026-10-09 17:09:42 +02:00
Marc-André Bombeck 3a5953bda2 Merge the score history, score ranges and top card toggle into release/v1.42.0 2026-10-09 17:02:56 +02:00
Marc-André Bombeck b94101d0fe chore(i18n): drop the keys of the removed count line, usual label and open-day link
The day's count line, the usual-range label beside the focus value and
the selection bar's "Open <day>" link are gone, and so are their strings
in all seven bundles. The pulse latest-day read is listed in the dense
read guard with why it stays bounded: one type over one day, the rows the
day view reads anyway. The summaries test's database stub answers that
read.
2026-10-09 16:57:08 +02:00
Marc-André Bombeck 67d8657336 Merge keyboard shortcuts and the command palette into release/v1.42.0 2026-10-09 16:53:21 +02:00
Marc-André Bombeck 668f568289 feat(dashboard): let the top card be switched off
The layout settings gain a switch for the dashboard's top card (the Today
card above the tiles). Stored server-side on the layout blob as
`todayCardVisible`: missing means shown, only an explicit false hides it,
the default is omitted from storage, a save that omits the field keeps
the stored choice, and a layout reset shows the card again. Hidden, the
card leaves no skeleton or error card behind. The flag rides the
account-settings backup with the rest of the layout.

The field is deliberately not `heroVisible`, the retired opt-in hero's
flag that older stored layouts may still carry.

An end-to-end journey on its own account switches the card off and on
and walks the score histories' range tabs.
2026-10-09 16:52:39 +02:00
Marc-André Bombeck ebad3665fd perf(shell): load the keyboard shortcut list on first open 2026-10-09 16:52:34 +02:00
Marc-André Bombeck 8d1283a32a style: format the separator guard 2026-10-09 16:52:30 +02:00
Marc-André Bombeck 62140a4aee feat(insights): follow every score over time with one range selector
The health score had no history anywhere, readiness and the sleep score
showed a fixed sparkline, and only the stored nightly scores offered the
range tabs. Every score page now charts its course with the same
7 / 30 / 90 / All tabs, remembered per page, and each point opens its day.

- GET /api/insights/score-history?score=&days= serves the daily course of
  the health score (its stored day rows), readiness (the nightly blend,
  filed on its wake day) and the sleep score (each night against the
  trailing window ending on it), so a point equals what the day view shows
  for that day. MANAGE record read, module-gated for readiness and sleep,
  days 1 to 3650, additive.
- The health score's line breaks where its recipe changed (configuration
  or algorithm version); the usual range is formed from the newest side
  of the last seam.
- The range tabs move into one shared component used by every chart;
  recovery, stress and strain keep their measurement chart, now with the
  range remembered and no overlay dropdown.
- New page /insights/health-score, linked from the score panel and from
  the day view's score tile.
2026-10-09 16:45:39 +02:00
Marc-André Bombeck 27715c06e0 fix(timeline): a name right of its own bar, one band per pair of rows, one line per notable
A closed period's name could not stand right of its own bar: the room
check counted the bar it names as the next mark ahead, so "Away" went
above its bar and sat on top of the life event named on the row above.
The room ahead now counts only what starts to the right. A row's names
and the names above the next row's bars share one band a few pixels
tall, so whatever is placed on one is written into the other as well.

The phone chronicle listed "First value of a measure" twice on a day
when two metrics began on it, under one React key; it lists one line per
kind of notable a day.
2026-10-09 16:45:23 +02:00
Marc-André Bombeck b274ee51ad test(timeline): pin the medication rows, the open day, the point card and the page height
The journey now writes two medications through the real routes (one with
a start date and its course, one known only by an intake) and asserts
each is drawn on exactly one row; carries an open day from /timeline to
/labs and back after folding it; hovers and focuses a value point and
checks the card names that line alone and stays inside the chart; and
measures the page at 1280, 1440 and 1920 px, with and without the docked
day: no sideways scroll, and no height past the content beyond the
shell's own padding. Expanding all of a day's values scrolls the panel
and leaves the page height as it was. The "Open <day>" link the bar no
longer has is asserted absent; a chip opens the day instead.

Label texts carry their lane under their own attribute, so a lane
selector matches the lane and not its names.
2026-10-09 16:41:44 +02:00
Marc-André Bombeck b1a0598c9d fix(timeline): a point reads on its own, the page ends where its content does, an open day stays open
A point of a value line now shows a card for that line and that point
only: its name, the value with its unit, the stretch it is the mean of and
the readings behind it, in the tooltip the other charts use. Each point
has a 24 px HTML target over it: a pointer resting on it shows the card,
the keyboard reaches every line once and walks its points with the
arrows, a tap shows the card and a second tap (or its "View the whole
day") opens the day. The card is clamped to the chart's width. The SVG's
native titles are gone.

The two screen-reader tables beside the chart were hidden with sr-only on
the table element itself, where overflow: hidden does not apply: they laid
out at full size below the page and made <main> scroll sideways and past
its last card. They are hidden by a wrapper now, and the page ends where
its content does at every width, with and without the docked day.

The docked day stays open across pages. Leaving a page drops ?day= from
the URL, and the column folded to its edge on every navigation. The
remembered day now also remembers that it was left open (per account and
record, as before); a page reached without ?day= while it was left open
brings the day back, without taking focus from the page. Folding,
closing, Escape, a swipe down or opening the Coach's conversations
beside it is what closes it.
2026-10-09 16:37:14 +02:00
Marc-André Bombeck 715b733164 feat(shell): add a command palette for pages, settings, modules and actions
Cmd/Ctrl+K or the search control at the end of the top bar opens a
combobox over a client-side index built from the lists the navigation
already renders: pages, Insights pages, every Settings section and card
(deep-linked by a new anchor prop on SettingsCardHeader), the Appearance
sub-pages, module switches and a few actions. Entries follow the module
map, the Coach capability, the admin role and a shared record's grant;
inside somebody else's record no cards, switches or backup are offered.

Ranking is prefix, then word start, then substring, then a small edit
distance, then subsequence, with a short multilingual synonym list. The
empty state shows recent places (per account, in localStorage) and the
main actions. The palette is its own lazily loaded chunk; the top bar
shows a search field where it has room and a magnifier otherwise. The
module switch mutation moves into a shared hook used by the Modules hub
and the palette.
2026-10-09 16:35:21 +02:00
Marc-André Bombeck df67e285de fix(timeline): names that fit, links to each value's page, a calmer selection bar
Labels are placed without collisions: a name keeps a full gap before the
next mark on its line, is cut with an ellipsis when only part of it fits,
and is left out when not even a few characters do. A name above an open
bar ends before the bar's arrow head. All names are drawn after the today
and selection lines, on a halo of the card colour, so a line passes
behind a name instead of through it.

A value line's name wraps onto a second line instead of reading "Blood
pressure sy…", in every bundle, and it leads to the metric's page, as do
the value tiles in the day view. Both read one map from a kind of value
to its page (metricPageHref); a kind without a page stays plain text. The
links carry a 44 px target, a hover and a focus ring.

A life event's category is no longer run on after its title ("Parental
leave Family"): the chart names the event alone, the selection bar shows
the category as a small chip inside the entry, and the phone chronicle
puts it on the muted line below.

The selection bar shows each mean as name and value; the readings behind
it ("mean of 19 readings") are a hover title and a screen-reader detail.
The lone "Open <date>" link at its end is gone; a day opens from its
chips, Enter on the chart or a double click.

In the day view an event's time keeps to one line in every locale.
2026-10-09 16:25:48 +02:00
Marc-André Bombeck cd72317356 fix(day): one entry per medication, one meaning for "Pulse", a quieter panel
Running that day listed a medication, its course and its pause as rows of
their own, so Mounjaro read as "Mounjaro 7.5 mg" above "Mounjaro". The day
read now folds them into one entry per medication: its name with the dose
in effect that day (the last dose change on or before it), the course's
days on the second line, or the paused entry while a pause runs. The
Coach's get_day reads the same loader and gets the same single entry.

The dashboard's pulse widget showed the resting heart rate whenever one
was recorded but called it "Pulse", while the day view's "Pulse" tile
was the day's pulse; one day read 62 on the dashboard and 77 in the day.
The widget is now named by what it shows ("Resting heart rate" with
resting rows, "Pulse" without), and the pulse's latest value is the
latest day's pulse, the mean of its local hours' means through the shared
day-mean helper and the day view's own canonical-source pick, so both
"Pulse" figures are one number. The day view's pulse goes through the
same helper in the record's zone.

The count line above the day ("33 values, 3 entries") and the usual range
repeated beside the focus value ("Your 30 days before") are gone; the
sections and the value tiles already say both. An assessment's score and
band join with a comma, not a middle dot.
2026-10-09 16:16:42 +02:00
Marc-André Bombeck 9f3da5ac7d fix(timeline): keep every medication still taken on its row, and the chronicle honest
An active medication whose courses had all ended had no stretch left in
the current window: the courses decided when it was taken, none reached
into the window, and the medication's own open span was set aside because
it had courses. The row disappeared while the day view listed the
medication as running. The time since its last course now stands in as
its stretch, open to today.

The server cut a lane's items at the window edge, so a medication arrived
with only some of its courses and without the dose change that set the
dose of the piece running into the window. A medication touching the
window is now sent whole.

On a phone, every entry wears its lane's colour: the icon in the lane
colour, and a medication listed inside an illness keeps a dot on its own
rail instead of reading as the illness's orange. A month without an event
is no longer called empty when it had readings (its header stays and
carries the means) or while a medication was taken in it; only a month
with none of the three gets the "No entries" line.
2026-10-09 16:05:40 +02:00
Marc-André Bombeck 86a3856ae9 fix(i18n): drop spaced dashes as separators from every visible string
A dash between two parts of a sentence ("{what} — tomorrow", "Could not
record {name} — try again", "End {name} — …") stands in for a comma, a
colon or a full stop the sentence should say. Every such string in the
seven bundles now says it in plain words. A range keeps its unspaced en
dash ("70–100 mmHg", "{start}–{end}"); that is how a range is written,
not a pause, and the spaced range forms became unspaced ones.

Labels the components put together themselves follow the same rule:
aria-labels read "Move up: Ramipril", a medication and its dose read
"Ramipril 5 mg", page titles use "|", Coach prefills are two sentences,
and the API's user-facing error messages lost their dashes too.

The bundle guard now refuses " — " and " – " beside " · ", with an empty
allowlist, and a sibling guard walks src/components and src/app for a
string or template literal that joins its parts with a spaced dash.
2026-10-09 16:05:32 +02:00
Marc-André Bombeck 44841c65fc feat(shell): add global keyboard shortcuts and their help dialog
g then d/m/l/t/i/c/s opens a page through the same visible-destination
lists the navigation renders, so a switched-off module, a Coach without a
provider or a door a shared record does not cover stays shut. n opens the
capture picker, [ and ] step the open day, ? lists everything. Keys typed
into a field, held with Cmd/Ctrl/Alt, or pressed while a dialog or menu is
open are left alone; [ and ] still act over the day sheet and when the
layout needs Alt or AltGr to type them. The list is also reachable from
the sidebar account menu.
2026-10-09 16:03:54 +02:00
Marc-André Bombeck 1d080ee166 Merge the day picker, Coach new-chat button, day panel on /coach and text cleanup into release/v1.42.0 2026-10-09 15:11:24 +02:00
Marc-André Bombeck 4ca2334805 Merge the timeline rework into release/v1.42.0 2026-10-09 15:02:46 +02:00
Marc-André Bombeck 1440ad4301 feat(timeline): one row per medication, no cap on value lines, range popover
A medication arrived as up to four kinds of item (its own span, its
courses, its dose changes, its pauses) and was drawn item by item, so a
medication with a course read as two bars on two rows. Every item of one
medication now carries the medication's id as `group`, and the lane draws
one row per group: the courses are the stretches it was taken, each dose
change cuts them into pieces labelled with their dose, and a pause is a
gap in the same bar. The selection bar and the phone chronicle list one
entry per medication with the dose in the text.

The value series cap of six is gone. The response carries
`availableSeries`, every series the record can show, and the picker is
one compact "Values (n)" button whose menu lists them. Past the fifth
line the five data colours repeat; every line is named at its left.

Choosing a range opens a popover anchored to the "Range" segment (a sheet
on a phone) with "Apply", so the chart no longer moves; the URL keeps the
range as before. The selection bar sits on the card under a hairline
instead of a grey box, the legend explains the dashed stroke and the
hollow point, and no timeline text joins its parts with a middle dot or
calls an open span "ongoing".
2026-10-09 15:02:14 +02:00
Marc-André Bombeck 2ca60ab956 refactor(day): word the cycle line through a typed key map 2026-10-09 14:29:07 +02:00
Marc-André Bombeck 6033935449 Merge v142/day-scores into release/v1.42.0 2026-10-09 14:22:21 +02:00
Marc-André Bombeck 745bcb64b7 Merge v142/webhook-discord into release/v1.42.0 2026-10-09 14:22:13 +02:00
Marc-André Bombeck cda6c58d12 Merge v142/shots-fixes into release/v1.42.0 2026-10-09 14:22:13 +02:00
Marc-André Bombeck d52550cedb test(day): report the time the score reads add to a day 2026-10-09 14:21:10 +02:00
Marc-André Bombeck 32a9624c82 fix(day): raise the calendar as its own sheet on a phone
In the phone's bottom sheet the date sits halfway down the screen, and a
month of finger-sized cells below it ran off the bottom, Today button and
all. There the calendar now opens as a bottom sheet of its own and hands
focus back to the date when it closes. The docked column and the side
sheet keep the popover.

The conversations panel's fold and unfold buttons carry a title instead
of a tooltip: focus lands on them when the panel opens or folds, and a
tooltip opened by that focus took the next Escape for itself.
2026-10-09 14:18:35 +02:00
Marc-André Bombeck bde6880c3e fix(timeline): read the sleep line as time asleep per night
Sleep is stored one row per stage, and the timeline read it like any
other type: the live path took the mean of a day's rows and the long
path the rolled-up month of them, so IN_BED, AWAKE and every CORE
fragment counted as a night, and a quarter of seven-hour nights read as
under two hours.

The sleep series now reconstructs nights the way the sleep page and the
day view do (CORE + DEEP + REM of the night's winning writer, keyed by
its wake day) and averages the bucket's nights, on every window length;
the rollups are not read for it. The stage rows are read a year at a
time with a day's margin on either side, so no read holds a decade of
rows and no night is cut where one read ends. A long window is cached
per record like the other long series.
2026-10-09 14:11:46 +02:00
Marc-André Bombeck 6e2a8fcd9f feat(day): show the day's scores and the cycle day with its phase
The day view gains a scores section below the values: the health score as
recorded that day, the readiness the nightly job persisted for the night
that ended that morning, a device's own recovery, the sleep score of that
night and strain (computed, or the device's day strain on its 0 to 21
scale). Each tile carries the person's usual range over the 30 days before
and opens the score's own page; a score the day does not hold has no tile,
and a day without any has no section.

Every value comes from where its page reads its history: the stored health
score record, the persisted RECOVERY_SCORE and STRAIN_SCORE rows read on
the day they describe, the canonical recovery ladder, and the sleep score
computed over one bounded read of the 30 nights up to the day. The stored
score rows leave the values list of the day they are stamped on, so a
readiness no longer appears a day early among the readings.

Scores follow their own module (recovery, sleep; the health score is core)
and map to the whole record for sharing: a scoped grant sees them named as
not shared, a whole-record grant sees them. get_day sends them to the
Coach and the MCP endpoint by name and number, and the Coach exclusions
take out every score built on excluded data.

A running cycle now states its cycle day and phase ("Cycle day 12,
follicular phase"), computed the way the cycle calendar does. Only logged
cycles count, the next logged start closes a cycle, and an open cycle
stops counting past its typical length plus the grace window instead of
reading a day count nobody recorded.
2026-10-09 14:03:31 +02:00
Marc-André Bombeck b5f1077b94 fix(day): date the running list against the day on screen
DayRunning takes the viewed day, so a start in another year names its
year. The timeline's chronicle test reads the reworded gap line.
2026-10-09 13:53:33 +02:00
Marc-André Bombeck da92dabb55 Merge the separator sweep into the day and Coach polish 2026-10-09 13:44:58 +02:00
Marc-André Bombeck 08f532b428 feat(coach): New conversation in the chat, a foldable list, and the day beside it
New conversation leaves the conversations panel's header and floats in
the conversation itself: a round 56 px button with a plus, the floating
Coach button's face, named "New conversation". It sits in the composer's
row, so it always belongs to the chat column, never to the list or a
docked day beside it. Where the column is wide it takes the bottom right
corner beside the composer; where the composer fills the width (a phone,
the drawer, a narrow column) it rises above the composer's edge. The
drawer shows it once there is a conversation to leave, in place of the
plus in its header. An empty conversation shows nothing either way.

The docked conversations panel folds like the day: its header opens with
a fold control, and folded it keeps a 48 px edge whose button opens it
again (aria-expanded, focus moved between the two, Escape inside folds
it). The top bar's toggle remains for the sheet below 1280 px. On /coach
the day docks right of the panel. Below 1600 px only one of the two is
open: a day opening folds the list without touching the remembered
choice, and opening the list folds the day to its edge. From 1600 px both
stay open. Switching or starting a conversation keeps an open day in the
URL.

The empty conversation drops everything under the composer: the daily
seeded opener and the scope pill are gone, and with them the only reader
of GET /api/insights/coach/seeded-question and of the
coach.ambientSuggestions preference. The route, its OpenAPI entry, the
preference and its Settings switch go too.
2026-10-09 13:44:58 +02:00
Marc-André Bombeck d029e4e1dc fix(notifications): send content and text so Discord and Slack webhooks deliver
The generic webhook body carried title, message, eventType and priority
only. Discord refuses a body without content (400, code 50006 "Cannot
send an empty message") and a Slack incoming webhook one without text,
so the test button and every reminder failed against the targets the
settings card names.

The generic body now appends content (cut to Discord's 2000 characters)
and text, both the masked title and message on two lines, plus
allowed_mentions with an empty parse list so Discord resolves no
mentions. The four original fields keep their values and order for
Home Assistant and n8n rules. The Gotify body is unchanged. The card
names what works and shows a hint for the generic format.
2026-10-09 13:43:28 +02:00
Marc-André Bombeck 9d5a529d53 feat(day): open any day from a calendar on the header's date
The written date in the day's header is now the way to any other day. A
click (or Enter, or Space) opens a month calendar beside it: the day on
screen is selected, every day that holds anything carries a dot read from
GET /api/day/index for the month shown, nothing past today can be picked,
and a Today button sits under the grid. Picking a day swaps it in like a
step, without a new history entry; Escape closes only the calendar and
focus returns to the date. The header keeps its one line and gains no
icon; the arrows stay. The same popover serves the docked column and both
sheets, with 40 px cells for a finger.

On /coach the day now docks beside the conversation like everywhere else,
right of the conversations, instead of opening as a sheet. A neighbouring
panel can fold the docked day to its edge without moving focus
(yieldDay).

The day's count line reads "3 values, 2 entries".
2026-10-09 13:38:11 +02:00
Marc-André Bombeck 94542e7f2d fix(i18n): drop dot and dash separators from visible strings
Every bundle value that chained facts with " · " now reads as a phrase,
a comma list or a parenthesis, in all seven locales, and the em-dash
separators in the day, documents, correlation, sharing, environment,
vaccination and Apple Health import strings become sentences or clauses.

The day view's running list names the record first, with the dose in
the name and a life event's category as a chip, and puts a quiet day
count below: "Day 4, since 31 December" instead of a dot chain. Document
meta, location periods, vaccination rows and mood crosstab rows drop
their composed dots too.

A guard walks every bundle and fails on any " · " outside a reasoned
allowlist, and on an allowlist entry that no longer needs its place.
2026-10-09 13:37:14 +02:00
Marc-André Bombeck 59b072750a test(timeline): load the page once before rendering every zoom 2026-10-09 13:29:29 +02:00
Marc-André Bombeck 1d3b2d0ebe test(timeline): state the template-key guard's floor on what it found 2026-10-09 13:18:08 +02:00
Marc-André Bombeck 78aa80b2ea refactor(timeline): word codes through typed key maps, never template keys
A key built from a template, t(`timeline.zoom.${zoom}`), is invisible to
the call-site coverage guard, so a code without its message would ship as
the raw key and the gate stays green. The timeline and the day view now
word every code family (lanes, zooms, legends, readiness rows, life event
categories, precisions and errors, document kinds, notables, cycle flow,
allergy severity, screener instruments and bands, sports, functional
impact) through maps typed over the code's own union, so a new code does
not compile without its key.

Three checks hold it: one resolves every map value in all seven bundles
and pins the band and sport maps to their sources, one forbids template
keys in both trees (the one existence-checked readiness lookup is named),
and one renders the whole timeline page at every zoom and every bucket in
two languages and fails on any message key in the visible text.
2026-10-09 13:13:33 +02:00
Marc-André Bombeck 3f47224825 fix(i18n): key the catalog's cache by its content, not the release
The service worker serves the locale catalog's boot script cache-first,
and its URL carried the package version. Every build of one release
therefore shared one URL: an instance updated within a release (a beta,
a redeploy) kept the catalog of its first build, and every message added
since read as its raw key ("timeline.zoom.range", "timeline.selection.week")
or fell back to English.

next.config.ts now hashes the message catalogs at build time and the
layout keys the script by that hash, so a changed catalog is a new URL.
2026-10-09 13:13:26 +02:00
Marc-André Bombeck cd78415084 test(documents): check the archive grid under the flowing default
The vault runs its months side by side by default now, so a month is
marked above its first document and no longer has a heading row. The
grid check reads the arrangement and the month marker instead of an h2.
2026-10-09 13:04:27 +02:00
Marc-André Bombeck 4c06ecafe3 fix(dashboard): show the blood pressure axis in mmHg, not Hg
The dashboard's blood pressure chart passed `yAxisUnit="Hg"` beside
`unit="mmHg"`, a shortening carried since the first release, so every
tick read "154 Hg". The override is gone; "mmHg" is four characters and
fits the axis gutter like "bpm" and "mg/dL" do.

A unit with a space of its own ("/ 5") is now joined with no-break
spaces as well, so Recharts cannot wrap it either. A guard fails on any
`yAxisUnit` that is a cut-down form of the `unit` beside it.
2026-10-09 12:56:32 +02:00
Marc-André Bombeck 2638174b66 fix(timeline): word every code an entry carries, and read a pause as one
The timeline and the day view showed what the server sends as it was sent:
a life event's category as FAMILY, a visit's kind as PREVENTIVE, a
document's kind as DISCHARGE_LETTER, a trip and a cycle under their
sentinel labels, a lab day as a bare "5", a mood as SUPER_GUT, a
screener as PHQ9 with its band code. A pause read as "Ramipril" at its
start and "Ramipril ended" at its end.

Every surface that shows a timeline item (lane labels, hover titles,
the screen-reader table, the selection bar, the phone chronicle) now
reads it through one wording, item-words.ts: a code goes through the
bundle's existing labels, a lab day reads "5 lab values", and a code
the bundle does not know is left out rather than shown. The day view's
title and second line do the same per event kind (mood, screener and
band, sport, severity, visit kind or planned, dose number, intensity,
functional impact, document kind), and a medication that starts, ends,
pauses or resumes says so, in the day view and in the visit
preparation alike.

A pause reads "Ramipril paused" at its start and "Ramipril resumed ·
after 11 days" at its end, on the lanes, in the selection bar and in
the chronicle.

A guard renders a record with every item kind, every category and every
kind code on each surface, in two languages, and fails on any upper-case
code or sentinel in the visible text. The wire schema now says which
fields carry codes.
2026-10-09 12:53:56 +02:00
Marc-André Bombeck d8a3614120 Merge the fold provenance and timeline boundary fixes into release/v1.42.0 2026-10-09 12:02:59 +02:00
Marc-André Bombeck b47a39319c fix(timeline): read rolled-up months by their UTC date
West of UTC the local midnight of the boundary month falls hours after the
month's UTC bucket start, so the first rolled-up month went missing and the
first quarter was averaged over two of its months. The rollup window is now
compared by UTC date at both ends.
2026-10-09 12:02:25 +02:00
Marc-André Bombeck 814d3e0179 fix(fold): recompute a stored mean only where the record shows two folds
The fold leftovers were told from a person's deletions by syncVersion = 1,
but raw samples are raised in place before a fold takes them (the collision
merge, a changed Health Connect value). The repair then recomputed windows
without those samples and could overwrite a correct mean, also in windows
one run had folded whole.

A tombstone of fold age now counts as a leftover when it carries
syncVersion = 1 or shares its deletion instant with such a tombstone of the
same day; anything else of that age is ambiguous and leaves its window as
stored. Only a window taken in two parts (fold runs at least an hour apart,
or a run and later live samples) is recomputed.

Once the repair has been through an account, the purge may remove class-A
leftovers inside the horizon, so the folds' re-fold of a stored day or hour
leaves it as stored, and the purge takes the fold lock per account. The
fold-lock transactions get an explicit timeout and maxWait, since the lock
wait counts against Prisma's five-second default.
2026-10-09 12:02:25 +02:00
Marc-André Bombeck f8dab58bc5 refactor(timeline): drop an unused month helper 2026-10-09 10:56:44 +02:00
Marc-André Bombeck ecf27cd9bc Merge the air quality history hardening into release/v1.42.0 2026-10-09 10:56:35 +02:00
Marc-André Bombeck fe87af7234 Merge the delete journal scope, seasonal marker and admin-only geo state into release/v1.42.0 2026-10-09 10:51:44 +02:00
Marc-André Bombeck 999169679a fix(environment): harden the air-quality history backfill
- Place a day at the home only from homeSince on, through the same
  resolver the nightly fetch uses; a home without homeSince places no day.
- End a run at the first request the feed refuses (429, 5xx, network)
  with status error, no follow-up and no pg-boss retry.
- Fetch the weather of days without a row before their air quality, and
  keep the weather rows when the air-quality request fails, so no paid
  request is asked for twice.
- Create no row for a day the archive answer leaves out.
- Re-read the module, the account switch and the operator switch before
  every range.
- Check the history's own ceilings in the budget's locked transaction
  together with the charge, instead of reading usage first.
- Read the days with entries once per chain and hand them to the
  follow-up in its payload.
2026-10-09 10:49:31 +02:00
Marc-André Bombeck 470ddeedc4 fix(version): move the finer geo state to the admin status
geoLookup and geoProviderChosen on the public /api/version added one
more way to tell instances apart. They now live on /api/admin/status
(requireAdmin, cookie only) as geo { lookup, providerChosen,
providerHost }, which both admin status views read. offlineGeoEnabled
and geoProviderHost stay public as before.
2026-10-09 10:44:18 +02:00
Marc-André Bombeck 0ea8d929d2 fix(correlations): date the earlier-engine note from the instance's first new run
The note for records whose findings predate the seasonally adjusted
engine compared against a fixed calendar date, so findings an instance
kept writing under the earlier engine until its deploy did not count.
The first discovery sync of the new engine now stamps
AppSettings.correlationSeasonalEngineSince before it writes; without a
stamp every stored discovery pattern counts as an earlier finding.

A restore from a portable export reset each pattern's createdAt to the
restore day and dropped the note for good. createdAt now rides every
export, and a file without it falls back to lastComputedAt.
2026-10-09 10:44:17 +02:00
Marc-André Bombeck 587ea35d34 fix(coach): bind the delete journal to the account and record it was sent for
A tab outlives a logout, another login and a switch into a managed
profile. A delete re-sent in another scope met a 404 that read as
"already gone" and dropped the entry, or tripped the record fence and
toasted a failure for a row the person could not see. Entries now carry
their owner, only the current owner's are re-sent, and a session end
clears the journal with the rest of the session state.
2026-10-09 10:29:36 +02:00
Marc-André Bombeck c40b21de3d Merge the Coach header, timeline colours, selection bar and range into release/v1.42.0 2026-10-09 10:18:23 +02:00
Marc-André Bombeck b5e15fc105 test(timeline): wait for the values menu before reading its dots 2026-10-09 10:08:54 +02:00
Marc-André Bombeck 5f5ddd37a6 fix(timeline): keep "No entries" for a bucket with nothing in it
A bucket that holds a mean but no lane entry is not empty; the means
say what it holds. The bar opened on such a bucket by default and called
it empty beside its own values.
2026-10-09 10:05:06 +02:00
Marc-André Bombeck 57c44006ee Merge the document layout default and the air quality history into release/v1.42.0 2026-10-09 10:02:56 +02:00
Marc-André Bombeck 36dbfb8af7 fix(environment): fill a day before the home only inside a location period
The past-days backfill placed every day without a row at the current home,
also before the home was set. Weather and air of a place the person was not
at would flow into correlations, the Coach and the day view as fact. A day
before homeSince is now filled only when a location period covers it; with
no homeSince stored the home still covers every day. Days without a place
are not counted in the progress.
2026-10-09 10:02:38 +02:00
Marc-André Bombeck 61155d1856 feat(timeline): add a chosen range as a fourth zoom
Beside All, 12 months and 3 months the timeline takes a range of the
person's own choosing, from and to, through the app's date field. Each
change is clamped softly: never past today, never before the record's
first entry, never inverted, never longer than fifteen years. On a phone,
where the chronicle has no zoom control, a range button opens the same
fields with a way back to the whole record. A range with nothing in it
keeps its fields instead of the empty-record notice.

The zoom and the range live in the URL (zoom, from, to), so Back and a
bookmark return to them; a parameter that does not read falls back to
All. The API accepts zoom=range with both ends, at most fifteen years,
and picks the bucket by its length: quarters from two years, months from
120 days, weeks from 42 days and single days below, a new day bucket
whose dashed bridge spans at most three missing days. A range borrows
the grid and the keyboard steps of the fixed zoom nearest its length.
The selection bar heads a day bucket with that day.
2026-10-09 10:02:37 +02:00
Marc-André Bombeck f8557f57a0 feat(timeline): colour each value line and name the selection's bucket
Each value line takes the colour its type carries in the measurement
list (a --chart-N token); where two chosen lines would share one, or a
type has none, the later line in the menu's order takes the first free
data token, and a sixth line with none left falls back to the
foreground. The swatch beside the name, the line, the dashed bridge,
filled and hollow points and a ring on the selected bucket all inherit
one colour from their group. The menu, the selection bar and the phone
chronicle mark each value with the same dot. The colour map moves to an
icon-free module so the timeline does not pull the list's icons.

The selection bar is a cross-section of one bucket and now says which:
its head names the quarter, month or week in full, its entries are the
ones that touch that bucket, and each mean keeps its readings count.
Values go through the day view's formatter, so sleep reads as a
duration and the unit preference applies; the server sends canonical
units, which the old formatter took for display units. Before anyone
picks a day the bar opens on the newest bucket it has something for in
the requested window (documents, which it never lists, no longer count),
and a short line says how to pick another.
2026-10-09 09:48:53 +02:00
Marc-André Bombeck f788e2892d fix(coach): put New chat in the conversations panel header
The round button floated over the list's bottom right and covered the
last conversation, which the list offset with a pb-20 reserve. It is now
the first control of the panel header, left of the title, in the docked
panel and the phone sheet alike: 28 px beside a fine pointer, the 44 px
floor on touch, with its accessible name and a tooltip. The reserve goes
with it, and the header packs its touch-sized buttons tighter so the
title keeps its word at 390 px.
2026-10-09 09:48:46 +02:00
Marc-André Bombeck d81a6ddf25 feat(environment): fill air quality for every past day with entries
A background job fetches air quality, pollen and UV for every earlier
local day with a reading, mood entry, dose, symptom or workout: after the
update (boot and nightly discovery), when the home is set and when air
quality is switched on. A day with a weather row gets its air quality; a
day without one gets both, at the covering location period or else at the
home, also before the home was set. Days the source does not reach (before
2013 in Europe, before August 2022 elsewhere) stay empty and are not
counted.

Pending days are grouped by coarse location and cut into ranges of up to
90 days, joining days at most 14 apart, newest first. A run holds itself to
300 of the account's 400 daily calls and half of each instance window,
stores its progress on the user (migration 0391) and sends a follow-up while
it makes progress; a budget stop waits for the next discovery. The
Location & weather card shows the progress.

The air-quality client now asks pollen and dust from 2021 and skips the
request outside Europe before CAMS global starts.
2026-10-09 09:43:01 +02:00
Marc-André Bombeck 402c503781 Merge the seasonal adjustment note into release/v1.42.0 2026-10-09 09:30:11 +02:00
Marc-André Bombeck 898729d1a8 feat(insights): explain the shorter correlation list once
Since correlations are tested on seasonally adjusted residuals with an
effective sample size, an account with history sees far fewer
relationships, which reads as a defect without a word about it.

The correlations response now carries findingsBeforeSeasonalAdjustment:
true when the record kept a discovery pattern first stored before the
change, one indexed lookup on the pattern store. An account created since
can never qualify. The mood "What stands out" card shows a short note
for those records, closable, remembered per viewer like the AI setup
hint. Copy in all seven locales; OpenAPI regenerated.
2026-10-09 09:29:38 +02:00
Marc-André Bombeck 4c58274bed Merge the Coach conversation delete fix into release/v1.42.0 2026-10-09 09:14:42 +02:00
Marc-André Bombeck 359f11fe4d fix(coach): make a deleted conversation stay deleted
Deleting a conversation hides the row and sends the DELETE only after
the six-second undo window. The pending delete lived in a timer alone,
so a reload, a closed tab or an installed app sent to the background
inside the window dropped it without a request, and the conversation
came back. When the window did run out, the hide filter was released
while the paginated history still held the row, so it showed again
until the refetch landed.

Every surface that deletes a conversation (the Coach panel, the
conversations page, the settings memory card) shares one hook, and the
fix lives there:

- a scheduled delete commits early when the page is hidden, on
  pagehide and on unmount, and the request is sent with keepalive
- a committed row leaves every list cache, head list and every history
  search variant, before anything else changes, and stays hidden
- deletes not yet confirmed are kept in sessionStorage; the next page
  hides them and sends them again, so its list read cannot bring the
  row back before the old page's request lands; a 404 counts as gone
- a failed delete restores the lists and says so instead of the row
  quietly returning
- Undo after the commit reports false, so the rail does not reopen a
  thread that is gone
2026-10-09 09:14:10 +02:00
Marc-André Bombeck 8425e61a0d fix(coach): delete a conversation in one owner-scoped statement
A find-then-delete pair let two deletes of the same conversation both
find the row, and the slower one failed with a 500. A client that
re-sends a delete it could not confirm now gets 404 for the second
request, which it reads as already gone.

The integration test covers the thread, its messages and its document
links going with it, the document itself staying, the list no longer
carrying it, a foreign account getting 404, and the concurrent pair.
2026-10-09 09:14:09 +02:00
Marc-André Bombeck 5cf1a6c552 fix(timeline): drop the explanatory line under the life event sheet title 2026-10-09 09:10:34 +02:00
Marc-André Bombeck f6f0969da9 Merge the timeline buckets, gaps and series limit into release/v1.42.0 2026-10-09 09:04:11 +02:00
Marc-André Bombeck fcc6f40410 feat(timeline): keep gaps in value lines, mark thin means, allow six lines
Every bucket with a reading gets its point; one with fewer than three
readings is drawn hollow. A short gap (one quarter, two months, four
weeks) is bridged with a dashed stroke between the two real points; a
longer one leaves the line open. The selection bar and the value table
for screen readers name each bucket's readings and say "no value" where
there is none. The phone chronicle names each bucket once, at its newest
month. The value menu takes up to six lines. The legend's causality line
is gone with its keys in all seven bundles.
2026-10-09 09:03:42 +02:00
Marc-André Bombeck af982e3812 feat(timeline): average value series per quarter, month or week with reading counts
The server picks the bucket per zoom and sends it as `bucket`: quarters
for an `all` window over two years, months for `year` and a shorter
`all`, weeks for `quarter`. Each point carries the readings behind its
mean as `count`; a bucket without a reading stays absent. Means remain
the mean of day values, so pulse-like types keep the hourly-mean day.
`values` accepts up to six series.
2026-10-09 09:03:42 +02:00
Marc-André Bombeck fb7f1eea61 Merge the day running-items and docked-panel changes into release/v1.42.0 2026-10-09 08:54:46 +02:00
Marc-André Bombeck c3dff2c388 feat(day): collapse the docked day to an edge instead of closing it
From 1280 px the day sits beside the page, and its control at the top
left took it away entirely: after a page change there was no way back to
the day short of finding it again. The control now hides the day the way
a sidebar collapses. A narrow edge stays on the right, holding the date,
with a button that opens the last day again on this page or any other.
Focus lands on that button when the day collapses and on the day's
heading when it comes back.

The last open day is remembered per browser, bound to the account and
the record it was read in, and removed at every session end. The Coach
button keeps clear of the edge as it does of the open day. Sheets below
1280 px and on phones close as before, and Back still closes them.
2026-10-09 08:54:11 +02:00
Marc-André Bombeck 7340cd3ab7 feat(documents): run the vault's months side by side by default
An account that never picked an arrangement now sees the months flowing
across the width. The layout PUT stores only the fields the person chose,
so an untouched field keeps following the default while a saved choice
stays.
2026-10-09 08:50:20 +02:00
Marc-André Bombeck 0a6811c03f fix(admin): read a geolocation provider the operator named as a decision, not a warning 2026-10-09 08:38:58 +02:00
Marc-André Bombeck 09f94d3eac fix(day): run only records with a start of their own through a day
The "Running that day" list carried the profile facts (smoking status,
alcohol pattern, shift schedule) as running items, titled by their raw
codes and counted "day n" from the date the fact was filed. A person who
quit smoking years ago and entered it last week read "day 13". Those facts
describe the person, hold no start the person gave, and are not part of a
day; the lifestyle section and its running kind are gone from the day, its
index and the model-facing get_day.

The same false count reached a medication filed without a start date: its
creation date stood in as the start. `since` is now null there, with no
day count, and the entry's date only bounds which days show it. A life
event's category is worded in the reader's language instead of its code.
2026-10-09 08:38:01 +02:00
Marc-André Bombeck 88836452eb fix(day): show several readings of a type as their mean in the day's tiles 2026-10-09 08:29:12 +02:00
Marc-André Bombeck 6ff24cf2cf fix(coach): scroll the Coach with the same scrollbar as every other surface 2026-10-09 08:22:41 +02:00
Marc-André Bombeck 950395da42 Merge the fold horizon fix into release/v1.42.0 2026-10-09 01:07:54 +02:00
Marc-André Bombeck 40ac8fccb1 docs: state the horizon of the v1.42 fold repair
The repair covers the last 75 days of daily means and the 75 days before
the raw window of hourly means; older days stay as they are. The ops page
names the two new run counters.
2026-10-09 01:03:02 +02:00
Marc-André Bombeck cbc3b3118d fix(measurements): recompute a stored fold mean only where all its samples remain
The fold repair and the regular folds rewrote an existing daily or hourly
stats: mean from the samples still in the table. Fold tombstones are purged
75 days after they were written, so for an older window those samples can
be a fragment: a re-upload after an anchor reset, the rows of one later
fold run. A mean over them replaced a stored mean that had been taken from
the whole day, and on old days it moved a lot.

A stored mean is now recomputed only for a day that starts at or after the
tombstone horizon (foldConstituentHorizon): every fold tombstone of such a
day was written after dayStart + the fold threshold and is still inside the
retention, so the live samples plus the fold leftovers are the whole day.
That is the last 75 days for daily means and, since dense days fold 90 days
late, the 75 days before the raw window for hourly means. A window also
needs at least one fold leftover; without one the rows in hand are not
known to be all of it.

Beyond the horizon:
- The repair leaves the day as it is and counts it per type as
  windowsSkippedBeyondHorizon.
- The daily-mean consolidation and the dense hourly fold leave a day or
  hour that already has a live stats: row exactly as it is, the stored
  mean and its live samples alike (daysLeftAsStored, hoursLeftAsStored).
  Up to v1.41 the fold overwrote such a row with the mean of the live rows
  alone, and the first v1.42 cut with the mean of those plus whatever
  tombstones were left; both are a mean of part of a day. Dropping the
  live rows instead would lose samples that may not be in the stored
  mean, so they stay as synced. They are few, the folded_window guard
  refuses new ones, and the nightly pass walks over them without writing.
  A day or hour without a stored mean still folds as before.

Inside the horizon, a pass that recomputes a stored mean soft-deletes the
live samples it took in as fold leftovers instead of deleting them, so a
second pass computes the same mean from the same samples. Before, a fold
that deleted them left the repair a fragment, and the repair then rewrote
the mean from the tombstones alone.

The repair, the mean consolidation and the dense fold now take a per-account
advisory lock after the restore lock in every day transaction, and the
repair reads the day's samples inside it, so the two never compute the
same window from two views of its samples or write the same row at once.
The repair also stops at the fold boundary: a day the fold has not
finished is the fold's to complete.
2026-10-09 01:03:02 +02:00
Marc-André Bombeck bb5e439553 Merge the end-to-end and code-scanning fixes into release/v1.42.0 2026-10-09 00:42:36 +02:00
Marc-André Bombeck 312e5de2b2 fix(security): keep the theme boot script a literal and drop regex tag stripping
The boot script interpolated its two colours; it is one string literal now,
checked against THEME_COLOR by the script's own tests. The retired-route
guard escapes every regex metacharacter including the backslash, and the
two markup tests read text by walking the string instead of stripping tags
with a regex.
2026-10-09 00:41:56 +02:00
Marc-André Bombeck 8b5199ee00 test(e2e): tag the just-in sleep batches as a foreground sync
A batch without a trigger only marks the dashboard snapshot stale since
v1.42, so a snapshot an earlier spec left for the account painted the day
final before this journey's own night counted. Foreground evicts.
2026-10-09 00:41:56 +02:00
Marc-André Bombeck 3ad0607cec fix(settings): stack the coach nudge frequency row on a phone
At 16 px touch type the longest frequency option and its label no longer
fit one row inside a 390 px card, and the select pushed the page sideways.
2026-10-09 00:41:56 +02:00
Marc-André Bombeck 4a02b78f5a test(e2e): take the mood empty state's add control in the managed-profile journey
Since an empty mood list carries the one add control and the header drops
its own, the journey clicked a header button that unmounted as the list
resolved. Start the profile without entries, assert the empty-state add,
then the header add once an entry exists. Both carry a data-slot.
2026-10-09 00:41:54 +02:00
Marc-André Bombeck 10f17e71e3 docs(changelog): note the toolchain advisories closed in v1.42.0 2026-10-08 23:19:32 +02:00
Marc-André Bombeck 7903c3b74e fix(deps): raise source-map-js, postcss-selector-parser and smol-toml past their advisories 2026-10-08 23:05:29 +02:00
Marc-André Bombeck c52ad73ff7 chore(release): v1.42.0 2026-10-08 23:04:20 +02:00
Marc-André Bombeck 4641943481 Merge the interface review fixes into release/v1.42.0 2026-10-08 23:04:04 +02:00
Marc-André Bombeck 6208a5357a docs(api): describe the folded_window duplicate and the fold-free sync feed 2026-10-08 22:58:42 +02:00
Marc-André Bombeck f0fba23c34 refactor(insights): leave the arrange editor's sorted rows to the compiler
The draft now comes out of a render-time reconcile, so a manual memo
over it could not be preserved and the compiler skipped the component.
2026-10-08 22:57:47 +02:00
Marc-André Bombeck 055cbcff0e fix(insights): count device strain by day, not by cycle
The band writes one strain row per physiological cycle, stamped at its
start, and a bedtime either side of midnight puts two starts on one
calendar day. The strain page counted those rows as days and drew an
extra sparkline point the recovery chart does not have. Collapse the
rows to one per local day at the day's mean, the chart's own value.
2026-10-08 22:57:47 +02:00
Marc-André Bombeck facfcad107 fix(day): keep the pinned heatmap tooltip off the right edge
The tooltip shrinks to the room right of its clamped left edge and
wraps, so it never ran past the screen, but near the right border it
filled that room to the last pixel. Cap its width at the room less the
8 px gutter the clamp was meant to leave.
2026-10-08 22:57:47 +02:00
Marc-André Bombeck b4e3e22d43 fix(ssr): clear the budget timer once a raced read answers
The mood and dashboard prefetches and the sign-in location lookup raced
their work against a bare setTimeout that was never cleared, leaving
one live timer behind per render or sign-in after the work had already
answered. A shared helper races the work and clears the timer.
2026-10-08 22:57:47 +02:00
Marc-André Bombeck dd76b434c4 fix(documents): cancel a layout read in flight before flipping the switch
A first read of the vault layout that was still running when the switch
was flipped landed afterwards and painted the old layout until the save
answered, so the switch jumped back for a moment.
2026-10-08 22:57:47 +02:00
Marc-André Bombeck 7525d2ed00 fix(settings): end the allergy and family-history cards in one action row
The allergy card had two action rows, the list's add button and the
free-text note's save, and the family-history card on a shared record
put its sentence about relatives after the list's actions. The add
button now rides the note's row before save, and the sentence moves
above the list.
2026-10-08 22:57:47 +02:00
Marc-André Bombeck 04084dd6e2 fix(ui): link the adherence tile straight to the medication wizard and drop dead rail rules
The empty adherence tile linked to the retired /medications/new route,
which only redirects. The sidebar's short-touch rules keyed on a touch
screen at most 520 px tall, where the phone shell hides the rail, so
they could never apply.
2026-10-08 22:57:47 +02:00
Marc-André Bombeck c1144b3c4c fix(ui): let a truncating label actually truncate
A span marked both shrink-0 and truncate never gives up width, so a
long source name on a collapsed priority group squeezed the metric
label at phone width, and a long month marker in the document flow
could overrun its cell. Both shrink now and cut with an ellipsis.
2026-10-08 22:57:47 +02:00
Marc-André Bombeck 8f5b5d6e54 fix(a11y): keep disclosure panels in the tree so aria-controls resolves
The encryption column table and the source-priority groups, overrides
and device ladder rendered their panel only while open, so a collapsed
toggle's aria-controls pointed at nothing. The panels now stay mounted,
hidden and empty, while closed.
2026-10-08 22:57:47 +02:00
Marc-André Bombeck ef1976832b refactor(css): move the shell and sheet rules into the components layer
Unlayered, the safe-area, keyboard and sheet-clearance rules outranked
every utility on the same element, so a later bg-* on the shell or a
bottom-* on a sheet would have been ignored without a trace. They now
sit under the utilities. The bottom sheet drops its bottom-0, which the
layered keyboard rule replaces with the same zero until the keyboard
opens; the keyboard rules that must win keep their !important. The
toast lift stays unlayered to outrank the toaster's own rules.
2026-10-08 22:57:47 +02:00
Marc-André Bombeck 938b5151de fix(ui): drop the home-indicator padding of a bottom sheet on the keyboard
A bottom sheet standing on the on-screen keyboard kept its safe-area
inset as bottom padding, leaving about 34 px of empty space between
its last row and the keys on an iPhone.
2026-10-08 22:57:47 +02:00
Marc-André Bombeck 6b41c11f30 fix(pwa): set the status-bar colour with the theme before first paint
The theme-color tags were rewritten to the app's theme only after
hydration, so with a light system and the app in dark mode the status
bar stayed light on a cold start. The existing boot script now sets
them along with the theme class, and once more after parsing in case
the tags stream in after it.
2026-10-08 22:57:47 +02:00
Marc-André Bombeck ad620bcf80 fix(pwa): keep the new worker's precache when loading a new version
The reload cleared every HealthLog cache after asking the worker to
update, but the update resolves as soon as the new worker starts
installing. Its fresh precache of logos and favicon was deleted with
the old ones, so the installed app lacked them offline. Leave the
target release's caches in place.
2026-10-08 22:57:47 +02:00
Marc-André Bombeck 58592e4822 fix(day): keep edge days and single readings in the dots under a time axis
A time-axis chart drawn from real instants tested each day's noon
anchor against the readings themselves, so the first day dropped out
when its reading came after noon UTC, the last when it came before, a
single reading never got a dot, and the span was cut in UTC while the
days were local. The row now spans the points' own calendar days, as
the chart hands them to its day links.
2026-10-08 22:57:47 +02:00
Marc-André Bombeck f38d8a5803 fix(day): word the cycle and travel titles by record kind, not by text
The day view translated any title reading "cycle" or "travel", so a
medication or a document with that name showed up as the localised
cycle or travel label.
2026-10-08 22:57:47 +02:00
Marc-André Bombeck 798fae747f fix(measurements): drop the bare separator before a unitless row's date
A score or a rating has no unit word, but the phone row still appended
the separator, so its meta line opened with a stray dot.
2026-10-08 22:57:47 +02:00
Marc-André Bombeck dcbd564962 fix(sources): stop listing a source whose readings were all deleted
The in-use probe matched tombstoned readings, so a source stayed marked
on the priority ladder after every reading from it was deleted. Both
the read and the save response of the source-priority route go through
this one probe.
2026-10-08 22:57:47 +02:00
Marc-André Bombeck 27dfc6a617 fix(ui): scroll to a deep link's target once a settling body reveals
A body held back until its reads settle was hidden when the page
arrived, so a link such as the pill-order anchor in the insights
settings had nothing to scroll to and the page stayed at the top.
2026-10-08 22:57:47 +02:00
Marc-André Bombeck ecf56c6a1f fix(a11y): give the adherence and efficacy charts a keyboard way to their days
Both charts open a day on a click or a tap but carried no data table
and no day link, so a keyboard or screen-reader user could not reach
the days at all. Render the chart's points as a data table with day
links, and have the coverage guard require that way on every
day-linked chart or name where it lives instead.
2026-10-08 22:57:47 +02:00
Marc-André Bombeck efbaf02335 fix(insights): settle the stat strip when its chart fails to load
The strip holds min, max, median and mean until the chart reports its
visible window. A chart chunk that failed was caught by the boundary
and never reported, so the cells stayed skeletons although the summary
was already there. The boundary now settles the strip with no window.
2026-10-08 22:57:47 +02:00
Marc-André Bombeck 78b0cc06e2 fix(documents): keep the bulk bar clear of the bottom bar on a phone held sideways
The bar dropped to the page edge at md, but the bottom bar follows the
shell and stays on a touch screen held sideways, which left the action
row, Delete included, half under it. Key the drop on shell-desktop and
include the home-indicator inset.
2026-10-08 22:57:47 +02:00
Marc-André Bombeck ca47077944 fix(pwa): lift toasts above the bottom bar wherever the phone shell shows it
The toaster's mobile offset applies below 600 px only, while the bottom
bar follows the shell up to md and on a touch screen held sideways. In
between, the update toast and its reload button sat over the tabs.
2026-10-08 22:57:47 +02:00
Marc-André Bombeck a5546507fa fix(insights): keep a pill save when the overview arrangement saves after it
The arrange card sent the tile list it copied on mount, with a token
read fresh from the cache, so a pill-order save made beside it was
overwritten without a conflict. Send the current server tiles, and let
an untouched section draft follow the server copy so the pill
manager's ECG toggle no longer reads as a pending edit.
2026-10-08 22:57:47 +02:00
Marc-André Bombeck 32cb75c1aa fix(db): let the maintenance job run behind a pooler that refuses startup options 2026-10-08 22:57:30 +02:00
Marc-André Bombeck 0d368170f3 Merge the remaining review fixes into release/v1.42.0 2026-10-08 22:53:07 +02:00
Marc-André Bombeck 38a69ee950 test(sharing): count the MANAGE matrix without the life-event handlers
Life events are owner-only, so their three MANAGE handlers leave the
admitted inventory and the integration matrix's driver counts with it.
2026-10-08 22:37:57 +02:00
Marc-André Bombeck 8ac64c3e4f chore(release): empty the release-pending lists for v1.42
The lab unit conversion moves to the next release, so its note string
leaves all seven bundles and the i18n pending list. The three columns
that hold the conversion provenance stay (migration 0385, nothing writes
them yet); the backup now carries them both ways, so the round-trip
pending list is empty too and the conversion needs no backup change when
it lands.
2026-10-08 22:37:57 +02:00
Marc-André Bombeck 055c48a3fe fix(handover): show the link holder display names only
The claim preview is anonymous: whoever holds the link has proved
nothing yet. It listed each guardian's grant id and fell back to their
login name. It now carries the display name alone, null when there is
none, and the page says "a guardian" for that.

The notifications to the guardians fell back to the new owner's login
name when the record had no display name. They now use sentences about
the person the guardian looked after instead.
2026-10-08 22:37:57 +02:00
Marc-André Bombeck f71becef29 fix(vaccinations): answer 409 to a concurrent duplicate vaccine name
Two adds of the same custom vaccine name both passed the name lookup,
and the loser failed on the unique index with a 500. A unique violation
on the add or the rename now answers the same 409 the lookup gives.
2026-10-08 22:37:57 +02:00
Marc-André Bombeck 7c6510eba9 fix(db): let a PgBouncer host turn the session startup options off
The statement timeout and, since v1.42, work_mem reach Postgres as the
libpq options startup parameter, which PgBouncer refuses unless it is in
ignore_startup_parameters. A host behind it could drop the string by
setting the timeout to 0; work_mem is now always sent, so that no longer
worked. DATABASE_SESSION_OPTIONS_DISABLED sends no session settings,
documented in the example env, the env manifest, the compose whitelist
and the scaling guide.
2026-10-08 22:37:57 +02:00
Marc-André Bombeck f950dffad4 fix(sharing): keep life events to their owner
Life events rode the profile sharing domain, so a profile share, a
legacy whole-record share and a MANAGE grant all read them, and MANAGE
could write them. No share level was ever worded for them, so in this
release they are the owner's only.

The life-event routes take a bare requireAuth, which refuses under an
acting-account switch. The day section and the timeline's life lane map
to no domain, so a delegate sees the section named not_shared and never
gets the lane, its readiness row or the day index marks. The profile
consent copy no longer names life events, the sharing guard and the
mutation inventory drop the four entries, and the OpenAPI descriptions
say owner-only.
2026-10-08 22:37:57 +02:00
Marc-André Bombeck e2ed62c74f docs(api): publish the environment backfill limits 2026-10-08 22:37:56 +02:00
Marc-André Bombeck 98183700ce fix(handover): keep a claimed handover when its Guardian deletes their account
created_by_id cascaded, so a Guardian who deleted their own account took
every handover they had issued with them, including a claimed one whose
decision the new owner had not made yet. The claim had already applied
the proposal, possibly MANAGE for a co-Guardian, and the owner lost the
one place that asks them to confirm it.

The column is nullable now and cleared on delete (migration 0390). An
unclaimed link without a creator is refused like any link whose creator
lost access. A decision that names a Guardian whose account is gone is
refused with 422 instead of failing on a grant for a missing account.
2026-10-08 22:37:56 +02:00
Marc-André Bombeck c7cee9c8a7 fix(coach): hold get_day to the Coach exclusions and keep free text out
get_day read through the day loader rather than the snapshot, so it
ignored the person's Coach exclusions: a person who kept mood or
medications from the Coach had both sent to the model as soon as it
asked for a day. Each exclusion token now maps to the day sections and
measurement types it covers; the sections are not loaded and the types
are dropped from the projection. A guard runs every token through the
executor.

A dated document reached the model under its title or file name, and a
visit under its reason or the practitioner's name. Both now carry their
kind. The remaining titles are fenced as the person's own text in the
Coach, as the MCP endpoint already did.
2026-10-08 22:37:56 +02:00
Marc-André Bombeck 509166afc4 fix(environment): keep one account from draining the Open-Meteo budget
The backfill route shared the analytics-read bucket and sent every
explicit range without a key, so one account could queue two-year
backfills until the instance-wide daily budget was gone for everyone.

The route now allows three backfills a rolling hour per account (429
environment.backfill_rate_limited) and one queued backfill per
twenty-minute slot, keyed on the account (409
environment.backfill_pending). Every fetch made for an account is also
charged to that account's own share of the daily budget, one twentieth
of it, so a refused request ends the run as budget-blocked and the
nightly run continues.
2026-10-08 22:37:56 +02:00
Marc-André Bombeck 78cde56542 fix(environment): fail closed on a travel period that does not open
A location period whose sealed location this host cannot open was
dropped from the resolver, so its days fell through to the home and the
next refresh overwrote the stored travel days with the weather at home.
Those days are now skipped and the run logs an error signal.

A restore now opens every sealed location once. One that does not open
is kept sealed, so the refresh keeps skipping its days, and is named in
the restore report under a new catalogue.
2026-10-08 22:37:56 +02:00
Marc-André Bombeck 342d73fb36 fix(google-health): read a full day of intraday samples once per local day
A watch worn without its phone reaches Google's cloud hours after the
samples were taken. The two-hour intraday overlap is measured from the
end of the previous clean cycle, so anything arriving later than that
was never fetched. The first cycle of each local day now reads intraday
samples with the full day of overlap; later cycles keep two hours.
2026-10-08 22:37:56 +02:00
Marc-André Bombeck a9bf72a7ce Merge the last UI round into release/v1.42.0 2026-10-08 21:12:05 +02:00
Marc-André Bombeck fc2753ef79 Let the delegated-write journey add through the empty list's control
An empty measurement list carries its add action in the empty state,
not the header, so the write-grant journey waited on a header button
that never appeared. It takes whichever add control the page shows, the
way the other add flows already do.
2026-10-08 21:10:06 +02:00
Marc-André Bombeck 01f3a8b875 Check the account page's language select where it now lives
The mobile consistency check pinned the language select to the date of
birth's grid row. It now asserts the select sits in the display card,
outside the profile form, while the date of birth stays in the form.
2026-10-08 21:10:06 +02:00
Marc-André Bombeck 313acdf304 Size the source-priority skeleton to every metric line
The loading state now reserves one collapsed line per metric, the upper
bound of the loaded list, so the action row does not move down when the
ladders arrive.
2026-10-08 21:04:09 +02:00
Marc-André Bombeck d99306275c Drop the settings section descriptions nothing reads
Sixteen settings.sections.<slug>.description strings per locale had no
call site: the shell paints each section's subtitle, and no literal or
key template builds a section description. The reverse-coverage guard
counted them as reached only because its suffix arm accepts any
`${prefix}.description` template next to a sibling key such as
.title.
2026-10-08 20:55:01 +02:00
Marc-André Bombeck 8ff35231f8 Write English navigation and insights chip labels in sentence case
Settings, admin and main navigation read "Data & privacy", "Export &
import", "Health score", "API & tokens", "MCP connector", "System
status", "Danger zone" and "Admin console", and the insights metric
chips drop their title case ("Blood pressure", "Body fat" and the rest),
matching the labels around them. The other locales already followed
their own conventions; the API page title is translated where it had
been left in English.
2026-10-08 20:53:18 +02:00
Marc-André Bombeck 0fd1168fd9 Pin the connector settings at one fewer success tint
The endpoint card's green Enabled badge went with the status badge
that repeated its switch.
2026-10-08 20:53:17 +02:00
Marc-André Bombeck a589e15f86 Name the arrangement settings hub "Layout"
The hub arranges and picks what the dashboard, insights and each module
show and holds no theme control, so "Appearance" promised something it
does not have. It reads "Layout" in every locale, and its subtitle says
what it does. In German "Darstellung" now names only the account page's
language-and-units card.
2026-10-08 20:47:55 +02:00
Marc-André Bombeck f648168266 Derive the privacy page's stored-data list from the module registry
"What's stored" was six hand-kept lines that never named documents,
vaccinations, the cycle or workouts. It now names every module that
keeps records, read off the registry so a new module joins without an
edit, beside fixed lines for measurements, the profile, integration
tokens and account security, in foreground text since it is the content
of the card.
2026-10-08 20:46:51 +02:00
Marc-André Bombeck c4272343e3 Collapse the admin per-column encryption table by default
A fresh install lists well over a hundred encrypted columns, nearly all
empty, and the open table pushed every other encryption card off the
page. The card now opens on a toggle that names how many columns hold
rows, lists only those when expanded, and offers the full list behind
a second toggle.
2026-10-08 20:45:31 +02:00
Marc-André Bombeck 834803ff21 Page the admin app logs fifty rows at a time
The buffer answers with up to ~500 events and the table painted all of
them. It now shows fifty per page with the login overview's pager,
resets to the first page when a filter changes, and clamps the page
when a refetch returns fewer events.
2026-10-08 20:42:35 +02:00
Marc-André Bombeck 2d79b80298 Give the account page's save-on-change preferences their own card
Language, units, glucose unit and the hour and date formats each save
the moment they change, yet sat inside the profile form as if its Save
covered them. They move to a Display card with no save button; the
profile card keeps its fields, the timezone (which saves with the form)
and its Save.
2026-10-08 20:41:50 +02:00
Marc-André Bombeck 51374274aa List only the sources an account uses on the source-priority page
The read and save of /api/auth/me/source-priority now carry inUse: the
ranked sources the account has connected or holds at least one
measurement from, one index probe per source. The settings page lists
only those, hides a metric nothing delivers, and opens one metric group
at a time, the first by default, with the winning source named on each
collapsed line. The ladders stay whole: moving a source swaps it with
its nearest listed neighbour, so a hidden source keeps its slot.
2026-10-08 20:40:39 +02:00
Marc-André Bombeck c816946140 Tidy the token, connector and share-link settings cards
The API tokens card names every kind of token it lists and reads in
sentence case. Each ingest card and the connector card fold the
shown-once note into the paragraph about what the token can do, and
their name fields suggest a fitting example instead of one phone
shortcut everywhere, short enough to stay whole on a phone. The
connector card drops the status badge that repeated its switch. Share
links get their own card next to the one that creates them.
2026-10-08 20:35:56 +02:00
Marc-André Bombeck c90d28e597 Name the admin account and invite cards apart from their pages
The users page card is now "Accounts" and the invites card "Invite
links", so neither repeats the page title above it, and the token
overview reads "API tokens" in sentence case. The role filter pills
stay neutral when selected, and the three icon tools on a phone row
wrap as one group instead of leaving the sign-out icon on its own line.
2026-10-08 20:35:56 +02:00
Marc-André Bombeck 161963c82b Put the passkey and security-key add actions in the card's action row
Both cards now end in the one right-aligned action row with the add
action as the card's primary, like the authenticator card's set-up
button, and both show the same plain empty state. The error line moves
above the row so nothing follows it.
2026-10-08 20:35:55 +02:00
Marc-André Bombeck f363405df0 Merge the settings and admin UI round into release/v1.42.0 2026-10-08 20:21:39 +02:00
Marc-André Bombeck ee16d3ca72 Update settings and admin copy
New card titles and one-sentence descriptions, sentence case for admin
titles, a current anamnesis subtitle, and the keys the pass retired.
2026-10-08 20:20:04 +02:00
Marc-André Bombeck f4d2ec8988 Tidy admin settings cards
Offer every shipped locale as the server default language, move the app
log refresh out of the header status slot, let filter selects fill their
grid cells, name the coverage table as a focusable region, hide the
legacy-key badge when only one key exists, keep the encryption cards
still while they load, and stop repeating the page title in card titles.
2026-10-08 20:20:03 +02:00
Marc-André Bombeck 37f922af97 Fix settings field rows, defaults and narrow layouts
Module rows drop the field-label colon and inset, the reminder number
fields align, target defaults print at their own precision, the
appointment offer wraps instead of squeezing to one word per line, the
export format legend matches its neighbour label, and the profile-photo
explainer reads as body text.
2026-10-08 20:20:03 +02:00
Marc-André Bombeck 2dda933d77 Align layout settings cards and the insights arrange editor
The insights arrange list becomes a Settings card with its own header and
a Save that waits for a change; the self-link back to the page it sits on
and the focus grab on load are gone. The medications view card takes the
header shape the labs and documents cards use, the order card shares
their title and loads with row placeholders, and the documents view card
uses their icon.
2026-10-08 20:20:03 +02:00
Marc-André Bombeck 991764b0c8 Give settings cards one action row at the foot
The TOTP enable, the source-priority reset, the biomarker and record add
actions, and the about-me, profile-facts, emergency and condition saves
move into SettingsCardActions, right-aligned and last in the card. The
emergency Save waits for a change like its neighbours, an empty list
leaves its add action to the empty state, and the source ladder keeps
its up/down buttons side by side on a phone.
2026-10-08 20:20:03 +02:00
Marc-André Bombeck 97e4dd4c05 Split the AI provider settings into one card per concern
The page was one card titled like the page, holding seven grey panels
with three primary Saves, each left-aligned mid-card. Provider, fallback
chain, response timeout, document reading, consents and the check row
are separate Settings cards now, each with one action row at its foot.
The chain rows keep the name on its own line on a phone, and the cards
below the provider card wait for its reads so they do not jump.
2026-10-08 20:20:02 +02:00
Marc-André Bombeck 8fc467f8c5 Merge the second UI round into release/v1.42.0 2026-10-08 20:05:44 +02:00
Marc-André Bombeck a1a0afe675 Let the vaccination journey wait for its list before adding
The header add disappears once an empty Impfpass has loaded; the
journey now waits for a record or the empty state and clicks whichever
add control is on screen.
2026-10-08 20:03:47 +02:00
Marc-André Bombeck 276cac1d5b Point the e2e add flows at whichever add control a surface shows
A list that is empty now carries its add action in the empty state
instead of the header, and on the phone shell the dashboard's add opens
the capture sheet. The overflow, mood timestamp, measurement flow,
onboarding tour and delegated-write journeys accept either control; the
measurement list's empty-state add gets a stable data-slot for that.
2026-10-08 19:51:16 +02:00
Marc-André Bombeck 5be7a9c242 Label the privacy page's sign-in link in German like its headings 2026-10-08 19:37:31 +02:00
Marc-André Bombeck a5e947ce97 Keep the unit and the time whole when the phone meta line wraps
'vor 10 Stunden' broke across two lines; the unit and the time now
wrap as units, the source badge after them.
2026-10-08 19:37:02 +02:00
Marc-André Bombeck 0bcf465e38 Close the off-switch token comment
A broken comment terminator swallowed the light theme's off-track
value, leaving the track unset.
2026-10-08 19:36:02 +02:00
Marc-André Bombeck 3b4d031e1e Lay the medication tabs out three-up on a phone
Wrapped, the last two tabs stretched across the second row.
2026-10-08 19:34:16 +02:00
Marc-André Bombeck 7d3ee68bf1 Describe a device-served strain score by the device line alone
The proxy's TRIMP method sat above the device note and described a
number the card was not showing.
2026-10-08 19:33:51 +02:00
Marc-André Bombeck dd49ec63a6 Sort the shell-variant classes the way the formatter orders them 2026-10-08 19:20:49 +02:00
Marc-André Bombeck e227f8e4f3 Give off switches and disabled primary buttons light-mode contrast
In the light theme an off switch's track was the input grey at ~1.4:1
on the card, and a disabled primary button was white on a half-opacity
violet at ~2:1. An off track now has its own token at ~3.5:1, and a
disabled primary button a pale violet fill with a dark violet label at
~5.5:1. The dark theme keeps its look through the same tokens.
2026-10-08 19:17:50 +02:00
Marc-André Bombeck 28458448c2 Bring the day surfaces into one language
The cycle and travel rows of a day read their server sentinels
('cycle', 'travel', a raw flow level) instead of words, so a day said
'cycle · Tag 8'. They are worded in the reader's language now. The
date in the phone measurement row and the workout row wrapped to
'vor 9 Stunden…' because the text column shrank to its value; it takes
the row's width and wraps instead of truncating. The intraday pulse
card drops its coloured identity edge, takes a title that says what it
shows, and says there is not enough for a course instead of drawing one
dot across 24 hours. The day body's top inset moves from the banned
pt-5 to pt-4.
2026-10-08 19:14:22 +02:00
Marc-André Bombeck e9a5614733 Colour insight series from the chart palette, not the status tokens
Metric pages coloured their series with success, info, warning,
destructive or a raw palette yellow, picked per page, so a steps line
read as 'good' and a resting-pulse line as an alarm. Series now take
the chart tokens by hue family (chart-2 green, chart-3 pink, chart-4
cyan, chart-5 orange), and the systolic/diastolic pair and the pulse
pair stay paired. Status colours remain where they carry meaning:
target bands, time in range, mood score scales. The mood tag scale
drops its raw palette reds and greens for the status tokens, which are
tuned for the light theme.
2026-10-08 19:08:51 +02:00
Marc-André Bombeck a1b46eb2f4 Write workout durations and pace the reader's way
Workout durations read '38m 00s' and '1h 02m' in every locale, the pace
'5:51 /km', and the detail header printed its raw source key. The
detail now shows a clock reading with the locale's unit ('38:00 Min.',
'1:02:05 Std.'), the list and the dashboard tile the shared localised
minutes label, the pace 'min/km', and the header the source's name.
2026-10-08 19:07:37 +02:00
Marc-André Bombeck 8302f5f62a Write mood insight figures with the reader's decimal separator
Averages, deltas and correlation figures on the mood insights went
through toFixed and read '3.4' in German. They use the locale formatter
now, and the whole-number crosstab cells pick up thousands grouping.
2026-10-08 19:04:30 +02:00
Marc-André Bombeck 7d738c0e49 Lead the meta line with the unit in the phone measurement list
On a phone a long unit beside the value ('12,5 Atemzüge/min') pushed
the row's controls. The value stands alone and the unit opens the muted
meta line beside the time and source, where meta belongs.
2026-10-08 19:03:32 +02:00
Marc-André Bombeck 5850036527 Number the Telegram steps once and spell Client-ID one way
The setup steps carried '1.' in their copy inside a numbered list, so
the card read '1. 1.'. The list numbers them now. Three integration
cards spelled 'Client ID' and 'Client Secret' where the others write
'Client-ID' and 'Client-Secret'.
2026-10-08 19:02:32 +02:00
Marc-André Bombeck b3fb9afa2c Lead the privacy and about pages with the reader's language
The privacy policy is German first in every body, but its section
headings, title and table of contents led in English and its date read
2026-08-11 on both lines. Headings now lead in German with the English
title beside them, the contents list follows, and each date line writes
the date as its language does. The about page's headings, date and
sign-in link follow the reader's locale. No legal wording changed.
2026-10-08 19:02:00 +02:00
Marc-André Bombeck 5bb6b6ad21 Name the share segment's not-found page in the URL-credential guard
It sits under the [token] segment, so the route-shape leg finds it; it
reads no token and resolves nothing.
2026-10-08 18:56:02 +02:00
Marc-André Bombeck f944c07cdf Explain a dead share link and a broken invite in the reader's terms
A practice opening an expired or revoked share link met the app's own
404, 'this page does not exist', with a button to a dashboard it has no
account for. The share segment now has its own not-found page: link
expired or invalid, ask the person who shared it for a new one, and no
link anywhere. It still answers 404 for every failure class alike.

A malformed invite link fell through to the plain registration form.
It now says the invitation is invalid and offers sign-in; the shape
check is public, so this reveals nothing about which tokens exist, and
a well-formed token still goes on to registration.
2026-10-08 18:55:45 +02:00
Marc-André Bombeck 005cb32ea8 Give every empty list one empty state and one add action
EmptyState now has a single action shape: centred at its own width on
the 44 px tap floor, never stretched edge to edge on some pages and
compact on others; the per-call-site size switch is gone, and the
dashed card takes the card radius. While a list is empty its empty
state carries the add action, so the header drops its own add button
there (measurements, mood, medications, labs, vaccinations, documents,
illness, check-ups, visits, procedures). The dashboard medication tile
of an account without medications offers 'Add a medication' instead of
'no data in the chosen range'.
2026-10-08 18:53:24 +02:00
Marc-André Bombeck 793c19e179 Keep delete affordances neutral until the confirmation
Row delete buttons, overflow-menu delete items and per-row revoke
buttons painted red in lists, so a list of tokens or shares read as a
wall of alarms. They now sit in the muted tone (foreground on hover)
or as an outline button; the red is kept for the confirmation dialog
that every one of them opens, and for the danger zones, which are a
destructive card-wide action by design.
2026-10-08 18:45:02 +02:00
Marc-André Bombeck 142dfa9360 Let the score section test settle its reveal
The section now waits for its reads before it shows; the mocked query
client reports nothing in flight.
2026-10-08 18:45:02 +02:00
Marc-André Bombeck aa3049a14c Emit the strain device block on every wellness read
The published WellnessScoreValue lists every field the engine sends, and
the contract test holds the two sets equal. The computed path now sends
device: null like anchor and components, and the device path has its
own contract case.
2026-10-08 18:45:01 +02:00
Marc-André Bombeck 651ec3aea8 Merge complete-window folding and the fold repair into release/v1.42.0 2026-10-08 18:42:46 +02:00
Marc-André Bombeck adc7baca14 Fold only complete local days and repair means folded from part of one
The daily-mean consolidation and the dense hourly fold cut at
`now - threshold` instead of a local day boundary. A day was folded in two
runs and the second run overwrote the stored mean with the mean of the
later part alone: every mean-type day, and the boundary hour of every
heart-rate, HRV and SpO2 day, plus the derived resting figure and the DAY
rollup of such a day. The Health Connect importer split its raw window at
the same unaligned instant.

- Fold up to the start of the account's local day the threshold ends in
  (`foldBoundary`), for every consolidation pass and the Health Connect
  importer's raw/hourly split.
- A window that already has a live `stats:` row is refolded from every
  sample of it, live rows plus the fold's own tombstones; with none left
  the stored mean stays.
- `folded_window` refuses a raw sample only when its local day lies before
  the aligned boundary and a live `stats:` row of its source covers it,
  also within reach of the sample after a timezone change.
- A deletion inside a day the fold had not finished is class B.
- Add a one-time, resumable fold repair per account that recomputes each
  hourly and daily mean from its remaining samples and writes only values
  that differ, recorded in `measurement_fold_repairs` (migration 0389).
- The compaction-tombstone purge waits for the repair per account, and the
  boot sends no purge run before one account is repaired.
- The restore keys live `stats:` ids by source, and writes compaction
  tombstones back from a file older than the account's repair, resetting
  the repair.
2026-10-08 18:42:01 +02:00
Marc-André Bombeck 2b91953cc9 Title the illness page as the navigation names it
The page called itself 'Krankheitstagebuch' while the navigation says
'Krankheit', and at 390 px the long word broke with a hyphen beside the
header actions. The title now reads the navigation label in every
locale.
2026-10-08 18:39:30 +02:00
Marc-André Bombeck d867c4bf83 Fold the medication API tab back into Erweitert
Seven tabs on a medication cut the strip off mid-label at 390 px. The
API tab's one group (per-medication tokens and drug coding) now sits in
Erweitert as a collapsed group before the danger zone, an old ?tab=api
link lands there, and on a phone the six remaining tabs wrap into two
rows instead of scrolling a label off the edge.
2026-10-08 18:39:12 +02:00
Marc-André Bombeck 6ca33882f1 Open the capture sheet from the dashboard's add button on phones
On the phone shell the header's 'Add' opened a dropdown while the plus
in the bottom nav, one thumb below, opened the capture sheet. The header
button now opens the same sheet there; the desktop rail keeps the
dropdown. The onboarding tour anchors on whichever of the two is laid
out.
2026-10-08 18:36:46 +02:00
Marc-André Bombeck b53782810f Keep the phone shell on a phone held sideways
A phone in landscape is 844 to 932 px wide, past md, so it got the
desktop rail and top bar: a sliver of content and no thumb-reachable
navigation. Two variants now decide the shell: shell-desktop from md up
except on a touch screen at most 520 px tall, shell-mobile otherwise.
The sidebar, top bar, bottom nav, the main column's bottom clearance,
the Coach button, the selection bar and the two full-height Coach pages
all key on them instead of a bare md.
2026-10-08 18:36:45 +02:00
Marc-André Bombeck a0c5ab6591 Hold data-shaped page bodies until their reads settle
Several pages stacked cards whose final height is the data's: the
illness day timeline, the measurement list, the insights arrange list,
the system status cards, the score notices that land with the slower
report. Each resolving on its own pushed the rest down the page, for a
CLS of 0.11 to 0.18 at desktop width. RevealWhenSettled mounts the body
at once but keeps it out of layout until no query is in flight (two
frames running, two seconds at most), with a placeholder in its place.

The chart loading shells were also ~100 px shorter than the card they
became: they now reserve the day-dot row, its caption and the collapsed
data-table disclosure, and the range-tab row at its 44 px height.
2026-10-08 18:34:11 +02:00
Marc-André Bombeck 1a52384a1d Open stat strips on the chart's window instead of all time
The strip painted the all-time summary until the chart reported its
visible window, then swapped to it, so every number jumped a beat after
load. The chart now reports only once its read has settled (a failed
read settles too), and until then the strip holds its cells at their
final height.
2026-10-08 18:20:57 +02:00
Marc-André Bombeck f7b6ea5dad Read Apple Health liveness off the data, not only the batch stamp
The status verdict looked only at the user's last-batch column, which
moves on a live batch alone. Rows that reached the account another way,
such as the export.zip import, left it null, and the card said 'waiting
for first data' beside a four-digit record count. The verdict now takes
the newest of that stamp, the per-type arrival ledger and the newest
stored sample.
2026-10-08 18:18:46 +02:00
Marc-André Bombeck ac482c508d Serve the device's day strain on the strain page when no proxy exists
The recovery page charts the band's own DAY_STRAIN while the strain
score page read only the computed proxy, so one account showed weeks of
strain on one page and 'not enough data' on the other. Without a proxy
row in the window the derived strain read now falls back to DAY_STRAIN:
the score is the reading as a share of the device scale, an additive
'device' block carries the native value, and the course below the card
charts that same series.
2026-10-08 18:17:20 +02:00
Marc-André Bombeck 605d9b1fb4 Stop rewarding a doubled or a skipped dose
The 'took it twice' and 'skipped a dose' badges rewarded over- and
under-dosing. Both definitions, their metrics and their copy in every
locale are gone. An unlock row already persisted for either id stays in
the database and in backups, but no longer renders: the evaluator only
walks the shipped catalogue.
2026-10-08 18:12:13 +02:00
Marc-André Bombeck d0e2238b65 Merge the app-wide UI audit into release/v1.42.0 2026-10-08 18:03:26 +02:00
Marc-André Bombeck 3a20f23f0f Give touch screens 44 px targets through the primitives
A pointer-coarse variant sits beside the pointer-fine one. Under it the
default, small and icon buttons, tab triggers, the sidebar rail rows and
the phone logo link reach the 44 px touch floor; a mouse keeps the
compact desktop density. The rail's height steps now apply to a mouse
only. A phone held sideways crosses the md breakpoint at about 390 px of
height, where the fixed footer left the list three rows: there the whole
rail scrolls as one column, without a native scrollbar.
2026-10-08 17:52:44 +02:00
Marc-André Bombeck 492c2a4131 Merge remote-tracking branch 'origin/release/v1.42.0' into v142/ui-audit 2026-10-08 17:44:00 +02:00
Marc-André Bombeck d286cf5bcd Merge PWA and mobile fixes into release/v1.42.0 2026-10-08 17:40:46 +02:00
Marc-André Bombeck 4673973a79 Title the mood sheet like the other capture sheets; fit the documents search hint
The mood page's sheet now reads 'Stimmung erfassen' like the same sheet
everywhere else, and the documents search placeholder is short enough to
show whole on a phone.
2026-10-08 17:34:21 +02:00
Marc-André Bombeck 105c293544 Size the catalog row skeleton to the row, drop the sparkline focus stop
The catalog's loading rows were 6 px shorter than the loaded ones, so the
groups below moved when a probe landed. The decorative score sparklines
sit in an aria-hidden wrapper, and Recharts' accessibility layer put a
focusable surface inside it.
2026-10-08 17:34:20 +02:00
Marc-André Bombeck 8a7eddaf26 Merge remote-tracking branch 'origin/release/v1.42.0' into v142/pwa 2026-10-08 17:24:38 +02:00
Marc-André Bombeck 2cd68779ec fix(pwa): follow the app's theme in the browser bar and toasts, lift toasts over the bottom bar
The theme-color tags followed the operating system's scheme, while the
app's theme is its own setting and defaults to dark: a phone in light
mode showed a light address bar or status bar over the dark app. The
client now rewrites both tags to the theme the page painted. The
toaster read Sonner's "system" for the same reason and now gets the
resolved theme as well.

Below 600 px Sonner stacks toasts 16 px above the bottom edge, which on
a phone is on top of the bottom bar: the "new version" hint covered the
tabs and the add button until dismissed. A mobile offset lifts toasts
above the bar and the home indicator.

pwa-mobile.spec.ts covers the hint's position and its Reload, a bottom
sheet standing on a stand-in keyboard, 16 px fields in portrait and held
sideways, and the sign-in card held sideways.
2026-10-08 17:24:30 +02:00
Marc-André Bombeck a4ad8ebe92 fix(mobile): fit the shell and sheets to safe areas and the on-screen keyboard
Safe areas. The installed app draws under the status bar and beside the
notch, and each 4rem header band padded the inset inside its own
border-box height: on a phone with a 59 px inset the top bar kept a 5 px
content box and the logo and account menu straddled its bottom border.
The authenticated shell now takes the top and side insets once on its
outer wrapper (`shell-safe-area`, the strip painted in the chrome
colour), so the top bar, the sidebar band and any docked panel start
below the status bar together and the seam between them holds. The two
Coach full-height pages subtract the inset at every width, the full-
height day sheet keeps its handle clear of the Dynamic Island, side
sheets keep clear of the status bar and the notch, the bottom bar and
the Coach button take the side insets of a phone held sideways, a
footer-less responsive sheet keeps its last row above the home
indicator, and the sign-in and onboarding shells pad themselves. The
sign-in shell grows with its content (`min-h-dvh`), so a phone held
sideways can scroll to the top of the card.

Keyboard. iOS Safari and Chrome on Android shrink only the visual
viewport when the keyboard opens, so a bottom sheet kept Save and the
Coach composer behind it. `KeyboardInsetBridge` publishes the covered
height (`--keyboard-inset`, `data-keyboard` on <html>); bottom sheets
stand on the keyboard and give up its height to their scrolling body,
and the bottom bar and the Coach button step aside while it is open.
Pinch-zoom and toolbar-sized changes do not count as a keyboard. The
sheet primitive names its side in `data-sheet-side`, which consumers do
not override the way they replace `data-slot`.

pwa-mobile-shell-guard.test.ts pins the structure no headless browser
can show (no browser in CI reports a safe-area inset).
2026-10-08 17:24:17 +02:00
Marc-André Bombeck 56a3008cf0 fix(mobile): keep form fields at 16 px on every touch screen
iOS and iPadOS Safari zoom the page into any focused field whose text
is below 16 px. The fields switched to 14 px at a width breakpoint
(`md:text-sm`, `sm:text-sm`), which a phone held sideways (844 px) and
every iPad pass, and the native select was 14 px at every width, so the
profile settings zoomed on a portrait phone too.

A `pointer-fine:` variant (a mouse or trackpad is the primary pointer)
now gates the compact size on Input, Textarea, NativeSelect, DateField,
TimeField and the Coach composer; touch screens keep 16 px at any width
and the desktop look is unchanged.
2026-10-08 17:23:57 +02:00
Marc-André Bombeck ac212cf6b7 fix(pwa): give the offline page a way back and reload once on any stale chunk
Without a connection a signed-in page opens the worker's offline page:
signed-in pages carry `private, no-store` from the proxy and are never
cached, so this is also what an offline launch of the installed app
shows. The page now offers a real 64 px retry control, reloads by itself
when the connection returns, follows the device's light or dark scheme
and keeps clear of the notch and home indicator. The worker's comment on
the page cache says plainly that only public pages land in it.

The single reload after a stale-shell chunk failure moves into
src/lib/pwa/chunk-reload.ts and now runs in the root error boundary too,
and it recognises a failed dynamic import as WebKit and Firefox word it.

pwa-offline.spec.ts runs in the service-worker project and proves the
offline strip, the offline page and the automatic recovery against the
real worker.
2026-10-08 17:23:47 +02:00
Marc-André Bombeck 503802e363 fix(pwa): update the worker on Reload instead of unregistering it
The "new version" Reload action unregistered every service worker before
reloading. Unregistering a registration ends its Web Push subscription
and nothing in the app re-creates one, so each update silently turned
push reminders off in the installed app: the server kept the dead
endpoint and the settings card showed push as off.

Reload now asks the registration to update (the new worker activates at
once and drops the previous release's caches), deletes only HealthLog's
own caches, and reloads. The cache-name contract moves into
sw-cache-policy.ts so the session-end cleanup and the poller share it.
2026-10-08 17:23:47 +02:00
Marc-André Bombeck fb3a13925d Merge remote-tracking branch 'origin/release/v1.42.0' into v142/ui-audit 2026-10-08 17:20:46 +02:00
Marc-André Bombeck ec272c9150 Show the callback path, not 'undefined/api/…', on an unset app URL
With NEXT_PUBLIC_APP_URL unset, some providers spliced the missing value
into their redirect template and the integration cards told the reader to
register 'undefined/api/polar/callback'. A callback that is not an
absolute http(s) URL now counts as not configured, so the card shows the
fixed path it shows for every other unconfigured provider.
2026-10-08 17:13:49 +02:00
Marc-André Bombeck 9f17960e7c Merge documents, wellness and mood polish into release/v1.42.0 2026-10-08 17:13:47 +02:00
Marc-André Bombeck 0f51a828c4 Keep chart ticks readable and insights strings in the reader's language
A word-length unit on the y-axis ticks ('Schritte', 'Atemzüge/min',
'mL/(kg·min)') was wider than the axis gutter and lost its leading
digits; such units stay off the ticks, where the stat strip above
already names them. The sleep stage toggle reads '7T / 14T / 30T' like
every other range control, the 'Mood entries' and '7-day' target sources
are named in the reader's language, and the BMI and medication empty
states ask the coach in that language too.
2026-10-08 17:10:41 +02:00
Marc-André Bombeck bde33b80c9 fix(insights): size the mood calendar skeleton to the height most calendars settle at
Without a known window the skeleton assumed twelve weeks, which on a
phone lands below the cell ceiling, so a short history's calendar grew
a few pixels when it replaced it. Eight weeks reaches the ceiling on
every width down to 360 px.
2026-10-08 17:06:04 +02:00
Marc-André Bombeck 3f00d2effd Merge remote-tracking branch 'origin/release/v1.42.0' into v142/ui-audit 2026-10-08 17:02:21 +02:00
Marc-André Bombeck 4d18c4e101 Stop insights sub-pages from shifting after first paint
Weight, blood pressure and pulse painted no stat strip until their read
landed, so the read strip and chart jumped down a card's height; the
strip now holds its place with the skeleton the HealthKit pages already
used, shared from the strip itself. The tab strip's scroll arrows render
from the start and only hide while the row fits, instead of mounting once
it measured as overflowing and shoving every pill sideways. The sleep
page shows the stage chart's skeleton while loading rather than a
'no stage data yet' card. Load shift on weight, blood pressure and pulse
drops from 0.3 to under 0.01.
2026-10-08 17:02:01 +02:00
Marc-André Bombeck 17b8b35397 Merge remote-tracking branch 'origin/release/v1.42.0' into v142/qol-1
# Conflicts:
#	src/app/insights/mood/page.tsx
#	src/components/charts/mood-heatmap.tsx
#	src/components/documents/document-bulk-bar.tsx
2026-10-08 16:49:03 +02:00
Marc-André Bombeck 41ed8eb6a3 feat(insights): mood calendar and chart arrive together; add via the header plus
On /insights/mood the line chart used to paint first and the calendar,
whose aggregate is the slower read, pushed it down when it landed
(CLS 0.16 at 390 px on a cold load). The page now holds both on
final-height skeletons until the chart's read has settled and the
calendar's has landed, then shows them in the same frame. The calendar's
height is written in container-query units, so its box is the right size
from the first paint, in the skeleton and the painted grid alike. A
server wrapper prefetches both reads in parallel (time-boxed, skipped
under a record switch, off when SSR prefetch is off), so on a warm cache
they are in the first HTML. The route and the prefetch share
`moodInsightsWire`, so the seeded cell is the route's shape.

The "Log a mood entry" and "Take a mental-wellbeing check-in" text links
under the heading are gone. Logging is the header plus, which opens the
mood form in place; the check-in keeps its own place in the navigation.
The plus is one shared component, `SubPageAddButton`, now also behind
the measurement pages' capture link and the workouts page, which drew
its own. An audit of every Insights page found no other text add-button
of this kind; the remaining buttons are empty-state calls to action.
A sweep keeps it that way.

`e2e/v142-qol.spec.ts` covers all three surfaces of this change set.
2026-10-08 16:45:49 +02:00
Marc-André Bombeck 1a5eab9f7c feat(insights): show every wellness score's course, drop the caveat line
Each score tile in "Your health scores" draws the score over its window
under the band word (the persisted window for recovery, stress and
strain; thirty days for the sleep score and readiness), on a fixed
0-100 scale so a few points do not read as a cliff. A score's detail page
carries its history directly under the score card: the stored nightly
scores get the full chart with the period switch, tooltip and day
access every measured page has; the two composites draw the course
their value carries.

The data was already there for three of the five. The sleep score now
returns the score of every scorable night in its window, judged by the
same blend and the same window baseline as the headline, so the last
point is the headline. Readiness reads its course from what the nightly
job persisted, the COMPUTED recovery row that is this exact blend,
without recomputing a single day. Both fields are additive.

The warning-coloured "descriptive proxy, not clinical" line above the
method is gone from all four score pages that carried it, with its keys
in all seven locales and the unused anatomy disclaimer key.
2026-10-08 16:45:24 +02:00
Marc-André Bombeck 4273af231e feat(documents): choose how the vault is laid out, and confirm every delete
The wrench beside Upload opens Settings, Appearance, Documents, the way
the illness journal offers its display options: preview cards or a
compact list, and months stacked as blocks or flowing across the full
width with the month name inline above the document that opens it. The
choice is stored per account in `users.documents_layout_json` behind
GET/PUT /api/documents/inbound/layout (preserve-when-absent, module
gated, the read delegable like the rest of the vault), carried in the
account-settings backup and cleared by the data wipe.

Deleting from the vault now always asks first. The bulk bar's Delete and
a focused card's Delete key both go through one confirmation on the page,
and the detail sheet's Delete opens its own; before, all three deleted on
the spot with only an undo toast behind them.

The selection bar is two tight lines: the count with an X at its right
edge, then one action row with Delete alone at the right in the
destructive style. Below 1024 px the verbs are icon-only at the 44 px tap
floor, so the row stays one line at 360 px. The bar carries the
selection-bar slot the Coach launcher already steps aside for.
2026-10-08 16:45:06 +02:00
Marc-André Bombeck ee8044b379 test(e2e): list the handover link among the signed-out routes 2026-10-08 16:43:30 +02:00
Marc-André Bombeck 06bd75b36e Merge consistent day access into release/v1.42.0 2026-10-08 16:38:10 +02:00
Marc-André Bombeck 6f8ea3faf2 chore: raise the aggregate bundle cap for the chart day doors
Measured 4386 KB gz on the release branch and 4446 with every chart drawn
in days opening its day; the difference is the aggregate counting the
shared hook, data table and day link once per chart chunk group. Every
eager route stays inside its allowance.
2026-10-08 16:35:16 +02:00
Marc-André Bombeck 3e1f630fa1 Fix copy, colour and frame details across the record pages
- Achievements: '1 Tag', not '1 Tage'; point totals grouped; whole
  descriptions instead of a clamp that hid the target at the end.
- Illness: the episode note label is translated, day notes read in the
  content colour.
- Coach history: conversation titles read in the content colour.
- Anamnesis and mental wellbeing: drop a second page gutter the shell
  already provides, and the nested main landmark.
- Privacy: repair words broken by a stray hyphen and space.
- Cycle: the ring caption reads 'Tag 9 deines Zyklus' as one phrase.
- Practitioners: the add button says 'Hinzufügen' like every other page;
  a shorter search placeholder fits a phone.
- Empty state cards inset their content from their border, so a phone's
  full-width action no longer runs into the frame.
2026-10-08 16:34:54 +02:00
Marc-André Bombeck 9fd43623dd Name the type on every mobile measurement row
On a phone the list showed an icon and a value only, so two readings in
the same unit (HRV and RMSSD, two weights) could not be told apart, and
only blood pressure carried a label. Each row now names its type above
the value. Score and rating tokens no longer print as units.
2026-10-08 16:34:53 +02:00
Marc-André Bombeck 75a0f00be9 Tidy the environment, invite and claim surfaces
Environment settings show the home date, travel ranges and latest air
quality day as formatted dates instead of ISO keys, the backfill card
ends in its action row with the status lines above it, and the
attribution list sits on the page's reading edge. The sharing invite card
keeps a one-sentence description and says once, under the field, that
the person needs an account here. An expired claim link offers the
sign-in page instead of a dead end.
2026-10-08 16:21:12 +02:00
Marc-André Bombeck 0d574f2f2d Hold the coach read strip's place while its read loads
The strip sits above the chart on every metric sub-page and painted
nothing until its read landed, so the chart and everything under it
jumped down a card's height after first paint. It now paints a card of
the same anatomy while loading.
2026-10-08 16:21:12 +02:00
Marc-André Bombeck 554f36aeab Show whole-number metrics as whole numbers
The dashboard tiles printed every value at one decimal: '121,0 mmHg',
'60,0 bpm', '9.240,0' steps. TrendCard takes the metric's precision, and
blood pressure, pulse, HRV, steps and mg/dL glucose pass zero. The
measurement list names count-shaped units the way the insights pages do
('13 Etagen', '12,5 Atemzüge/min') instead of the source's 'count'.
2026-10-08 16:21:12 +02:00
Marc-André Bombeck b80dc14142 Clear the accessibility findings on about, privacy, achievements, cycle and admin
Inline links on the prose pages carry an underline and their body copy
reads in the content colour, so the links no longer rely on colour alone
and the code chips meet contrast. Locked achievements drop the opacity
that pushed their text below AA. The cycle calendar nests its headers and
day cells in ARIA rows, the login overview's page-size select is named,
and the measurement type badges take a lighter wash in the light theme so
their label holds 4.5:1.
2026-10-08 16:21:00 +02:00
Marc-André Bombeck 03f374794e Settle the form sheets on one anatomy
Field labels no longer lose the space before '(optional)' or set the
colon apart from a unit, and a question label carries no colon after its
question mark. Cancel next to a primary is the outline button in every
form, the illness sheet's selects span the field width, and the reminder
sheet drops a description that repeated the page subtitle. On a phone a
sheet now opens with focus on itself rather than its first field, so a
date or name field no longer raises the keyboard over a sheet nobody has
read yet.
2026-10-08 16:20:42 +02:00
Marc-André Bombeck 0b881fe5b7 Name every capture sheet by what it logs, in one order
The measurement and mood sheets were titled with the bare 'Add', the
medication one with the noun alone. Every capture sheet now carries the
verb phrase the dashboard's add menu shows, and the add menu lists its
entries in the capture picker's order. Normalise 'z.B.' to 'z. B.' in the
German catalogue and drop the register page's subtitle, which repeated
its title.
2026-10-08 16:20:41 +02:00
Marc-André Bombeck 4e7221f59b fix: spell a day's sleep and scores as their charts do
The day route sends a night's length in min and a device score in score.
The day read the night as a bare 412 and the score in the wire's word;
it now reads a duration and the reader's word for points. The phone
sheet's header takes the short date, so a long weekday and month no
longer cut off the year at 360 px.
2026-10-08 16:20:36 +02:00
Marc-André Bombeck 7493ec48e4 Fit the sidebar rail to short windows and hide its native scrollbar
On a 13-inch laptop the rail's list overflowed and a classic scrollbar
with arrow buttons appeared inside it. The rows now step down by window
height (gaps close at 940 px, 36 px rows at 820 px, 32 px at 740 px), so
1366 x 768 fits every entry collapsed and expanded. Where the list still
scrolls, the native scrollbar is hidden, fades mark the edge that has
more, and a focused row scrolls clear of the fades.
2026-10-08 16:20:26 +02:00
Marc-André Bombeck 8bcb3e6301 fix: keep the two-step on chart points on touch
A point and a calendar cell are small targets; one tap that opened the
day opened days by accident. On a touch screen the first tap shows the
value again and the tooltip's calm 44 px row opens the day, on every
day-linked chart and calendar alike. A date in a list still opens in one
tap; a click on a fine pointer still opens directly. The tooltip keeps
not gliding into place.
2026-10-08 16:04:54 +02:00
Marc-André Bombeck fee6088e08 feat: open the day with one tap and from every place a day is shown
A tap on a point, a bar, a calendar cell or a date now opens the day at
once, as a click does; the tooltip's second step is gone and the tapped
value heads the day. The dashboard's charts (not its today area), the
adherence chart, the rhythm events, the ECG list and detail and the rest
mode notes open their days too.

The day's header carries the close control top left, the Coach panel's
own toggle and glyph, the date on one centred line and the arrows on the
right. While the day is docked, the Coach button and the documents bar
move clear of it.

Smooth the layer: the tooltip no longer glides into place on touch, the
neighbouring day is read before a step, the sheet animates in 300 ms and
not at all under reduced motion, and the opener stays put while the page
beside the docked day reflows.
2026-10-08 15:56:40 +02:00
Marc-André Bombeck c41ec02e92 feat: prepare the next visit from the server's notable window
The block under a planned visit read the day index and up to twelve days
to find what changed, so a dose change on a quiet day could fall out of
the picked days. It now reads GET /api/day/notable for the stretch from
the last visit to today: every change the server names, every notable
reading.

Add a guard that walks every chart, chart mount and calendar heatmap and
fails when one drawn in days lacks the day doors and is not on the
reasoned NOT_HERE list. Give the dose-strength curve, the efficacy chart,
the mood dimensions, the intraday pulse header and the environment note
the same doors.
2026-10-08 15:30:12 +02:00
Marc-André Bombeck 866e871102 Merge remote-tracking branch 'origin/release/v1.42.0' into v142/tk-consistency 2026-10-08 15:19:11 +02:00
Marc-André Bombeck 7fbde1bc83 feat: open the day from every chart drawn in days
Move the chart's doors to the day (click, the touch tooltip's button, the
dashed line through the open day, the row of day dots, the caption) into
one hook and give it to every chart with a date axis: recovery, sleep
duration and stages, the mood line and calendar, the intake calendar,
water and caffeine, lab markers, custom metrics, questionnaires and basal
temperature. Heatmaps share one adapter; every chart's keyboard way to a
day is its data table or the dated list beside it.
2026-10-08 15:19:09 +02:00
Marc-André Bombeck ce6d2fe72a Merge the day and timeline server into release/v1.42.0 2026-10-08 15:15:05 +02:00
Marc-André Bombeck b5596f872d ops: load pg_stat_statements in the bundled database 2026-10-08 15:01:02 +02:00
Marc-André Bombeck 43f2c639ae Merge remote-tracking branch 'origin/release/v1.42.0' into v142/t1-server
# Conflicts:
#	messages/de.json
#	messages/en.json
#	messages/es.json
#	messages/fr.json
#	messages/it.json
#	messages/ko.json
#	messages/pl.json
2026-10-08 14:42:48 +02:00
Marc-André Bombeck a81dfaab3d perf(day): leave pulse to the resting heart rate in the extremes
A pulse day value is a mean over thousands of background samples, and
comparing it over two years scanned every one of them: a recent day on a
dense account took about 300 ms, two thirds of it here. Resting heart rate
stays among the compared vitals.
2026-10-08 14:40:37 +02:00
Marc-André Bombeck 5bd70c29e6 Merge the timeline into release/v1.42.0 2026-10-08 14:38:17 +02:00
Marc-André Bombeck dbd9618d44 Merge remote-tracking branch 'origin/release/v1.42.0' into v142/t1-server
# Conflicts:
#	docs/api/openapi.yaml
#	src/lib/ai/coach/stream-events.ts
#	src/lib/ai/coach/types.ts
2026-10-08 14:33:12 +02:00
Marc-André Bombeck 0924285cd6 feat: serve the day, its index, the timeline and life events
Replace the day, timeline and life-event stubs with their implementations
(#613) and drop the not-implemented helper, its error code and its OpenAPI
response with the last stub.

loadDay reads one local day for every reader: the readings live inside the
record's own day window (never the UTC rollups), through the canonical-source
engine the mood sheet already uses (resolveDayReadings in linked-context.ts),
with the person's usual range over the 30 days before; the record sections
from their own tables, each in the storage form it speaks (an instant in the
local window, a date string with its own zone, a calendar date). A switched-off
module's section is not read; a section the grant does not cover is named
not_shared and not read.

The day, the day index and the new visit preparation (GET /api/day/notable)
are admitted on the readings and narrowed per section through
actingDomainVisibility, so a scoped grant sees exactly its sections. The
timeline and its readiness stay whole-record reads and narrow their lanes the
same way.

Notable days are deterministic keys: a three-month high or low with history
behind it, a first reading, and in the visit preparation a regular reading
that stopped. The timeline lays out spans and points from the owning tables,
standing items without a date, and series by zoom: months from the rollup
tier inside its five-year window and from one cached live query before it.
The readiness inventory rates each lane with one link per gap.

Life events are created, edited and soft-deleted as MANAGE verbs with audit
rows, encrypted at rest, and join the sharing guard and the handler matrix.
get_day reads a day for the Coach and the MCP endpoint without life events,
notes or document names; its step carries the date it read.
2026-10-08 14:31:13 +02:00
Marc-André Bombeck 2b89e627ab Merge release/v1.42.0 with the day view into v142/t3-timeline
The timeline now opens a day through the shell's day layer (openDay) and reads ?day= with its parser instead of a copy of both; the capture picker's life-event form starts on the picker's defaultDate like the other forms.
2026-10-08 14:29:25 +02:00
Marc-André Bombeck 32258bc5ab feat: add the timeline page, its readiness inventory and life-event entry
The opt-in timeline module gets its page (#613). At 768 px and up the
record's lanes (life, conditions, allergies, medications, vaccinations,
doctor and procedures, labs, documents) are drawn in one SVG on a shared
time axis, with a year, month or week grid, open periods ending in an
arrow, a dashed lead-in where a start is unknown, dose marks and pauses on
the medication they belong to, a Today line and a selection line. Up to
three neutral value lines run below as monthly means, chosen in a menu.
Recharts stays for every value chart; this surface is plain SVG like the
heatmaps.

All geometry is pure (timeline-geometry.ts): window, scale, row packing
and label placement through one occupancy map, so no label overlaps
another label or a bar, none runs past the card edge, and labels that do
not fit are left out rather than squeezed. The width comes from a
ResizeObserver, so a docked day panel re-runs the layout.

The bar under the chart names the selected month's entries as chips, the
month's means and "Open 3 Jan.", which puts ?day= in the URL for the day
layer. The chart is one tab stop: arrows move the selection by a day, a
month or a year with the zoom, Home and End jump to the ends, Enter opens
the day. A visually hidden table lists every entry for screen readers.
The legend says once that stacked means at the same time, not cause and
effect.

Below 768 px the timeline is a chronicle, newest first by month or year,
with an Ongoing block for what has no meaningful start, rails beside the
rows an episode or course spans, and empty stretches named as such.

Switching the module on in Settings opens the readiness inventory: each
lane carries, is thin or is empty, with one link per gap and the verdict
as a count. The same inventory sits as a dismissible card on the timeline
while a gap is open, and under Layers as "Data coverage".

Life events are entered in a sheet (title, kind, a date to the day, month
or year, an optional end, an optional note) from the timeline, the
capture picker and the dashboard's add menu; one surface line maps the
capture kind to the timeline module, and it is offered in one's own
record only. Deleting asks first.

Server routes are built in parallel; the e2e journey answers them with the
contract's shapes. Copy in all seven locales; the timeline and lifeEvents
release-pending prefixes are gone.
2026-10-08 14:27:02 +02:00
Marc-André Bombeck 4cfad0e116 Merge the day view into release/v1.42.0 2026-10-08 14:21:00 +02:00
Marc-André Bombeck bf83d414e2 feat: open any past day as a layer over the page
A date is now a door to its day (#613). `?day=YYYY-MM-DD` opens the day
over whichever page is open: docked beside it from 1280 px, the way the
Coach's conversations panel sits, a sheet from the right below that, and a
bottom sheet on a phone that opens at a little over half the height and
grows to full height from its handle. On /coach it is always a sheet, so
there is never more than one docked panel. Back and Escape close it, the
previous and next day replace the history entry instead of adding one,
Alt with an arrow steps a day, focus moves to the day's heading on open and
returns to whatever opened it on close, and a polite region names the day.
A malformed or future date is dropped from the URL.

The day shows the value it was opened from with its comparison, what ran
through the day, the readings against the person's own usual range on a
short number line (eight curated, the rest behind "All values"), and what
happened in order of the clock, each row linking to its record. Loading,
failure, an empty day and areas a grant does not cover each read as such.
The footer offers the timeline when that module is on and capture for the
day, with the capture forms starting on that date.

Doors into it: a click on a daily point of a metric chart (a tap shows the
value and the tooltip offers the day on touch screens), the dates in the
chart's data table, a quiet row of dots under the axis for days with
entries and a ring for notable ones, the lab chart and lab readings, the
measurement, mood and intake lists, symptom and illness day headings,
episode onset and recovery, document dates, workout dates, the workout
detail, the mood and adherence heatmaps, and the days a Coach answer's
get_day calls read, shown as chips. The next planned visit carries what
happened since the last one, each date opening its day, with a doctor
report for that window.

Not here, and frozen by a guard: the dashboard's today area, mini charts
in tiles, the cycle calendar, model-written text, future dates, the
clinician share link and the doctor-report PDF.
2026-10-08 14:20:01 +02:00
Marc-André Bombeck b8f6372963 Merge the day and timeline contract into release/v1.42.0
# Conflicts:
#	src/__tests__/sharing-surface-guard.test.ts
2026-10-08 13:13:49 +02:00
Marc-André Bombeck 9d4a50282e Merge tickets part 2 into release/v1.42.0
# Conflicts:
#	src/__tests__/i18n-reverse-coverage.test.ts
2026-10-08 13:07:02 +02:00
Marc-André Bombeck c79fc6cc7b feat: lay down the day view and timeline contract
Land every file the day view, the timeline and life events share before
any of them is built, so the server, the day view and the timeline can be
built side by side without editing the same file (#613).

Schema and migration 0387: LifeEvent with a short category list, calendar
start and end dates, a day/month/year precision, encrypted title and note,
a soft-delete tombstone and an index for the per-user live list. Every new
column is annotated for the column-reader sweep. Life events travel in both
backup purposes with their two ends in one file, are wiped with the
account, and are registered for key rotation.

src/lib/day/contract.ts carries the constants and wire types for
GET /api/day/{date}, /api/day/index, /api/timeline, /api/timeline/readiness
and the life-event routes, free of Zod so the navigation chrome can import
it; the schemas beside it feed the routes and the published contract. The
Coach and MCP tool name get_day is reserved there, and life events are
listed as the one section no model ever sees.

The opt-in timeline module, its navigation entry, the day-section and
timeline-lane surfaces, the day and timeline query keys (evicted by every
record data bundle), and stub routes that answer 501 behind their
admissions. The four cross-section reads declare the whole record for now;
the life-event list is a profile read, and life events join the profile
sharing domain and its consent copy in all seven locales. The day,
timeline and lifeEvents message namespaces carry their base copy as
release-pending keys.

The aggregate bundle cap moves to 4400: the branch already measured 4365
against 4335, and the new copy adds 10 KB.
2026-10-08 13:03:37 +02:00
Marc-André Bombeck b299904552 Let a record define its own vaccines
A dose can now name a vaccine the catalogue does not list. The person
defines it once (a name, the diseases it protects against from the
catalogue's antigen list, the series length and the booster interval) and
logs doses against it; such a dose counts into each listed antigen's
series, settles a booster reminder keyed to one of them and offers a
booster, the way a catalogue combination does.

One resolver (resolve-vaccine-entry.ts) now answers for every consumer
that asked the catalogue directly: the series, the satisfy matcher, the
booster mint, the dose DTO, the doctor report, document links, the
upload suggestion, MCP search and the client's info sentences and
booster offer. The catalogue wins when a dose carries both.

The custom routes resolve the record through requireRecordAuth (list at
READ, create at WRITE with the actor-keyed write bucket, edit and remove
at MANAGE with the C4 overwrite audit) and join the sharing guard, the
MANAGE matrix and the activity feed. Removing a definition tombstones it
and lets go of its doses, each keeping a name. Definitions travel in the
backup and restore before the doses that name them; the dose form picks
them, adds one without leaving the form, and the page lists them for
editing. With the last contract stub implemented, the 501 helper and the
not_implemented code leave the tree.
2026-10-08 13:00:37 +02:00
Marc-André Bombeck 6ab72aec3e Fold Health Connect heart rate like Apple Health
The nightly dense-intraday fold scanned Apple Health rows only, so the
per-minute heart rate a Health Connect import writes for the last 90 days
never aged into hourly means and every new export added another quarter
of raw samples.

The fold now walks each fold source in turn, Apple Health first and
unchanged, and folds Health Connect samples into hourly stats: rows under
their own source, deleting the raw rows outright. The folded_window rule
covers both sources for the hour window and looks a covering row up under
the sample's own source; the day window stays Apple Health only. The
Health Connect importer applies the same rule to raw samples, so an
export that still holds folded minutes does not put them back.
2026-10-08 12:04:36 +02:00
Marc-André Bombeck 861ba21086 Merge tickets part 1 into release/v1.42.0
# Conflicts:
#	src/__tests__/i18n-reverse-coverage.test.ts
2026-10-08 11:35:07 +02:00
Marc-André Bombeck 7eb5659ab9 Merge iOS server asks and client work into release/v1.42.0
# Conflicts:
#	docs/api/openapi.yaml
#	src/lib/openapi/routes/auth.ts
2026-10-08 11:27:42 +02:00
Marc-André Bombeck 12e422c25e Lighten the client shell and finish four client surfaces
- Load each settings section on its own: the section route imported all
  twenty-one statically. A preload starts the chunk beside the record
  gate's read, and the loading state paints the settings card shell.
  /settings/[section] goes from 611 to 403 KB gz.
- Take Zod out of the shell every route shares: the account-access parse
  run on each boot is plain TypeScript now, held to the published schema
  by an equivalence test over good and broken blocks. The canonical unit
  table moves to its own module, so the display transform no longer
  pulls Zod in either. Layout entry 199 to 136 KB gz; /insights 369 to
  304, /medications 428 to 365, /labs 400 to 337.
- Open a saved Coach conversation at its last answer: an instant jump
  instead of the smooth scroll that lost the end, then follow late
  layout (charts, tables) for a few seconds until the reader scrolls.
- Show the document archive as a month grid of preview tiles, two
  across on a phone and three or four on a desktop, with the month
  headings kept.
- Add the admin card for the measurement table maintenance: a status
  read on the maintenance route, one calm confirmation, and the run's
  state while it is queued, running, finished or stopped.
- Re-base the bundle budget: /insights at 304, /settings/[section]
  watched at 403, aggregate cap 4335 with the reason written down.
2026-10-08 11:25:11 +02:00
Marc-André Bombeck 920bd19617 Merge managed profile handover into release/v1.42.0
# Conflicts:
#	docs/api/openapi.yaml
#	src/__tests__/i18n-reverse-coverage.test.ts
2026-10-08 11:21:25 +02:00
Marc-André Bombeck 8617f809f0 feat(sharing): hand a managed profile over to the person it describes
A guardian with a fresh second factor mints a one-time handover link
(hlp_ token, HMAC hash stored only, 1/7/14 days, at most one open per
profile, withdrawable) with a proposed access level per guardian: end,
view only or manage, view only by default. The link is shown once with a
copy button and a QR code; nothing is mailed. The panel sits at the end
of each managed-profile row and hints when the person is under 16.

The person opens /claim/<token>, sees a minimal preview (name, expiry,
who keeps which access, no health data) and chooses a username, a
required email and a password under the registration policy. The claim
is one transaction under the managed-profile lock: the token is
consumed, the credentials are set, the marker is cleared, every pending
invitation the record offered is withdrawn, each guardian's access is
set to its proposal, the disclaimer, onboarding and AI consent are
reset, and every step is audited on the record and on each guardian's
own account. A failure anywhere leaves the profile managed and the link
unused. Each guardian is notified on their own channels.

The new owner then sees each guardian's access on the first screen after
signing in and decides finally; until then the proposal holds, and the
decision waits at the top of Shared access. A guardian whose access
moved since the claim is left alone. The same screen points the owner
at their own notification channels, since reminders no longer reach the
guardians.

Both anonymous routes refuse a single-sign-on-only instance, refuse a
live session before spending the per-address rate limit, check the link
before probing names, and answer one 404 for every unusable token. The
token travels in request bodies only; hlp_ joins the redaction and
secret-shape rules and /claim/ the path-secret registry. Usernames
starting with managed- are reserved. The session-surface and acting-
account guards list the new surfaces with their reasons.
2026-10-08 11:18:14 +02:00
Marc-André Bombeck bb563b6506 Answer the native client's open server asks
- Publish timezone, glucoseUnit and a features block on the account
  payload; glucoseUnit reads as the closed pair the contract names, and
  features.trackIntake tells a separately released client that
  medications take the record-only switch.
- Refuse a new dose for a record-only medication with
  medication.intake.notTracked on every dose-writing route; the bulk
  route reports it per entry as intake_not_tracked, and correcting an
  already resolved event stays open.
- Answer passkey sign-in failures by name instead of a 500:
  passkey.challenge.expired, passkey.unknown, passkey.response.invalid
  and passkey.verification.failed, each with an audit row.
- Carry one account-wide adherence figure in meta.aggregate on the
  batched compliance read, weighted by expected doses, and accept
  ?days=30|60|90|180|365 for a report window.
- Accept ?days=30|90|180|365 on the mood insights read so stability
  covers the period shown, and name the window it covers.
- Keep only the reminder that announces a dose off APNs when the phone
  owns medication reminders; follow-ups for a dose still open are
  pushed again, marked by a followUp flag the reminder cron sets.
- Document which batch validation issues belong to an entry and pin it
  with a test that splits and resends a batch; publish rejectedFields
  items as ValidationIssue.
2026-10-08 10:32:11 +02:00
Marc-André Bombeck 7bcb6f71d9 Merge environment data into release/v1.42.0
# Conflicts:
#	src/__tests__/i18n-reverse-coverage.test.ts
2026-10-08 10:04:45 +02:00
Marc-André Bombeck 3c98d95a40 feat(environment): air quality, pollen and UV per day, locations sealed at rest
Fetch the Open-Meteo air-quality feed (CAMS, domains=auto) with the weather:
17 hourly variables folded into local days by fixed rules (means and maxima
only with 18 of 24 hours, ozone as the highest 8-hour running mean, UV only
with the midday hours), a value the feed did not cover stored as null, never
as zero. Requests are cut into 90-day chunks, newest first, and chunks before
mid 2022 ask for the eight pollutant and index variables only. An air-quality
failure never costs the weather row; the day stays unfetched and the nightly
gap fill (up to 90 days per account, newest first) asks for it again.

Every Open-Meteo request asks an instance-wide budget kept in Postgres first
(500 a minute, 4000 an hour, 8000 a day, weighted by the hosted pricing
rule). A refused request is not sent and the run ends as done with
budget_blocked. OPENMETEO_AIR_QUALITY_URL and ENVIRONMENT_AIR_QUALITY_DISABLED
are honoured; the account switch lives on PATCH /api/environment/preferences.

The home, each dated location period and each stored day keep their coarse
location sealed as one value under healthlog/environment-location/v1. The
writers write the sealed value only, the readers open it and fall back to the
readable columns for rows the free-text encryption backfill has not reached,
and the backfill now seals all three and clears the readable columns. The
backup carries the location readable in a portable file and sealed in a
disaster-recovery file; a restore seals either under the receiving key.

Three correlation channels join the environment family (PM2.5, the ozone
8-hour high, the day's highest pollen count), read through the averaged
same-day window and left empty while air quality is off. The Coach and MCP
gain get_environment: module-gated, clamped to the lookback limit, no
coordinate or place name anywhere, with provenance and the attribution
lines. The settings section gets the switch, the coverage and the newest
day's values; the dashboard shows a quiet chip on a day with high pollen,
a warm night or very poor air. Copernicus and Open-Meteo are credited next
to the values.
2026-10-08 10:02:39 +02:00
Marc-André Bombeck 321ed1241c Merge Health Connect import into release/v1.42.0
# Conflicts:
#	src/lib/jobs/apple-health-import-worker.ts
2026-10-08 09:59:05 +02:00
Marc-André Bombeck 996d2519d1 feat(import): read Health Connect exports
Upload the ZIP the Android Health Connect app writes and import its
`health_connect_export.db` in the background, the same way an Apple
Health export is imported: streamed to disk with a SHA-256, capped at
1 GiB, one import per account, a job row of kind `health_connect` that
the shared reconcile cron and staging sweep now look up on its own
queue.

The worker extracts the database (4 GiB ceiling, zip-bomb checks) and
opens it read-only with `immutable=1`, `trusted_schema=OFF` and
`query_only`. A view in place of a record table refuses the file,
columns are found by name, and schema versions below 9 are refused.
Records are read in batches of 500 and aggregated in SQLite: steps,
energy and distance as one total per day from the app the person's
Health Connect priority list ranks first, heart rate per minute for 90
days and as hourly means before that, sleep stages with the unknown
stage counted only in sessions without real stages, workouts, cycle
days, water and micronutrients. Apps whose integration the account has
connected are left out, and a reading already stored under another
source is skipped. Rows carry source HEALTH_CONNECT, `hc:<uuid>` or the
`stats:` ids, so a second import of the same export writes nothing.

Both staged files are removed on every way out; the job result holds
counts per type and per app only. Settings gain a Health Connect card
with the export steps, progress and the outcome, in all seven
languages, and the import is documented in
docs/integrations/health-connect.md.
2026-10-08 09:54:42 +02:00
Marc-André Bombeck 1fe4a7af95 fix: settle the new edit and HRV surfaces after a look at them
The HRV page's second chart reported its visible window into the same
stat-strip slot as the first, so the two replaced each other on every
render until React stopped it and the chart failed to load. Only the
primary chart reports the window now; the strip describes that measure.

The workout edit sheet no longer shows the "leave empty if you just
finished" hint, which is about logging and not about a stored start. On
the Apple Health card a type's name reads as the row's content above its
arrival line, with room between rows.
2026-10-08 09:52:51 +02:00
Marc-André Bombeck 196c331068 fix(mcp): make the metric and lab deep links land on their pages
MCP `search` and `fetch` link a metric as `/insights?metric=<id>` and a lab
value as `/labs?analyte=<name>`. Neither page read its query, so every link
opened the overview or the full list.

`/insights` now resolves the id server-side, in whichever spelling the
result carries (a Coach source key such as `hrv` or `bp`, a signal or
metric-status key such as `GRIP_STRENGTH`, a measurement type, or a
sub-page slug), and redirects to the metric's page, or to its value list
when no page focuses it. An id it cannot place renders the overview as
before.

`/labs` asks for the newest reading stored under the exact analyte name
and opens its marker's page; a name with no reading keeps the list and
drops the query.
2026-10-08 09:40:12 +02:00
Marc-André Bombeck b8c7eda89c test(insights): reset the HRV page mock without returning a teardown 2026-10-08 09:40:11 +02:00
Marc-André Bombeck 370270bbc1 feat(apple-health): ingest RMSSD heart-rate variability from iOS 27
iOS and watchOS 27 add `HKQuantityTypeIdentifierHeartRateVariabilityRMSSD`
(Apple's symbol `c:@HKQuantityTypeIdentifierHeartRateVariabilityRMSSD`,
available from 27.0). The live batch and the export.zip import now store it
as `HRV_RMSSD`, the type WHOOP, Oura and Polar already write, in
milliseconds, mean-aggregated and flagged privacy-sensitive like SDNN. The
accepted range is 1 to 300 ms.

RMSSD ranks on its own `hrvRmssd` ladder in the SQL source collapse, so a
day holding it from Apple Health and a strap resolves to one source
instead of a name tiebreak.

SDNN and RMSSD are different statistics and are kept apart once one
account holds both. The HRV page charts each on its own, titled with its
measure, instead of hiding RMSSD behind SDNN. The Coach's HRV section
carries one timeline per measure under `byMeasure` (same section key, so
every reader still finds it) rather than one daily series averaging the
two; the budget pass condenses and sheds each measure's timeline like a
glucose context.
2026-10-08 09:37:04 +02:00
Marc-André Bombeck 2731c46399 feat(workouts): edit a hand-entered workout from its detail page
A workout logged by hand could only be deleted and logged again. The
detail page now offers an edit control beside delete for a MANUAL workout
carrying the form's `manual:` id, in one's own record. It opens the log
sheet on the stored values and saves by re-posting that id through
`POST /api/workouts/batch`, which already applies such a re-post as an
overwrite of that one row. No new route.

The overwrite nulls every overwritable column a re-post leaves out, and
the form has no fields for heart rate, steps, elevation or pauses. The
edit therefore sends those back as stored. A field left as shown sends the
stored value rather than one re-parsed from its display, so saving an
untouched edit keeps the start's seconds and a distance shown in miles at
its exact metres, and answers `duplicate`.

The detail read's `avgHr` / `maxHr` can come from a same-session twin;
the read now also serves the row's own `storedAvgHr` / `storedMaxHr`,
which is what the edit sends back.
2026-10-08 09:28:56 +02:00
Marc-André Bombeck ba99551faa feat(apple-health): show when each type last arrived and how
The Apple Health card listed each type's newest sample. A type the phone
only hands over on "Sync all" can carry a reading from this morning and
never have arrived in the background, so the sample time could not answer
whether a type flows on its own.

The batch route's per-type ledger now records, in one upsert per batch,
when each Apple Health type last arrived, the trigger that carried it
(including the new `manual`), and when a batch last brought a new or
updated sample of it. Duplicate-only re-sends move the arrival but keep the
last new value; scoped credentials and MANUAL rows write nothing, the same
rule as the sync checkpoint. A ledger failure leaves a warn line and never
fails the ingest.

`GET /api/integrations/healthkit` lays `lastReceivedAt`, `lastTrigger` and
`lastNewSampleAt` over each `metricFreshness` entry (null for a type no live
sync has carried, absent on the workouts entry and when the ledger read
failed), and the card shows them as a second line per type.

The per-type newest-sample read walks live types through
`listLiveMeasurementTypes` and probes each type's newest row instead of
grouping every live row of the source.
2026-10-08 09:22:49 +02:00
Marc-André Bombeck c8470cc1e6 fix(jobs): log the maintenance job's caught failures and ship the provider-health strings 2026-10-08 09:09:04 +02:00
Marc-André Bombeck 5c23f9ed63 Merge AI operations and error-log discipline into release/v1.42.0 2026-10-08 09:06:58 +02:00
Marc-André Bombeck 511eae8241 fix(jobs): report every caught failure in background jobs or say why not
A catch that drops what it caught is where the silent nightly failures
started. Every `catch` and `.catch()` under src/lib/jobs now rethrows,
fails the job, writes to a ledger an operator reads, counts into the run's
failure facts, hands the error back to its caller, or logs at warn under a
stable action. The enqueue helpers that swallowed a failed send (briefing,
status, narrative, morning digest, document index / summary / thumbnail,
workout insight, boot-time sweeps), the Coach nudge's provider fallback,
the workout and reaction-line cleanups, the advisory-lock release, the
backup attempt bookkeeping and the cycle reminder's dedupe reads now leave
a warn line instead of nothing or an info annotation.

`error-level-discipline-guard` holds the rule. The catches that stay
silent are listed with their reason (the pgboss readers' documented null on
a web-only deployment, the key-ring trial, the restore drill's per-account
result, an already-deleted Telegram message); a new silent catch fails, an
entry that no longer matches fails, and each entry is shown to be
load-bearing by removing it.

A Coach turn cut short by its token, time or round budget writes
`coach.turn.budget_stop` at warn.
2026-10-08 09:06:17 +02:00
Marc-André Bombeck 27342ee734 fix(ai): name the real cause of failed briefings and stop retrying what cannot heal
The nightly briefing warm completed every night and read "all-failed",
because one run event held every account's annotations and the last
account won. Each candidate now runs in its own wide event and the run
reports every outcome through `reportJobRun`, so ok, dead sign-in,
withheld and failed accounts show side by side.

A dead Codex refresh token threw a bare error with no status, so the chain
read it as a network failure: the health ledger recorded hard_failed, no
"reconnect" hint appeared, and every night tried the same token. The
refresh now throws `CodexRefreshError` with a 401 for a dead grant
(invalid_grant / invalid_refresh_token on a 400 included), as do a missing
or undecryptable stored credential. The ledger records auth_failed, the
chain marks the primary credential expired, and the nightly pass reports
the account as auth_failed without queuing a retry.

A briefing the outbound screen withheld (a causal claim) failed outright,
and the 45-minute provider retry then asked the same question eight times
a night. The generator now makes one bounded repair that names the broken
rule and keeps the repaired payload only if it passes the same parse,
grounding and screen; a withheld outcome queues no retry.

A provider that keeps failing is paused: from five consecutive hard
failures the skip window starts at 30 minutes and doubles per further
failure, capped at a day. A paused provider is walked last and tried again
when the window lifts; one success resumes it. The operator card shows how
many accounts it is paused for and until when. After a 5xx from a
non-canonical OpenAI-compatible endpoint the client asks the free model
listing, and a configured model the endpoint no longer offers is named in
the error, on the hop line and on the card.

Also:
- The tiered context's live day band read `measured_at AT TIME ZONE tz`,
  which treats the stored UTC as local time; a far-from-UTC reader's
  evening readings landed on the next day. It converts from UTC first now.
- Google Health intraday heart rate re-reads two hours per sync instead of
  a day; daily summaries and sleep keep the day (#1023).
- The first filtered daily read that answers 400 and triggers the
  snake_case retry is recorded as a note, not an error (#1023).
- The single derived-score read goes through the derived cache with
  stale-while-revalidate, like the batch beside it.
2026-10-08 09:06:09 +02:00
Marc-André Bombeck 2210f823d6 feat(logging): give failed runs and caught failures their own error lines
A wide event carries one action and the last annotation wins, so a hop
that failed before the next provider answered, one failed account among
six, or a caught error the code carried on past was either a meta key on
someone else's info line or nothing at all. `emitSignal` writes one extra
line under a stable action and level, joined to the surrounding event by
request and trace id; `logCaught` / `caughtAs` are its warn-level shape
for a catch that degrades on purpose.

Every failed pg-boss run now writes `job.run.failed` at error with the
queue and how many runs of it in a row have failed, counted from pg-boss's
own rows (`readConsecutiveRunFailures`). `reportJobRun` takes a fan-out
pass's whole candidate list: an all-failed run is `job.run.failed`, a
partial one `job.run.partial` at warn, and the run reports `all_failed` in
its facts so a completed night with nothing done still counts toward the
streak. The repeated-failure alert keys on that one action and number.

Retention is per queue now. `failureReaderRetention` refuses a queue
retention under 96 hours (the 72-hour reader window plus a day), and a
guard refuses any `deleteAfterSeconds` that is neither that helper nor a
literal at or above the floor.

A refusal the request was meant to get (any error carrying a 4xx status)
is recorded at warn instead of error. Expired-token 401s were most of the
error lines and hid the real failures between them.
2026-10-08 09:05:54 +02:00
Marc-André Bombeck 2622f63571 Merge database and ingest work into release/v1.42.0 2026-10-08 08:48:55 +02:00
Marc-André Bombeck 900a5c4f1f perf: stop keeping folded samples and re-reading duplicates
The nightly folds soft-deleted every raw Apple Health sample they folded
into an hourly or daily stats row and kept it for 75 days. Nothing ever
read those rows as deletions; they only held a re-upload of the sample
off, and they were most of the measurements table and its indexes.

Ingest now refuses such a re-upload directly: a raw sample of a dense or
mean type, old enough to have been folded, whose hour or day a live
stats row covers, is a duplicate with reason folded_window. The batch
route and the Apple Health export import apply the same rule. With that
in place the dense retention and the daily-mean consolidation delete the
raw rows outright, and a new compaction-tombstone-purge job removes the
backlog: 5,000 rows a batch, one account at a time under the restore
lock, a short pause between batches, resumable because the predicate is
idempotent, queued at every boot and continuing itself while it makes
progress. A person's own deletions are never in its predicate. The sync
feed leaves compaction tombstones out and a restore does not write them
back; all three classify through the same function.

The batch route answers an exact re-sent sample without a transaction:
one read over the batch's sample ids, live or deleted, decides what the
reconciler would have decided, at the same point in the loop, so every
status, reason, counter and the cross-source merge stay as they were. A
batch of duplicates only no longer queues PR detection, a reminder sweep
or the detection audit row. Caches are evicted only for a foreground or
manual sync or a batch that wrote a hand-entered reading, and marked
stale otherwise; the targets cache follows the same split.

Also:
- POST /api/admin/maintenance/measurements queues VACUUM (ANALYZE) and a
  REINDEX INDEX CONCURRENTLY per index, largest first, on a connection of
  its own; it refuses while the purge runs, drops invalid leftover copies
  and stops at the first failed rebuild. Runbook in docs/ops.
- The pure volume queues keep their finished jobs for 96 hours instead
  of seven days, never less than the failure window plus a day.
- work_mem is set per app connection from DATABASE_WORK_MEM (16MB), also
  when the statement timeout is disabled.
- The PR-detection fallback tick re-scans only accounts whose readings
  or workouts changed since the previous tick.
- Type discovery for the status refill walks the live-rows index as a
  loose index scan instead of grouping every live row.
- The host metrics chart polls every five minutes, like its sampler.
- The batch route records per-type HealthKit arrivals.
2026-10-08 08:41:25 +02:00
Marc-André Bombeck aca497d9ac fix(insights): correlate residuals, not the calendar
Two daily series that both follow the season (temperature and blood
pressure, daylight and sleep) correlated strongly with nothing between
them. In a seeded simulation of such a pair the engine surfaced a
finding in 58 % of 400-day runs and 30 % of 180-day runs, and a real
next-day effect under the season came back with the wrong sign.

Every daily pair now goes through seasonallyAdjustedPearson: a linear
trend and one annual harmonic are removed from both series (trend only
under 120 paired days), the residuals are correlated, and the p-value
uses the Pyper-Peterman effective sample size. Shrinkage and the
phrasing tier read the effective size too. BH-FDR and the effect floor
are unchanged. Pairs too short to fit the basis are not tested.

The weather channels are tested once over the mean of the day before
and the day itself against the same day's outcome (lagDays 0), which
pairs the overnight low with the night it shaped now that sleep is
keyed on the wake day. Discovered pairs carry an additive nEff, the
same-day templates are wired, and the matrix skips the weather read
when the environment module is off.

scripts/compare-correlation-findings.ts counts findings per account
before and after, read-only, for the release-candidate dry run.
2026-10-08 08:32:15 +02:00
Marc-André Bombeck 70dc7dd24f ci: group weekly patch and minor dependency updates into one pull request per kind 2026-10-08 07:59:22 +02:00
Marc-André Bombeck 22c8fe2e8d ci: pin runners to ubuntu-24.04 ahead of the ubuntu-latest move 2026-10-08 07:58:54 +02:00
Marc-André Bombeck e63be67b5a feat: lay down the shared v1.42 contract
Land every file the v1.42 work shares before any of it is built, so the
parts built side by side never edit the same file.

Schema and migrations 0379-0386: the HEALTH_CONNECT measurement source,
ImportJob.kind, the air-quality columns and the per-user switch on the
environment tables, sealed environment locations with nullable plaintext,
managed-profile handover links, the per-type HealthKit arrival ledger, lab
unit-conversion provenance, and user-defined vaccines. Every new column is
annotated for the column-reader sweep.

HEALTH_CONNECT reaches every exhaustive list: the source labels, the
read enum (not the write allowlist), the source-priority ladder cap, now
the number of sources rather than a fixed eight, and the write-path smoke
test. RMSSD gets its own priority class and a 300 ms ceiling. The batch
syncTrigger gains `manual`, with its label on the Apple Health card.

Queues for the Health Connect import, the compaction-tombstone purge and
the measurement-table maintenance are registered with stub handlers;
host-metric sampling moves to every five minutes. Environment and work_mem
variables reach the compose whitelist, the production example and the
manifest. The proxy admits the handover link and redirects it at the edge,
and the Health Connect upload bypasses the proxy body limit.

The new routes answer 501 behind their final authentication and are
published in the contract; backup, wipe and key rotation classify the new
models and columns; the seven message bundles carry the base copy. Guards
that list release-pending keys and columns fail once an entry is wired, so
those lists empty themselves before the tag.

Also re-anchor a login-throttle fixture to the wall clock, where its
expiry is compared, and re-base the aggregate bundle cap on a measured
12 KB of new locale strings.
2026-10-08 07:56:59 +02:00
Marc-André Bombeck 125b15bfb7 Merge pull request #1176 from MBombeck/release/v1.41.2
v1.41.2
2026-10-07 23:45:20 +02:00
Marc-André Bombeck b184a4bbac chore(release): v1.41.2 2026-10-07 22:48:49 +02:00
Marc-André Bombeck 15b2f6f4de Declare every MCP output field and read live rows in discovery
list_metrics declares availability, the Coach-backed reads declare
searchedWindow and available, and get_intraday_pulse declares min and
max per bucket. An integration test validates every tool's real output
against its advertised schema under a strict 2020-12 validator.

The metric-status discovery rows and the search clinical-signal probe
filter deletedAt, guarded for every measurement read in the MCP and
Coach tool trees.
2026-10-07 22:48:49 +02:00
Marc-André Bombeck 316e52dee3 Widen the Coach turn budget and keep its rounds on the prompt cache
Own-plan turns get 165k tokens, 200 s and 16 rounds, operator turns 55k,
120 s and 8; the final-answer reserve, the concurrent-turn slot and the
step cap move with them.

Codex requests carry a per-conversation prompt_cache_key and a stable
session id, api.openai.com gets the same key, Anthropic usage counts
cache reads and writes in the gross, and local servers report cached
prompt tokens, so the turn budget discounts cached input on every
provider.
2026-10-07 22:48:48 +02:00
Marc-André Bombeck dce53291a4 Merge pull request #1175 from MBombeck/release/v1.41.1
v1.41.1
2026-10-07 17:36:23 +02:00
Marc-André Bombeck 352a36fa47 chore(release): v1.41.1 2026-10-07 16:56:20 +02:00
Marc-André Bombeck dd72634791 Rebuild the Coach thinking row as one calm line
While a turn runs the Coach now shows a single row: a brain icon, the
current step in the answer's text size with a shimmer passing through it
and fading dots, and a chevron once there are steps to open. There is no
running clock any more, and the interim table preview with its sparkline
no longer renders under the row; interim tables stay in state until the
answer's own results show them.

When the answer is done the row reads "Thought process · N steps" with
proper plurals, and "Answer needed" while a clarifying question waits.
It never opens by itself, and a row opened during the run closes when
the turn ends. Opened, the steps form a vertical list with an icon per
kind of work on a thin connector line, and a reasoning round shows its
summary under its title.

The shimmer and the dots are a `text-shimmer` utility and a reusable
WaitingText component, both still under reduced motion.

The demo's write block now carries `meta.errorCode: "demo.readOnly"`,
listed in the error-code catalogue, and the Coach answers it with an
honest line instead of the provider-outage copy.

Removes the interim preview component, the elapsed-seconds and summary
helpers, and their message keys in all seven locales.
2026-10-07 16:55:25 +02:00
Marc-André Bombeck 3df5754ab1 Merge pull request #1172 from MBombeck/release/v1.41.0
v1.41.0
2026-10-06 23:50:57 +02:00
Marc-André Bombeck 42e4292683 docs(changelog): note the dependency advisories 2026-10-06 23:26:51 +02:00
Marc-André Bombeck a3efedfb07 fix(deps): raise the MCP SDK to 1.32 and proxy-addr to 2.0.8 2026-10-06 23:26:40 +02:00
Marc-André Bombeck 4d6c342827 docs(changelog): name the thinking depths as the app does 2026-10-06 23:19:24 +02:00
Marc-André Bombeck 5bbc915073 chore(release): v1.41.0 2026-10-06 23:09:56 +02:00
Marc-André Bombeck 305765194e Merge remote-tracking branch 'origin/v141/integration' into v141/f1-providers 2026-10-06 21:50:22 +02:00
Marc-André Bombeck 1acd41eb58 fix(ai): keep tools on the forced final round and narrow reasoning-dialect learning
The Coach loop's final round sent toolChoice "none" without the tool
definitions. With tool_use/tool_result blocks in the history Anthropic
refuses such a request (400), and OpenAI Chat Completions refuses
tool_choice without tools, so every budget, time, cap and no-progress stop
ended in an error on those wires. The final round now carries the tools
with toolChoice "none", which every wire accepts, thinking included.

Codex kept reasoning items as provider state even when the request asked
for no encrypted reasoning; replaying such an item under store:false is a
400. Items are kept only when the request included
reasoning.encrypted_content and the item carries it.

The reasoning dialect cache learned from any 4xx that mentioned
reasoning or thinking (auth, rate limits, model names such as
deepseek-reasoner) and from replay errors, and kept the downgrade for the
life of the process. Learning now requires an HTTP 400 that refuses a
sent parameter, ignores replay errors and the model name, and a learned
downgrade expires after an hour.
2026-10-06 21:50:19 +02:00
Marc-André Bombeck 49ce23809a Merge held memory writes and trail data fixes into release/v1.41.0 2026-10-06 21:50:01 +02:00
Marc-André Bombeck 5361239ae5 fix(coach): keep a turn's fact and plan only with the answer that carries them
remember_fact and propose_plan wrote their rows during the tool round. An
answer that was then blocked, failed or abandoned left the fact in memory
without its note and Undo, and left the plan listed, activatable and fed
into later turns and the briefing. Both are now staged under an id minted
up front and written in the transaction that stores the assistant message,
only when its provenance carries them. A background proposal counts as
offered only once an answer carrying it is stored.

Plan text is model text: ifCue, thenAction and target pass the outbound
dose and risk screen in the person's locale, with their scheduled doses and
medication names, and the injection screen; a trip declines as "unsafe".

The trail route serves recalled facts and the proposal's fact to the owner
whatever the AI capability (only the model-written entries are gated), and
withholds medication facts while the medications module is off.

Accepting a fact proposal is decided under a per-message advisory lock, so
concurrent taps write one fact. Editing a fact re-reads its wording: text
naming a medication moves to "medication", other health wording moves a
non-health fact to "condition", and a health fact the person edited counts
as confirmed.
2026-10-06 21:49:27 +02:00
Marc-André Bombeck 8fd7e9f7a2 style: draw keyboard focus in a neutral tone everywhere 2026-10-06 21:37:48 +02:00
Marc-André Bombeck bd7a354ddd Merge the Coach interface polish into release/v1.41.0 2026-10-06 21:37:29 +02:00
Marc-André Bombeck fac55720a4 Merge the named-medication dose screen into release/v1.41.0 2026-10-06 21:31:50 +02:00
Marc-André Bombeck 27b94a6bf9 fix(safety): count drug names, class words and spelled counts in the dose screen
The dose-change class demanded a dosage-form noun next to every skip, stop
or extra imperative, and a drug name never counted. "Skip the amlodipine",
"then take an extra ramipril", "take one more tablet of ramipril", "Nimm
abends eine Tablette mehr", "Lass das Ramipril morgen weg" and "Stop your
blood thinner before the run" all passed.

A medication noun is now a dosage-form word, a drug-class word per locale
(blood thinner, Blutverdünner, anticoagulant, statin, insulin, ...), a
common generic-name stem (-pril, -sartan, -olol, -dipine, -formin, ...),
or one of the person's own medication names. The Coach loads the names
with the turn context and passes them to the reply screen and the trail
screen; a scheduled brand name is only known that way.

Add the extra / another / one more shape in en, de, fr, es, it, pl and ko,
spelled-out counts as a dose target, and stop / leave out / go without a
named medication. The number-free shapes use a two-word slot that a
preposition ends, so "take an extra minute to log your medication" and
"stop worrying about your medication" pass. A sentence that only names a
drug, a schedule description, a past change from the log, a negated
warning and a referral ("ask your doctor whether you should stop ...")
still pass.
2026-10-06 21:31:18 +02:00
Marc-André Bombeck e406b9c7ab fix(coach): calm the answer column, its replies and its focus marks
The running status line now carries its seconds right after the text, in
the line's own muted tone, instead of at the far edge of the column. The
live count and every finished round read "4 s", the same spacing as the
settled summary, with a no-break space so a round never wraps between
number and unit.

Reply pills (follow-ups, clarifying choices, memory and plan decisions)
sit directly under the answer, and the action row is the last thing in
the column. Beside a pointer the row stays invisible until hover or
focus; above the pills it left an empty band between an answer and its
replies. Hovering or focusing the message still reveals the row without
moving anything.

Keyboard focus in the Coach panel is a solid ring in the neutral
text-field focus tone, never the purple ring: the pills, Undo and Change
merge it over the button primitive, and the hand-built controls carry it
directly. A structural test keeps the purple ring out of the panel.

A disabled native select now looks disabled, like Input and Select, so a
thinking depth locked by the operator no longer reads as a live choice.
In Settings, thinking depth takes the shape of Tone and Reply length
beside it; a memory entry's Forget is an icon like its edit; the
operator's highest-depth label sits on the switches' left edge.
2026-10-06 21:24:24 +02:00
Marc-André Bombeck c7d659eb11 fix(coach): ask the capability before deferring a memory refresh, and pin test fixtures to the new quiet time 2026-10-06 21:02:41 +02:00
Marc-André Bombeck c56a05925c Merge time-of-day signals and dated latest readings into release/v1.41.0 2026-10-06 20:54:12 +02:00
Marc-André Bombeck 072c1e9394 Compare glucose signals by the hour and date stale strip readings
The signals of the day held today's newest single reading against the
7- and 30-day mean of every reading. For glucose, whose level climbs
with each meal, a fasting morning read as low against an all-day mean
and fed that into the briefing and status prompts. A type in
SAME_HOURS_TYPES now compares today's mean so far with the earlier
days cut at the local time of today's latest reading, the basis the
coincident-deviation flag and the metric strip already use. Below a
week of like-for-like days there is no signal. The signal carries
basis "sameHours" and both briefing prompts say what it means. Other
metrics keep the reading-against-window comparison.

The usual-range strip called the newest reading today's on every
metric page, even when it was days old. CoachReadBaseline now carries
latestDate (the reading's day in the account's zone) and latestIsToday
(isFromToday), on the REST response and the get_metric_baseline MCP
output. The strip names the date when the reading is not from today,
in all seven locales.
2026-10-06 20:53:39 +02:00
Marc-André Bombeck 36d48bb135 chore(i18n): ship the reasoning and memory strings in every locale and drop the retired self-context replies 2026-10-06 20:48:58 +02:00
Marc-André Bombeck 3b5fb1e900 Merge the Coach reasoning interface into release/v1.41.0 2026-10-06 20:48:42 +02:00
Marc-André Bombeck 7b36b09f1f test(e2e): drive the Coach stream frame by frame for the trail, pills and memory
The chat POST is answered from a stream the spec feeds, so each live
state is observed: the status line moving from thinking to fetching to
summarising, a table previewed before the first token, the trail closed
in every state and after a reload, at 1440 and 390 px in both themes.
Clarifying choices, memory and plan decisions answer through pills, an
entry in the memory list is edited in place, and the thinking depth
shows the operator's cap and lock. The dialog spec reads the pills
where it read the card.
2026-10-06 20:47:28 +02:00
Marc-André Bombeck c95dcb34db feat(settings): set how hard the Coach thinks, cap it as an operator, edit its memory
Thinking depth (Off, Low, Medium, High) sits under the model in the
Coach's quick settings and in Settings → Coach. It renders the resolved
block from the account payload: levels above the operator's cap are
disabled and say so, the field locks with one sentence when the
operator switched reasoning off, and Off reads Minimal where the
provider cannot stop thinking. The provider's own reasoning select
leaves the quick settings and says the Coach has its own.

The operator gets a switch to allow deeper thinking and a select for
the highest depth. The memory list shows where each entry came from,
lets it be edited in place, and is linked from the quick settings with
its count. The deep-linked data section no longer draws a ring.
2026-10-06 20:47:27 +02:00
Marc-André Bombeck 11bafd68b5 feat(coach): one quiet status line, reply pills and memory notes in the thread
The top of an answer is one line that says what the Coach is doing right
now (thinking, fetching a metric, summarising the readings, writing),
live from the activity frames, with the seconds so far. It never opens
by itself: the trail stays closed while the turn runs, when it asks,
when it stops early, after the answer and after a reload, and opens
only on a tap. Once the answer is in, the line settles into one summary
of lookups and seconds. A table read during the turn shows as a
one-line preview with a sparkline until the answer takes it over.

Clarifying choices, follow-ups and the answers to a fact or plan
proposal share one pattern: quiet pills in the answer's column whose
tap sends the label as the person's own message. The clarification
card and the in-thread plan card are gone. A fact the Coach kept is one
meta line with Undo, an assumption one line with Change. The remember
button on a message now keeps it in the Coach's memory.
2026-10-06 20:47:17 +02:00
Marc-André Bombeck 4298207500 feat(coach): draw a comparison of two periods or two metrics
The compare chart replaces the placeholder that rendered nothing: two
periods overlaid on one axis (the earlier one dashed), or two metrics on
two axes whose grid lines meet. Same tokens, legend and tooltip as the
other result charts, which stay as they were. The legend key is spelled
out so the chart runtime does not pull the Coach's schemas into every
chart.
2026-10-06 20:47:06 +02:00
Marc-André Bombeck df163123e3 Merge same-hours glucose standing and the updated_at guard into release/v1.41.0 2026-10-06 20:30:35 +02:00
Marc-André Bombeck 3e968f03a9 fix(insights): place a glucose day in progress against the same hours
The usual-range strip on a metric page placed the latest single reading
against a band built from whole-day means. For glucose the day mean climbs
with every meal, so at nine in the morning a fasting reading sat under a
band of whole days and the strip said "below your range" on an ordinary
morning; the MCP baseline read reported the same.

Glucose is now placed by its day mean. A day still in progress, in the
reader's own zone, is held against the earlier days cut at the local time
of today's latest reading, the comparison the health status already makes;
with fewer than seven earlier days that have readings in those hours there
is no verdict. The baseline carries `basis: "sameHours"` on the coach-read
route and the MCP read, and the strip names the range as the one for this
time of day, in all seven locales. Other metrics keep their placement.
2026-10-06 20:29:57 +02:00
Marc-André Bombeck 362818418e test(coach): count the conversation writes that may stamp updatedAt
The guard allowed every unnamed coachConversation write in a listed file,
so a background write added beside the rename and the document attach
would have passed. Each listed file now carries how many such writes it
holds, and a changed count fails.
2026-10-06 20:29:56 +02:00
Marc-André Bombeck e59fef2545 fix(coach): catalog the decline and clarify-choice strings, and let a briefing asked for by hand reason 2026-10-06 20:14:11 +02:00
Marc-André Bombeck 8f9c443d35 Merge dose-change screen into release/v1.41.0 2026-10-06 20:14:04 +02:00
Marc-André Bombeck c7844c3bc6 fix(safety): screen the dose-change class, not one sentence shape
"Increase your metformin to 2000 mg daily" passed the outbound dose
screen. The English change pattern accepted only "it" or "your dose"
between the verb and "to", so a drug name in that slot walked past it,
and no pattern covered an imperative to take a stated amount, a count
unit (tablets, capsules, puffs), or an imperative to skip or stop a
medication. Each locale bank had the same narrow object slot.

Add a per-locale dose-change class (en, de, fr, es, it, pl, ko) with
three shapes: an imperative or infinitive change verb with up to four
words of object and a target dose; an imperative to take or inject a
stated amount; and an imperative to halve, double, skip, pause or stop
a medication. Negated imperatives, past-tense log restatements,
present-tense descriptions ("you take 1000 mg"), clinician referrals
without a target, and nutrition advice (sodium, water, steps) still pass.

A unit now ends on an explicit lookahead instead of \b, so a lab
concentration ("under 140 mg/dL") no longer trips the dose bank while a
rate ("2000 mg/day") still does. "as usual" and "wie gewohnt" join the
continuation anchors, still gated on the user's schedule.
2026-10-06 20:12:05 +02:00
Marc-André Bombeck d403cf40af Merge reasoning settings, admin controls and background reasoning into release/v1.41.0 2026-10-06 20:07:52 +02:00
Marc-André Bombeck ae5cee331e feat(coach): resolve the turn's thinking depth from the person and the operator
The chat route hands the turn the level resolveCoachTurnReasoningLevel
returns: the stored Coach preference, absent read as medium, after the
operator's switch and cap. The turn still applies the operator-paid
ceiling once its chain is known. The controls loader no longer throws
when the request cache cannot serve it; it fails closed to off. The
admin guard pins the route to the resolver.
2026-10-06 19:57:04 +02:00
Marc-André Bombeck 85ebdad5c9 Merge remote-tracking branch 'origin/v141/integration' into v141/d-settings 2026-10-06 19:54:43 +02:00
Marc-André Bombeck 3f7a16316f chore(ai): drop an unused reasoning default 2026-10-06 19:54:42 +02:00
Marc-André Bombeck 8e7fd0b873 chore(coach): publish the trail route, list it among the mixed reads, and ship its strings in every locale 2026-10-06 19:53:43 +02:00
Marc-André Bombeck 2f5877cf97 Merge loop, budget, trail and clarifications into release/v1.41.0 2026-10-06 19:49:51 +02:00
Marc-André Bombeck de5b315999 feat(coach): give the turn the memory block, its recalled facts and a pending proposal
Build the memory block for the providers the turn sends to, send it on
both paths, keep the facts it recalled in the owner's trail, and offer a
health fact the background found as the answer's one note. Every turn
enqueues the memory refresh; the job's debounce and singleton key decide
when it runs. A remembered fact points back to the stored user message.
2026-10-06 19:48:55 +02:00
Marc-André Bombeck 4dd2a2b6bc feat(insights): show the daily briefing how active plans are going
The briefing prompt carries the server-computed progress line of up to
two active Coach plans, fenced as data because it quotes the person's
own goal words. The model may mention one and may not invent one; the
figures in the lines are admitted by the grounding gate like any other
figure the server handed over. The lines join the snapshot fingerprint
only when there is one, so an account without an active plan keeps its
stored fingerprint, and a failure to build them drops the lines, never
the briefing.
2026-10-06 19:46:44 +02:00
Marc-André Bombeck 656d8b8563 Merge remote-tracking branch 'origin/release/v1.41.0' into v141/d-settings 2026-10-06 19:44:56 +02:00
Marc-André Bombeck fadb2ff6ed feat(insights): let the daily briefing and the period narratives reason
runStatusCompletion and runBriefingCompletion take an optional
reasoningJob. Only a named job carries reasoning, resolved against the
operator's switch and cap and the payer; a call without one, which is
every status card, batch note, derived score and workout line, sends
exactly what it sent before. Thinking is reserved on the day's ledger
with the answer budget, and a reasoning status call falls back to a
90 s ceiling instead of 60 s.

The briefing reasons on its generation and phrasing re-roll, the weekly
and monthly narratives on their first pass; the JSON and grounding
repairs do not. An inventory test freezes which files may name a job.
2026-10-06 19:44:52 +02:00
Marc-André Bombeck df0689ffbe feat(auth): publish the resolved Coach reasoning on the account payload
GET /api/auth/me gains coachReasoning: the level a turn runs at, the
person's own pick, the highest level the settings may offer, whether
reasoning can run at all, whether the lowest option is a real off, and
the layer that decided. Resolved on the server so the web settings and
the native client read the same answer.

The web reads it through useCoachReasoning. The Coach preferences route
names the stored level on its wide event, and a unit test round-trips
the level through the account-settings backup section.
2026-10-06 19:44:52 +02:00
Marc-André Bombeck adb4cd5c40 feat(admin): carry the reasoning switch and cap on the assistant flags
PUT /api/admin/settings/assistant-flags accepts aiReasoningEnabled and
aiReasoningMaxEffort (low, medium, high), writes them field by field
with an audit row like the other switches, and both methods echo them
as reasoning: { enabled, maxEffort }.
2026-10-06 19:44:52 +02:00
Marc-André Bombeck 74e68745c1 feat(ai): resolve the reasoning level against the operator's controls
Add resolveReasoning, the one decision for how hard a call may think:
the operator's switch, then the provider's ability, then the requested
level (the person's Coach preference, or a background job's own level),
then the operator's cap, then a medium ceiling when the operator pays.
The answer names the layer that decided it.

loadReasoningControls reads the two app_settings columns, memoised per
request, read afresh in background runs, and fails closed to "off".
loadCoachReasoningState resolves the published Coach state from the
memoised provider presence; resolveJobReasoning resolves one background
call and never fails it.

A guard proves the switch and the cap over the whole input space and
requires every call that carries reasoning to take it from the
resolver.
2026-10-06 19:44:51 +02:00
Marc-André Bombeck 8f58cad7f3 fix(insights): say a steady usual value as one figure, not a range of equal ends 2026-10-06 19:41:39 +02:00
Marc-André Bombeck f46e0960fb Merge remote-tracking branch 'origin/v141/e-memory' into v141/b-loop 2026-10-06 19:39:10 +02:00
Marc-André Bombeck 88b2bf13b0 Merge remote-tracking branch 'origin/release/v1.41.0' into v141/b-loop 2026-10-06 19:39:06 +02:00
Marc-André Bombeck 8e74bc05c8 test(coach): cover the budget, the brake, the trail, questions and comparisons
Unit tests for the turn budget and the no-progress brake, the budgeted
loop (every stop reason, the round-by-round ledger, repeated calls,
questions, comparisons, reasoning and provider state), the trail recorder
and its screen, clarifications through the tool and the brake, the
comparison table and chart, the trail column and its owner-only read, the
reply frames, and the turn's frames end to end through the route. An
integration test proves the trail against a real database. Eval
scenarios cover a multi-round why question, a period comparison with its
chart, a question asked through the tool, and keep the charts the
existing table scenarios already showed.

Take the effective reasoning level from the route rather than resolving
it in the turn; the turn only caps it when the operator pays.
2026-10-06 19:38:57 +02:00
Marc-André Bombeck 0ba698c54f Merge memory, plans and voice into release/v1.41.0 2026-10-06 19:33:17 +02:00
Marc-André Bombeck 912558b96a feat(coach): answer first, short and without filler
Give the Coach one statement of how an answer is shaped, in the German and
English bodies and on every other locale: the first sentence answers, one
carrying number with its unit and window, the person's own baseline, an
assumption in one line, at most one next step and no habitual closing
question, 40 to 120 words, and a closed list of filler it never writes.

- Brief is 30 to 70 words in one paragraph, detailed up to 220 in three.
- Motivational-interviewing moves only when the person opens a change topic,
  at most one, never as a closing question; the confidence ruler only on an
  explicit plan request.
- The few-shot examples are rewritten to model the voice.
- Result tables: show the numbers whenever they carry the answer and span
  more than one value, a table for exact values or up to twelve like rows,
  one figure alone only for a moment.
- Follow-up chips are the answer's only "what next".
- A voice grader for the eval (length, filler, emoji, next steps, closing
  question, warm-up opening) plugs into the golden set as criteria; five
  voice cases in German and English.
- A guard holds both prompt bodies to the rules and every example answer to
  the grader, each check broken once on purpose.
2026-10-06 19:32:32 +02:00
Marc-André Bombeck acec87f02c feat(coach): give every turn its memory and let the Coach remember and propose plans
What a person told the Coach never reached a tool-mode turn: facts and plans
rode the snapshot's memory block, which tool mode does not send, and the
extraction ran only past twenty turns and failed to parse on every
non-Codex chain, because the status chain runs in JSON-object mode while the
prompt asked for a bare array.

- Memory block: WHAT YOU KNOW ABOUT THIS PERSON, built per turn behind the
  wire egress check (nothing is read on a refusal), fenced as data, at most
  1,500 characters, never trimmed before the inventory. Confirmed facts
  first, then the newest; active plans with a server-computed progress line;
  reminders the person asked for. Medication facts stay out while the
  medications module is off. Building it stamps last_used_at without moving
  updated_at. buildCoachToolRequest and buildCoachProviderPrompts take it as
  an optional memoryBlock; the snapshot memory keeps only the narrative and
  trend recall.
- remember_fact: preferences, goals and context are saved at once
  (source coach, source_message_id set); anything that names a condition,
  limitation or medication, by category or by wording, is only proposed.
  A fact must come from the person's current message; one note per answer.
- The person's answer reads the proposed fact from the encrypted trail on
  the assistant message, never from the request.
- propose_plan writes a proposed plan; the tap activates it with its review
  window counted from the tap. One per answer, three open at most.
- Progress: one sentence per active plan from the person's own day series
  and units, also offered to the briefing (two lines at most, behind the
  briefing capability). The plan review and the progress line read a step
  plan as daily totals instead of per-sample means.
- Background extraction asks for {"facts":[]} / {"plans":[]} and reads
  either shape, runs once a conversation has been quiet for 30 minutes, and
  stores health facts as proposals offered once at the next turn.
- Unanswered plan and fact proposals lapse after 14 days in the reminder
  sweep.
- Memory list API: POST saves one of the caller's own messages (the
  remember button), PATCH edits a fact's wording, GET reports source, origin
  and last use and never lists a waiting proposal.
- A structural guard keeps the egress check ahead of every read and the
  callers of both builders to the turn and the briefing.
2026-10-06 19:32:31 +02:00
Marc-André Bombeck 6112cfc6d5 test(ai): keep provider state out of logs and storage
providerState may be read only in the clients, the contract and the Coach tool loop, never on a line that logs, annotates or persists, and has no schema column.
2026-10-06 19:26:30 +02:00
Marc-André Bombeck 34afa3a4f0 feat(ai): reason deterministically in the mock and count reasoning in the chain runner
The mock reports a fixed summary, title and provider state for calls that ask for reasoning. The runner passes reasoning and onReasoning to every candidate unchanged and adds the reasoning token and summary counts to the wide event, never the text or the state.
2026-10-06 19:26:30 +02:00
Marc-André Bombeck edf6fffc32 test: admit the per-call reasoning level in the reasoning_effort guard
The key now appears twice in each client: the bound chain setting, sent only when the call carries no level, and the call's resolved level. The guard pins both, the condition in front of the bound value, and the absence of a literal.
2026-10-06 19:26:30 +02:00
Marc-André Bombeck 0281883247 feat(ai): send reasoning on chat-completions endpoints and local servers
OpenAI sends reasoning_effort for reasoning models on rounds without tools, off mapped to each family's lowest value. Gateways send reasoning_effort, OpenRouter its reasoning object, and the reasoning details or thinking blocks a gateway returns go back on the next round's assistant message. Local servers send reasoning_effort and read reasoning_content, reasoning and a leading think block, live on the stream without letting it reach the answer.

The thinking budget rides on top of the answer budget where reasoning counts against it. A refused reasoning field is retried once without it and remembered per endpoint and model, before the JSON dialect check. A call's own level wins over the stored chain setting, which still applies when the call has none.
2026-10-06 19:26:29 +02:00
Marc-André Bombeck 6b1bafca3c feat(ai): ask Anthropic models to think in the dialect their generation takes
Up to 4.5 a thinking budget (with the interleaved-thinking header on tool rounds), from 4.6 adaptive thinking with an effort and summarized display. From the 5 generation thinking cannot be switched off, so off becomes the lowest effort. Temperature is never sent from 4.7 on, nor with thinking, which also removes a 400 on the newer model presets.

Thinking blocks travel to the next round unchanged, signature included; a tool round another provider started runs without thinking. Every text block is joined instead of only the first. The JSON instruction goes only to callers that ask for JSON. Unknown model names learn the dialect and the sampling rule from a refusal.
2026-10-06 19:26:29 +02:00
Marc-André Bombeck d87b8204f3 feat(ai): send reasoning on the Codex wire and hand its items back between tool rounds
A call that carries a reasoning level sends reasoning {effort, summary} with include [reasoning.encrypted_content]; off is effort none. Summary titles are reported as soon as the bold first line closes, the text when the part is done. Reasoning items come back as provider state and are re-inserted before the next round's message and calls, only for the slug that made them.

A 400 that names a reasoning parameter is answered with the lesser request the backend lists (no summary, the nearest supported effort, no reasoning), remembered per slug. Calls without a level keep reasoning null and include [].
2026-10-06 19:26:28 +02:00
Marc-André Bombeck 8d23c79ef5 feat(ai): add the reasoning support matrix and a per-endpoint dialect cache
reasoningSupport() reads what each provider can do with reasoning from the provider type and model name: effort, summaries, live summaries, state that must travel between tool rounds, and whether off is a real off. Unknown names are treated optimistically.

The dialect cache records what an endpoint refused (keyed by provider, endpoint and model) so a client retries with the lesser request once and never pays the refused round-trip again. Downgrades are marked on the wide event with meta keys only.
2026-10-06 19:26:28 +02:00
Marc-André Bombeck cf68055cb2 feat(coach): run the turn on a budget with a live trail, questions and comparisons
Replace the fixed three tool rounds with a budget fixed by who pays:
120k tokens, 150 s and 12 rounds on the person's own plan, key or local
model, 40k, 90 s and 6 rounds when the operator pays. Cached input counts
at a tenth. The final answer is reserved from the start, so a turn whose
budget, clock or round cap runs out answers from what it read. A
no-progress brake stops a turn that repeats calls or reads nothing new,
and a repeated call is never run again.

The day's ledger is booked round by round: round one and the final
reserve when the turn starts, each further round right before it runs,
every round settled as it returns, the unused reserve given back at the
end. A round the daily ceiling refuses ends the turn with an answer.

Every phase goes out live as an activity frame (thinking, fetching,
summarising, asking, remembering, planning, the stop, the answer), with
screened reasoning titles and summaries; the structure rides the
provenance and the text is sealed in trail_encrypted, served by
GET /api/insights/chat/{id}/messages/{messageId}/trail. Tables go out as
interim results as soon as they are read.

Add ask_clarification with a server brake, remember_fact and
propose_plan over the memory contract, model-free decision turns,
compare_series with the compare chart, eight tables per message,
assumptions with change-assumption chips, a reasoning level per turn and
at most two concurrent turns per person.
2026-10-06 19:09:15 +02:00
Marc-André Bombeck 210becb28b feat(coach): lay down the reasoning, trail and memory contract
Add the shared contract the reasoning release builds on, with no change
to how a turn runs today.

- Schema: operator reasoning controls on app_settings (0376), the
  encrypted trail text on coach_messages (0377), and source,
  last_used_at and source_message_id on coach_facts (0378).
- Provider contract: CompletionParams.reasoning and onReasoning,
  CompletionResult.reasoning and providerState, AiMessage.providerState.
- Reasoning levels, thinking budgets and the background-job table in
  src/lib/ai/reasoning/levels.ts; coachPrefsJson.reasoning, absent
  meaning medium, salvaged field by field like every other pref.
- Stream: activity, memoryNote and planProposal frames, done.stop and
  done.withheldResults, result.interim, a compare chart kind, eight
  table refs, three more clarification kinds with an assumed choice,
  the change_assumption chip, and assumptions, activity, stop,
  memoryNote and planProposal on the provenance. Zod mirror, type
  parity and the native-client key rule cover every new frame.
- Chat request: memoryDecision and planDecision, parsed and not yet
  acted on.
- Stub contracts for the memory functions and the activity recorder,
  inert until their implementations land.
- Backup, restore and rotation carry the new columns; a fact's message
  reference is resolved against the restored messages like its
  conversation reference.
- Message keys for the trail, memory, plans, assumptions, replies,
  interim tables, comparison charts and reasoning settings in all seven
  locales, each reachable from a catalog and checked both ways.
2026-10-06 18:39:50 +02:00
Marc-André Bombeck f269100b01 Merge pull request #1171 from MBombeck/release/v1.40.6
chore(release): v1.40.6
2026-10-06 00:51:13 +02:00
Marc-André Bombeck ef9dc1ddc6 test: pin the Coach layout clock to midday and read copied text without a tag-stripping replace
The panel groups conversations by the browser's local day, so the e2e
fixture's "an hour ago" landed in yesterday when CI ran just after
midnight. The turn-steps test reads its markup by splitting rather than
with a replace CodeQL reads as an incomplete sanitiser.
2026-10-06 00:30:13 +02:00
Marc-André Bombeck 68720418e3 docs(changelog): add the MCP schema dialect and the local-day briefing to 1.40.6 2026-10-05 22:34:13 +02:00
Marc-André Bombeck 5328c6a534 Merge branch 'fix/mcp-schema-2020-12' into release/v1.40.6 2026-10-05 22:33:59 +02:00
Marc-André Bombeck 6671276d57 Merge branch 'fix/v1406-today' into release/v1.40.6 2026-10-05 22:33:59 +02:00
Marc-André Bombeck 63b395915e test: seed cached briefings with their generation moment
The today check now reads briefingGeneratedAt from the cached payload
and treats a payload without it as not from today. The integration
and e2e fixtures that seed a fresh briefing write it, as every real
generation does.
2026-10-05 22:28:10 +02:00
Marc-André Bombeck 7c7576423f fix(dashboard): give the score's meta lines their own row on a phone
Below md the lines under the 80 px dial (the score delta, how long it
has held, what it rests on) were capped at the dial's width. In German
they broke into three to five rows each, and beside a short lead that
column set the height of the read row and left a gap above the facts.

The score column now dissolves into the grid below md: the dial stays
the top-right cell beside the lead, and its meta lines take a
full-width row right after the read row, flush with the dial's edge,
one line each. The facts move one row down. From md up the column is
the same flex column as before (desktop captures are pixel-identical).

The Today overview e2e renders German at 390 and 360 px with a short
and a long lead and asserts the dial size, one row per meta line, the
lines flush with the dial, the meta row right after the read row, and
exactly one row gap above the facts.
2026-10-05 22:21:07 +02:00
Marc-André Bombeck 43361bd74e fix(daily): write the briefing for the reader's own morning and readings
With signals of the day resting on today's readings only, the briefing
had to be written after those readings, and it was only written at the
04:30 Berlin warm, the post-sleep morning refresh, or a page open on a
day-old cache. A morning blood pressure or weigh-in never became a
signal, and since an earlier day's briefing is no longer served, a
reader in Los Angeles or Seoul had none for most of the day: the Berlin
warm lands the previous evening there, or at noon.

The snapshot read every surface goes through (dashboard, iOS, digest,
morning push) now asks for a briefing for today at two moments: the
first read of the reader's local day with no briefing written today,
and the first read after a signal metric's first reading of the day
that the briefing has not read. Each reason is claimed once per user
per local day in a Postgres bucket and remembered in-process, so later
reads cost no query; no write path needs a hook.

A claim enqueues one today warm: immediately for a new day, after a
five-minute window for readings, so a burst (weigh-in, then blood
pressure) folds into one job. The warm writes the comprehensive
briefing only, past the one-hour freshness gate but under the failure
backoff, the daily forced-warm cap (two per 24 h) and the content-hash
gate.

Worst case per user per local day: one enqueue per reason, at most
eight (the day plus seven signal reading types, measured over a day of
reads every two minutes); at most two forced comprehensive attempts
under the existing cap, each at most three completions (the answer, a
JSON retry, a grounding retry), so six provider calls at the ceiling.
A warm whose data has not changed costs at most the existing
once-a-day paragraph re-roll, inside the same cap.
2026-10-05 22:09:41 +02:00
Marc-André Bombeck 79e0eca023 fix(mcp): advertise tool schemas as JSON Schema 2020-12
The SDK renders every tool's Zod shape with a fixed draft-07 target and
stamps "$schema": "http://json-schema.org/draft-07/schema#" on each
inputSchema and outputSchema. Clients that validate with a 2020-12-only
validator refuse that dialect when the tool list arrives, so every tool
was unusable there (#1170).

Wrap the tools/list handler once, after the last tool is registered, and
re-render each advertised schema from the same Zod shape with the
draft-2020-12 target, without a $schema key. A schema with no $schema is
2020-12 under the MCP spec and still compiles in a draft-07 client. All
other fields of a tool entry pass through unchanged, and the SDK keeps
validating structuredContent against the Zod shape, so tool results are
unaffected. For the current surface the 54 rendered schemas differ from
the previous ones only by the dropped key.

The new guard reads tools/list through a real client for a write-scoped
session, rejects any draft-07 marker or construct, and compiles every
schema with Ajv's 2020-12 build (ajv added as a dev dependency at the
version the SDK already resolves).
2026-10-05 22:09:03 +02:00
Marc-André Bombeck 143a39e369 fix(coach): take a condensed series summary from its daily values
The summary of a condensed block was taken over the block's mix of daily
values and weekly means: over a year the newest fourteen days weighed as
much as fourteen weeks, min and max were extremes of means, and change was
one day minus a weekly mean.

Each series block now hands the budget pass its daily values over the
whole read, computed only when the block is condensed. The summary states
the first and last day, the lowest and highest day, the mean of the days,
and the change from the first calendar week to the latest one. A block
without daily values gets no summary rather than one of what is left. The
coverage note says how the summary is taken.
2026-10-05 22:03:59 +02:00
Marc-André Bombeck f6b0131731 fix(daily): serve the briefing as it stands today on every surface
Only the digest applied briefingForToday. The dashboard snapshot (the
web dashboard, its server prefetch and the iOS cold-launch read) and
the /insights advisor payload still served an earlier day's text and
the raw model deltas ("+33.72 bpm").

The snapshot read now resolves the briefing after the cache, beside
the capability decision, from the payload's generation moment and the
snapshot's own newest reading per type; a withheld briefing reads
"preparing", never "ready" with nothing in it. The digest takes the
resolved briefing from that read on its own clock instead of resolving
again. The advisor route resolves dailyBriefing on every branch it
returns, reading the newest reading per signal type in one grouped
query only when a reading-backed signal is present; text generated on
an earlier day no longer counts as fresh on the read GET (so the warm
is enqueued) and no longer short-circuits a provider-backed POST.

The OpenAPI descriptions of both advisor responses say so.
2026-10-05 22:03:58 +02:00
Marc-André Bombeck 491d542f58 fix(daily): judge the briefing's day by when its text was written
The check that keeps an earlier day's briefing off the Today surfaces
read the cache row's insightsCachedAt. Every warm moves that column,
including the unchanged-hash short-circuit that keeps yesterday's text
and only refreshes the stamp, so text written the day before passed as
today's after the nightly warm.

The cached payload now carries briefingGeneratedAt, written only where
new text is written: a full generation in the warm and in the
on-demand route, and the daily paragraph re-roll. The stamp refresh
leaves it untouched. briefingForToday reads that moment, and a payload
without it counts as not from today, so the hero falls back to its own
lead until the next generation.
2026-10-05 21:57:49 +02:00
Marc-André Bombeck 756c3fb845 fix(coach): condense each series block in its own fields
The condensing step read every block as a value series: daily rows under
value, weekly rows under mean and count. Sleep nights carry minutes, so a
condensed sleep summary ended on the last weekly mean weeks before the
newest night; adherence rates fold by doses, not readings; glucose keeps a
series per measurement context; workouts are a sessions list.

Condensing now knows each block's shape: sleep reads minutes, adherence
folds rate weighted by total doses, glucose condenses every context, and a
workouts block keeps its newest sessions beside the whole-window rollup.
2026-10-05 21:54:46 +02:00
Marc-André Bombeck 14a2931a99 fix(daily): read signal deltas in the reader's number format
formatSignalDelta matched only the locale's decimal mark, so an
English-formatted figure inside German text was cut at the comma
("+1,234.5 kg" read "+1,2.5 kg"), and a difference that rounded to
nothing lost its sign ("+0.04 kg" read "0.0 kg").

A figure with a decimal or grouping mark is now read as a whole: with
a point and a comma the later one is the decimal, thin spaces and
repeated marks are grouping, and a single mark is grouping only when
it is the locale's grouping mark before exactly three digits. The
figure is re-rounded to the metric's precision and re-written with the
locale's own marks. A non-zero difference that rounds to nothing reads
"±0" at that precision. Plain integers stay as written.

Glucose joins the reading types, its precision following the unit the
delta is written in (whole mg/dL, one decimal of mmol/L). The map is
keyed by string, because the published sourceMetric enum has no
glucose value yet and widening it would change the iOS contract.

Tests cover all seven locales.
2026-10-05 21:54:01 +02:00
Marc-André Bombeck 73ce721ff2 fix(mcp): keep the metric series read as it was
The Coach's figure check ran before the MCP branch, so an MCP client was
told retrieval_failed for a block it used to receive, and the plain-text
renderer then said no data was on file. Any single-source build also
condensed its own block, which reached MCP through the source snapshot's
fallback to the full builder.

The figure check now runs after the MCP branch, and condensing is an
explicit option of the snapshot build that only the Coach tools set (it is
part of the cache key). MCP output is what it was before.
2026-10-05 21:52:48 +02:00
Marc-André Bombeck af79f4fb5c fix(coach): do not count the freshness stamp as a figure in a series block
A block trimmed to its unit and freshness stamp passed the figure check,
because asOf.daysAgo is a number. The check now skips the asOf and scope
fields, so that shape answers retrieval_failed like any other block
without a reading.
2026-10-05 21:50:48 +02:00
Marc-André Bombeck 28517affd0 chore(release): v1.40.6 2026-10-05 21:38:46 +02:00
Marc-André Bombeck ab14747013 Merge branch 'fix/dashboard-hero-mobile' into release/v1.40.6 2026-10-05 21:38:21 +02:00
Marc-André Bombeck 861d1ba868 Merge branch 'fix/coach-panel-search-composer-ring' into release/v1.40.6 2026-10-05 21:38:14 +02:00
Marc-André Bombeck ff0769f045 fix(dashboard): lay the Today hero out for a phone
Below md the score ring sat centred under the Today facts as a 168 px
dial, where the bottom navigation covered it. The hero is now one
grid with two arrangements: on a phone the ring is a compact 80 px
dial at the top right beside the lead and signal line, flush with the
card's trailing edge, and the Today facts run the full card width
under both; from md up the ring spans both rows of the trailing
column exactly as before (desktop captures are pixel-identical).
Without a lead the facts take the leading cell, so the ring never
sits beside an empty row.

ScoreRing gains a `responsive` size (80 px, md:168 px) whose label is
left out below md; the ring keeps its aria-label and the surrounding
link names it. The meta lines under the phone dial are capped to its
width, so the lead keeps at least 180 px beside it at 360 px.

Each fact now stacks its muted label above its foreground value on
the reading edge at every width, instead of a label-left /
value-right row that broke long values into a ragged right-aligned
block. Rows stay at the 44 px tap floor below md.

The Today overview e2e covers 1440, 390 and 360 px in both themes
for an AI lead, a long AI lead, an AI-off day and a quiet day, and
asserts the dial size, its place beside the lead, symmetric insets,
full-width facts and stacked 44 px rows on a phone.
2026-10-05 21:30:29 +02:00
Marc-André Bombeck ac48e0d0ed fix(coach): give a series read its numbers when the shared build cut its block
Inside a chat turn every tool read lands on the turn's shared full-source
snapshot when its window matches. On an account with many synced series
that build passes the prompt budget, and the budget pass collapses blocks
to a bare unit and then to an `{ omitted }` marker. get_metric_series
sliced the marker out and answered `present: true` with it, so the model
was told the readings exist and was given no figure. The same held for
sleep, glucose, compliance and workouts.

- The snapshot result names the blocks the budget pass cut. A tool whose
  block was cut in the shared build reads it again from its own source at
  the same window.
- A read scoped to one source condenses its own blocks last and never
  swaps them for the marker: it keeps a summary (first, last, min, max,
  mean, change) taken before anything is cut, the newest daily values,
  weekly means folded into monthly means, and names each cut.
- get_metric_series never answers present with a block holding no figure;
  that read reports retrieval_failed instead.
- A chat series read carries the readings it counted, so the step shows
  them, and on a long or condensed window a note on what it covers. The
  tool description and a new tool-mode rule tell the model to call
  get_metric_table itself for longer history instead of asking.
- The screen-reader comma between step parts is unselectable, so a copied
  row no longer reads "Weight · ,all time".

MCP output is unchanged: the source-snapshot equality suite stays green.
2026-10-05 21:29:04 +02:00
Marc-André Bombeck aa222f5b02 fix(daily): speak about today only from today's readings
A statement that says "today", or compares today against a baseline,
now only rests on a reading taken on the current calendar day in the
reader's zone. Yesterday morning's pulse read at nine in the evening
was narrated as today's: the signals of the day accepted any reading
from the last two weeks, and the Today overview, the coincident
deviation flag and the health status counted yesterday as current.

- computeSignalsOfDay drops a metric with no reading today, so the
  briefing prompt and its content hash never see a stale value as the
  day's; the prompt says so in both languages.
- The digest serves no cached briefing written on an earlier day and
  drops a reading-backed signal whose metric was not measured today,
  so the web hero, the iOS topSignal and the morning push agree.
- The Today overview's vitals line and deterministic lead, the
  coincident flag and the health status deviations use the new
  isFromToday rule (calendar day, not elapsed hours).

Signal deltas now carry each metric's display precision: the prompt
input is rounded (whole bpm and mmHg, one decimal of kg), and the
delta string under the hero lead is re-read at that precision in the
reader's locale ("+34 bpm", not "+33,72 bpm").
2026-10-05 21:22:57 +02:00
Marc-André Bombeck bed47387de fix(ui): show text-field focus as a neutral border, not a purple ring
Every text-entry control (Input, Textarea, Select and NativeSelect
triggers, the date and time fields, the calendar's dropdowns, the Coach
composer and the hand-built note fields) now marks focus by turning its
border the new neutral input-focus tone, about 5:1 on the dark
background and 4:1 on the light one, with no ring. A text field matches
:focus-visible on every mouse click, so the ring was on for everyone.
An invalid field keeps its red border and draws its destructive ring
only while focused. Buttons, links, checkboxes and switches keep their
rings.

text-field-focus-guard.test.ts fails when a text-field class string
brings a focus ring or a primary/ring-coloured focus border back.
2026-10-05 21:08:48 +02:00
Marc-André Bombeck b6ce54e2c6 fix(coach): drop the search field from the conversations panel
The panel lists the history grouped by recency with load-more, rename
and delete; title search stays on the full /coach/conversations page.
2026-10-05 21:05:13 +02:00
Marc-André Bombeck 6cea4be2a1 Merge pull request #1169 from MBombeck/feat/coach-panel-full-height
chore(release): v1.40.5
2026-10-05 20:05:49 +02:00
Marc-André Bombeck 315b653079 fix(coach): keep the docked panel header and new-chat button clear of the safe areas
A standalone PWA on a tablet reserves the status bar in the top bar but
not in the docked panel's header row, which could tuck its title and
icons under the status bar; the new-chat button sat without the home
indicator inset. Both now reserve the same insets as the top bar.
2026-10-05 19:47:50 +02:00
Marc-André Bombeck b01c193274 chore(release): v1.40.5 2026-10-05 18:46:44 +02:00
Marc-André Bombeck 61f065c651 feat(coach): run the conversations panel the full height of the window
The docked panel was part of the page, below the top bar. It is now a
column of the shell beside the content column, portalled through a new
side-panel slot: it runs from the top of the window to the bottom, flush
right, and the top bar ends at its left edge. Shut, the panel is gone
entirely and the top bar runs to the window's edge. The panel's header row
is the top bar's band, so the two bottom borders draw one line; the panel
takes the sidebar surface and border, like the rail on the left.

The top bar says where the reader is on desktop: "Coach", then the open
conversation's title, truncated. The panel toggle is its last item, 12 px
from its trailing edge, so it stands against the open panel and at the
window's edge when the panel is shut; it carries a tooltip and is 28 px
beside a fine pointer, 44 px with touch.

New chat is a round 48 px primary button with a plus, 24 px in from the
panel's corner (16 px in the phone sheet), with a tooltip. The top bar's
action slot and the new context and side-panel slots share one portal
helper.
2026-10-05 18:46:27 +02:00
Marc-André Bombeck 4909a94485 Merge pull request #1168 from MBombeck/feat/coach-panel-polish
chore(release): v1.40.4
2026-10-05 18:23:00 +02:00
Marc-André Bombeck 4ec9b58415 chore(release): v1.40.4 2026-10-05 17:53:27 +02:00
Marc-André Bombeck 3c59fc168e test(coach): document that a hidden stored view chip still resolves
Reading a stored reply back drops a view chip whose table the answer
already shows with its toggle, but the resolver reads the raw stored
chips. A tap from a client that still shows such a chip is answered, by
design: the chip was a valid offer when it was stored. The test pins both
halves for "as a table" and "as a chart".
2026-10-05 17:53:18 +02:00
Marc-André Bombeck 7533a3c5de test(shell): pin the full-bleed condition to exactly /coach
The condition moves out of the shell into isFullBleedPage(), a pure
helper with its own test: /coach renders edge to edge; /coach/plans,
/coach/conversations, a trailing slash, look-alike paths and other
routes keep the padded frame, and so does a refusal on /coach (outside a
shared record, module off).
2026-10-05 17:52:45 +02:00
Marc-André Bombeck b2c188bc46 fix(coach): keep the answer details open on Enter and Space
Focus opens the details tooltip, and the click that Enter or Space then
fires toggled it shut again. A click with no pointer-down before it is a
keyboard activation and now always opens; a tap or a mouse click still
toggles, and Escape closes it.
2026-10-05 17:52:14 +02:00
Marc-André Bombeck 11b6119482 refactor(shell): return the switched-off module instead of a flag
The flag helper sat between the module gate's doc comment and the
component, so the comment read as its own. It is now
switchedOffModule(), which returns the owning module when it is off and
has a doc comment of its own above the gate's; the gate narrows on its
result directly instead of looking the module up twice.
2026-10-05 17:51:32 +02:00
Marc-André Bombeck c133b4ac74 fix(coach): size the panel's controls by pointer, not viewport width
The panel header buttons shrank to 28 px from md and the conversation rows
to 36 px from sm, so a touch tablet wide enough to dock the panel got
pointer-sized targets. They now shrink only under pointer-fine: 28 px
header buttons, a 36 px gear in the drawer, 36 px rows and row menus
beside a mouse or trackpad, the 44 px floor with touch at any width.

A unit test refuses a width breakpoint on a control size in the three
panel files; an e2e case measures the docked panel at 1280 px with touch
and with a fine pointer.
2026-10-05 17:50:55 +02:00
Marc-André Bombeck 5aa3f3346d chore(release): v1.40.4 2026-10-05 17:46:26 +02:00
Marc-André Bombeck b0ac267af9 fix(coach): stop offering a view chip the result panel already toggles
A table the answer displays with a chart renders its own chart/table
toggle, and the "Show as a chart" / "Show as a table" chips under it
repeated that control. The server no longer derives a view chip for such
a table; a table the answer only used, folded under "Data used", still
gets "Show as a chart", since that brings it into the answer. A reuse
turn shows its table with the toggle and so offers no view chip either.

The rule is one predicate in a leaf module, applied where chips are
derived and where a stored reply is read back, so a reply stored before
the change offers the same chips as a new one on every client.
2026-10-05 17:41:20 +02:00
Marc-André Bombeck 0c3de5bdfb feat(coach): move an answer's model and tokens behind a details icon
The tokens and model no longer sit as right-aligned text under a Coach
answer. The row is one left-aligned group: copy, read aloud, try again, a
details icon, then the time. The details icon is a focusable button whose
tooltip names the model and the token count; it opens on hover, on
keyboard focus and on a tap, and a second tap, a tap elsewhere or Escape
closes it. Strings in all seven locales; the joined tokens-with-model
string is gone.
2026-10-05 17:29:28 +02:00
Marc-André Bombeck 3bede8230b fix(coach): mirror the panel toggle glyph and keep it beside the open panel
The toggle shows the panel-right open glyph while the panel is open and
the close glyph while it is shut, both mirrored so the chevron points the
way the panel moves on a click. Docked and open, the toggle keeps the
panel's width as its right margin, so it sits beside the panel's leading
edge and travels with it (200 ms, none under reduced motion). Labels and
aria-expanded are unchanged.
2026-10-05 17:25:24 +02:00
Marc-André Bombeck c49a04bac6 fix(coach): dock the conversations panel flush against the right edge
The Coach page cancelled the shell's container padding with negative
margins, so <main> kept its reserved scrollbar gutter and painted a dead
strip to the right of the panel on classic-scrollbar platforms. The shell
now renders /coach full bleed: no centred container, no padding and no
reserved gutter, and the page fills what <main> leaves below the top bar.
A refusal card (shared record, module off) keeps the padded frame.

The panel header takes one 12 px inset with a gap of 8 between title and
icons, a text-lg title, and 28 px icon buttons beside a pointer (44 px on
touch). Conversation rows take the control radius and a 36 px pointer
floor.
2026-10-05 17:24:49 +02:00
Marc-André Bombeck 854cd31e18 Merge pull request #1167 from MBombeck/fix/v1403-polish
chore(release): v1.40.3
2026-10-05 13:37:36 +02:00
Marc-André Bombeck 9158cf3e24 chore(release): v1.40.3 2026-10-05 13:12:18 +02:00
Marc-André Bombeck 22cb792379 fix(insights): read a same-hours day by its day mean
The same-hours standing averaged each day as sum / n. Pulse already folds
its day as the mean of its local hours' means, and the day-aggregate reader
returns that as dayMean; a type moved onto that reader would have been
compared raw. The standing now reads dayMean where the reader carries one.
2026-10-05 13:12:06 +02:00
Marc-André Bombeck abb1c9f8a9 fix(daily): count the ellipsis toward the lead budget
Text with no space inside the budget was cut at 160 characters and then
given an ellipsis, so the lead came out 161 long. The ellipsis now counts
toward the budget: a space at the edge still ends a whole word, and
unbroken text is cut one short.
2026-10-05 13:11:29 +02:00
Marc-André Bombeck 2a3bd67b68 fix(insights): cut glucose days at today's latest reading and say so
The same-hours comparison cut the earlier days at the current clock time.
At two in the afternoon a day holding only its fasting reading was then
held against mornings plus lunch, and read as below its range again. The
earlier days are now cut at the local time of today's latest reading.

A range read this way is the usual range for this time of day, not the
whole-day band other surfaces show. The deviation carries a basis, the
health-status API publishes it as `basis` (`day` or `sameHours`), and the
Today lead and the health-status card name the range as the one for this
time of day, in all seven locales.
2026-10-05 13:10:42 +02:00
Marc-André Bombeck 130ace74ee fix(crypto): keep updatedAt when key rotation re-seals a value
Key rotation rewrote every ciphertext column through a plain Prisma update,
in the in-app corpus walk and in the rotation script alike, so a rotation
stamped the run time on every encrypted row: the Coach panel then listed
old conversations as recent, and every client re-pulled measurements and
mood entries whose sync cursor had moved. Both now read each row's
updatedAt and write it back, matching on it, so a row edited since the
read is left for the next run instead of being stamped. The models that
carry the column are listed beside the registry and checked against the
schema, and the updatedAt guard now covers the rotation writes.
2026-10-05 13:07:22 +02:00
Marc-André Bombeck 7400852fb4 fix(daily): match a lead's metric by whole words
The terms that decide whether the Today lead already names the top
signal's metric were bare stems: "risen" and "chosen" matched the Polish
"sen" (sleep), "impulse" matched "puls", "Passing" matched the Italian
"passi", and the Korean "잠" matched "잠시" (a moment). A lead about
weight then dropped a sleep headline. Every term now has to start a word,
whole-word terms have to end one (Unicode-aware on both sides), and the
Korean terms carry their particle.
2026-10-05 13:02:04 +02:00
Marc-André Bombeck 25c34b0e7c chore(release): v1.40.3 2026-10-05 12:53:40 +02:00
Marc-André Bombeck 7836610358 fix(coach): move bumped conversation timestamps back to the last message
Earlier rewrites of conversation rows stamped updated_at with the run time,
so the Coach panel listed untouched conversations under Today. Migration
0375 moves updated_at back to the newest message's created_at where it is
more than a minute later. A rename or attachment shortly after the last
turn keeps its stamp, conversations without messages are left alone, and a
second run changes nothing. One set-based statement; the per-conversation
maximum reads from the (conversation_id, created_at) index.
2026-10-05 12:53:40 +02:00
Marc-André Bombeck 370fa10efa docs: describe the Today overview in the README hero alt text 2026-10-05 12:40:55 +02:00
Marc-André Bombeck a6d841d3ae fix(insights): compare a glucose day in progress with the same hours
Today's glucose mean was held against a band built from whole days. By
mid-morning the day holds a fasting reading and nothing after, so an
ordinary morning read as "below your range". A glucose day still in
progress is now compared with the earlier days cut at the same local clock
time, read in SQL so a dense stream is averaged whole rather than through
its newest fifty rows. With fewer than seven earlier days that have
readings in those hours, today gets no verdict. The day-aggregate reader
gains the local-clock cut; an integration case pins it against the
in-memory fold in a half-hour zone.
2026-10-05 12:40:55 +02:00
Marc-André Bombeck 7d42cf7122 fix(coach): keep updatedAt through storage-only rewrites
The title encryption backfill moved each Coach conversation's title into
ciphertext with a plain Prisma update, which stamps updatedAt. The Coach
panel orders and groups conversations by that column, so threads untouched
for months showed up under Today. The memory summary refresh did the same
in the background.

Storage-only rewrites now carry the row's own updatedAt and match on it, so
a turn that lands meanwhile wins and the row is retried on the next pass:
conversation titles, practitioner contacts, measurement and mood notes
(where updatedAt is the sync cursor), inventory notes, and the mood-note
script. The summary refresh writes through raw SQL. A guard freezes both
rules: every backfill write on a model with @updatedAt names the column or
is listed with the reason the stamp is wanted, and every conversation
write outside the person's own actions keeps it.
2026-10-05 12:40:55 +02:00
Marc-André Bombeck 3381026acb fix(insights): give a personal band a minimum width
When most days in the window carry the same value, the median ± k·MAD band
has no width: the coach strip read "Your usual range is 61–61 bpm" and the
next ordinary reading of 62 counted as outside it. The shared band builder
now keeps the edges at least the measurement's own day-to-day wobble apart
(per metric, in its canonical unit, falling back to one display step), so
every surface built on it reads a real range and classifies against it.
The reported spread stays the measured dispersion.
2026-10-05 12:40:55 +02:00
Marc-André Bombeck f5b63d33ab fix(charts): name every series by its localised metric label
The chart's label map covered eleven measurement types and fell back to
the raw enum for the rest, so a resting-pulse chart with its trend
overlays on showed "RESTING_HEART_RATE" in the trend line, the legend,
the tooltip and the spoken summary. Types outside the chart's short
labels now take their measurement label, which exists for every type in
every locale.
2026-10-05 12:40:55 +02:00
Marc-André Bombeck 524975d5f3 fix(daily): decide the line under the Today lead on the server
The muted line under the lead repeated what the lead had just said: a lead
about blood pressure sat above "Your latest blood pressure is sitting in
the optimal band · ↓ ~10 mmHg…". The web client only dropped the headline
when the lead quoted it word for word, and other clients had no rule at
all. The digest now carries `signalLine`, built next to the lead: when the
lead already talks about the top signal's metric (matched in every shipped
language) or says its headline, only the delta remains, and with no delta
there is no line. The hero renders the field verbatim.
2026-10-05 12:19:41 +02:00
Marc-André Bombeck 20085d5759 fix(daily): show the Today lead whole instead of cutting it mid-word
A lead past the 160-character budget was sliced at a character count, so
a long briefing sentence reached the hero as "…sleep landed r…". Model
text now leads only when it fits whole: the reaction line keeps the
complete sentences that fit, the briefing leads with its first sentence
when it fits, and otherwise the headline or the deterministic sentence
takes the slot. Only when no source fits is the first sentence shortened,
at a word boundary. The budget is unchanged, so the hero keeps its
height.
2026-10-05 12:16:40 +02:00
Marc-André Bombeck a9bddca998 Merge pull request #1166 from MBombeck/docs/readme-shots-1402
docs: refresh README screenshots from the 1.40.2 demo
2026-10-05 12:10:53 +02:00
Marc-André Bombeck 7306bcda5c Merge pull request #1165 from MBombeck/feat/demo-seed-v1402
fix(demo): seed the surfaces the start page and Coach now show
2026-10-05 12:05:51 +02:00
Marc-André Bombeck 021c8c0adc docs: refresh README screenshots from the 1.40.2 demo
Recapture the dashboard, Insights, resting heart rate, Medications and
Coach after a fresh demo seed. The dashboard image now shows the Today
overview and the worth-a-look rail; the Coach image shows the history
grouped by day and a full answer with its steps and action row. Frames
match the previous refresh (2552x1308; the dashboard keeps 2434x1247).
2026-10-05 11:43:44 +02:00
Marc-André Bombeck adb0e7c3b1 fix(demo): seal the Coach titles the way the app writes them
A readable title is moved to title_encrypted by the boot backfill, and
that update also sets updated_at to the moment it ran, so every seeded
thread sorted under "Today" a minute after the restart. The seed now
writes the ciphertext directly.
2026-10-05 11:39:15 +02:00
Marc-André Bombeck 25d486f956 fix(demo): end glucose at yesterday and steady the sleep average
The vitals check holds a day's glucose mean against the band of full
days, so a part day seeded up to the current hour read "below your
range". Spot readings and the CGM stream now end with yesterday.

The aggregate sleep walk drifted to almost nine hours, against which the
~7.5 h per-stage nights read as an hour short. A tighter walk keeps the
trailing average near the nights it is compared with.
2026-10-05 11:28:40 +02:00
Marc-André Bombeck 2b5d22e93d fix(demo): seed the surfaces the start page and Coach now show
The demo seed predates the Today overview, the grouped Coach history and
the procedure filter on documents, and it carried a few defects that a
fresh seed made visible:

- Store every Date as UTC. node-postgres serialises a Date in the host's
  zone and the cast to timestamp drops the offset, so a seed run from a
  machine on Berlin time wrote every instant two hours ahead.
- Keep today's later glucose readings, CGM samples and the evening mood
  entry out of the future.
- Close the seasonal allergy and its flare in the spring. Any open
  episode puts the account in Rest Mode, and the open one read
  "Rest mode, day 401" on the start page.
- Pin today's vitals to the median of the prior four weeks, so the
  Today overview does not report a random tail value as out of range.
- Confirm the key-backup step for the key the demo runs with, which
  removes the backup banner every visitor saw above the dashboard.
- Add two practitioners, a knee arthroscopy with its operative report
  and discharge letter linked (small generated PDFs), its follow-up and
  a check-up booked for tomorrow.
- Replace the single Coach exchange with five conversations from today
  back over a few weeks, the newest with a full answer and its steps.
- Drop the layout field `heroVisible`, which the layout type no longer
  has.
2026-10-05 11:23:28 +02:00
Marc-André Bombeck 643e1b3c25 Merge pull request #1163 from MBombeck/release/v1.40.2
chore(release): v1.40.2
2026-10-05 10:03:51 +02:00
Marc-André Bombeck fd895bfd5f chore(release): v1.40.2 2026-10-05 08:44:10 +02:00
Marc-André Bombeck f42a28b7f6 chore(bundle): re-measure the aggregate client budget for the Coach redesign and the Today overview 2026-10-05 08:43:46 +02:00
Marc-André Bombeck a1ec3b1264 Merge branch 'fix/v1402-review' into release/v1.40.2 2026-10-05 08:42:28 +02:00
Marc-André Bombeck 4db7fc5113 fix(coach): open the deep-linked sheet only once the viewport is known
/coach?settings=data opened the conversations sheet from the first
render. The server and hydration renders do not know the viewport and
take the narrow layout, so on a desktop the sheet was open until the
docked panel replaced it; only the batching of the hydration re-render
kept it off the screen. The deep link now opens the sheet only once
hydrated on a viewport that does not dock the panel; on a docked one the
panel is revealed as before.

The desktop deep-link spec records every mount of the sheet from the
first byte and expects none; a phone deep-link spec covers the sheet
path and serves as the watcher's positive control.
2026-10-05 08:34:28 +02:00
Marc-André Bombeck 4bb03600b3 perf(dashboard): cache today's cycle read and run the vitals reads together
The digest is polled every 120 seconds. Its cycle line read the whole
cycle history, 90 days of day logs and wrist temperature on every poll,
outside any cache, and the coincident-deviation engine walked its eleven
vitals one after another, two queries each.

The cycle read now rides the digest extras cell, which every cycle write
already hard-evicts; the cycle preferences route evicts it too, and a
module switch does through the health-score invalidator. A record without
a cycle profile stops after one point read. The per-vital reads run
together and are folded in registry order, so the result does not depend
on which finished first.

On a seeded four-year account (1.25M measurements, 52 cycles) under a
792 MB heap, the warm digest went from 9.9 to 5.0 ms and the vitals engine
from 11.1 to 4.6 ms (medians of four interleaved rounds).
2026-10-05 08:34:28 +02:00
Marc-André Bombeck bfcf71b7eb fix(dashboard): keep numbers out of the morning push line
The digest line is the body of the morning push and lands on a locked
phone. It took the briefing's first sentence, the top signal's headline
or the deterministic signal sentence verbatim, so a resting heart rate or
a blood pressure average could sit on the lock screen, and its score
floor stated the score.

A candidate with a figure in it is now passed over for the next, and the
floor says the score is ready instead of what it is. The hero's lead in
the app keeps its numbers. Tests that pinned the numeric line are turned
around, and the push builder is checked on a digest whose lead carries a
reading.
2026-10-05 08:34:28 +02:00
Marc-André Bombeck bd8e05e484 fix(dashboard): drop a lead sentence only when it repeats the score as the score
The lead filter dropped any sentence containing the score's digits, so a
score of 52 threw out "You slept 7 h 52 min" and a resting heart rate of
52 bpm. A sentence now repeats the ring only when it names the score (in
any shipped language, or as "out of 100") and carries the number on its
own, not as part of a time, a decimal or a measurement with a unit.
2026-10-05 08:34:27 +02:00
Marc-André Bombeck 9e647051d5 fix(dashboard): tell a steady score only when its band held, and say at least at the read's edge
The steady line compared the newest stored score with the number on
screen but not with its band, version or composition, so a score whose
band moved overnight at the same number was still called steady. A run
that reached back past the 120 days read was also told as if it started
there.

The newest stored day now has to match the ring in value, band and,
where the digest carries them, algorithm version and composition. A run
the read cannot see the start of is marked as open (steadyAtLeast on the
digest score) and the hero says "at least" in seven locales.
2026-10-05 08:34:27 +02:00
Marc-André Bombeck 13ac27c22d docs(coach): say that older answers may still quote older values
The lookback limit governs what the Coach reads from now on. Answers it
gave earlier in an older conversation stay as written, so the note under
the limit now says they may still quote values from before it.
2026-10-05 08:34:27 +02:00
Marc-André Bombeck 87cf32bc67 fix(coach): leave derived scores out when their window does not fit the limit
The derived block shortened each engine's window to the lookback limit,
so a 7-day limit produced a readiness computed over 7 days instead of the
30 the ring on the dashboard uses, under the same name. A block whose own
window does not fit is now left out, as the lookback rule says, and the
coincident-deviation flag follows its 30-day baseline the same way.
2026-10-05 08:34:27 +02:00
Marc-André Bombeck 8dc34c7691 fix(coach): keep GLP-1 dose changes before the lookback limit out
The GLP-1 block sent every dose change with its decrypted note whatever
the lookback limit said. Dose changes are entries, so only those inside
the limit reach the prompt; the dose in force stays as the current dose.
2026-10-05 08:34:27 +02:00
Marc-André Bombeck 308e154978 fix(coach): move focus to the composer before hiding the jump button
Jumping to the latest message pinned the thread first, which hides the
button behind aria-hidden, and only then moved focus to the composer.
Focus now leaves the button before its state changes.
2026-10-05 08:34:27 +02:00
Marc-André Bombeck 3b283507b5 fix(dashboard): lead with the stored lines for a digest cached before the lead
The hero reads only digest.lead. A digest the service worker cached
before the update carries no such field, so the hero lost its lead line
until the next fetch. Such a digest now falls back to the reaction line
or the briefing lead it was built to show; a current digest whose lead
is null stays silent.
2026-10-05 08:34:27 +02:00
Marc-André Bombeck 94e0da7953 chore(i18n): drop the orphaned Coach window labels
The insights.coach.window labels lost their last caller with the source
chips. No t() call, literal or templated, reads them in any locale.
2026-10-05 08:34:27 +02:00
Marc-André Bombeck 35edc33871 fix(coach): lock the reasoning select while a change saves
The provider and model selects in the Coach's settings already lock while
a write is in flight; the reasoning select did not, so a second PATCH
could race the first and one change could be lost.
2026-10-05 08:34:27 +02:00
Marc-André Bombeck eda0b5831d feat(settings): summarise what the Coach sees beside the link to its settings
Settings → Coach pointed at the gear with a muted paragraph. It now says
how many data areas the Coach reads and how far back it looks, from the
saved preferences, next to an Open Coach settings button that lands on
that section.
2026-10-05 08:11:10 +02:00
Marc-André Bombeck 816269b3ed fix(documents): match the filter triggers to the search field's height
The facet triggers and the clear control were 32 px beside a 44/40 px
search field, below the phone tap floor. They now share its height and
reach 44 px square on a phone, and the browser's own clear button no
longer sits on the search icon when the field shrinks.
2026-10-05 08:11:10 +02:00
Marc-André Bombeck 88406f8e66 fix(dashboard): give the Today overview room under the lead and phone-sized rows
The overview sat 12 px under the lead sentence; it now keeps a section's
distance (16 px, 24 px from md). On a phone each fact row is a link and
now meets the 44 px tap floor.
2026-10-05 08:11:10 +02:00
Marc-André Bombeck 150ee7695c fix(coach): lift toasts clear of the panel's New chat button
Bottom toasts landed on the round New chat button for their whole
lifetime, so the delete Undo blocked the next action. While the
conversations panel is open, docked or as a sheet, bottom toasts rise by
the button's footprint.
2026-10-05 08:11:09 +02:00
Marc-André Bombeck 07f4961f27 fix(coach): label the quick-settings fields alike and give the lookback full width
The model fields carried the form label's colon and the lookback picker
an uppercase section-style label and a fit-content 36 px trigger. All four
fields now share one unadorned label and the full-width 44/40 px control.
2026-10-05 08:11:09 +02:00
Marc-André Bombeck d0b680c277 fix(dashboard): count the last hour of a 25-hour day in today's doses
buildMedsTodayBlock ended the day at local midnight plus 24 hours. On the
day the clocks go back that stops an hour early, so a dose between 23:00
and midnight was neither projected nor counted in the doses tally. The
exclusive end is now the next local day's start, as the digest loader
already computes it.
2026-10-05 07:48:58 +02:00
Marc-André Bombeck 169c7bdfe6 fix(coach): salvage Coach preferences field by field
parseCoachPrefs parsed the stored blob as a whole and returned the
defaults on any invalid field. A lookback value this version does not
know therefore dropped the metric exclusions with it. Each field now
falls back on its own, and the exclusion and cluster lists keep their
known entries, so drift narrows what the Coach reads instead of widening
it.

The deploy runbook's rollback section notes the one case this cannot
cover: v1.40.1 and earlier still parse the blob whole, so an account that
saved the new "Last year" lookback loses its exclusions there.
2026-10-05 07:48:37 +02:00
Marc-André Bombeck 1158ae9ffd fix(coach): keep earlier tables beyond the lookback limit out of the context
The earlier-tables block names each stored table with its window and row
count. The list was filtered by module and exclusion only, so a table
stored before the limit was set or narrowed still told the model how much
history it covered. It now answers to the same range test show_result
uses, and the history-reach guard checks both the filter and that the
call site passes the limit.
2026-10-05 07:47:31 +02:00
Marc-André Bombeck 0c1dd1e967 fix(coach): show the operator's central Codex when it answers
The operator's shared Codex is appended to the resolved chain but is never
a chain entry, so the Coach's provider select had no option for it and
showed its first option as the one answering. The answering provider now
appears as a disabled option, with a note that the operator manages the
connection.
2026-10-05 07:46:29 +02:00
Marc-André Bombeck 2a556b47c3 fix(coach): offer only providers that can answer and say when a switch does not take
The provider-chain summary marked every entry available, so the Coach's
provider select offered entries without a key or sign-in. Choosing one
saved it to the front of the chain, the resolver skipped it, and the
select jumped back without a word.

Availability now comes from the presence probe, which names every chain
type whose credential is present. After a switch the picker reads the
chain back and says so when the server still answers with another
provider.
2026-10-05 07:45:05 +02:00
Marc-André Bombeck a2ef2eec24 test(e2e): scan the sources rail with the rest of the Coach frame
The rail's switched-off rows now meet AA contrast, so the exclusion the
frame's accessibility scan carried for them is gone.
2026-10-05 07:32:01 +02:00
Marc-André Bombeck 359203d09a Merge remote-tracking branch 'origin/feat/coach-lookback-limit' into release/v1.40.2 2026-10-05 07:31:34 +02:00
Marc-André Bombeck 929c0ebaa4 Merge remote-tracking branch 'origin/main' into feat/coach-lookback-limit
# Conflicts:
#	src/lib/ai/coach/snapshot.ts
#	src/lib/ai/coach/tools/executor.ts
#	src/lib/insights/features.ts
2026-10-05 07:31:03 +02:00
Marc-André Bombeck 03c43ef70a Merge remote-tracking branch 'origin/feat/documents-procedure-filter' into release/v1.40.2 2026-10-05 07:17:17 +02:00
Marc-André Bombeck 98ac9c3d80 Merge remote-tracking branch 'origin/feat/dashboard-today-overview' into release/v1.40.2 2026-10-05 07:17:17 +02:00
Marc-André Bombeck 0d6bac8c5c Merge remote-tracking branch 'origin/feat/coach-answers' into release/v1.40.2
# Conflicts:
#	messages/de.json
#	messages/en.json
#	messages/es.json
#	messages/fr.json
#	messages/it.json
#	messages/ko.json
#	messages/pl.json
2026-10-05 07:16:55 +02:00
Marc-André Bombeck 57a889ef3c Merge remote-tracking branch 'origin/feat/coach-frame' into release/v1.40.2 2026-10-05 07:16:09 +02:00
Marc-André Bombeck 324e59914e Merge pull request #1161 from MBombeck/release/v1.40.1
chore(release): v1.40.1
2026-10-05 00:06:19 +02:00
Marc-André Bombeck 4769502a4b fix(backup): check the weekly copy's preview against the schema section by section
Taking the weekly preview from the writer dropped the schema check the
old scanner ran at the end, so a copy that violates the schema was
described as restorable. The collector now checks each section in its
JSON form as the writer shows it, an array section and a bulk table one
element at a time, plus the schema's one whole-document rule (a
canonical copy names every measurement by id). A copy that fails gets
no stored preview, and the preview route reads the copy itself, as it
did before. Nothing is kept beyond one element.

Measured on the preview memory test under --max-old-space-size=524:
held after the producer 3 MB with and without the check, raw peak
heapUsed 293 MB with it (335 MB without). The test now loads the
backup schema before its baseline: a module loaded once per process,
not memory that grows with the record.
2026-10-04 22:17:27 +02:00
Marc-André Bombeck 48ed149ef0 fix(import): leave out a marked Apple Health sample only when this account holds it
A sample carrying the HealthLog origin marker was always dropped from an
export import. After a move to a new instance without a backup, the
export is how the mirrored values come back, and every one of them was
silently left out. A marked sample now counts as written by HealthLog
only when its HKExternalUUID names a row of this account of the same
kind, deleted rows included; a marked cycle sample only when the
account holds a day-log on its day. Every other marked sample is
imported, and still meets the manual-value match. A correlation's own
HKExternalUUID now covers records that carry none.
2026-10-04 22:15:48 +02:00
Marc-André Bombeck 090e3d9a7e perf(measurements): weigh pulse days with window functions over one scan
The day weight joined its source to an hour grouping of itself. With
stale or missing planner statistics, as after a new account imports a
year of pulse below the autoanalyze threshold, the planner ran that
join as a nested loop and the all-time pulse aggregate ran into the
60 s statement timeout. The weight is now two window passes over one
scan: the readings in a row's hour, and one marker per hour summed over
the day for the hour count. The weights are unchanged.

A new integration test seeds over 100k pulse rows with autovacuum off
and no ANALYZE, bounds the all-time reader and a zoned per-source mean
well under the timeout, and compares every row's weight against the
earlier self-join after ANALYZE. The ANALYZE the metric-series snapshot
test needed for the same trap is gone.
2026-10-04 22:14:14 +02:00
Marc-André Bombeck 0219a85e92 feat(documents): filter the archive by procedure or surgery
The vault's filter row gains a procedure facet beside the condition
facet. It lists the record's procedures and surgeries that hold at least
one live document, headed the way the visits page heads them (the
reason, else the practice, else the kind) with the body site and date
beneath, and narrows the list through the existing `encounterId` filter.
Same control, placement and clear behaviour as the condition facet; the
choice rides the URL as `?encounter=`, and an upload dropped into the
filtered view is filed against the procedure.

The usage read publishes the choices as `linkedProcedures` (additive),
decrypting only the label columns, and returns none when a sharing
grant does not cover the visits section.

On a phone an active facet now gives up label width before the search
field does, and the search keeps room for its icon and padding.
2026-10-04 21:59:22 +02:00
Marc-André Bombeck 3681b23d30 test(e2e): mount every overview section before asserting none paints
The AI-optional overview and account journeys asserted zero error cards,
Coach hand-offs and briefings without scrolling the deferred sections
into view, so the sections below the first three never mounted and the
zero counts held whatever they would paint. Both journeys now reveal
every section and wait for the reads those sections fire to land;
waiting on networkidle again resolves at once, because the page reached
that state before.
2026-10-04 21:36:25 +02:00
Marc-André Bombeck 2bbcce73e4 docs: name the memory cap on small hosts and the shared connection pool
Getting started names the APP_MEMORY_LIMIT default and how to lower it
on a 2 GB host; the scaling guide says that web and worker share one
pool in the bundled single-container setup. The rollup comment names
migration 0372, which carries the hourly-mean statistic.
2026-10-04 21:25:28 +02:00
Marc-André Bombeck da56177290 fix(backup): take an interrupted account last for one run only
An account whose last backup attempt never came back was kept behind
every other account for as long as its attempt stayed open. A pass that
regularly stops on its time budget never reached it, so it never got a
copy again. It is now held back only until a later run has started on
another account; if its record kills the process again, its attempt is
the newest once more and it goes last again. A crash loop still leaves
the other accounts their copies, and the account itself is attempted at
least every second run.
2026-10-04 21:21:32 +02:00
Marc-André Bombeck 4261139dfa fix(backup): run the restore drill as a locked long pass
The drill now reads every account's newest off-host object but still ran
on the default fifteen-minute expiry, without a lock and with two
retries, so a large instance could see it killed, run beside itself, and
page every failed account three times. It now carries a four-hour expiry,
stops between accounts on its budget and reports the objects it did not
reach, holds a pass lock at the binding, and is not retried.
2026-10-04 21:19:32 +02:00
Marc-André Bombeck 4be7109a2b fix(medications): refetch an invalidated prefetch on mount
A hover prefetch excused the mount refetch even after a dose logged
elsewhere had invalidated the medication reads, so the page showed the
compliance from before the dose. An invalidated entry now always
refetches.
2026-10-04 21:13:28 +02:00
Marc-André Bombeck 5e5e387fab chore(release): v1.40.1 2026-10-04 20:55:14 +02:00
Marc-André Bombeck dc7061634e Merge pull request #1152 from MBombeck/dependabot/npm_and_yarn/p-limit-7.3.3
chore(deps): bump p-limit from 7.3.1 to 7.3.3
2026-10-04 20:52:41 +02:00
Marc-André Bombeck 92b4a9c103 Merge pull request #1149 from MBombeck/dependabot/npm_and_yarn/testcontainers-12.2.0
chore(deps-dev): bump testcontainers from 12.1.0 to 12.2.0
2026-10-04 20:52:26 +02:00
Marc-André Bombeck 31b7fd0a45 Merge pull request #1148 from MBombeck/dependabot/npm_and_yarn/zod-openapi-6.0.2
chore(deps): bump zod-openapi from 6.0.1 to 6.0.2
2026-10-04 20:52:11 +02:00
Marc-André Bombeck 10e34bcab6 Merge pull request #1150 from MBombeck/dependabot/npm_and_yarn/eslint-config-next-16.3.8
chore(deps-dev): bump eslint-config-next from 16.3.6 to 16.3.8
2026-10-04 20:51:56 +02:00
Marc-André Bombeck 5e267e2ddf Merge pull request #1151 from MBombeck/dependabot/npm_and_yarn/next-16.3.8
chore(deps): bump next from 16.3.6 to 16.3.8
2026-10-04 20:51:40 +02:00
Marc-André Bombeck d23595f924 Merge pull request #1153 from MBombeck/perf/mcp-metrics-slim
perf: single-metric MCP reads from the metric's own rows; load the backup schema on first use
2026-10-04 20:51:28 +02:00
Marc-André Bombeck 393ac955f6 Make the Coach lookback a limit on every read
The saved Coach window only seeded the snapshot and each tool's default.
A tool could still read back to all time, the availability probe reported
counts and means for history outside the window, and several blocks read
fixed windows of their own (labs 12 months, correlations 180 days, the
coarse tail, the all-time extremes) whatever the setting said.

coachPrefs.defaultWindow is now enforced as a limit through one module,
history-reach.ts: the turn context clamps the client window, the snapshot
clamps its window and gives every fixed-window block either the shorter
window or, when the block's own window cannot fit, leaves it out. Every
retrieval tool clamps its window, an earlier period or a stored table that
reaches past the limit answers outside_reach, and the probe reads only
inside the limit and names older history as outside_reach without any
figure. Follow-up chips that would read past the limit are not offered.
The default (allTime) is no limit and reads as before. MCP clients pass no
limit and keep their own window arguments.

Adds the twelve-month option, rewrites the picker text to state what is
read with the numbers the readers use, shows the same options in the
managed-profile settings, counts cluster members with a singular, and
drops the opacity on switched-off rows that put their text below WCAG AA.
2026-10-04 20:45:21 +02:00
Marc-André Bombeck 59311fe401 Merge remote-tracking branch 'origin/main' into perf/mcp-metrics-slim 2026-10-04 20:08:25 +02:00
Marc-André Bombeck d07f5796d7 Seed the demo Coach answer with valid metric tokens, steps and token meta 2026-10-04 20:02:49 +02:00
Marc-André Bombeck d1849eed16 Measure the Coach answer layout at 1440 and 390 px 2026-10-04 20:02:49 +02:00
Marc-André Bombeck a91e1bf0a7 Give each Coach answer one action row and keep its chips in its column
Under an answer: copy, read aloud, try again and the time as text on the
left; tokens and model on the right, on one line down to 390 px. The
thumbs leave the web (the feedback route stays for the native client),
and so does the "What I'm looking at" disclosure: the method line and
the tables the answer read move into the steps list, and an older
message without steps lists the areas it drew on, never a raw key.

Remember becomes a bookmark icon in the question's row and confirms in
place with a toast. Follow-up chips render inside the latest answer's
column at its text size. The answer column takes the full width below
`sm` and a fixed 80 % above, so nothing moves sideways as a reply
streams. A round button fades in when the thread is scrolled away from
the end and brings it back.
2026-10-04 20:02:48 +02:00
Marc-André Bombeck 5cfacba16a feat(dashboard): add a Today overview to the hero
The hero led with the first sentence of the cached briefing, which was
often a greeting ("Good morning.") while the sentence with content was
never shown. Without an AI provider it fell back to "Your health score
today is N.", which the hero then removed because the ring already
shows the number, leaving a ring and "Nothing needs your attention".

The daily digest now publishes the lead resolved on the server and a
short list of facts about the day:

- lead: the reaction line or the first briefing sentence that is not a
  greeting and does not repeat the score while AI text is available,
  otherwise a deterministic sentence about the strongest signal (a vital
  outside its personal range, a night an hour off the usual, vitals in
  range, last night). A fact the lead was built from leaves the list.
- today: up to five statements in priority order (Rest Mode, doses taken
  today, an appointment today or tomorrow that is not already on the
  rail, last night's sleep against the usual, vitals against their
  personal range, cycle day). Module-gated on the server, never padded,
  never asking for anything. A phone shows the first four.
- restMode: passed through while an illness episode is active.
- score.steadyWeeks: how long the score has held, read off the stored
  daily scores, shown under the ring in place of a delta.

briefingLead skips greetings and score sentences the same way, so the
morning push improves with it. Visit times are rendered in the profile
timezone and hour-cycle preference; the cycle day comes from the same
verdict the cycle page uses. The ring, the rail and the charts are
unchanged. OpenAPI regenerated; additive fields only.
2026-10-04 20:00:18 +02:00
Marc-André Bombeck b8d31f945d test(e2e): cover the Coach page frame at 1440, 1280 and 390
Measures that the thread and composer keep the same width with the panel
open and closed, that the choice survives a reload, Escape and focus
return for the panel, the sheet and the settings overlay, rename and
delete with undo from the row menu, the settings deep link, and axe on the
panel and the overlay in both themes. Screenshots are attached to the
report.
2026-10-04 20:00:07 +02:00
Marc-André Bombeck 12153fe143 feat(coach): move conversations into a collapsible right panel
The Coach page gets a conversations panel on the right of the thread. From
1280 px it docks beside the thread and slides open and shut; the reading
column keeps its width either way and only its centring moves. It is open
on a first visit and remembers the last choice per device. Below 1280 px it
is a sheet from the right. The toggle sits at the trailing edge of the top
bar, through a small page-owned slot the top bar now renders.

The panel lists every conversation grouped by recency, with a row menu to
rename in place (Enter saves, Escape cancels without closing the panel) and
delete with the existing undo toast. The undo window now survives the sheet
closing. New chat is the round button at the panel's bottom right, and
picking or starting a conversation keeps ?c= in the URL.

The gear in the panel header opens quick settings: a popover from md up, a
bottom sheet on a phone. It switches the answering provider (chain reordered
through the existing route), the model and reasoning level where the person
owns them, and shows what the Coach can see. Keys, the Codex sign-in and the
fallback chain stay in Settings > AI. The drawer's gear opens the same
overlay. The composer's + menu is gone.

Settings > Coach links to /coach?settings=data, which opens the overlay on
what the Coach can see instead of a rail that no longer exists.
2026-10-04 20:00:07 +02:00
dependabot[bot] 8fd58e9824 chore(deps): bump next from 16.3.6 to 16.3.8
Bumps [next](https://github.com/vercel/next.js) from 16.3.6 to 16.3.8.
- [Release notes](https://github.com/vercel/next.js/releases)
- [Commits](https://github.com/vercel/next.js/compare/v16.3.6...v16.3.8)

---
updated-dependencies:
- dependency-name: next
  dependency-version: 16.3.8
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-04 17:33:27 +00:00
dependabot[bot] c064210a34 chore(deps): bump p-limit from 7.3.1 to 7.3.3
Bumps [p-limit](https://github.com/sindresorhus/p-limit) from 7.3.1 to 7.3.3.
- [Release notes](https://github.com/sindresorhus/p-limit/releases)
- [Commits](https://github.com/sindresorhus/p-limit/compare/v7.3.1...v7.3.3)

---
updated-dependencies:
- dependency-name: p-limit
  dependency-version: 7.3.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-04 17:33:00 +00:00
dependabot[bot] ddb5c6462a chore(deps-dev): bump eslint-config-next from 16.3.6 to 16.3.8
Bumps [eslint-config-next](https://github.com/vercel/next.js/tree/HEAD/packages/eslint-config-next) from 16.3.6 to 16.3.8.
- [Release notes](https://github.com/vercel/next.js/releases)
- [Commits](https://github.com/vercel/next.js/commits/v16.3.8/packages/eslint-config-next)

---
updated-dependencies:
- dependency-name: eslint-config-next
  dependency-version: 16.3.8
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-04 17:32:08 +00:00
dependabot[bot] 10d5b7334e chore(deps): bump zod-openapi from 6.0.1 to 6.0.2
Bumps [zod-openapi](https://github.com/samchungy/zod-openapi) from 6.0.1 to 6.0.2.
- [Release notes](https://github.com/samchungy/zod-openapi/releases)
- [Changelog](https://github.com/samchungy/zod-openapi/blob/master/CHANGELOG.md)
- [Commits](https://github.com/samchungy/zod-openapi/compare/zod-openapi@6.0.1...zod-openapi@6.0.2)

---
updated-dependencies:
- dependency-name: zod-openapi
  dependency-version: 6.0.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-04 17:31:50 +00:00
dependabot[bot] d64cbbaebf chore(deps-dev): bump testcontainers from 12.1.0 to 12.2.0
Bumps [testcontainers](https://github.com/testcontainers/testcontainers-node) from 12.1.0 to 12.2.0.
- [Release notes](https://github.com/testcontainers/testcontainers-node/releases)
- [Commits](https://github.com/testcontainers/testcontainers-node/compare/v12.1.0...v12.2.0)

---
updated-dependencies:
- dependency-name: testcontainers
  dependency-version: 12.2.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-04 17:31:43 +00:00
Marc-André Bombeck 3be83eee22 Merge pull request #1155 from MBombeck/test/zone-offset-reference-cache
test(tz): reuse one reference formatter per zone in the offset parity test
2026-10-04 19:29:19 +02:00
Marc-André Bombeck 016278ba24 test(mcp): analyze the seeded rows before the metric-series golden test
The first case spent 14 s per full-snapshot build in the all-time pulse
aggregate, planned against a table with no statistics, until autovacuum
analyzed it; on a loaded runner that took it past its 60 s budget. Analyze
measurements and rollups after seeding, as a production table would be.
The cases and the comparison they make are unchanged.
2026-10-04 19:21:01 +02:00
Marc-André Bombeck 528ecfbcdc test(tz): reuse one reference formatter per zone in the offset parity test
The parity test built a fresh Intl.DateTimeFormat for each of ~36k
comparisons and crossed the 5 s timeout on CI runners. One formatter per
zone halves its time; it also gets an explicit 20 s budget, since it is
meant to be exhaustive.
2026-10-04 19:07:54 +02:00
Marc-André Bombeck 13ea849188 perf(backup): load the backup payload schema on first use
backupPayloadSchema is built when validations/backup.ts is evaluated and
holds about 12 MB of heap. The restore job, the stored-backup readers and
the export writer import that module, and they sit on the boot path, so
every server process built the schema at start whether or not it ever
touched a backup.

The schema version, the compatibility check and the per-section summary
move to validations/backup-summary.ts, which never builds the schema. The
call sites that validate a file (upload, restore, the summary and download
routes, and the streamed reader's element check) load backup.ts with a
dynamic import at that moment; everything else takes only types. The
OpenAPI registry keeps its static import, since only the spec generator
loads it.

After warm-up on a production build under --max-old-space-size=524, the
used heap drops by about 29 MB, and the schema no longer appears in a heap
snapshot. A structural test refuses a new static value import of the
module outside the registry.
2026-10-04 18:23:14 +02:00
Marc-André Bombeck 546e67475f perf(mcp): build single-metric reads from the metric's own rows
get_metric_series (and get_metrics, the metric resources and the review
prompts, which fan out over it) answered with one section of a Coach
snapshot, but built the whole snapshot to get it: the all-type feature
extraction, the derived wellness layer, trajectory, correlations, memory,
GLP-1, cycle, illness, labs and visits. On a seeded account with about
215k measurements a resting heart rate read ran 208 SQL statements and
blocked the event loop for 175 ms to return under 2 KB.

The MCP surface now asks the executor for a source snapshot. It runs the
same prelude (prefs, module gates, admitted sources, cutoffs, now shared in
snapshot-prelude.ts) and the same block builders over the requested
source's reads only. The core clinical aggregates come from extractFeatures
scoped to the one block, whose bulk read keeps its all-type cap so the rows
a block sees do not change. A section large enough for the full builder's
prompt-budget pass to trim, and the environment cluster, go to the full
builder, so their results are the old ones by construction.

The integration test compares the tool's serialised result with the full
builder's section and grounding for nine sources over four windows, the
get_metrics batch and the metric resource, on a seeded account dense enough
to put the feature row cap into play.
2026-10-04 18:17:24 +02:00
Marc-André Bombeck 1cb50ed21d Merge pull request #1144 from MBombeck/fix/restore-key-probe
fix(backup): restore opens legacy values, drill checks every account, backups hold less memory
2026-10-04 17:44:57 +02:00
Marc-André Bombeck 72ca843fd5 Merge pull request #1147 from MBombeck/perf/dashboard-server
perf(server): one Prisma client, indexed coverage probe, memoised timezone offsets
2026-10-04 17:33:57 +02:00
Marc-André Bombeck 77fece0fa5 Merge pull request #1146 from MBombeck/chore/app-memory-default
chore(compose): default the app to 1536m so Node gets a 792 MB heap
2026-10-04 17:33:54 +02:00
Marc-André Bombeck 006679ed9e Merge pull request #1145 from MBombeck/perf/dashboard-client
perf: faster charts and insights, no sidebar scrollbar flash, Umami events delivered
2026-10-04 17:33:51 +02:00
Marc-André Bombeck 92f17c8ca4 Merge pull request #1140 from MBombeck/ready/pr-1131
fix(import): leave out Apple Health samples HealthLog wrote itself
2026-10-04 17:33:47 +02:00
Marc-André Bombeck 45fafdf096 Merge pull request #1138 from MBombeck/feat/pulse-hourly-mean-complete
fix(pulse): a day of pulse is the mean of its hours' means, everywhere
2026-10-04 17:33:43 +02:00
Marc-André Bombeck 6920694912 chore(compose): give the app 1536m by default so Node gets a 792 MB heap
Node sizes its heap from the container's memory cap. The 1g default gave
524 MB, which a long-used account fills in normal operation: on a live
instance 490 to 507 MB stayed live after a full GC, garbage collection
stalled requests for up to 8.5 s, and the process ran out of heap 26 times
in a week, taking the weekly backup with it. 1536m gives 792 MB. The
example env and the backup runbook say how to lower it on a small host.
2026-10-04 16:38:10 +02:00
Marc-André Bombeck 6c0a606bf3 fix(a11y): give the loading score and vitals grids a role to name
Both grids carry an aria-label while their data loads, on a plain div,
which axe reports as aria-prohibited-attr: the label has no role to
name. A scan that lands while the scores strip is still loading fails on
it. The loading grids now take role=status, the role a loading region
has and which already implies the polite live region; the loaded vitals
grid carries neither.
2026-10-04 16:24:37 +02:00
Marc-André Bombeck 4916a29bbe perf(insights): mount below-the-fold overview sections near the viewport
Entering the Insights overview fired every section's reads at once, about
two dozen requests on a seeded account, and the server worked through
them in a queue: the hero and the first sections waited behind trends,
the period review, cycle, signals, rhythm events, health status,
breathing, labs and ECG, all far below the fold.

The first three sections in the account's own order still mount with
the page. Every later one renders a 1 px sentinel until it comes within
400 px of the visible area of the shell's scroll container, then mounts
and fetches; once mounted it stays. Measured on a seeded account under a
4x CPU slowdown: 24 -> 13 requests on entry, the entry network span
1.3-1.8 s -> 0.5-0.9 s, LCP about 1.35 s -> 0.9 s, blocking time about
550 ms -> 200 ms. Scrolling to the end still loads all 25 and renders
all ten sections.

Specs that assert on, measure or scan the lower sections scroll them in
first through a shared helper, so their coverage is unchanged.
2026-10-04 16:24:37 +02:00
Marc-André Bombeck fa48110875 fix(nav): mark the scroll edge when the sidebar list overflows
On a short window (around 785 px and below) the nav list is taller than
the room between the logo band and the footer group, so it scrolls. With
nothing marking the edge, the last visible entry was cut off right at
the Collapse row and read as the footer covering it; macOS hides the
scrollbar that would have said otherwise.

While the list overflows, the footer group draws the same hairline the
user section does. A ResizeObserver on the nav and its list keeps the
state current as the window or the entries change; with room to spare
the rail is unchanged. The e2e spec checks the edge, that the last entry
scrolls fully clear of the footer, and that a tall window draws no edge.
2026-10-04 16:24:37 +02:00
Marc-André Bombeck 194712c916 Merge pull request #1143 from MBombeck/fix/rest-mode-date
fix(insights): show the Rest Mode onset as a date
2026-10-04 16:15:03 +02:00
Marc-André Bombeck ef14f539de Merge pull request #1142 from MBombeck/ready/pr-1134
fix(series): follow the source priority ladder in the blood-pressure series
2026-10-04 16:15:00 +02:00
Marc-André Bombeck 63a5304e43 Merge pull request #1141 from MBombeck/ready/pr-1132
feat(labs): let the lab list's range bar use a wide card
2026-10-04 16:14:56 +02:00
Marc-André Bombeck dc9b0e3936 test(import): read the note's text without a tag-stripping replace 2026-10-04 16:14:47 +02:00
Marc-André Bombeck 28046f05f7 test(measurements): group the hourly-mean fake once instead of per day
The in-memory day-aggregate fake re-filtered every row for every day,
converting each timestamp to the profile timezone again, so a 1000-day
series took about five seconds and the graded pulse-status test hit its
timeout. It now groups by day and segment in one pass (4.8 s to 85 ms).
2026-10-04 16:14:10 +02:00
Marc-André Bombeck 23961d2280 perf(medications): stop re-sending the read a nav prefetch just made
The medications list and the compliance summary refetch on every mount,
so a take or skip logged on another device shows on return (#316). The
nav link's hover / touch / focus intent and the route-commit preload
prefetch both reads too, so every visit through a link sent each of
them twice: the prefetch, then the identical request from the mount a
few hundred milliseconds after the first answer landed.

The mount now skips its refetch only when the cached data answers a
prefetch that went to the network for this visit: within the last 10 s,
and since the route last left /medications. A direct load, a return
with an older entry, or a quick return whose prefetch found the entry
still fresh keep the refetch on mount.
2026-10-04 16:12:04 +02:00
Marc-André Bombeck c08e049a05 perf(rollups): share an in-flight coverage probe between concurrent readers
A Coach snapshot, a derived-metric batch or the Insights page fans out
into a dozen readers that each probe the same account's rollup coverage
at once. In production one MCP get_metrics call held 59 of these probes
open on the database together, each taking about 320 ms under that
contention, and the same burst repeated every 15 minutes.

Concurrent probes for one account now join the query already running.
Nothing is kept after it settles, each caller gets its own map, and a
measurement write drops the in-flight entry so a probe that starts after
a write always reads afresh. The registry lives on globalThis so every
server bundle shares it.
2026-10-04 16:04:04 +02:00
Marc-André Bombeck 3e08fd0e68 fix(documents): stop queueing a thumbnail that cannot be rendered on every boot
A document whose preview render fails for good (a PDF whose first page
renders blank) was queued by the boot-time backfill, refused, and queued
again at the next boot, without end.

The thumbnail job now keeps the failure with the document
(document_thumbnail_failures, migration 0374): the reason, the attempts
since the last success and when the newest failed. The backfill, both the
boot discovery and the per-account pass, leaves a document out after one
deterministic refusal (raster-failed, empty-render, pixel-cap) or three of
any other kind, until the document changes. A direct enqueue still renders
it, and a preview that is made clears the record.
2026-10-04 15:56:56 +02:00
Marc-André Bombeck f18e6ed34b fix(backup): notice a backup run the process died under, and take its account last
A weekly or nightly backup job whose process died (a restart, a
container out of memory) stayed active until its two- or four-hour
expiry, and its retry walked the accounts in the same order: the account
that killed it has the oldest copy, so it led again, and every account
after it went without a copy for as long as that record stayed too
large.

Both passes now send with a five-minute pg-boss heartbeat, so a dead run
fails within minutes and its retry picks it up. Each pass records when it
started and finished every account (backup_pass_attempts, migration
0373); an account whose last attempt never finished goes behind all the
others. The backups page shows when the run in progress started and names
an account a run died under, for the weekly and the off-host card, and a
heartbeat timeout reads as what it means.
2026-10-04 15:56:56 +02:00
Marc-André Bombeck d55e329c48 perf(backup): take the weekly copy's preview from the writer, not a second parse
Since v1.39.6 the preview scanner parsed the JSON on its way into the
pieces, kept every section but the three bulk tables, and validated the
whole document at the end: a second copy of the record beside the
writer's own, inside the weekly job. On an account with a document vault
that is most of the record.

The writer now shows each section and each bulk row to an observer just
before writing it. The preview collects key uses from that and takes its
counts from the writer's own FullBackupCounts; nothing is parsed or kept.
An upload hands over the summary and key uses it has already validated.
The round trip pins the writer's counts and key uses to a full read of
the copy on an account with a row in every model, and a memory test holds
what the pass keeps once the producer is done.

Measured on 70,000 measurements, 3,000 mood entries and 40 one-megabyte
documents under --max-old-space-size=524: held after the producer
56 MB before, 3 MB after; peak heapUsed 398 MB before, 334 MB after.
2026-10-04 15:56:56 +02:00
Marc-André Bombeck 5345c8be1e fix(backup): check every account of the newest date in the restore drill
The drill read only the alphabetically last object of the newest date, so
one account stood in for all of them. It now checks every object of that
date one at a time, records a failure against the account it belongs to,
keeps going, and pages once per failing object with its key and how many
accounts were checked.
2026-10-04 15:56:55 +02:00
Marc-André Bombeck f67006783c fix(backup): open legacy text ciphertext as a string and probe only counted sections
A text column written before key ids existed holds the bare base64 of
nonce, tag and ciphertext. The key check read such a value as an encoded
Bytes column, decoded it once too often, and accused the v1 key of not
opening a copy it opens. The instance's stored AI key has exactly that
shape and is the shortest v1 value in every disaster-recovery copy, so it
was picked as the probe and refused every restore of an account with v1
values, even with the instance settings left out of the verdict.

A legacy value now reads as a string unless its decoded head is base64
text. Samples are kept per section and only the sections the verdict
counts are probed; a key is unreadable when none of its probes opens.
Binary values open with the label their column is sealed under, taken
from the encrypted-column registry, and are not probed when no label is
known. A preview stored by an earlier release is still read, and its
sample's layout is derived again from the value.
2026-10-04 15:56:55 +02:00
Marc-André Bombeck 76e621b067 perf(tz): memoise zone offsets per hour for wall-clock reads
Day keys and wall-clock fields are computed per row in the analytics,
digest and personal-record loops, and each call ran
Intl.DateTimeFormat#formatToParts plus a resolvedOptions() allocation
to re-validate the zone. In a CPU profile of an Insights page load the
zone helpers were 17-19% of the busy time on the request thread.

Probe the offset once per UTC hour at both ends of the hour and derive
the wall clock arithmetically; an hour that contains a transition, an
instant outside 1971-2199 or an invalid date keeps the formatter path.
Zone validity is memoised per name. userDayKey drops from 9.3 to 1.5 us
per call, wallClockInTz from 6.4 to 0.5 us; a parity test checks the
result against Intl around every 2026 transition in ten zones.
2026-10-04 15:53:48 +02:00
Marc-André Bombeck b25df614f9 fix(insights): show the Rest Mode onset as a date, not a raw timestamp
The health score card passed the episode onset, an ISO instant, straight
into its sentence, so it read "Rest Mode active since
2025-08-04T09:55:00.000Z". It now shows the day that instant falls on in
the profile timezone, in the user's date format. The test that pinned the
raw value now pins the formatted day.
2026-10-04 15:36:04 +02:00
Marc-André Bombeck be69e409af Merge pull request #1139 from MBombeck/docs/readme-screenshots-1400
docs: refresh README screenshots from the 1.40.0 demo
2026-10-04 15:31:02 +02:00
Marc-André Bombeck 4ab2a8be38 perf(rollups): probe coverage with a loose index scan
probeRollupCoverage runs ahead of the dashboard snapshot, the analytics
slices and the comprehensive insights. It read every live row of the
account for SELECT DISTINCT type and counted every DAY bucket per type,
which the planner served with a sequential scan of measurement_rollups.
Walk the distinct types with a recursive CTE along the live index and
test coverage with EXISTS. On a copy of a long-lived account the probe
drops from 24-50 ms to under 2 ms and returns the same map; an
integration test keeps the old query as the oracle.
2026-10-04 15:29:02 +02:00
Marc-André Bombeck 5cbb080262 fix(db): share one Prisma client across the server bundles
A production build loads src/lib/db.ts once per server bundle, and the
client was only parked on globalThis outside production. The running
process held three clients: about 28 MB of query-compiler state each on
a heap capped near 520 MB, and three pg pools each sized to the full
per-process connection budget. Park the client on globalThis in every
environment so each copy of the module resolves the same one.
2026-10-04 15:29:01 +02:00
Marc-André Bombeck 2c1369f01b perf(charts): measure chart text outside <body>
Recharts sizes every tick and label by writing it into one off-screen
span and reading getBoundingClientRect(). Under <body> each read forced
a document-wide style recalc, because the stylesheet's descendant-subject
:has() rules leave Chromium no way to scope the invalidation. A seeded
dashboard made 149 such reads per cold load at about 2.8 ms each, about
1.8 s of main-thread time under a 4x CPU slowdown.

The chart runtime now parks that span in a zero-size, contain: strict
host under <html>, which Recharts finds by id and keeps using. Each read
drops to about 0.01 ms; the dashboard's total blocking time under the
same slowdown falls from about 2.5 s to about 0.75 s. The host copies
<body>'s classes, where the app font is set, so every string measures
exactly as before; an e2e spec checks that parity on the drawn labels
and that the host survives a client-side round trip.
2026-10-04 15:28:28 +02:00
Marc-André Bombeck 1b1da652d3 fix(monitoring): route Umami events to the endpoint the tracker was built with
The tracker posts to `${data-host-url}${collect path}`, and the collect
path is compiled into the operator's Umami build: /api/send by default,
anything else when the instance sets COLLECT_API_ENDPOINT. The tag
pointed data-host-url at the app origin and the proxy listened only on
/api/send, so a renamed endpoint sent every page view and event to an
app path that does not exist and got a full 404 page back each time.

Point data-host-url at /api/monitoring/umami and answer every path under
it with one proxy route. The route reads the collect path from the
configured tracker (cached for an hour), forwards only that path to the
Umami host, and answers anything else, or an unreadable tracker, with an
empty response. The admin test event posts to the same learnt path. The
send-URL resolver lives in one module instead of two copies.
2026-10-04 15:13:52 +02:00
Marc-André Bombeck 2e150e97cf fix(nav): stop the sidebar flashing scrollbars while it expands
The rail animates its width from w-16 to w-64, and the labels laid out
against the growing width: every row overran the nav and multi-word
labels wrapped, so the nav's overflow-y-auto painted a vertical and a
horizontal scrollbar for the length of the transition.

Lay the expanded column out at its final width from the first frame and
let the rail clip it. The final layout is unchanged; the column fills
the expanded rail exactly. A unit test pins the structure and an e2e
spec samples the nav on every frame of a real expand and collapse.
2026-10-04 15:08:42 +02:00
Marc-André Bombeck 2ce9d4651d docs: refresh README screenshots from the 1.40.0 demo
Recapture Insights, resting heart rate, Medications and Coach at the
same 2552x1308 frame. The resting heart rate shot no longer shows the
raw metric enum in the chart legend. Images are palette-quantised, about
a fifth of their previous size.
2026-10-04 14:56:03 +02:00
Marc-André Bombeck 57c7cf8be9 chore(db): number the pulse mean migration after 0371 2026-10-04 14:55:03 +02:00
Marc-André Bombeck 13b8920dd5 Merge remote-tracking branch 'origin/main' into feat/pulse-hourly-mean-complete 2026-10-04 14:54:53 +02:00
Marc-André Bombeck bc0d9f915f test(import): answer the MANUAL-type lookup in the import mocks 2026-10-04 14:49:07 +02:00
Marc-André Bombeck 4cad43e4d5 Merge pull request #1137 from MBombeck/fix/favicon-ico
fix(ui): transparent favicon.ico with the current heart
2026-10-04 14:45:24 +02:00
Marc-André Bombeck 67e8e7c4f2 fix(import): pluralise the note on records HealthLog wrote itself
The note had one form per locale, so a single left-out record read as a
plural. It now resolves One, Few and Other through tCount like the other
counted strings, with the singular in every locale and the Polish few form.
2026-10-04 14:34:42 +02:00
Marc-André Bombeck 4dabc83513 fix(import): match HealthLog's own samples by row id and ask for mirrors per type
A sample the app wrote before the origin marker existed still carries the
HealthLog row id as HKExternalUUID. A hit on one of the account's rows of
the same kind now leaves it out exactly, for Withings and import mirrors as
well as manual ones; either id of a blood pressure pair covers both halves,
since the app stamps both samples of a reading with one id.

The manual-mirror lookup read one OR clause per row on every batch, even
for an account without a single MANUAL row. The types that hold MANUAL rows
are now read once per import; a batch asks nothing for any other type, and
one time window per type for those.

Cycle samples carry the marker too and are now left out like the rest, and
a sample the export repeats (a correlation's records also stand on their
own) is counted once.
2026-10-04 14:34:38 +02:00
Marc-André Bombeck 8add851fb7 docs(import): say what the origin marker is for and who reads it
The comment claimed the app's own sync filters by the marker. It does not:
the app's echo check is HKExternalUUID plus the sample's authoring source
being the app itself, and the marker exists for the deletion path, where
the source is not visible. Say so, and say why the export import reads the
marker and the row id instead.
2026-10-04 14:34:37 +02:00
Marc-André Bombeck 29798a76e3 fix(labs): keep the trend column as wide as its sparkline on a wide list
The wide tier let the trend column grow to 8rem while the sparkline it
holds is a fixed 72px glyph, so at 1920 px an empty 56 px strip opened
between the sparkline and the chevron. The column now stays 72px in both
tiers; the extra width goes to the range bar and the name, as before.

The wide-list spec reads the resolved grid tracks and asserts the trend
track is 72px at every width it covers.
2026-10-04 14:28:04 +02:00
Marc-André Bombeck d1ec6715d9 fix(series): pick one source per day for both halves of a blood-pressure reading
The systolic and diastolic reads each took the ladder's source per day on
their own. Whenever a source held only half of a reading on a day (a deleted
diastolic, an orphaned sample, a sub-floor systolic), the two picks diverged
and the pairing joined one source's systolic to another source's diastolic.
A sub-floor systolic could also claim a day and then contribute nothing,
leaving the day empty although a lower-ranked source had a valid reading.

The source is now picked per day from the systolic rows that pass the floor,
both halves are read from it in one statement, and a pair is only made
within one source, which also covers a reading that straddles midnight.
2026-10-04 14:14:22 +02:00
Marc-André Bombeck 0ae50849d8 Merge remote-tracking branch 'origin/main' into ready/pr-1132 2026-10-04 14:08:29 +02:00
Marc-André Bombeck fbd639d9f1 Merge remote-tracking branch 'origin/main' into ready/pr-1131 2026-10-04 14:08:27 +02:00
Marc-André Bombeck b088e31a0f Merge remote-tracking branch 'origin/main' into ready/pr-1134 2026-10-04 14:08:26 +02:00
Marc-André Bombeck 66be88287f fix(ui): replace the white-backed favicon.ico with the current transparent heart
src/app/favicon.ico was still the filled heart from the first release on a
solid white square. Next serves it next to favicon.svg, and browsers that
pick the ICO (Safari, bookmarks, history, some tab states) showed the old
icon on a white slab. It is now the current logo at 16, 32 and 48 px with a
transparent background.
2026-10-04 14:05:44 +02:00
Marc-André Bombeck c2a3a26537 Merge pull request #1136 from MBombeck/release/v1.40.0
chore(release): v1.40.0
2026-10-04 14:04:56 +02:00
Marc-André Bombeck dee00613d8 Merge remote-tracking branch 'origin/main' into release/v1.40.0 2026-10-04 12:23:40 +02:00
Marc-André Bombeck 32de7ecf37 Merge pull request #1135 from MBombeck/fix/cycle-backup-and-med-put
fix(backup): carry encrypted free text readable in portable backups
2026-10-04 12:23:35 +02:00
Marc-André Bombeck 06f210b56f Guard every pulse mean reader behind the day-mean helper
A structural test lists every file that takes a database or in-memory mean
over measurement values and names pulse, and requires each to use the shared
statistic, hand the type to a fold that does, or state why it never averages
pulse readings. A new plain mean over pulse, or a reader that drops the type,
fails it.
2026-10-04 11:39:33 +02:00
Marc-André Bombeck 8f730484e0 Average pulse by day in the comparison snapshot and Coach inventory
The insights comparison snapshot took its current and baseline pulse figures
from a reading AVG, and the Coach's data inventory told the model a reading
mean of the whole pulse history. Both now read the mean of the user's local
days, each the mean of its hours' means, through the day-mean helper. Every
other type keeps its aggregate.
2026-10-04 11:37:57 +02:00
Marc-André Bombeck 11e35a6ba1 Average pulse by day in the insights, Coach and report trend
The feature extraction, the signals of the day, the status cards, the graded
series, the Coach snapshot, the mood correlations and the report's trend arrow
each still averaged pulse over its readings, so a dense workout hour decided
the day and a workout day the window. They now read a pulse day as the mean of
its hours' means and a window as the mean of its days, through the day-mean
helper. Counts, extremes and spreads stay over every reading; every other type
is unchanged.
2026-10-04 11:33:45 +02:00
Marc-André Bombeck db524ded98 Average pulse by day in the doctor report, correlations and baselines
The doctor report's pulse average and day points, the correlation channels,
the illness correlation, the personal baseline and the Coach's tiered context
each folded pulse readings into a day by plain mean. They now take the day as
the mean of its local hours' means and a window as the mean of its days, in
the zone each reader buckets by. The day aggregates carry the weighted sums for
an hourly-mean type, so every caller reads one day value. Every other type is
unchanged.
2026-10-04 11:33:42 +02:00
Marc-André Bombeck 257d3ef6a5 docs(changelog): add the portable backup fixes to 1.40.0 2026-10-04 11:32:04 +02:00
Marc-André Bombeck 5d28d05004 docs(changelog): add reasoning effort, blood pressure and previews to 1.40.0 2026-10-04 11:30:20 +02:00
Marc-André Bombeck 4e88243075 Merge remote-tracking branch 'origin/main' into release/v1.40.0 2026-10-04 11:29:47 +02:00
Marc-André Bombeck 24786f6549 Merge pull request #1130 from MBombeck/feat/provider-reasoning-effort
feat(ai): per-provider reasoning effort for Local and gateway models
2026-10-04 11:29:44 +02:00
BernhardBuckel 1763ac85d9 fix(import): honour the origin marker on a blood pressure correlation
A blood pressure reading is a <Correlation> wrapping its systolic and diastolic
records, and the marker can sit on the correlation instead of on each record.
The import only read it from a record's own metadata, so a marked correlation
would have come back in full.

A correlation's records are now held until it closes, then all kept or all left
out together: the marker on the correlation, before or after its records, or on
a record, applies. Tests cover each, the marker not spilling onto the next
record, and a self-closing record.
2026-10-04 11:28:08 +02:00
Marc-André Bombeck 3634f6c520 Average pulse by day in the dashboard, insights and series stats
The dashboard summaries, the insights aggregate and the series stats strip
still took a plain mean over pulse readings for their 7-day, 30-day, prior-month
and all-time figures, while the year-ago figure and the chart already weighed
each day once, so the year delta compared two different statistics. They now
go through the day-mean helper: each day is the mean of its hours' means and a
window the mean of its days, in the frame each reader buckets its days by.
Count, min, max, the median and the spread stay over every reading; every other
type is unchanged.
2026-10-04 11:22:17 +02:00
BernhardBuckel 12e202958f fix(series): follow the source priority ladder in the blood-pressure series
The blood-pressure branch of /api/measurements/series still read systolic and
diastolic with an unranked findMany, so with a second provider the pairs blended
both sources and a reading present in both showed twice, while the other kinds
already follow the ladder since the dense and raw reads were moved onto the
shared helper.

Each of the two types now reads the ladder-winning source per day through the
same helper. The physiological floors (systolic 40, diastolic 20) and the
five-minute pairing are unchanged.
2026-10-04 11:17:00 +02:00
Marc-André Bombeck ad0f35eadd Merge remote-tracking branch 'origin/main' into release/v1.40.0
# Conflicts:
#	CHANGELOG.md
2026-10-04 11:13:49 +02:00
Marc-André Bombeck ecddcb3fa5 Merge pull request #1128 from MBombeck/fix/pdf-raster-ocrmypdf
fix(documents): hoist the pinned canvas binary and render PDFs with pdfjs font data
2026-10-04 11:13:29 +02:00
Marc-André Bombeck bb36a2afd5 Merge pull request #1127 from MBombeck/fix/idempotency-body-fingerprint
fix(idempotency): replay a key only for the same request body
2026-10-04 11:13:26 +02:00
Marc-André Bombeck a5ffde31c2 Recompute stored pulse rollup means to the hourly-mean statistic
Rows written before this change keep the reading mean: the boot backfill and
the freshness check fill only missing days, so nothing rewrites them. Migration
0373 recomputes the mean of every stored PULSE row from the readings, DAY as
the mean of its hours' means and WEEK / MONTH / YEAR as the mean of their days,
and leaves count, min, max and the sums alone. The writer stores the coarse
rows the same way, so a refold and the migration agree.
2026-10-04 11:10:55 +02:00
BernhardBuckel 841d4e559c test(import): add a synthetic export.xml fixture for the origin marker
The fixture is invented, in the shape Apple's DTD documents, and says so in its
header and its file name. It shows the parser reads a file laid out like an
export; it cannot show that Apple writes a custom metadata key verbatim.
2026-10-04 11:10:49 +02:00
BernhardBuckel 503f0f183a feat(labs): add the lab unit conversion registry and its pure functions
Phase 2 of #1095: new code only, no behaviour change. Nothing imports it yet.

An analyte lists its units as affine maps to a hub unit (hub = a * value + b),
so any pair goes unit -> hub -> unit. Molar-mass factors are derived from the
formula and a cited atomic-weight table instead of being typed; glucose reuses
MGDL_PER_MMOL. HbA1c is the IFCC-NGSP master equation. Units that differ only
by a power of ten convert for any analyte, inside one family and never across
mass and amount. An entry with no source, a hub that is not one of its units, or
a non-positive factor makes the registry fail to load.

convertLabValue, convertLabRange and conversionsFor refuse rather than guess:
unknown unit, unknown analyte, a mass to amount pair without an analyte key,
a value that is not finite. Triglycerides, haemoglobin in mmol/L, ferritin and
B12 in pmol/L and enzymes in ukat/L stay refusals.

Refs #1095
2026-10-04 11:05:36 +02:00
Marc-André Bombeck c755a42d93 Add one day-mean helper for the hourly-mean types
A day of pulse is the mean of its hours' means and a window the mean of its
days, but only the rollup tier and the chart knew it. The helper gives every
other reader the same statistic in both shapes it needs: in TypeScript over
readings it holds, and in SQL as a per-reading day weight whose sums give the
mean of the day values over any window. Every other type keeps its plain mean.
2026-10-04 11:05:35 +02:00
BernhardBuckel a1a84f924b feat(labs): let the lab list's range bar use a wide card
On a wide window the compact lab list was one five-column row whose only
flexible column was the name: the name column sat mostly empty while the range
bar and the trend stayed small at the right edge.

The list's card is now a container, and the columns follow its width instead of
the window's. Below 64 rem of card the row is the lg table it was, with the
range bar at 12 rem; from 64 rem the bar column grows from 12 to 18 rem and the
trend column from 72 px to 8 rem, and the bar fills its column. The two tiers
exclude each other, so the result does not depend on the order the CSS is
emitted in. The trend sparkline itself is unchanged.

From lg up a long analyte name wraps instead of being cut off; a phone keeps the
single line with an ellipsis, and the bar keeps its own 12 rem cap there.

The shared 1280 px frame is untouched; a Playwright spec measures it on /labs,
/measurements and /mood at 1280, 1920 and 3440 px.

Fixes #1057
2026-10-04 10:56:43 +02:00
BernhardBuckel a5e98c9e71 fix(import): leave out Apple Health samples HealthLog wrote itself
The iOS app writes manual entries into Apple Health, and mirrors Withings and
import rows into it, each stamped with dev.healthlog.app.origin = healthlog.
The server already holds those readings under their own source, so the same
sample arriving through an Apple Health export is a second copy: the series
shows it twice and the rollups count it twice.

The export import now leaves out a record carrying that marker. The marker is a
child <MetadataEntry>, read after the record's open tag, so a record is
committed at its close tag. As a fallback for manual entries mirrored before the
app set the marker, a record is also left out when a MANUAL row of the same
type and value sits within 2 s (same rule and tolerance as the sync path).
Withings and import rows are not matched by value and time: they reach Apple
Health only through the mirror, which always sets the marker.

The import summary says how many records were left out for this reason.

Fixes #1109
2026-10-04 10:53:23 +02:00
Marc-André Bombeck a0fa4783c0 fix(ai): keep JSON mode when an endpoint rejects only reasoning_effort
An endpoint that does not know reasoning_effort answers with an unknown
parameter error, which the JSON-mode probe read as a refusal of
response_format and switched that base URL to plain prompts.
2026-10-04 10:48:35 +02:00
Marc-André Bombeck 39a5862d54 feat(settings): add a Reasoning select to the Local and gateway forms
One select under the model field of the Local and OpenAI-compatible
provider forms: Default, Off, Low, Medium, High, with a one-line hint
that Off leaves the answer budget for the answer itself. It reuses the
NativeSelect, label and hint layout of the field above it, seeds from
the stored entry, and saves with the form. Strings in all seven
locales.

Refs #1126
2026-10-04 10:43:04 +02:00
Marc-André Bombeck 9cb06ec44d fix(idempotency): read a rebuilt multipart form as the same request
A client that rebuilds a form for a retry picks a fresh random boundary,
so the raw bytes differ and a repeated document upload ran a second time
instead of replaying. The boundary named in Content-Type is replaced by a
fixed token before hashing.
2026-10-04 10:43:00 +02:00
Marc-André Bombeck befac59576 feat(ai): send a per-entry reasoning effort to Local and gateway providers
Reasoning models on Ollama and similar servers think by default and can
spend the whole max_tokens budget before the visible answer starts, so
the call comes back empty or times out. The Local and OpenAI-compatible
chain entries now carry an optional reasoning setting: Default sends
nothing, Off sends reasoning_effort "none", Low / Medium / High send
their own value. It is stored on the entry in aiProviderChain, so a
chain can mix a thinking model with one that does not know the field.

- Every exported resolver binds the entry's value onto the client, the
  connection test included, keyed on the instance type so the operator
  provider, OpenAI, Anthropic and Codex never carry it.
- PATCH /api/user/ai-provider accepts localReasoningEffort and
  compatReasoningEffort and GET reports them. A provider absent from
  the chain holds its value on a disabled entry; restating Default on a
  chain that was never customised writes nothing.
- PUT /api/insights/provider-chain accepts reasoningEffort on local and
  openai-compatible entries, refuses it on any other, and keeps the
  stored value when the body leaves it out. Its schema is now shared
  with the OpenAPI registry.
- A Default request body is byte-identical to the one sent before.

Refs #1126
2026-10-04 10:42:59 +02:00
BernhardBuckel 497da7ca8b fix(pulse): make a day of pulse the mean of its hours' means
A watch samples pulse about every 5 to 10 minutes at rest and about every
5 seconds in a workout. The daily value was the plain mean over a day's
readings, so a workout hour of several hundred readings outweighed the other
23 hours: a day with one hard hour and 23 resting hours (60 bpm) read about
128 bpm, not 64.

A day of pulse is now the mean of its per-hour means, in the rollup DAY
bucket, the live per-day read the chart serves, and the series route's day
grain. Windows over several days weigh each day once instead of by its
reading count, so the bias does not return one level up. Reading counts,
min/max, and the regression sums still run over every reading. The chart
tooltip and the data table name the statistic, in all seven locales.

The statistic is limited to pulse; HRV, SpO2 and glucose keep the plain mean
(see src/lib/measurements/day-statistic.ts). Stored DAY rows are rewritten by
the next rollup recompute (admin recompute route), not on read.

Fixes #1107
2026-10-04 10:41:08 +02:00
Marc-André Bombeck 8cf1210bad fix(documents): log why a document got no thumbnail
Refs #1124. The thumbnail job opened no wide event, so every annotation
it made went nowhere: one instance ran it hundreds of times, left sixteen
PDFs without a preview, and the log never said so.

Each run is now a `job.document_thumbnail` background event. A run that
should have produced a preview and did not is raised to warn and names
the reason; a type that never has a preview stays at info.
2026-10-04 10:37:24 +02:00
Marc-André Bombeck 4b73099e75 fix(documents): render PDFs with pdfjs font data and refuse blank pages
Refs #1124. The rasterizer opened pdfjs without its standard font, CMap
and wasm directories and told it to use system fonts. The runtime image
has none, so any text pdfjs could not draw from an embedded program (a
non-embedded standard font, or an embedded one it cannot parse) came out
as nothing, and an image-only provider was sent the page background. A
workstation hides this because its own fonts stand in.

pdfjs now gets the directories from its own package, resolved at runtime
from the working directory, and Liberation Sans from that package is
registered as the canvas' sans-serif, serif and monospace faces for the
text pdfjs draws with a substitute.

A document whose every rendered page is blank now reports render-failed,
so the read records raster-failed instead of indexing a transcript of an
empty page as a success.
2026-10-04 10:37:24 +02:00
Marc-André Bombeck b63fa317f3 fix(docker): hoist the canvas binary of the pinned version
Refs #1124. The store holds two @napi-rs/canvas versions, the pinned one
and an older one that pdf-parse's own pdfjs-dist pulls in. The runner
stage took the musl binary with `find | head -1`, got the old one, and
copied it into the new loader's directory, where the loader looks first.
Rendering still worked, but `loadImage` calls `Image.decode`, which the
old binary lacks, so every preview thumbnail failed with "image.decode is
not a function" on both architectures.

The builder now records the resolved canvas version and the runner hoists
that version by name, as it already does for pdfjs-dist. The build stops
when the binary it copies belongs to another version.
2026-10-04 10:37:23 +02:00
Marc-André Bombeck bf35779db7 Fail the round trip when a portable file carries ciphertext
With every sealed column filled, the column round trip now also builds
the portable file of the same account and fails on any section that
carries a sealed column as stored ciphertext, unless the section is named
with a reason. The disaster-recovery file of the same account is the
floor, so a matcher that finds nothing cannot pass the check.
2026-10-04 10:19:32 +02:00
Marc-André Bombeck b36190b2df Restore custom mood tags without mood entries
The account's own mood categories, tags and hidden set were written only
inside the restore's mood-entry branch, so a vocabulary set up before the
first entry did not come back. They are written whether or not the file
carries an entry.
2026-10-04 10:19:32 +02:00
Marc-André Bombeck 8ac3fddc3b Carry visit and vaccination free text readable in a portable backup
A practitioner's note, a visit's reason, outcome and body site and a
vaccination's note travelled as stored ciphertext in a portable file, the
same defect the cycle and mood sections had. They now go through the
shared sealed-text helpers: readable in a portable file, sealed under the
receiving host's key on restore, ciphertext kept only where this host
opens it and otherwise reported as visitCiphertext or
vaccinationCiphertext. The practitioner address and phone ciphertext of a
disaster-recovery file gets the same check, since the key preflight now
leaves these sections to it.
2026-10-04 10:19:31 +02:00
Marc-André Bombeck 2798cc953f refactor(workouts): drop the unused clock arguments of the blank draft 2026-10-04 10:18:16 +02:00
Marc-André Bombeck 183b7ce2c5 Merge remote-tracking branch 'origin/main' into release/v1.40.0 2026-10-04 10:16:56 +02:00
Marc-André Bombeck b55850967d fix(idempotency): replay a key only for the same request body
The replay cell was keyed on (user, key, method, path). A client that sends
two different writes under one key got the first write's cached response for
the second, and the second was never saved. Native clients up to 1.1.0 post
the two halves of a manual blood pressure exactly that way, so the diastolic
value was silently dropped.

Each cell now stores a SHA-256 fingerprint of the request body (JSON with
recursively sorted keys, otherwise the raw bytes; scoped by method and path;
at most the first MiB is read). Lookup replays, or refuses as in flight, only
when the fingerprint matches. A different body, or a row written before the
column existed, runs the handler without claiming or caching and leaves the
existing cell for its own retries; a lost claim race re-checks the winner's
fingerprint the same way. The body itself is never stored.

Migration 0371 adds the nullable idempotency_keys.request_fingerprint column.
The OpenAPI parameter description, the client notes and the Home Assistant
guide say what a replay now requires. docs/ops gains a read-only query that
lists systolic readings left without their diastolic half, held to the
schema by an integration test.
2026-10-04 10:14:22 +02:00
Marc-André Bombeck 808b2672cd Merge pull request #1125 from MBombeck/fix/manual-workout-blank-start
Read a blank workout start as now minus the duration
2026-10-04 09:30:20 +02:00
Marc-André Bombeck 17b653b819 feat(workouts): read a blank start as now minus the duration
Refs #1085. Logging a workout right after finishing it meant changing the
start first, or the form refused it as ending in the future. The start field
now opens blank; left blank, the workout is saved as starting the duration
before now. A start the person enters is validated as before.
2026-10-04 09:07:39 +02:00
Marc-André Bombeck c7a2eec078 Drop the column fallback around the medication update
The update retried without pausedAt and notificationsEnabled for a
database that lacked the columns. Migrations guarantee both, and inside
the transaction a failed statement aborts it, so the retry could not
work anyway.
2026-10-04 08:31:11 +02:00
Marc-André Bombeck 43a53255c9 Carry custom mood labels readable in a portable backup
The labels of the mood tags and mood categories an account created
travelled as the stored ciphertext in a portable file, so a restore onto
a host with another key refused the file or wrote labels nobody could
open. They now follow the cycle note: readable in a portable file, sealed
under the receiving host's key on restore, ciphertext kept only where
this host opens it and otherwise reported as moodLabelCiphertext.

The sealing helpers move to one module both sections share.
2026-10-04 08:31:11 +02:00
Marc-André Bombeck 715df7d5d3 docs(readme): keep the contributor list without a separate line for one person 2026-10-04 08:29:43 +02:00
Marc-André Bombeck b3e18739fb docs(readme): name the people whose reports shaped HealthLog 2026-10-04 08:24:07 +02:00
Marc-André Bombeck 6bc764fa28 Write a medication PUT and its course in one transaction
The row update (name, schedule, oneShot) committed before the course
write. When a concurrent course made the course write refuse, the client
got the refusal while the rest was already written. The schedule replace,
the row update, the pause era and the course write now commit together,
so a refusal leaves the medication unchanged. The rollup recompute runs
after the commit.
2026-10-04 08:17:34 +02:00
Marc-André Bombeck 8121935642 Carry cycle free text readable in a portable backup
A portable file carried the day-log note, the sensitive-category envelope
and custom symptom labels as the stored ciphertext, so a restore onto a
host with another key either refused the file or wrote values nobody
could open. The portable file now carries them readable and the restore
seals them under the receiving host's key; a disaster-recovery file keeps
the ciphertext.

A file written before this still restores: each ciphertext value is
opened with this host's keys first, and one that does not open is kept
out of the row and reported in the skip summary as cycleCiphertext. The
key preflight leaves the cycle sections to that per-value check.
2026-10-04 08:17:34 +02:00
Marc-André Bombeck 964a3d050c Merge pull request #1123 from MBombeck/fix/key-check-newest
Never refuse a key that opens the newest data
2026-10-04 02:22:13 +02:00
Marc-André Bombeck 2f3da10317 Ask the newest values before the key check refuses
The probe decided on the eight oldest values alone. An install
reinstalled under a fresh key long ago holds old rows sealed under a key
that is gone and all current data under the key it runs with; it works
today, and the check would refuse it with 503 on every request.

When every oldest value fails, the newest values of the same columns are
now sampled in reverse order. One that opens makes the verdict
inconclusive (mixed): the process serves, warns and probes again at the
next start. Only old and newest values that all fail are a mismatch. A
newest-only open never seals; sealing stays with the oldest-first rule.

Every probe query now runs in its own transaction under a
transaction-local statement_timeout (2 s), so a large or locked table
ends that column with an error and the probe as incomplete instead of
holding up the boot. The newest order is the exact mirror of the oldest,
so an index on the timestamp serves both directions.

The module comment lists the three conditions that still yield a
mismatch.
2026-10-04 01:58:04 +02:00
Marc-André Bombeck f685cbe460 chore(release): v1.40.0 — back up the encryption key, medication courses and categories, your own symptoms, document reads in the background 2026-10-04 01:26:37 +02:00
Marc-André Bombeck 2c2e3fcc72 Merge pull request #1122 from MBombeck/feat/medication-categories-courses
Custom medication categories and several courses per medication
2026-10-04 01:25:33 +02:00
Marc-André Bombeck db9618ee7b Merge remote-tracking branch 'origin/main' into wip/v140-H 2026-10-04 00:43:10 +02:00
Marc-André Bombeck 4317eb91ca Make the rolling course anchor opt-in
The rule that keeps an earlier course's intake from anchoring a rolling
cadence applied to every caller unless one opted out, which is how a
booster reminder picked it up. It now applies only when the context
declares rollingAnchor: "courseStart"; the four medication context
builders declare it, and every other caller keeps the previous
behaviour, where the last intake always anchors. A guard enumerates
every RecurrenceContext builder in src, requires each to be listed as a
medication or a non-medication builder, and checks the declaration on
both sides.
2026-10-04 00:42:54 +02:00
Marc-André Bombeck df85fd62c9 Drop the unused built-in category list export 2026-10-04 00:33:34 +02:00
Marc-André Bombeck b930ab80ff Keep booster and measurement reminders anchored on the last satisfaction
The course rule that stops an old intake from anchoring a rolling
cadence read a reminder's anchor date as a course start, so a booster
fourteen years past on a ten-year cadence came due later today instead
of staying overdue. Measurement and booster reminders now opt out with
rollingAnchor: "lastIntake"; the rule stays on medications.
2026-10-04 00:33:34 +02:00
Marc-André Bombeck bc41dfb1b3 Merge pull request #1121 from MBombeck/fix/key-check-hardening
Make the encryption key check unable to lock out a correct key
2026-10-04 00:30:28 +02:00
Marc-André Bombeck 363070540b Merge remote-tracking branch 'origin/main' into fix/key-check-hardening 2026-10-04 00:01:07 +02:00
Marc-André Bombeck e04daed904 Merge pull request #1120 from MBombeck/fix/v140-review-ui
Keep the glucose card in mg/dL and fix pre-release review findings
2026-10-04 00:00:53 +02:00
Marc-André Bombeck 69bae8bdb4 Add ENCRYPTION_KEY_CHECK=warn as an escape hatch for the boot key check
At a first start there is no recorded key yet, so an operator facing a
refusal they are certain is false has no row to delete. With
ENCRYPTION_KEY_CHECK=warn (default enforce) the check still runs and logs
its full outcome, /api/health names a mismatch as a warning field without
failing, requests are served, and no key is recorded unless every key id
passes.

The variable is on the compose whitelist, in both example files, the env
manifest, the Unraid and Portainer templates and the published-env guard.
The log block, the mismatch page and the self-hosting pages offer it as the
last resort after the three real fixes, and say that it disables a safety
check.
2026-10-03 23:46:40 +02:00
Marc-André Bombeck 2c2151ae70 Keep the rolling anchor when a start only moved forward
An intake before startsOn stopped anchoring a rolling cadence, which
also moved the next dose of a single-course medication whose start had
merely been pushed forward a few days. The intake is now ignored only
when the dose it implies would fall before the course starts (a new
course after a gap); otherwise it anchors as before.
2026-10-03 23:42:29 +02:00
Marc-André Bombeck 17bced4fdf docs(self-hosting): an Apple Health import from a web-only container is queued, not refused 2026-10-03 23:38:13 +02:00
Marc-André Bombeck b74c6de7ce fix(cycle): give the custom-symptom popover the shared symptom icon picker 2026-10-03 23:38:12 +02:00
Marc-André Bombeck eada82f61e Derive a backfilled course in the account's time zone
The backfill and the restore of an older file read the creation day of
an end-only window in UTC while every reader of the window uses the
account's zone; both now use the account's zone (the default zone when
the stored one is unknown to Postgres). A window whose start lies after
its end, which the API refuses but older rows hold, becomes a one-day
course on the end day: every reader treated it as empty and ended, and
a swap would have invented expected doses. One shared rule
(courseFromWindow) serves the restore and the course writer.
2026-10-03 23:36:08 +02:00
Marc-André Bombeck 4783711651 Refuse MCP and its OAuth endpoints while the encryption key does not match
apiHandler answers 503 encryption.key_mismatch before auth, but the remote
MCP endpoint and the OAuth authorize, token and register routes wrap in
withBackgroundEvent instead and never checked. An MCP write tool could seal
new rows under the wrong key, and those rows would open under it at the
next boot.

The refusal moves into one helper used by apiHandler, /mcp (same envelope)
and the OAuth routes (RFC 6749 temporarily_unavailable with the error code
as an extension member). A structural test fails when a route outside
apiHandler, or a server action, lacks the refusal in every exported handler
and is not on a short allowlist of routes that never touch the database.
2026-10-03 23:32:07 +02:00
Marc-André Bombeck c809a811e6 docs(self-hosting): say which reads run on the worker and why a split worker must match the web version 2026-10-03 23:32:04 +02:00
Marc-André Bombeck 2c295bbea8 Probe the oldest stored values before recording an encryption key
The boot key check recorded whatever key was configured whenever its probe
found nothing to try. It only read string columns, so an install whose
ciphertext sat in Bytes columns (notes, documents, ECG, symptoms) read as
empty; a probe that ran out of time did the same; and rows written by a
process that served under a wrong key could open on the next boot and get
that key recorded as the right one.

The probe now reads every registered column, string and Bytes, in all four
stored layouts, and tries the oldest values first, ordered by the row's
updatedAt (else createdAt, else primary key). A key is recorded only when
the oldest value that opens is preceded by at most one that does not, or
when no column holds a single value under the key id. A time-out, an
unreadable table, oversized values or mixed results are inconclusive: the
process serves, records nothing and checks again at the next start.

When the first insert loses a race, the stored record is read back and must
open under this process's key, else the outcome is a mismatch.

A rotation that walked every column without errors removes the record of
each previous key id that no longer holds any value. The refusal log block
and the self-hosting pages explain how to reset the check for a key id.
2026-10-03 23:29:55 +02:00
Marc-André Bombeck 6c262f2099 fix(documents): compare a confirmed lab fact's unit with the shared spelling normaliser 2026-10-03 23:29:13 +02:00
Marc-André Bombeck 510d4b1d04 Write a medication's window and its course in one transaction
The PUT wrote startsOn/endsOn on the row and then rewrote the latest
course in a second transaction, so a failure between the two left the
row and its courses disagreeing. The window now reaches the row only
through the course writer, which writes both under one lock.
2026-10-03 23:29:12 +02:00
Marc-André Bombeck 62d47d7056 fix(workouts): drop a hand-entered workout's paragraph when an edit changes its values
The paragraph described the old values. It is deleted with them, its generation claim is detached the way a delete detaches it (the day's slot stays spent), and the workout's arrival is raised again so the paragraph is written from the corrected values through the ordinary path, under the same daily cap.
2026-10-03 23:26:48 +02:00
Marc-André Bombeck e811c64bfb fix(i18n): name the thermometer icon the German way 2026-10-03 23:26:48 +02:00
Marc-André Bombeck 9f865a9456 Show every course in the dose history
The history minted bands only from the latest course, so misses in an
earlier course counted in the rate and appeared nowhere in the history.
It now mints over the span of every course and keeps the slots on a
course day. The compliance ledger and the cadence missed tally drop the
gap slots before the reconstruction instead of after it, so a dose
logged between courses stays visible as an off-schedule take in both.
2026-10-03 23:24:23 +02:00
Marc-André Bombeck 8aa2e33435 Walk every course in the streak and the cadence timeline
The rate counted misses in earlier courses while the streak and the
cadence chart's missed tally minted only the latest course, so a miss
in a previous course never broke the streak. The cadence engine context
now carries the courses and expands over their span, keeping only course
days, the same rule as the compliance ledger.
2026-10-03 23:16:16 +02:00
Marc-André Bombeck 0100256c60 test(labs): pin the lab scan's waiting line to the copy that does not promise the page can be left 2026-10-03 23:13:59 +02:00
Marc-André Bombeck 16c16000e7 Refuse a first course beside a medication taken continuously
A medication with no course rows and no window is running since its
creation; a course added beside it projected the medication into ending,
which stopped its reminders and dropped its history from compliance. The
create now refuses that (medications.course.currentOpen; End course
writes the first course). A window the rows never recorded is
materialised as the first course before the new one is checked.
2026-10-03 23:11:28 +02:00
Marc-André Bombeck 367a1bb4b4 fix(symptoms): size the icon pickers for touch and name each icon in the reader's language 2026-10-03 23:10:56 +02:00
Marc-André Bombeck 1eab6145a9 fix(symptoms): show the load error in the quick entry instead of the empty hint 2026-10-03 23:07:16 +02:00
Marc-André Bombeck d644db1975 Revert "feat(dashboard): send the glucose card in the owner's glucose unit"
This reverts commit 9455b64375.
2026-10-03 23:03:02 +02:00
Marc-André Bombeck 1494409209 Merge remote-tracking branch 'origin/main' into wip/v140-H
# Conflicts:
#	messages/de.json
#	messages/en.json
#	messages/es.json
#	messages/fr.json
#	messages/it.json
#	messages/ko.json
#	messages/pl.json
#	src/__tests__/sharing-surface-guard.test.ts
#	src/__tests__/v137-wave-zero-fixtures.test.ts
#	tests/integration/sharing-manage-handler-matrix.test.ts
2026-10-03 22:52:49 +02:00
Marc-André Bombeck 50bc0dab5a fix(medications): keep a custom category when a native client sends OTHER back
An iPhone build that predates custom categories decodes an unknown category
as OTHER and sends it back on every edit. A Bearer update sending OTHER over
a custom category now keeps the custom one; the web can still move it back.
2026-10-03 22:52:05 +02:00
Marc-André Bombeck 4d7abdd12b Give a category its own line in the manage sheet
At phone width the three actions left the label a few characters; the
count and the hidden state now sit under it, the course list's row
anatomy.
2026-10-03 22:47:09 +02:00
Marc-André Bombeck 3a99c8cfee Merge pull request #1115 from MBombeck/docs/ideas-board
Point feature ideas to the Ideas category
2026-10-03 22:43:31 +02:00
Marc-André Bombeck 0cb54f1dd4 Merge pull request #1118 from MBombeck/feat/custom-symptoms
Log your own symptoms in one step
2026-10-03 22:43:22 +02:00
Marc-André Bombeck 157002f75d Translate the medication courses into every locale 2026-10-03 22:39:28 +02:00
Marc-André Bombeck a14174fe4d Let a medication have several courses
A medication taken now and then (#1024) keeps one row and gains course
rows: calendar spans, both ends inclusive, an optional encrypted note, no
schedule or dose of their own. Courses never overlap and every course but
the latest has ended, so the latest course is always the one the
medication's startsOn/endsOn project. src/lib/medications/courses.ts is
the only writer of those two columns for a medication with courses; it
writes under a per-medication advisory lock and re-projects in the same
transaction, so reminders, the next-due line and intakeActionable keep
reading the columns and stay right.

- Migration 0368 adds medication_courses and backfills one course per
  medication window (an end-only window starts on the creation day, or
  on its end day when that lies earlier); a medication without a window
  stays course-less. Idempotent, data-preserving.
- GET/POST /api/medications/{id}/courses and PATCH/DELETE
  .../courses/{courseId} (owner-only); create seeds the first course
  from a window; the medication PUT edits the latest course and refuses
  a window that would break the rules (medications.course.*).
- List, detail, create and update carry courses, courseCount,
  previousCourseEndedOn and canStartCourse, resolved on the server.
- Compliance expands over the span of all courses and drops the slots
  between them; every compliance read selects the courses. A rolling
  cadence is not anchored by an intake from a previous course.
- The card names the last course's end and the running course's number;
  the detail Lifecycle group lists the courses with start, edit and
  delete. The Coach compliance block, the MCP resource, the doctor
  report window and table, the clinician view and FHIR (one
  MedicationStatement per course with effectivePeriod and status) read
  the courses.
- Backup carries courses per medication and the restore writes them and
  re-projects; an older file derives the one course its window
  describes. Wipe plan, encrypted columns, rotation and OpenAPI follow.
2026-10-03 22:39:25 +02:00
Marc-André Bombeck ef0028c020 Merge remote-tracking branch 'origin/main' into wip/v140-I
# Conflicts:
#	docs/api/openapi.yaml
2026-10-03 22:16:29 +02:00
Marc-André Bombeck 4a02414e97 Merge pull request #1119 from MBombeck/feat/document-ai-runs-rest
Run document summaries, suggestions and extraction in the background, and add sick notes
2026-10-03 22:16:05 +02:00
Marc-André Bombeck 9c2b57df33 Merge remote-tracking branch 'origin/main' into wip/v140-I 2026-10-03 22:00:18 +02:00
Marc-André Bombeck 1ee6f9005f chore(bundle): lower the eager route baselines and re-base the aggregate for on-demand quick-entry forms 2026-10-03 22:00:16 +02:00
Marc-André Bombeck 9b71231010 Exempt the medication category routes from the module gate
They are raw CRUD over the account's own rows, the data-layer reasoning
every other medication route is exempt on.
2026-10-03 22:00:07 +02:00
Marc-André Bombeck b7458f0607 Stub the custom category read in the export route tests
The full backup now reads the account's own medication categories, and
three route tests that drive the payload builder against a mocked client
had no delegate for it.
2026-10-03 21:59:42 +02:00
Marc-André Bombeck 8068626aa8 Merge remote-tracking branch 'origin/main' into wip/v140-K 2026-10-03 21:48:26 +02:00
Marc-André Bombeck 8d64da2807 Name the sick note kind in every locale
Label SICK_NOTE in all seven bundles and pin that every document kind
the database knows has a label in each locale, a place in the vault's
kind list and a glyph: the kind reaches the screen through a dynamic
key, which the call-site coverage guard cannot see.
2026-10-03 21:43:37 +02:00
Marc-André Bombeck 57a54061be Merge pull request #1112 from BernhardBuckel/fix/series-source-ladder
fix(series): follow the source priority ladder in the dense and raw series reads
2026-10-03 21:41:33 +02:00
Marc-André Bombeck 1564e7c396 Add the sick note document kind
A certificate of incapacity for work (in Germany the
Arbeitsunfähigkeitsbescheinigung) is now its own document kind,
SICK_NOTE, instead of Other. The enum value rides migration 0370.

Uploads, edits, the kind filter, bulk edits, the import script and the
backup format accept it; the vault files it with a thermometer glyph
between prescriptions and referrals; the filing suggestion can propose
it and names the words a sick note carries. The extraction's own kind
field keeps its three values.
2026-10-03 21:40:01 +02:00
Marc-André Bombeck ca4a45ee2c i18n(symptoms): add the symptom strings in all seven locales
The capture picker and Add menu entries, the quick-entry sheet, the
illness page section and the episode card, the activity-feed verbs, the
restore skip label and the symptoms.* API error messages, in de, en, es,
fr, it, ko and pl (German informal).
2026-10-03 21:38:37 +02:00
Marc-André Bombeck 802759df02 Translate the custom medication categories into every locale 2026-10-03 21:38:05 +02:00
Marc-André Bombeck dbbff339f6 test(symptoms): register the new surfaces with the export, destructive and success guards
The full-backup route tests mock the symptom definition read the payload
now makes; the destructive-control registry names the symptom hooks (an
entry or a whole symptom, confirmed by an AlertDialog); the symptom quick
entry's save toast is pinned beside the measurement and mood forms it
mirrors.
2026-10-03 21:38:03 +02:00
Marc-André Bombeck 4cb8118802 Let a person name their own medication categories
A medication can be filed under a category the person named, next to the
built-in ones (#1041). The labels live in medication_category_labels,
encrypted at rest, capped at 20 per account; a medication filed under one
carries its custom:<uuid> key in the category column.

- GET/POST /api/medications/categories (POST idempotent, 30/min per actor,
  admitted at MANAGE like the cycle symptom vocabulary) and owner-only
  PATCH/DELETE /api/medications/categories/{key}. DELETE moves every
  medication filed under the category to OTHER in the same transaction
  and answers movedCount.
- category is widened to a built-in value or custom:<uuid> on the wire,
  the create and update bodies refuse a key that is not the caller's
  (422 medications.category.unknown), and list, detail, the insights
  medication tiles and the MCP medications resource carry categoryLabel.
  A key with no owned label reads as OTHER and is counted on the event.
- The wizard's class step lists the person's categories under the
  built-in rows with an inline add row; the list page gains a category
  filter (session only) and a manage sheet for rename, hide and delete.
- Backup carries customMedicationCategories (decrypted labels) and the
  restore writes them before the medications; wipe plan, encrypted
  column registry, rotation script, sharing guards and OpenAPI follow.
2026-10-03 21:38:03 +02:00
Marc-André Bombeck 65b2f75421 perf(dashboard): load the quick-entry forms when a sheet opens
The dashboard and the bottom bar's capture picker imported all five
quick-entry forms eagerly, so every authenticated paint carried them for
sheets most visits never open. One module now wraps them in next/dynamic
(client-only, a skeleton holding the sheet body while the chunk loads)
and both callers use it. The discard guard stays static. The picker tests
mock the one module instead of five.
2026-10-03 21:30:38 +02:00
Marc-André Bombeck bcbfd87ae9 feat(coach): name the person's own symptoms in the illness block
The Coach illness block now carries the last 14 days of the person's own
symptoms (name sanitised for the prompt, count, highest intensity, last
occurrence; at most ten, most frequent first), and exists when there are
symptoms but no episode. The prompt describes the field in English and
German. The MCP get_illness_recovery result leaves it out in this
release.
2026-10-03 21:24:28 +02:00
Marc-André Bombeck 9272daa59c feat(symptoms): log a symptom in one step and show it on the illness pages
- A new capture kind "symptom" (illness module, illness write grant) in
  the bottom bar's picker and the dashboard Add menu. The sheet follows
  the measurement and mood forms: the person's symptoms as chips with the
  cycle sheet's dashed "new symptom" chip, the 0-10 slider with its
  anchors, the time prefilled with now, an optional note, and a "during"
  selector only while an illness episode is open.
- "Your symptoms" on the illness page: one line per symptom (how often in
  30 days, how strong at worst, when last), the last 30 days grouped by
  day, edit and delete per entry, rename, icon, hide and delete per
  symptom. Renders nothing until a symptom exists.
- "Symptoms logged" on an episode's page: the entries filed against it or
  logged while it ran, the filed ones marked. This is the reader of the
  episode link.
- Query keys live under the illness root, so either side's writes
  refresh both.
2026-10-03 21:24:12 +02:00
Marc-André Bombeck bb58b40b2a Run fact extraction in the background, re-checking under a row lock
The extract route accepts `Prefer: respond-async` for all three inputs
(text, stored, vision) and queues a DOCUMENT_EXTRACT run (added to
migration 0370). The run's result is the count of staged facts and the
document status; the facts themselves are read from the document.
Without the header the route answers with the document detail as
before; both paths run one shared body.

A background read can take minutes, and a review the person finishes in
the meantime must not have its approved facts replaced. Staging now
locks the document row and asks again whether it may stage, refusing
with the route's own alreadyConfirmed / alreadyPartlyConfirmed codes.
The synchronous path gets the same re-check.

The vault's stored-text extract uses the background path and shows the
shared waiting line in place of its hint while the read runs.
2026-10-03 21:14:47 +02:00
Marc-André Bombeck 9d9e1f153c feat(insights): make each person-defined symptom its own correlation channel
Every active symptom definition enters pattern discovery as an outcome
channel SYMPTOM:<id>, labelled with its decrypted name (the data label
the interpretation already prefers over the key). The daily value is the
day's highest intensity in the person's zone; days without an occurrence
count as 0 only between the first and the last logged day. At most eight,
the active-definition cap. The SYMPTOM: prefix resolves to the illness
module in the surface map, so the family leaves the matrix with the
module, and the discovery guard lists the new fetcher.
2026-10-03 21:14:08 +02:00
Marc-André Bombeck d6571c4e9c feat(symptoms): add person-defined symptoms and timestamped occurrences
A symptom of one's own ("aura", "headache") is a definition with an
encrypted name and an icon from the cycle custom-symptom allowlist; each
occurrence carries a time, a 0-10 intensity (the pain score's scale), an
optional encrypted note and an optional link to one of the record's open
illness episodes. Both ride the illness module: every route answers
illness.disabled when it is off.

- Migration 0369 (idempotent): symptom_definitions and symptom_events,
  intensity CHECK 0-10, episode link ON DELETE SET NULL.
- /api/symptoms/definitions{,/[id]} and /api/symptoms/events{,/[id]}:
  eight active definitions per record (a hidden one frees its slot,
  showing it again re-checks), hide or purge, events create/list/edit/
  delete, a new link must name an open episode of the same record
  (422 symptoms.episode.notOpen). Error group symptoms.*.
- Sharing: reads at READ, logging an occurrence at WRITE, defining and
  editing at MANAGE; C1/C3/C4 audit details; frozen lists, the handler
  matrix and the activity verbs updated.
- Both encrypted columns registered and rotated; both models in the wipe
  plan; both carried at both backup ends with occurrences nested in their
  definition, plaintext in a portable file and ciphertext in a recovery
  file, and a link to an episode the file lacks dropped and named
  (symptomEpisodeReference).
- OpenAPI regenerated.
2026-10-03 21:13:45 +02:00
Marc-André Bombeck eda5776886 Run document summaries and filing suggestions in the background
The summary and suggest routes accept `Prefer: respond-async`: after the
same quick refusals they answer 202 with a run id, and the background
document AI worker runs the read under two new run kinds
(DOCUMENT_SUMMARY, DOCUMENT_SUGGEST, migration 0370). Without the header
both routes answer synchronously as before; both paths run one shared
body, so they cannot drift.

The worker asks the capability for the record and re-checks the wire for
the provider it picks before every read. A summary that is to be stored
marks the document PENDING while queued, READY when it lands, and
UNAVAILABLE when the run fails or the write does not land. A second
request attaches to a live run only when it would produce the same thing.

The vault asks for both in the background and shows the same calm
waiting line the index uses, worded so a transient read never promises
that leaving the page keeps it. The egress guard and the route inventory
now hold every run kind to its dispatcher re-check and its capability.
2026-10-03 20:51:57 +02:00
Marc-André Bombeck 350c646463 Adopt the medication category table into the schema
The clinical category lived in a side table the app created at runtime,
after migration 0004 had dropped it as orphaned. It is now the
MedicationCategoryAssignment model. Migration 0368 creates the table
IF NOT EXISTS with the runtime DDL, so an existing table converges with
its rows untouched (verified with prisma migrate diff against a database
holding the runtime table and against a fresh one).

The helpers keep their names and run on the Prisma delegate; the runtime
CREATE TABLE and the pre-transaction ensure call in the restore are gone.
The backup plan judges the table as a schema model (backed up, two-ended,
seeded in the round trip) instead of under RAW_SQL_TABLES, which is now
empty; its guard proves its matcher on a fixture string instead.
2026-10-03 20:45:02 +02:00
Marc-André Bombeck b961e82a71 chore(github): point feature ideas to the upvotable Ideas category 2026-10-03 20:34:51 +02:00
Marc-André Bombeck 7cd323499e Merge pull request #1106 from MBombeck/feat/v140-fixes
Workout insight cap, marker unit lock, natural durations, charts that include today, and display fixes
2026-10-03 20:32:09 +02:00
BernhardBuckel bb1acfab64 fix(series): follow the source priority ladder in the dense and raw series reads
The rollup tier and sleep resolve every (type, day) to the source the person's
ladder picks before they aggregate. The dense and raw reads of
/api/measurements/series filtered on user and type only, so with a second
provider a day's value blended the two, a reading present in both showed
twice, and the configured ladder had no effect on those charts.

Both reads now take the ladder-winning source per day through the shared
canonicalMeasurementsCte helper, as the other live readers do:

- the day and hour bucket read of the dense kinds (pulse, glucose, HRV, SpO2),
  and the stats strip over its rows;
- the generic raw read (weight, body fat, steps, resting heart rate, ...).

The collapse is per day for the hour grain too, as everywhere else. The helper
gains an optional single-type scope so a series scans one metric, not every row
the person has in the window.

Only a type that has a ladder is collapsed. For a type without one (glucose,
body water, bone mass) the helper breaks the tie by source name, which would
silently hide one of two real sources, so those keep every source as before.
A single-source account reads exactly what is stored.

Tests: a real-database test covering the ladder in both directions, the
per-day fallback, a reading mirrored into two sources, the day-bucket, hour-
bucket and raw reads, a type with no ladder and a single-source account. The
route's unit test file reads its fixtures through a small adapter for the new
raw statements, and the dense-read guard drops the exception for the removed
findMany. The OpenAPI description states the rule.

Refs #1108
2026-10-03 20:18:50 +02:00
Marc-André Bombeck cd3c0b7de2 Merge remote-tracking branch 'origin/main' into feat/v140-fixes
# Conflicts:
#	docs/api/openapi.yaml
2026-10-03 20:06:16 +02:00
Marc-André Bombeck af1a6e0b14 Merge pull request #1104 from MBombeck/feat/key-backup-and-transport
Back up the encryption key, refuse a key that cannot open the data, and stop sign-ins that cannot keep their cookie
2026-10-03 20:05:58 +02:00
Marc-André Bombeck 1c2822a67b chore(bundle): re-base the client budget for v1.40 2026-10-03 19:43:40 +02:00
Marc-André Bombeck e132110fcd chore(bundle): re-base the client budget for v1.40 2026-10-03 19:43:39 +02:00
Marc-André Bombeck 816eabb3c3 Merge remote-tracking branch 'origin/main' into feat/v140-fixes
# Conflicts:
#	docs/api/openapi.yaml
2026-10-03 19:22:56 +02:00
Marc-André Bombeck 37793298a8 Merge remote-tracking branch 'origin/main' into feat/key-backup-and-transport
# Conflicts:
#	docs/api/openapi.yaml
2026-10-03 19:22:01 +02:00
Marc-André Bombeck 02b6f06521 Merge pull request #1103 from MBombeck/feat/document-ai-runs
Read documents and lab scans in the background
2026-10-03 19:21:43 +02:00
Marc-André Bombeck d753621bd1 Merge pull request #1105 from MBombeck/fix/lab-list-columns
Keep lab list columns aligned for readings without a range
2026-10-03 19:07:43 +02:00
Marc-André Bombeck dcf3b18c91 Merge pull request #1099 from MBombeck/docs/readme-thanks
Update the thanks section
2026-10-03 18:59:01 +02:00
Marc-André Bombeck f3ccc809a8 Merge remote-tracking branch 'origin/main' into wip/v140-E 2026-10-03 18:56:39 +02:00
Marc-André Bombeck c3525cd02f fix(labs): stop the biomarker backfill on its job budget
The integration-backfill drain handed its job budget only to the
Google Health backfill. The lab-biomarker backfill now takes it too,
asks it before each analyte group and, once it says stop, throws
without touching the rest. Finished groups are linked and drop out of
the next scan, so the retry resumes where the pass stopped instead of
running on past the job's expiry beside its own retry.
2026-10-03 18:53:28 +02:00
Marc-André Bombeck 9455b64375 feat(dashboard): send the glucose card in the owner's glucose unit
/api/dashboard/summary sent the glucose card's value in mg/dL with
unit null and a unitKey that reads mg/dL, so a reader on mmol/L saw
the mg/dL number. The card now carries latestValue, secondaryValue
and sparkline in the record owner's glucose unit and names it in
unit, the field that wins over unitKey, as the sleep card already
does. Applied after the summary cache read, so a unit change shows
on the next request.
2026-10-03 18:50:35 +02:00
Marc-André Bombeck facae940b9 Merge remote-tracking branch 'origin/main' into feat/key-backup-and-transport
# Conflicts:
#	docs/api/openapi.yaml
2026-10-03 18:49:04 +02:00
Marc-André Bombeck a01554bd53 fix(dashboard): give the key backup link a 44 px touch target 2026-10-03 18:48:58 +02:00
Marc-André Bombeck cfddf097c7 fix(documents): file an undated document under the reader's upload day
The archive grouped a document without a stated date by the UTC day
of its upload, so an upload late on the last evening of a month west
of UTC opened next month's section (and early on the first morning
east of UTC, last month's). The upload day is now read in the
profile time zone for the month sections and the year filter.
2026-10-03 18:47:05 +02:00
Marc-André Bombeck 9ecdda6358 fix(dates): show a stated calendar date as that date east of UTC+12
Day keys anchored at noon UTC went through the zone-aware
dateShortSmart, which shows them as the next day in UTC+12..+14. A
dateShortSmartCalendar formatter reads the key in UTC with the same
year rule, and the predictions panel, the illness timeline, the
intraday pulse day label, the nutrient chart and the coach result
chart use it. The lab chart dates a date-only reading by its calendar
date and a timed one in the reader's zone, as the lab lists do. The
timezone formatter guard now fails on a noon anchor handed to a
zone-aware formatter.
2026-10-03 18:44:06 +02:00
Marc-André Bombeck 20470d99f4 Merge remote-tracking branch 'origin/main' into feat/document-ai-runs
# Conflicts:
#	docs/api/openapi.yaml
2026-10-03 18:41:55 +02:00
Marc-André Bombeck 358ef23239 fix(clinician): write the shared view's numbers in the owner's locale
The clinician view rounded numbers and printed them bare, so a German
record showed 24.5 where every other surface shows 24,5. Its number
formatter now goes through the locale formatter it already builds,
at most two decimals.
2026-10-03 18:39:03 +02:00
Marc-André Bombeck 5c300a8d9c fix(labs): keep the range bar and trend in their columns when a reading has no range
The range badge renders nothing for a reading without a reference range, so
that row had four cells instead of five and the range bar and the trend
moved one column to the left. The badge now sits in a cell of its own. The
chevron is hidden from lg up, where the row's hover already says it opens;
a phone keeps it.
2026-10-03 18:38:31 +02:00
Marc-André Bombeck fdd34f2c5e fix(charts): keep a chart's window open to the end of today
A chart showing the last N days fixed its window end at the mount
instant. The query key kept that bound, so the refetch after a new
reading asked for the same old window and the reading stayed hidden
until the range tab was switched. The window now ends at the end of
the current day in the profile time zone (openEndedFetchWindow, keyed
on the day): every reading saved today falls inside it, the cache key
stays the same all day, and it moves on after midnight. The dashboard
batch shares the end-of-day helper.

Refs #1102
2026-10-03 18:35:49 +02:00
Marc-André Bombeck 51e2d68a88 i18n(measurements): say how a duration entry was read
Refs #1101
2026-10-03 18:35:26 +02:00
Marc-André Bombeck 313d8491de feat(measurements): read a sleep duration written as 7:30 or 7h 30m
The sleep field asked for decimal hours only. A shared parser
(parse-duration.ts) now reads a bare number with point or comma,
H:MM, and hours and minutes with unit words (7h, 7h30, 7 h 30 min,
45m), answers in the field's unit, and refuses a negative, unknown or
longer-than-a-day entry. The form shows how the entry was read under
the field and puts a refusal on the field itself. The stored value and
the API are unchanged.

Refs #1101
2026-10-03 18:28:39 +02:00
Marc-André Bombeck d805ccf28b Merge pull request #1100 from MBombeck/ci/trivy-braces
Ignore the braces advisory until a fix is published
2026-10-03 18:28:24 +02:00
Marc-André Bombeck 7b51edcebe Merge pull request #1096 from BernhardBuckel/fix/lab-unit-mismatch
fix(labs): refuse a reading whose unit differs from its marker's
2026-10-03 18:28:20 +02:00
Marc-André Bombeck b04b1f2995 fix(encryption): sample several stored values before declaring a key mismatch
The boot check decided on the first value it found under a key id, so
one damaged or foreign-sealed row could take a correctly keyed server
down with 503 on every request. The probe now samples up to five
values, one per column first. One value that opens clears the key; a
mismatch needs at least two values and none opening. A single value
that does not open is inconclusive: the server keeps serving, writes no
canary, logs a warning and probes again at the next start.

Integration setup truncates encryption_key_canaries between tests.
2026-10-03 18:27:28 +02:00
Marc-André Bombeck 3a35dbcf37 i18n(labs): say why a biomarker's unit cannot change once it has readings 2026-10-03 18:22:55 +02:00
Marc-André Bombeck ced17569bf i18n: login notice for a page whose sign-in cookie cannot stick
Adds auth.insecureTransport in all seven locales.
2026-10-03 18:21:23 +02:00
Marc-André Bombeck b100c73a62 fix(auth): refuse a sign-in over plain http before it spends a factor (#1097)
A server that issues Secure session cookies cannot sign anyone in on a
plain http:// page: the browser drops the cookie. The login request was
still verified, the MFA ticket minted and then claimed, and the next
attempt answered "Invalid or expired challenge".

The web client now sends an optional clientTransport on the browser
sign-in requests (password login, passkey start, both MFA completions).
When the server issues Secure cookies and the page is plain http on a
non-loopback host, the request is refused with 409
auth.session.insecure_transport before the password is checked, a
ticket is minted or claimed, a code is spent, or a passkey challenge is
issued. A request without the field is never refused. The SSO start
takes client_proto/client_host and sends such a page back to the login
page instead of to the provider.

GET /api/auth/registration-status publishes sessionCookieSecure, and the
login page shows the reason with both fixes and disables the sign-in
controls before anything is sent; on localhost it shows a note only.
SESSION_COOKIE_SECURE keeps its meaning; the header heuristic log line
stays.
2026-10-03 18:21:19 +02:00
Marc-André Bombeck 89540a999b i18n(documents): say when a read continues in the background
Seven locales for the background-run copy: the calm line while a document
read or a lab scan runs in the worker, the hint when no worker has picked it
up, and the two failures only a background run can produce.
2026-10-03 18:20:59 +02:00
Marc-André Bombeck dd7636e147 feat(labs): refuse a biomarker unit change while readings are linked
Readers label every linked reading with the marker's unit, so changing
it relabelled stored numbers without converting them. PUT
/api/biomarkers/{id} now answers 422 biomarkers.unit.locked with
meta.readingCount when the unit differs and any reading (tombstoned
ones included) is linked. The marker editor says so under the unit
field.
2026-10-03 18:19:50 +02:00
Marc-André Bombeck 0acf3da575 feat(documents): read documents and lab scans in the background
"Read with AI" and the lab scan used to run their model calls inside the
request. At the default response time a read of three calls outlasted the
100 s a proxy in front of the server commonly allows, and the person saw a
cut connection for a read the server was still doing.

Both now run as a background run (`DocumentAiRun`, migration 0366):

- `POST /api/documents/inbound/{id}/index` with `Prefer: respond-async`
  answers 202 with a run id once every quick refusal has passed. Without
  the header it answers exactly as before, which the iPhone app relies on.
  A second request while a read of the same document is queued or running
  attaches to that run.
- `POST /api/labs/ocr/extract` always answers 202. Only the web client
  calls it.
- `GET /api/ai-runs/{id}` serves the run: the same body the synchronous
  route answers with, or the failure code and status it would have given.
  Scoped to the caller; another account's run is a 404.

The worker (`document-ai-run`, a long queue with a one-hour expiry, one
job per run under `lockedPass`) claims the run, asks the capability again
for the record and re-checks the wire for the provider it picks, sets the
run's deadline from the person's AI response time and refuses a read that
cannot fit the job's expiry. Sync route and worker run one body per kind,
so the two paths cannot drift. Budget is reserved at enqueue and settled
once by whoever ends the run.

Input and result are sealed with labelled AES-256-GCM, registered for key
rotation; the input is dropped when the run ends. A five-minute reaper
fails a run no worker took within fifteen minutes (and hands its
reservation back), fails a run past its deadline, and deletes a finished
run after an hour. Runs are outside backups and inside the data wipe.

The web client polls through the query-key factory and says, calmly, that
the read continues in the background, and when no worker has picked it up.
2026-10-03 18:18:56 +02:00
Marc-André Bombeck 911e642c24 i18n: encryption key backup step, refusal page and dashboard banner
Adds admin.keyBackup, admin.keyMismatch and dashboard.keyBackupBanner in
all seven locales.
2026-10-03 18:06:33 +02:00
Marc-André Bombeck 308470f229 feat(encryption): back up the key, and refuse to serve when it cannot open the database
Every configured key id now has a canary in encryption_key_canaries,
checked at boot. A key that cannot open its canary, or existing data
written before the canary existed, puts the process in a refusal state:
every API route answers 503 encryption.key_mismatch, /api/health reports
reason encryption_key_mismatch, the pages render one explanation, and
neither the queue producer nor the worker starts. The process stays up
so the reason is visible instead of hiding in a crash loop.

The admin Encryption page gains a "Back up your encryption key" step:
key id and fingerprint (never the key), where the key lives per
platform, a check of a pasted copy that is compared and discarded, and
a confirmation bound to the active key id and fingerprint. The overview
gets a tile and admins get a dashboard banner while it is due.

HEALTHLOG_PLATFORM picks the platform the step opens on. Adds the
TrueNAS self-hosting page and key backup sections in the docs.
2026-10-03 18:06:28 +02:00
Marc-André Bombeck 7022b698e7 fix(ai): forget the remembered working provider when the chain is saved
The in-memory memo of the last provider that answered reorders the
chain for up to an hour. Saving a new chain now drops it for that
account, so a provider the person removed or disabled no longer leads
the chain they just arranged.
2026-10-03 18:04:09 +02:00
Marc-André Bombeck 916f918674 feat(workouts): save an edited resubmit of a hand-entered workout
The manual form sends one manual: id per opening and resends it after
an edit. The batch route now treats a MANUAL entry with a manual: id
as an overwrite, the way stats: measurements are: the stored row takes
the new values and the entry reports "updated", or "duplicate" when
nothing changed. Records keyed on the workout are removed under the
personal-records lock and re-derived. Every other id stays
first-write-wins. The status enum gains "updated".
2026-10-03 17:59:20 +02:00
Marc-André Bombeck 87fa2f85fa Merge branch 'main' into ci/trivy-braces 2026-10-03 17:57:51 +02:00
Marc-André Bombeck 3cb98f92e9 Merge branch 'main' into docs/readme-thanks 2026-10-03 17:57:50 +02:00
Marc-André Bombeck 6bb65824bf fix(workouts): serialise record detection with a workout delete
The workout-slot detection reads the best workout and writes its record
inside one transaction under a per-user advisory lock, and the workout
DELETE takes the same lock before removing records and the workout. A
delete can no longer land between the read and the write and leave a
record for a workout that is gone.

The DELETE's match for a legacy record without an external id is scoped
to the slots of the deleted workout's sport, so a record another sport
set at the same instant stays.
2026-10-03 17:54:04 +02:00
Marc-André Bombeck ab081d2a85 fix(workouts): keep a deleted workout's insight slot spent
The insight claim row is now the daily-cap ledger. Its workout_id is
nullable with ON DELETE SET NULL (migration 0367), so deleting a workout
no longer frees the day's slot. The cap counts claims that reached the
provider plus live leases, and drops the separate count of insight rows.
A daily workout-insight-claim-cleanup queue removes claim rows untouched
for seven days.
2026-10-03 17:53:36 +02:00
Marc-André Bombeck ed94e56c5e Merge branch 'main' into fix/lab-unit-mismatch 2026-10-03 17:51:46 +02:00
Marc-André Bombeck b9867caeea Merge pull request #1098 from MBombeck/fix/backup-round-trip-tombstone-age
Keep the backup round-trip tombstone inside the retention window
2026-10-03 17:51:43 +02:00
Marc-André Bombeck 1d86bb8d6d i18n(labs): unit mismatch strings for es, fr, it, ko and pl 2026-10-03 17:45:05 +02:00
Marc-André Bombeck 296ca5d2dc ci(trivy): ignore the braces advisory until a fix is published
braces is reached only by development tooling and never ships in the
production image; no patched version exists yet.
2026-10-03 17:29:51 +02:00
Marc-André Bombeck 1dab2687ab docs(readme): thank contributors, reporters and supporters by what they gave 2026-10-03 17:29:34 +02:00
Marc-André Bombeck 86a6e81fc1 test(backup): keep the tombstone fixture inside the retention window
The round-trip fixture deleted its measurement on a fixed date. The restore
drops a measurement tombstone older than TOMBSTONE_RETENTION_DAYS (75) on
purpose, so on 2026-10-03 the fixture aged out and the suite failed on main
and on every open branch. The deletion is now ten days before the run.
2026-10-03 17:28:50 +02:00
BernhardBuckel d3916036c2 fix(labs): refuse a reading whose unit differs from its marker's
A numeric lab reading was stored with its value as received and the marker's
unit stamped on it, so 5.6 mmol/L against a marker kept in mg/dL became
5.6 mg/dL and read as far below range. Both the manual/API write and the
scan commit did this; nothing compared the two units.

- POST /api/labs returns 422 labs.unit.mismatch, with the marker's unit and
  the reading's unit in meta, and writes nothing. The unit is compared only
  when one was sent: on the biomarkerId path it may be omitted, and the
  marker's unit is then the stated one. A qualitative reading has no unit.
- The scan commit skips such a row with reason unit_mismatch, so the outcome
  reads partial or failed instead of a clean save, and the toast names the
  analytes concerned rather than saying "duplicates".
- The extract response carries the matched marker's unit. The review screen
  shows a calm badge on a row in another unit, starts it unticked like a
  duplicate, and treats it as a blocking error on the unit field if it is
  ticked anyway.
- A small lab-specific normaliser forgives spelling only: spacing, the micro
  prefix, the case of the litre, and a unit printed in capitals for a short
  list where capitals cannot mean another unit. Case is otherwise significant,
  so mIU/L and MIU/L, or g/L and G/L, are never merged.

The document-commit path already refuses a mismatch and is left as it is.
Readings stored wrongly before this cannot be identified, because the
original unit was never kept; they are not touched.

Adds EN and DE strings; the other locales are left to the maintainers. The
OpenAPI spec and the error-code catalogue carry the new code.

Fixes #1094
2026-10-03 15:44:51 +02:00
7056 changed files with 998622 additions and 148538 deletions

No files matched your search

+6
View File
@@ -28,6 +28,12 @@ playwright/.cache
playwright.config.ts
vitest.config.mts
vitest.integration.config.mts
vitest.mutation.config.mts
# The unit setup imports tests/ (excluded above); the image never runs it and
# the build's type check must not see a half-present test harness.
vitest.setup.ts
stryker.config.mjs
stryker.floors.json
tests
tests/
e2e
+82 -6
View File
@@ -38,6 +38,13 @@
# echo "POSTGRES_PASSWORD=$(openssl rand -hex 32)" >> .env
# Left commented here so that append does not create a duplicate key. Uncomment
# and set it directly instead if you prefer to edit the file by hand.
# The container runs the app as `healthlog_app_<database>_<hash>`, a database
# role it creates on every start that reads and writes rows but cannot change
# the schema. The
# migrations keep the DATABASE_URL user. "off" keeps the app on that user, if
# you manage database roles yourself (docs/self-hosting/database-role.md).
# DB_APP_ROLE="off"
# POSTGRES_PASSWORD="generate-with-openssl-rand-hex-32"
@@ -62,6 +69,18 @@ NODE_ENV="development"
# internet; the session cookie crosses the wire in cleartext.
# SESSION_COOKIE_SECURE="false"
# Where this server's environment is configured: compose, truenas, coolify,
# unraid or portainer. Only picks which instructions the admin's "Back up
# your encryption key" step shows first; unset means compose.
# HEALTHLOG_PLATFORM="compose"
# Start-up encryption key check: enforce (default) refuses to serve when the
# key does not open the stored data; warn only logs it and serves anyway. A
# last resort for a refusal you are certain is false (a first start, with no
# recorded key to reset). It disables a safety check: with a wrong key every
# encrypted value fails and new rows are written under that key.
# ENCRYPTION_KEY_CHECK="enforce"
# -----------------------------------------------------------------------------
# Required Secrets — generate with `openssl rand -hex 32`
@@ -166,6 +185,14 @@ API_TOKEN_HMAC_KEY="REPLACE-WITH-OUTPUT-OF-openssl-rand-hex-32"
# leave the host unencrypted.
# LOG_INCLUDE_STACK="true"
# Heap diagnostics, off by default. HEALTHLOG_HEAP_SNAPSHOT=1 writes ONE heap
# snapshot the first time the heap reaches 85 % of its limit, into
# HEALTHLOG_DIAG_DIR (default /tmp). A snapshot contains decrypted health data
# and keys; enable it only to chase a crash and delete the file afterwards.
# See docs/ops/heap-diagnostics.md.
# HEALTHLOG_HEAP_SNAPSHOT="1"
# HEALTHLOG_DIAG_DIR="/tmp"
# Optional: ship Wide Events to Loki. Leave blank for stdout-only logging.
# LOKI_ENDPOINT="http://loki:3100"
# LOKI_USERNAME=""
@@ -182,7 +209,10 @@ API_TOKEN_HMAC_KEY="REPLACE-WITH-OUTPUT-OF-openssl-rand-hex-32"
#
# Set this to "1" ONLY when every public request to HealthLog terminates
# at Cloudflare (Tunnel, Workers, classic proxy) before it reaches your
# origin. Deployments behind nginx, Caddy, Traefik, an AWS ALB, or any
# origin, and the origin accepts connections from Cloudflare alone (a
# Tunnel, or a firewall that admits only Cloudflare's address ranges): the
# header is believed from every connection, before any other check.
# Deployments behind nginx, Caddy, Traefik, an AWS ALB, or any
# direct-to-Next.js exposure MUST leave this off.
#
# Security implications when the flag is mis-set:
@@ -204,10 +234,19 @@ API_TOKEN_HMAC_KEY="REPLACE-WITH-OUTPUT-OF-openssl-rand-hex-32"
# Number of trusted reverse-proxy hops in front of the app. The client IP is
# read that many entries from the right of the X-Forwarded-For chain. Set 0
# for a directly-exposed host; 1 (the default when unset) behind a single
# reverse proxy. X-Real-IP is read only at 1. A wrong value makes the app
# reverse proxy. X-Real-IP is read only at 1. Forwarding headers are read only
# from a connection whose own address is not public (loopback, private, CGNAT,
# unique-local, link-local, reserved or multicast; the full list is in
# docs/self-hosting/reverse-proxy.md). A wrong value makes the app
# refuse the headers, log one warning, and share one rate-limit bucket across
# all anonymous callers.
# TRUST_PROXY_HOPS="1"
# The addresses the reverse proxy connects from (comma-separated CIDRs). Set,
# only those connections may report the client's address; unset, any private
# address may, which behind Docker Desktop, OrbStack or docker-proxy includes
# every caller of the published port. Same host: 127.0.0.1/32,::1/128; Docker
# network: the proxy's subnet. See docs/self-hosting/reverse-proxy.md.
# TRUSTED_PROXY_CIDRS=""
# TRUST_CF_CONNECTING_IP=""
@@ -257,6 +296,33 @@ API_TOKEN_HMAC_KEY="REPLACE-WITH-OUTPUT-OF-openssl-rand-hex-32"
# BACKUP_ENCRYPTION_KEY="" # 64-hex-char or base64 32-byte; SEPARATE from ENCRYPTION_KEY
# BACKUP_ENCRYPTION_PREVIOUS_KEYS="" # retired off-host keys, comma-separated, read-only during a rotation
# -----------------------------------------------------------------------------
# Database dumps (optional, on by default)
# -----------------------------------------------------------------------------
# The whole-database dump that rebuilds a lost server: nightly, and before
# every update that brings migrations. Written to DB_BACKUP_DIR with a
# manifest beside it (row counts, key fingerprints, versions); with the
# off-host bucket above set, each nightly dump is also uploaded under
# `database/`, sealed with BACKUP_ENCRYPTION_KEY. Local dumps are not
# encrypted: they sit beside the database volume, in the same trust zone.
# Restore: `docker compose run --rm db-restore latest`. See
# docs/ops/disaster-recovery.md.
#
# Set "false" to stop the nightly dump.
# DB_BACKUP_ENABLED="true"
# Where dumps are written. The bundled compose file mounts the `backups`
# volume here; change it only together with that mount. In a container a
# directory that is not on a mount of its own sits in the container's layer
# and is lost at the next update, so no dump is kept there.
# DB_BACKUP_DIR="/var/lib/healthlog/backups"
# Five-field cron, in Europe/Berlin time like every scheduled job.
# DB_BACKUP_SCHEDULE="30 1 * * *"
# Nightly dumps kept (at least 1). The last two pre-migration dumps are kept
# besides.
# DB_BACKUP_KEEP="7"
# Set "false" to start an update without the dump before its migrations.
# DB_BACKUP_BEFORE_MIGRATE="true"
# -----------------------------------------------------------------------------
# Stored backups in the database (optional)
# -----------------------------------------------------------------------------
@@ -308,11 +374,21 @@ API_TOKEN_HMAC_KEY="REPLACE-WITH-OUTPUT-OF-openssl-rand-hex-32"
# Auto-deploy webhook (v1.4.15 — phase C2)
# -----------------------------------------------------------------------------
# Coolify (or any CI) pings POST /api/internal/deploy-webhook after a deploy
# attempt with success/failure. We verify the request via this shared secret
# carried in the `X-Deploy-Webhook-Secret` header (timing-safe compare).
# Generate with `openssl rand -hex 32`. Configure the SAME value in Coolify's
# Notifications → Webhook custom-headers panel. See docs/audit/v1415-auto-deploy.md.
# attempt with success/failure. A request authenticates with this shared
# secret in the `X-Deploy-Webhook-Secret` header (timing-safe compare; what
# Coolify's stock sender does, with no replay protection), or by signing:
# `X-Deploy-Webhook-Signature: sha256=<hex>`, the HMAC-SHA256 under this
# secret of the `X-Deploy-Webhook-Timestamp` value, a dot and the raw body,
# the timestamp within 5 minutes. Generate with `openssl rand -hex 32`.
# Configure the SAME value in Coolify's Notifications → Webhook
# custom-headers panel. See docs/ops/deploy.md.
# DEPLOY_WEBHOOK_SECRET=""
# "true" also requires a fresh X-Deploy-Webhook-Timestamp on a secret-header
# request; leave unset for Coolify's stock sender, which sends none.
# DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP=""
# "true" accepts signed requests only (the setting that stops a replay);
# leave unset for Coolify's stock sender.
# DEPLOY_WEBHOOK_REQUIRE_SIGNATURE=""
# -----------------------------------------------------------------------------
+184 -27
View File
@@ -49,6 +49,11 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
# "<NEXT_PUBLIC_APP_URL>/api/google-health/callback"; it must stay same-origin
# with NEXT_PUBLIC_APP_URL or the handshake is refused.
# GOOGLE_HEALTH_REDIRECT_URI=""
#
# How many years back the history backfill reads after a connect: a whole
# number from 1 to 100, 20 when unset. A data type whose history goes further
# back ends at that point, and the integration card names the date.
# GOOGLE_HEALTH_BACKFILL_HORIZON_YEARS="20"
# -----------------------------------------------------------------------------
@@ -70,6 +75,12 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
# for a self-hosted Nightscout that is not reachable from the public internet.
# NIGHTSCOUT_PRIVATE_ORIGINS=""
# Dawarich instances on your own network that the movement-context connection
# may reach, comma-separated exact origins (e.g. "http://dawarich.lan:3000").
# Each entry is one scheme://host[:port], never a suffix or a range. A public
# Dawarich instance needs nothing here.
# DAWARICH_PRIVATE_ORIGINS=""
# -----------------------------------------------------------------------------
# Notification targets on a private network -- optional
@@ -142,9 +153,11 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
# Oura Cloud v2 -- optional integration (full OAuth, shared app from env)
# -----------------------------------------------------------------------------
# Register an app at https://cloud.ouraring.com/oauth/applications and set its
# redirect URI to `${NEXT_PUBLIC_APP_URL}/api/oura/callback`. Both vars together
# enable the Oura connect button. Oura uses refresh tokens; the sync refreshes
# reactively on a 401. OURA_REDIRECT_URI overrides the derived callback.
# redirect URI to `${NEXT_PUBLIC_APP_URL}/api/oura/callback`. Enable the Daily,
# Personal, SpO2, Heart health and Stress permissions on it; HealthLog asks for
# exactly those. Both vars together enable the Oura connect button. Oura uses
# refresh tokens; the sync refreshes reactively on a 401. OURA_REDIRECT_URI
# overrides the derived callback.
# OURA_CLIENT_ID=""
# OURA_CLIENT_SECRET=""
# OURA_REDIRECT_URI=""
@@ -257,6 +270,24 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
# SESSION_COOKIE_SECURE="false"
# -----------------------------------------------------------------------------
# Platform hint for the encryption key backup step
# -----------------------------------------------------------------------------
# Where this server's environment lives: compose, truenas, coolify, unraid or
# portainer. The admin's "Back up your encryption key" step opens on the
# instructions for that platform. Unset means compose. Catalog templates set
# it for you.
# HEALTHLOG_PLATFORM="compose"
# Start-up encryption key check: enforce (default) refuses to serve when the
# key does not open the stored data; warn only logs it, reports it as a
# warning on /api/health, and serves anyway. A last resort for a refusal you
# are certain is false (a first start, with no recorded key to reset). It
# disables a safety check: with a wrong key every encrypted value fails and
# new rows are written under that key. Remove it once the key is confirmed.
# ENCRYPTION_KEY_CHECK="enforce"
# -----------------------------------------------------------------------------
# Deploy webhook -- Coolify -> HealthLog auto-deploy feedback
# -----------------------------------------------------------------------------
@@ -264,11 +295,17 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
# Optional: deployment-dashboard URL (e.g. the Coolify UI) linked in
# deploy-failure notifications. When unset the notification omits the link.
# DEPLOY_LOGS_URL=""
# Optional replay hardening: require a fresh X-Deploy-Webhook-Timestamp
# header (unix seconds / ms or ISO-8601, +/- 5 minutes) on every webhook
# call. Leave unset for Coolify's stock sender (it attaches no
# timestamp); see docs/ops/deploy.md.
# Optional: require an X-Deploy-Webhook-Timestamp within +/- 5 minutes on a
# request that authenticates with the secret header. Keeps a stale capture
# out, not a fresh forgery by someone who saw the secret. Leave unset for
# Coolify's stock sender, which sends no timestamp.
# DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP="false"
# Optional replay protection: accept only signed webhook calls
# (X-Deploy-Webhook-Signature, an HMAC of the timestamp and body under the
# secret, with a timestamp within +/- 5 minutes); the secret header alone is
# refused. Leave unset for Coolify's stock sender, which can only send the
# secret header; see docs/ops/deploy.md.
# DEPLOY_WEBHOOK_REQUIRE_SIGNATURE="false"
# -----------------------------------------------------------------------------
@@ -309,6 +346,13 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
# docs/integrations/ai-providers.md.
# DOCUMENT_AI_LIMIT_PER_HOUR=""
# Minimum hours between two regenerations of a status card after a sync
# (optional). A watch or an integration delivers readings all day, and each
# delivery would otherwise regenerate the cards it touches every hour. Hand
# entries keep a one-hour gap. Default 6, whole hours from 1 to 48. The value
# under Admin > Coach wins over this one. See docs/integrations/ai-providers.md.
# AI_SYNC_REGEN_GAP_HOURS=""
# Hourly upload ceiling for each document-upload token (optional). Applies
# only to uploads made with a token minted under Settings > API & Tokens >
# Document tokens: a Paperless workflow or the import script. Every upload
@@ -331,11 +375,11 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
# per-user BYO keys keep the pinned public-host posture either way.
# ADMIN_AI_BASE_URL_ALLOWLIST=""
# Codex (ChatGPT sign-in) model slugs. CODEX_MODEL pins one slug ahead of the
# built-in chain; CODEX_MODEL_FALLBACK_CHAIN replaces the chain outright
# (comma-separated, first match wins). Reach for these when the provider
# rotates a slug and requests start failing with 503 -- without them a
# rotation needs a new release to fix.
# Codex (ChatGPT sign-in) model slugs. Left unset, each sign-in picks from
# the models its account is offered (read from the provider the way the Codex
# CLI reads it), with a built-in order as the fallback. CODEX_MODEL pins one
# slug ahead of that; CODEX_MODEL_FALLBACK_CHAIN replaces the order outright
# (comma-separated, first match wins) and the account's list is then not read.
# CODEX_MODEL=""
# CODEX_MODEL_FALLBACK_CHAIN=""
@@ -376,6 +420,33 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
# key once the lifecycle rule has retired every object written under it.
# BACKUP_ENCRYPTION_PREVIOUS_KEYS=""
# -----------------------------------------------------------------------------
# Database dumps (optional, on by default)
# -----------------------------------------------------------------------------
# The whole-database dump that rebuilds a lost server: nightly, and before
# every update that brings migrations. Written to DB_BACKUP_DIR with a
# manifest beside it (row counts, key fingerprints, versions); with the
# off-host bucket above set, each nightly dump is also uploaded under
# `database/`, sealed with BACKUP_ENCRYPTION_KEY. Local dumps are not
# encrypted: they sit beside the database volume, in the same trust zone.
# Restore: `docker compose run --rm db-restore latest`. See
# docs/ops/disaster-recovery.md.
#
# Set "false" to stop the nightly dump.
# DB_BACKUP_ENABLED="true"
# Where dumps are written. The bundled compose file mounts the `backups`
# volume here; change it only together with that mount. In a container a
# directory that is not on a mount of its own sits in the container's layer
# and is lost at the next update, so no dump is kept there.
# DB_BACKUP_DIR="/var/lib/healthlog/backups"
# Five-field cron, in Europe/Berlin time like every scheduled job.
# DB_BACKUP_SCHEDULE="30 1 * * *"
# Nightly dumps kept (at least 1). The last two pre-migration dumps are kept
# besides.
# DB_BACKUP_KEEP="7"
# Set "false" to start an update without the dump before its migrations.
# DB_BACKUP_BEFORE_MIGRATE="true"
# -----------------------------------------------------------------------------
# Stored backups in the database (optional)
# -----------------------------------------------------------------------------
@@ -408,6 +479,16 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
# 90 rather than repeat the default.
# COACH_MESSAGE_RETENTION_DAYS="365"
# How long deleting an account, a managed profile or "all data" stays
# undoable, in days. Default 14, ceiling 30. The account is locked at once
# (signed out everywhere, sign-in refused, nothing runs for it) and an admin
# can undo the deletion from the users list until the window ends; the purge
# job erases it then, together with its stored and off-host copies. A wipe
# keeps a copy of the record an admin can restore for the same time. 0 erases
# immediately, as releases before 1.46 did. The Data & Privacy page states
# this number, so it has to reach the container.
# ACCOUNT_DELETION_GRACE_DAYS="14"
# How long the host CPU/memory samples behind the admin monitoring charts are
# kept, in days. Default 7. Raise it for a longer trend; lower it on a host
# where disk is the scarce resource.
@@ -432,6 +513,27 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
# for the heaviest legitimate read (the live-aggregate analytics fallback on a
# large account). Set "0" to disable it and restore unbounded behaviour.
# DATABASE_STATEMENT_TIMEOUT_MS="60000"
#
# Postgres `work_mem` applied to every app session, as a Postgres size string.
# Default 16MB. Large sorts and aggregates that exceed it spill to disk; each
# sort step of each pooled connection may use this much memory, so raise it
# with the pool size in mind.
# DATABASE_WORK_MEM="16MB"
#
# Both settings above reach Postgres as the libpq `options` startup
# parameter. PgBouncer refuses that parameter ("unsupported startup
# parameter: options") unless it is listed in its ignore_startup_parameters.
# Behind such a pooler, set this to 1, true or yes to send no session
# settings at all; the pooler's or the database's own defaults then apply.
# DATABASE_SESSION_OPTIONS_DISABLED="1"
#
# The app runs as `healthlog_app_<database>_<hash>`, a database role the
# container creates for this database on every start: it reads and writes
# rows but cannot alter or drop tables, own anything, or create roles. Migrations and restores still use the user in DATABASE_URL.
# Where that user may not create roles (a managed database), the start log
# says so and the app uses DATABASE_URL as given. Set "off" if you manage
# the database roles yourself. See docs/self-hosting/database-role.md.
# DB_APP_ROLE="off"
# -----------------------------------------------------------------------------
@@ -466,21 +568,53 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
# -----------------------------------------------------------------------------
# Client-IP resolution -- optional (required behind Cloudflare)
# -----------------------------------------------------------------------------
# When the stack sits behind Cloudflare, the real visitor IP arrives in the
# `cf-connecting-ip` header; the X-Forwarded-For chain otherwise yields the
# Cloudflare edge IP, so the audit-log overview stores the CF datacentre
# address and the geo lookup resolves the wrong location. Set this to "1"
# ONLY when Cloudflare is in front -- a direct-exposed self-host must leave
# it unset, or an attacker could forge the header. Off by default.
# Number of trusted reverse-proxy hops in front of the app; the client IP is
# read that many entries from the right of the X-Forwarded-For chain. 0 for a
# directly-exposed host, 1 (the default when unset) behind a single reverse
# proxy; X-Real-IP is read only at 1. A wrong value makes the app refuse the
# headers, log one warning, and collapse anonymous callers into one
# rate-limit bucket.
# directly-exposed host (the connection's own address is used), 1 (the
# default when unset) behind a single reverse proxy; X-Real-IP is read only
# at 1. Forwarding headers are read only from a connection whose own address
# is not public: loopback, private, CGNAT, unique-local, link-local, reserved
# or multicast (the full list is in docs/self-hosting/reverse-proxy.md). A
# wrong value makes the app refuse the headers, log one warning, and collapse
# anonymous callers into one rate-limit bucket.
# TRUST_PROXY_HOPS="1"
#
# The addresses your reverse proxy connects from, as comma-separated CIDRs
# (IPv4 or IPv6; a bare address is one host). When set, only those
# connections may report the client's address; every other connection is
# its own client. When unset, any private address may, which is the old
# behaviour and is NOT safe behind Docker Desktop, OrbStack or docker-proxy:
# there every caller of the published port arrives from the gateway, a
# private address, and could name its own client address. Set it whenever a
# reverse proxy is in front:
# proxy on the same host (proxying to 127.0.0.1:3000): 127.0.0.1/32,::1/128
# proxy container on a Docker network: that network's subnet (docker
# network inspect <network>), e.g. 172.18.0.0/16
# proxy on another machine with a public address: that address, /32
# A proxy on a public address that is not listed is not believed: every
# request through it shares the proxy's address and one rate-limit bucket.
# The boot log names the rule in force in one [proxy-trust] line.
# TRUSTED_PROXY_CIDRS="127.0.0.1/32,::1/128"
#
# Behind Cloudflare the real visitor IP arrives in the `cf-connecting-ip`
# header; the X-Forwarded-For chain otherwise yields the Cloudflare edge IP,
# so the audit-log overview stores the CF datacentre address and the geo
# lookup resolves the wrong location. "1" believes that header from EVERY
# connection, before the checks above, so set it ONLY when the origin accepts
# connections from Cloudflare alone (a Cloudflare Tunnel, or a firewall that
# admits only Cloudflare's address ranges). An origin that also answers on
# its own public address lets any caller forge the address that rate limits
# and the audit log record. Off by default.
# TRUST_CF_CONNECTING_IP="1"
# Memory cap of the app container (read by docker-compose.yml, not by the app).
# Node sizes its heap from it: 1536m (the default) gives 792 MB. 1536m is the
# supported floor: below it a long-used account fills the heap in normal
# operation, and the boot log, the support report and Admin > System status
# say the app runs below the supported floor. The host needs 3 GB of RAM,
# 4 GB recommended. Raise it on a busy instance; do not lower it.
# APP_MEMORY_LIMIT="1536m"
# -----------------------------------------------------------------------------
# Outbound requests HealthLog makes on its own -- optional switches
# -----------------------------------------------------------------------------
@@ -568,14 +702,28 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
# force the public-host guard, so an internal instance is not blocked).
# OPENMETEO_BASE_URL="https://archive-api.open-meteo.com"
# OPENMETEO_GEOCODING_URL="https://geocoding-api.open-meteo.com"
#
# Air quality, pollen and UV come from the Open-Meteo air-quality API, which
# serves the Copernicus Atmosphere Monitoring Service (CAMS) models. CAMS data
# is free to use with attribution, which the app shows next to the values.
# Like the URLs above this one is optional and can point at a self-hosted
# Open-Meteo instance. Set ENVIRONMENT_AIR_QUALITY_DISABLED to 1, true or yes
# to stop the air-quality requests for the whole instance; weather and
# daylight keep working, and no user can turn air quality back on.
# OPENMETEO_AIR_QUALITY_URL="https://air-quality-api.open-meteo.com"
# ENVIRONMENT_AIR_QUALITY_DISABLED="1"
# ── Native canvas renderer (document thumbnails, scanned-PDF rasterization) ──
# The bundled renderer's x64 build uses AVX2 CPU instructions. On x86-64 hosts
# WITHOUT AVX2 (Celeron/Atom-class NAS CPUs) it is disabled automatically and
# those features degrade cleanly (no thumbnails; scanned PDFs read as text
# only). Override only if auto-detection misfires:
# On x86-64 the renderer is enabled only on CPUs with AVX2. On older chips
# (Celeron/Atom-class NAS CPUs) it is disabled automatically and those features
# degrade cleanly: no thumbnails, and an AI provider that cannot take PDF files
# reads a PDF by its text layer, so a scanned PDF needs a provider that takes
# PDFs directly (Anthropic) or photos of its pages. On arm64 the published image
# renders on every 64-bit core, the Raspberry Pi 4 included. Only a musl
# (Alpine) build of your own needs ARMv8.2 (LSE atomics, RCpc) and is gated the
# same way. Override only if auto-detection misfires:
# off — force-disable the renderer
# on — force-enable (crashes the container if the CPU truly lacks AVX2)
# on — force-enable (crashes PDF processing if the CPU truly lacks them)
# NATIVE_CANVAS="off"
@@ -593,6 +741,15 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
# LOG_SLOW_THRESHOLD_MS="3000"
# LOG_INCLUDE_STACK="false"
# Heap diagnostics, off by default. The process always logs its heap
# (runtime.heap.sampled) and, at 85 % of the limit, one report of the work in
# flight (runtime.heap.pressure). HEALTHLOG_HEAP_SNAPSHOT=1 additionally writes
# ONE heap snapshot at that moment into HEALTHLOG_DIAG_DIR (default /tmp). A
# snapshot contains decrypted health data and keys; enable it only to chase a
# crash and delete the file afterwards. See docs/ops/heap-diagnostics.md.
# HEALTHLOG_HEAP_SNAPSHOT="1"
# HEALTHLOG_DIAG_DIR="/tmp"
# Optional log shipping to a Loki instance. LOKI_ENDPOINT turns shipping on.
# Either form works: the base URL (http://loki:3100) or the full push URL
# (http://loki:3100/loki/api/v1/push). A Loki on your own network is fine,
+19 -3
View File
@@ -15,7 +15,7 @@ body:
id: version
attributes:
label: HealthLog version
description: Output of `/api/version` or the tag of the GHCR image you're running.
description: Settings, About shows it, as does `/api/version`, or the tag of the GHCR image you're running.
placeholder: "1.4.19"
validations:
required: true
@@ -55,11 +55,27 @@ body:
3. See error
validations:
required: true
- type: input
id: error-id
attributes:
label: Error ID
description: If the failure showed an error ID (eight characters, e.g. `3f9a2c1b`), paste it here. The operator finds the event with it under Admin, App logs.
placeholder: "3f9a2c1b"
- type: textarea
id: support-info
attributes:
label: Support info / support report
description: |
Everyone: Settings, About, "Copy support info".
Operators: Admin, System status, "Support report", then Copy.
Both are redacted (no names, emails, values or tokens); read them before you paste.
See https://github.com/MBombeck/HealthLog/blob/main/docs/self-hosting/support.md
render: text
- type: textarea
id: logs
attributes:
label: Relevant logs / screenshots
description: Paste any relevant log output (redact secrets) or attach screenshots. Wide-event logs from `/admin/app-logs` are useful.
label: Screenshots / other logs
description: Attach screenshots, or paste other log output if you have it. Redact secrets and personal data first.
render: shell
- type: input
id: browser
+3
View File
@@ -1,5 +1,8 @@
blank_issues_enabled: false
contact_links:
- name: Ideas and upvotes
url: https://github.com/MBombeck/HealthLog/discussions/categories/ideas?discussions_q=category%3AIdeas+sort%3Atop
about: Suggest an improvement or upvote the ones you want. One idea per discussion; the most-voted ones get looked at first.
- name: Documentation
url: https://docs.healthlog.dev
about: Setup, configuration, and feature documentation. Most usage questions are answered here.
+1 -1
View File
@@ -6,7 +6,7 @@ body:
- type: markdown
attributes:
value: |
Feature ideas welcome. HealthLog is a personal-health PWA — the bar for inclusion is high (privacy-first, offline-capable, self-hostable). Use this template to propose something concrete; for open-ended discussion, prefer a [Discussion](https://github.com/MBombeck/HealthLog/discussions) thread.
Feature ideas welcome. HealthLog is a personal-health PWA, and the bar for inclusion is high (privacy-first, offline-capable, self-hostable). Use this template to propose something concrete. For an idea you'd like others to weigh in on, or a small improvement, start it in [Ideas](https://github.com/MBombeck/HealthLog/discussions/categories/ideas) instead: people can upvote it there, and the most-voted ideas get looked at first (see #1114).
- type: textarea
id: problem
attributes:
+6 -3
View File
@@ -1,16 +1,16 @@
name: Translation feedback
description: Report a wording, grammar, or register problem in HealthLog's AI-initial locales (FR / ES / IT / PL).
description: Report a wording, grammar, or register problem in HealthLog's community-maintained locales (FR / ES / IT / PL / KO / NL / PT-BR).
title: "[Translation] "
labels: ["i18n", "translation-feedback"]
body:
- type: markdown
attributes:
value: |
HealthLog ships German and English as maintainer-curated translations. French, Spanish, Italian, and Polish ship as **AI-initial translations** — the prose passes the locale-integrity test (full key parity, no empty values) but has not been hand-reviewed for register or regional vocabulary.
HealthLog ships German and English as maintainer-curated translations. French, Spanish, Italian, Polish, Korean, Dutch, and Brazilian Portuguese ship as **AI-initial, community-maintained translations** — the prose passes the locale-integrity test (full key parity, no empty values) but has not been hand-reviewed for register or regional vocabulary.
This template captures concrete fixes you would like to see. Phrasing nuance, regional vocabulary (e.g. "Tú" vs "Usted"), gendered forms, and domain-specific health terms are all in scope. The maintainer applies fixes during the regular release cycle.
For wholly new locale requests (e.g. "please add Portuguese"), open a [Discussion](https://github.com/MBombeck/HealthLog/discussions) instead.
For wholly new locale requests (e.g. "please add Swedish"), open a [Discussion](https://github.com/MBombeck/HealthLog/discussions) instead.
- type: dropdown
id: locale
attributes:
@@ -20,6 +20,9 @@ body:
- Spanish (es)
- Italian (it)
- Polish (pl)
- Korean (ko)
- Dutch (nl)
- Brazilian Portuguese (pt-BR)
- Other (specify under "Surface")
validations:
required: true
+53 -1
View File
@@ -10,6 +10,41 @@ updates:
applies-to: security-updates
update-types:
- "patch"
# One weekly pull request per dependency kind instead of a trickle of
# single bumps: patch and minor updates travel together and pass the
# same checks once. Majors stay single so each gets its own review.
#
# The Next.js line travels alone. Its bumps are the ones that move
# URL state after history traversal (see the note on 16.4 below), and a
# red Next bump inside the group would hold every harmless update in
# it red as well.
production-weekly:
dependency-type: "production"
update-types:
- "patch"
- "minor"
exclude-patterns:
- "next"
development-weekly:
dependency-type: "development"
update-types:
- "patch"
- "minor"
exclude-patterns:
- "eslint-config-next"
- "@next/bundle-analyzer"
# No ignore for the Next.js line any more. 16.4.0 lost URL state after
# history traversal: with a sheet opened through `?doc=`, Back left it
# open, and `[` / `]` sometimes did not move `?day=` (3 of 40 repeated
# runs of three end-to-end journeys, 0 of 80 on 16.3.8). 16.4.2 does the
# same (the day keys lost a step in 2 of 20 runs locally, the `?doc=`
# journey failed once in CI). Both were ours to fix: the sheet followed
# `useSearchParams`, which 16.4 updates after the write it copies, and a
# day step wrote the URL before Next had adopted the previous step, whose
# late adoption then put the older URL back. The sheet now follows the
# address bar and a step waits for the layer. Measured after both fixes:
# 200 of 200 repeated runs of the three journeys, and another 80 of 80
# each for the `?doc=` and day-key journeys.
- package-ecosystem: "github-actions"
directory: "/"
schedule:
@@ -29,6 +64,21 @@ updates:
schedule:
interval: "weekly"
open-pull-requests-limit: 3
ignore:
# The Dockerfile's `pgclient` stage carries pg_dump and pg_restore of
# the bundled database's major. A major bump there is part of the same
# migration decision as the database itself, and the runner's paths and
# version checks name major 18, so it arrives by hand, not as a PR. Its
# minor moves with the database's in docker-compose.yml
# (runtime-package-pins.test.ts fails while the two differ).
- dependency-name: "postgres"
update-types: ["version-update:semver-major"]
# The Node major in the build and runner stages is the one `engines`,
# `.nvmrc` and `.node-version` name, and the native modules are built
# against it. Moving it is a release decision, so it arrives by hand.
# Digest and minor updates of the pinned major still come as PRs.
- dependency-name: "node"
update-types: ["version-update:semver-major"]
# The `docker` ecosystem above reads the Dockerfile only. The bundled
# database lives in `docker-compose.yml`, which GitHub treats as its own
# ecosystem, so without this block a new PostgreSQL minor release never
@@ -44,6 +94,8 @@ updates:
# The first run of this ecosystem proposed 16.15 -> 18.6, which would
# have handed self-hosters a database their data directory cannot be
# read by. Minor releases still arrive as PRs; a major is a deliberate
# project decision with a migration guide behind it.
# project decision with a migration guide behind it. The ignore also
# keeps `db-upgrade` on 16: it exists to read Postgres 16 data, and a
# bump to 18 would leave it unable to start on the volume it moves.
- dependency-name: "postgres"
update-types: ["version-update:semver-major"]
+2 -2
View File
@@ -26,7 +26,7 @@ permissions:
jobs:
coach-eval:
name: Coach eval — deterministic floor (always) + live judge (opt-in)
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
timeout-minutes: 15
# Map the opt-in secret to a job-level env so the live-judge steps can gate
@@ -41,7 +41,7 @@ jobs:
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: 22
cache: pnpm
+3 -3
View File
@@ -36,7 +36,7 @@ concurrency:
jobs:
analyze:
name: Analyse javascript-typescript
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
timeout-minutes: 30
permissions:
contents: read
@@ -60,7 +60,7 @@ jobs:
# minutes and can produce nothing actionable, because nobody edits
# it and the fix for anything found there is `pnpm prisma generate`.
- name: Initialise CodeQL
uses: github/codeql-action/init@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
uses: github/codeql-action/init@33d7b85da1a04171e211befe2c739303b9a4c066 # v4.39.3
with:
languages: javascript-typescript
build-mode: none
@@ -75,6 +75,6 @@ jobs:
- public
- name: Analyse
uses: github/codeql-action/analyze@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
uses: github/codeql-action/analyze@33d7b85da1a04171e211befe2c739303b9a4c066 # v4.39.3
with:
category: "/language:javascript-typescript"
+51
View File
@@ -0,0 +1,51 @@
name: Compose database move
# Plays the Postgres 16 -> 18 move in docker-compose.yml against real
# containers: a v1.45 database with every migration applied, then the current
# compose file's `db-upgrade` on its first start, a second start, a move cut
# short, an emptied new volume and a new install. The service runs once on an
# operator's host and has no second chance there, so every change to the
# compose file or to the check runs it here first.
# `scripts/compose-db-upgrade-check.sh` is the same check, runnable locally.
on:
pull_request:
branches: [main]
paths:
- docker-compose.yml
- scripts/compose-db-upgrade-check.sh
- .github/workflows/compose-db-upgrade.yml
push:
branches: [main]
paths:
- docker-compose.yml
- scripts/compose-db-upgrade-check.sh
- .github/workflows/compose-db-upgrade.yml
workflow_dispatch:
permissions:
contents: read
jobs:
move:
name: Postgres 16 -> 18 move
runs-on: ubuntu-24.04
timeout-minutes: 20
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# The check reads the v1.45 compose file from its tag.
- name: Fetch the v1.45.0 tag
run: git fetch --no-tags --depth=1 origin tag v1.45.0
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- name: Move check
run: bash scripts/compose-db-upgrade-check.sh
+70
View File
@@ -0,0 +1,70 @@
name: Disaster recovery drill
# Rebuilds a lost server from the nightly database dump and the keys alone,
# against real containers: the app image built from this checkout, the
# bundled compose file with an S3-compatible bucket outside it, the demo seed
# plus a Guardian with a managed profile, the dump taken and uploaded by the
# scheduled job, every volume of the stack destroyed, a new stack, the
# restore, and the app booted on the result (health, login, an encrypted
# note, a document, the Guardian's profile, every table's row count against
# the manifest). Two sources: from the bucket (`db-restore latest
# --from-bucket`, the disaster path) and from dump files copied off the host
# (`db-restore latest`).
# `scripts/compose-dr-drill.sh` is the same drill, runnable locally; its
# header lists what it checks and the mutations that must turn it red.
on:
pull_request:
branches: [main]
paths:
- docker-compose.yml
- Dockerfile
- docker-entrypoint.sh
- scripts/compose-dr-drill.sh
- scripts/dr-drill-seed.ts
- scripts/db-dump.mjs
- scripts/db-restore.mjs
- scripts/pending-migrations.mjs
- src/lib/database-backup/**
- src/lib/jobs/database-backup-queue.ts
- src/lib/jobs/reminder/backup-handlers.ts
- .github/workflows/compose-dr-drill.yml
schedule:
# Nightly, 02:41 UTC: off the hour, after the evening merges.
- cron: "41 2 * * *"
workflow_dispatch:
permissions:
contents: read
concurrency:
group: compose-dr-drill-${{ github.ref }}
cancel-in-progress: true
jobs:
drill:
name: Restore a lost server from its dump (${{ matrix.source }})
runs-on: ubuntu-24.04
timeout-minutes: 60
strategy:
fail-fast: false
matrix:
source: [bucket, local]
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: 22
cache: pnpm
# The seed scripts run from the checkout against the drill's database.
- run: pnpm install --frozen-lockfile
- run: pnpm db:generate
- name: Drill
env:
DRILL_SOURCE: ${{ matrix.source }}
run: bash scripts/compose-dr-drill.sh
+2 -2
View File
@@ -11,7 +11,7 @@ permissions:
jobs:
auto-merge:
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
if: github.actor == 'dependabot[bot]'
permissions:
contents: write
@@ -22,7 +22,7 @@ jobs:
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: 22
cache: "pnpm"
+8 -8
View File
@@ -39,7 +39,7 @@ concurrency:
jobs:
release-policy:
name: Validate release policy
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
timeout-minutes: 5
permissions:
actions: read
@@ -121,7 +121,7 @@ jobs:
matrix:
include:
- platform: linux/amd64
runner: ubuntu-latest
runner: ubuntu-24.04
cache_scope: amd64
- platform: linux/arm64
runner: ubuntu-24.04-arm
@@ -150,7 +150,7 @@ jobs:
# Image is built but not pushed in that case.
- name: Log in to GitHub Container Registry
if: github.event_name != 'pull_request'
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
@@ -296,7 +296,7 @@ jobs:
- name: Upload digest artifact
if: github.event_name != 'pull_request'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: digest-${{ matrix.cache_scope }}
path: /tmp/digests/*
@@ -313,7 +313,7 @@ jobs:
# the registry instead of on one cross-emulated runner.
merge:
name: Merge multi-arch manifest
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
if: github.event_name != 'pull_request'
needs: [build]
timeout-minutes: 10
@@ -334,7 +334,7 @@ jobs:
steps:
- name: Download digest artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
path: /tmp/digests
pattern: digest-*
@@ -350,7 +350,7 @@ jobs:
echo "IMAGE_REF=${REGISTRY}/${IMAGE_NAME,,}" >> "$GITHUB_ENV"
- name: Log in to GitHub Container Registry
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
@@ -449,7 +449,7 @@ jobs:
promote:
name: Promote verified image to Coolify
needs: [merge, verify-published-image]
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
timeout-minutes: 5
permissions:
contents: read
+148 -47
View File
@@ -23,9 +23,16 @@ jobs:
# Two shards grew to 14–19 minutes against the 20-minute cap once the
# mobile route sweep landed, and a slow runner tipped them over; three
# shards with a 30-minute cap leave room for both.
# Every Playwright job runs inside Microsoft's Playwright image, which ships
# the browsers with their system libraries: installing them from apt at run
# time stalled shards for 29 minutes twice, once for WebKit and once for
# Chromium alone.
shard:
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
needs: playwright-version
timeout-minutes: 30
container:
image: mcr.microsoft.com/playwright:v${{ needs.playwright-version.outputs.version }}-noble
strategy:
fail-fast: false
matrix:
@@ -33,13 +40,11 @@ jobs:
name: e2e shard ${{ matrix.shard }}/3
services:
postgres:
image: postgres:16-alpine
image: postgres:18-alpine
env:
POSTGRES_USER: healthlog
POSTGRES_PASSWORD: healthlog
POSTGRES_DB: healthlog
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U healthlog"
--health-interval 5s
@@ -47,7 +52,126 @@ jobs:
--health-retries 10
env:
DATABASE_URL: postgresql://healthlog:healthlog@localhost:5432/healthlog?schema=public
# Inside a job container the service is reached by its name.
DATABASE_URL: postgresql://healthlog:healthlog@postgres:5432/healthlog?schema=public
ENCRYPTION_KEYS: '{"v1":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}'
ENCRYPTION_ACTIVE_KEY_ID: v1
API_TOKEN_HMAC_KEY: bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb
AUTH_RP_NAME: HealthLog
AUTH_RP_ID: localhost
AUTH_RP_ORIGIN: http://localhost:3000
SESSION_COOKIE_SECURE: "false"
NODE_ENV: production
# The WebKit specs run in the `webkit` job below.
E2E_SKIP_WEBKIT: "1"
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# The image runs as root while the checkout belongs to the runner user;
# git refuses such a tree, and the prepare hook calls git.
- run: git config --global --add safe.directory "$GITHUB_WORKSPACE"
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm db:generate
- run: pnpm db:migrate:deploy
# `pnpm build` (not bare `next build`): the prebuild hook generates
# the versioned service-worker stamp AND the static i18n catalog
# assets (public/i18n/<locale>.js) the client boots from — without
# them every UI interaction waits on missing catalogs.
# The repository-wide Next type worker exceeds V8's ~4 GiB default
# old-space ceiling. Keep the larger heap scoped to this production
# build only; tests and the runtime retain their normal limits.
- run: NODE_OPTIONS=--max-old-space-size=8192 pnpm build
# Client-bundle budget gate — rides this job because it is the one
# place CI already has a production build on disk. Measures the first
# load of every page (files deduplicated by content hash) against its
# baseline, the p95 and max across pages, and the distinct client code
# (modules deduplicated by id); see the script header. Catches a
# statically imported message catalog, a duplicated recharts chunk, or
# a heavy module (Zod) pulled into a page that had none.
- run: pnpm bundle-check
- run: pnpm exec playwright test --shard=${{ matrix.shard }}/3
env:
E2E_BASE_URL: http://localhost:3000
- name: Upload Playwright HTML report
if: always()
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: playwright-report-shard-${{ matrix.shard }}
path: playwright-report/
retention-days: 7
if-no-files-found: ignore
# The machine-readable report, for the skipped-spec check in the gate
# job below. Only uploaded when the shard ran to completion, so a missing
# artifact there means a shard that did not finish, not an empty suite.
- name: Upload Playwright JSON report
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: playwright-json-shard-${{ matrix.shard }}
path: playwright-json/results.json
retention-days: 7
if-no-files-found: error
# The Playwright image must carry the browsers of the exact Playwright the
# lockfile resolves, or the run refuses them. Read the version from the
# lockfile so a dependency bump moves the image with it.
playwright-version:
runs-on: ubuntu-24.04
timeout-minutes: 5
outputs:
version: ${{ steps.read.outputs.version }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
sparse-checkout: pnpm-lock.yaml
sparse-checkout-cone-mode: false
- id: read
run: |
version=$(grep -m1 -E "^ playwright-core@[0-9]" pnpm-lock.yaml | sed -E "s/^ playwright-core@([0-9.]+):.*/\1/")
if [ -z "$version" ]; then echo "playwright-core not found in pnpm-lock.yaml"; exit 1; fi
echo "version=$version" >> "$GITHUB_OUTPUT"
# The WebKit specs, in the same image as the shards: the bottom sheet on an
# iPhone (`bottom-sheet.spec.ts`, project `webkit-iphone`) and the app shell
# on an iPad (`tablet-shell.spec.ts`, project `webkit-tablet`).
webkit:
runs-on: ubuntu-24.04
needs: playwright-version
timeout-minutes: 30
name: e2e webkit
container:
image: mcr.microsoft.com/playwright:v${{ needs.playwright-version.outputs.version }}-noble
services:
postgres:
image: postgres:18-alpine
env:
POSTGRES_USER: healthlog
POSTGRES_PASSWORD: healthlog
POSTGRES_DB: healthlog
options: >-
--health-cmd "pg_isready -U healthlog"
--health-interval 5s
--health-timeout 5s
--health-retries 10
env:
# Inside a job container the service is reached by its name.
DATABASE_URL: postgresql://healthlog:healthlog@postgres:5432/healthlog?schema=public
ENCRYPTION_KEYS: '{"v1":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}'
ENCRYPTION_ACTIVE_KEY_ID: v1
API_TOKEN_HMAC_KEY: bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb
@@ -60,9 +184,13 @@ jobs:
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# The image runs as root while the checkout belongs to the runner user;
# git refuses such a tree, and the prepare hook calls git.
- run: git config --global --add safe.directory "$GITHUB_WORKSPACE"
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: 22
cache: pnpm
@@ -73,70 +201,43 @@ jobs:
- run: pnpm db:migrate:deploy
- name: Cache Playwright browsers
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
playwright-${{ runner.os }}-
- run: pnpm exec playwright install --with-deps chromium
# `pnpm build` (not bare `next build`): the prebuild hook generates
# the versioned service-worker stamp AND the static i18n catalog
# assets (public/i18n/<locale>.js) the client boots from — without
# them every UI interaction waits on missing catalogs.
# The repository-wide Next type worker exceeds V8's ~4 GiB default
# old-space ceiling. Keep the larger heap scoped to this production
# build only; tests and the runtime retain their normal limits.
- run: NODE_OPTIONS=--max-old-space-size=8192 pnpm build
# Client-bundle budget gate — rides this job because it is the one
# place CI already has a production build on disk. Catches the
# regressions the dead webpack-era tooling (ANALYZE=1, the old
# bundle-report) let through: a statically imported message catalog
# (~100 KB gz on every route) or a chart import bypassing the shared
# chart-runtime boundary (a duplicated ~92 KB gz recharts chunk).
- run: pnpm bundle-check
- run: pnpm exec playwright test --shard=${{ matrix.shard }}/3
- run: pnpm exec playwright test --project=webkit-iphone --project=webkit-tablet
env:
E2E_BASE_URL: http://localhost:3000
- name: Upload Playwright HTML report
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: playwright-report-shard-${{ matrix.shard }}
name: playwright-report-webkit
path: playwright-report/
retention-days: 7
if-no-files-found: ignore
# The machine-readable report, for the skipped-spec check in the gate
# job below. Only uploaded when the shard ran to completion, so a missing
# artifact there means a shard that did not finish, not an empty suite.
- name: Upload Playwright JSON report
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: playwright-json-shard-${{ matrix.shard }}
name: playwright-json-shard-webkit
path: playwright-json/results.json
retention-days: 7
if-no-files-found: error
# Branch protection requires a check named exactly "e2e". This job carries
# that name and answers for both shards: it fails when any shard failed or
# was cancelled, so a half-green matrix cannot satisfy the gate.
# that name and answers for every shard and the WebKit job: it fails when
# any of them failed or was cancelled, so a half-green run cannot satisfy
# the gate.
e2e:
runs-on: ubuntu-latest
needs: shard
runs-on: ubuntu-24.04
needs: [shard, webkit]
if: always()
timeout-minutes: 5
steps:
- name: Every shard finished green
run: |
if [ "${{ needs.shard.result }}" != "success" ]; then
echo "shard result: ${{ needs.shard.result }}"
if [ "${{ needs.shard.result }}" != "success" ] || [ "${{ needs.webkit.result }}" != "success" ]; then
echo "shard result: ${{ needs.shard.result }}, webkit result: ${{ needs.webkit.result }}"
exit 1
fi
@@ -146,10 +247,10 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
sparse-checkout: scripts
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: 22
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
pattern: playwright-json-shard-*
path: playwright-json
+2 -2
View File
@@ -21,7 +21,7 @@ permissions:
jobs:
env-check:
name: pnpm check-env against .env.production.example
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- name: Checkout
@@ -31,7 +31,7 @@ jobs:
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
- name: Set up Node
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: 22
cache: pnpm
+45
View File
@@ -0,0 +1,45 @@
name: Integration tests (long)
# The integration tests that take their size from the environment run small
# on every pull request and at full size here, once a night. Today that is
# the Google Health multi-year backfill: 120 and 160 days of heart rate on a
# pull request, three and four years (some 3.7 million readings, over twenty
# minutes) here. See the header of
# tests/integration/google-health-backfill-multi-year.test.ts.
on:
schedule:
- cron: "17 3 * * *"
workflow_dispatch:
permissions:
contents: read
jobs:
long:
runs-on: ubuntu-24.04
timeout-minutes: 75
name: integration long
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm db:generate
- run: pnpm exec vitest run --config vitest.integration.config.mts tests/integration/google-health-backfill-multi-year.test.ts
env:
HEALTHLOG_BACKFILL_TEST_DAYS: "1095"
# Under the production heap ceiling, which the test's heap check
# is about.
NODE_OPTIONS: "--max-old-space-size=524"
# Quoted for the reason given in integration.yml.
API_TOKEN_HMAC_KEY: "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"
ENCRYPTION_KEY: "0000000000000000000000000000000000000000000000000000000000000000"
SESSION_SECRET: "integration-tests"
+33 -16
View File
@@ -10,20 +10,23 @@ permissions:
contents: read
jobs:
# The suite outgrew a single runner, then a half-suite outgrew a slow one,
# and then a third of it did too. Three shards of about 68 files cleared in
# 13 minutes on an ordinary runner, but a slow runner day ran every test at
# twice its usual duration and the first shard was still working through
# its list when the 25-minute guard cut it off: three of four pushes to main
# between 2026-08-31 and 2026-09-01 failed that way, with green tests logged
# up to the last second. Four shards keep each slice around 50 files, and
# the guard sits at three times the ordinary run rather than under two. A
# wedged test still surfaces through vitest's own 60-second budget, so the
# job timeout only needs to catch a container that never boots. Each shard
# boots its own Postgres testcontainer.
# Four shards of about equal duration, each running its files in parallel
# against one database per worker. The shards used to be four slices of
# equal file count, run serially: 954 s to 1 449 s, because a slice holding
# the sharing matrix and the backup files took half again as long as one
# holding small files. `--shard` now goes through the duration sequencer
# (`tests/durations/sequencer.ts`, timings in
# `tests/durations/integration.json`), and every file in a shard runs on
# one of three workers, each against its own clone of the migrated template
# (`tests/integration/worker-database.ts`). Three workers leave a core of
# the four-core runner to the Postgres container. The job guard stays well
# above an ordinary run; a wedged test still surfaces through vitest's own
# 60-second budget, so the guard only needs to catch a container that never
# boots. Each shard boots its own Postgres testcontainer and uploads its
# JSON report, which `scripts/test-durations.mjs` turns into the timing file.
shard:
runs-on: ubuntu-latest
timeout-minutes: 40
runs-on: ubuntu-24.04
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
@@ -34,7 +37,7 @@ jobs:
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: 22
cache: pnpm
@@ -48,8 +51,13 @@ jobs:
# vitest a stray positional argument and the shard flag never applies —
# both "shards" then run the full suite. Proven by a paired local run
# before this workflow shipped.
- run: pnpm exec vitest run --config vitest.integration.config.mts --shard=${{ matrix.shard }}/4
- run: >-
pnpm exec vitest run --config vitest.integration.config.mts
--shard=${{ matrix.shard }}/4
--reporter=default --reporter=json
--outputFile.json=integration-report-${{ matrix.shard }}.json
env:
HEALTHLOG_INTEGRATION_WORKERS: "3"
# The integration suite boots a Postgres testcontainer and points
# Prisma at it via DATABASE_URL set inside `tests/integration/setup.ts`.
# These env vars satisfy code paths that read them at module load.
@@ -63,11 +71,20 @@ jobs:
ENCRYPTION_KEY: "0000000000000000000000000000000000000000000000000000000000000000"
SESSION_SECRET: "integration-tests"
- name: Upload the timing report
if: always()
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: integration-report-${{ matrix.shard }}
path: integration-report-${{ matrix.shard }}.json
if-no-files-found: ignore
retention-days: 14
# Branch protection requires a check named exactly "integration". This job
# carries that name and answers for every shard: it fails when any shard
# failed or was cancelled, so a half-green matrix cannot satisfy the gate.
integration:
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
needs: shard
if: always()
timeout-minutes: 5
+2 -2
View File
@@ -12,7 +12,7 @@ permissions:
jobs:
knip:
name: Dead-code gate
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
@@ -20,7 +20,7 @@ jobs:
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: 22
cache: pnpm
+53
View File
@@ -0,0 +1,53 @@
name: Mutation tests
# Stryker on the core libraries (stryker.config.mjs): encryption at rest,
# medication compliance, rollups, source ranking, unit conversion and backup
# validation. Too slow for a pull request; it runs once a night and on
# demand, and fails when a module's score drops below its floor in
# stryker.floors.json. The HTML report is uploaded either way.
on:
schedule:
- cron: "23 1 * * *"
workflow_dispatch:
permissions:
contents: read
jobs:
mutation:
runs-on: ubuntu-24.04
timeout-minutes: 120
name: mutation (core libraries)
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm db:generate
- name: Run Stryker
run: pnpm exec stryker run --concurrency 4
# Without pipefail the step takes the exit status of `tee`, which is
# always 0, so a module under its floor could never fail the run.
- name: Check the per-module floors
shell: bash
run: |
set -o pipefail
node scripts/mutation-floors.mjs | tee -a "$GITHUB_STEP_SUMMARY"
- name: Upload the report
if: always()
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: mutation-report
path: reports/mutation/
if-no-files-found: ignore
retention-days: 30
+1 -1
View File
@@ -16,7 +16,7 @@ jobs:
# future contributor reintroduces the pattern.
grep:
name: Forbidden marker grep
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+3 -3
View File
@@ -45,7 +45,7 @@ permissions:
jobs:
verify:
name: Pull and smoke-test exact published image
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
timeout-minutes: 10
env:
IMAGE_REF: ${{ inputs.image_ref }}
@@ -56,7 +56,7 @@ jobs:
services:
postgres:
image: postgres:16-alpine
image: postgres:18-alpine
env:
POSTGRES_USER: healthlog
POSTGRES_PASSWORD: healthlog
@@ -71,7 +71,7 @@ jobs:
steps:
- name: Log in to GHCR
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
with:
registry: ghcr.io
username: ${{ github.actor }}
+3 -3
View File
@@ -25,7 +25,7 @@ permissions: read-all
jobs:
analysis:
name: Score supply-chain practices
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
timeout-minutes: 15
permissions:
# Write the findings into the repository's code-scanning alerts.
@@ -47,14 +47,14 @@ jobs:
persist-credentials: false
- name: Run analysis
uses: ossf/scorecard-action@2d1146689b8cda280b9bc96326124645441f03bc # v2.4.4
uses: ossf/scorecard-action@fa7117db83c52d787ae5a89f350999701505d78e # v2.5.0
with:
results_file: scorecard-results.sarif
results_format: sarif
publish_results: true
- name: Upload results
uses: github/codeql-action/upload-sarif@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
uses: github/codeql-action/upload-sarif@33d7b85da1a04171e211befe2c739303b9a4c066 # v4.39.3
with:
sarif_file: scorecard-results.sarif
category: scorecard
+114 -39
View File
@@ -15,9 +15,11 @@ permissions:
contents: read
jobs:
quality:
name: Lint, Typecheck & Test
runs-on: ubuntu-latest
# The static checks: everything in the old single quality job except the
# unit suite, which runs sharded below.
static:
name: Lint, Typecheck & OpenAPI
runs-on: ubuntu-24.04
if: github.event_name != 'schedule'
steps:
@@ -25,7 +27,7 @@ jobs:
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: 22
cache: "pnpm"
@@ -34,26 +36,6 @@ jobs:
- run: pnpm db:generate
# The hero-geometry check in the unit suite renders the real panel in a
# real browser and measures the boxes. Without a browser here it caught
# its own launch failure and passed anyway, so from its first day until
# v1.38 it measured nothing on any run of this job. The browser is part
# of the gate now; the test refuses to skip when CI is set.
#
# Headless shell only: `chromium.launch()` resolves to
# chromium_headless_shell, which is what the failing runs named, and the
# full headed build is several times the download for nothing this job
# uses. The e2e job keeps `--with-deps chromium` — it drives a real app.
- name: Cache Playwright headless shell
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ~/.cache/ms-playwright
key: playwright-shell-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
playwright-shell-${{ runner.os }}-
- run: pnpm exec playwright install --only-shell chromium
# Blocking since v1.15.20 — the settings-split refactor that kept
# this warn-only is long done and the tree lints clean.
- name: ESLint
@@ -68,9 +50,6 @@ jobs:
- name: TypeScript strict check
run: pnpm typecheck
- name: Unit tests
run: pnpm test
# OpenAPI spec drift gate — hard-fail (flipped from warn-only in v1.4.25).
#
# The Zod registry is the source of truth for the public API contract.
@@ -87,6 +66,91 @@ jobs:
fi
echo "::notice::OpenAPI spec in sync with Zod source schemas."
# The unit suite in three slices of about equal duration. `--shard` goes
# through the duration sequencer in `vitest.config.mts`, which deals files
# out by their measured time (`tests/durations/unit.json`) instead of by
# count. Each slice uploads its JSON report, which is what
# `scripts/test-durations.mjs` regenerates the timing file from.
unit:
runs-on: ubuntu-24.04
if: github.event_name != 'schedule'
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3]
name: unit shard ${{ matrix.shard }}/3
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: 22
cache: "pnpm"
- run: pnpm install --frozen-lockfile
- run: pnpm db:generate
# The hero-geometry check in the unit suite renders the real panel in a
# real browser and measures the boxes. Without a browser here it caught
# its own launch failure and passed anyway, so from its first day until
# v1.38 it measured nothing on any run of this job. The browser is part
# of the gate now; the test refuses to skip when CI is set. Every slice
# installs it, because the sequencer decides which slice the file lands in.
#
# Headless shell only: `chromium.launch()` resolves to
# chromium_headless_shell, which is what the failing runs named, and the
# full headed build is several times the download for nothing this job
# uses. The e2e job keeps `--with-deps chromium` — it drives a real app.
- name: Cache Playwright headless shell
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ~/.cache/ms-playwright
key: playwright-shell-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
playwright-shell-${{ runner.os }}-
- run: pnpm exec playwright install --only-shell chromium
# vitest directly, not `pnpm test --`: pnpm hands the separator through
# literally and the shard flag would never reach vitest.
- name: Unit tests
run: >-
pnpm exec vitest run --shard=${{ matrix.shard }}/3
--reporter=default --reporter=json
--outputFile.json=unit-report-${{ matrix.shard }}.json
- name: Upload the timing report
if: always()
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: unit-report-${{ matrix.shard }}
path: unit-report-${{ matrix.shard }}.json
if-no-files-found: ignore
retention-days: 14
# Branch protection requires a check named exactly "Lint, Typecheck & Test".
# That used to be one job running everything in turn; it is now this one,
# which answers for the static checks and every unit slice, so a half-green
# set cannot satisfy the gate.
quality:
name: Lint, Typecheck & Test
runs-on: ubuntu-24.04
needs: [static, unit]
if: always() && github.event_name != 'schedule'
timeout-minutes: 5
steps:
- name: The static checks and every unit slice finished green
run: |
if [ "${{ needs.static.result }}" != "success" ] || [ "${{ needs.unit.result }}" != "success" ]; then
echo "static: ${{ needs.static.result }}, unit: ${{ needs.unit.result }}"
exit 1
fi
# Not on pull requests. `pnpm audit` answers a question about the published
# advisory database, not about the diff: the same lockfile passes on Monday
# and fails on Tuesday because someone else published, and the branch that
@@ -100,7 +164,7 @@ jobs:
# published image daily.
dependency-audit:
name: Dependency Audit
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
# Runs on pull requests too, but advisory: an advisory published against a
# dependency the branch did not touch must not block the branch. The push
# run on main and the daily schedule stay blocking, and a tag is only ever
@@ -112,7 +176,7 @@ jobs:
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: 22
cache: "pnpm"
@@ -130,7 +194,7 @@ jobs:
secret-scan:
name: Secret Scanning
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
if: github.event_name != 'schedule'
steps:
@@ -145,7 +209,7 @@ jobs:
docker-security:
name: Container Security
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
steps:
@@ -181,12 +245,23 @@ jobs:
rm trivy-install.sh
trivy --version
# Blocking since v1.15.20 — a HIGH/CRITICAL finding in the built
# image fails the job. For a finding without an upstream fix
# (e.g. a base-image CVE Alpine has not patched yet), add the CVE
# id to a `.trivyignore` file at the repository root — Trivy reads
# it automatically — one CVE per line, each with a comment naming
# the tracking issue and the re-check condition. Never suppress a
# finding that has a fixed version available; bump instead.
# Every HIGH/CRITICAL finding in the built image, fixed or not, as a
# table in the job log. Reporting only: a Debian base carries
# advisories the distribution has not fixed yet (or marked as not
# worth a fix), and nothing this repository changes can remove those.
# The list is here so they stay visible and move to the gate below
# the day a fix is published.
- name: Trivy container scan (all findings)
run: trivy image --format table --severity CRITICAL,HIGH --exit-code 0 healthlog:scan
# Blocking since v1.15.20 — a HIGH/CRITICAL finding with a published
# fix fails the job. `--ignore-unfixed` leaves out only the findings
# listed above that have no fixed version yet; a fix that reaches the
# Debian archive, the Node image or a dependency turns this red until
# it is taken (rebuild, bump the base digest, or bump the package).
# `.trivyignore` at the repository root (Trivy reads it automatically)
# is for a finding with a fix that cannot reach this image, one CVE
# per line with a comment naming what it covers and when to remove
# it. Never suppress a finding whose fix applies here; take it.
- name: Trivy container scan
run: trivy image --format table --severity CRITICAL,HIGH --exit-code 1 healthlog:scan
run: trivy image --format table --severity CRITICAL,HIGH --ignore-unfixed --exit-code 1 healthlog:scan
+2 -2
View File
@@ -45,13 +45,13 @@ permissions:
jobs:
journey:
name: Sign in, write a reading, read it back
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: 22
+182
View File
@@ -0,0 +1,182 @@
name: Tests (nightly, shuffled)
# Looks for what a pull request run cannot see by design.
#
# - Order dependence. Both suites run with files and tests in a random order
# (`--sequence.shuffle`), seeded from the clock and printed at the top of
# the log and in the job summary. A failure that does not reproduce on a
# pull request reproduces here with `--sequence.seed=<seed>`.
# - New property-test inputs. Pull requests run every fast-check property
# with a fixed seed; this run picks a fresh one (`FC_SEED`) and five times
# the cases, and fast-check prints the seed and the shrunk counterexample
# on a failure (see tests/property/fast-check.ts).
# - Stale shard timings. The integration reports of this run are merged into
# a regenerated `tests/durations/integration.json`, uploaded as an
# artifact for a maintainer to commit when the shards drift apart.
#
# Not a gate: nothing here blocks a merge. A red run is an issue to open.
on:
schedule:
- cron: "41 2 * * *"
workflow_dispatch:
permissions:
contents: read
jobs:
unit:
runs-on: ubuntu-24.04
timeout-minutes: 45
name: unit (shuffled)
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm db:generate
- name: Cache Playwright headless shell
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ~/.cache/ms-playwright
key: playwright-shell-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
playwright-shell-${{ runner.os }}-
- run: pnpm exec playwright install --only-shell chromium
- name: Pick the seeds
run: |
echo "TEST_SEED=$(date +%s)" >> "$GITHUB_ENV"
echo "FC_SEED=$(( $(date +%s%N) % 2147483647 ))" >> "$GITHUB_ENV"
- name: Unit tests, shuffled
shell: bash
env:
# Five times each property's own case count (tests/property/fast-check.ts).
FC_RUNS_FACTOR: "5"
run: |
set -o pipefail
echo "Order seed ${TEST_SEED}, property seed ${FC_SEED}" | tee -a "$GITHUB_STEP_SUMMARY"
pnpm exec vitest run --sequence.shuffle --sequence.seed="${TEST_SEED}" \
--reporter=default --reporter=json --outputFile.json=unit-report.json
- name: Upload the timing report
if: always()
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: nightly-unit-report
path: unit-report.json
if-no-files-found: ignore
retention-days: 14
integration:
runs-on: ubuntu-24.04
timeout-minutes: 45
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4]
name: integration shard ${{ matrix.shard }}/4 (shuffled, Postgres 16)
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm db:generate
# One seed per run, shared by the shards through the run id, so every
# shard shuffles the same way and a failure names one seed to replay.
- name: Pick the seeds
run: |
echo "TEST_SEED=${{ github.run_id }}" >> "$GITHUB_ENV"
echo "FC_SEED=$(( ${{ github.run_id }} % 2147483647 ))" >> "$GITHUB_ENV"
- name: Integration tests, shuffled
shell: bash
env:
HEALTHLOG_INTEGRATION_WORKERS: "3"
# Pull requests run the suite on Postgres 18, the compose default.
# Installs that bring their own database may still be on 16, so the
# nightly run keeps that major covered without doubling every PR.
INTEGRATION_PG_IMAGE: "postgres:16-alpine"
# Four times each property's own case count; a property's timeout
# grows by the same factor (`propertyTimeout`).
FC_RUNS_FACTOR: "4"
# Quoted for the reason given in integration.yml.
API_TOKEN_HMAC_KEY: "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"
ENCRYPTION_KEY: "0000000000000000000000000000000000000000000000000000000000000000"
SESSION_SECRET: "integration-tests"
run: |
set -o pipefail
echo "Order seed ${TEST_SEED}, property seed ${FC_SEED}" | tee -a "$GITHUB_STEP_SUMMARY"
pnpm exec vitest run --config vitest.integration.config.mts \
--shard=${{ matrix.shard }}/4 \
--sequence.shuffle --sequence.seed="${TEST_SEED}" \
--reporter=default --reporter=json \
--outputFile.json=integration-report-${{ matrix.shard }}.json
- name: Upload the timing report
if: always()
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: nightly-integration-report-${{ matrix.shard }}
path: integration-report-${{ matrix.shard }}.json
if-no-files-found: ignore
retention-days: 14
# Merge the timings of this run into fresh timing files. Uploaded, not
# committed: the workflow has read-only access, and a maintainer decides
# whether the drift is worth a commit.
durations:
runs-on: ubuntu-24.04
needs: [unit, integration]
if: always()
timeout-minutes: 10
name: regenerate shard timings
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: 22
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
pattern: nightly-*-report*
path: reports
- name: Regenerate
shell: bash
run: |
set -o pipefail
shopt -s nullglob
integration=(reports/nightly-integration-report-*/*.json)
unit=(reports/nightly-unit-report/*.json)
if [ ${#integration[@]} -gt 0 ]; then
node scripts/test-durations.mjs integration "${integration[@]}"
fi
if [ ${#unit[@]} -gt 0 ]; then
node scripts/test-durations.mjs unit "${unit[@]}"
fi
git diff --stat -- tests/durations | tee -a "$GITHUB_STEP_SUMMARY"
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: test-durations
path: tests/durations/*.json
retention-days: 14
+16 -6
View File
@@ -6,7 +6,7 @@ name: Trivy Scheduled Scan
# - `security.yml` → `dependency-audit` runs `pnpm audit --prod` on every
# push to `main` and once a day, blocking on HIGH/CRITICAL.
# - `security.yml` → `docker-security` builds the image on a push to `main`
# and runs `trivy image` against it, blocking on HIGH/CRITICAL.
# and runs `trivy image` against it, blocking on HIGH/CRITICAL with a fix.
#
# Both are change-triggered, and both answer "is this change safe". Neither
# answers "is what we shipped last week still safe", because an advisory
@@ -30,7 +30,7 @@ concurrency:
jobs:
filesystem:
name: Lockfile (including dev dependencies)
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
timeout-minutes: 20
permissions:
contents: read
@@ -88,14 +88,14 @@ jobs:
skip-dirs: src/generated
- name: Upload results
uses: github/codeql-action/upload-sarif@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
uses: github/codeql-action/upload-sarif@33d7b85da1a04171e211befe2c739303b9a4c066 # v4.39.3
with:
sarif_file: trivy-filesystem.sarif
category: trivy-filesystem
published-image:
name: Published image
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
timeout-minutes: 20
permissions:
contents: read
@@ -113,7 +113,7 @@ jobs:
# this covers `linux/amd64`. The arm64 manifest shares every
# application layer and differs only in native binaries.
- name: Log in to GitHub Container Registry
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -142,10 +142,17 @@ jobs:
output: trivy-published-image.sarif
severity: "CRITICAL,HIGH,MEDIUM"
limit-severities-for-sarif: true
# Only findings with a published fix reach code scanning. Debian
# leaves many base-package advisories unfixed in stable (no-dsa,
# postponed), and nothing in this repository can act on those: the
# build's `apt-get upgrade` takes the fix the day Debian ships one,
# and that is when the alert should appear. Listing them all made
# the page a hundred-odd rows deep without one to act on.
ignore-unfixed: true
exit-code: "0"
- name: Upload results
uses: github/codeql-action/upload-sarif@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
uses: github/codeql-action/upload-sarif@33d7b85da1a04171e211befe2c739303b9a4c066 # v4.39.3
with:
sarif_file: trivy-published-image.sarif
category: trivy-published-image
@@ -162,3 +169,6 @@ jobs:
format: table
severity: "CRITICAL"
exit-code: "1"
# Same rule as the blocking gate in security.yml and the SARIF
# report above: only a finding with a published fix counts.
ignore-unfixed: true
+3
View File
@@ -98,3 +98,6 @@ next-env.d.ts
# same reason in a different tool.
/.wt-*/
/.worktrees/
# Mutation testing (stryker.config.mjs): reports and the sandbox copy.
/reports/mutation
/.stryker-tmp
+3
View File
@@ -11,6 +11,9 @@ paths = [
# All test files contain hardcoded fake tokens / idempotency-keys.
'''(^|/)__tests__/''',
'''\.test\.ts$''',
# Golden copies of the backup file written by the parity test: synthetic
# fixture values, and every `…Encrypted` value already opened to its text.
'''^tests/integration/__golden__/''',
# OpenAPI spec contains example tokens (`hlk_example…`) used to document the schema.
'''^docs/api/openapi\.yaml$''',
]
+23
View File
@@ -16,3 +16,26 @@ docs/api/openapi.yaml
# Build-generated service worker (the PWA build emits it).
public/sw.js
# Ratchet baselines, written by the guards themselves (RATCHET_WRITE=1). The
# writer's JSON layout is the canonical one, so a write-back never fails the
# format check.
src/__tests__/baselines
# Benchmark results, written by scripts/bench/run.ts and committed as they
# come out, so a re-run diffs cleanly against the last one.
scripts/bench/results
# Golden copies of the backup file and the restored account, written by the
# parity test itself (BACKUP_PARITY_WRITE=1), so a rewrite diffs cleanly.
tests/integration/__golden__
# A release's backup file exactly as its exporter wrote it; the golden-backup
# test checks the bytes against the manifest's SHA-256, so it is never
# reformatted. The manifests are written by the same test.
tests/fixtures/golden-backups/*.json
# inlang writes its working files (cache, metadata, README) next to the
# project settings when its CLI or SDK runs; only settings.json is tracked.
project.inlang/*
!project.inlang/settings.json
+8 -19
View File
@@ -3,25 +3,6 @@
# can apply; never suppress one with a fixed version available — bump instead.
# Each entry names what it covers and the condition to re-check / remove it.
# CVE-2026-33671 (picomatch ReDoS, HIGH) — flagged only in the npm CLI's
# OWN bundled copy that ships inside the Node base image
# (usr/local/lib/node_modules/npm/node_modules/picomatch, v4.0.3), used at
# image-build time by npm, not by our runtime. Our application dependency
# tree pins picomatch >= 4.0.4 via a pnpm override, so the running app is
# not affected and the path is not reachable at runtime. Re-check when the
# Node base image ships an npm build whose bundled picomatch is >= 4.0.4,
# then drop this entry.
CVE-2026-33671
# CVE-2026-48815 (sigstore certificate-OID verification, HIGH) — flagged only
# in the npm CLI's OWN bundled copy inside the Node base image
# (usr/local/lib/node_modules/npm/node_modules/sigstore, v3.1.0). npm uses
# sigstore for publish provenance at image-build time only; it is not in our
# application dependency tree at all (`pnpm why sigstore` is empty) and is
# never reachable at runtime. Re-check when the Node base image ships an npm
# build whose bundled sigstore is >= 4.1.1, then drop this entry.
CVE-2026-48815
# CVE-2026-85393 (node-forge RSA PKCS#1 v1.5 signature forgery, HIGH; GitHub
# advisory GHSA-86w9-cpqp-85rv) — node-forge <= 1.4.0 arrives only through
# @parse/node-apn, which uses it in its certificate-based credential path
@@ -31,3 +12,11 @@ CVE-2026-48815
# entry, the matching `auditConfig.ignoreGhsas` entry in pnpm-workspace.yaml,
# and raise the floor once node-forge 1.4.1 is out.
CVE-2026-85393
# braces <= 3.0.3 stack overflow on deeply nested patterns (CVE-2026-93687).
# braces arrives only through micromatch -> fast-glob in development tools
# (@next/eslint-plugin-next, @ts-morph/common, the shadcn CLI); `pnpm why
# braces --prod` is empty and the production image does not contain it, and
# those tools only expand patterns written in this repository. No fixed
# release is published (latest is 3.0.3). Remove this entry once one is.
CVE-2026-93687
+2767
View File
File diff suppressed because it is too large. Load diff
+36 -7
View File
@@ -121,7 +121,7 @@ Contributors: branch off `main`; PRs target `main`.
## Translations
HealthLog ships with English (`en`) and German (`de`) maintained by the project, plus French (`fr`), Spanish (`es`), Italian (`it`), Polish (`pl`), and Korean (`ko`) that are **AI-initial, community-maintained**. The maintained locales are reviewed line-by-line; the community-maintained ones surface a small banner inviting users to improve them.
HealthLog ships with English (`en`) and German (`de`) maintained by the project, plus French (`fr`), Spanish (`es`), Italian (`it`), Polish (`pl`), Korean (`ko`), Dutch (`nl`), and Brazilian Portuguese (`pt-BR`) that are **AI-initial, community-maintained**. The maintained locales are reviewed line-by-line; the community-maintained ones surface a small banner inviting users to improve them.
### Improving an existing locale
@@ -134,16 +134,45 @@ To suggest a fix or improvement:
3. Edit `messages/<locale>.json`. Keep the JSON valid; the i18n integrity test will flag missing or extra keys against `en.json` before CI accepts the PR.
4. Open a PR titled `i18n(<locale>): <area>`. Describe what was unclear, awkward, or wrong in the previous wording.
### Translating in the browser
Every locale can be edited in [Fink](https://fink.inlang.com/?repo=MBombeck/HealthLog&project=project.inlang), inlang's browser editor, without cloning the repository. Fink reads `project.inlang/settings.json`, which points it at `messages/<locale>.json`, shows each message next to its translations, and flags strings that are missing or use other placeholders than English.
1. Open the link and sign in with GitHub.
2. Without write access to this repository, choose **Fork**. Fink asks you to install the Inlang GitHub App on your own account so it can push to your fork.
3. Edit, then choose **Push**. Fink commits to the fork; **Open pull request** takes you to GitHub's compare page, where you submit the change against `main`.
Fink writes the same nested JSON this repository uses and changes only the lines you edited. A string you add for a key the locale did not have yet goes to the end of its section instead of the position it has in `en.json`, which is harmless.
What a reviewer checks on a translation pull request:
- Only `messages/*.json` changed, and only the strings the description names.
- Every `{placeholder}` of the English string is still there and spelled the same. The i18n integrity test fails the build otherwise.
- Medical terms are the ones the language's clinical guidelines use, and the register matches the rest of the locale (`du` in German, `você` in Brazilian Portuguese).
- Words the app or the Telegram bot matches literally stay as they are: slash commands, `taken <name>`, and the unit symbols.
- Machine translation is fine as a first draft, but someone who reads the language has gone through every string before the pull request is opened, and the description says so.
- No questionnaire text. The bundles hold the app's own copy around the questionnaires (titles, notes, buttons), never an item, an answer option or a stem; see the next section.
To run the checks Fink shows on a local checkout: `npx @inlang/cli@3.4.4 check --project ./project.inlang`. It reads the files and changes nothing.
### Questionnaire wording
The PHQ-9, GAD-7, WHO-5 and SCI are not translated through the bundles, and Fink never shows them. Their text lives in code, one file per instrument and language under `src/lib/mental-health/wording/<instrument>/<locale>.ts`, because changing a word in a validated questionnaire changes what it measures. Each file declares what it is:
- `validated: true` with a `source`: a published validated translation, reproduced word for word (stem, items, answer options and, for the PHQ-9, the follow-up question, in the form of address the source uses). The source names who made it, the document it was read from with its link, and the terms that allow reproducing it in software. The file's header comment names anything that differs from the printed form, such as a corrected misprint.
- `validated: false`: no validated version exists that may be reproduced, so the file carries a translation written for HealthLog, faithful to the English and on the same scale. The app shows a note beside it that the result is orientation, offers the English original in one tap, and records on the result that it was answered in an unvalidated translation. The header comment names any validated version that was found and why it is not used (paywalled, terms that allow private study only).
To propose a validated version for a language that has none, or a better source for one that does, open an issue or a pull request with the publication, a link to the PDF it can be read from, and its terms of use. A pull request replaces the language's file: the text copied exactly from the PDF, `validated: true`, and the `source` filled in. The tests in `src/lib/mental-health/__tests__/` check that every instrument has text in every language, that every validated entry names its source, and that each answer is bound to the score the instrument gives it; the spot checks in `instrument-wording.test.ts` are the place to pin a line that is easy to transcribe wrongly. A correction to HealthLog's own translation of an unvalidated entry is welcome too; it stays `validated: false`.
### Adding a new locale
If you want to introduce a locale that isn't shipped (e.g. `nl`, `pt-BR`, `cs`):
If you want to introduce a locale that isn't shipped (e.g. `sv`, `cs`):
1. Open an issue first using the **Translation request** template. We'll confirm the locale is in scope and decide whether it joins the AI-initial-community-maintained tier or the maintained tier.
2. Copy `messages/en.json` to `messages/<locale>.json`.
2. Copy `messages/en.json` to `messages/<locale>.json`. Questionnaire text is not in it; the new language needs a wording file per instrument instead (see Questionnaire wording above), which step 4 makes impossible to forget.
3. Translate. AI assistance is welcome as a starting point but the PR description should note whether it's an AI-initial pass or a fully human-reviewed one — that information drives the banner state.
4. Add the locale to the supported list in `src/lib/i18n/locales.ts` (or wherever the registry lives — grep `SUPPORTED_LOCALES`).
5. The Coach system prompts at `src/lib/ai/prompts/coach-prompt.ts` and `src/lib/ai/prompts/insight-generator.ts` and the doctor-report PDF strings need translation too. Include those in the same PR.
6. Run `pnpm test -- i18n-integrity` locally before submitting.
4. Add the locale to `locales` in `src/lib/i18n/config.ts` and to `locales` in `project.inlang/settings.json`, then run `pnpm typecheck`. Every per-locale table that still lacks the new locale fails to compile, and that list is the work: date and number formatting, the reply-language footers of the Coach and insights prompts, the outbound safety banks, the prose-grounding patterns, the no-key fallback copy and the questionnaire wording registry. Include those in the same PR.
5. Run `pnpm test` before submitting. The i18n guards hold the new bundle to the English keys and placeholders.
### Style guide for translators
@@ -156,7 +185,7 @@ If you want to introduce a locale that isn't shipped (e.g. `nl`, `pt-BR`, `cs`):
### Long-term
When the community grows beyond ad-hoc PRs, we'll spin up a self-hosted [Weblate](https://weblate.org/) instance. That replaces the JSON-PR workflow with a web UI but keeps every translation under our own roof — no third-party telemetry. Tracking issue: `v1.6 — Weblate self-hosted on edge-01`.
Fink covers browser editing today. It edits the files in this repository directly and keeps no copy of the translations anywhere else. If the community outgrows it, a self-hosted [Weblate](https://weblate.org/) instance remains the fallback. Tracking issue: `v1.6 — Weblate self-hosted on edge-01`.
## Code of Conduct
+308 -81
View File
@@ -1,14 +1,35 @@
# ── Base images ────────────────────────────────────────────
# glibc, not musl, in every stage. The native modules the runtime loads (the
# @napi-rs/canvas Skia renderer, @node-rs/argon2, the Prisma schema engine)
# then run their glibc builds. The arm64 musl Skia build needs ARMv8.2 (LSE
# atomics, RCpc loads) and dies with SIGILL on an ARMv8.0 core such as the
# Raspberry Pi 4's Cortex-A72; the glibc arm64 build does not use those
# instructions (#1311).
#
# Every Node stage, the runner included, is the official node:22-trixie-slim
# image, pinned by its multi-arch index digest. Node security releases reach
# that image within days of their publication, which no distribution package
# of an older Node stream matched. The runner removes the package managers
# the image ships; the one npm install the runtime needs happens in the
# `prisma-cli` stage and is copied over. pg_dump and pg_restore come from the
# official PostgreSQL image in the `pgclient` stage. The runner still checks
# the Node module ABI and the glibc against the builder at build time, so a
# future change to one of the stages cannot pair them wrongly.
# ── Stage 1: Dependencies ──────────────────────────────────
FROM node:22-alpine@sha256:16e22a550f3863206a3f701448c45f7912c6896a62de43add43bb9c86130c3e2 AS deps
FROM node:22-trixie-slim@sha256:2117f713a417f3aff682ac8f5bc2ebbdf1beed51f36e67ab1e0fd3866b6d2d8f AS deps
RUN corepack enable && corepack prepare pnpm@11.15.1 --activate
WORKDIR /app
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
# The dependency patches pnpm-workspace.yaml names; the frozen install
# refuses to run without them.
COPY patches patches
COPY scripts/prepare.mjs scripts/prepare.mjs
RUN pnpm install --frozen-lockfile --prod=false
# ── Stage 2: Build ─────────────────────────────────────────
FROM node:22-alpine@sha256:16e22a550f3863206a3f701448c45f7912c6896a62de43add43bb9c86130c3e2 AS builder
FROM node:22-trixie-slim@sha256:2117f713a417f3aff682ac8f5bc2ebbdf1beed51f36e67ab1e0fd3866b6d2d8f AS builder
RUN corepack enable && corepack prepare pnpm@11.15.1 --activate
WORKDIR /app
@@ -54,25 +75,171 @@ RUN NODE_OPTIONS=--max-old-space-size=8192 pnpm build
# name; see the note there for why guessing was wrong.
RUN node -p "require('/app/node_modules/pdfjs-dist/package.json').version" \
> /app/.pdfjs-version
# The Node module ABI and the glibc the native packages were installed for;
# the runner refuses to build on an older glibc or another module ABI.
RUN node -p "process.versions.modules + ' ' + process.report.getReport().header.glibcVersionRuntime + ' ' + process.version" \
> /app/.native-abi
# The same for @napi-rs/canvas, whose native binary must match its JS loader.
RUN node -p "require('/app/node_modules/@napi-rs/canvas/package.json').version" \
> /app/.canvas-version
# Every shipped locale's message catalog is present and parses, checked
# against the locale list in the source; the list is written out so the runner
# stage can check the catalogs the image actually carries.
RUN node scripts/verify-message-catalogs.mjs --dir messages \
--config src/lib/i18n/config.ts --write /app/.message-locales
# ── Stage 3: Production runner ─────────────────────────────
FROM node:22-alpine@sha256:16e22a550f3863206a3f701448c45f7912c6896a62de43add43bb9c86130c3e2 AS runner
# ── Stage 3: Migration CLI ─────────────────────────────────
# Install the migration CLI, its config dependency, and a pinned launcher for
# the maintenance scripts already shipped in the standalone tree. This is the
# only npm install the runtime needs; it runs here, on the runner's own base,
# and the runner copies `/opt/prisma-cli` over, so npm never reaches the
# runtime image.
#
# The `allowScripts` field is for npm 12 and later, which runs no dependency
# install scripts unless package.json lists the package there. Three packages
# here have one: `@prisma/engines` downloads the schema engine `migrate
# deploy` runs, `prisma` checks its environment, and `esbuild` (under tsx)
# checks its binary. The npm in this image still runs them without the field;
# keeping it means a later npm major cannot drop the schema engine quietly.
# Either way the runner asks for the engine file after the copy.
#
# This install belongs to npm, not pnpm, so the `overrides` block in
# `pnpm-workspace.yaml` does not reach it: a package pinned there for the app
# tree still arrives here at whatever version Prisma asks for. That is not
# hypothetical. `prisma@7.8.0` resolves `@prisma/config@7.8.0`, which resolves
# `deepmerge-ts@7.1.5`, and the image scan reported CVE-2026-40345 (HIGH,
# stack exhaustion on recursive merges) against `/opt/prisma-cli` on a commit
# whose dependency audit over the app tree was green. Two installs, two
# override mechanisms, one of them set. `overrides` is npm's own mechanism and
# `npm pkg set` writes it into the package.json that `npm init -y` just
# created, before anything resolves.
#
# Measured by rebuilding this exact install both ways: without the field
# `deepmerge-ts@7.1.5` lands, with it `8.0.1` lands and `prisma --version`
# still reports 7.8.0. Keep the range in step with the entry in
# `pnpm-workspace.yaml` — they are one decision applied to two package
# managers, and a fix that lands in only one of them is the defect above.
#
# The same class recurred on 2026-09-02: the image scan reported
# `@hono/node-server@1.19.11` (GHSA-frvp-7c67-39w9, CVE-2026-39406) and
# `valibot@1.2.0` (CVE-2026-59952) under this path, both reached through
# `prisma -> @prisma/dev`, while the app tree carried 1.19.17 and a pin the
# npm install never reads. Rebuilding this install both ways confirms it:
# without the two fields below npm resolves 1.19.11 and 1.2.0, with them it
# resolves 1.19.17 and 1.4.2, and `prisma --version` still reports 7.8.0.
FROM node:22-trixie-slim@sha256:2117f713a417f3aff682ac8f5bc2ebbdf1beed51f36e67ab1e0fd3866b6d2d8f AS prisma-cli
RUN mkdir -p /opt/prisma-cli && \
cd /opt/prisma-cli && \
npm init -y && \
npm pkg set 'overrides.deepmerge-ts=^8.0.0' && \
npm pkg set 'overrides.mysql2=^3.22.0' && \
npm pkg set 'overrides.@hono/node-server=^2.1.3' && \
npm pkg set 'overrides.valibot=^1.4.2' && \
npm pkg set 'allowScripts.@prisma/engines=true' 'allowScripts.prisma=true' 'allowScripts.esbuild=true' --json && \
npm install --omit=dev prisma@7.10.1 @prisma/engines@7.10.1 tsx@4.23.1 dotenv@18.0.9 --no-fund --no-audit
# ── Stage 4: PostgreSQL client ─────────────────────────────
# pg_dump and pg_restore of the bundled database's major, taken from the
# official PostgreSQL image (Debian trixie, the runner's own release; its
# binaries are the PGDG builds) together with the libpq they link. Debian
# trixie itself ships major 17. The PGDG apt package was the other route: it
# brings Perl and Debian's version-picking wrapper scripts for two binaries
# the app calls by name, and its repository drops a minor once the next one
# is out, so a pinned package version stops installing. A digest-pinned
# image stays buildable, and Dependabot proposes its next minor the way it
# does for the bundled database. The libpq file is found through the loader
# rather than by an architecture path, so the step is the same on amd64 and
# arm64.
FROM postgres:18.6-trixie@sha256:3a4264520e121176619f54ff867d12774bc81e95e7b84db58d63bccdb6144b81 AS pgclient
RUN set -eu; \
mkdir -p /out/bin /out/lib; \
cp /usr/lib/postgresql/18/bin/pg_dump /usr/lib/postgresql/18/bin/pg_restore /out/bin/; \
LIBPQ="$(ldd /usr/lib/postgresql/18/bin/pg_dump | awk '$1 == "libpq.so.5" { print $3 }')"; \
[ -n "$LIBPQ" ] || { echo "ERROR: pg_dump links no libpq.so.5"; exit 1; }; \
cp -L "$LIBPQ" /out/lib/libpq.so.5
# ── Stage 5: Production runner ─────────────────────────────
FROM node:22-trixie-slim@sha256:2117f713a417f3aff682ac8f5bc2ebbdf1beed51f36e67ab1e0fd3866b6d2d8f AS runner
# The package managers the base ships (npm, npx, corepack, yarn) go first.
# Nothing at runtime installs a package, and the bundled npm carried the only
# HIGH findings with a fix the image scan reported. Removed in a later layer,
# their files stay in the base layer's bytes but leave the image's file
# system, which is what runs and what the scan reads.
#
# `tzdata` is required so Europe/Berlin schedules (pg-boss cron, locale-aware
# timestamp formatting) resolve to the actual offset instead of silently
# falling back to UTC on Alpine images that ship without it.
# falling back to UTC; the slim base ships without it. tzdata stays unpinned
# on purpose: a zone rule change is a fix to take.
#
# `apk upgrade` pulls in OS-package security fixes the pinned base digest
# `apt-get upgrade` pulls in OS-package security fixes the pinned base digest
# has not absorbed yet (the digest pin stays for build reproducibility;
# security patches from the Alpine repo override it deliberately —
# CVE-2026-14456 in openssl was the first instance the container scan
# caught between base-image rebuilds).
RUN apk add --no-cache tzdata && apk upgrade --no-cache
# security patches from the Debian archive override it deliberately —
# CVE-2026-14456 in openssl was the first instance the container scan caught
# between base-image rebuilds).
#
# `pg_dump` and `pg_restore` serve the nightly database dump, the
# pre-migration dump in the entrypoint and the `db-restore` service
# (docs/ops/disaster-recovery.md). Major 18 matches the bundled database; a
# client dumps servers of its own major and older, so it also serves an
# install still on its own Postgres 16. The two binaries and their libpq come
# from the `pgclient` stage above; the Debian libraries libpq links
# (Kerberos, LDAP, SASL) come from apt below, so the build-time upgrade and
# the image scan see them. The copied binaries are outside dpkg: the scan
# does not see them and `apt-get upgrade` does not patch them, so a
# PostgreSQL client fix arrives by moving the `pgclient` pin.
# `src/__tests__/runtime-package-pins.test.ts` holds that pin to the bundled
# database's minor in docker-compose.yml.
#
# `ca-certificates` is the system trust store the OpenSSL-linked tools use
# (the Prisma schema engine, libpq) for a TLS connection to the database.
# `tar` and `gzip` for the GeoLite2 extraction and `sh` (dash) for the
# entrypoint are part of the Debian base.
RUN set -eux; \
rm -rf /usr/local/lib/node_modules/npm /usr/local/lib/node_modules/corepack \
/usr/local/bin/npm /usr/local/bin/npx /usr/local/bin/corepack \
/usr/local/bin/yarn /usr/local/bin/yarnpkg /opt/yarn-v*; \
sh -c '! command -v npm && ! command -v npx && ! command -v corepack && ! command -v yarn && ! command -v pnpm'; \
export DEBIAN_FRONTEND=noninteractive; \
apt-get update; \
apt-get upgrade -y --no-install-recommends; \
apt-get install -y --no-install-recommends \
ca-certificates tzdata libgssapi-krb5-2 libldap2 libsasl2-2; \
apt-get clean; \
rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/* /root/.npm /root/.cache
COPY --from=pgclient /out/bin/ /usr/lib/postgresql/18/bin/
COPY --from=pgclient /out/lib/libpq.so.5 /usr/lib/postgresql/18/lib/libpq.so.5
RUN echo /usr/lib/postgresql/18/lib > /etc/ld.so.conf.d/postgresql-18.conf && \
ldconfig && \
! ldd /usr/lib/postgresql/18/bin/pg_dump /usr/lib/postgresql/18/bin/pg_restore | grep -q "not found"
ENV PATH=/usr/lib/postgresql/18/bin:$PATH
# The builder and the runner start from the same image, so the two agree
# today. The check stays: it fails the build the day one of them moves to
# another base or another Node major without the other.
COPY --from=builder /app/.native-abi /tmp/.native-abi
RUN set -e; \
read -r BUILD_ABI BUILD_GLIBC BUILD_NODE < /tmp/.native-abi; \
rm -f /tmp/.native-abi; \
RUN_ABI="$(node -p process.versions.modules)"; \
RUN_GLIBC="$(node -p 'process.report.getReport().header.glibcVersionRuntime')"; \
[ "$RUN_ABI" = "$BUILD_ABI" ] || { echo "ERROR: runner Node module ABI $RUN_ABI, builder $BUILD_ABI"; exit 1; }; \
[ "$(printf '%s\n%s\n' "$BUILD_GLIBC" "$RUN_GLIBC" | sort -V | head -1)" = "$BUILD_GLIBC" ] || \
{ echo "ERROR: runner glibc $RUN_GLIBC is older than the builder's $BUILD_GLIBC"; exit 1; }; \
echo "native ABI $RUN_ABI (builder $BUILD_NODE, runner $(node -v)); glibc $BUILD_GLIBC -> $RUN_GLIBC"
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
ENV TZ=Europe/Berlin
# When the heap runs out, Node writes a diagnostic report (the JavaScript
# and native stacks, heap spaces, libuv handles) to stderr, where
# `docker compose logs` and Loki keep it; without it the log holds one
# line. Environment variables and network interfaces are left out, so no
# key, password or address from the environment reaches the log. An
# operator who sets NODE_OPTIONS replaces this value and should carry these
# flags over (docs/ops/heap-diagnostics.md).
ENV NODE_OPTIONS="--report-on-fatalerror --report-filename=stderr --report-exclude-env --report-exclude-network"
# v1.4.43 B11 — forward NEXT_PUBLIC_APP_VERSION from the builder stage
# into the runner so /api/version reads the build-arg value instead of
# the package.json fallback. The variable is keyed to the CI tag ref
@@ -88,11 +255,30 @@ ENV NEXT_PUBLIC_APP_VERSION=$NEXT_PUBLIC_APP_VERSION
ARG NEXT_PUBLIC_APP_BUILD_SHA
ENV NEXT_PUBLIC_APP_BUILD_SHA=$NEXT_PUBLIC_APP_BUILD_SHA
RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 nextjs
RUN groupadd --system --gid 1001 nodejs && \
useradd --system --uid 1001 --gid nodejs --create-home --home-dir /home/nextjs \
--shell /usr/sbin/nologin nextjs
# Where the database dumps go (`DB_BACKUP_DIR`). Owned by the app user, so a
# fresh named volume mounted here starts with that owner: Docker fills an
# empty named volume from the image directory it covers, ownership included.
# Without a mount the directory stays in the container's layer; the app reads
# its mount table, sees that, and keeps no dump here (dump-core.mjs).
RUN mkdir -p /var/lib/healthlog/backups && \
chown nextjs:nodejs /var/lib/healthlog/backups && \
chmod 700 /var/lib/healthlog/backups
# Copy built assets
COPY --from=builder /app/public ./public
# The server reads its message catalogs from here on first use, one locale at
# a time (src/lib/i18n/server-catalogs.ts); they are not bundled.
COPY --from=builder /app/messages ./messages
# A missing or broken catalog is only noticed when a page asks for that
# locale, so check the copies in this stage against the builder's list.
COPY --from=builder /app/.message-locales /app/scripts/verify-message-catalogs.mjs /tmp/catalog-check/
RUN node /tmp/catalog-check/verify-message-catalogs.mjs --dir /app/messages \
--locales /tmp/catalog-check/.message-locales && \
rm -rf /tmp/catalog-check
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
# Next's standalone output must carry the externalized worker dependencies.
@@ -119,35 +305,57 @@ RUN node -e "require.resolve('pg-boss'); require.resolve('pg')"
# HOISTED to the top level there (verified: without it pdfjs logs "Cannot load
# @napi-rs/canvas" → "Cannot polyfill DOMMatrix" → rasterize ReferenceError);
# (2) @napi-rs/canvas's own loader then resolves its platform binary. Hoist BOTH
# the canvas package and the musl binary package to /app/node_modules/@napi-rs
# via symlink into the .pnpm store, and also drop the prebuilt .node into the
# canvas dir so the loader's local-file fallback (`require('./skia.<triple>.node')`)
# is belt-and-suspenders. Per-arch buildx installs only the matching musl triple.
# the canvas package and the glibc (`-gnu`) binary package to
# /app/node_modules/@napi-rs via symlink into the .pnpm store, and drop the prebuilt .node into the canvas
# dir: the loader tries that local file (`require('./skia.<triple>.node')`)
# before the platform package, so it decides which binary runs.
#
# Hoist the version the app pins, by name, exactly like pdfjs-dist below. The
# store once held a second, older canvas that a second pdfjs-dist pulled in,
# and this step used to take whichever `find | head -1` returned first. It
# returned the old binary, which then sat next to the new loader: rendering
# still worked, but `loadImage` called an `Image.decode` the old binary does
# not have, and every preview thumbnail failed with "image.decode is not a
# function". Name the version, and fail the build when the binary that lands
# in the canvas dir is not that version's.
COPY --from=builder /app/.canvas-version /tmp/.canvas-version
RUN set -e; \
CANVAS_DIR="$(find /app/node_modules/.pnpm -maxdepth 4 -type d -path '*@napi-rs+canvas@*/node_modules/@napi-rs/canvas' 2>/dev/null | head -1)"; \
MUSL_DIR="$(find /app/node_modules/.pnpm -maxdepth 4 -type d -path '*@napi-rs+canvas-linux-*-musl@*/node_modules/@napi-rs/canvas-linux-*-musl' 2>/dev/null | head -1)"; \
NODE_BIN="$(find /app/node_modules/.pnpm -maxdepth 5 -name 'skia.linux-*-musl.node' 2>/dev/null | head -1)"; \
if [ -n "$CANVAS_DIR" ]; then \
mkdir -p /app/node_modules/@napi-rs; \
ln -sfn "$CANVAS_DIR" /app/node_modules/@napi-rs/canvas; \
[ -n "$MUSL_DIR" ] && ln -sfn "$MUSL_DIR" "/app/node_modules/@napi-rs/$(basename "$MUSL_DIR")"; \
[ -n "$NODE_BIN" ] && cp "$NODE_BIN" "$CANVAS_DIR/"; \
chown -R nextjs:nodejs /app/node_modules/@napi-rs; \
echo "canvas hoisted for pdfjs: $CANVAS_DIR (musl=$MUSL_DIR)"; \
else echo "WARN: @napi-rs/canvas not found; PDF rasterization will degrade to local text"; fi
CANVAS_VERSION="$(cat /tmp/.canvas-version)"; \
rm -f /tmp/.canvas-version; \
CANVAS_DIR="/app/node_modules/.pnpm/@napi-rs+canvas@${CANVAS_VERSION}/node_modules/@napi-rs/canvas"; \
GNU_DIR="$(find /app/node_modules/.pnpm -maxdepth 4 -type d -path "*@napi-rs+canvas-linux-*-gnu@${CANVAS_VERSION}/node_modules/@napi-rs/canvas-linux-*-gnu" 2>/dev/null | head -1)"; \
if [ ! -d "$CANVAS_DIR" ] || [ -z "$GNU_DIR" ]; then \
echo "ERROR: pinned @napi-rs/canvas@${CANVAS_VERSION} or its glibc binary did not reach the traced image."; \
echo "Present instead:"; \
find /app/node_modules/.pnpm -maxdepth 1 -name '@napi-rs+canvas*' 2>/dev/null; \
exit 1; \
fi; \
NODE_BIN="$(find "$GNU_DIR" -maxdepth 1 -name 'skia.linux-*-gnu.node' | head -1)"; \
[ -n "$NODE_BIN" ] || { echo "ERROR: no skia binary in $GNU_DIR"; exit 1; }; \
mkdir -p /app/node_modules/@napi-rs; \
ln -sfn "$CANVAS_DIR" /app/node_modules/@napi-rs/canvas; \
ln -sfn "$GNU_DIR" "/app/node_modules/@napi-rs/$(basename "$GNU_DIR")"; \
cp "$NODE_BIN" "$CANVAS_DIR/"; \
chown -R nextjs:nodejs /app/node_modules/@napi-rs; \
BIN_VERSION="$(node -p "require('$GNU_DIR/package.json').version")"; \
[ "$BIN_VERSION" = "$CANVAS_VERSION" ] || { echo "ERROR: canvas binary is $BIN_VERSION, loader is $CANVAS_VERSION"; exit 1; }; \
cmp -s "$NODE_BIN" "$CANVAS_DIR/$(basename "$NODE_BIN")" || { echo "ERROR: the canvas dir holds a different binary"; exit 1; }; \
echo "canvas hoisted for pdfjs: $CANVAS_DIR (binary $BIN_VERSION from $GNU_DIR)"
# pdfjs-dist is `serverExternalPackages` (NOT bundled — the bundled copy's render
# path breaks in the standalone image), so the server does a runtime bare
# `import('pdfjs-dist/legacy/build/pdf.mjs')`. Node resolves that up to
# /app/node_modules, so hoist the real pnpm-store copy to the top level too.
#
# Hoist the version the app pins, by name. More than one pdfjs-dist reaches the
# store — `pdf-parse` carries its own, older copy for the text-extraction path —
# and this step used to take whatever `find | head -1` returned first. That is
# readdir order, so it could hoist the OLDER copy and the version named in
# package.json would never run in the image: the renderer resolving one version
# while every local check exercised another. Name the version and fail the build
# when it is absent, instead of degrading to whichever copy comes back first.
# Hoist the version the app pins, by name. More than one pdfjs-dist once reached
# the store (a text-extraction dependency carried its own, older copy), and this
# step used to take whatever `find | head -1` returned first. That is readdir
# order, so it could hoist the OLDER copy and the version named in package.json
# would never run in the image: the renderer resolving one version while every
# local check exercised another. Name the version and fail the build when it is
# absent, instead of degrading to whichever copy comes back first. Two copies in
# one process also break each other (#1277); the lockfile now resolves one, and
# `src/__tests__/single-pdfjs-guard.test.ts` keeps it that way.
COPY --from=builder /app/.pdfjs-version /tmp/.pdfjs-version
RUN set -e; \
PDFJS_VERSION="$(cat /tmp/.pdfjs-version)"; \
@@ -179,57 +387,54 @@ COPY --from=builder /app/src/generated ./src/generated
# `@node-rs/argon2` from the traced runtime tree. Scripts that import the
# application graph (`@/…`, the generated Prisma client) run from a source
# checkout instead. `src/__tests__/container-scripts-ship-guard.test.ts` holds
# the runbooks and this list in step. Root-owned on purpose: the app user can
# run them but not rewrite them.
# the runbooks and the entrypoint against this list. `pending-migrations.mjs`
# and `migrate-deploy.mjs` are the entrypoint's own: the first logs the
# pending migration count, the second runs `migrate deploy` with its
# lock-timeout retry. `db-dump.mjs` takes the dump before migrating, and
# `db-restore.mjs` is the `db-restore` service; both share
# `src/lib/database-backup/dump-core.mjs` with the app. The files are
# root-owned, so the app user can run them but not edit them in place. The
# directory around them stays the app user's.
COPY --from=builder /app/scripts/reset-password.mjs ./scripts/reset-password.mjs
COPY --from=builder /app/scripts/pending-migrations.mjs ./scripts/pending-migrations.mjs
COPY --from=builder /app/scripts/migrate-deploy.mjs ./scripts/migrate-deploy.mjs
COPY --from=builder /app/scripts/db-dump.mjs ./scripts/db-dump.mjs
COPY --from=builder /app/scripts/db-restore.mjs ./scripts/db-restore.mjs
COPY --from=builder /app/scripts/db-app-role.mjs ./scripts/db-app-role.mjs
COPY --from=builder /app/src/lib/jobs/retired-measurement-indexes.mjs ./src/lib/jobs/retired-measurement-indexes.mjs
COPY --from=builder /app/src/lib/database-backup/dump-core.mjs ./src/lib/database-backup/dump-core.mjs
COPY --from=builder /app/src/lib/auth/argon2-params.mjs ./src/lib/auth/argon2-params.mjs
COPY --from=builder /app/scripts/repair-intake-anomalies.ts ./scripts/repair-intake-anomalies.ts
# Prove the reset CLI's imports resolve in this tree: with every import found
# it stops at its own usage line; a missing module throws before reaching it.
RUN node scripts/reset-password.mjs 2>&1 | grep -q "reset-password: usage:"
# The boot notice parses, and the `pg` it loads on a real boot resolves here.
RUN env -u DATABASE_URL node scripts/pending-migrations.mjs && \
node --input-type=module -e "await import('pg')" && \
node --input-type=module -e "await import('./scripts/migrate-deploy.mjs')"
# The dump and restore scripts load, the client tools they run are the
# major the bundled database runs, and the S3 client `db-restore --from-bucket`
# loads resolves from the shared module's place in the tree.
RUN node scripts/db-dump.mjs 2>&1 | grep -q "db-dump: usage:" && \
node --input-type=module -e "import { createRequire } from 'node:module'; createRequire('/app/src/lib/database-backup/dump-core.mjs').resolve('@aws-sdk/client-s3')" && \
node scripts/db-restore.mjs 2>&1 | grep -q "db-restore: usage:" && \
pg_dump --version | grep -q "(PostgreSQL) 18\." && \
pg_restore --version | grep -q "(PostgreSQL) 18\."
# Install the migration CLI, its config dependency, and a pinned launcher for
# the maintenance scripts already shipped in the standalone tree. The Prisma
# config loads dotenv from /app, so expose the isolated copy there before
# stripping npm/Corepack and their caches from the runtime surface.
#
# This install belongs to npm, not pnpm, so the `overrides` block in
# `pnpm-workspace.yaml` does not reach it: a package pinned there for the app
# tree still arrives here at whatever version Prisma asks for. That is not
# hypothetical. `prisma@7.8.0` resolves `@prisma/config@7.8.0`, which resolves
# `deepmerge-ts@7.1.5`, and the image scan reported CVE-2026-40345 (HIGH,
# stack exhaustion on recursive merges) against `/opt/prisma-cli` on a commit
# whose dependency audit over the app tree was green. Two installs, two
# override mechanisms, one of them set. `overrides` is npm's own mechanism and
# `npm pkg set` writes it into the package.json that `npm init -y` just
# created, before anything resolves.
#
# Measured by rebuilding this exact install both ways: without the field
# `deepmerge-ts@7.1.5` lands, with it `8.0.1` lands and `prisma --version`
# still reports 7.8.0. Keep the range in step with the entry in
# `pnpm-workspace.yaml` — they are one decision applied to two package
# managers, and a fix that lands in only one of them is the defect above.
#
# The same class recurred on 2026-09-02: the image scan reported
# `@hono/node-server@1.19.11` (GHSA-frvp-7c67-39w9, CVE-2026-39406) and
# `valibot@1.2.0` (CVE-2026-59952) under this path, both reached through
# `prisma -> @prisma/dev`, while the app tree carried 1.19.17 and a pin the
# npm install never reads. Rebuilding this install both ways confirms it:
# without the two fields below npm resolves 1.19.11 and 1.2.0, with them it
# resolves 1.19.17 and 1.4.2, and `prisma --version` still reports 7.8.0.
RUN mkdir -p /opt/prisma-cli && \
cd /opt/prisma-cli && \
npm init -y && \
npm pkg set 'overrides.deepmerge-ts=^8.0.0' && \
npm pkg set 'overrides.mysql2=^3.22.0' && \
npm pkg set 'overrides.@hono/node-server=^1.19.15' && \
npm pkg set 'overrides.valibot=^1.4.2' && \
npm install --omit=dev prisma@7.10.0 @prisma/engines@7.10.0 tsx@4.23.1 dotenv@17.4.2 && \
ln -sfn /opt/prisma-cli/node_modules/.bin/tsx /usr/local/bin/healthlog-tsx && \
# The migration CLI from the `prisma-cli` stage. The Prisma config loads
# dotenv from /app, so expose the isolated copy there. /opt/prisma-cli is
# root-owned: the app user can run it but not change it.
COPY --from=prisma-cli /opt/prisma-cli /opt/prisma-cli
RUN ln -sfn /opt/prisma-cli/node_modules/.bin/tsx /usr/local/bin/healthlog-tsx && \
ln -sfn /opt/prisma-cli/node_modules/dotenv /app/node_modules/dotenv && \
ln -sfn /opt/prisma-cli/node_modules/prisma /app/node_modules/prisma && \
rm -rf /usr/local/lib/node_modules/npm /usr/local/lib/node_modules/corepack /root/.cache/node/corepack /root/.npm && \
rm -f /usr/local/bin/npm /usr/local/bin/npx /usr/local/bin/corepack /usr/local/bin/pnpm /usr/local/bin/pnpx
ln -sfn /opt/prisma-cli/node_modules/prisma /app/node_modules/prisma
# The schema engine the entrypoint's `migrate deploy` runs is in place.
# Look for the file before asking the CLI: a CLI that finds none downloads
# one, which would hide the missing install script here and fail offline.
RUN ls /opt/prisma-cli/node_modules/@prisma/engines/schema-engine-* >/dev/null && \
node /opt/prisma-cli/node_modules/prisma/build/index.js --version 2>&1 | \
grep -q "Schema Engine *: schema-engine-cli [0-9a-f]* (at "
# Same proof for the intake-repair script under the launcher it is documented
# with: its `pg` import resolves, so it reaches its own DATABASE_URL check.
@@ -252,17 +457,31 @@ RUN env -u DATABASE_URL healthlog-tsx scripts/repair-intake-anomalies.ts 2>&1 |
# user so the runtime worker can WRITE it: a self-hoster who sets
# MAXMIND_LICENSE_KEY on the published image has the databases fetched into
# this directory at runtime (src/lib/geo/geolite2-fetch.ts) with no rebuild or
# manual mount. `tar`/`gzip` for the extraction ship in the busybox base. A
# manual mount. `tar`/`gzip` for the extraction come with the base. A
# read-only bind mount at this path (the bring-your-own-database route) still
# works — the runtime fetch just fails soft and the mounted files stand.
RUN mkdir -p /opt/geolite2 && chown nextjs:nodejs /opt/geolite2
COPY --chown=nextjs:nodejs assets/geolite2/ /opt/geolite2/
# `wget` for health checks written outside the image. A compose file copied
# from this repository up to v1.46.6 and the TrueNAS catalog app both call
# `wget --spider` against /api/health, and their check replaces the
# HEALTHCHECK below; the slim base has no wget, so without this every such
# container stayed unhealthy. `scripts/wget-shim.mjs` understands the flags
# those checks use, exits like wget, and writes nowhere but stdout. The
# image's own HEALTHCHECK and the bundled compose file use the Node probe.
# Root-owned: the app user can run it but not replace it.
COPY --from=builder /app/scripts/wget-shim.mjs /usr/local/lib/healthlog/wget-shim.mjs
RUN printf '#!/bin/sh\nexec node /usr/local/lib/healthlog/wget-shim.mjs "$@"\n' > /usr/local/bin/wget && \
chmod 755 /usr/local/bin/wget && \
wget 2>&1 | grep -q "health check shim" && \
! wget --no-verbose --tries=1 --spider http://127.0.0.1:9/api/health
# ISO-8601 build timestamp — /api/version returns it as `builtAt`.
# Declared this late in the stage on purpose: the value differs on
# every CI run, and every instruction after an ARG shares its cache
# fate. Down here the only layers it invalidates are the cheap
# entrypoint COPY/chmod below; the npm-install layers above stay
# entrypoint COPY/chmod below; the layers above stay
# cache-warm across rebuilds of the same release.
ARG NEXT_PUBLIC_APP_BUILT_AT
ENV NEXT_PUBLIC_APP_BUILT_AT=$NEXT_PUBLIC_APP_BUILT_AT
@@ -277,8 +496,16 @@ EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://127.0.0.1:3000/api/health || exit 1
# Start period: the entrypoint migrates before the server listens, and a
# migration waiting out a held lock retries for up to about eight minutes.
# Five retries: a pool saturated for a minute during a boot storm makes the
# probe's 2 s database check miss once or twice without marking the container
# unhealthy (docker-compose.yml explains the numbers). The probe is Node's own
# fetch: it needs nothing beyond the runtime, the same probe works on any
# base, and no HTTP client has to be installed for one line. A non-2xx
# answer, a refused connection and the 4 s abort all exit 1.
HEALTHCHECK --interval=30s --timeout=5s --start-period=10m --retries=5 \
CMD ["node", "-e", "fetch('http://127.0.0.1:3000/api/health',{signal:AbortSignal.timeout(4000)}).then(r=>process.exit(r.ok?0:1),()=>process.exit(1))"]
ENTRYPOINT ["./docker-entrypoint.sh"]
CMD ["node", "server.js"]
+30 -29
View File
@@ -24,20 +24,16 @@
<a href="https://testflight.apple.com/join/bucuTBpa">iOS TestFlight</a>
</p>
<img width="2434" height="1247" alt="HealthLog dashboard: health metric tiles with 7-day trends, blood-pressure and weight charts, dark theme" src="docs/assets/dashboard-hero.jpeg" />
<img width="2400" height="1500" alt="HealthLog dashboard in dark theme: a lead sentence on the latest vitals, today's medications, sleep and cycle, the health score ring, worth-a-look cards, and metric tiles with 7-day trends" src="docs/assets/dashboard.jpeg" />
<p align="center">
<img src="docs/assets/Insights.png"
alt="Insights"
width="23%">
<img src="docs/assets/resting-pulse.png"
alt="Insights RHR"
width="23%">
<img src="docs/assets/Medications.png"
alt="Medications"
width="23%">
<img src="docs/assets/Coach.png"
alt="Coach Chat"
width="23%">
<img src="docs/assets/timeline.jpeg" alt="Timeline: life events, conditions, medications, vaccinations, labs and cycles on one time axis, above blood pressure, weight and resting heart rate" width="32%">
<img src="docs/assets/day-panel.jpeg" alt="Blood pressure insights with the day panel open: what was running that day, its values and scores" width="32%">
<img src="docs/assets/resting-heart-rate.jpeg" alt="Resting heart rate insights: summary, usual range and the full history chart" width="32%">
</p>
<p align="center">
<img src="docs/assets/medications.jpeg" alt="Medications with next and last intake, adherence and streaks" width="32%">
<img src="docs/assets/labs.jpeg" alt="Lab results with reference ranges and trend sparklines" width="32%">
<img src="docs/assets/coach.jpeg" alt="Coach: a conversation about the week's recovery, grounded in the recorded data" width="32%">
</p>
## Why HealthLog
@@ -54,13 +50,13 @@ 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.
- **Clinician-ready output.** A doctor-report PDF rendered on your server, a read-only HL7 FHIR R4 API, and scoped, time-limited share links you revoke after the visit.
- **Private by construction.** AES-256-GCM encryption at rest for notes and other free text, documents, questionnaire answers, AI-written text and credentials, with zero-downtime key rotation. Measurement values, lab values, medication names and mood scores are stored unencrypted in the database, so the database volume and its backups need the same protection as the host. Passkey login, server-side sessions, strict CSP, and no third-party tracking anywhere.
- **Built to be lived in.** Installable PWA with offline support, medication reminders over APNs, Telegram, ntfy, and Web Push, a sub-second dashboard on years of imported history — in English, German, Spanish, French, Italian, Polish, and Korean end to end.
- **Built to be lived in.** Installable PWA with offline support, medication reminders over APNs, Telegram, ntfy, and Web Push, a sub-second dashboard on years of imported history — in English, German, Spanish, French, Italian, Polish, Korean, Dutch, and Brazilian Portuguese end to end.
The full feature tour, integration guides, and API reference live at [docs.healthlog.dev](https://docs.healthlog.dev).
@@ -89,11 +85,11 @@ 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 (`pgdata`, the Postgres data dir); the app container is stateless, so backing up the database backs up everything.
Persistent state lives in the database volume (`postgres-data`) and in `backups`, which holds the nightly database dumps; the app container is stateless. An install from v1.45 or earlier moves its Postgres 16 database from `pgdata` into the new volume on the first start ([details](docs/ops/postgres-major-upgrade.md)); recovering a lost server is in [disaster-recovery.md](docs/ops/disaster-recovery.md).
NAS and homelab users have ready-made paths: [Unraid Community Applications](docs/self-hosting/unraid.md) and [Portainer app templates](docs/self-hosting/portainer/README.md).
NAS and homelab users have ready-made paths: [Unraid Community Applications](docs/self-hosting/unraid.md), [Portainer app templates](docs/self-hosting/portainer/README.md) and [TrueNAS](docs/self-hosting/truenas.md) (the Community train app is pending catalog review).
## Self-hosting
@@ -109,17 +105,18 @@ The App Store listing carries a small price, which exists to cover Apple's yearl
## Tech stack
Next.js 16 (App Router, React Server Components), TypeScript strict, PostgreSQL 16 with Prisma, pg-boss for jobs, Tailwind CSS 4 + shadcn/ui, Recharts, WebAuthn passkeys, Vitest + Playwright, multi-stage Alpine Docker image. The OpenAPI 3.1 contract for native clients is generated from the Zod schemas at [`docs/api/openapi.yaml`](docs/api/openapi.yaml).
Next.js 16 (App Router, React Server Components), TypeScript strict, PostgreSQL 18 with Prisma, pg-boss for jobs, Tailwind CSS 4 + shadcn/ui, Recharts, WebAuthn passkeys, Vitest + Playwright, multi-stage Docker image on the official Node 22 Debian slim base. The OpenAPI 3.1 contract for native clients is generated from the Zod schemas at [`docs/api/openapi.yaml`](docs/api/openapi.yaml).
## Documentation
| | |
| ---------------------- | ------------------------------------------------ |
| User and operator docs | [docs.healthlog.dev](https://docs.healthlog.dev) |
| Release history | [CHANGELOG.md](CHANGELOG.md) |
| API contract | [`docs/api/openapi.yaml`](docs/api/openapi.yaml) |
| Security policy | [SECURITY.md](SECURITY.md) |
| Contributing | [CONTRIBUTING.md](CONTRIBUTING.md) |
| | |
| ---------------------- | ------------------------------------------------------------ |
| User and operator docs | [docs.healthlog.dev](https://docs.healthlog.dev) |
| Release history | [CHANGELOG.md](CHANGELOG.md) |
| API contract | [`docs/api/openapi.yaml`](docs/api/openapi.yaml) |
| Security policy | [SECURITY.md](SECURITY.md) |
| Contributing | [CONTRIBUTING.md](CONTRIBUTING.md) |
| Translating | [CONTRIBUTING.md#translations](CONTRIBUTING.md#translations) |
## Status
@@ -127,9 +124,13 @@ Actively developed — new releases roughly weekly, issue reports and PRs welcom
## Thanks
HealthLog is built in the open and people keep making it better. Alongside
bug reports, setup questions and the ideas that turned into features, these
people have sent code that shipped:
HealthLog started as something I built for myself. That it has turned into a project other people use, care about and help shape still surprises me, and I am grateful for every part of it.
Thank you to everyone who sent code that shipped: [@TimonBed](https://github.com/TimonBed), [@Antiheld86](https://github.com/Antiheld86), [@BernhardBuckel](https://github.com/BernhardBuckel), [@balajiv113](https://github.com/balajiv113), [@aucun6352](https://github.com/aucun6352), [@mathewcsims](https://github.com/mathewcsims) and [@muhdusama](https://github.com/muhdusama).
Thank you to everyone who opened an issue, asked a question in the discussions or wrote me an email, and especially to [@lutzkind](https://github.com/lutzkind), [@HercoGC](https://github.com/HercoGC), [@mills1975](https://github.com/mills1975), [@sreeramachandramurthy](https://github.com/sreeramachandramurthy), [@CritLoren](https://github.com/CritLoren), [@mbreitkreuz](https://github.com/mbreitkreuz), [@k-nacion](https://github.com/k-nacion), [@NebuPookins](https://github.com/NebuPookins), [@Nazza01](https://github.com/Nazza01), [@Cnote43](https://github.com/Cnote43) and [@juliaanvaneijndt](https://github.com/juliaanvaneijndt), whose reports shaped HealthLog again and again. Most of what got better in the last months started with someone describing what went wrong for them, often with a log, a screenshot and a second try when the first fix was not enough.
And thank you to everyone who supported the project with a coffee or by buying the iPhone app. I don't name anyone here, but I read every message that comes with it. It pays for the servers and the Apple developer account, and it tells me that HealthLog is worth the evenings.
<a href="https://github.com/MBombeck/HealthLog/graphs/contributors">
<img src="https://contrib.rocks/image?repo=MBombeck/HealthLog" alt="Contributors to HealthLog" />
+119 -9
View File
@@ -1,12 +1,122 @@
{
"$comment": "Client-bundle budgets, enforced by scripts/check-bundle-budget.mjs (--check) after `next build` in CI. Values are KB gzip and include every emitted lazy route and locale chunk, while route budgets include the shared rootMainFiles baseline. Re-measured at v1.37.2: / 455, /insights 443, /measurements 441, /insights/mood 453, total 3328, baseline 130, lazy catalogs 849. Every per-route ceiling is unchanged. The /insights overview crept a kilobyte over its 445 ceiling when the ECG teaser began importing its overview cap from the classification module, dragging that module's label map and helpers into the eager page; the cap now lives in its own value-only module, and the Health Score's single provenance entry is split out of the full metric-provenance map, so the overview imports the bare number and citation it renders rather than every metric's label map behind them. Both are back under 445 with room to spare. The 42 KB aggregate rise from 3286 (v1.35.0) is all locale-catalog strings: v1.36 and v1.37 added the encounter, mood-context, mood-prognosis, practitioner, workout-batch and account-sharing surfaces, and their copy lands across the six locale catalogs the whole-build sum counts independently (804 to 849 KB). The non-catalog client chunks are flat against v1.35.0, so nothing a person downloads on a given route grew beyond its own ceiling. The aggregate cap therefore moves from 3310 to 3352 (24 KB headroom, matching the previous margin); per-route ceilings, the static-catalog guard and the one-Recharts-chunk guard remain unchanged. v1.37.4's honest-error-state charge then added recoverable read-failure handling to thirteen surfaces (the shared QueryErrorCard and QueryErrorRow primitives plus an isError branch per surface) and rewrote the MCP scope note and the managed-record error copy across the six locales; the measured total rose to 3404 while every per-route ceiling stayed met (/page 457, /insights 445, /measurements 443, /insights/mood 455). The aggregate cap moves from 3352 to 3428, keeping the same margin; nothing a person downloads on a given route grew beyond its ceiling. v1.37.15's async-truth charge then added the refresh settling state with its two-locale-key copy, the index-outcome plain-words line (seven keys across the six catalogs) and the reindex-count wiring; the measured total rose to 3429 while every per-route ceiling stayed met (/page 456, /insights 445, /measurements 442, /insights/mood 455). The aggregate cap moves from 3428 to 3452, keeping the same margin; nothing a person downloads on a given route grew beyond its ceiling. v1.37.27 found /insights sitting a few hundred bytes under 445 on main and a few hundred over it on the release branch — the page had been at its ceiling for three releases and the release only had to breathe on it. The weight it was carrying was not its own: the authenticated shell imported one destination predicate from the `@/lib/record-settings` barrel, and the barrel also re-exports the managed settings-patch schemas, so Zod, the module registry and three validation modules rode into the eager bundle of every authenticated route for a function that reads a URL slug. The shell imports the `classification` leaf now. Measured across all 585 client-rendered routes: none grew by a byte, 100 fell, by 4.7 KB at the median and 8.1 KB at the most; /insights 444 to 437, /measurements 442 to 435, / 456 to 450, /insights/mood 455 to 450. The whole-build sum moved the other way, 3451 to 3476, and that direction is the metric rather than the change: the aggregate counts each emitted chunk once, so a graph that leaves the chunk group every route shares gets counted again wherever it lands — here inside the deferred settings chunks that actually use it. Bytes on disk rose 25 KB; bytes a reader downloads only ever fell. The aggregate cap moves from 3452 to 3500, the same 24 KB headroom as the four re-measurements before it. Per-route ceilings keep their old values rather than tightening to the new floor: the CI runner measures each route about 1.5 KB heavier than a local arm64 build does, and a ceiling set from a local number would fail on a build that had not regressed. The static-catalog guard and the one-Recharts-chunk guard remain unchanged. Raised to 3700 at v1.37.32 for the seventh interface language (Korean): the lazy message catalogs went 6->7 chunks (1026 KB gz measured), pushing the aggregate to 3631 against the old 3500. The raise is entirely the new catalog; no route budget moved, and the four eager route ceilings are unchanged and still passing. Most of the aggregate is the dynamic locale-switch and fallback path that a first paint never loads (see issue #253), so this ceiling guards bytes almost no visitor requests; 3700 leaves ~69 KB for ordinary drift until that double-count is cleaned up. v1.38.14 re-measures the four eager routes and changes what the number means. The ceilings had been re-stated but never re-based since v1.37.4, so /inLine truncated
"routeBaselineKbGz": {
"/page": 447,
"/insights/page": 434,
"/measurements/page": 433,
"/insights/mood/page": 447
},
"$comment": "Client-bundle budgets, enforced by scripts/check-bundle-budget.mjs --check after `pnpm build` in CI; the script header describes the method. Numbers are KB gzip. routeBaselineKbGz is the measured first load of every page (the shared rootMainFiles plus the page's client-reference chunks, each file counted once by content hash), written with --write-baselines; a page fails when it is more than routeDriftAllowanceKbGz over its baseline, and a page without a baseline fails until one is written in the same change. firstLoadP95KbGz and firstLoadMaxKbGz cap the distribution across pages. uniqueClientKbGz caps the distinct client code: every module counted once by its module id, the lazy message catalogs excluded. maxCatalogKbGz holds each catalog, maxRechartsChunks the one shared chart chunk. The summed-chunk aggregate the gate used up to v1.44.0 (3875 KB there, capped at 3900) is still printed but no longer gated: it counted a module once per chunk group it landed in, so it rose whenever a page moved code into on-demand chunks and needed a raise in most releases while no reader downloaded more. Re-based at v1.45.0 from the v1.44.0 build, measured on a local arm64 machine: 110 pages, first load p50 336, p95 437, max 451 KB; unique client code 1904 KB in 1224 modules. The five pages watched before keep their numbers within a kilobyte (/ 359, /measurements 339, /insights/mood 337, /insights 323, /settings/[section] 311), and the metric pages that no watched route covered before are gated now too (/insights/weight 339, /insights/pulse 339). Caps: p95 447 and max 461 KB (10 KB over the measurement), unique client code 1930 KB (about 25 KB, roughly two releases of new client code). A raise or a re-base is a reviewed change with the measured numbers and the reason written here; earlier re-bases are in the history of this file. v1.47.0 raised maxCatalogKbGz from 210 to 250 for the Russian catalog: Cyrillic is two bytes per letter in UTF-8, so the same key set is 1.12 MB raw against about 0.8 MB for the Latin-script locales, 240 KB gz as an emitted chunk where the largest Latin one is under 210. A reader downloads one catalog, once; nothing else in the budget moved.",
"routeDriftAllowanceKbGz": 20,
"totalClientKbGz": 3749,
"maxRechartsChunks": 1
"firstLoadP95KbGz": 447,
"firstLoadMaxKbGz": 461,
"uniqueClientKbGz": 1930,
"maxRechartsChunks": 1,
"maxCatalogKbGz": 250,
"routeBaselineKbGz": {
"/_global-error/page": 133,
"/_not-found/page": 282,
"/about/page": 282,
"/achievements/page": 292,
"/admin/[section]/page": 451,
"/admin/page": 297,
"/auth/claim/page": 298,
"/auth/login/page": 290,
"/auth/register/page": 288,
"/c/[token]/page": 284,
"/checkups/page": 365,
"/checkups/practitioners/page": 289,
"/claim/[token]/page": 282,
"/coach/conversations/page": 294,
"/coach/page": 355,
"/coach/plans/page": 288,
"/confirm-access/page": 287,
"/custom-metrics/[id]/page": 303,
"/custom-metrics/[id]/values/page": 311,
"/cycle/page": 412,
"/documents/deleted/page": 291,
"/documents/page": 378,
"/enroll-mfa/page": 305,
"/illness/[id]/page": 447,
"/illness/page": 421,
"/insights/active-energy/page": 336,
"/insights/audio-events/page": 337,
"/insights/blood-glucose/page": 340,
"/insights/blood-pressure/page": 336,
"/insights/bmi/page": 331,
"/insights/body-fat/page": 336,
"/insights/body-temperature/page": 336,
"/insights/body-water/page": 336,
"/insights/bone-mass/page": 336,
"/insights/breathing-disturbances/page": 337,
"/insights/cardio-fitness/page": 336,
"/insights/cardio-recovery/page": 337,
"/insights/catalog/page": 317,
"/insights/daylight/page": 336,
"/insights/double-support-time/page": 336,
"/insights/ecg/[id]/page": 316,
"/insights/ecg/page": 316,
"/insights/environmental-audio/page": 337,
"/insights/falls/page": 336,
"/insights/fat-free-mass/page": 336,
"/insights/fat-mass/page": 336,
"/insights/flights-climbed/page": 336,
"/insights/grip-strength/page": 337,
"/insights/headphone-audio/page": 336,
"/insights/health-score/page": 315,
"/insights/hrv/page": 337,
"/insights/lean-body-mass/page": 336,
"/insights/medications/page": 338,
"/insights/mood/page": 337,
"/insights/muscle-mass/page": 337,
"/insights/nutrients/page": 319,
"/insights/oxygen/page": 337,
"/insights/page": 323,
"/insights/pain/page": 336,
"/insights/pulse-wave-velocity/page": 336,
"/insights/pulse/page": 339,
"/insights/recovery/page": 330,
"/insights/respiratory-rate/page": 336,
"/insights/resting-pulse/page": 337,
"/insights/scores/[metric]/page": 328,
"/insights/six-minute-walk/page": 336,
"/insights/skin-temperature/page": 338,
"/insights/sleep/page": 337,
"/insights/sources/page": 321,
"/insights/stair-ascent-speed/page": 336,
"/insights/stair-descent-speed/page": 336,
"/insights/step-length/page": 336,
"/insights/steps/page": 337,
"/insights/values/[type]/page": 350,
"/insights/vascular-age/page": 337,
"/insights/visceral-fat/page": 336,
"/insights/waist-to-height/page": 336,
"/insights/waist/page": 336,
"/insights/walking-asymmetry/page": 336,
"/insights/walking-distance/page": 336,
"/insights/walking-heart-rate/page": 337,
"/insights/walking-speed/page": 336,
"/insights/walking-steadiness/page": 336,
"/insights/weight/page": 339,
"/insights/workouts/[id]/page": 437,
"/insights/workouts/page": 437,
"/insights/wrist-temperature/page": 336,
"/invite/[token]/page": 282,
"/labs/[biomarkerId]/page": 327,
"/labs/[biomarkerId]/values/page": 335,
"/labs/page": 423,
"/measurements/page": 339,
"/medications/[id]/history/page": 283,
"/medications/[id]/page": 402,
"/medications/new/page": 282,
"/medications/page": 355,
"/mental-wellbeing/page": 297,
"/mood/page": 438,
"/notifications/page": 289,
"/onboarding/[step]/page": 445,
"/onboarding/handover/page": 295,
"/onboarding/page": 286,
"/page": 359,
"/privacy/page": 282,
"/profile/page": 285,
"/settings/[section]/page": 311,
"/settings/layout/[module]/page": 380,
"/settings/modules/[module]/page": 374,
"/settings/page": 282,
"/timeline/page": 331,
"/vaccinations/page": 327
}
}
+372 -40
View File
@@ -1,4 +1,4 @@
# HealthLog — two-container stack (app + Postgres 16).
# HealthLog — two-container stack (app + Postgres 18).
#
# First run (clean host):
# cp .env.example .env
@@ -14,9 +14,13 @@
# and login silently fails. This is the single most common self-host snag.
# Leave it unset (or true) only when a TLS reverse proxy serves HTTPS.
#
# Persistent state lives in exactly one named volume (`pgdata`, Postgres'
# data dir). The app container is stateless, so a backup of the database is
# a backup of everything. The optional GeoLite2 databases are either baked
# Persistent state lives in the named volume `postgres-data` (Postgres' data).
# The app container is stateless, so a backup of the database is a backup of
# everything. `backups` holds the database dumps the app takes every night and
# before every migration (docs/ops/disaster-recovery.md); keep a copy of them
# off this host, or set the off-host bucket below. `pgdata` is where Postgres 16 kept the data up to
# v1.45; on an install that has it, `db-upgrade` moves it into Postgres 18 once
# and then leaves it alone as the way back (docs/ops/postgres-major-upgrade.md). The optional GeoLite2 databases are either baked
# into the image at build time or mounted read-only from the host and
# pointed at with GEOLITE2_DIR — either way they are replaceable, not state.
@@ -72,6 +76,17 @@ x-healthlog-env: &healthlog_env
# (default 60000; 0 disables it and restores the unbounded behaviour).
# Raise it if a very large account's analytics fallback is being cut off.
DATABASE_STATEMENT_TIMEOUT_MS: "${DATABASE_STATEMENT_TIMEOUT_MS:-}"
# Postgres work_mem for every app session (default 16MB).
DATABASE_WORK_MEM: "${DATABASE_WORK_MEM:-}"
# 1 sends neither of the two settings above (they ride the libpq `options`
# startup parameter, which PgBouncer refuses by default).
DATABASE_SESSION_OPTIONS_DISABLED: "${DATABASE_SESSION_OPTIONS_DISABLED:-}"
# The app connects as `healthlog_app_<database>_<hash>`, a role the
# entrypoint creates for this database that reads and writes rows but
# cannot change the schema; the migrations and the queue's install keep
# the owner above. off keeps the app on the owner (for an operator who
# manages database roles). See docs/self-hosting/database-role.md.
DB_APP_ROLE: "${DB_APP_ROLE:-}"
WITHINGS_WEBHOOK_SECRET: "${WITHINGS_WEBHOOK_SECRET:-}"
# Overrides the derived `${NEXT_PUBLIC_APP_URL}/api/withings/callback`;
# set it when the URL registered with Withings differs from the app's own
@@ -83,8 +98,10 @@ x-healthlog-env: &healthlog_env
TELEGRAM_WEBHOOK_SECRET: "${TELEGRAM_WEBHOOK_SECRET:-}"
# v1.18.11 (W3) — client-IP resolution + geo lookup. Behind Cloudflare
# the real visitor IP is in `cf-connecting-ip`; set
# TRUST_CF_CONNECTING_IP=1 ONLY when Cloudflare is in front (off by
# default — a direct-exposed host must not trust a forgeable header).
# TRUST_CF_CONNECTING_IP=1 ONLY when the origin accepts connections from
# Cloudflare alone (a Tunnel, or a firewall admitting only Cloudflare's
# ranges): the header is believed from every connection (off by default;
# see docs/self-hosting/reverse-proxy.md).
# IP_GEO_LOOKUP_URL overrides the default ipwho.is provider (HTTPS by
# default); IP_GEO_LOOKUP_DISABLED=1 stops all third-party IP egress.
# IP_GEO_ALLOW_INSECURE=true permits a plain-HTTP provider URL (e.g. the
@@ -97,7 +114,14 @@ x-healthlog-env: &healthlog_env
# collapses every anonymous caller into one rate-limit bucket. Listed here
# so an operator value in .env reaches the container (the `environment:`
# block is a whitelist).
# TRUSTED_PROXY_CIDRS names the addresses the reverse proxy connects from
# (comma-separated CIDRs). Set, only those connections may report a client
# address; unset, any private address may, which behind Docker Desktop,
# OrbStack or docker-proxy includes every caller of the published port.
# Same host: 127.0.0.1/32,::1/128. Docker network: the proxy's subnet.
# See docs/self-hosting/reverse-proxy.md.
TRUST_PROXY_HOPS: "${TRUST_PROXY_HOPS:-}"
TRUSTED_PROXY_CIDRS: "${TRUSTED_PROXY_CIDRS:-}"
TRUST_CF_CONNECTING_IP: "${TRUST_CF_CONNECTING_IP:-}"
IP_GEO_LOOKUP_URL: "${IP_GEO_LOOKUP_URL:-}"
IP_GEO_LOOKUP_DISABLED: "${IP_GEO_LOOKUP_DISABLED:-}"
@@ -132,6 +156,28 @@ x-healthlog-env: &healthlog_env
# all = bundle web + worker in this container (default, single-host)
# web = serve HTTP only; pair with a separate app-worker service
HEALTHLOG_PROCESS_TYPE: "${HEALTHLOG_PROCESS_TYPE:-all}"
# compose | truenas | coolify | unraid | portainer. Only chooses which
# instructions the admin's "Back up your encryption key" step shows first.
HEALTHLOG_PLATFORM: "${HEALTHLOG_PLATFORM:-}"
# enforce (default) | warn. The start-up encryption key check refuses to
# serve when the key does not open the stored data; warn only logs it and
# serves anyway. A last resort for a refusal you are certain is false: it
# disables a safety check. See docs/self-hosting/getting-started.md.
ENCRYPTION_KEY_CHECK: "${ENCRYPTION_KEY_CHECK:-}"
# The whole-database dump (docs/ops/disaster-recovery.md). Nightly at
# DB_BACKUP_SCHEDULE (five-field cron in Europe/Berlin time, default
# "30 1 * * *") into DB_BACKUP_DIR (default /var/lib/healthlog/backups, the
# `backups` volume below), keeping DB_BACKUP_KEEP daily dumps (default 7)
# and the last two taken before a migration. With the off-host bucket set,
# each nightly dump is also uploaded under `database/`, sealed with
# BACKUP_ENCRYPTION_KEY. DB_BACKUP_ENABLED=false stops the nightly dump;
# DB_BACKUP_BEFORE_MIGRATE=false skips the dump before migrations. Change
# DB_BACKUP_DIR only together with the volume mount.
DB_BACKUP_ENABLED: "${DB_BACKUP_ENABLED:-}"
DB_BACKUP_DIR: "${DB_BACKUP_DIR:-}"
DB_BACKUP_SCHEDULE: "${DB_BACKUP_SCHEDULE:-}"
DB_BACKUP_KEEP: "${DB_BACKUP_KEEP:-}"
DB_BACKUP_BEFORE_MIGRATE: "${DB_BACKUP_BEFORE_MIGRATE:-}"
BACKUP_ENCRYPTION_KEY: "${BACKUP_ENCRYPTION_KEY:-}"
BACKUP_ENCRYPTION_PREVIOUS_KEYS: "${BACKUP_ENCRYPTION_PREVIOUS_KEYS:-}"
BACKUP_S3_ENDPOINT: "${BACKUP_S3_ENDPOINT:-}"
@@ -153,6 +199,7 @@ x-healthlog-env: &healthlog_env
# the block exists: `environment:` is a whitelist, and a value in .env that
# is not named here never arrives.
AUDIT_LOG_RETENTION_DAYS: "${AUDIT_LOG_RETENTION_DAYS:-}"
ACCOUNT_DELETION_GRACE_DAYS: "${ACCOUNT_DELETION_GRACE_DAYS:-}"
# How long Coach conversations are kept, in days (default 365, floor 30).
# Like the audit window this number is PUBLISHED to the user on the Data &
# Privacy page, so an instance running 90 has to be able to say 90.
@@ -213,10 +260,19 @@ x-healthlog-env: &healthlog_env
# the authorization code somewhere else.
FITBIT_REDIRECT_URI: "${FITBIT_REDIRECT_URI:-}"
GOOGLE_HEALTH_REDIRECT_URI: "${GOOGLE_HEALTH_REDIRECT_URI:-}"
# How many years back the Google Health history backfill reads (a whole
# number from 1 to 100; 20 when unset). A data type whose history goes
# further ends there, and the integration card says so.
GOOGLE_HEALTH_BACKFILL_HORIZON_YEARS: "${GOOGLE_HEALTH_BACKFILL_HORIZON_YEARS:-}"
# Nightscout origins on your own network that the per-user connection
# validator should accept, comma-separated (e.g. "http://nightscout.lan").
# Without it a LAN/private Nightscout URL is refused as an SSRF target.
NIGHTSCOUT_PRIVATE_ORIGINS: "${NIGHTSCOUT_PRIVATE_ORIGINS:-}"
# Dawarich instances on your own network that the movement-context
# connection may reach, comma-separated exact origins (e.g.
# "http://dawarich.lan:3000"). Without it a LAN/private Dawarich URL is
# refused as an SSRF target; a public instance needs nothing here.
DAWARICH_PRIVATE_ORIGINS: "${DAWARICH_PRIVATE_ORIGINS:-}"
# Webhook (Gotify, Home Assistant, n8n) and ntfy targets on your own
# network, comma-separated exact origins (e.g. "https://gotify.example.com,
# http://ntfy.lan:8080"). Without it a target that resolves to a private
@@ -236,6 +292,11 @@ x-healthlog-env: &healthlog_env
# the coordinate lookups off a third party.
OPENMETEO_BASE_URL: "${OPENMETEO_BASE_URL:-}"
OPENMETEO_GEOCODING_URL: "${OPENMETEO_GEOCODING_URL:-}"
# Air quality, pollen and UV (CAMS via Open-Meteo). Same hosted default and
# self-host option as above; ENVIRONMENT_AIR_QUALITY_DISABLED=1 stops the
# air-quality requests for the whole instance.
OPENMETEO_AIR_QUALITY_URL: "${OPENMETEO_AIR_QUALITY_URL:-}"
ENVIRONMENT_AIR_QUALITY_DISABLED: "${ENVIRONMENT_AIR_QUALITY_DISABLED:-}"
# OIDC SSO login (self-hosted IdP: Authentik/Keycloak/Authelia/etc).
# All three of ISSUER_URL/CLIENT_ID/CLIENT_SECRET together enable the
# "Sign in with SSO" button; set none to leave it disabled. OIDC_ONLY
@@ -251,17 +312,23 @@ x-healthlog-env: &healthlog_env
OIDC_SCOPES: "${OIDC_SCOPES:-}"
OIDC_BUTTON_LABEL: "${OIDC_BUTTON_LABEL:-}"
OIDC_ONLY: "${OIDC_ONLY:-}"
# Deploy-result webhook (optional). DEPLOY_WEBHOOK_SECRET authenticates
# POST /api/internal/deploy-webhook; DEPLOY_LOGS_URL is the
# deployment-dashboard URL (e.g. the Coolify UI) linked in
# deploy-failure notifications. Listed here so operator values in
# .env reach the container (the `environment:` block is a whitelist).
# Deploy-result webhook (optional). POST /api/internal/deploy-webhook
# authenticates either with DEPLOY_WEBHOOK_SECRET in the
# X-Deploy-Webhook-Secret header (what Coolify's stock sender can do; no
# replay protection) or with X-Deploy-Webhook-Signature: sha256=<HMAC of
# "<X-Deploy-Webhook-Timestamp>.<body>" under the secret>, the timestamp
# within 5 minutes. DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP=true also requires a
# fresh X-Deploy-Webhook-Timestamp on a secret-header request;
# DEPLOY_WEBHOOK_REQUIRE_SIGNATURE=true accepts signed requests only, the
# one setting that stops a replay. DEPLOY_LOGS_URL is the
# deployment-dashboard URL (e.g. the Coolify UI) linked in deploy-failure
# notifications. Listed here so operator values in .env reach the
# container (the `environment:` block is a whitelist). See
# docs/ops/deploy.md.
DEPLOY_WEBHOOK_SECRET: "${DEPLOY_WEBHOOK_SECRET:-}"
DEPLOY_LOGS_URL: "${DEPLOY_LOGS_URL:-}"
# Optional replay hardening: when "true", the deploy webhook
# requires a fresh X-Deploy-Webhook-Timestamp header (±5 min).
# See docs/ops/deploy.md.
DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP: "${DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP:-}"
DEPLOY_WEBHOOK_REQUIRE_SIGNATURE: "${DEPLOY_WEBHOOK_REQUIRE_SIGNATURE:-}"
# TLS leaf SPKI-change alarm baseline (optional). The native client
# pins the served TLS leaf; this is the known-good pin set the monitor
# alarms against on a rotation. Comma-separated for the dual-pin
@@ -286,11 +353,11 @@ x-healthlog-env: &healthlog_env
# you run a gateway this instance should trust; it widens only the admin
# setting, never the per-user BYO-key posture.
ADMIN_AI_BASE_URL_ALLOWLIST: "${ADMIN_AI_BASE_URL_ALLOWLIST:-}"
# Codex (ChatGPT sign-in) model slugs. Set CODEX_MODEL to pin one slug
# ahead of the built-in chain, or CODEX_MODEL_FALLBACK_CHAIN (comma-
# separated) to replace the chain outright. Reach for these when the
# provider rotates a slug and every request starts coming back 503 —
# without them a rotation needs a new release to fix.
# Codex (ChatGPT sign-in) model slugs. Unset, each sign-in picks from the
# models its account is offered, with a built-in order as the fallback.
# Set CODEX_MODEL to pin one slug ahead of that, or
# CODEX_MODEL_FALLBACK_CHAIN (comma-separated) to replace the order
# outright; the account's list is then not read.
CODEX_MODEL: "${CODEX_MODEL:-}"
CODEX_MODEL_FALLBACK_CHAIN: "${CODEX_MODEL_FALLBACK_CHAIN:-}"
# Surfaces reviewed n-of-1 experiment outcomes in the Coach snapshot.
@@ -314,6 +381,11 @@ x-healthlog-env: &healthlog_env
# (the `environment:` block is a whitelist). See
# docs/integrations/ai-providers.md.
DOCUMENT_AI_LIMIT_PER_HOUR: "${DOCUMENT_AI_LIMIT_PER_HOUR:-}"
# Minimum hours between two status-card regenerations after a sync
# (optional; default 6, whole hours 1-48; hand entries keep one hour). The
# value under Admin > Coach wins. Listed here so an operator value in .env
# reaches the container. See docs/integrations/ai-providers.md.
AI_SYNC_REGEN_GAP_HOURS: "${AI_SYNC_REGEN_GAP_HOURS:-}"
# Hourly upload ceiling for each narrow document-upload token (a Paperless
# workflow, the import script; optional; default 120, clamped 1-1000).
# Uploads from the web app and the phone keep their own bucket. Listed here
@@ -381,6 +453,13 @@ x-healthlog-env: &healthlog_env
LOG_SAMPLE_RATE: "${LOG_SAMPLE_RATE:-}"
LOG_SLOW_THRESHOLD_MS: "${LOG_SLOW_THRESHOLD_MS:-}"
LOG_INCLUDE_STACK: "${LOG_INCLUDE_STACK:-}"
# Heap diagnostics, off by default. HEALTHLOG_HEAP_SNAPSHOT=1 writes ONE
# heap snapshot the first time the heap reaches 85 % of its limit, into
# HEALTHLOG_DIAG_DIR (default /tmp inside the container). A snapshot holds
# decrypted health data and keys: turn it on only to chase a crash, then
# delete the file. See docs/ops/heap-diagnostics.md.
HEALTHLOG_HEAP_SNAPSHOT: "${HEALTHLOG_HEAP_SNAPSHOT:-}"
HEALTHLOG_DIAG_DIR: "${HEALTHLOG_DIAG_DIR:-}"
# Optional log shipping to a Loki instance. Set LOKI_ENDPOINT to the base
# URL or the full push URL to turn it on; a Loki on your own network works.
# The username/password pair is only needed when your Loki is behind basic
@@ -434,6 +513,11 @@ services:
- "3000:3000"
environment:
<<: *healthlog_env
# The database dumps (DB_BACKUP_DIR). Without this mount the app still
# copies its nightly dump off-host when the bucket is set, but keeps none
# here and takes none before a migration; the boot log says so.
volumes:
- backups:/var/lib/healthlog/backups
# Bring your own GeoLite2 databases. The published image only carries
# MMDBs if the build had a MaxMind licence key, so on `:latest` the
# offline tier is usually absent and every login IP goes to the online
@@ -443,13 +527,15 @@ services:
# matches GEOLITE2_DIR's default (/opt/geolite2), so this works as-is
# once uncommented. Mounting at a different container path also works,
# just also set GEOLITE2_DIR in .env to match it.
# Steps: docs/self-hosting/geolite2.md.
# Steps: docs/self-hosting/geolite2.md. The line goes under `volumes:`
# above, beside the backups line:
#
# volumes:
# - ./geolite2:/opt/geolite2:ro
depends_on:
db:
condition: service_healthy
db-upgrade:
condition: service_completed_successfully
restart: unless-stopped
# Bounded log rotation: the default json-file driver keeps every line
# forever, which is exactly what fills the disk on the small NAS /
@@ -459,23 +545,51 @@ services:
options:
max-size: "10m"
max-file: "3"
# Conservative memory ceiling for a small host. HEALTHLOG_PROCESS_TYPE=all
# bundles the AI/rollup worker into this same process, and unbounded that
# workload can grow large enough under load to starve Postgres of memory
# on a shared host. 1g is a starting point, not a measured ceiling — raise
# it with APP_MEMORY_LIMIT in .env if the app gets OOM-killed under real
# load.
mem_limit: "${APP_MEMORY_LIMIT:-1g}"
# Memory ceiling for the app. HEALTHLOG_PROCESS_TYPE=all bundles the
# AI/rollup/backup worker into this same process, and unbounded that
# workload can grow large enough to starve Postgres on a shared host.
# Node sizes its heap from this limit: 1g gives a 524 MB heap, which a
# long-used account fills in normal operation, so garbage collection
# stalls requests for seconds. 1536m gives 792 MB and is the supported
# floor (host: 3 GB RAM, 4 GB recommended). Below it the boot log, the
# support report and Admin > System status say so. On a busy instance,
# raise it with APP_MEMORY_LIMIT in .env; do not lower it.
mem_limit: "${APP_MEMORY_LIMIT:-1536m}"
# On `docker stop` (a redeploy) the app stops taking requests, lets the
# requests in flight finish and gives running jobs up to 30 s to end
# before it exits. Docker's default of 10 s cut that short and left
# jobs marked active until their expiry, holding exclusive queues.
stop_grace_period: 45s
healthcheck:
test:
[
"CMD-SHELL",
"wget --no-verbose --tries=1 --spider http://127.0.0.1:3000/api/health || exit 1",
"CMD",
"node",
"-e",
"fetch('http://127.0.0.1:3000/api/health',{signal:AbortSignal.timeout(4000)}).then(r=>process.exit(r.ok?0:1),()=>process.exit(1))",
]
interval: 30s
timeout: 5s
retries: 3
start_period: 30s
# Five misses in a row, two and a half minutes, before "unhealthy". The
# probe gives the database two seconds to answer SELECT 1 and says 503
# past that, so a boot storm that saturates the pool for a minute after
# the start period (backfills, a restore, another tenant's import) costs
# a probe or two, not the container's health and the proxy's routing.
# The probe's own 4 s abort ends the request inside Docker's 5 s, so a
# stalled connection reads as one clean miss rather than a killed probe.
# A database that stays away for 2.5 minutes is a real outage and shows.
retries: 5
# The entrypoint applies migrations before the server listens. A
# migration that waits out a held lock retries for up to about eight
# minutes (docs/ops/deploy.md), and failed probes in that time must not
# read as a dead container. The first probe that answers still ends
# the start period, so a normal boot is healthy within 30 seconds of
# listening. After it listens, the pg-boss worker starts in the
# background and /api/health answers 503 with reason "starting" until it
# is up (10–60 s under load, reported as "starting" for at most five
# minutes); inside the start period those probes do not count, so a slow
# worker start cannot mark the container unhealthy.
start_period: 10m
# Optional dedicated worker container — uncomment + set
# HEALTHLOG_PROCESS_TYPE=web on `app` to split the deployment.
@@ -494,24 +608,29 @@ services:
# environment:
# <<: *healthlog_env
# HEALTHLOG_PROCESS_TYPE: "worker"
# # The worker takes the nightly database dump, so it needs the volume.
# volumes:
# - backups:/var/lib/healthlog/backups
# depends_on:
# db:
# condition: service_healthy
# db-upgrade:
# condition: service_completed_successfully
# restart: unless-stopped
# logging:
# driver: json-file
# options:
# max-size: "10m"
# max-file: "3"
# mem_limit: "${WORKER_MEMORY_LIMIT:-1g}"
# mem_limit: "${WORKER_MEMORY_LIMIT:-1536m}"
# stop_grace_period: 45s
db:
# Pinned to the concrete minor instead of the floating `16-alpine`
# Pinned to the concrete minor instead of the floating `18-alpine`
# alias so a database update is always a deliberate, reviewable
# change (at pin time `16-alpine` resolved to this same digest, so
# existing deployments see no image change). Minor releases share
# the on-disk format — bumping this pin is safe without a dump /
# restore; a major-version bump (17+) is not.
# change. Minor releases share the on-disk format, so bumping this pin
# is safe without a dump / restore; a major version is not, which is
# what `db-upgrade` below and docs/ops/postgres-major-upgrade.md are for.
#
# The pin only works as long as something watches it: PostgreSQL
# publishes minor releases with security and data-corruption fixes and
@@ -521,8 +640,23 @@ services:
# `.github/dependabot.yml` now raises a PR for each new minor;
# `src/__tests__/database-pin-is-watched.test.ts` keeps both halves
# (concrete pin, active watch) from being dropped.
image: postgres:16.15-alpine
image: postgres:18.6-alpine
container_name: healthlog-db
# Loads pg_stat_statements so slow queries can be found from measurements
# instead of guesses. Loading the library only collects statistics; the
# view appears after a one-time `CREATE EXTENSION pg_stat_statements;`
# (docs/ops/query-statistics.md). Changing this line restarts the
# database once on the next `compose up`.
command:
- postgres
- -c
- shared_preload_libraries=pg_stat_statements
- -c
- pg_stat_statements.track=top
# Docker gives a container 64 MB of /dev/shm by default. Postgres keeps
# the shared memory of parallel queries and parallel maintenance there,
# and a larger plan fails with "could not resize shared memory segment".
shm_size: 256mb
# cap_drop: [ALL] is deliberately NOT applied here (unlike `app`): the
# official Postgres entrypoint starts as root, chowns the data
# directory, and drops privileges to the `postgres` user itself, which
@@ -536,8 +670,12 @@ services:
POSTGRES_USER: healthlog
POSTGRES_PASSWORD: "${POSTGRES_PASSWORD}"
POSTGRES_DB: healthlog
# From 18 on, the image keeps its data under /var/lib/postgresql/18/docker
# and expects the volume one level up, so the next major can sit beside
# this one in the same volume. Mounting it at .../data, as 16 had it,
# makes the container refuse to start.
volumes:
- pgdata:/var/lib/postgresql/data
- postgres-data:/var/lib/postgresql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U healthlog"]
interval: 5s
@@ -558,5 +696,199 @@ services:
max-size: "10m"
max-file: "3"
# Moves a Postgres 16 database from v1.45 or earlier into Postgres 18, once,
# on the first start after the update. A major version cannot read the data
# directory of an older one, so changing the image line alone would leave
# the database refusing to start.
#
# On every start it decides in a second and exits:
# - no `pgdata` data (a new install): nothing to do;
# - the database service runs Postgres 16 (after going back): nothing to
# do, the live 16 volume is left alone;
# - 16 was used again after an earlier move: refuses, so the app does not
# start on the move-day copy;
# - Postgres 18 already holds the database: nothing to do;
# - otherwise it starts Postgres 16 inside this container on the old
# volume, streams a dump into the new database in one transaction,
# compares the row count of every table, gathers planner statistics and
# marks the old volume as moved. `app` starts only after it succeeded.
# A move cut short (a restart, a full disk) leaves a "moving" marker, and
# the next start empties the new database and runs it again from scratch.
#
# The old volume is read, never changed, apart from that marker file, so
# going back is the previous image and volume lines. If anything fails, the
# new database stays empty, `app` does not start, and the log says why.
# It costs one small image pull; a later release removes the service.
db-upgrade:
image: postgres:16.15-alpine
container_name: healthlog-db-upgrade
# Runs once per `compose up` and exits. "no" also keeps hosts such as
# Coolify from reporting the exited container as a failed service.
restart: "no"
security_opt:
- no-new-privileges:true
environment:
POSTGRES_PASSWORD: "${POSTGRES_PASSWORD}"
volumes:
- pgdata:/var/lib/postgresql/data
# Read-only, only to check there is room for the copy before starting.
- postgres-data:/var/lib/postgresql/next:ro
depends_on:
db:
condition: service_healthy
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
# `$$` is how compose spells a literal `$`.
entrypoint: ["/bin/sh", "-c"]
command:
- |
set -eu
set -o pipefail
log() { echo "db-upgrade: $$*"; }
OLD=/var/lib/postgresql/data
MARK="$$OLD/HEALTHLOG_MOVED_TO_POSTGRES_18"
MOVING="$$OLD/HEALTHLOG_MOVING_TO_POSTGRES_18"
export PGPASSWORD="$$POSTGRES_PASSWORD"
new() { psql -h db -U healthlog -d healthlog -v ON_ERROR_STOP=1 -Atqc "$$1"; }
old() { psql -h /tmp -U healthlog -d healthlog -v ON_ERROR_STOP=1 -Atqc "$$1"; }
empty_new() {
for S in $$(new "SELECT quote_ident(nspname) FROM pg_namespace WHERE nspname NOT LIKE 'pg\\_%' AND nspname <> 'information_schema'"); do
new "DROP SCHEMA $$S CASCADE"
done
new "CREATE SCHEMA public"
}
if [ ! -s "$$OLD/PG_VERSION" ]; then
log "no Postgres 16 database to move, nothing to do"
exit 0
fi
# The database service must be the new major. After going back to 16 the
# service runs on this very volume: touching it then would wipe the live data.
i=0
until NEWVER=$$(new "SHOW server_version_num" 2>/dev/null); do
i=$$((i + 1))
[ "$$i" -lt 60 ] || { log "the database service did not answer for a minute"; exit 1; }
sleep 1
done
if [ "$$NEWVER" -lt 180000 ]; then
log "the database service runs Postgres $$((NEWVER / 10000)), not 18: nothing to move, the old volume is left alone"
exit 0
fi
checkpoint() { pg_controldata "$$OLD" | awk -F': *' '/^Latest checkpoint location/ { print $$2 }'; }
USER_TABLES="FROM pg_tables WHERE schemaname NOT IN ('pg_catalog', 'information_schema')"
TABLES=$$(new "SELECT count(*) $$USER_TABLES")
if [ -e "$$MARK" ]; then
MOVED_ON=$$(sed -n 1p "$$MARK")
MOVED_CHECKPOINT=$$(sed -n 2p "$$MARK")
if [ -n "$$MOVED_CHECKPOINT" ] && [ "$$(checkpoint)" != "$$MOVED_CHECKPOINT" ]; then
log "the Postgres 16 database changed after it was moved on $$MOVED_ON: it was used again after going back to 16."
log "Postgres 18 still holds the state of that day, so it is not started on. To move the current 16 database,"
log "see docs/ops/postgres-major-upgrade.md, \"Updating again after going back\"."
exit 1
fi
if [ "$$TABLES" != 0 ]; then
log "the Postgres 16 database was moved on $$MOVED_ON, nothing to do"
exit 0
fi
log "the Postgres 16 database was moved on $$MOVED_ON, but the Postgres 18 database is empty now."
log "Not importing the old state again by itself. See docs/ops/postgres-major-upgrade.md, \"Starting over\"."
exit 1
fi
if [ -e "$$MOVING" ]; then
log "an earlier move did not finish; emptying the Postgres 18 database and starting it again"
empty_new
elif [ "$$TABLES" != 0 ]; then
log "the Postgres 18 database already holds data, leaving both as they are"
exit 0
fi
VERSION=$$(cat "$$OLD/PG_VERSION")
if [ "$$VERSION" != 16 ]; then
log "the old data directory is Postgres $$VERSION, this step moves Postgres 16 only."
log "See docs/ops/postgres-major-upgrade.md to move it by hand."
exit 1
fi
NEED=$$(du -sk "$$OLD" | cut -f1)
FREE=$$(df -Pk /var/lib/postgresql/next | awk 'NR == 2 { print $$4 }')
# Room for the copy, and for the dump the app takes before its first migration.
if [ "$$FREE" -le $$((NEED * 3 / 2)) ]; then
log "the move needs about $$((NEED * 3 / 2 / 1024)) MiB free for the new database and its first dump, the disk has $$((FREE / 1024)) MiB."
log "Nothing was changed. Free some space and start the stack again."
exit 1
fi
trap 'CODE=$$?; [ "$$CODE" = 0 ] || log "the move stopped (exit $$CODE). The old volume is untouched; the next start begins it again."' EXIT
date -u +%Y-%m-%dT%H:%M:%SZ > "$$MOVING"
log "moving the Postgres 16 database ($$((NEED / 1024)) MiB on disk) into Postgres 18"
su-exec postgres postgres -D "$$OLD" -c listen_addresses= -c unix_socket_directories=/tmp &
PID=$$!
i=0
until pg_isready -h /tmp -U healthlog -q; do
kill -0 "$$PID" 2>/dev/null || { log "the Postgres 16 server did not start, see the lines above"; exit 1; }
i=$$((i + 1))
[ "$$i" -lt 600 ] || { log "the Postgres 16 server did not get ready in ten minutes"; exit 1; }
sleep 1
done
pg_dump -h /tmp -U healthlog -Fc healthlog |
pg_restore -h db -U healthlog -d healthlog --no-owner --single-transaction --exit-on-error
COUNTED=0
for T in $$(old "SELECT quote_ident(schemaname) || '.' || quote_ident(tablename) $$USER_TABLES ORDER BY 1"); do
A=$$(old "SELECT count(*) FROM $$T")
B=$$(new "SELECT count(*) FROM $$T")
if [ "$$A" != "$$B" ]; then
log "table $$T has $$A rows in Postgres 16 but $$B in Postgres 18. Emptying the new database again."
log "Please report this with the lines above."
empty_new
exit 1
fi
COUNTED=$$((COUNTED + 1))
done
log "all $$COUNTED tables arrived with the same row counts"
vacuumdb -h db -U healthlog -d healthlog --analyze-in-stages --quiet
kill -INT "$$PID"
wait "$$PID" || true
# The marker keeps the old database's last checkpoint: a later start can tell
# whether 16 was used again after the move.
{ date -u +%Y-%m-%dT%H:%M:%SZ; checkpoint; } > "$$MOVING"
mv "$$MOVING" "$$MARK"
log "done. The old volume stays as it was, as a way back; see docs/ops/postgres-major-upgrade.md"
# Restores a database dump into an EMPTY database (docs/ops/disaster-recovery.md).
# Never started by `docker compose up`; run it by name:
#
# docker compose run --rm db-restore list
# docker compose run --rm db-restore latest
# docker compose run --rm db-restore healthlog-<time>-daily.dump
# docker compose run --rm db-restore latest --from-bucket
#
# It checks the dump against its manifest and the keys in .env against the
# keys the dump was written with, refuses a database that already holds
# tables, restores in one transaction and compares every table's row count
# with the manifest. Start the app only after it says "Restored".
# `--from-bucket` downloads the dump and its manifest from the off-host bucket
# (`database/`, the BACKUP_S3_* variables) and opens them with
# BACKUP_ENCRYPTION_KEY: the restore after the host and its local dumps are
# gone. Files you downloaded yourself restore from a directory you mount:
# `docker compose run --rm -v ./restore:/restore:ro db-restore
# /restore/healthlog-<time>-daily.dump.enc`.
db-restore:
image: ghcr.io/mbombeck/healthlog${HEALTHLOG_IMAGE_REF:-:latest}
profiles: ["restore"]
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
environment:
<<: *healthlog_env
volumes:
- backups:/var/lib/healthlog/backups
depends_on:
db:
condition: service_healthy
entrypoint: ["node", "scripts/db-restore.mjs"]
command: ["list"]
volumes:
postgres-data:
pgdata:
backups:
+34 -1
View File
@@ -85,11 +85,44 @@ done
echo "HealthLog: Database is reachable."
# ── Run Prisma migrations ────────────────────────────────────
# Say how many migrations this image is about to apply, with a reminder to
# back up first. Reporting only: it never stops the boot.
node scripts/pending-migrations.mjs || true
# When migrations are pending, dump the database into DB_BACKUP_DIR first, so
# the state before them can be restored (docs/ops/disaster-recovery.md). No
# directory that survives the container (none writable, or the image's own
# without the backups volume): a warning, and the boot goes on. A kept
# directory and a failed dump: the boot stops here, before anything is
# migrated.
if ! node scripts/db-dump.mjs pre-migration; then
echo "HealthLog: ERROR — not migrating without the dump. Free disk space or fix the error above and start again." >&2
echo "HealthLog: to migrate without a dump this once, set DB_BACKUP_BEFORE_MIGRATE=false." >&2
exit 1
fi
# `migrate-deploy.mjs` runs `prisma migrate deploy` and retries only a
# migration that timed out on a lock and is marked safe to re-run; any other
# failure stops the boot with Prisma's exit code (docs/ops/deploy.md).
echo "HealthLog: Running database migrations..."
NODE_PATH=/opt/prisma-cli/node_modules \
node /opt/prisma-cli/node_modules/prisma/build/index.js migrate deploy
node scripts/migrate-deploy.mjs
echo "HealthLog: Migrations complete."
# ── The app's database role ──────────────────────────────────
# Everything above ran as the owner DATABASE_URL names. The app itself
# connects as `healthlog_app_<database>_<hash>`, this database's own role,
# which reads and writes rows and runs the queue but cannot change the schema (scripts/db-app-role.mjs says what it may and
# may not do, and docs/self-hosting/database-role.md how to opt out). The
# script hands the role's URL over on descriptor 3 only, never on stdout or
# stderr; nothing handed over means it said why and the app stays on the
# owner, as before. The owner's URL is replaced, not kept beside it, so it
# does not reach the app's environment.
app_database_url="$(node scripts/db-app-role.mjs 3>&1 1>&2)" || app_database_url=""
if [ -n "$app_database_url" ]; then
DATABASE_URL="$app_database_url"
export DATABASE_URL
fi
unset app_database_url
# ── Start application ────────────────────────────────────────
echo "HealthLog: Starting application..."
exec "$@"
+1 -1
View File
@@ -19,7 +19,7 @@ Every JSON route answers the same shape: `{ data, error }`, with `meta` on the e
- Refresh tokens rotate per device and are one-time-use. Reusing an old one revokes that device's whole token family, on purpose. Persist the newest pair atomically and never retry a refresh with a token that already succeeded once.
- Bearer token scopes are fail-closed. A token minted for one purpose does not open unrelated routes, and a route that does not declare a scope refuses narrow tokens outright. Expect `403` to be a normal, recoverable answer, not an anomaly.
- Admin surfaces are cookie-session only, by construction. A native client cannot be an admin client, whatever scopes its token carries. Do not build admin features.
- Mutations accept an `Idempotency-Key` header. Replays answer with the original result plus `X-Idempotent-Replay`, so retrying on a dropped connection is safe. Use it for anything a user would notice twice.
- Mutations accept an `Idempotency-Key` header. A repeat of the same key with the same body answers with the original result plus `X-Idempotent-Replay`, so retrying on a dropped connection is safe. A different body under the same key is treated as a new request, not a replay; still, give each distinct write its own key. Use it for anything a user would notice twice.
## Data semantics worth knowing before you sync
+34 -9
View File
@@ -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)
@@ -33,13 +37,24 @@ notes, and machine-readable specs that ship alongside the source:
three access levels, the eight sections a grant can be narrowed to,
managed profiles for somebody with no login and the guardians who look
after them, what revocation does and does not undo, and which trust
properties of the instance are unchanged by any of it.
properties of the instance are unchanged by any of it. And
[`self-hosting/support.md`](./self-hosting/support.md) — reporting a
problem: error ids, the support info every account can copy, the
operator's redacted support report, and finding an error in the admin log.
- [`using/`](./using/) — notes on using the app that sit close to the
code. [`using/search.md`](./using/search.md) covers the command palette's
record search: opening it, what it finds, how matching works, and its
limits on very large and shared records.
[`using/cycle-calendar-feed.md`](./using/cycle-calendar-feed.md) covers
subscribing to the cycle forecast from Google, Apple or Outlook.
- [`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
Garmin; 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
@@ -49,8 +64,18 @@ notes, and machine-readable specs that ship alongside the source:
— the iOS submission gate.
- [`codex-protocol-spec.md`](./codex-protocol-spec.md) — Codex / ChatGPT
OAuth protocol reverse-engineering notes.
- [`ui-guidelines.md`](./ui-guidelines.md) — UI / a11y house style.
- [`contributing/ui-standards.md`](./contributing/ui-standards.md) — the
binding UI standards: card anatomy, spacing, text colour, tokens and
which primitive to use where. Read before touching any surface.
- [`contributing/benchmarks.md`](./contributing/benchmarks.md) — the
large-account benchmark (latency and statements per request) and the
statement budgets on the hot routes.
- [`contributing/testing.md`](./contributing/testing.md) — which suite a
test belongs in, the route test kit, the integration database, sharding,
property and mutation tests, and the flaky-test quarantine.
- [`ui-guidelines.md`](./ui-guidelines.md) — UI / a11y house style; where
it disagrees with the UI standards, the standards win.
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).
+11 -4
View File
@@ -66,7 +66,11 @@ before treating a warning as a regression.
`health:write`), audience-bound to `/mcp` and refused on REST writes;
- `POST /api/tokens/measurements` mints `measurements:write`, accepted by
`POST /api/measurements` and `POST /api/measurements/batch` only, on the
holder's own record only;
holder's own record only. The readings it writes carry `source: EXTERNAL`
and are linked to the token; whether the HealthLog app writes them into
Apple Health is the token's `mirrorToAppleHealth` setting (off by
default, changed with `PATCH /api/tokens/{id}`), resolved onto every
measurement row as `appleHealthMirror`;
- `POST /api/tokens/workouts` mints `workouts:write`, accepted by
`POST /api/workouts/batch` only, on the holder's own record only; the
workouts it writes carry `source: EXTERNAL`;
@@ -78,9 +82,12 @@ before treating a warning as a regression.
v1.30.17. The path still lists (`GET`) and revokes (`DELETE /api/tokens/{id}`)
every token above, whichever surface issued it.
- `sessionCookie` — HttpOnly `healthlog_session` cookie set by the
password and passkey login flows. Used by the web app and any iOS
client running inside a web view.
- `sessionCookie` — HttpOnly session cookie set by the password and
passkey login flows. Used by the web app and any iOS client running
inside a web view. It is named `__Host-healthlog_session` wherever it is
`Secure` (every HTTPS install), so no other host under the same domain
can set it; a plain-HTTP install keeps `healthlog_session`. A cookie
under the old name is still read and is re-issued under the new one.
- **`components.schemas`** — DTOs for the documented routes plus the
underlying Prisma models (e.g. `Measurement`, `Medication`,
`MedicationIntakeEvent`, `MoodEntry`, `User`, `DashboardLayout`,
+8
View File
@@ -110,6 +110,14 @@ What `search` matches for the clinical records:
reading deferred is matched on its title and details only.
- `vaccination:<id>`: vaccine name, catalogue name, practitioner.
A lab has two ids. `lab:<analyte>` is the analyte view and answers with that
marker's latest reading. `labresult:<id>` is one reading: its value or text
result, unit, reference range, status, date, and its own links (the report
it was read from, the visits it is filed against). Every link to a lab result
(from a document, a visit, a condition or a medication) uses the reading id,
so following a report's link lands on the reading that report holds rather
than on a later draw. Each reading in `get_labs` carries the same id.
Condition and vaccination notes are never searched or returned, the same
rule the Coach and the doctor report follow. A deleted practitioner is not
named or matched. Query words shorter than four letters match whole words or
+27385 -6327
View File
File diff suppressed because it is too large. Load diff
Binary file not shown.

Before

Width:  |  Height:  |  Size: 344 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 831 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 461 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 270 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 302 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 400 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 310 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 272 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 262 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 778 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 292 KiB

+42 -17
View File
@@ -630,9 +630,11 @@ Confirmed-rejected slugs (despite the source-tree references in the table above)
Confirmed-accepted slugs on a ChatGPT Plus subscription (per `available_in_plans` in `models.json`):
- `gpt-5.5` — HealthLog's default (first chain entry) as of 2026-06
- `gpt-5.4` / `gpt-5.4-mini` / `gpt-5.2` — accepted per plan
- `gpt-5.3-codex` — was the default (verified 2026-05-09); the backend rotated off the `-codex` line on 2026-06-02 and this slug is now only accepted intermittently, so it sits on a late chain rung
- `gpt-5.6-terra` — HealthLog's default (first chain entry) as of 2026-10-24; the backend rejected `gpt-5.5` and everything below it that day. `gpt-5.6-luna` and `gpt-5.6-sol` are its siblings on the same line
- `gpt-6-sol`, `gpt-6.1-sol`, `gpt-6-luna`, `gpt-6-astra` — the gpt-6 line, listed in `models.json` since 2026-09-22 (`gpt-6.1-sol` the catalog default since 2026-09-29). The catalog names `gpt-6-sol` as the upgrade of `gpt-5.6-terra` and `gpt-5.6-sol`, and `gpt-6-luna` as that of `gpt-5.6-luna`. Every gpt-6 slug lists `input_modalities: ["text", "image"]` and the same reasoning presets as the gpt-5.6 line (`low` to `max`, sol and astra also `ultra`). The presets omit `none`, as they do for `gpt-5.5`, which a live probe on 2026-10-06 showed accepts it; HealthLog sends `none` for "off" on the gpt-6 line too and learns a refusal per slug. A live check on 2026-10-24 found the account's own list (`GET /backend-api/codex/models?client_version=…`) offering `gpt-6.1-sol`, `gpt-6-astra`, `gpt-6-sol`, `gpt-6-luna` and the `gpt-5.6` line, all with `input_modalities: ["text", "image"]`. From a HealthLog container with its stored sign-in, `gpt-6.1-sol`, `gpt-6-sol` and `gpt-6-luna` each answered text and read a lab-report image correctly. `gpt-6.1-sol` refuses `reasoning.effort: "none"` and names `low` as its lowest effort, which the client learns and steps up to on the same slug; the other two accept `none`
- `gpt-5.5` — the default from 2026-06 until 2026-10-24, kept as the last chain rung while plans differ
Retired for ChatGPT sign-in (OpenAI's Codex models page): `gpt-5.2` and `gpt-5.3-codex` on 2026-05-26, `gpt-5.4` and `gpt-5.4-mini` on 2026-08-31, `gpt-5.5` on 2026-10-14 at 19:00 UTC. The backend answers each of them with 400 `model_not_found`, "The '<slug>' model is not supported when using Codex with a ChatGPT account.", which the client reads as a reason to try the next slug.
The `codex-rs/tui/src/model_migration.rs` references to `gpt-5-codex` etc. are _migration prompts_ shown to existing users — they are NOT the slugs sent on the wire after migration. The wire slugs come from `models.json`. Always cross-check `models.json` (`gh api repos/openai/codex/contents/codex-rs/models-manager/models.json`) before picking a default.
@@ -640,23 +642,43 @@ The `codex-rs/tui/src/model_migration.rs` references to `gpt-5-codex` etc. are _
The ChatGPT-Codex allow-list is rotated by OpenAI without prior notice (`gpt-5-codex` → `gpt-5.3-codex` happened over 48 h in May 2026 and bricked HealthLog twice while the v1.4.7..v1.4.13 saga unrolled). The cause: a single hard-coded slug in `CODEX_MODEL` flips invalid the moment the upstream rotates it. Operator brief, verbatim 2026-05-09: "Die Integration des Slug Drift Risiko darf halt immer überhaupt nicht sein."
To eliminate that risk class, HealthLog's `CodexClient` walks an ordered chain on every fresh request series.
To eliminate that risk class, HealthLog's `CodexClient` tries its sign-in's working slug first and otherwise walks an ordered chain built from the models the account is offered.
**Chain** (top = first try; default order is the empirically-safest most-current-first ordering as of 2026-06, after the backend rotated off the `-codex` line on 2026-06-02):
**The account's model list.** Before it walks, the client reads the list the Codex CLI reads, on the same sign-in as the responses call:
```http
GET /backend-api/codex/models?client_version=0.165.0 HTTP/1.1
Host: chatgpt.com
Authorization: Bearer <oauth-access-token>
ChatGPT-Account-ID: <chatgpt_account_id>
originator: healthlog
```
The answer is `ModelsResponse` from `codex-rs/protocol/src/openai_models.rs`: `{ "models": [ModelInfo, ...] }`, where the client reads `slug`, `visibility` (`list` / `hide` / `none`), `priority` (lower first), `input_modalities` (`text`, `image`, `audio`) and `model_specialty`. `codex-rs/codex-api/src/endpoint/models.rs` appends `client_version`; the backend leaves out models whose `minimal_client_version` is above it, so the version sent tracks a current Codex CLI release (`CODEX_MODELS_CLIENT_VERSION` in `src/lib/ai/codex-model-catalog.ts`). The read is bounded like the CLI's (5 s, `codex-rs/model-provider/src/models_endpoint.rs`) and to 4 MiB, cached per sign-in for an hour, and a failed read waits ten minutes before the next attempt. The list is a hint, never a gate: a read that fails, times out, does not parse or comes back empty leaves the static chain below, and one `ai.codex.models_unreadable` warning per sign-in reports the stretch.
**Order.** `CODEX_MODEL` first when set. `CODEX_MODEL_FALLBACK_CHAIN`, when set, replaces everything after it and the list is not read. Otherwise: the default chain below kept to what the account is offered; then every other offered model in the list's `priority` order, leaving out what the list hides (`visibility` other than `list`), the specialist lines (`model_specialty`) and the `-astra` frontier tier; then the rest of the default chain, so the walk never holds fewer models than before the list existed. `supported_in_api` is not a filter: it describes API-key access, and the CLI ignores it for a ChatGPT sign-in (`ModelPreset::filter_by_auth`). A slug this sign-in was refused in the last six hours goes to the very end, even while its list still offers it. With the list's `input_modalities` for the slug in hand, the Codex vision gate reads them; without, it falls back to the model family.
**Default chain** (top = first try). `gpt-5.6-terra` leads because it is the slug verified against the backend. The gpt-6 rungs follow before terra's own siblings: the catalog names `gpt-6-sol` as terra's upgrade, and the 2026-10-24 rotation retired a whole line at once, so a rotation that takes terra most likely takes `gpt-5.6-luna` and `gpt-5.6-sol` with it. `gpt-6.1-sol` (the catalog default, also on plans `gpt-6-sol` is not offered on) and `gpt-6-luna` (the lighter one) come next. `gpt-6-astra`, the frontier tier, stays out of the default:
```ts
const DEFAULT_SLUG_FALLBACK_CHAIN = [
"gpt-5.5", // current default across paid tiers as of 2026-06-02
"gpt-5.4", // documented fallback
"gpt-5.4-mini", // documented lighter-weight fallback
"gpt-5.3-codex", // late rung — still accepted intermittently
"gpt-5.2", // legacy floor
"gpt-5.6-terra", // verified against the backend 2026-10-24
"gpt-6-sol", // terra's upgrade in the upstream catalog
"gpt-6.1-sol", // the catalog default, on every plan
"gpt-6-luna", // lighter gpt-6 sibling
"gpt-5.6-luna", // lighter sibling on terra's line
"gpt-5.6-sol", // heavier sibling on terra's line
"gpt-5.5", // previous default, kept as the last rung while plans differ
];
```
The shipped constant in `src/lib/ai/codex-client.ts` is authoritative; when the upstream rotates again, that file moves first and this listing follows.
Every default rung must be a model family the reasoning, sampling and vision gates recognise (`src/lib/ai/openai-model-family.ts`); `codex-slug-fallback.test.ts` holds the chain to that.
The chain is overridable via the env var `CODEX_MODEL_FALLBACK_CHAIN` (comma-separated). The first env-var-named slug supersedes `CODEX_MODEL`; if both are set, `CODEX_MODEL` is folded into position 0 and de-duplicated.
The shipped constant in `src/lib/ai/codex-slug-chain.ts` is authoritative; when the upstream rotates again, that file moves first and this listing follows.
A fixed chain goes stale. From 2026-10-14, when GPT-5.5 retired, every model on the chain HealthLog shipped then was retired, and every Codex request answered 503 "all configured Codex slugs were rejected" until the chain was updated or `CODEX_MODEL` was set by hand. That is why the account's live list now comes first and the default chain only orders and backs it up.
The chain is overridable via the env var `CODEX_MODEL_FALLBACK_CHAIN` (comma-separated), which replaces it outright; if `CODEX_MODEL` is also set, it is folded into position 0 and de-duplicated.
**Trigger condition** (when to walk to the next slug):
@@ -670,15 +692,15 @@ The chain is overridable via the env var `CODEX_MODEL_FALLBACK_CHAIN` (comma-sep
| `401` after refresh, `403`, `429`, `5xx` | propagate (don't walk) |
| `200` with SSE `response.failed.error.code === "invalid_prompt"` | propagate (request shape is wrong, slug is fine) |
**Positive cache** — when a slug is observed working (HTTP 200 with at least one `response.completed` event), record `(slug, timestamp)` in a process-local Map. On the next request, jump straight to the cached slug. TTL: 1 hour. Cache scope: per-process (no DB write).
**Positive cache** — when a slug is observed working (HTTP 200 with at least one `response.completed` event), record `(slug, timestamp)` for that sign-in. On its next request, jump straight to the cached slug; the model list is read only when there is none or it stops working. TTL: 1 hour. Scope: per stored credential (`user:<id>` for a person's connection, `central` for the operator-shared one), process-local, no DB write, at most 1000 sign-ins held. Each entry carries a digest of the ChatGPT account id it was learned under, and a client reads it only under that account, so a credential reconnected to another account starts empty: its first request tries no slug, skips no refusal and trusts no list the old account left, in any process. The vision gate holds no account id; the connect and disconnect routes rebind or drop the entry, so in the process that handled the change it answers from the new sign-in at once, and elsewhere from that process's first request on it. Two sign-ins on different plans no longer overwrite each other's working slug.
**Cache invalidation**: on first 400/404 walk, the cached entry is dropped. The next-best slug is promoted into the cache only after it succeeds.
**Cache invalidation**: on a 400/404 walk the refused slug leaves the working slot and goes to the back of that sign-in's line for six hours. The next-best slug is promoted into the cache only after it succeeds.
**All-failed case**: if every slug in the chain rejects with the walk-trigger, the client throws a structured error with `httpStatus: 503` and message `"AI provider unreachable — all configured Codex slugs were rejected"`. The route layer surfaces this to the user with "AI provider unreachable — check Settings".
**Observability**: each attempted slug is recorded in the Wide-Event annotation `codex.slug.attempts: ["gpt-5.3-codex","gpt-5-codex"]` so Loki can answer "which slug carried this request" and "how often did the cache hit". Cache hits emit `codex.slug.cache: "hit" | "miss" | "expired"`.
**Observability**: a request that builds the chain annotates `codex.models.chain` (`operator` / `live` / `static`), `codex.models.read` (`live` / `cache` / `skipped`), `codex.models.failure` (`timeout`, `network`, `auth`, `http_error`, `too_large`, `unparsable`, `empty`, or null) and `codex.models.offered` (a count). `CodexClient.getLastDiagnostics()` holds the attempted slugs, the cache state and the working slug.
The implementation lives in `src/lib/ai/codex-client.ts`; the contract is unit-tested in `src/lib/ai/__tests__/codex-client.test.ts`.
The walk lives in `src/lib/ai/codex-client.ts`, the order in `src/lib/ai/codex-slug-chain.ts`, the list read in `src/lib/ai/codex-model-catalog.ts`, the per-sign-in memory in `src/lib/ai/codex-slug-cache.ts`; the contract is unit-tested in `src/lib/ai/__tests__/codex-live-models.test.ts`, `codex-slug-chain.test.ts`, `codex-model-catalog.test.ts` and `codex-slug-fallback.test.ts`.
---
@@ -773,7 +795,10 @@ All paths relative to `openai/codex` repo:
- `codex-rs/codex-api/src/rate_limits.rs` — rate-limit headers
- `codex-rs/codex-api/src/auth.rs` — `AuthProvider` trait
- `codex-rs/protocol/src/models.rs` — `ResponseItem`, `ContentItem`, `MessagePhase`, `ImageDetail`, etc.
- `codex-rs/protocol/src/openai_models.rs` — `ReasoningEffort` enum
- `codex-rs/protocol/src/openai_models.rs` — `ReasoningEffort` enum; `ModelsResponse`, `ModelInfo`, `ModelVisibility`, `InputModality`, `ModelPreset::filter_by_auth` (the `/models` wire)
- `codex-rs/codex-api/src/endpoint/models.rs` — `ModelsClient`: `GET models?client_version=…`
- `codex-rs/model-provider/src/models_endpoint.rs` — the 5 s model-list bound; `models_identity.rs` — the per-account cache identity
- `codex-rs/models-manager/src/manager.rs` — priority sort, picker visibility, cache TTL
- `codex-rs/protocol/src/config_types.rs` — `ReasoningSummary`, `Verbosity` enums
- `codex-rs/model-provider/src/bearer_auth_provider.rs` — `Authorization` + `ChatGPT-Account-ID` headers
- `codex-rs/model-provider/src/auth.rs` — auth-provider selection
+119
View File
@@ -0,0 +1,119 @@
# Benchmarks
Two tools measure the server's read and write paths. Use them before and after any change to a hot path, and put both numbers in the PR.
- **`scripts/bench/run.ts`** measures latency (p50, p95, max) and SQL statements per request against a seeded account of about 1.26 million measurement rows, through the production build. It is a local tool, not a CI gate: latency depends on the machine.
- **`tests/integration/query-budget-hot-routes.test.ts`** holds a statement budget per hot route on a small fixture. Statement counts are exact on a fixed fixture, so this one is a gate: a change that adds a query per row, or a second read of something already loaded, fails it.
## The large-account benchmark
### 1. A throwaway Postgres with pg_stat_statements
Never point the seed or the benchmark at a database that holds real data. They write under a synthetic user, and the seed's `--reset` deletes that user with everything it owns.
```sh
docker run -d --name hl-bench-db --shm-size=256m \
-e POSTGRES_USER=bench -e POSTGRES_PASSWORD=bench -e POSTGRES_DB=healthlog_bench \
-p 55432:5432 postgres:18.6-alpine \
postgres -c shared_preload_libraries=pg_stat_statements -c pg_stat_statements.track=top
```
The image and the two settings are the ones `docker-compose.yml` runs in production.
### 2. Environment, migrations, seed
```sh
export DATABASE_URL="postgresql://bench:bench@127.0.0.1:55432/healthlog_bench?schema=public"
export ENCRYPTION_KEYS='{"v1":"<64 hex>"}' ENCRYPTION_ACTIVE_KEY_ID=v1
export API_TOKEN_HMAC_KEY="<64 hex>"
pnpm db:migrate:deploy
pnpm dlx tsx scripts/bench/seed-large-account.ts --reset --anchor 2026-10-22
```
Any throwaway key values work, as long as the seed, the benchmark and the server see the same ones.
The seed writes ten years for one user: Apple Health dense rows (heart rate about every ten minutes, HRV, SpO2, respiratory rate, gait, audio), Withings blood pressure, pulse and scale, a weekly manual weight, three years of Oura resting heart rate and RMSSD, 90 days of Nightscout CGM, `stats:` and per-sample cumulative rows, nightly sleep stages, about 3,000 workouts, three years of mood and of three medications with their intake events. It then folds the rollup tier the way the boot backfill does and runs `ANALYZE`. It takes about 80 seconds on the Mac mini.
The seed is deterministic for a given `--anchor`: values and timestamps are functions of the row index and that day. Without `--anchor` it anchors on today, which is what you want when the benchmark should see recent data.
### 3. A production build
```sh
pnpm build
```
### 4. Run
```sh
pnpm dlx tsx scripts/bench/run.ts --out scripts/bench/results/<label>
```
The script boots the build once with every role, so the worker creates its queues as it does in production, drops whatever that boot scheduled, and then runs the measurement against a web-only process (`HEALTHLOG_PROCESS_TYPE=web`) under the production heap ceiling (`--max-old-space-size=792`, the value Node derives in the 1.5 GB production container). Without the worker, its polling does not land in every statement count. It prints the server's idle statement rate so the remaining background is visible.
For each route it sends three warm-up requests, then twenty measured ones in each of two modes, one request at a time:
- **cold**: before every request a one-row ingest marked as a manual sync evicts the user's caches, so the request rebuilds what it serves. This is a reader's first look after a sync.
- **warm**: back to back, what a cache hit costs.
The batch ingest posts 5,000 fresh heart-rate rows as ten requests of 500, the route's per-request limit, and removes them again at the end.
Statements are the change in `sum(calls)` of `pg_stat_statements` over the database, read before and after each request, once no statement is in flight and the counter holds still. Fire-and-forget writes a request starts are therefore counted with it.
Options: `--iterations`, `--warmup`, `--user`, `--heap-mb`, `--port`, and `--base-url` to measure a server you started yourself.
The output is `<label>.json` (every number, plus the commit, Node and Postgres versions and the row count) and `<label>.md` (the table). Commit the pair when a change is meant to move a number, so the PR shows before and after.
### Reading the numbers
Run on a quiet machine; a parallel build or test run moves latency by tens of percent and leaves the statement counts alone. Compare statement counts across machines, latency only across runs on the same machine. A local number is only valid under the production heap ceiling, which the script sets.
## Boot and idle-memory profile
`scripts/bench/boot-profile.ts` profiles a production start the way production runs it: `next start` on the build, every role (web and worker), the production heap ceiling (`--max-old-space-size=792`, the heap Node derives in the 1.5 GB container), against the seeded benchmark database.
```sh
pnpm dlx tsx scripts/bench/boot-profile.ts --idle-seconds 300 --out scripts/bench/results/<label>-boot
```
It records:
- the time to the first answer from `/api/health` and to the worker's `startup` line;
- every `event_loop_stall` the process logs, with the uptime, startup phase, boot step and work in flight it names;
- every `runtime.heap.sampled` line, and every full collection from `--trace-gc` (used heap before, live heap after);
- a CPU profile of the start: the main thread's busy stretches over 50 ms without an idle sample, each with its self time per source module, resolved through the chunk source maps (`profile-analyze.ts`);
- one heap snapshot after the idle wait, taken with `--heapsnapshot-signal` (after a full collection, so it is the live heap), summarised by node type, constructor, largest strings and the largest retained subtrees with a retaining path, and the number of parsed locale catalogs.
The worker runs for the whole idle wait, and its boot passes fold and consolidate the seeded account (old dense readings into their `stats:` rows), roughly halving its row count. Reseed before the next request benchmark, or its numbers describe a different account.
The raw `.cpuprofile` and `.heapsnapshot` go to a temporary directory and are deleted after the summary unless `--keep` is given. Only run it against the benchmark database: a snapshot holds whatever the process decrypted. `--no-snapshot` and `--no-cpu-prof` skip either part. The profiler itself costs memory, so read RSS from this run as an upper bound.
### What the profile found (v1.44 runtime, October 2026)
On the seeded account, idle, with the worker running and no requests (`results/v1.45.0-before-boot.md`):
- **The boot stall is module evaluation.** The longest busy stretch of the main thread was 753 ms, 0.7 s into the start: the instrumentation hook importing the worker, whose five registrars pulled practically all of `src/lib` into one synchronous evaluation (`register-maintenance.ts` alone 280 ms inclusive), followed by the first `/api/health` request compiling its route. The event-loop monitor started after the key and time-zone checks, and a 250 ms tick measures a block by how late the next tick fires, so it reported less than the block or nothing. The beta's 718 ms and 671 ms stalls are this block on a slower machine.
- **The live heap is about 165 MB, not 400.** Full collections brought the heap back to 162 to 172 MB every 15 to 20 s while idle; the used heap between them reached 190 MB. What a heap line shows above the live heap is garbage awaiting the next full collection, plus, on a server with users, the server caches (13 caches of up to 2,000 entries with stale windows of up to an hour). The beta's 400 MB plateau is therefore not a baseline the process cannot shed; it is what the heap grows to under a large limit before a collection, plus caches.
- **What the live heap holds.** Of 172 MB: the query compiler about 50 MB (the engine object 34 MB, its WebAssembly base64 source twice at 4.4 MB, its chunk source twice at 5.6 MB); the locale catalogs about 33 MB (32 parsed catalog objects, eight locales in each of four server bundles, 14 MB, plus the eight-locale chunk source twice at 9.7 MB); module source strings over 50 KB about 44 MB in total (the catalog chunks included); compiled code 19 MB.
### What changed (v1.45.0)
- The event-loop monitor starts first in `register()`, and a stall line names the boot step, startup phase and work in flight (`src/lib/boot/boot-steps.ts`, one `boot.steps` line per boot with each step's duration).
- The worker's registrars are imported one at a time after `boss.start()`, with a turn of the event loop between them.
- The server reads a locale catalog from `messages/<locale>.json` on first use and keeps one parsed copy per process (`src/lib/i18n/server-catalogs.ts`); the seven matchers that index every locale build their index on first use.
Result on the same machine (`results/v1.45.0-boot.md`): the worker graph loads in stretches of at most 246 ms instead of one of 753 ms (the longest application stretch left is the query compiler's start, 336 ms, unchanged); the live heap after five idle minutes went from 162 to 172 MB to 142 to 153 MB; two parsed catalogs (1.6 MB) instead of 32 (14.1 MB), and no catalog chunk sources.
## Statement budgets in the integration suite
`tests/integration/query-budget-hot-routes.test.ts` seeds a small account (four months of the main types with overlapping sources, sleep, glucose, a medication, workouts and mood), and counts the statements of one request per hot route on a cache miss: `/api/auth/me`, the dashboard summary and snapshot, the pulse series over 30, 365 and 3650 days, the day view, insights health status, correlations, and a 500-row batch ingest.
The counter (`tests/integration/query-counter.ts`) wraps `pg`'s `Client.prototype.query` on the copy of `pg` the Prisma adapter loads, and settles background tasks before it closes the window.
Each budget is the measured count. When a change lowers one, lower the budget in the same change. When a change has to raise one, raise it with the reason in the PR. A failure prints every statement the request sent, grouped by text, so an added loop shows up as one statement with a count next to it.
Run it like any integration file:
```sh
pnpm test:integration tests/integration/query-budget-hot-routes.test.ts
```
+297
View File
@@ -0,0 +1,297 @@
# Testing
How the suites are organised, which one a new test belongs in, and the tools
that keep them fast and honest. Commands are in `CONTRIBUTING.md`; this page
is about choosing and writing tests.
## Which suite
| What you are testing | Where | Runs with |
| --------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------- | ----------------------- |
| Pure logic: statistics, time zones, cadence, parsers, unit conversion, formatting | `src/**/__tests__/*.test.ts` (unit) | `pnpm test` |
| Laws of that logic over random inputs | `*.property.test.ts` next to the unit tests | `pnpm test` |
| What a component renders for a given state | `src/**/__tests__/*.test.tsx` (unit) | `pnpm test` |
| How a component behaves when someone types, clicks or presses keys, and what it sends | `src/**/__tests__/*.browser.test.tsx` | `pnpm test` |
| A structural rule about the tree (a guard) | `src/__tests__/` with a stated floor | `pnpm test` |
| A route: auth, scope, validation, what it stores and what it answers | `tests/integration/routes/` with the kit | `pnpm test:integration` |
| Anything whose truth lives in Postgres: constraints, `where` semantics, transactions, locks, cascades, time zones in SQL, backups, jobs | `tests/integration/` | `pnpm test:integration` |
| A journey through the real UI | `e2e/` | `pnpm e2e` |
The rule behind the table: put a behavioural test at the level where the
risky collaborator is real. For a route that collaborator is Postgres and the
API kit (`apiHandler`, `requireAuth`, the envelope), so a route test runs
against both. A unit test that replaces Prisma with a mock and then asserts on
the calls it received checks the query text, not the behaviour; it passes when
the query is wrong and fails when it is rewritten correctly. Do not write new
ones: `src/__tests__/test-realism-ratchet.test.ts` counts route tests that mock
`@/lib/db` wholesale or stub `apiHandler`, and `as never` casts in tests, and
those numbers may only fall. Mock at the process boundary you do not own instead: provider HTTP
(`safeFetch`), APNs, SMTP, the model.
## Critical journeys
A few e2e journeys carry `@critical` in their test titles: signing in with a
password and a passkey and signing out, a reading reaching the dashboard, a
dose moving compliance, a mood entry, a lab CSV import, a questionnaire in
German and Brazilian Portuguese, a setting that persists, a backup coming
back, sharing a record, and the offline page. `pnpm e2e:critical` runs them.
They run against the real server and a seeded Postgres. A spec file that
carries the tag may not intercept anything under `/api`, directly or through
a helper it imports; `src/__tests__/e2e-critical-no-api-stub-guard.test.ts`
refuses it and keeps a floor on the number of tagged tests. Stubbing an
unreachable third-party host that is not the journey's subject is allowed,
with the reason next to the call.
A journey that writes gets an account nobody else writes. The accounts in
`e2e/setup/critical-fixture.ts` are seeded by global setup, one per journey
and per Chromium project, so a journey runs at both widths without the two
projects meeting. Reset what a previous run left with `resetCriticalAccount`
in a `beforeEach`, and read results through the app's own routes rather than
through the database.
## The route test kit
`tests/kit/` holds two modules for tests under `tests/integration/`.
`factories.ts` writes typed fixtures through the application's Prisma client
and returns the stored rows: `makeUser`, `makeAdmin`, `makeSession`,
`makeToken`, `makeMeasurement`, `makeMedication`, `makeWorkout`, `makeGrant`.
Overrides are the Prisma create input of that model, so the compiler checks
every fixture field; there is no reason for `as never` in a kit test.
Sessions and tokens are minted by the server's own `createSession` and
`issueApiToken`, so a fixture cannot drift into a shape authentication no
longer accepts.
`call-route.ts` exports `callRoute(handler, options)`, which builds a real
`NextRequest`, fills the `next/headers` stand-in from `auth`, calls the
exported handler of a `route.ts` (and with it the real `apiHandler`), and
returns `{ status, data, error, meta, body, headers }`.
```ts
import { GET } from "@/app/api/tokens/route";
import { DELETE } from "@/app/api/tokens/[id]/route";
import { callRoute } from "../../kit/call-route";
import { makeSession, makeToken, makeUser } from "../../kit/factories";
import { getPrismaClient, truncateAllTables } from "../setup";
beforeEach(() => truncateAllTables(getPrismaClient()));
it("revokes the caller's token and the token stops authenticating", async () => {
const owner = await makeUser();
const phone = await makeToken(owner);
const res = await callRoute<{ revoked: boolean }>(DELETE, {
method: "DELETE",
route: "/api/tokens/{id}",
params: { id: phone.tokenId },
auth: await makeSession(owner),
});
expect(res.status).toBe(200);
const row = await getPrismaClient().apiToken.findUniqueOrThrow({
where: { id: phone.tokenId },
});
expect(row.revoked).toBe(true);
const after = await callRoute(GET, {
method: "GET",
route: "/api/tokens",
auth: phone,
});
expect(after.status).toBe(401);
});
```
`auth` is a `SessionFixture`, a `BearerFixture`, or `anonymous`. `route` is
the path as the OpenAPI registry names it, with `{…}` segments filled from
`params`.
Every call is also a contract check. The status must be one the registry
(`src/lib/openapi/routes/`) declares for that path and method, a JSON body
must parse with the registered schema, and a key the schema does not declare
fails the call: a client generated from the contract cannot see that field, so
nobody promised to keep it. There is no generic answer the kit accepts on an
operation's behalf: the scope gate's 403 `auth.scope.insufficient` is declared
on every operation that passes the gate (`withScopeRefusal` in
`src/lib/openapi/routes/shared.ts`), so it is checked like any other status. A
test that calls an unregistered route on purpose passes
`contract: { skip: "<reason>" }`.
`tests/integration/routes/kit-contract.test.ts` proves the check can fail.
Assert what a person or a client would notice: the stored row, the response,
the next request's answer. Not the calls a mock received.
The kit runs the handler, not the edge. Security headers, the public-path
allowlist and demo mode live in `src/proxy.ts` and are tested there.
### The settings round trip
`tests/integration/settings-round-trip.test.ts` holds one table with an entry
for every `User` column the backup classifies as `SETTING`
(`USER_COLUMN_BACKUP_CLASS` in `src/lib/export/backup-plan.ts`). For each one
it sets a non-default value through the API, reads it back through the API and
through `/api/auth/me` where that publishes it, exports a backup, restores it
into a fresh account and reads it back there. A new setting column without a
table entry fails the file. A column with no write route says why in its
entry.
## Browser tests
Interaction belongs in the `browser` project: `*.browser.test.tsx` files run
in headless Chromium through Vitest's Playwright provider, as part of
`pnpm test`. Run them alone with `pnpm test --project browser`, or one file
with `pnpm exec vitest run --project browser <path>`. The Node projects never
collect these files and this project collects nothing else. CI installs the
browser it needs (`playwright install --only-shell chromium`); locally, run
that once.
Reach for it when the claim is about behaviour a markup test cannot see:
focus, keys, an open menu, a pending state, the body a form posts, the URL a
filter writes. A `renderToStaticMarkup` grep stays the tool for what a given
state renders, and bottom-sheet gestures stay in `e2e/`.
The helper is `tests/browser/`:
- `renderWithApp(ui, { locale, user })` mounts the app's own providers: a real
TanStack `QueryClient` (no retries) and `I18nProvider` with the real
catalog. The signed-in account is seeded where `useAuth` reads it, with
`authUser()` defaults under `user`. The app stylesheet and the default
theme class are loaded, so layout, visibility and colour tokens resolve.
- `stubFetch()` replaces `fetch` and nothing above it. Answer a route with
`.on(method, path, { status, data, error, meta })`, hold one with
`.deferred(...)` to assert a pending state, and read the parsed bodies from
`.calls(method, path)`. An unmatched request gets a 404 envelope and is
recorded. Do not mock `@tanstack/react-query` or `@/lib/api/api-fetch`.
- `next/navigation` is aliased to a shim over the page's real `location` and
`history`; `url.ts` sets the starting query, records pushes and replaces,
and simulates Back or an edited address.
- `pinDate(iso)` fakes `Date` only, so timers and animation frames still run.
Every test gets the real `fetch`, clock, address and an empty `localStorage`
back afterwards. Locate by role and accessible name, the way a person finds a
control; a `Label` adds a colon to its name, so match a labelled combobox with
`/^Type/` rather than `"Type"`.
## The integration database
Global setup boots one Postgres container and migrates one template database.
Each Vitest worker clones it on its first file (`CREATE DATABASE … TEMPLATE`,
see `tests/integration/worker-database.ts`), so files run in parallel without
seeing each other's rows, advisory locks or queue schema. The worker count
follows the host (half the cores, at most eight) unless
`HEALTHLOG_INTEGRATION_WORKERS` pins it; CI uses three. To reproduce a CI runner's
contention on a larger machine, cap the database container with
`HEALTHLOG_INTEGRATION_PG_CPUS=2`.
`truncateAllTables` empties every table the migrations created and puts back
the rows they seed (the mood-tag and symptom catalogues), so a test starts
from what a fresh install holds. The list is the catalogue, read from the
template, not something to maintain. `START_EMPTY_TABLES` names the one
seeded table a test starts without, and why. Schemas created at runtime
(pg-boss) are dropped when the next file starts in the worker.
`next/headers` and the boot compatibility probe are mocked for every file in
`environment-setup.ts`. A file needs its own `vi.mock("next/headers")` only
when it wants different behaviour.
## Speed and sharding
CI splits both suites by measured duration, not by file count: the sequencer
in `tests/durations/sequencer.ts` reads `tests/durations/unit.json` and
`tests/durations/integration.json` and deals files out longest first to the
shard with the least work. A file without a measurement counts as the median.
Regenerate a timing file from Vitest JSON reports when the shards drift apart:
```sh
pnpm exec vitest run --reporter=default --reporter=json --outputFile.json=unit.json
node scripts/test-durations.mjs unit unit.json
```
The nightly workflow (`.github/workflows/test-nightly.yml`) uploads a file
regenerated from CI timings, which is the better source.
The unit suite is three Node projects and the `browser` project above. The
Node ones are decided per file from its source
(`tests/unit-projects.ts`): `guards` and `unit` share a module graph within a
worker, `isolated` gives each file a fresh one. A file lands in `isolated` when
it mocks, spies, stubs env or globals, fakes timers or resets modules. If a
file needs isolation for a reason the classifier cannot see, put a
`// vitest-isolate` comment in it.
## Property tests
`fast-check` properties live in `*.property.test.ts` files and take their
parameters from `tests/property/fast-check.ts`. Pull requests run every
property with a fixed seed, so a run is reproducible on any machine. The
nightly run uses a fresh `FC_SEED` and multiplies every property's own case
count by `FC_RUNS_FACTOR`; a failure prints the seed and the shrunk
counterexample, and `FC_SEED=<seed> pnpm test <file>` replays it. A property
whose cases are expensive states a small count and passes
`propertyTimeout(baseMs)` as its test timeout, which grows by the same factor.
Write a property when the code has a law: an inverse (encrypt and decrypt, a
unit and its inverse, a wall clock and its instant), an invariant (a rate
between 0 and 100, a day that contains its own instants), or an oracle small
enough to be obviously right (a day-by-day walk of a weekly schedule). Do not
reimplement the function under test in the property.
## Mutation testing
Stryker runs on the core libraries (`stryker.config.mjs`) nightly and on
demand (`.github/workflows/mutation.yml`), not on pull requests. Each module's
score is checked against a floor in `stryker.floors.json` by
`node scripts/mutation-floors.mjs`. A floor sits just below the last measured
score; raise it when a change raises the score, never lower it to make a run
pass. The Vitest runner is patched for Vitest 5's test names
(`patches/@stryker-mutator__vitest-runner@10.0.0.patch`); without it every
test inside a `describe` block is filtered out of the mutant runs and almost
every mutant survives. Locally:
```sh
pnpm exec stryker run
node scripts/mutation-floors.mjs
```
## Flaky tests
Vitest runs without retries, and stays that way: a retry that turns red into
green hides the flake it was meant to report. e2e keeps two retries with
`failOnFlakyTests`, so a test that needed one still fails the run.
A flaky unit or integration test may be taken out of the run only through
`tests/quarantine/quarantine.json`:
```json
{
"file": "tests/integration/example.test.ts",
"test": "outer describe > the test name",
"issue": "#1234",
"reason": "fails about one run in twenty when the queue worker is slow",
"added": "2026-10-22",
"expires": "2026-11-05"
}
```
The test is reported as skipped with its issue while the entry is live. An
entry expires at most 14 days after it was added; after its expiry date the
test runs again, and `tests/quarantine/quarantine-guard.test.ts` fails until
the entry is removed. The guard also fails on an entry that names a file or a
test that does not exist. Fix the flake, or delete the test if it no longer
earns its place; do not extend the date.
Order dependence is looked for on purpose: the nightly workflow runs both
suites with `--sequence.shuffle` and prints the seed. Replay a failure with
`--sequence.shuffle --sequence.seed=<seed>`.
A case resets or states what it reads: `vi.resetAllMocks()` (or
`mockReset()`) where a case swaps in its own implementation, since
`clearAllMocks` keeps it and a `mockResolvedValueOnce` the code never consumed
answers the next case; a module cache dropped through its own invalidator; a
row the case needs written by the case or its own `beforeAll`. A file that is
one walk by design (a parity sequence, a migration's before and after, a
dry run followed by the import and the same file again) says so in a comment
and passes `{ shuffle: false }` to its `describe`, which keeps that block in
order while the rest of the suite still shuffles. Wrap the whole file in one
such block when its top-level blocks build on each other.
+187
View File
@@ -0,0 +1,187 @@
# UI standards
The binding reference for building or touching any surface of the web app. The rules are derived from the primitives' own contracts (`src/components/ui/card.tsx`, `tile-header.tsx`, `page-header.tsx`, `settings/_card-header.tsx`, `layout/auth-shell.tsx`) and from a review of every surface against them. When a rule here conflicts with an existing file, the file is drift: fix it when you touch it, don't copy it. Where an older code comment or [`docs/ui-guidelines.md`](../ui-guidelines.md) says otherwise, this document wins. A deviation needs a written reason in the pull request.
---
## 1. Card anatomy
**The Card is gap-based, not padding-based** (`src/components/ui/card.tsx:24`): `flex flex-col gap-4 rounded-xl border py-4 md:gap-6 md:py-6`; header/content/footer carry `px-4 md:px-6`. The header→body distance comes from the **parent flex gap**, never from slot padding.
- FORBIDDEN: `pb-*` on `CardHeader`, `pt-*` on `CardContent`. Under the gap model these add on top of the gap (or are no-ops) and invert their intent. Want a tighter card? Override the gap on the Card itself: `<Card className="gap-2">`.
- Two densities, nothing else:
- `default`: Card as shipped: `py-4 md:py-6`, `gap-4 md:gap-6`.
- `compact`: `<Card density="compact">` (stat strips, assessment blocks, small tiles); resolves to `gap-2 py-3 md:gap-2 md:py-4`. Never spell it as a class list: the hand-written `gap-2 py-3 md:py-4` kept the base `md:gap-6` and opened 24 px on desktop (v1.43.8; `card-density.test.tsx` refuses the class list on a Card).
- No `md:py-5` half-steps.
- Radius: cards are `rounded-xl` (tracks `--radius`). `rounded-md` = controls, `rounded-lg` = inner tiles, `rounded-full` = pills. `rounded-2xl` only as a deliberately marked identity surface (Coach hero/input).
- Hand-rolled `bg-card … border … p-N` shells are legacy. New surfaces compose `<Card>` (or `SettingsCard` in Settings). If a hand-rolled shell must exist, its padding is exactly `p-4 md:p-6` or `p-3` (dense tile), never `p-5`, never a static `p-6`, never a `sm:` step (`SettingsCard`'s docblock explains why the breakpoint is `md:`).
- **Insights overview sections use `InsightSectionCard`** (`src/components/insights/insight-section-card.tsx`). It composes `<Card>`, so the frame is the card contract, and pins the section's inner rhythm at one step (`gap-3`); the six hand-rolled copies it replaced had drifted to three different rhythms in the same column. A section whose body is an edge-to-edge divided list (rows owning their own inset) passes `flush`; everything else takes the padded body. Do not hand-roll a section shell beside it.
- **The dashboard/insights hero band is a sanctioned identity surface.** Its translucent `bg-card/65 … backdrop-blur-sm` tiles are deliberate, not drift: the band reads as one glass surface behind the page's opening statement. It stays out of the shell sweep. Everything below the band is a plain card.
### Header patterns; exactly three, pick by surface
| Surface | Primitive | Contract |
| ---------------------------------- | ------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Page title | `PageHeader` (`ui/page-header.tsx`) | H1 `text-2xl font-bold tracking-tight`, description `text-muted-foreground text-sm`, `space-y-1.5`, **no icon beside the H1**, optional backLink + actions slot |
| Settings card | `SettingsCard` + `SettingsCardHeader` (`settings/_card-header.tsx`) | icon `text-muted-foreground h-5 w-5`, title `h2 text-lg font-semibold`, status slot top-right, description muted below |
| Insights / dashboard / module tile | `Card` + `TileHeader` (`insights/tile-header.tsx`) | icon **`text-foreground` `h-5 w-5 shrink-0`**, title `CardTitle text-base`, `flex items-center gap-2`, optional `right` slot |
**Icon colour rule (both header contracts):** header icons are `text-foreground` (tiles) or `text-muted-foreground` (settings). Never `text-primary`, `text-info`, or any accent; primary/purple stays reserved for actions and highlight states. Pass the Lucide component (`icon={Target}`), never a pre-sized node; the primitive owns size and colour.
The gaps this section used to list are closed: `TileHeader` carries a `size="sm"` variant for compact tiles and an optional `icon` for icon-less headers, and `InsightSectionCard` is the overview container mirroring `SettingsCard`. Use them; do not invent a fourth header language.
## 2. Spacing scale
> **Scroll height is shell-owned.** Only `AuthShell` owns the scroll container's height and bottom padding (`pb-20` wrapper, `pb-24` in the desktop shell, + the phone shell's bottom-nav clearance on `<main>`). The shell itself takes the window's edges (`absolute inset-0`), never `h-dvh`, and `<main>` is the one vertical scroller: the document neither scrolls nor bounces under it. Pages/sections never add viewport-height reserves (`min-h-dvh`, `min-h-[calc(100dvh-…)]`), `h-full` chains, or bottom gutters inside the scrolling `<main>`; nested reserves stack on the shell budget and scroll past the last card. One sanctioned exception: the settings-shell grid floor (budget-derived, documented; retune it when shell paddings change). Lists that need their own scrolling use `max-h-* overflow-y-auto overscroll-contain`. Guard: `e2e/settings-overscroll.spec.ts`.
- **Allowed steps:** `1 / 1.5 / 2 / 2.5 / 3 / 3.5 / 4 / 6 / 8 / 10`. `2.5` and `3.5` are allowed: the medication card's rhythm is built on them, and the in-between steps earn their keep in dense card interiors. `5` (20 px), as in `py-5` or `p-5`, is not allowed; replace it when you touch a file that still has it. `7` (28 px) is not allowed either: it was the old Settings and Admin `pl-7` body gutter, which is gone (§13).
- **Page frame:** exactly one authenticated container, owned by `AuthShell` (`MAIN_CONTENT_WRAPPER_CLASS` in `layout/shell-metrics.ts`): `mx-auto max-w-screen-xl px-4 pt-6 pb-20 md:px-6 shell-desktop:pb-24`. Never re-implement it in a page; full-bleed pages opt out via a shell flag, not negative margins. Prose pages (about/privacy/legal): `mx-auto max-w-3xl px-4 py-8 md:px-6 md:py-12`.
- **Page rhythm:** root of every page is `space-y-6`. Grouped card sections: `space-y-10` between groups, `space-y-4` inside a group (notifications/integrations pattern). Insights sub-pages join `space-y-6`.
- **Left reading edge:** page-level text (H1, explainer, section headings) sits at the container edge. Only surfaces that paint a visible border/background may inset their content (`px-4 md:px-6`). A block without a border must not carry horizontal padding.
- **Grids:** grids **of** cards/tiles = `gap-4`; value read-outs **inside** one card = `gap-x-3 gap-y-2`; form field grids = `gap-4`.
- **Anchors:** `scroll-mt-28`, single token (31-to-1 in the tree already).
## 3. Text colour rules
The base tokens are AA-tuned in both themes (`--muted-foreground`: 5.0–6.4:1, documented in `globals.css`). The system is two tiers:
- **`text-foreground` = content.** Everything the user came to read: values, chart labels the reading depends on, AI narrative bodies, consent/safety copy, and **all user-entered data** (notes, journal text, free-text fields). User data is never muted.
- **`text-muted-foreground` = meta.** Timestamps, units, provenance, counts, placeholders, one-sentence descriptions under a heading. Heading-in-foreground + short-description-in-muted is the app-wide convention (CardDescription, PageHeader, SettingsCardHeader); keep it; do not "fix" it to foreground.
- **Description slots hold exactly one meta sentence, guideline ≤ 120 characters.** One sentence, one full stop, no paragraph stack. 120 characters is the guideline, not a hard cut: it is the p75 of the descriptions that already read well, and a description that needs 130 characters to stay true is fine while a two-sentence one never is. Multi-sentence explainer prose goes in the body as `<p className="text-sm">` (foreground), not into a muted description slot; if the body would not carry it either, the sentence is not needed on the surface at all and belongs in the docs. The description slot renders a single text run; it deliberately owns no vertical rhythm of its own, so a call site cannot stack paragraphs into it.
- **One statement, one string.** A section's page subtitle and its card description must not say the same thing twice on one screen. When a section owns a single card, the subtitle carries the statement and the card header drops its description. Same rule for a field label and its field description: the label names the field, the description adds what the label cannot.
- **FORBIDDEN: opacity modifiers on muted text.** `text-muted-foreground/70` ≈ 2.9–3.9:1 and `/80` ≈ 3.4–4.2:1, below AA, and they silently undo the token tuning. Full `text-muted-foreground` IS the de-emphasis tier; a further tier is expressed via size/weight, never via alpha. `text-foreground/80` is safe (≥6.7:1) and allowed. `/60` is icon-only (3:1 graphics floor).
- **Disabled look:** `opacity-50` on the container + `aria-disabled`, not colour dimming on the text node.
- **Size floor:** muted text is never smaller than `text-xs`. Below that, the one legal step is the `text-2xs` token (0.6875rem, minted in `globals.css`) and it is for chips, badges, and tabular-nums micro-labels with verified contrast, never for muted body or meta text. Arbitrary sizes (`text-[0.65rem]`, `text-[11px]`) are drift; chart-axis `text-[10px]`/`text-[11px]` stays tolerated as documented.
- **Status colours** carry meaning, not decoration: `text-success` / `text-warning` / `text-info` / `text-destructive` (+ `bg-*/10` washes). All four are AA-retuned per theme.
## 4. Colour token rules
- **Semantic tokens only** in components: `--foreground/--muted-foreground/--primary/--success/--warning/--info/--destructive/--chart-1..5/--border/--card`. The light theme remaps them wholesale; anything outside the token layer is invisible to a retune.
- **No raw `dracula-*` utilities in new code.** Only `--dracula-yellow` has a light override; `text-dracula-green` on a light card is 1.4:1. Status meaning → semantic token; brand identity (Coach gradient) → a named `--brand-*` token with per-theme values.
- **No hex in JS props.** Chart colours are passed as `var(--token)` strings, never `color="#22c55e"`. Data series use the `--chart-1..5` tokens (`color="var(--chart-2)"`); status tokens (`--success`, `--warning`, `--destructive`, `--info`) only where the colour carries meaning, such as a target band, time in range or a mood scale (v1.42). Recharts `stroke`/`fill` likewise. This is the known lint blind spot; review for it explicitly until the rule covers it.
- **No arbitrary-value colours** (`bg-[#…]`, `text-[oklch(…)]`).
- Exemptions (documented at the definition site): `global-error.tsx` (renders without the stylesheet), the `themeColor` meta, the QR quiet-zone `bg-white`.
## 5. Typography scale
| Tier | Classes | Where |
| ------------------------ | ------------------------------------------------------------------- | --------------------------------------------------------------- |
| App H1 | `text-2xl font-bold tracking-tight` | every module page, via `PageHeader`: weight is **bold**, always |
| Settings card title | `text-lg font-semibold` | via `SettingsCardHeader` |
| Tile title | `text-base` on `CardTitle` (`font-semibold` from the primitive) | via `TileHeader` |
| Chip / badge micro-label | `text-2xs` (11 px) | `TagChip`, `Badge`, tabular-nums counters: never muted prose |
| Section label | `text-xs font-medium uppercase tracking-wide text-muted-foreground` | list group headings |
| Body | `text-sm` (foreground) | default content |
| Meta | `text-xs text-muted-foreground` | timestamps, units, hints: the floor |
| Prose-page H1 | `text-3xl md:text-4xl font-bold` | about/privacy/landing tier only |
No `text-[9px]`. No off-scale sizes (`text-[28px]`); round to the neighbouring step. Icon sizes come from the five-step vocabulary `size-3 / 3.5 / 4 / 5 / 6`; prefer `size-N` over `h-N w-N` (identical CSS, one spelling).
## 6. Component mapping; when to use which primitive
| Situation | Use | Never |
| --------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Page title + description + actions | `PageHeader`: `headingRef` + `focusable` for a title that takes focus after navigation, `titleAccessory` for a glyph or badge beside the H1 (outside its name) | hand-rolled `text-2xl font-bold` H1 blocks (`page-header-h1-guard.test.ts` lists where another H1 is the point: heroes, sign-in, error and prose pages, first-run steps) |
| A second copy of the page title in the same shell (mobile above the nav strip, desktop inside the grid) | `PageHeader headingAs="div"`: one real `<h1>`, one `role="heading" aria-level={1}` | a hand-rolled `role="heading"` div beside a hand-rolled `<h1>` |
| Settings card | `SettingsCard` + `SettingsCardHeader` + `FieldGroup` | raw `Card` in Settings sections |
| Heading above a group of cards (Insights overview, Settings groups) | `SectionHeading` (`ui/section-heading.tsx`) | a `SettingsCardHeader` with no card under it, or a hand-rolled `h2` |
| Action row in a Settings / Admin card | `SettingsCardActions` | a hand-rolled `flex justify-end gap-2` div |
| Admin mini stat tile | `StatTile` (`admin/_stat-tile.tsx`) | a hand-rolled `bg-muted/NN rounded-lg p-3` box |
| Insights/dashboard tile | `Card` + `TileHeader` | hand-rolled icon+title rows, `bg-card rounded-xl border p-4` divs |
| Insights overview section | `InsightSectionCard` | a hand-rolled section shell beside it |
| Neutral label chip on a card header | `TagChip` | a hand-copied `bg-muted … rounded-full` literal, or a `Badge` (that means status) |
| Read-query failed (`isError` on a primary / list surface) | `QueryErrorCard` with `error={query.error}` and `onRetry={refetch}` | bare `text-destructive` paragraphs without retry; a card without `error` |
| Read-query failed inside a dense metric strip, tile or supplementary block, where the tall card would dominate the geometry | `QueryErrorRow` with `error={query.error}` and `onRetry={refetch}` | a hand-rolled row; a third read-failure primitive (`ReadFailureRow` was one and is gone) |
| Read-query failed inside a card that already has its frame and header (a tile body, a settings card section) | `QueryErrorRow inset`: keeps the card and its title, no second border | `QueryErrorCard` inside a card (card in card); replacing the card and its header with the error |
| Read-query failed on a chart body | `ChartErrorState` with `error={query.error}`: same decision as the other two | either of the above |
| A list the server capped (`meta.truncated`) | `ListCapNotice` under the list, read through `apiGetList` / `apiPostList` | a hand-written "showing the latest N" line; reading `data.truncated` |
| Mutation error inside a form/sheet | inline `role="alert"` text | `QueryErrorCard` (wrong shape) |
| Honest no-data state | `EmptyState` | reusing error copy: a failed query must never fall through to "no data yet" |
| Modal reachable on mobile | `ResponsiveSheet` (bottom sheet on phones) | raw `Dialog` (admin/desktop-only surfaces excepted) |
| A sheet from the bottom edge (phones) | `<SheetContent side="bottom">` or `ResponsiveSheet`, both of which render `BottomSheetContent` (`ui/bottom-sheet.tsx`): grip, swipe to close, X on the title row. `preview="58dvh"` for a sheet that opens part-way and pulls up to 90 dvh (its footer `data-sheet-footer`, its scroll body `data-sheet-body`); `draggable={false}` only for a sheet that fills the window | a hand-rolled grab handle or drag; a `fixed bottom-0` panel standing in for a sheet |
| Date / time entry | `DateField` / `TimeField` / `DateTimeField` | native `type="date | time | datetime-local"` inputs (browser-locale trap, v1.25.4) |
| Small text action inside a line (Open schedule, Select all, View all, Retry, Learn more) | `InlineAction` (`ui/inline-action.tsx`): tone `primary` / `muted` / `foreground`, size `xs` / `sm`, `asChild` for a `Link`; owns the focus ring and `pointer-coarse:min-h-11` | a hand-rolled underlined `<button>` / `<Link>` (`inline-action-guard.test.ts`) |
| Mutually exclusive choice on one track (range, zoom, cards vs table, recent vs all) | `Segmented` (`ui/segmented.tsx`): no track, the chosen segment filled in primary (the chart tabs' look; a muted track with a raised segment was tried in 1.43.8 and dropped in 1.43.10 as too heavy); `mode="radio"` (default, roving focus) or `"toggle"` (view switches, form sheets), `iconOnly` for icon segments, `size="sm"` for range rows in a chart header; 44 px per segment on a coarse pointer | a row of ghost/secondary Buttons, a fifth segmented variant (`segmented-single-implementation.test.ts` holds the definition to one module) |
| A grade beside a reading (confidence, score gain, band, lab range flag) | `ScorePill` (`ui/score-pill.tsx`): tone `success` / `info` / `warning` / `destructive` / `neutral`, `numeric` for figures; `CONFIDENCE_TONE` maps both confidence scales | a hand-rolled `rounded-full px-2 py-0.5 text-xs bg-*/15` chip (`score-pill-guard.test.ts`); `TagChip` (neutral label) and `Badge` (connection/account state) stay what they are |
| "This vital on these days vs those days" board | `MetricCrosstab` (`insights/metric-crosstab.tsx`): caller formats the numbers, the board owns row, neutral signed delta, confidence chip, dismissal | a fourth copy of the crosstab list |
| A trend / stat tile in a grid (KPI strip, derived tiles, their skeletons and building states) | `TrendTile` (`ui/trend-tile.tsx`) | the hand-spelled `bg-card border-border … p-4 md:p-6` frame |
| A whole card that points to another page | `CrossLinkCard` (`insights/cross-link-card.tsx`) | a `Link` styled as a card |
| Picking documents from the vault | `DocumentPickerSheet` (`documents/document-picker-sheet.tsx`); the caller decides what a toggle means | a copy of the search + list sheet |
| A field / chart / list with nothing to show | `EmptyState variant="plain" size="compact"` (with `action` for the next step), `ChartEmptyState` on a chart body | a hand-written muted paragraph (`empty-state-ratchet.test.ts` freezes each remaining site as `path: key`, and a new one fails; the frozen ones are field-level "not recorded" lines and editor hints) |
| A number shown to a person | `useFormatters()` (`fmt.number` / `integer` / `percent`), `formatBytes(bytes, locale)` from `lib/format/bytes` | `toFixed()`, a locale-less `toLocaleString()`, `String(a / b)` (`healthlog/no-unlocalised-decimal`) |
| Numeric range entry (0–10 self-rating, any bounded scale) | `Slider` / `SliderField` (`ui/slider.tsx`): `SliderField` when the scale has anchors, which is nearly always | a hand-rolled `input type="range"`, or an 11-button radiogroup (eleven 32 px targets overflow a 390 px viewport) |
| Outbound fetch | `safeFetch` | raw `fetch(` (lint-enforced) |
| TanStack query keys | `src/lib/query-keys/` factory | bare arrays (lint-enforced) |
**What a failed read offers is decided by the primitive, never the call site.** `QueryErrorCard`, `QueryErrorRow` and `ChartErrorState` take the query's `error` (required) and branch on `readFailureKind` (`src/lib/api/read-failure.ts`). It reads `meta.errorCode` first and the status second, because every error envelope carries a code (`http.*` when the route names none): a step-up or re-proof code on a 401 is a failure with retry, not an ended session; an unknown code falls back to the status.
- **Session ended** (a session code, or a 401 without a more specific one) → one sentence and a Sign in button (back to the current page). Never a silent blank.
- **403, refused** → the surface's `refused` state if it has one, otherwise nothing. A refusal is an answer, not a failure; no retry.
- **429, throttled** → one sentence that the read budget is spent, no retry (an immediate retry would only be refused again).
- **anything else** → the surface's message with Retry.
The `queryFn` hands the `ApiError` through untouched: no `try { … } catch { throw new Error(t(…)) }` around the fetch, the surface's sentence goes in through `message`. A call site does not inspect `error.status` around a read-failure primitive, and does not pass `error={null}`. `query-error-error-prop-guard.test.ts` enforces the prop and the untouched error at every site, `read-failure-markup-guard.test.ts` refuses hand-rolled failure markup next to a read hook. Error codes become sentences through `src/lib/i18n/translate-code.ts`, never through a `t(key) === key` probe at the call site (`translate-probe-guard.test.ts`).
## 7. Enforcement
Live, all at `error`: `healthlog/queryKey-factory`, `healthlog/safe-fetch-required`, `healthlog/api-fetch-required`, `healthlog/no-raw-palette-color` (palette + colour-shaped JS props + arbitrary values), `healthlog/no-dracula-utility`, `healthlog/spacing-scale` (`pt-`/`pb-` on `CardHeader`/`CardContent`, and the banned `5` step on a `bg-card` + `border` shell), `healthlog/no-raw-card-shell` (since v1.43.8: `bg-card` + `border` + `rounded-xl` on one literal under `src/components` / `src/app` outside `ui/`; the files where a shell is the point (shell primitives, the hero, popovers, the chart frame shared by chart/skeleton/error state, table frames, sign-in and first-run screens) are the rule's `allow` list in `eslint.config.mjs`, each with its reason, no inline escape), `healthlog/no-unlocalised-decimal` (since v1.43.8; reasoned `eslint-disable-next-line` only, for SVG coordinates and number-input values).
Structural tests (since v1.43.8): `inline-action-guard`, `score-pill-guard`, `empty-state-ratchet`, `page-header-h1-guard`, `card-density`, `segmented` (one definition), `client-route-zod-guard` (documents and Coach ship no zod in their static client graph).
Planned (until it lands, this is a review-checklist item):
1. A tile-header icon-colour check (`text-muted-foreground` / `text-primary` on a glyph inside a `CardTitle` or tile-header row).
**PR checklist for any UI change:** both themes checked visually · no new `dracula-*`/hex/opacity-muted · headers via a primitive · card gap untouched by slot padding · `isError` → `QueryErrorCard` or `QueryErrorRow` by surface, with `error` · mobile modal = `ResponsiveSheet` · `pnpm format` + `pnpm lint` + `pnpm typecheck` green.
---
## 8. Insights left reading edge; two edges, one gutter
Every text run on an Insights surface starts on one of exactly two vertical lines, and card headings offset from the card line by exactly one gutter:
- **Edge A, reading edge (x = 0):** the AuthShell container inner edge (`px-4 md:px-6`). All **borderless page-level blocks** start here with **no extra horizontal padding**: the sub-page `<h1>` + explainer (`SubPageShell`), and every `SectionHeading`. If a sub-page heading must track the card body instead, it uses the responsive card inset `px-4 md:px-6`, **never** a flat `px-6` (that sat 8 px right of the card body on mobile and reopened the heading-vs-body step).
- **Edge B, card edge (x = card `px`):** every `<Card>` insets header **and** body to `px-4 md:px-6`. No card inset with a static `p-5` (banned) or a `p-4`-only/`sm:` shell; header and body share one inset so the body left edge is identical on every tile.
- **Heading gutter (Edge B + gutter):** every card heading routes through `TileHeader`. The icon's left edge sits on Edge B; the heading **text** sits at Edge B + gutter (icon box + `gap-2` = 28 px at `size="default"`, 24 px at `size="sm"`). Compact tiles use `TileHeader size="sm"`, never a bespoke `h-4` icon row. Tile header icons are `text-foreground` (never muted, never an accent) at both sizes.
Forbidden on any Insights card: a hand-rolled icon+title row, a card title centred by default, an icon box outside `TileHeader`'s h-5/h-4 vocabulary, and a horizontal inset other than `px-4 md:px-6` (or the sanctioned `p-3` dense inner tile). Verify at 390 px: every card body shares one left x; every card heading text shares a second x one gutter to the right; the `<h1>` + any `SectionHeading` sit on the container edge.
## 9. One scroll floor; sub-shells never re-declare bottom-space
Reinforces §2's "scroll height is shell-owned". Only `AuthShell` owns the scroll container's bottom padding and any viewport-height reserve: the `<main>` clears the mobile bottom-nav (`pb-[calc(4rem+safe-area)]`), or the home indicator in the desktop shell, and its wrapper clears the Coach FAB (`pb-20`, `pb-24` in the desktop shell). **No page, section, or sub-shell** adds a `min-h-[calc(100dvh-…)]` / `min-h-dvh` reserve or a `pb-*` bottom gutter inside the scrolling `<main>`; a nested reserve stacks on the shell budget and scrolls past the last card.
A two-column sub-shell that needs a "don't jump while loading" floor derives it from **one shared source**: `SUB_SHELL_GRID_FLOOR` (`src/components/layout/shell-metrics.ts`); applied to the **grid** (not a column), so the `1fr` content row stretches to fill without over-shooting. It never re-declares the floor per-shell (`AdminShell` drifting to a stale 12rem column reserve is exactly how this recurred). Guard: `e2e/settings-overscroll.spec.ts` covers Settings **and** Admin and measures against each element's content-box bottom, so a redundant sub-shell `pb-*` fails instead of inflating the allowed budget.
## 10. Navigation; utilities live only in the user menu
Settings + Notifications are **account utilities, not feature destinations**. They live ONLY in the user/avatar menu (mobile top-bar `DropdownMenu`; desktop sidebar avatar menu, with Settings additionally in the sidebar footer). The mobile "More" overflow hub carries **feature destinations only** (Measurements, Mood, Cycle, Labs, Coach, …), never Settings or Notifications. The hub is `mobileMoreHubDestinations()` = `visibleNavDestinations` minus the primary slots (`/`, `/medications`, `/insights`); it does **not** append the utility tail. `NAV_UTILITY_DESTINATIONS` (`visibleUtilityDestinations()`) feeds the sidebar footer + user menus, not the mobile overflow. Never surface a utility in two menus reachable from the same screen.
The rail's destination list is the one part of the rail that gives up height: the logo band, the footer group and the account row stay pinned, and the list scrolls between them (`overscroll-contain`, no native scrollbar, fades at an edge with more). A mouse gets the height-stepped row density; a finger keeps the 44 px rows, and at an edge with more destinations the rail shows a scroll cue (`sidebar-nav-scroll-cue`) that a tap follows. Guard: `e2e/tablet-shell.spec.ts`.
## 11. Inline action row; the PageHeader pattern
A title/sentence with an adjacent action is one row, not a wrapping stack: `flex items-start justify-between gap-3`, the text `min-w-0 flex-1` (wraps internally), the action `shrink-0` (stays beside the text, never pushed to its own line). This is the canonical `PageHeader` / `TileHeader` (`right` slot) shape; reuse it (e.g. the dashboard-hero verdict + CTA) instead of a one-off `flex-wrap` row whose `gap-y-*` drops the action below on a full sentence. Keep the mobile tap-target floor (`min-h-11 sm:min-h-9`).
## 12. The action row; one place, one shape
A card's actions live in **one** row, and that row is the **last thing in the card**. Reference implementations: `src/components/settings/score-section.tsx`, `src/components/settings/coach-prefs-section.tsx`.
- **Use `SettingsCardActions`** (`settings/_card-actions.tsx`). It owns the row: `flex flex-wrap items-center gap-2`, `justify-end` by default. Never hand-roll a `flex justify-end gap-2` div in a Settings or Admin card.
- **Primary action:** last child of the row (rightmost), `variant` default, `size="sm"` + `min-h-11`. One per card.
- **Secondary action:** `outline`, before the primary in the same row. Cancel/reset are secondary, never `ghost` in the same row as a primary.
- **Destructive card-wide action:** the same row, `variant="destructive"`. Never hand-tinted (`text-destructive border-destructive/30` on an `outline` is drift).
- **Nothing follows the action row.** Status lines, result lists, and confirmations belong **above** it; a result set large enough to need its own frame becomes its own card or a collapsible.
- **The header `status` slot carries status, not actions**; a badge, a pill, a switch. Not a primary, not a destructive, not a filter group. Filters get a toolbar row above the content.
- **`outline` has exactly one meaning: secondary.** A card whose only actions are deep links out (privacy, about, export) has no primary; those links stay `outline size="sm"` and the row uses `align="start"`, because a navigation row reads with the text, not against it. That is the one documented exception, and `SettingsCardActions` spells it (`align="start"`).
## 13. Settings + Admin card rhythm; owned by the container
- **`SettingsCard` is gap-based**, like `Card`: `flex flex-col gap-4 p-4 md:p-6`. The header→body distance comes from the container's gap. A call site never adds `mt-4` to its first body child, never `mb-4` to the header, never `space-y-4` to the card. A denser card overrides the gap on the card itself (`className="gap-2"`), exactly as `Card` does.
- **The body starts at the card edge.** The old `pl-7` gutter (icon box + `gap-2`) is abolished: it is off the scale in §2, it broke the moment two neighbouring cards disagreed, and it silently mis-aligned action rows against their own forms. Header text is offset from the card edge by the icon gutter; body content is not. This is the deliberate difference from §8's Insights gutter, where the tiles carry no body form.
- **Group headings above a group of cards use `SectionHeading`** (`ui/section-heading.tsx`, the heading of the Insights overview, shared by every area), not a bare `SettingsCardHeader`. `text-base` on the group, `text-lg` on the card title: that step is what says which one labels which.
- **Admin uses the Settings anatomy verbatim.** No admin-only card shell, no admin-only header, no admin-only mini tile; `SettingsCard`, `SettingsCardHeader`, `SettingsCardActions`, `StatTile` (`admin/_stat-tile.tsx`) are the whole vocabulary.
- **A loading state paints the card and its header.** A section that renders a bare spinner while loading and a card afterwards moves the page under the cursor. Reference: `admin/coach-feedback-section.tsx`.
### Enforcement
The `p-5` / `py-5` / `px-5` / `space-y-5` family does not occur in the app, and `healthlog/spacing-scale` runs at `error` over `src/components/**` and `src/app/**`, so the step cannot come back unnoticed. `no-raw-card-shell` is described in §7. The header icon colour (§1) has no lint rule; check it in review.
+1 -1
View File
@@ -81,7 +81,7 @@
<text x="600" y="360" text-anchor="middle" fill="#6272a4" font-size="11">pre-aggregated buckets</text>
<rect x="510" y="392" width="180" height="56" rx="10" fill="#44475a" stroke="#ff79c6" stroke-width="1.5" />
<text x="600" y="417" text-anchor="middle" font-weight="600">PostgreSQL 16</text>
<text x="600" y="417" text-anchor="middle" font-weight="600">PostgreSQL 18</text>
<text x="600" y="435" text-anchor="middle" fill="#bd93f9" font-size="11">encrypted at rest</text>
</g>

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

+2 -2
View File
@@ -1,6 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 620" font-family="ui-sans-serif, -apple-system, Segoe UI, Roboto, sans-serif" role="img" aria-labelledby="title desc">
<title id="title">HealthLog self-hosting topology</title>
<desc id="desc">Internet traffic terminates at a reverse proxy (Caddy, Traefik or Nginx) for TLS, then reaches the Next.js app on port 3000. The app shares a Postgres 16 database with the pg-boss worker process for reminders, insights, backups and the Apple Health import. GHCR ships the multi-arch image; Coolify watches the digest and pulls; off-host S3-compatible storage is optional for backups.</desc>
<desc id="desc">Internet traffic terminates at a reverse proxy (Caddy, Traefik or Nginx) for TLS, then reaches the Next.js app on port 3000. The app shares a Postgres 18 database with the pg-boss worker process for reminders, insights, backups and the Apple Health import. GHCR ships the multi-arch image; Coolify watches the digest and pulls; off-host S3-compatible storage is optional for backups.</desc>
<rect x="0" y="0" width="1200" height="620" fill="#282a36" />
@@ -47,7 +47,7 @@
<!-- Postgres (green) -->
<g>
<rect x="760" y="250" width="200" height="80" rx="10" fill="#44475a" stroke="#50fa7b" stroke-width="1.5" />
<text x="860" y="276" text-anchor="middle" fill="#f8f8f2" font-size="14" font-weight="600">PostgreSQL 16</text>
<text x="860" y="276" text-anchor="middle" fill="#f8f8f2" font-size="14" font-weight="600">PostgreSQL 18</text>
<text x="860" y="296" text-anchor="middle" fill="#bd93f9" font-size="11">26 Prisma models</text>
<text x="860" y="312" text-anchor="middle" fill="#6272a4" font-size="11">+ pg-boss queues</text>
</g>

Before

Width:  |  Height:  |  Size: 8.1 KiB

After

Width:  |  Height:  |  Size: 8.1 KiB

+1 -1
View File
@@ -10,7 +10,7 @@ copy.
| ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `01-data-flow.svg` | Sources (Withings, Apple Health export.zip, iPhone HealthKit, manual, Nightscout) → ingest endpoints → Measurement + MeasurementRollup → reads (rollup probe / live SQL fallback / cache) → surfaces (dashboard, insights, Coach, doctor PDF). |
| `02-coach-pipeline.svg` | User question → snapshot builder → prompt assembler → provider chain (ChatGPT OAuth / OpenAI / Anthropic / local / admin-shared) → response parser → cited reply with mini-charts. |
| `03-self-hosting-topology.svg` | Internet → reverse proxy (TLS) → Next.js app (port 3000) ↔ pg-boss worker ↔ Postgres 16, plus GHCR image pull, optional Coolify autodeploy, optional S3 off-host backup. |
| `03-self-hosting-topology.svg` | Internet → reverse proxy (TLS) → Next.js app (port 3000) ↔ pg-boss worker ↔ Postgres 18, plus GHCR image pull, optional Coolify autodeploy, optional S3 off-host backup. |
| `04-source-priority.svg` | Three sources for the same metric on the same day → resolver picks one canonical row per (cumulative vs point) ladder. Losing rows stay in the audit trail. |
| `05-security-model.svg` | Three concentric layers — auth perimeter (passkey + password fallback), session perimeter (HttpOnly cookie + Postgres sessions), encrypted core (AES-256-GCM versioned keys). Side rails: rate limiter, audit log, HMAC tokens, CSP/HSTS, SSRF guard. |
+37
View File
@@ -216,6 +216,18 @@ JSON surfaces send the standard
the field is detected on the first refusal and retried without it,
so strict gateways and older local servers both work unmodified.
**Azure OpenAI.** Point the gateway at the resource's v1 endpoint,
`https://<resource>.openai.azure.com/openai/v1`, and put the
deployment name in Model. A reasoning model (gpt-5 on, the o-series)
takes `max_completion_tokens` and no `temperature`, the same as on
`api.openai.com`. HealthLog sends that from the first request when the
deployment is named after its model (`gpt-6-luna-eu`). A deployment
with a name of its own (`prod-chat`) gets the usual `max_tokens`
once; Azure refuses it, and the request goes out again with
`max_completion_tokens`. HealthLog remembers that for the endpoint and
deployment until the container restarts. Any other gateway that
refuses `max_tokens` the same way is handled the same way.
**When the gateway refuses.** OpenRouter answers a refused generation
with HTTP 200 and the reason inside the body, so every refusal used to
reach the provider health card as an empty reply with no status. It now
@@ -431,6 +443,31 @@ many documents against a generous provider budget; lower it on a
tight one. The 429 names the actual wait (the window is an hour) and
carries the reset instant in `X-RateLimit-Reset`.
`AI_SYNC_REGEN_GAP_HOURS` (default `6`, whole hours from 1 to 48) is
the minimum time between two regenerations of a status card after a sync
at the `standard` AI usage level. A watch or an integration delivers
readings all day, and each delivery moves today's figures, so without a
gap every touched card regenerated about once an hour. A reading entered
by hand keeps a one-hour gap, so it is still narrated the same day. The
value set under Admin > Coach (AI usage levels) wins over the environment.
Each account runs at one of three AI usage levels, chosen under
Settings > AI within the bounds the operator sets under Admin > Coach (the
highest level allowed and the default for accounts without a choice):
| | `frugal` | `standard` | `full` |
| ------------------------------- | ------------------ | -------------------- | -------------------- |
| Status cards after a sync | at most once a day | the gap above | hourly |
| Status cards after a hand entry | every 6 hours | hourly | hourly |
| Snapshot size (compact JSON) | 10 000 characters | 14 000 | 14 000 |
| Background reasoning | off | job default | job default |
| Daily briefing | short form | full | full |
| Automatic document reading | on request only | the account's switch | the account's switch |
| Supplement label photo | not offered | offered | offered |
The Coach is the same at every level. Settings shows an estimate of
tokens per day for each level, scaled from the account's last seven days.
`LABS_OCR_LIMIT_PER_HOUR` (default `6`, clamped to 1–1000) does the
same for lab-report scans. The photo or PDF scan and the browser-OCR
text scan share one bucket per user, and a scan that fails before the
+16 -5
View File
@@ -120,6 +120,14 @@ The HK quantity types that map to HealthLog metric types live in
mapped set (for example workout routes) is counted as unsupported and
not stored.
Heart-rate variability comes in two measures, and they stay two types.
`HeartRateVariabilitySDNN` becomes `HEART_RATE_VARIABILITY`;
`HeartRateVariabilityRMSSD`, which iOS and watchOS 27 added, becomes
`HRV_RMSSD`, the same type WHOOP, Oura and Polar write. RMSSD readings
up to 300 ms are accepted. When both arrive, the HRV page charts each on
its own and the Coach reads them apart, because one is not a stand-in for
the other.
### ECG archive safety, privacy, and idempotency
ECG support is source-agnostic: if HealthKit includes a valid
@@ -284,7 +292,10 @@ Concrete consequences:
source tag — the lowest rank — is reserved for legacy CSV/JSON
imports that pre-date the Apple Health passthrough.
Override per-user via Settings → Sources (`/settings/sources`).
RMSSD HRV has its own ladder, **WHOOP ≻ OURA ≻ POLAR ≻ APPLE_HEALTH**,
separate from the SDNN one.
Override per-user via Settings → Source priority (`/settings/sources`).
## Failure modes
@@ -303,7 +314,7 @@ id for debugging.
## Garmin owners
Garmin has no direct connector for a self-hosted instance. Garmin Connect writes
to Apple Health automatically, so the import path on this page is how Garmin data
reaches HealthLog on iOS. See [garmin.md](./garmin.md) for what comes through and
what Garmin withholds.
Garmin Connect writes to Apple Health automatically, so the import path on this
page brings Garmin data in on iOS. The direct [Garmin](./garmin.md) integration
reads more (Body Battery, stress, Garmin's sleep score) with a key from your
own Garmin account.
@@ -0,0 +1,97 @@
# Push a room's CO2, temperature and humidity to HealthLog every few minutes.
#
# Use it for one room. The readings carry no room name, so a second room would
# land in the same series, and a push in the same minute is refused (409).
#
# Needs the `healthlog_room_climate` rest_command from
# docs/integrations/home-assistant.md ("Room climate") and a measurement
# ingest token. Every sensor is optional; a sensor that reports `unknown`,
# `unavailable` or a non-number is left out of that push, and a push with
# nothing left is skipped. Temperatures in °F are converted to °C.
blueprint:
name: HealthLog room climate
description: >-
Sends indoor CO2 (ppm), temperature (°C) and relative humidity (%) to
HealthLog through a measurement ingest token. HealthLog keeps each
reading and shows the day's mean, low and high.
domain: automation
homeassistant:
min_version: 2024.10.0
source_url: https://github.com/MBombeck/HealthLog/blob/main/docs/integrations/blueprints/healthlog-room-climate.yaml
input:
co2_sensor:
name: CO2 sensor
description: A sensor reporting carbon dioxide in ppm.
default: ""
selector:
entity:
domain: sensor
device_class: carbon_dioxide
temperature_sensor:
name: Temperature sensor
description: A sensor reporting the room temperature (°C or °F).
default: ""
selector:
entity:
domain: sensor
device_class: temperature
humidity_sensor:
name: Humidity sensor
description: A sensor reporting relative humidity in %.
default: ""
selector:
entity:
domain: sensor
device_class: humidity
interval_minutes:
name: Interval
description: >-
Minutes between pushes, on the hour and every interval after it.
15 is plenty for a daily picture.
default: "15"
selector:
select:
options: ["5", "10", "15", "20", "30", "60"]
mode: single
variables:
co2_sensor: !input co2_sensor
temperature_sensor: !input temperature_sensor
humidity_sensor: !input humidity_sensor
interval_minutes: !input interval_minutes
# A list of readings (Home Assistant keeps a rendered list a list).
readings: >-
{% set at = now().replace(second=0, microsecond=0).isoformat() %}
{% set ns = namespace(items=[]) %}
{% for type, entity in [
('INDOOR_CO2', co2_sensor),
('INDOOR_TEMPERATURE', temperature_sensor),
('INDOOR_HUMIDITY', humidity_sensor)
] %}
{% if entity and is_number(states(entity)) %}
{% set v = states(entity) | float %}
{% if type == 'INDOOR_TEMPERATURE'
and state_attr(entity, 'unit_of_measurement') == '°F' %}
{% set v = ((v - 32) * 5 / 9) | round(2) %}
{% endif %}
{% set ns.items = ns.items
+ [{'type': type, 'value': v, 'measuredAt': at}] %}
{% endif %}
{% endfor %}
{{ ns.items }}
triggers:
- trigger: time_pattern
minutes: "/5"
conditions:
- condition: template
value_template: >-
{{ now().minute % (interval_minutes | int(15)) == 0
and readings | count > 0 }}
actions:
- action: rest_command.healthlog_room_climate
data:
readings: "{{ readings }}"
+56 -22
View File
@@ -57,23 +57,50 @@ arrays:
}
```
Each array holds at most **10 000** entries. The endpoint is rate-limited
to **5 imports per hour per account**. Existing rows (matched on the
type + timestamp uniqueness, or on `externalId` for mood entries) are
skipped and counted under `skipped` in the response.
Each array holds at most **10 000** entries and the file at most 16 MB.
The endpoint is rate-limited to **5 imports per hour per account**.
Existing rows (matched on the type + timestamp uniqueness, or on
`externalId` for mood entries) are skipped and counted under `skipped` in
the response.
The app's own files go straight in: the JSON export (**Export → JSON**,
read inside its `data` envelope) and the portable full backup. Every field
either file writes for a reading or a mood entry comes back, apart from
the row ids, the source (every imported row is stored as `IMPORT`) and the
sync bookkeeping of the account the file came from. An empty field, `null`
in the backup and `""` in the export, reads as absent, a backup's deletion
records are skipped and counted, and a reading in a display unit (lb, °F,
mmol/L) is converted to the stored one.
A mood entry's tags and rated factors are linked by key. A full backup
also carries the account's own tags, rated factors and tag groups, and the
import brings them along: a tag the importing account already has is used
as it is, and one it lacks is created (within the account's limit of 50
custom tags and 12 groups). The account's own tag is never overwritten: when
the file names it differently, the account's name stays and the response
lists the key in `keptMoodTagKeys` (`keptMoodCategoryKeys` for a group).
Moving a file between two accounts of one server works too; the tags come
over under keys of their own. A key the account cannot resolve at all is
dropped from that entry and named in `droppedTagKeys` or
`droppedFactorKeys`, and the rest of the entry lands.
### Measurement entry
| Field | Required | Type | Notes |
| ---------------- | -------- | ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `type` | yes | enum | One of the `MeasurementType` values in the table below. |
| `value` | yes | number | Plausibility-checked per type; out-of-range values are rejected. |
| `unit` | yes | string | The canonical unit for the type (see the table). |
| `measuredAt` | yes | string | ISO-8601 datetime, e.g. `2026-05-01T08:00:00.000Z`. |
| `source` | no | string | Free-text origin label. Imported rows are tagged `IMPORT` server-side regardless. |
| `notes` | no | string | Optional free text. |
| `glucoseContext` | no | enum | Only for `BLOOD_GLUCOSE`. See [Glucose context](#glucose-context). |
| `externalId` | no | string | A source-stable id (1–120 chars). When present, a re-import upserts on `(user, type, source, externalId)` so an edit upstream is reflected rather than skipped. |
| Field | Required | Type | Notes |
| ---------------------- | -------- | ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `type` | yes | enum | One of the `MeasurementType` values in the table below. |
| `value` | yes | number | Plausibility-checked per type; out-of-range values are rejected. |
| `unit` | yes | string | The canonical unit for the type (see the table). |
| `measuredAt` | yes | string | ISO-8601 datetime, e.g. `2026-05-01T08:00:00.000Z`. |
| `source` | no | string | Free-text origin label, not stored. Imported rows are tagged `IMPORT` server-side regardless. |
| `notes` | no | string | Optional free text. |
| `glucoseContext` | no | enum | Only for `BLOOD_GLUCOSE`. See [Glucose context](#glucose-context). |
| `valueMin` | no | number | Lower bound of a range reading, such as an hourly heart-rate statistic. Not above `valueMax`. |
| `valueMax` | no | number | Upper bound of a range reading. |
| `sleepStage` | no | enum | A sleep row's stage: `IN_BED`, `ASLEEP`, `AWAKE`, `CORE`, `DEEP` or `REM`. |
| `rhythmClassification` | no | enum | The device's verdict on an ECG, a rhythm alert or a steadiness reading. |
| `deviceType` | no | string | The writer of the reading (watch, phone), 1–32 chars. |
| `externalId` | no | string | A source-stable id (1–120 chars). When present, a re-import upserts on `(user, type, source, externalId)` so an edit upstream is reflected rather than skipped. |
### Glucose context
@@ -98,14 +125,20 @@ moment you know it.
### Mood entry
| Field | Required | Type | Notes |
| ------------ | -------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `date` | yes | string | `YYYY-MM-DD`. |
| `mood` | yes | enum | One of `SUPER_GUT`, `GUT`, `OKAY`, `SCHLECHT`, `LAUSIG`. |
| `score` | yes | integer | `1`–`5`. |
| `tags` | no | string | Comma-separated tags. |
| `loggedAt` | no | string | ISO-8601 datetime; defaults to the start of the given `date` in the default timezone. |
| `externalId` | no | string | A source-stable id (1–120 chars). When present, a re-import upserts on `(user, source, externalId)` so an edit upstream is reflected rather than skipped. |
| Field | Required | Type | Notes |
| ---------------- | -------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `date` | yes | string | `YYYY-MM-DD`. |
| `mood` | yes | enum | One of `SUPER_GUT`, `GUT`, `OKAY`, `SCHLECHT`, `LAUSIG`. |
| `score` | yes | integer | `1`–`5`. |
| `tags` | no | string | Comma-separated tags. |
| `note` | no | string | The entry's note, up to 500 chars. |
| `a1`–`a5` | no | integer | The five level-A values, `0`–`10`. Without `a1`, pleasantness is derived from `mood`. |
| `loggedAt` | no | string | ISO-8601 datetime; defaults to the start of the given `date` in the default timezone. |
| `tz` | no | string | The IANA zone `date` is anchored to. |
| `factors` | no | array | Rated factors, `[{ "key": "…", "rating": 1–5 }]`; replace the entry's rated links. |
| `structuredTags` | no | array | Ticked tags by key; replace the entry's tag links. |
| `context` | no | object | The day context (work, contacts, leisure, an event, a note), as the backup writes it; replaces the entry's context. |
| `externalId` | no | string | A source-stable id (1–120 chars). When present, a re-import upserts on `(user, source, externalId)` so an edit upstream is reflected rather than skipped. |
### `MeasurementType` values and canonical units
@@ -161,6 +194,7 @@ hand-authored.
| `WALKING_ASYMMETRY` | percent (0–100) |
| `WALKING_DOUBLE_SUPPORT` | percent (0–100) |
| `ACTIVE_ENERGY_BURNED` | kcal |
| `BASAL_ENERGY_BURNED` | kcal |
| `ENERGY_EXPENDITURE_KJ` | kJ |
| `VO2_MAX` | mL/(kg·min) |
| `PULSE_WAVE_VELOCITY` | m/s |
+7
View File
@@ -0,0 +1,7 @@
# Dawarich integration
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.
+19 -9
View File
@@ -16,14 +16,24 @@ Settings. The internal integration key is `fitbit`.
> consent screen with `unauthorized_client — Invalid client_id`, because Fitbit
> and Google Cloud are separate client registries.
> **Heads-up — new registrations are closed.** Fitbit has **discontinued
> self-serve app registration**: the form at `dev.fitbit.com/apps/new` is
> disabled for new applications, and the classic Fitbit Web API sunsets in
> **September 2026**. If you already hold a registered Fitbit app, it keeps
> working until the sunset and the setup below still applies. If you are
> connecting Fitbit data for the first time, use the
> [Google Health integration](google-health.md) instead — Fitbit devices sync
> into Google's health stack, which HealthLog reads directly.
> **The classic Fitbit Web API is turned off on 30 October 2026.** Google
> ended support for it on 30 September 2026 and turns it off on
> **30 October 2026**, after which it "will no longer function or sync data"
> ([Google's migration guide](https://developers.google.com/health/migration)).
> New app registrations at `dev.fitbit.com/apps/new` are already closed.
>
> From that day HealthLog stops calling the Web API for every Fitbit
> connection: no hourly sync, no backfill, no token refresh, and no new
> connection. Nothing is deleted. The connection and every reading it imported
> stay, and the Fitbit card explains the move instead of asking for a
> reconnect. To keep Fitbit and Pixel data coming, connect the
> [Google Health integration](google-health.md). It reads the same devices,
> but Google asks for consent once more: Fitbit tokens cannot be carried over.
>
> Both integrations can run side by side while you move. Where both deliver the
> same day, the source-priority ladder keeps one of them per day (Fitbit ranks
> just above Google Health by default), so steps, sleep and the other daily
> values are not counted twice. After the turndown only Google Health delivers.
## 1. Use an existing Fitbit developer app
@@ -93,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.
+102 -40
View File
@@ -1,52 +1,114 @@
# Garmin (via Apple Health or Google Health Connect)
# Garmin
**There is no direct Garmin connector for a self-hosted HealthLog instance, and
there cannot be one.** Garmin's data-sharing program is business-partner-only:
it is approval-gated, requires a public callback endpoint, and is not the
"register your own app, paste a key" model the wearable integrations here rely
on. So instead of a broken half-integration, HealthLog reads Garmin data the
honest way — through the phone health platform your Garmin already feeds.
HealthLog reads Garmin Connect directly through Garmin's **Personal API**: you
create a key for your own account in Garmin Connect, paste it into HealthLog,
and the server pulls your data every hour. Nothing is pushed by Garmin, and
nothing is written back.
## The path that works
The Personal API is for your own data only. Each person on an instance
creates and pastes their own key; there is no shared app and no operator
setup.
```
Garmin device → Garmin Connect → Apple Health (iOS) ─┐
→ Google Health Connect ├─→ HealthLog
(Android) ─┘
```
## Create a key
- **iOS:** Garmin Connect writes to Apple Health automatically. Import it with
the existing [Apple Health](./apple-health.md) path (the `export.zip` upload or
the native client's background sync).
- **Android:** since Garmin Connect 5.14.1 (July 2025) the app writes to **Google
Health Connect**. Connect HealthLog through the existing
[Google Health](./google-health.md) integration.
1. Open [Garmin Connect → Settings → Personal API](https://connect.garmin.com/app/settings/personalApiToken).
2. Create a key and choose how long it stays valid: 30, 90, 180 days or one
year.
3. Copy it right away. Garmin shows the key once.
No extra code, no Garmin developer account, no approval queue — if your Garmin
already syncs to your phone's health platform, HealthLog already reads it.
## Connect
Open **Settings → Integrations → Garmin**, paste the key, and pick the same
validity you chose in Garmin Connect. HealthLog checks the key with Garmin
before storing anything, so a mistyped or revoked key is refused on the spot.
The key is stored encrypted at rest and is never shown again, logged or sent
anywhere but Garmin.
Accounts registered with Garmin's China service work too: pick **Garmin
Connect China** as the service. The key is only ever sent to the service you
pick.
After connecting, the last 30 days arrive within a minute and the rest of your
history follows in the background. The card says "Older history is still
loading" until it is done.
## Key expiry
Garmin keys expire and cannot be extended. Garmin does not report a key's end
date through the API, so HealthLog works it out from the validity you picked
at connect, and replaces that estimate with Garmin's own date once the key is
in its last day (the short-lived tokens it buys are cut off at the key's end).
From seven days before the end the card says when the key expires. When it
has expired, or you revoked it in Garmin Connect, syncing stops and the card
asks for a new key: create one, open **Replace key** in the card's menu and
paste it. Nothing imported is lost. If you did not pick a validity, the card
says that it cannot warn ahead; **Key validity** in the card's menu sets it
afterwards, together with the day you created the key, without pasting the
key again. A key Garmin turned down is asked about again once a day, so a
refusal that passes on its own clears without you doing anything.
## What comes through
Everything Garmin shares with Apple Health / Health Connect flows in: steps,
heart rate (including beat-to-beat variability), sleep stages (REM / deep /
light), calories (active and total), distance, weight and body composition,
cadence, and — partially — blood-oxygen saturation.
| Garmin data | HealthLog type | Unit |
| --------------------------------------------------------------------- | -------------------------- | ------------- |
| Steps | `ACTIVITY_STEPS` | steps |
| Step distance | `WALKING_RUNNING_DISTANCE` | m |
| Floors climbed | `FLIGHTS_CLIMBED` | flights |
| Active calories | `ACTIVE_ENERGY_BURNED` | kcal |
| Resting heart rate | `RESTING_HEART_RATE` | bpm |
| All-day average stress level | `STRESS_LEVEL` | 0–100 |
| Body Battery (the day's high) | `BODY_BATTERY` | 0–100 |
| Sleep stages (light, deep, REM, awake) | `SLEEP_DURATION` per stage | minutes |
| Sleep score | `SLEEP_SCORE` | 0–100 |
| Average overnight SpO₂ | `OXYGEN_SATURATION` | % |
| Average overnight breathing rate | `RESPIRATORY_RATE` | breaths/min |
| Overnight HRV (RMSSD) | `HRV_RMSSD` | ms |
| Weight, BMI, body fat, body water, muscle and bone mass, visceral fat | the body-composition types | kg, %, rating |
| Activities | workouts | |
| Logged period days | the cycle tracker | |
## What Garmin withholds
Notes:
These are Garmin's **proprietary** scores. Garmin does **not** share them with
Apple Health or Health Connect, so no self-hosted integration — and no amount of
configuration — can obtain them. They are only available inside Garmin Connect
itself:
- Garmin's HRV is RMSSD and lands on `HRV_RMSSD`. Garmin reports no SDNN, so
nothing is written to `HEART_RATE_VARIABILITY`.
- Stress and Body Battery are Garmin's own scores and keep types of their own.
They are not mixed into HealthLog's computed stress score.
- Days you did not wear the watch import nothing, not zeros.
- Period days are only added on days you have not logged yourself; your own
entries are never changed. Cycles Garmin only predicts are not imported.
- Weigh-ins are read back to the start of Garmin Connect, also from before
your first watch, so a scale-only account gets its whole history.
- A night you delete in Garmin Connect, or that Garmin turns into a nap, is
removed here on the next sync.
- Data Garmin does not provide for your account (a menstrual-cycle summary
when you track none, for example) is skipped, does not count as an error,
and is listed under **Details for a bug report** on the card.
- When the same data also arrives through Apple Health or Health Connect, the
source order in **Settings → Sources** decides which one a chart shows.
Garmin ranks with the other watches.
- Body Battery
- HRV Status
- VO₂ max
- Training Load and Training Effect
- Recovery Time
- Intensity Minutes
- Stress score
- Running dynamics
## What the Personal API does not carry
If you rely on those specific numbers, keep the Garmin Connect app for them;
everything else lands in HealthLog through the platform path above.
Training Readiness, Training Status, acute training load and recovery time
are not part of the Personal API. If you read them elsewhere, for example
through a Home Assistant integration, push them through an ingest token: see
[Home Assistant](./home-assistant.md#garmin-training-figures). They land as
`TRAINING_READINESS` and `ACUTE_TRAINING_LOAD` and show on the recovery page
beside Body Battery.
## Limits
- Garmin sets how many requests a key may make. HealthLog reads one resource
at a time and stops the hour's run at Garmin's first rate-limit answer; the
history backfill waits as long as Garmin asks before it goes on.
- Disconnecting removes the key from HealthLog. It stays valid at Garmin until
you revoke it there.
## Without a key
Garmin Connect also writes to Apple Health on iOS and to Google Health Connect
on Android, so the [Apple Health](./apple-health.md) and
[Google Health](./google-health.md) paths keep working for anyone who prefers
not to create a key. Body Battery, stress and Garmin's sleep score only come
through the Personal API.
+179 -8
View File
@@ -2,9 +2,12 @@
HealthLog reads Fitbit, Pixel Watch and Fitbit Air data through the **Google
Health API** (`health.googleapis.com/v4`), the successor to the classic Fitbit
Web API. Google retires the classic Fitbit Web API in **September 2026**, and
Fitbit access/refresh tokens do not carry over — every account re-consents
through Google OAuth. This integration is that path.
Web API. Google ended support for the classic Fitbit Web API on 30 September
2026 and turns it off on **30 October 2026**
([migration guide](https://developers.google.com/health/migration)); from that
day HealthLog's `fitbit` connection stops syncing and keeps what it imported.
Fitbit access/refresh tokens do not carry over, so every account consents once
more through Google OAuth. This integration is that path.
The OAuth app is a **Google Cloud** OAuth client, not a Fitbit developer-console
app. Credentials are brought **per user**: each user registers their own Google
@@ -120,6 +123,174 @@ GOOGLE_HEALTH_REDIRECT_URI="https://your-instance.example.com/api/google-health/
The Google Cloud client id/secret are per-user (Settings), not env vars.
## Backfill, the hourly sync and Google's rate limit
Only one sync runs per account at a time. **Sync now** reports that a sync is
already in progress while the history backfill that follows a connect is
running, and shows its progress instead of starting a second one.
The backfill reads the account's whole history. Heart rate is by far the
largest part: a page holds up to 1,000 readings, so a watch that records once
a minute fills some 525 pages a year, and one that records every few seconds
(as many Pixel and Fitbit watches do during the day) several thousand; one
account measured about 4,100 pages a year. It runs in
slices: a slice reads for up to five minutes or 500 pages, writes what it
read, notes how far it got and lets go of the account. The next slice starts
a minute later where the last one stopped. Two kinds of note are kept: a data
type read to the end is not read again, and a data type read page by page
(heart rate, weight, body fat, blood glucose, core temperature) keeps the
time of the oldest reading it has written, so the next slice asks Google only
for what is older. A restart or an update in the middle loses at most the page
that was being read.
Asking for "what is older" uses an upper bound on the sample time, which
Google's filter grammar allows but which a given account may still refuse. If
Google answers that first resumed request with `400`, the slice reads the data
type again without the bound, passes over the readings newer than its note
without writing them, and carries on writing from there. The refusal is noted
per data type, so later slices read without the bound straight away. The pages
passed over are not counted against the slice's 500 pages, but they do run on
its five minutes, and passing over stops after three minutes at most, so a
slice never holds the account longer than a slice. Wherever the walk stops, on
a limit or on a rate limit, it keeps Google's page token for the page it read
last, and the next slice reads that one page again and goes on from there
instead of passing over everything earlier slices wrote: the pages read stay
in proportion to the history. If Google no longer takes the token, or the page
it returns no longer reaches the noted reading, the slice starts again from the
newest page and passes over as before. A `400` that names something else (the
page size, or the page token of a walk that did not start from a kept one) is
still a failure.
The hourly sync waits for the slice that is running (up to eight minutes)
instead of skipping the account, so recent data keeps arriving while an old
history is still coming in. The hourly job stays inside its own fifteen-minute
limit: the wait is cut short when the job's time runs low, the sync after it
gets only what is left and leaves the rest of its window to the next hour, and
an account the job reaches after its time is up waits for the next hour.
Google limits how many requests one account may make per minute. When a sync
reaches that limit Google answers `429`, and HealthLog waits and repeats the
request. If Google asks for a longer wait than a minute, the slice ends there
and the next one starts five minutes later. A server error (`500`, `502`,
`503`, `504`), a request that times out and a dropped connection are repeated
three times before they count as a failure. None of these mark the connection
as needing a reconnect. Access tokens last an hour, and a long backfill renews
its token as it goes.
### How far back the backfill reads
The backfill is not bounded by a number of pages, because how many pages a
year of history fills depends on the device. Two bounds apply instead:
- **The horizon.** It reads back at most 20 years. Set
`GOOGLE_HEALTH_BACKFILL_HORIZON_YEARS` to a whole number from 1 to 100 to
change that (it is on the compose `environment:` list; any other value
falls back to 20). A data type whose history goes back further is imported
up to the horizon and ends there.
- **Progress.** A walk has to reach older readings as it goes. If Google
hands out 20 pages in a row that reach no further back than the ones before
them (the same page again, or a page token that goes round in a circle),
the walk stops where it got to. A passing fault in Google's paging looks
the same as the end of what it holds, so the first time this happens the
data type only waits: it keeps its place, the rest of the backfill goes
on, and a day later it is walked again from there. If that second attempt
also stops without getting anywhere, the data type's history ends at the
oldest reading written so far. If it moves on, it is an ordinary walk
again. A walk that stops before it has written a single reading notes no
end at all (there is no date it could be said to end at); the slice fails
as stuck and is retried like any other. A reconnect starts the backfill
over.
Either way the data type counts as finished once its end is final, the
backfill completes once every other type is in, and the integration card
stops saying that older history is loading. Instead it says, per data type,
how far back the import reaches and why it ends there. While a type waits
for its second attempt the backfill is not complete, and the card still
says older history is loading.
Before 1.43.6 every page-by-page data type had a ceiling of pages counted
across all slices (10,000 for heart rate, 1,000 for the others). A dense
heart-rate history reaches 10,000 pages two to three years back, the count
never reset, and from then on every slice stopped at the same place without
the backfill ever completing. A backfill left in that state continues from
where it stopped with the first slice after the update.
A slice that moved nothing forward (no data type finished, no reading older
than before) is a real failure: the job fails with the reason, pg-boss retries
it a few times, and after that the next restart offers the backfill again.
Everything already written stays.
If an older version left your Google Health card asking for a reconnect after
a backfill, the first sync after updating checks the stored grant and resumes
on its own when the grant is still valid.
### Reading the backfill in the logs
Every slice writes one log line when it ends, with
`"background":{"task_name":"job.integration_backfill"}`. The parts worth
reading:
- `meta.kind` is `google-health-backfill`, `meta.slice` the slice number
(0 for the first), `meta.job_id` the pg-boss job.
- `external_calls` lists the requests the slice made to Google, one entry per
request: `method` names the data type and page (`fetchHeartRate(page=212)`),
`status` the HTTP status, `duration_ms` how long it took. A request that was
repeated carries a `note` (`rate_limited_retrying_in_ms=4000`,
`server_error_retrying_in_ms=2000`, `no_response_retrying_in_ms=2000`,
`token_rejected_retrying_with_fresh_token`,
`edge_filter_rejected_reading_without_it`); one that failed for good
carries an `error`. Successful requests are listed up to 60 per line and
everything up to 200; `external_calls_omitted` counts the rest.
- `meta["googleHealth.backfill.slice"]` sums the slice up:
- `pages`, `elapsedMs` and `imported`: what the slice read, how long it
took, how many readings it wrote. `skippedPages` is the part of `pages`
read only to pass over readings already written, on a data type whose
upper-bound filter Google refused.
- `stopReason`: why it ended early. `time_budget` and `page_budget` are the
normal end of a slice, `rate_limited` means Google would not take more
requests for now, `job_budget` that the job itself ran out of time,
`lane_wanted` that the slice made room for another heavy job (a nightly
run, a backup, an upload) that was waiting; the next slice starts two
minutes later. `null` means the slice read everything it started.
- `collectionsDone` of `collectionsTotal`: data types read to the end so
far.
- `collections`: one entry per data type the slice touched, with its
`status` (`done`, `stopped` short on a budget or a rate limit,
`deferred` to the next slice, or `failed`), its `pages`, and for the
page-by-page types
`edgeFrom` and `edgeTo`, the oldest reading written before and after the
slice.
- `edges`: where every unfinished page-by-page data type stands now.
- `ended`: data types whose history ended short of its beginning, each
with its measurement `type`, the instant it ends at (`at`) and the
`reason` (`horizon` or `no_progress`). A `no_progress` entry also
carries `attempts` and `endedAt`; with one attempt it is waiting for
its second, a day after `endedAt`, and is not yet published.
- `action: google_health.backfill.retry_scheduled` marks a slice that found
nothing left but a data type waiting for its second attempt and queued the
next slice for when it is due (`delaySeconds`).
- `meta["googleHealth.backfill.ended"]` appears on the slice where a data
type ended short: the `collection`, its measurement `type`, the `reason`,
whether the end is `final`, how many data types have ended short so far
(`endedCount`) and of those for good (`finalCount`), and their `types`.
The full entries are in the slice report's `ended`. A walk stopped for lack of
progress also leaves a warning:
`fetchHeartRate: 20 pages in a row reached no further back than <instant>`.
- `meta["googleHealth.backfill.edgeFilterUnsupported"]` appears on the slice
where Google refused the upper-bound filter, listing the data types noted
as refusing it.
- `action.name` is `google_health.backfill.continued` for a slice that made
progress and queued the next one, `google_health.backfill.complete` for the
one that finished the history, and `google_health.backfill.stuck` for one
that moved nothing. A stuck slice also fails the job with a message that
names each unfinished data type and its status, for example
`no progress in slice 4, stopped on rate_limited; fetchHeartRate:stopped`.
While a slice runs, the usual `job.progress` line for the
`integration-backfill-admission` queue reports
`google_health_backfill_slice`, `google_health_backfill_pages` and
`google_health_backfill_collections_done` every two minutes.
## The 7-day re-consent caveat
While the OAuth consent screen stays in **Testing** publishing mode, Google
@@ -134,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
@@ -144,7 +315,7 @@ Previously synced measurements stay in the database.
## Garmin owners
Garmin has no direct connector for a self-hosted instance. Since Garmin Connect
5.14.1 (July 2025) the app writes to Google Health Connect, so this integration
is how Garmin data reaches HealthLog on Android. See [garmin.md](./garmin.md) for
what comes through and what Garmin withholds.
Since Garmin Connect 5.14.1 (July 2025) the app writes to Google Health Connect,
so this path brings Garmin data in on Android. The direct [Garmin](./garmin.md)
integration reads more (Body Battery, stress, Garmin's sleep score) with a key
from your own Garmin account.
+166
View File
@@ -0,0 +1,166 @@
# Health Connect import
HealthLog reads the export file of Android's Health Connect and folds
it into the same timeline as every other source. There is no Android
app and no live sync: you export from the phone, upload the file, and
a background job imports it. Rows from this path carry the source
`HEALTH_CONNECT`.
## What you need
- An Android phone with Health Connect and its export feature. The
export arrived with Android 15 and reaches Android 14 through a
system update; if you do not see it, update Health Connect first.
- A cloud storage app on the phone (Google Drive, Dropbox, Nextcloud
or any other app that offers itself as a storage location). Health
Connect refuses to save its export to the phone's own storage or the
Downloads folder, so the file always goes through a cloud first.
- A HealthLog account in a browser. The upload uses your normal
session; there is no token to create.
## 1. Export on the phone
1. Open Health Connect (in the system settings, or the Health Connect
app).
2. Go to **Manage data**, then **Export and import**, then
**Scheduled export** or **Export now**, depending on the version.
3. Pick the cloud storage app as the destination. You can choose how
often Health Connect exports again; HealthLog only ever reads the
file you upload.
The result is a ZIP file named `Health Connect.zip` with a single
entry, `health_connect_export.db`. That entry is a copy of the phone's
Health Connect database (a SQLite file). Health Connect does not
encrypt it, so treat the file like any other export of your health
data.
## 2. Upload to HealthLog
Download the ZIP from the cloud to the computer or phone you use
HealthLog on, open **Settings → Export & Import** and drop the file
onto the **Health Connect export** card.
The endpoint is `POST /api/import/health-connect-export`
(`multipart/form-data`, field `file`). It:
1. Allows three uploads per minute per account.
2. Refuses anything above **1 GiB**, once against the declared
`Content-Length` and again while the body streams to disk.
3. Streams the body to a temp file and hashes it with SHA-256 on the
way. Uploading the same bytes again returns the earlier job
(`idempotent: true`) instead of importing twice; a failed job never
blocks a retry.
4. Runs one import per account at a time, Apple Health and Health
Connect together. A second upload while one runs answers `409` and
the file is discarded.
5. Records the upload's size and hash in the audit log, and nothing
from inside the file.
`GET /api/import/health-connect-export/status` answers the account's
latest Health Connect job, or `null`. The card polls it while a job
runs.
## 3. What the import does
The worker extracts `health_connect_export.db` (at most 4 GiB, with
the same zip-bomb checks as the Apple Health importer) and opens it
read-only. The file is treated as untrusted input:
- It is opened with SQLite's `immutable` flag, so a database last
written in WAL mode opens without its `-wal` and `-shm` files, and
nothing is ever written next to it.
- Views, triggers and generated columns in the file cannot call
functions with side effects (`trusted_schema=OFF`), and a view in
place of one of the record tables refuses the whole file.
- Columns are looked up by name. Health Connect adds columns as its
schema version moves, so their order differs between phones.
Database versions below 9 are refused; versions above 27 (the newest
known when this was written) are read, with a warning on the job.
A table that lacks a column the importer needs is skipped and named
in the job's `warnings`.
### What becomes what
| Health Connect | HealthLog |
| -------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
| Weight, lean body mass (grams) | `WEIGHT`, `LEAN_BODY_MASS` in kg |
| Body fat | `BODY_FAT` in % |
| Blood pressure | `BLOOD_PRESSURE_SYS` and `BLOOD_PRESSURE_DIA`, one pair per reading |
| Resting heart rate, heart-rate variability (RMSSD) | `RESTING_HEART_RATE`, `HRV_RMSSD` |
| Oxygen saturation, respiratory rate, body temperature, VO2 max | the matching types |
| Blood glucose (mmol/L) | `BLOOD_GLUCOSE` in mg/dL, fasting and after-meal context kept |
| Heart rate | per minute for the last 90 days; older history as hourly means with the hour's minimum and maximum |
| Steps, active calories, distance | one total per day |
| Sleep sessions and stages | one row per stage; light sleep becomes core sleep, both awake stages awake time; a session without stages counts as asleep |
| Exercise sessions | workouts, with the Health Connect exercise type kept on the workout |
| Menstruation flow and periods | cycle day logs (needs cycle tracking switched on) |
| Hydration and vitamins and minerals | daily nutrient totals (needs the Nutrients module) |
Left out on purpose: food energy and macronutrients, exercise routes,
planned workouts, Health Connect's access logs, the "out of bed" sleep
stage, and an "unknown" sleep stage inside a session that also has real
stages.
### When several apps write the same thing
The export holds every app's data side by side. Summing them would
count a step the phone and the watch both saw twice, so:
- A day total (steps, calories, distance, water, nutrients) comes from
one app per day: the first one your Health Connect app priority
list names for that category, otherwise the app with the largest
total.
- Heart rate comes from one app per hour, by the same rule.
- Of two overlapping sleep sessions or workouts from different apps,
the one from the higher-ranked app is kept.
- Two apps writing the same type at the same instant keep the
higher-ranked app's value.
### Apps HealthLog already connects to
If your account has the Withings, Fitbit, Google Health, Oura, Polar
or WHOOP integration connected, that app's records in the export are
left out: HealthLog already receives them directly, and importing them
again would put every reading in twice. As a further safety net, a
reading that matches one already stored under another source (same
type and value within two seconds) is left out too. The job result
lists how many records each app contributed and which apps were left
out.
### Importing again
Every row is keyed by the record's Health Connect ID (`hc:<uuid>`),
day totals and hourly means by their `stats:` ID. Importing the same
export again writes nothing; a newer export that overlaps an older one
adds only what is new. A value that changed on the phone is updated;
a value you deleted in HealthLog stays deleted.
Heart rate imported per minute ages the way Apple Health heart rate
does: once a minute is older than 90 days, the nightly pass folds its
hour into one hourly mean and removes the minutes. A later import of
an export that still holds those minutes leaves them out, so an hour
is never counted twice.
## Privacy
- The uploaded ZIP and the extracted database are deleted as soon as
the job ends, whether it succeeded or failed. A periodic sweep
removes anything a crash left behind.
- The job result holds counts only: per measurement type and per app.
No value, no timestamp and no text from the file (notes, titles)
is stored on the job.
## Operator notes
- The worker uses Node's built-in `node:sqlite` module. It needs no
flag on Node 22 and prints one `ExperimentalWarning` per process.
- Memory stays bounded: the database is read in batches of 500 rows
and aggregated inside SQLite, so what the import keeps alive does not
grow with the length of the history. Measured under a 792 MB heap,
one and three years of minute-by-minute heart rate peaked at about
160 MB of heap in total.
- Split web and worker containers need a shared staging directory,
exactly as for the Apple Health import.
- The status of a running job shows up under **Settings → Export &
Import**; a job interrupted by a restart is marked failed by the
same reconcile pass that watches Apple Health imports.
+124 -8
View File
@@ -109,20 +109,20 @@ a new weight:
```yaml
automation:
- alias: "Push weight to HealthLog"
trigger:
- platform: state
triggers:
- trigger: state
entity_id: sensor.bathroom_scale_weight
condition:
conditions:
- condition: template
value_template: "{{ trigger.to_state.state not in ['unknown', 'unavailable'] }}"
action:
- service: rest_command.healthlog_measurement
actions:
- action: rest_command.healthlog_measurement
data:
type: WEIGHT
value: "{{ trigger.to_state.state | float }}"
```
The `condition` is not decoration. Home Assistant sensors publish `unknown` and
The condition is not decoration. Home Assistant sensors publish `unknown` and
`unavailable` as ordinary states, and forwarding one produces a 422 on every
restart.
@@ -149,6 +149,121 @@ Units are derived server-side from the type, so send the number in the unit
above and do not send a `unit` field. The full enum is in
`docs/api/openapi.yaml` under `MeasurementType`.
## Garmin training figures
The Garmin Personal API does not carry Training Readiness or the acute
training load (see [Garmin](./garmin.md)). If Home Assistant reads them, for
example through a Garmin Connect integration, send them through the same
ingest token. They have types of their own and appear on the recovery page:
| What the sensor reports | `type` | Unit expected |
| -------------------------- | --------------------- | ------------- |
| Garmin Training Readiness | `TRAINING_READINESS` | score 0–100 |
| Garmin acute training load | `ACUTE_TRAINING_LOAD` | load, no unit |
Both change about once a day. This automation pushes Training Readiness
whenever the sensor changes, through the `healthlog_measurement` command
above; the acute load is the same with its own sensor and type:
```yaml
automation:
- alias: "HealthLog: Garmin training readiness"
triggers:
- trigger: state
entity_id: sensor.garmin_training_readiness
conditions:
- condition: template
value_template: "{{ trigger.to_state.state | is_number }}"
actions:
- action: rest_command.healthlog_measurement
data:
type: TRAINING_READINESS
value: "{{ trigger.to_state.state | float }}"
```
## Room climate
Indoor CO₂, temperature and humidity are their own types, kept apart from the
body temperatures and from the outdoor weather the environment module pulls:
| What the sensor reports | `type` | Unit expected |
| ----------------------- | -------------------- | ------------- |
| Carbon dioxide | `INDOOR_CO2` | ppm |
| Room temperature | `INDOOR_TEMPERATURE` | °C |
| Relative humidity | `INDOOR_HUMIDITY` | % |
Each push is kept as a reading, and the day's mean, low and high are what the
charts and the daily views read, so a sensor sampled every 15 minutes gives a
clear picture without anything further on your side. Pushing every few seconds
adds rows, not detail.
Add a second `rest_command` that takes a ready-made list of readings, so one
request carries all three:
```yaml
rest_command:
healthlog_room_climate:
url: "https://healthlog.example.com/api/measurements"
method: POST
headers:
Authorization: !secret healthlog_token
Content-Type: "application/json"
payload: "{{ readings | to_json }}"
```
Track one room, the one you sleep in: that is the one worth reading next to
your sleep. The readings carry no room name, so a second room would land in the
same series and blur both, and two pushes in the same minute collide on a 409
(see below).
Then import the blueprint (Home Assistant 2024.10 or newer), either from
[`docs/integrations/blueprints/healthlog-room-climate.yaml`](./blueprints/healthlog-room-climate.yaml)
or by its raw GitHub URL under Settings → Automations & scenes → Blueprints →
Import blueprint, and create one automation from it for that room. It asks for
the three sensors (each optional) and an interval of 5, 10, 15, 20, 30 or 60
minutes, counted from the full hour. Every push:
- leaves out a sensor that reports `unknown`, `unavailable` or anything that is
not a number, and skips the push when nothing is left;
- converts a temperature reported in °F to °C;
- stamps all readings with the current minute, so a retry of the same push is
refused with a 409 instead of written twice.
If you would rather write the automation yourself, this is the same thing for a
single room without the blueprint:
```yaml
automation:
- alias: "Push bedroom climate to HealthLog"
triggers:
- trigger: time_pattern
minutes: "/15"
variables:
readings: >-
{% set at = now().replace(second=0, microsecond=0).isoformat() %}
{% set ns = namespace(items=[]) %}
{% for type, entity in [
('INDOOR_CO2', 'sensor.bedroom_co2'),
('INDOOR_TEMPERATURE', 'sensor.bedroom_temperature'),
('INDOOR_HUMIDITY', 'sensor.bedroom_humidity')
] %}
{% if is_number(states(entity)) %}
{% set ns.items = ns.items + [{'type': type,
'value': states(entity) | float, 'measuredAt': at}] %}
{% endif %}
{% endfor %}
{{ ns.items }}
conditions:
- condition: template
value_template: "{{ readings | count > 0 }}"
actions:
- action: rest_command.healthlog_room_climate
data:
readings: "{{ readings }}"
```
One request takes up to five readings, so the room is one call.
## Sending several readings at once
A bare array writes up to five readings in one request — the shape to use for
@@ -171,8 +286,9 @@ tells you which rows to retry.
## Retries and duplicates
Send an `Idempotency-Key` header with a value your automation can reproduce for
the same reading. A retry with the same key returns the original response
instead of writing a second row:
the same reading. A retry with the same key and the same body returns the
original response instead of writing a second row. A different body under the
same key is a different reading and is written:
```yaml
headers:
+4 -72
View File
@@ -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.
+59 -4
View File
@@ -25,6 +25,56 @@ Activity data (steps, active energy, distance, floors climbed) lands
via the `user.activity` OAuth scope shipped in v1.4.25. Sleep stages
arrive via the sleep series endpoint.
## Withings API plans (October 2026)
On 12 October 2026 Withings split its public API into plans
([plans page](https://static.withings.com/content/api/plans/plans.html)).
The developer app a self-hoster registers for HealthLog sits on the free
**Start for Free** plan, which covers the core biomarkers: activity, sleep,
body and heart. The FDA/CE-cleared biomarkers and a few body-composition
extras now need the paid **Advanced Biomarkers** plan.
A request that names anything outside the plan is refused as a whole with
status 277 ("One of the data type requested in this API request is not
available in your API plan"). Before v1.42.3 HealthLog asked for every type
in one request, so from that date no Withings reading imported at all, and
the connection showed "Reauthorisation required" although reconnecting could
not help.
**Still synced on the free plan:** weight, fat mass and fat-free mass, muscle
mass, bone mass, body water, blood pressure, heart rate, a manual SpO2
reading, body temperature, steps, distance and calories, and the sleep
stages (duration, deep, light, REM, awake, wake-ups).
**Needs the paid Advanced Biomarkers plan** (per Withings'
[available health data](https://developer.withings.com/developer-guide/v3/data-api/all-available-health-data/)
table): pulse wave velocity (meastype 91), vascular age (155), visceral fat
(170), VO2 max (123), skin temperature (73), ECG recordings and AFib
verdicts, and the sleep score, overnight HRV and overnight breathing rate
from the sleep summary.
**What HealthLog does about it, automatically:**
- When Withings refuses a request with 277, HealthLog asks again without
the types Withings lists as advanced. If that is still refused, it
splits the remaining types until it has found the ones the plan
refuses, within a fixed number of requests. Everything the plan allows
imports as before.
- The refused types are remembered per connection, so later syncs skip
them without a refused request. Once a week HealthLog asks for the full
set again, so a plan upgrade is picked up on its own.
- ECG recordings are skipped quietly while the plan refuses them.
- A plan refusal is never counted as a sync failure and never asks you to
reconnect. Connections that an earlier version parked over it are
released on the next hourly sync, and that sync imports everything since
the last successful one.
- Settings → Integrations → Withings shows a short note naming the
readings your plan withholds, with a link to the Withings plans page.
Nothing needs to be configured. If you move your developer app to the
Advanced Biomarkers plan, the extra readings start arriving within a week,
or right away after reconnecting Withings once.
## 1. Register a Withings developer app
1. Sign in at <https://developer.withings.com/> and **create a new
@@ -38,7 +88,7 @@ arrive via the sleep series endpoint.
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.
@@ -95,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.
@@ -131,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
@@ -149,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.
@@ -167,6 +217,11 @@ fixes the mismatch by updating the URL Withings holds.
### Sync is stuck on "Reauthorisation required"
If this started on or after 12 October 2026 and your version is older than
v1.42.3, the cause is the API plan change, not your grant: see
[Withings API plans (October 2026)](#withings-api-plans-october-2026).
Updating is the fix; reconnecting is not needed.
Withings revokes refresh tokens on password change, app un-authorise,
or after extended inactivity. The `WithingsConnection` row flips to
`error_reauth` status and the scheduled sync no-ops until the user
+5 -3
View File
@@ -204,9 +204,11 @@ n8n, and Health Connect integrations need no change.
## URL changes
- **Settings has moved to per-section routes.** `/settings#integrations`
→ `/settings/integrations`, etc. The legacy `/settings#anchor` URLs
still 308-redirect to the new routes; sidebar deep-links and the
`<a href="/settings#thresholds">` patterns from 1.3 keep working.
→ `/settings/integrations`, etc. A legacy `/settings#anchor` URL opens
Settings (`/settings` redirects to `/settings/account`), but the part
after `#` never reaches the server, so it cannot pick the section: update
bookmarks and links of the `/settings#thresholds` shape to the
per-section route.
## What landed in v1.4 vs v1.4.1
+18 -12
View File
@@ -1,18 +1,24 @@
# `attemptNumber` audit-row vs alert-signal semantics
# `attempt_number` signal vs alert semantics
Two numbers describe a failing integration sync, and they can diverge.
On-call operators reading an audit-log row alongside an admin alert
should expect the alert number to be smaller than (or equal to) the
audit number, never the other way around.
Operators reading an `integrations.sync.failed` log line alongside an admin
alert should expect the alert number to be smaller than (or equal to) the
logged number, never the other way around.
Since v1.43.7 a failed sync is a `warn` signal in the log store, not an
audit-log row: the audit log records what a person or an admin did to an
account, and a provider answering 503 is neither. The provider's message is
not on the signal; it stays encrypted on the integration's status row and
shows in the integration's settings.
## The two numbers
`integrations.sync.failed` audit rows write
`attemptNumber = transient + reauth_required + persistent` — the **sum**
`integrations.sync.failed` signals carry
`attempt_number = transient + reauth_required + persistent` — the **sum**
of all three failure buckets. This is the historical "consecutive
failures" running total carried by the legacy
`IntegrationStatus.consecutiveFailures` column (dropped in migration
`0077`, v1.4.47). Auditors looking at a single row see one integer
`0077`, v1.4.47). An operator looking at a single line sees one integer
that captures how many failures the bucket ledger has accumulated since
the last success.
@@ -27,14 +33,14 @@ single-bucket threshold even if its sum looks alarming.
## When the two diverge
A typed-failure-streak edge case: integration fails 2× transient, then
2× reauth_required, then 1× persistent. Audit row shows
`attemptNumber: 5`. Alert signal is `max(2, 2, 1) = 2`, which is below
the default threshold of 3, so no page fires. From the audit log this
2× reauth_required, then 1× persistent. The last signal shows
`attempt_number: 5`. The alert number is `max(2, 2, 1) = 2`, which is below
the default threshold of 3, so no page fires. From the log this
looks like a sustained 5-failure outage; from the alert dispatcher it
looks like three small bumps that never crossed a single-bucket
ceiling.
Both readings are correct for their purpose. The audit log records what
Both readings are correct for their purpose. The signal records what
happened end-to-end; the alert dispatcher only fires when a single
failure mode persists, because mixed-bucket streaks usually indicate a
transient network blip layered on top of an unrelated reauth prompt —
@@ -43,7 +49,7 @@ we want to wake someone up for.
## Runbook implication
If you see an audit row with `attemptNumber >= threshold` but no alert
If you see a signal with `attempt_number >= threshold` but no alert
was dispatched, check the per-bucket counts on
`IntegrationStatus.consecutiveFailuresByKind` (DB column
`consecutive_failures_by_kind`) for that user/integration pair. The
+191 -431
View File
@@ -1,472 +1,232 @@
# Off-host backup & restore
# Account copies and restore
HealthLog ships with an optional daily off-host backup that ships every
user's JSON dump, encrypted with AES-256-GCM under a SEPARATE key
(`BACKUP_ENCRYPTION_KEY`), to any S3-compatible bucket — Cloudflare R2,
AWS S3, Backblaze B2, MinIO, etc.
HealthLog keeps copies of each account so that one account can be rolled back
on this server. These copies are per account. They cannot rebuild a lost
server: a restore writes into an account on this server, the one the copy
belongs to, or, when that account has been erased, one you choose (see
"Restoring an erased account"). To recover a server, you need a database dump
and the encryption keys. That procedure is in
[disaster-recovery.md](disaster-recovery.md).
The backup runs at **02:30 Europe/Berlin** every day from the worker
container (queue `data-backup-offhost`). Object key layout:
## What exists
```
<bucket>/YYYY-MM-DD/user-<userId>.json.enc
```
| Copy | Where | When | Kept |
| ----------------------- | ------------------------- | -------------------------------------------------------------- | ----------------------------------------------------------------------------- |
| Weekly account rollback | this database | Sundays 03:00, or **Backup now** in Admin → Backups | one per account, replaced in place each week |
| Copy before a data wipe | this database | right before "Delete all data" deletes anything | until `ACCOUNT_DELETION_GRACE_DAYS` has passed, then the purge job deletes it |
| Uploaded copy | this database | when an admin uploads a file in Admin → Backups | until deleted |
| Off-host copy | your S3-compatible bucket | nightly at 02:30 Europe/Berlin, one object per account per day | as long as the bucket's lifecycle rule keeps it |
| Personal export | the person's device | Settings → Export, "Full backup" | wherever they keep it |
## Wire format (binary)
The weekly, pre-wipe and off-host copies carry the whole record, document
files and ECG traces included, with the database's ciphertext inside them as
stored. An uploaded copy holds whatever its file held. The personal
export is portable: readable values, and documents as metadata only. It is
meant for the person to keep and read. A restore refuses it when the file
lists any document, because it carries no document content to put back.
```
magic = "HLBK" (4 bytes, ASCII)
version = 0x04 (1 byte)
keyIdLen, keyId (1 byte + 12 ASCII hex: the first 12 hex digits of SHA-256(key))
iv = 12 random bytes (AES-GCM nonce)
ciphertext = N bytes (AES-256-GCM over gzip(JSON dump), key = BACKUP_ENCRYPTION_KEY,
associated data = "healthlog/offhost-backup/v4|<object key>")
authTag = 16 bytes (AES-GCM tag, trailing)
```
Two kinds of data are not in any account copy: workout GPS routes and sample
series. Credentials, devices, share links and account grants are left out on
purpose (see "What a restore does not touch").
Version `0x04` (from v1.39.3) adds two things. The key id says which off-host
key an object needs, so a reader holding a retired key as well finds the right
one. The associated data is the object's own key in the bucket,
`YYYY-MM-DD/user-<id>.json.enc`: an object copied to another account's name or
another date does not decrypt, where before it opened as if it belonged there.
The restore script passes the key it downloaded, so nothing changes for an
operator.
The JSON import in Settings → Export & import is not a restore. It adds
measurements and mood entries, up to 16 MB and 10,000 entries per list.
Four versions exist in the wild and all four restore. `0x01` encrypted the
JSON directly and `0x02` gzipped it first; both carry the tag in FRONT of the
ciphertext, which is what made them impossible to write a piece at a time —
GCM only produces the tag once the last block is in, so a leading tag means the
whole object has to exist before its first byte can be sent. `0x03` moves the tag to the end and changes nothing else: it still
covers every ciphertext byte, and the reader still verifies it before returning
a single byte of plaintext. Objects already in your bucket stay readable, and
the restore script needs no flag to tell them apart.
## The deletion grace window
## Required env vars
`ACCOUNT_DELETION_GRACE_DAYS` (14 by default, at most 30) sets how long an
erasure waits:
| Var | Required | Notes |
| --------------------------------- | -------- | ----------------------------------------------------------------------------------- |
| `BACKUP_ENCRYPTION_KEY` | yes | 64 hex chars or 32-byte base64. **Different from `ENCRYPTION_KEY`.** |
| `BACKUP_S3_ENDPOINT` | yes | e.g. `https://<account>.r2.cloudflarestorage.com` |
| `BACKUP_S3_BUCKET` | yes | |
| `BACKUP_S3_ACCESS_KEY` | yes | |
| `BACKUP_S3_SECRET_KEY` | yes | |
| `BACKUP_S3_REGION` | no | defaults to `auto` (Cloudflare R2) |
| `BACKUP_RETENTION_DAYS` | no | not read by the app; the number for your bucket's lifecycle rule |
| `BACKUP_ENCRYPTION_PREVIOUS_KEYS` | no | retired off-host keys, comma-separated, read only (see "Rotating the off-host key") |
- **Deleting an account**, or a guardian deleting a **managed profile**, locks
the account at once. Every session, refresh token, API token, connector and
share link is revoked, sign-in is refused with the date the account is
erased, and nothing runs for the account any more: no reminders, syncs,
copies, scores or AI work. The record, its stored copies and its off-host
objects stay as they are. **Admin → Users** shows the account with its
erasure date and an **Undo deletion** button. When the window ends, the
hourly `account-deletion-purge` job erases the account and asks the bucket to
delete its off-host objects.
- **Delete all data** first stores the whole record as a copy of its own
(shown as "Before all data was deleted" in Admin → Backups), then empties the
record. An admin can restore that copy until the window ends. The off-host
objects from before the wipe are kept until then too.
## Bucket permissions
Database dumps are not purged per account: a deleted account stays in them
until they age out (`DB_BACKUP_KEEP` nights on the server, and the bucket's
lifecycle rule for `database/`). The deletion dialogs say so.
The worker needs `PutObject`, `GetObject`, `ListBucket`, `DeleteObject` and
`AbortMultipartUpload`. On Cloudflare R2 the **Object Read & Write** token
covers all of them.
`0` erases immediately, as releases before 1.46 did. The Data & Privacy page
and the deletion dialogs state the configured number.
`DeleteObject` is needed because the app deletes objects in two cases, and
only those:
## Restoring an account
- **An account is deleted, or its data is wiped.** Deleting an account,
deleting a managed profile and "Delete all data" each write a deletion
request in the same transaction as the deletion. The `offhost-backup-purge` job lists the bucket, deletes every
`YYYY-MM-DD/user-<id>.json.enc` of that account and removes the request once
nothing of the account is left. It runs right after the deletion and again
every night at 03:40. A copy the nightly upload was writing while the account
was deleted is removed by the upload itself.
- **The connection test** deletes its own `_healthcheck/<ts>.bin`.
In **Admin → Backups**, every stored copy has **Restore**. The dialog first
shows what the copy holds, then asks you to type `RESTORE`. The restore runs
in the background and replaces the account's data tables: every row the copy
holds comes back under its original id, and every row the account gained
after the copy was taken is deleted. If the current state is worth keeping,
press **Backup now** first.
The admin wipe of every account deletes nothing in the bucket. It is you
clearing your own host, the bucket is yours, and its copies are the way back
from a wipe pressed by mistake. If you are handing the host on, empty the
bucket yourself.
_Also restore instance settings_ writes the registration, MFA, locale, module,
notification and AI settings from the copy back for the whole server. Leave it
clear unless those settings themselves need to go back.
Earlier versions of this page said the worker never deletes an object. That
was the design until v1.39.3, and it meant a deleted account's copies stayed
in the bucket for as long as the lifecycle rule kept them, or forever without
one. If the bucket refuses the delete (a credential without `DeleteObject`),
the request stays, the job retries it every night, and **Admin → Backups →
Off-host** shows how many deletions are outstanding and the bucket's answer.
`AbortMultipartUpload` only reaches an upload the same worker started and is
what clears the parts of a run that failed halfway. Without it, a failed
upload leaves parts that are billed and do not show in a bucket listing.
## Bucket lifecycle (required for retention)
The app does not expire objects by age, and nothing in the app reads
`BACKUP_RETENTION_DAYS`. Expiry is the storage provider's lifecycle rule, set
to match the number you wrote there. Without one, every nightly copy of every
account is kept forever. **Admin → Backups → Off-host** reads the bucket's
lifecycle configuration and says so when there is none, or when the credential
may not read it (grant `GetLifecycleConfiguration` to let it check):
```
Filter: "" (all objects)
Action: Expire after 30 days
```
For Cloudflare R2 add this from the bucket's **Settings → Lifecycle**.
## Smoke test
After deploying, hit `POST /api/admin/backup/test` (admin-only). It
performs a 1-byte PUT + GET round-trip and returns:
```json
{
"data": {
"endpoint": "https://...r2.cloudflarestorage.com",
"bucket": "healthlog-backups",
"region": "auto",
"putLatencyMs": 142,
"getLatencyMs": 38,
"ok": true
},
"error": null
}
```
The credentials are never returned.
## Restore
Pick a key (e.g. `2026-05-08/user-clx123.json.enc`) from the bucket
and run the restore script with the same backup credentials and
encryption key the backup was written under — a freshly generated
`BACKUP_ENCRYPTION_KEY` cannot decrypt any existing object:
To restore from an **off-host object**, fetch and decrypt it from a source
checkout with the backup variables of the server that wrote it:
```bash
BACKUP_S3_ENDPOINT=https://...r2.cloudflarestorage.com \
BACKUP_S3_BUCKET=healthlog-backups \
BACKUP_S3_ACCESS_KEY=... \
BACKUP_S3_SECRET_KEY=... \
BACKUP_S3_REGION=auto \
BACKUP_ENCRYPTION_KEY=<the key the backup was written under> \
pnpm dlx tsx scripts/restore-backup.ts \
2026-05-08/user-clx123.json.enc \
/tmp/restored.json
BACKUP_S3_ENDPOINT=... BACKUP_S3_BUCKET=... \
BACKUP_S3_ACCESS_KEY=... BACKUP_S3_SECRET_KEY=... \
BACKUP_ENCRYPTION_KEY=<the key the object was written under> \
pnpm dlx tsx scripts/restore-backup.ts 2026-05-08/user-<id>.json.enc /tmp/restored.json
```
Run this command from a source checkout with the production backup variables
exported. The script imports the full application dependency graph, which the
minimal production image does not expose as an operator scripting environment.
Then upload `/tmp/restored.json` in Admin → Backups (up to 512 MB after
compression) and restore it from its row. When the account `<id>` no longer
exists on this server, the upload offers to put the copy into another account
(next section).
The script downloads the object, decrypts it, and writes the JSON dump
to disk. Importing the JSON back into a HealthLog instance is left to
the operator (use `prisma db seed` or a custom script).
Every restore, preview and upload checks first that this server holds every
application key the copy's content was written under, and refuses with the key
id named if not. Nothing is changed by a refused restore.
Restoring is not streamed and does not need to be. It holds the whole document,
because the next thing anyone does with a backup is parse it as one JSON
object, and handing back plaintext the auth tag has not yet covered would trade
the authentication for memory. It runs on your machine rather than in the
container, so give it room: a record of several hundred thousand measurements
decompresses to a few hundred megabytes, and
`NODE_OPTIONS=--max-old-space-size=2048` in front of the command is enough for
a 445 000-measurement account.
### Restoring an erased account
### Restoring from the backups console
Once an account has been erased (its deletion window ended and the hourly purge
ran), its copies on this server and in the bucket are gone, but a file you kept
still holds the record: a disaster-recovery download, an off-host object you
fetched before it expired, or the person's own full backup. Uploading it in
Admin → Backups is refused at first, because the account it names no longer
exists, and the console then asks where the records should go:
An admin can restore a stored snapshot from the browser instead of the shell.
`/admin/backups` lists one row per account and per backup type: **Backup now**
enqueues the `data-backup` pass for every account on the instance, **Download**
decrypts one snapshot and hands back the JSON, and **Restore** opens a dialog
that first shows what the file carries and then requires the word `RESTORE` to
be typed before it will run. The restore targets the account the snapshot was
taken for, never the admin running it, and it refuses a file whose declared
owner does not match the stored row. Instance-wide settings ride along only
when you tick _Also restore instance settings_ in the dialog; leave it clear to
restore the account alone.
- **A new account.** Type a username. The account is created without a
password or email, as an ordinary account with the user role, and nobody
can sign into it yet. Before handing it over, set its first password with
**Reset password** in Admin → Users; the person can change it, add an email
and enrol a passkey from their settings. The account is made before the
file is read, with an `admin.user.create` audit row (`outcome: "created"`),
and removed again when the file is refused, with an `admin.user.delete` row
(`reason: "backup_upload_refused"`). When that row says `outcome: "failed"`,
the removal did not happen and the account is left over, holding nothing:
delete it in Admin → Users.
- **An existing account**, for someone who has signed up again. Pick the
account and type its username to confirm. The account must hold no records
yet: a restore replaces an account's records and does not merge them, so an
account with even one record of any kind a copy carries is refused, with the
kinds named. What signing up and the setup questions leave behind does not
count (the setup answers and the cycle settings): the restore replaces both
with the copy's. A reading or medication entered on setup's last screen is a
record, and the account is refused until it is deleted, because the restore
would delete it too. Managed profiles, accounts pending deletion and admin
accounts are not offered: an admin account is the operator's, its settings
are read under the operator's wider AI endpoint policy, and its role reaches
every account, so another person's record does not go into it.
A stored copy this instance can no longer open — the key that wrote it dropped
from `ENCRYPTION_KEYS` one rotation too early, or bytes that are not the ones
written — is refused by all three buttons with `422` and
`meta.errorCode = backup.payload.undecryptable`, before anything is read back.
The file is then uploaded a second time, and its owner is changed to the chosen
account as it is read. Nothing else in the file changes. The stored copy is
then that account's copy in every respect but one: its preview, its download
and its restore are the ordinary ones, and **Restore** on its row brings
everything back under the chosen account. The one difference is that it is
stored as `RETARGETED_UPLOAD_<time>` rather than `MANUAL_UPLOAD_<time>`, and its
restore reads the file's settings as the file's author's, never as the
operator's: an AI endpoint on a private address comes back only when this host
grants it to ordinary accounts (`AI_PRIVATE_ORIGINS`), even if the account has
been made an admin since. The erased account's id is not stored, not in
the copy and not in the audit trail.
### What a backup deliberately does not carry
A copy whose own account still exists on this server can only go back into
that account; choosing a target for it is refused. The copy does not carry
what a restore never touches (below), so a returning person signs in fresh,
and the links of a managed profile to its guardians do not come back: a
restored managed profile is an ordinary account.
Once stored, the copy restores like any other: a restore run later, after the
account has gained records, replaces those too.
A file goes into one account only. Its records keep the ids they had in the
erased account, so once it has been restored into one account, restoring the
same file into a second account would write those ids twice. The upload still
stores the second copy, but its restore is refused with
`backup.restore.rows_exist_elsewhere` and rolled back, and nothing changes in
either account. To move the record to another account, delete the first
account (or its records) before restoring into the second.
### What a restore does not touch
Every credential-shaped row is left out, and this is not an oversight to fix:
API tokens, trusted devices, step-up elevations, known devices, clinician share
links, and the account grants behind shared record access. Restoring data is
rolling a record back to a known state. Restoring an authorization is different
in kind, because a grant the owner revoked on Tuesday would come back alive out
of Monday's file with nobody deciding it and neither person told.
links and account grants are not in any copy. A restore on this server leaves
them as they are, so shared access carries on. The reasoning per model is in
`src/lib/export/backup-plan.ts`.
What this means in practice depends on where you restore to.
## Off-host copies
**Onto the same instance.** Nothing changes. The restore replaces the account's
data tables and does not touch grants, tokens or devices, so shared access
carries on exactly as it was. Somebody who had read access before the restore
still has it afterwards, now looking at the restored data.
| Variable | Required | Notes |
| --------------------------------- | -------- | --------------------------------------------------------------------- |
| `BACKUP_ENCRYPTION_KEY` | yes | 64 hex chars or 32-byte base64; not the same as `ENCRYPTION_KEY` |
| `BACKUP_S3_ENDPOINT` | yes | e.g. `https://<account>.r2.cloudflarestorage.com` |
| `BACKUP_S3_BUCKET` | yes | |
| `BACKUP_S3_ACCESS_KEY` | yes | |
| `BACKUP_S3_SECRET_KEY` | yes | |
| `BACKUP_S3_REGION` | no | defaults to `auto` (Cloudflare R2) |
| `BACKUP_RETENTION_DAYS` | no | not read by the app; the number to put in the bucket's lifecycle rule |
| `BACKUP_ENCRYPTION_PREVIOUS_KEYS` | no | retired off-host keys, comma-separated, used for reading only |
**Onto a fresh instance.** None of it comes with the file. Nobody has access to
anybody's record, every API token has to be reissued, every device re-trusted,
and both people have to invite and accept again before sharing works. That is
the fail-safe direction — access lost, never access resumed — and re-consenting
is the right amount of ceremony for handing someone your health record a second
time. Plan for it rather than discovering it.
Objects are named `YYYY-MM-DD/user-<id>.json.enc`: the record as JSON,
gzipped and sealed with AES-256-GCM under `BACKUP_ENCRYPTION_KEY`, with the
object's own name as associated data and the key's id in the header.
The full per-model reasoning lives in `src/lib/export/backup-plan.ts`, where
every excluded model carries a written verdict and a structural test refuses to
let a new model land without one.
The nightly database dump goes into the same bucket, as
`database/<host of APP_URL>/healthlog-<time>-daily.dump.enc` with its manifest
beside it, sealed the same way. The folder keeps the dumps of instances that
share a bucket apart. It is the copy that rebuilds a lost server
([disaster-recovery.md](disaster-recovery.md)).
## Container memory (the nightly off-host job)
**Permissions.** The worker needs `PutObject`, `GetObject`, `ListBucket`,
`DeleteObject` and `AbortMultipartUpload` (R2's Object Read & Write token covers
them). It deletes only two things: the objects of an account that was erased or
wiped, once the grace window has ended, and its own connection-test object. A
deletion the bucket refuses is retried every night and shown in Admin →
Backups → Off-host. The admin wipe of every account deletes nothing in the
bucket.
This is the part that bites, and it bit the nightly job a release after it bit
the weekly one. The job runs inside the app process, so V8's heap limit is the
app's heap limit, and a container capped at 1 GB gives Node a 524 MB old-space
limit by default. A long-lived Next.js server is already holding a large share
of that before the job starts.
**Retention.** The app never expires objects by age. Set a lifecycle rule on
the bucket that covers both the account objects and `database/` (for example,
expire all objects after 30 days); Admin → Backups says when there is none.
The uploader used to build the whole backup JSON as one string, gzipped
that whole string, ran a whole-buffer cipher pass over the result and handed
the finished buffer to a single `PutObject` — four full copies of the record
alive at once. On an account of 445 000 measurements the JSON alone is 242 MB,
and the first configured run took the container down seventeen seconds in with
`FATAL ERROR: Reached heap limit`. Because the job shares the app process, one
account's size restarted the instance for everybody on it.
**Checking it works.** Admin → Backups → Off-host shows, per account, when the
last object landed: fresh, due (one night missed), stale (two missed), never,
or no record yet. A night that uploaded nothing for anybody fails the job with
the bucket's own error. Accounts pending deletion are not uploaded and are not
listed. On the 1st of each month at 04:11, a drill downloads the newest object
of every account, decrypts it and checks its shape and keys. It restores
nothing.
It streams now. The JSON is produced a page at a time, gzip and the cipher
consume it as it arrives, and the object goes up as a multipart upload that
holds two 8 MB parts. What the process holds is fixed by that pipeline's shape
rather than by the size of the record going through it: measured on the same
445 000-measurement account under `--max-old-space-size=450`, the old path
died and the new one finished holding tens of megabytes, writing a 9.1 MB
object that restores to the identical record.
**Rotating the off-host key.** Put the new key in `BACKUP_ENCRYPTION_KEY`, move
the old one to `BACKUP_ENCRYPTION_PREVIOUS_KEYS`, restart. Remove the old key
once the lifecycle rule has expired every object written under it.
One ceiling remains, and it is structural rather than a memory bound: a
multipart upload carries 10 000 parts, so 80 GB is the largest object one
account can produce. Past it the account's backup fails with a clear refusal,
is counted in the run's `offhost_backup_oversized` meta, and the pass carries
on with everybody else.
## Keys a copy needs
### Reading a failed run
A copy carries the database's ciphertext as stored, so it needs the
application keys it was written under, also after a rotation. Keep a retired
`ENCRYPTION_KEYS` entry until no copy you may still restore needs it. **Admin →
Encryption → Keys the backups still need** lists them, the database dumps
included: a dump needs the key that was active when it was taken, for as long
as it is kept on this server or in the bucket. Off-host objects also need
`BACKUP_ENCRYPTION_KEY` (or the previous key that wrote them).
A nightly run that could not upload for **anybody** now fails the pg-boss job
instead of reporting success. This is the case wrong credentials, a missing
bucket and an unreachable endpoint all land in, and the target's own sentence
rides out as the failure cause — `The request signature we calculated does not
match the signature you provided`, `The specified bucket does not exist`,
`connect ECONNREFUSED`. Check `offhost_backup_uploaded` against
`offhost_backup_total_users`: before this change a run where every single
upload failed still read `ok: true`, so a bucket could stay empty while the
jobs page looked healthy.
## Size and memory
A run where SOME account got a copy still succeeds, with the rest counted in
`offhost_backup_failed`. Failing the whole queue over one account's object
would re-upload everybody's on every retry.
Writing, reading and restoring a copy streams: the record goes through a page
at a time, documents one at a time, and a stored copy is kept as sealed pieces
of about a megabyte. Memory does not grow with the record. Give the app
container at least 1 GB.
### Which account has no copy
`BACKUP_MAX_STORED_MB` (default 2048) caps one account's stored copy. An account
past it fails alone, keeps its previous copy, and is counted as
`records_oversized`; the message names the setting.
The counts above say how many accounts were uploaded, never which. An account
whose object has failed every night for a month reads as `99 uploaded, 1
failed` each time, which looks like weather.
**Admin console → Backups → Off-host copies** answers it per account: when this
host last put that account's encrypted copy in the bucket, how big it was, and
how that compares with the nightly schedule. **Fresh** is inside one run,
**due** means one run produced nothing for that account, **stale** means two
did not, **never** means a run walked the account and no object has ever
landed for it, and **no record yet** means no run has recorded the account at
all. On a host without `BACKUP_S3_*` the card says off-host backup is not
configured and stops there.
The ledger starts empty on the release that adds it, so on a host that has
been uploading nightly for months every account reads **no record yet** until
the first run after the upgrade. That is the ledger having no history, not the
bucket being empty — which is why it is a separate word from **never**, and
why the row says so rather than claiming nothing has ever reached the bucket.
Each verdict carries six hours of slack on top of the schedule's own 24, and
that slack is load-bearing on two nights. The cron runs at 02:30 Europe/Berlin,
so on the DST fall-back Sunday two consecutive runs are 25 hours apart; and the
timestamp is when _that account's_ object landed, so on a cohort walked one
account at a time an account reached later tonight than last night drifts by
hours. Without the slack a healthy host would paint its whole cohort **due**
once a year.
The verdicts come from a ledger the worker writes when the object lands
(`offhost_backup_state`), not from a listing of the bucket. Keep the worker's
grant as documented — PutObject, GetObject, AbortMultipartUpload — and the card
still tells the truth. A row that goes stale while the run reports success is
that account's object being refused, and the reason is in the run's
`offhost_backup_failures` meta.
## The weekly in-database backup (`data-backup`)
Separate from the off-host job above, and easy to confuse with it. A second
pg-boss job writes one `WEEKLY_AUTO` copy per user into the database, the same
JSON document, gzipped and then encrypted under `ENCRYPTION_KEY` /
`ENCRYPTION_KEYS`, staying inside the instance. It is what
`/api/admin/backups/<id>/restore` reads, and an uploaded backup file is stored
the same way.
### How a copy is stored
From v1.39.2 a copy is kept as ordered pieces of about a megabyte in
`data_backup_chunks`, with the list of pieces on its `data_backups` row. The
writer produces the JSON a page at a time, gzips it as it goes and seals each
megabyte of gzip output on its own with AES-256-GCM. Reading it back (restore,
preview, download) takes the pieces one at a time. Nothing on either path holds
the whole copy, so the size of a copy has nothing to do with the app's memory.
Measured on an account of 2.6 million readings in the default 1 GB container,
with the web server and the worker in one process: the backup took 87 seconds
and stored 97 MB in 96 pieces, the preview 20 seconds, the download of the
1.4 GB file 25 seconds, and the restore 264 seconds, which gave back every
reading exactly. The container peaked at 567 MB and did not restart. What the
backup, preview and download hold on top of the running app stays at a few tens
of megabytes and does not grow with the account. A 512 MB container is too
small for the app itself: its heap limit is 259 MB and a backup of any account,
even one with 130 000 readings, runs it out of heap.
Each piece carries, inside its encryption, which copy it belongs to, its
position, and whether it is the last. Before a restore deletes anything, every
piece is checked: a piece that is missing, moved, altered, taken from another
copy, or a copy that stops short of its last piece is refused with
`backup.payload.undecryptable`, and the account is left as it was.
The write is one transaction. Until the new copy is complete the previous one
stays in place and readable; a run that fails halfway leaves it untouched. A
second backup of the same account started while one is running (a manual run
during the weekly one, say) waits for the first to finish and then replaces
its copy; neither leaves a partial copy behind.
While a restore of an account's copy is queued or running, the weekly and
manual backup leave that account's copy alone and back up everyone else. The
run's `users_skipped_restoring` meta counts such accounts; the next run
replaces the copy as usual. If a copy is replaced anyway while it is being
read (a download or preview in progress when the weekly run finishes), the
reader answers `409` with `backup_changed` and "The backup was replaced by a
newer one while it was being read", not the undecryptable error, and a restore
rolls back with nothing changed. Start it again to use the new copy. A
download already sending when this happens stops partway; download again.
Copies written before v1.39.2 are a single value in `data_backups.data` and
still restore, preview and download as they did. The next weekly run replaces
the weekly copy in the new form, and a key rotation (below) converts every
remaining one, including uploaded copies. Until then those older copies are
read as one value, as before, which needs the memory they needed when they were
written.
Migration 0352 cannot be undone by going back to v1.39.1. That release reads
only the single value, so it finds a copy in pieces empty, and if it writes a
weekly copy into a row that has pieces, the row holds both forms. v1.39.2 then
refuses to read that row, because nothing says which of the two is current
("This backup holds both a single stored value and pieces ..."); the next
weekly backup replaces it. If you must go back, restore the database backup
you took before upgrading.
### The size limit
The one limit left is on storage: a copy of one account may take at most
`BACKUP_MAX_STORED_MB` megabytes (default 2048). It exists so a runaway copy
fails with a message instead of filling the database volume, which holds the
old and the new copy side by side while a backup runs. An account past it fails
for that account alone, is counted in the run's `users_failed` and
`records_oversized` meta, and keeps its previous copy; the pass carries on with
everybody else. The message names the setting. Raise it in `.env`, recreate the
app container, and make sure the database volume has room for two copies of
that size.
Before v1.39.2 the limit was a fifth of the app's heap, 105 MB in a 1 GB
container, because the copy had to pass through the app as one value. An
account with 1.75 million readings was past it, and the only way round was more
memory. That is no longer the case: `records_oversized` means the storage limit,
never memory.
The writer deliberately reads no heap gauge. A version that did shipped in
v1.38.6 and compared the whole process's live usage, garbage included, against
80 % of the limit. A Next.js server that has been up for a week sits at 400 MB
of largely collectable heap, so the weekly pass aborted every account on the
first chunk, including one whose whole stored copy is 1.2 MB, and reported
success while doing it. If you are on v1.38.6, a `data-backup` run that finishes
in seconds having backed up nothing is that defect and not your record.
### Key rotation
Rotation reaches a stored backup's envelope and nothing inside it. The
content of a disaster-recovery backup is the database's ciphertext, carried
as it is stored, and a restore writes it back verbatim: a note, a document or
a coach message inside a copy taken before a rotation still needs the key the
rotation retired. The same holds for every copy in the off-host bucket and for
every file an operator has downloaded. So the rule is: **keep a retired
`ENCRYPTION_KEYS` entry until no backup you may still restore needs it.**
**Admin → Encryption → Keys the backups still need** lists, per key id, the
stored copies that need it with the oldest date, and when the last off-host
copy needing it expires under the bucket's lifecycle rule. Copies written
before v1.39.3 did not record their keys and are listed as such.
Every restore, the restore preview, the admin upload and the monthly drill
check this before anything is changed: a file whose content needs a key this
server does not have (or an id whose key material differs) is refused with the
key id named, and the account is left as it was.
Re-encrypting the content of stored backups during rotation was considered and
not done: it would mean decrypting, parsing, re-encrypting and re-writing every
copy in full, and it still could not reach the off-host copies or downloaded
files, so the rule above would be needed regardless.
`scripts/rotate-encryption-key.ts` and the rotation in the admin console
re-seal every piece under the active key, in small batches, without reading
the backup inside. The link between a piece, its copy and its position is
inside the encryption, so it comes through rotation unchanged.
A copy still in the older single-value form (any of them, including the
`~hlgcm1.` form v1.38.6 to v1.39.1 wrote) is not re-sealed in place, which would need the
whole copy in memory several times over. Rotation converts it into pieces
under the active key instead, one copy at a time, reading the stored value a
few megabytes at a time; the copy keeps its date. The conversion of a copy is
one transaction, so a copy that fails its check is left exactly as it was and
counted as an error. Rotation up to v1.39.1 counted every `~hlgcm1.` copy
as an error; run it again after updating before retiring the old key.
### What a restore replaces
A restore replaces the account's data tables; it does not merge into them.
Every row the file carries is written back under its original id, and every row
the account gained after the snapshot was taken — readings, doses, mood
entries, documents — is deleted with the rest of the class before the file is
read back. Restoring Monday's copy on Wednesday therefore costs the account
everything it recorded on Tuesday. If the current state is worth keeping, take
a fresh snapshot from the backups console before restoring the old one.
## Rotating the off-host key
`BACKUP_ENCRYPTION_KEY` is the key new objects are written under. To rotate
it, set the new key there and move the old one into
`BACKUP_ENCRYPTION_PREVIOUS_KEYS` (comma-separated if there are several), then
restart. New objects are written under the new key; older objects stay
readable because every version-4 object names its key and older objects are
tried against each key in turn. Remove the old key once the lifecycle rule has
retired every object written under it, that is `BACKUP_RETENTION_DAYS` after
the restart.
## Monthly restore drill (automatic)
Since v1.16.4 a pg-boss job (`data-restore-drill`, cron `11 4 1 * *` —
04:11 on the 1st of each month) exercises the read path end-to-end:
fetch the most recent backup object from the bucket, decrypt it under
the current `BACKUP_ENCRYPTION_KEY`, JSON-parse it, and sanity-check
the payload shape. It performs **no database restore** — it validates
the artefact, not the import path.
Outcomes:
- **Success** — record counts, object age, and sizes land in the
wide-event meta (`job.restore_drill`).
- **Stale chain** — the newest object is older than 3 days: the nightly
uploader has stalled (or the lifecycle rule is too aggressive). The
drill pages via the worker error reporter (stderr + GlitchTip).
- **Failure** — empty bucket, fetch error, decryption failure (wrong or
rotated key), malformed JSON: pages the same way. A decryption
failure right after a `BACKUP_ENCRYPTION_KEY` change means the new
key cannot read the existing objects: put the old key into
`BACKUP_ENCRYPTION_PREVIOUS_KEYS` (see "Rotating the off-host key").
- **Content under a missing application key** — the object opens, but its
content was written under an `ENCRYPTION_KEYS` entry this server no longer
has, or under an id whose key material changed. The drill decrypts one value
per key id it finds, and pages naming the key. A restore of that object
would write back rows nobody can open; put the key back before restoring.
- **Not configured** — deployments without the `BACKUP_S3_*` vars skip
silently (wide-event warning only).
The drill lists the bucket to find the newest object, so it needs
`ListBucket` on top of the uploader's `PutObject`, `GetObject` and
`AbortMultipartUpload`. Cloudflare R2's **Object Read & Write** token covers
all four.
Both backup passes note which account they are on. An account whose last
attempt never finished (the process died under it) is taken last on the next
run and named in Admin → Backups, so one record too large for the container
cannot keep everyone else from a copy.
@@ -0,0 +1,60 @@
# Blood pressure readings missing their diastolic half
## What happened
The iPhone app (every App Store build up to 1.0.3 and TestFlight 1.1.0)
saves a manually entered blood pressure as two requests to
`POST /api/measurements`: one `BLOOD_PRESSURE_SYS`, one
`BLOOD_PRESSURE_DIA`, both carrying the same `Idempotency-Key`. Until the
replay cache learned to compare request bodies, the server treated the second
request as a retry of the first. It answered with the first request's cached
`201` (header `X-Idempotent-Replay: true`) and never wrote the diastolic
value. The app showed success, so nobody noticed.
The fix makes the cache replay only a request with the same body. A different
body under the same key now runs normally. Nothing recovers the values that
were never sent to the database: they exist only on the person's phone, if at
all.
## Finding the affected readings
Read-only. Run it against the HealthLog database (with the bundled compose
file: `docker compose exec db psql -U healthlog healthlog`). It lists every live systolic reading that has no
live diastolic reading for the same account at the same instant.
<!-- bp-missing-diastolic:start -->
```sql
SELECT u.username,
m.id,
m.measured_at,
m.value AS systolic,
m.source
FROM measurements m
JOIN users u ON u.id = m.user_id
WHERE m.type = 'BLOOD_PRESSURE_SYS'
AND m.deleted_at IS NULL
AND NOT EXISTS (
SELECT 1
FROM measurements d
WHERE d.user_id = m.user_id
AND d.measured_at = m.measured_at
AND d.type = 'BLOOD_PRESSURE_DIA'
AND d.deleted_at IS NULL
)
ORDER BY u.username, m.measured_at;
```
<!-- bp-missing-diastolic:end -->
Readings from the phone app carry `source = 'MANUAL'`. Rows from a synced
source (Withings, Apple Health) that show up here are a different gap, not
this one: those sources write both halves themselves.
## What to tell users
There is nothing the server can repair: the diastolic value never reached
it. A person whose reading appears in the list can re-enter it: delete the
incomplete reading, then add the blood pressure again with both values and
the original date and time. From this release on, both halves are saved on
the first try, from any app version.
+302 -29
View File
@@ -13,6 +13,14 @@ Migrations land **before** the new app container takes traffic. The
canonical command sequence on a single-instance host:
```sh
# 0. Nothing to do by hand for the way back: when the new image brings
# migrations, its entrypoint first writes
# healthlog-<time>-pre-migration.dump into DB_BACKUP_DIR (the `backups`
# volume) and keeps the last two. A migration is not undone by going back
# to the previous image; that dump is the way back
# (docs/ops/disaster-recovery.md, "Going back to before a migration").
# Copy it off the host if the host itself is what you are worried about.
# 1. Pull the new image. If you pin a version with HEALTHLOG_IMAGE_REF,
# set it to the new one first.
docker compose pull app
@@ -27,6 +35,15 @@ docker compose run --rm app sh -c 'exit 0'
docker compose up -d app
```
Step 2 takes the pre-migration dump too, in the one-off container, before it
migrates. When the backups directory is on its volume and that dump fails (a
full disk, most often), the entrypoint stops before migrating and says so;
free the space and run the step again, or set `DB_BACKUP_BEFORE_MIGRATE=false`
for one start without the dump. Without the `backups` volume the directory
is not writable, or it is the image's own and sits inside the container that
the update replaces; either way the log carries a warning, no dump is taken,
and the migrations run.
Use `docker compose run` rather than a plain `docker run` for step 2. A
plain `docker run` starts outside the stack's network, where `db` does not
resolve, and the entrypoint gives up after thirty seconds with "Database
@@ -34,6 +51,191 @@ not reachable". The entrypoint also runs the migrations on every boot, so
step 2 is optional; it is there for when you want to see the migration
result before the new container takes traffic.
The entrypoint runs every migration in a UTC session, the zone the app's own
connections use, whatever the database's default `TimeZone` is: it adds
`-c TimeZone=UTC` to the `options` parameter of `DATABASE_URL` for the Prisma
calls (an `options` value you set yourself is kept, with the pin after it).
Behind a connection pooler that refuses `options`, the
`DATABASE_SESSION_OPTIONS_DISABLED` switch turns this off along with the
app's own session settings; set the database or role default to UTC there
(`ALTER DATABASE healthlog SET timezone = 'UTC'`).
### A migration that timed out on a lock
Some migrations bound how long they wait for a table lock, so that a
long-running holder (most often an autovacuum that is preventing
transaction-ID wraparound, which does not step aside) cannot queue every
write to that table behind the migration. Migration `0412` waits at most ten
seconds for `measurements`. When the wait runs out, the boot log shows
Prisma's report followed by a line from the entrypoint:
```
Error: P3018
...
Migration name: 0412_measurement_hot_updates
Database error code: 55P03
...
HealthLog: migration 0412_measurement_hot_updates timed out waiting for a lock (55P03) and changed nothing; marking it rolled back and retrying in 15s (retry 1 of 5).
```
A marked migration that loses a deadlock against an app transaction
(`Database error code: 40P01`) is in the same state, its block rolled back
whole, and is handled the same way; the entrypoint's line then says
`was cancelled in a deadlock (40P01)`.
What happens on its own: such a migration is written so that a timed-out
attempt changes nothing, and it says so in its file
(`-- @retry-on-lock-timeout`). The entrypoint then marks that one migration
rolled back (`prisma migrate resolve --rolled-back`) and runs
`migrate deploy` again after 15, 30, 60, 120 and 240 seconds. The app starts
as soon as an attempt gets the lock. A later boot that finds the attempt still
on record (Prisma's `P3009`) reads the cause from `_prisma_migrations` and
does the same. Nothing else is retried: any other failure, and a lock timeout
in a migration that does not carry the marker, stops the boot exactly as
before.
The retries fit inside the container's health check: its start period is ten
minutes (`docker-compose.yml` and the image's own `HEALTHCHECK`), so failed
probes while the entrypoint is still migrating do not mark the container
unhealthy. The first probe that answers ends the start period, so a normal
boot is reported healthy within half a minute of the server listening.
If all five retries time out, the log ends with
`HealthLog: ERROR — migration … timed out on a lock 6 times in a row; giving
up.` and the container exits. Something has held the table for about eight
minutes. Find it:
```sh
docker compose exec db psql -U healthlog -c "
SELECT pid, backend_type, state, now() - xact_start AS running, query
FROM pg_stat_activity
WHERE pid IN (SELECT pid FROM pg_locks
WHERE relation = 'measurements'::regclass)"
```
An `autovacuum: VACUUM … (to prevent wraparound)` row finishes on its own;
wait for it to disappear and start the container again (a restart policy does
that for you, and each boot retries). Anything else is a session you can
judge: a stuck client may be ended with `SELECT pg_terminate_backend(<pid>)`.
You never need to run `migrate resolve` by hand for this case.
### A concurrent index build that failed
A migration that builds an index `CONCURRENTLY` (0477 is one) and fails
half-way, on a constraint violation, a cancel or a killed container, leaves an
INVALID index behind and its attempt on record as failed. Once you have mended
the cause and run `prisma migrate resolve --rolled-back <migration>`, the next
boot drops that INVALID index before it migrates, logs
`dropped the invalid index …`, and the re-run builds it again. Without that
step the re-run would find the index by name and skip it.
A build that is still running looks the same, INVALID with its migration not
yet finished. The boot therefore leaves every index alone while another
instance is migrating (it checks Prisma's migration lock first) and never
drops an index Postgres reports as being built, so a second container that
starts during the first one's migration cannot cancel it.
An INVALID index whose migration is already recorded applied is not touched at
boot. The boot names it on stderr with `WARNING — index … is INVALID`; rebuild
it with `REINDEX INDEX CONCURRENTLY "<index>";` when the database is quiet.
### The app's database role
The steps above run as the owner `DATABASE_URL` names: the dump, the
migrations and, after them, the job queue's own install or migration and the
setup of `healthlog_app_<database>_<hash>`, the role the app then connects as
([database-role.md](../self-hosting/database-role.md)). Neither the app nor
its queue ever runs a migration. A migration that creates a table needs
nothing more: the setup grants the role every table it lacks before the app
starts.
The queue schema `pgboss` stays the owner's. When an update brings a newer
pg-boss with a schema migration, the start runs it as the owner, including
its index builds, before the app starts; on a large queue that start takes as
long as the builds. Stop the old container (and a separate worker) before the
new one starts, as for any update with migrations. A web container and a
worker container that start together wait for each other on an advisory lock
and derive the same password. Going back to 1.46 runs as the owner, as 1.46
always did, and finds nothing in the queue schema it did not create.
### v1.46.0 notes
- **Postgres 18.** The compose file moves the bundled database from
Postgres 16 to 18. The first `docker compose up -d` with the new compose
file runs `db-upgrade`, which copies the database into a new
`postgres-data` volume, checks every table's row count, and only then lets
the app start; the old `pgdata` volume stays as it was, as the way back.
Pulling only the new image with the old compose file keeps 16, which this
release still runs on. Details, rollback and the steps for a database you
run yourself: [postgres-major-upgrade.md](postgres-major-upgrade.md).
- Migration `0485` drops `preview_features_json` from `users`, the same
pattern as `0430` in v1.44.0 below: do not run the previous image beside
the new schema. The sequence above stops the old container first; a
web/worker split stops both roles before the first new container starts.
- Migration `0487` drops four columns from `app_settings`
(`encryption_key_backup_confirmed_at`, `_key_id`, `_fingerprint` and
`_by_user_id`) after carrying the confirmation they held into the new
`recovery_kit_confirmations` table. The previous image reads those columns
on nearly every request that loads the instance settings and fails once
they are gone, so the same rule holds: never run it beside the new schema.
- The other migrations create tables (`0486`, `0487`), add columns
(`0480`, `0482`, `0483`, `0488`, `0489`) and build two indexes: `0477`
concurrently on `measurements`, `0488` without `CONCURRENTLY` on `users`,
which is small enough that the short lock does not matter. Three touch
rows: `0476` links API-written measurements to their token on accounts
that have exactly one measurement token, and `0478`/`0479` give PDFs whose
preview or text read failed one more attempt. On a prod-sized copy all of
them together finished in under a second.
- **Rolling back to v1.45 while accounts are pending deletion.** v1.46 keeps
a deleted account, a deleted managed profile and a data wipe's copy for a
grace window (`0488`). v1.45 knows none of that: an account pending
deletion can sign in again with its password and every background job runs
for it again, nothing ever erases it, a wipe's off-host purge held to the
end of its window runs at once, and v1.45's wipe deletes the copies taken
before earlier wipes. Before going back, list the accounts with
`SELECT id, username, deletion_purge_at FROM users WHERE deletion_requested_at IS NOT NULL;`
and either undo each deletion in the admin users list or let the purge
finish them first; after coming forward again, delete any that should
still go.
### v1.45.0 notes
A single-container install (`HEALTHLOG_PROCESS_TYPE=all`, the shipped
compose file) needs no action: the sequence above applies the one migration
and the job-queue upgrade on the first boot.
- Migration `0471` adds one nullable column to `healthkit_type_syncs`. It is
additive and rewrites no rows.
- The job queue library moves its own schema from version 45 to 46 (new
columns on its queue table, four indexes built concurrently afterwards).
Only a worker process runs that upgrade; a web-only process opens its
send-only connection without migrating and refuses a schema older than
its own (`pg-boss database requires migrations`), so it cannot queue
anything until the upgrade has run. A web/worker split therefore deploys
in this order: stop both roles, start the new **worker** and wait until its
startup log line carries `"reminder_worker": "started"`, then start the new
web containers.
The other order leaves the web containers without a queue (imports and
document reads answer `503 Background worker is not running`) until they
are restarted after the worker. An old web container left running beside
the upgraded schema loses its queue the next time it reconnects.
- The shipped compose file gives the app `stop_grace_period: 45s`, so a
redeploy lets running jobs finish instead of cutting them off after
Docker's default ten seconds. A forked or older compose file should add the
same line to the app and worker services.
### v1.44.0 notes
Migration `0430` drops the `insights_cached_text` column from `users`. A
process still running the previous image selects that column on every full
user read and fails once it is gone, so do not run old and new containers side
by side across this release: stop the old app container, let the new one
apply the migrations, then start it (the sequence above does this on a single
host). A web/worker split deployment stops both roles before the first new
container starts. Rolling back to an image older than v1.44.0 needs the column
back first (see Rollback below).
### v1.11.0 notes
Migrations `0110`–`0114` are additive (new tables / columns for the
@@ -74,10 +276,47 @@ roll back:
app version reads forward-compatible-ish shapes for nearly every
release pair shipped on the v1.4.x line.
3. If a column-drop migration ships in the rollback target, hand-roll
the column back via a fresh forward migration (`pnpm prisma migrate
dev --create-only --name vX_Y_Z_rollback_<col>`), edit the SQL,
commit, and re-deploy. Never `migrate resolve --rolled-back` against
production unless you've taken a fresh `pg_dump` first.
the column back via a fresh forward migration
(`pnpm prisma migrate dev --create-only --name vX_Y_Z_rollback_<col>`),
edit the SQL, commit, and re-deploy. Never `migrate resolve --rolled-back` against
production unless you've taken a fresh `pg_dump` first. The columns each
release drops are named in its notes above: for v1.46.0 that is
`users.preview_features_json` (`0485`) and the four
`app_settings.encryption_key_backup_confirmed_*` columns (`0487`); the
key backup confirmation then has to be given again on v1.45.
4. Rolling back from v1.40.2 or later to v1.40.1 or earlier: an account
that saved the Coach lookback "Last year" reads its whole Coach
preference blob as defaults on the older version, metric exclusions
included, until the setting is saved again. Ask affected accounts to
pick another lookback before the rollback, or re-save their exclusions
after it.
When the data itself has to go back to before the update, restore the
pre-migration dump with the previous image; the steps are in
[`disaster-recovery.md`](disaster-recovery.md#going-back-to-before-a-migration).
## Catalog templates
The TrueNAS catalog bot follows new image tags on its own, so a routine
release needs nothing there. These changes do need a matching pull request to
`truenas/apps` (`ix-dev/community/healthlog/`) in the same release cycle, and
an update to `docs/self-hosting/unraid/healthlog.xml` and
`docs/self-hosting/portainer/templates.json`:
- a new required environment variable, or a new format or minimum for an
existing one (the form validates the secrets);
- a changed listening port, health check path or health semantics, or start
time beyond the template's five-minute start period;
- a required PostgreSQL major version, or an extension the stock `postgres`
image does not ship;
- any other change to the compose shape: a new container, a volume the app
must write to, a different user.
The catalog app sets `HEALTHLOG_PLATFORM=truenas`, `PORT`, `HOSTNAME`,
`DATABASE_URL`, `ENCRYPTION_KEY`, `API_TOKEN_HMAC_KEY`, `APP_URL`,
`NEXT_PUBLIC_APP_URL` and `SESSION_COOKIE_SECURE`; everything else reaches it
through the form's additional environment variables. See
`docs/self-hosting/truenas.md` for the operator side.
## Verify the served version
@@ -89,13 +328,31 @@ 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.
The worker loads each job's handler module on that job's first run, so a
module the image cannot load does not stop the worker from starting. Once
it has bound its queues, the worker loads every handler a single time in
the background and reports each one that fails, normally within a minute
of start. Each failure is a `job.run.failed` event at `error` with
`reason: "handler_load_failed"` and the queue's name, and stderr also gets
`[pg-boss] [registry] preloaded N/M job handlers; failed to load: <queues>`.
If either shows up after a deploy, the image is broken for those queues,
and their jobs will fail when they run. Roll back (see Rollback above)
instead of waiting for the retries to run out:
```sh
docker compose logs --since 5m app 2>&1 | grep -E 'handler_load_failed|failed to load:'
```
## Walk the synthetic journey
The version check proves the instance serves the right build. It does not
@@ -310,29 +567,45 @@ low-friction default.
## Deploy-status webhook: replay protection
`POST /api/internal/deploy-webhook` authenticates via the static
`X-Deploy-Webhook-Secret` header. Since v1.16.4 it additionally
enforces a freshness window: a request carrying an
`X-Deploy-Webhook-Timestamp` header (unix seconds, unix milliseconds,
or ISO-8601) is rejected with `401 {"status":"stale"}` when the
timestamp lies more than **5 minutes** from server time — so a captured
request cannot be replayed later to re-page admins or forge deploy
outcomes in the audit log.
`POST /api/internal/deploy-webhook` accepts two kinds of request.
Two modes:
- **Secret header.** `X-Deploy-Webhook-Secret` carries the shared secret.
This is what Coolify's stock notification sender can do. It does not
protect against a replay: anyone who captures one request holds the
secret and can send any request they like. An `X-Deploy-Webhook-Timestamp`
beside it is still checked (a stale or unparseable one is refused with
`401 {"status":"stale"}`), but it is covered by nothing, so treat it as a
staleness hint. Versions before 1.46 described it as replay protection.
- **Signature.** `X-Deploy-Webhook-Signature: sha256=<hex>` is the
HMAC-SHA256, under `DEPLOY_WEBHOOK_SECRET`, of the
`X-Deploy-Webhook-Timestamp` value, a dot, and the exact request body. The
timestamp (unix seconds, unix milliseconds or ISO-8601) must lie within
**5 minutes** of server time. A signed request sends no secret, and a
captured one is useless once the window has passed.
- **Default (tolerant).** Requests _without_ the header pass — Coolify's
stock notification sender does not attach one. A header that is
present but stale or unparseable is always rejected.
- **Strict.** Set `DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP=true` in the app
environment to make the header mandatory, closing the replay window
completely. Use this when the sender (or a reverse proxy in front of
the app) can attach the timestamp, e.g.:
Two settings tighten this:
```sh
curl -X POST https://<host>/api/internal/deploy-webhook \
-H "X-Deploy-Webhook-Secret: $SECRET" \
-H "X-Deploy-Webhook-Timestamp: $(date +%s)" \
-H "Content-Type: application/json" \
-d '{"status":"success"}'
```
- `DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP=true` requires a fresh
`X-Deploy-Webhook-Timestamp` on a request that uses the secret header; one
without it is refused with `401 {"status":"stale"}`. This is what the
setting has always meant. It keeps an old capture out, but not someone who
saw the secret and sends a new request with a new timestamp.
- `DEPLOY_WEBHOOK_REQUIRE_SIGNATURE=true` accepts signed requests only; the
secret header alone is refused with 401. This is the one that stops a
replay. Use it when the sender, or a small relay in front of the app, can
sign:
```sh
TS=$(date +%s)
BODY='{"status":"success"}'
SIG=$(printf '%s.%s' "$TS" "$BODY" \
| openssl dgst -sha256 -hmac "$SECRET" | sed 's/^.*= //')
curl -X POST https://<host>/api/internal/deploy-webhook \
-H "X-Deploy-Webhook-Timestamp: $TS" \
-H "X-Deploy-Webhook-Signature: sha256=$SIG" \
-H "Content-Type: application/json" \
-d "$BODY"
```
Both may be set; a signed request satisfies both. An operator who set the
timestamp flag before 1.46 keeps exactly the behaviour they had.
+240
View File
@@ -0,0 +1,240 @@
# Disaster recovery
The copy that rebuilds a lost server is the database dump. This page says
what is dumped and where it goes, which keys a restore needs, how to restore
onto a new server, and how the procedure is tested.
## What is backed up, and where
| Copy | When | Where | Kept |
| ----------------------- | --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- | ------------------------------- |
| Nightly dump | 01:30 Europe/Berlin, the zone every schedule runs in (`DB_BACKUP_SCHEDULE`) | `DB_BACKUP_DIR`, the `backups` volume of the bundled compose file | the newest 7 (`DB_BACKUP_KEEP`) |
| Pre-migration dump | on start, when the new image brings migrations, before them | the same directory | the newest 2 |
| Off-host copy of a dump | right after each nightly dump, if the bucket is configured | your bucket, under `database/<host of APP_URL>/`, sealed with `BACKUP_ENCRYPTION_KEY` | your bucket's lifecycle rule |
A dump is `pg_dump --format=custom` of the whole database: every account,
sign-ins, passkeys, sharing grants and managed profiles, documents, the job
queue and the instance settings. It is two files:
- `healthlog-<UTC time>-<daily|pre-migration>.dump`, the archive;
- `healthlog-<UTC time>-<daily|pre-migration>.manifest.json` beside it: the
app version and build, the newest applied migration, the Postgres version,
the archive's size and SHA-256, the row count of every table, and the
fingerprints (never the values) of the keys the database needs.
A dump counts as good only when both files exist under their final names. The
archive is written as `.partial`, read back with `pg_restore --list`, and
renamed after its manifest; a run that fails leaves nothing that looks good.
The admin backups page shows the newest good dump, where it is, and the
newest failed run. The start log says on every boot where dumps go, or that
none can be kept.
Local dumps are not encrypted. They sit beside the database volume, in the
same trust zone: whoever can read one volume can read the other. The columns
HealthLog encrypts stay encrypted inside the dump, under `ENCRYPTION_KEYS`.
Keep a copy of the dumps off the host; the off-host bucket does that for you,
and it seals each copy with `BACKUP_ENCRYPTION_KEY` in the envelope the
per-account copies use.
A dump holds every account that existed when it was taken, including one
deleted since. Deleted accounts leave the dumps as they age out: after
`DB_BACKUP_KEEP` nights on the server, and after your lifecycle rule in the
bucket. A pre-migration dump goes once it is older than every nightly dump
kept, except the one taken before the latest update: that one is the way back
from it and stays until the next update writes a newer one. On an install
that rarely updates, delete it by hand once you no longer want the way back,
or state that exception with the retention period. Give that rule a prefix that covers `database/` too; the admin
backups page reads the bucket's rules and says when none does, and the
recovery readiness card calls it attention and tells the admins once. This is the
retention period to state where you tell people that deleting their account
deletes their data.
A dump is kept on the server only when `DB_BACKUP_DIR` survives the container
being replaced: a mounted volume or bind mount, or any writable directory when
HealthLog does not run in a container. The image creates the default
directory, so an install that runs it without the `backups` volume can write
there, but the directory sits in the container's own layer and the next update
throws it away. HealthLog reads the container's mount table to tell the two
apart and treats such a directory, or one on a tmpfs, like one it cannot
write: the nightly dump is uploaded off-host and nothing is kept on the
server, and no dump is taken before migrations; with no bucket either, no
dump is taken at all. The start log, the admin backups page and the recovery
readiness card say so.
The per-account copies (the weekly in-database copy, the nightly per-account
objects) roll one account back on the same server. They cannot rebuild a
server; see [`backup-restore.md`](backup-restore.md).
## The keys a restore needs
| Key | Without it |
| ------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
| `ENCRYPTION_KEYS` + `ENCRYPTION_ACTIVE_KEY_ID`, or `ENCRYPTION_KEY` | Nothing encrypted can be read. The restore refuses when the active key is missing or different. Keep every retired id still in the map. |
| `API_TOKEN_HMAC_KEY` | No data is lost, but every session, app token and share link stops working; people sign in again and re-create tokens. |
| `BACKUP_ENCRYPTION_KEY` (and `BACKUP_ENCRYPTION_PREVIOUS_KEYS`) | Off-host copies do not open. |
| `POSTGRES_PASSWORD` | Not needed: a new server can use a new one. |
The restore compares the keys in its environment with the fingerprints in the
manifest before it writes anything, and names each key that is missing or
different.
## Restore onto a new server
1. Install Docker and take the `docker-compose.yml` of the version that wrote
the dump or a newer one. The manifest's `appVersion` names it; to run that
exact version set `HEALTHLOG_IMAGE_REF=:v<version>` in `.env`.
2. Write `.env` with the keys from your records (see above) and a new
`POSTGRES_PASSWORD`.
3. Do not run `docker compose up` yet. The app would create an empty schema,
and the restore only writes into a database with no tables. Start only the
database:
```bash
docker compose up -d db
```
4. Restore. When the host is lost, its local dumps went with it, and the
copy is the one in your bucket: put the `BACKUP_S3_*` variables and
`BACKUP_ENCRYPTION_KEY` (with any `BACKUP_ENCRYPTION_PREVIOUS_KEYS`) in
`.env` as on the old server, then
```bash
docker compose run --rm db-restore list --from-bucket
docker compose run --rm db-restore latest --from-bucket
```
`latest --from-bucket` downloads the newest dump of this instance whose
manifest is in the bucket too, opens both with `BACKUP_ENCRYPTION_KEY`
and restores. Name a dump instead of `latest` to take an older one.
Each instance uploads under its own folder, `database/<host of APP_URL>/`,
so set `APP_URL` as on the old server. A bucket shared with another
instance holds its dumps too: `latest` refuses rather than take one of
those, and `list --from-bucket` marks them. To restore one on purpose
(after moving to a new domain, the old one), add
`--instance=<old host>`, or name the dump by its full key,
`database/<host>/healthlog-….dump.enc`.
The opened copy is a whole database, decrypted, in a scratch directory
beside the backups. The restore removes it when it ends, on Ctrl-C and
`docker compose stop` too. One left by a restore that was killed outright
is removed when the next restore starts, and by the nightly retention.
With the files in hand instead (copied off the host, or downloaded from
the bucket with your own tools: both `<name>.dump.enc` and
`<name>.manifest.json.enc`, or a `.dump` with its `.manifest.json`), put
them in a directory such as `./restore`, make them readable for the
container (`chmod a+r restore/*`) and name the dump:
```bash
docker compose run --rm -v "$PWD/restore:/restore:ro" db-restore list /restore
docker compose run --rm -v "$PWD/restore:/restore:ro" db-restore \
/restore/healthlog-20261024T233000Z-daily.dump
```
Either way it checks the archive against the manifest's SHA-256, the keys
against the manifest's fingerprints, and that the database is empty. Then
it restores in one transaction, gathers planner statistics and counts
every table against the manifest. A count that differs empties the
database again and fails. Wait for the line that starts with `Restored`.
5. Start the stack and check it:
```bash
docker compose up -d
curl -s http://localhost:3000/api/health
```
Sign in. The app's own key check runs at start and refuses to serve on a
key that does not open the data.
The restore runs as the owner `DATABASE_URL` names, and the restored tables
belong to it. The app connects as its own role,
`healthlog_app_<database>_<hash>`
([database-role.md](../self-hosting/database-role.md)): the start in step 5
grants that role the restored tables, the queue's included, before the app
runs; the queue schema stays the owner's. A dump carries grants to the role;
`db-restore` leaves them out (`--no-owner --no-privileges`). Restoring with `pg_restore` by hand, pass
the same two flags, or it fails on a database without the role.
When the dumps are still in the server's `backups` volume (the database
volume was lost, the backups volume was not), step 4 is
`docker compose run --rm db-restore latest`.
`--ignore-key-mismatch` restores although the active encryption key differs.
Use it only when that key is lost for good: everything encrypted stays
unreadable.
## Going back to before a migration
The entrypoint dumps the database before it applies migrations. To return to
that state (the previous image, the data as it was before the update):
```bash
docker compose stop app
# Keep the current state too, in case it is wanted later.
docker compose exec -T db pg_dump -U healthlog -Fc healthlog > healthlog-current.dump
docker compose exec db dropdb -U healthlog healthlog
docker compose exec db createdb -U healthlog healthlog
docker compose run --rm db-restore list
docker compose run --rm db-restore healthlog-<time>-pre-migration.dump
# Set HEALTHLOG_IMAGE_REF in .env to the version you ran before the update.
docker compose up -d
```
The dump before a migration is taken while the new image starts. If it fails
on a directory that keeps it (a full disk, most often), the start stops before
anything is migrated and the log names the switch: `DB_BACKUP_BEFORE_MIGRATE=false`
starts without it, once.
## Knowing the copies are there
Nobody opens the backups page until the day it matters, so the server tells
the admins instead:
- The **Recovery readiness** card, on the admin overview and on Admin,
Backups, judges every copy in one verdict each: the database dump (fresh,
failed, or kept nowhere that survives the container), the weekly account
copies, the off-host copies, the last restore drill, whether the bucket is
set up, whether its lifecycle rule ever removes the dumps under
`database/`, and the recovery kit.
- The **recovery kit** under Admin, Encryption lists every key a restore
needs (the table above), by id and fingerprint, and lets an admin check a
saved copy and confirm it. It is due again after any key change.
- An hourly check sends each admin a **Backup health** notification
(`BACKUP_HEALTH`) when a signal on the card turns to needs attention or at
risk, a reminder once a day while one stays at risk, and one more when it
recovers. Each admin chooses the channels for it under Notifications.
[Getting started](../self-hosting/getting-started.md#save-the-recovery-kit)
walks through saving the kit.
## How this is tested
`scripts/compose-dr-drill.sh` plays this page against real containers: it
builds the image, seeds several accounts (the demo account with documents
and encrypted notes, a Guardian with a managed profile), lets the scheduled
job take the dump and upload it to an S3-compatible bucket, destroys every
volume of the stack (the local dumps included), and restores on a new stack
given only the bucket and the keys
(`docker compose run --rm db-restore latest --from-bucket`). A second run
restores from the dump files instead (`DRILL_SOURCE=local`). Both then count
every table against the manifest and boot the app: health, sign-in, an
encrypted note read back, a document opened, the Guardian still seeing the
profile. It runs in CI nightly and on every change to the
compose file, the image or the dump and restore code
(`.github/workflows/compose-dr-drill.yml`), and it is checked to fail on a
wrong encryption key, a wrong off-host key, a dump that lost a table's rows
and a target that is not empty.
Test your own copies too: once, and after any change to how the server is
run, restore last night's dump on a spare machine with the steps above.
## Settings
| Variable | Default | What it does |
| -------------------------- | ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
| `DB_BACKUP_ENABLED` | on | `false` stops the nightly dump. |
| `DB_BACKUP_DIR` | `/var/lib/healthlog/backups` | Where dumps are written and read. Change it only together with the volume mount. |
| `DB_BACKUP_SCHEDULE` | `30 1 * * *` | Five-field cron, Europe/Berlin time like every HealthLog schedule. |
| `DB_BACKUP_KEEP` | `7` | Nightly dumps kept (at least 1). Up to two pre-migration dumps are kept besides, none older than the oldest nightly dump except the newest. |
| `DB_BACKUP_BEFORE_MIGRATE` | on | `false` starts an update without the dump before its migrations. |
+18 -6
View File
@@ -43,12 +43,12 @@ under `v1` (the synthetic id assigned to the existing `ENCRYPTION_KEY`).
retained.
3. **Re-encrypt the stored data under the new key.** Open **Admin →
Encryption** and press **Rotate now**. The button needs an admin account
with a second factor, confirmed recently; it queues one background run
with a second factor or a passkey, confirmed recently; it queues one background run
(`POST /api/admin/encryption/rotate`) on the app's own worker, so it
works on the published image with nothing else installed. The same page
shows the per-column counts and the result of the last run.
Without a second factor on any admin account, run the rotation script
Without a second factor or a passkey on any admin account, run the rotation script
instead. It imports the application code and the generated database
client, so it runs from a source checkout of the same release, not
inside the production container (the image has no package manager and
@@ -103,6 +103,13 @@ under `v1` (the synthetic id assigned to the existing `ENCRYPTION_KEY`).
a full `Columns walked` line. Both read the same registry the rotation
walks.
A run that walked every column without an error also removes the boot
key check's record of each previous key id that no longer holds a single
value (`encryption_key_canaries`); the script prints which ids it removed
and, for an id that still holds values, the columns that do. With the record
gone, the id can later carry a different key without the start-up check
refusing it.
A zero only means "nothing left" when the run also says it walked every
registered column. A zero from a run that skipped columns means "never
looked", and dropping the old key on it makes those rows permanently
@@ -130,10 +137,15 @@ under `v1` (the synthetic id assigned to the existing `ENCRYPTION_KEY`).
> So before step 4, also check **Admin → Encryption → Keys the backups still
> need**. It lists, per key id, the stored copies that need it with the oldest
> date, and when the last off-host copy needing it expires under the bucket's
> lifecycle rule. Keep the old key in `ENCRYPTION_KEYS` until it is no longer
> listed there (the weekly copy replaces itself within a week; delete old
> uploaded copies you no longer want, and wait out the off-host retention), or
> accept that those copies cannot be restored. Copies written before v1.39.3
> lifecycle rule. The database dumps are listed too: a dump is restored only
> with the key that was active when it was taken (`db-restore` refuses
> without it), so the old key stays listed while a nightly or pre-migration
> dump taken under it is on this server, and until the bucket's rule for
> `database/` has expired the off-host copies. Keep the old key in
> `ENCRYPTION_KEYS` until it is no longer listed there (the weekly copy
> replaces itself within a week; delete old uploaded copies you no longer
> want, and wait out the dump and off-host retention), or accept that those
> copies cannot be restored. Copies written before v1.39.3
> did not record their keys and are listed as such: treat them as needing
> every key that existed when they were written.
>
+48
View File
@@ -0,0 +1,48 @@
# Heap diagnostics
A Node process that reaches its heap limit stops with `JavaScript heap out of memory` and writes nothing else: no stack, no record of what it was doing. HealthLog therefore logs its own heap while it is still alive.
## What is always on
Two background wide events, no configuration needed:
- `runtime.heap.sampled` (info), every five minutes, and every minute while the heap is at or above 70 % of its limit. It goes to stdout and Loki but not to Admin > App logs, so it cannot push other events out of that page's 500-entry buffer. It carries `heap_used_mb`, `heap_total_mb`, `heap_limit_mb`, `heap_used_ratio`, the used size of each heap space (`new_space_mb`, `old_space_mb`, `code_space_mb`, `large_object_space_mb`, `new_large_object_space_mb`, `code_large_object_space_mb`, `trusted_space_mb`, `read_only_space_mb`, and `other_spaces_mb` for any space not named), `rss_mb`, `external_mb`, `array_buffers_mb`, `active_requests`, `active_jobs`, `in_flight_dropped` and `uptime_s`. The spaces add up to `heap_used_mb`: a large buffer or string (a profile picture held in memory, a long response body) shows in `large_object_space_mb`, not in `old_space_mb`, and memory outside the V8 heap shows in `external_mb` and `array_buffers_mb`.
- `runtime.heap.pressure` (warn), the first time the heap reaches 85 % of its limit. Besides the figures above it lists the 50 oldest requests and jobs in flight (`in_flight_oldest`: kind, method, route, age in seconds, whether it was a server action) and the routes most of them share (`in_flight_by_route`). Routes are reduced to their shape: no query string, secret path segments replaced by `[REDACTED]`, ids by `:id`, anything unusual by `:x`. It writes again only after the heap has dropped below 70 % and thirty minutes have passed. One exception: when the heap reaches 95 %, the same report is written again at once, with `pressure_ratio: 0.95`, whatever the cooldown; that tier re-arms as soon as the heap is back under 85 %. A heap that climbs to its limit shortly after an 85 % report therefore still leaves a report naming the work that pushed it over.
The heap is read every five seconds, a single call that takes microseconds. Tracking requests costs one map insert and one delete per request.
To find the last lines before a crash:
```sh
docker compose logs app --since 2h | grep '"runtime.heap.'
```
## The report Node writes when the heap runs out
The image sets `NODE_OPTIONS="--report-on-fatalerror --report-filename=stderr --report-exclude-env --report-exclude-network"`. When the process runs out of heap, Node writes a diagnostic report, about 10 KB of JSON with `"trigger": "OOMError"`, to stderr just before it exits: the JavaScript stack at the failed allocation, the native stack, the heap spaces and the open handles. It lands in the container log beside the `runtime.heap.*` lines:
```sh
docker compose logs app --since 2h | grep -A400 '"trigger": "OOMError"'
```
The two exclude flags keep environment variables (keys, passwords, the database URL) and network interfaces out of the report. If you set `NODE_OPTIONS` yourself, for example to raise `--max-old-space-size`, your value replaces the image's; add the four flags to it to keep the report.
## Optional heap snapshot
A snapshot shows what the heap is made of, which the events above cannot. Turn it on only while you are chasing a specific crash:
```sh
HEALTHLOG_HEAP_SNAPSHOT=1
HEALTHLOG_DIAG_DIR=/tmp # default; a path inside the container
```
With the switch set, the first `runtime.heap.pressure` event is followed by one `v8.writeHeapSnapshot()` and a `runtime.heap.snapshot` event naming the file. At most one snapshot is written per process. The file goes into a new directory with a random name, `healthlog-heap-XXXXXX`, created with mode 0700 and checked to be a real directory owned by the app's user before anything is written; the file itself is mode 0600. The event names the full path.
**A heap snapshot contains everything the process held in memory: decrypted health records, session data, API tokens and the encryption keys.** Treat it like a database dump with the keys attached.
- HealthLog never uploads or logs a snapshot. Only its path appears in the log.
- Copy it off the container only to a machine you trust, for example `docker compose cp app:/tmp/healthlog-heap-XXXXXX ./`, and open it in Chrome DevTools (Memory tab, Load).
- Delete it as soon as you are done, inside the container and wherever you copied it: `docker compose exec app rm -rf /tmp/healthlog-heap-XXXXXX`. A file under `/tmp` in the container also disappears when the container is recreated, not when it restarts.
- Do not attach a snapshot to an issue or send it to anyone.
Writing a snapshot blocks the process for several seconds and needs memory roughly the size of the heap on top of what is in use. Near the container's memory limit that can end the process sooner than the heap would have. The pressure report is written before the snapshot starts, so it is on record either way. Turn the switch off again when you have what you need.
+6 -10
View File
@@ -33,9 +33,9 @@ metadata (`medication_schedules`):
- After upgrading to v1.15.19+, when a user reports a compliance rate above
100 %, duplicate entries in the medication history, or a dose shown as
taken days away from its slot.
- The compliance rollup counts DISTINCT slots since v1.15.19, so the
percentages self-correct on recompute even without running this script.
Running it additionally clears the phantom rows from the history view.
- Compliance counts DISTINCT slots since v1.15.19, so the percentages are
right even without running this script. Running it additionally clears
the phantom rows from the history view.
- The `intake-slot-dedup` worker already collapses canonical-slot
duplicates at boot and on a daily cron; this script is the manual,
immediately-observable fallback and the only path that surfaces the
@@ -104,13 +104,9 @@ healthlog-tsx scripts/repair-intake-anomalies.ts --fix
as the duplicate losers). A medication without any derivable anchor
(PRN-only / unscheduled) is skipped — there is nothing to compare
against.
- **Affected compliance rollups are recomputed** for every touched
`(user, medication, day)`. The script executes the same DISTINCT-slot
aggregation SQL the shared helper runs (a verbatim twin of
`recomputeMedicationComplianceForDay` in
`src/lib/rollups/medication-compliance-rollups.ts` — kept inline so
the script needs no app imports), so the scheduled/taken counts and
the rate self-correct immediately.
- **No separate recompute is needed.** Every compliance reader folds the
rows that share one slot, so the scheduled/taken counts and the rate are
right as soon as the duplicates are tombstoned.
The script is idempotent: a second `--fix` run finds zero duplicate groups
and changes nothing. Exit code is 0 on success (including a clean
+275
View File
@@ -0,0 +1,275 @@
# Measurement table maintenance
Since v1.42 the nightly folds of Apple Health data delete the raw samples they
fold into hourly or daily rows, instead of keeping them as deleted rows for 75
days. On an instance that has synced Apple Health for a while, those leftovers
("compaction tombstones") were most of the `measurements` table and of its
indexes. Three pieces deal with them, in this order:
1. **The fold repair** runs by itself, once per account, before anything is
deleted. Up to v1.42 the folds could fold a day in two runs, and the
second run stored the mean of the later part of the day only. The repair
recomputes those hourly and daily means from all of the day's samples,
including the compaction tombstones, and corrects a stored mean only when
it differs. It does so only where every sample of the day is provably
still in the table: the last 75 days for daily means, and for hourly
means (folded 90 days late) the 75 days before that. Older days stay as
they are, because the deleted rows of their samples have passed the
75-day retention and what is left can be a fragment of the day. When it
has finished an account it records that in `measurement_fold_repairs`.
2. **The backlog purge** runs by itself, after the repair. Every worker boot queues one
`compaction-tombstone-purge` job. It deletes the leftovers 5,000 rows at a
time, one account at a time, pausing briefly between batches, and stops
after 40 batches; a run that stopped with work left queues the next one a
minute later. Rows a person deleted are never touched by it: they keep the
75-day retention of the nightly tombstone cleanup. The purge leaves an
account alone until the repair has finished it, and a boot queues no purge
run while the repair has finished no account.
3. **The table maintenance** is yours to start, after the purge. It runs
`VACUUM (ANALYZE)` on `measurements` and then rebuilds each of its indexes
with `REINDEX INDEX CONCURRENTLY`, largest first. The purge leaves the
indexes full of empty pages, and only a rebuild gives that space back.
## Repair before purge
The order matters because the purge deletes what the repair reads. Do not
delete compaction tombstones by hand, and do not insert rows into
`measurement_fold_repairs` to hurry the purge along: a mean the repair has not
seen keeps its wrong value for good once its tombstones are gone.
The repair reports every run on the wide event `job.measurement_fold_repair`
(`fold_repair_means_checked`, `fold_repair_means_corrected`,
`fold_repair_resting_corrected`, `fold_repair_skipped_beyond_horizon` for the
older windows it left as they are, `fold_repair_samples_absorbed` for live
samples it took into a checked mean, and `fold_repair_by_type` with the same
counts per type; counts only, never a value). A second run over an account
corrects nothing. It is finished when every account has a row:
```sql
SELECT count(*) AS accounts_left
FROM users u
LEFT JOIN measurement_fold_repairs r ON r.user_id = u.id
WHERE r.user_id IS NULL;
```
Each run works on one account, a day at a time, and stops on its time budget
with a follow-up that resumes at the next day; an account under restore is
tried again five minutes later. While accounts are left, purge runs report
them as `compaction_purge_awaiting_repair_accounts`.
A restore of a backup exported before the account's repair finished writes
the compaction tombstones back, removes the account's row and queues the
repair again; the purge follows once the repair is through.
## Is the purge finished?
The purge reports every run on the wide event `job.compaction_tombstone_purge`
(`compaction_purge_deleted`, `compaction_purge_drained`,
`compaction_purge_deferred_accounts`). It is finished when a run reports
`drained: true` and deletes nothing. From `psql`:
```sql
SELECT state, count(*)
FROM pgboss.job
WHERE name = 'compaction-tombstone-purge'
GROUP BY state;
```
No `created`, `retry` or `active` rows means nothing is queued or running. The
maintenance refuses to start while one is.
## Before you start
- **Pick a quiet window.** Reads and writes keep working during the rebuild,
but each rebuild reads the whole table twice and writes a full new copy of
the index.
- **Free disk.** A concurrent rebuild needs room for one more copy of the
index it is rebuilding, plus the WAL it writes. Check the largest index:
```sql
SELECT c.relname, pg_size_pretty(pg_relation_size(c.oid))
FROM pg_index i JOIN pg_class c ON c.oid = i.indexrelid
WHERE i.indrelid = 'measurements'::regclass
ORDER BY pg_relation_size(c.oid) DESC
LIMIT 3;
```
Have at least twice the largest size free on the database volume.
- **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
In the admin area, or with a signed-in admin session:
```sh
curl -X POST https://<your-instance>/api/admin/maintenance/measurements \
-H 'content-type: application/json' \
--cookie '__Host-healthlog_session=<session>' \
-d '{"vacuum": true, "reindex": true}'
```
On a plain-HTTP install the cookie is named `healthlog_session`.
The answer is `202` with `{ "enqueued": true }`, or `enqueued: false` when a
run is already queued or running. A Bearer token is refused: the route is
cookie-only, like every admin route. Without a background worker the route
answers `503`.
Either step can be left out (`"reindex": false` runs only the vacuum).
## What it does, and what stops it
The run uses a connection of its own: no statement timeout (a rebuild of a
large index takes longer than the app's 60 seconds), `maintenance_work_mem`
of 128 MB for this session only, and a lock timeout of five minutes, so a
rebuild that cannot get its lock gives up instead of holding writers behind
it.
- It refuses to start while the purge is queued or running.
- Before the first rebuild it drops any invalid `<index>_ccnew` copy an
interrupted earlier run left behind.
- If a rebuild fails, most likely for lack of disk, it drops that rebuild's
half-built copy at once and stops. The remaining indexes keep working as
they are. Free some space and start it again; finished indexes are simply
rebuilt once more.
- One run at a time. It is never retried automatically.
## Reading the result
The run's wide event `job.measurement_maintenance` carries
`maintenance_outcome` (`completed`, `nothing_due` for the nightly check,
`refused_purge_running`, `failed`), the live and dead rows the run started from, the
table and index sizes before and after, and `maintenance_steps`, one entry
per step with its target, size before and after, and duration. The same
sizes from `psql`:
```sql
SELECT pg_size_pretty(pg_total_relation_size('measurements')) AS total,
pg_size_pretty(pg_indexes_size('measurements')) AS indexes;
```
## The nightly check
Since v1.43.7 the same queue also runs by itself at 03:40 (Europe/Berlin).
That run names no steps and changes nothing beyond dropping a retired index
(next section): vacuuming the table is
autovacuum's job, which migration 0412 starts at 5 % dead rows instead of
Postgres's 20 %. The check reads the table's live and dead rows and warns
(`maintenance_autovacuum_behind` on the wide event) when there are at least
10,000 dead rows and more than one for every five live ones, four times the
point at which autovacuum should already have run. That means autovacuum is
not keeping up: look for a transaction left open for hours
(`pg_stat_activity`, `state = 'idle in transaction'`) or for autovacuum
workers busy elsewhere. The check never rebuilds an index. It shares the
singleton key with the run you start, so the two never overlap. The admin
card keeps showing only the runs you started.
The check also measures how far the table file has grown past what its live
rows need (`maintenance_bloat_factor` on the wide event, as a factor such as
1.3; `bloat_percent` in the job result, as whole percent such as 130, because a
job fact is a whole number). Past 3 it adds a warning to the event: a vacuum makes the space
reusable but does not shrink the file, and only `VACUUM FULL` does (see
below).
Why the table grew in the first place: until v1.43.7 an update that changed
nothing still moved `updated_at`, which sits in an index, so no update could
stay on its page. Migration 0412 keeps `updated_at` and `sync_version` on such
an update and gives the table `fillfactor = 85` and a 5 % autovacuum
threshold. To check that updates stay on their page:
```sql
SELECT n_tup_upd, n_tup_hot_upd, n_live_tup, n_dead_tup,
pg_size_pretty(pg_relation_size('measurements')) AS heap
FROM pg_stat_user_tables WHERE relname = 'measurements';
```
`n_tup_hot_upd` should now grow along with `n_tup_upd`. The fill factor
applies to pages written after the migration; a `VACUUM FULL` after the
upgrade applies it to the whole table.
## Retired indexes
An index the schema no longer declares has to leave the database too, and on
`measurements` the obvious migration is the wrong one. A plain `DROP INDEX`
needs an exclusive lock on the table; `DROP INDEX CONCURRENTLY` blocks nobody
but waits, without limit, for every transaction already running on the
table. A migration runs at boot, so either one waits for whatever holds the
table (a `pg_dump`, a long backfill, a `VACUUM FULL`); past the container's
start period the container is replaced with the migration half done, and the
next boot stops at `P3009` until someone runs `prisma migrate resolve`.
So an index is retired in two halves (v1.43.8, migration 0416, for
`measurements_user_id_type_measured_at_idx`):
1. The migration tries a plain `DROP INDEX IF EXISTS` inside a `DO` block with
a two-second `lock_timeout` and catches the timeout
(`EXCEPTION WHEN lock_not_available`). When nothing holds the table the
index is gone at once; otherwise the migration still succeeds and the
index stays. The longest the table's other statements can wait behind the
attempt is those two seconds. A new database and Prisma's shadow database
always take the lock, so the migration history ends without the index,
exactly as the schema does, and `prisma migrate dev` / `migrate diff` see
no drift and generate nothing.
2. The index name goes into `RETIRED_MEASUREMENT_INDEXES` in
`src/lib/jobs/retired-measurement-indexes.mjs`. Every run of this job, the
nightly 03:40 check included, drops each name still present with
`DROP INDEX CONCURRENTLY IF EXISTS`, waiting at most 60 seconds for its
locks. A run that runs out of time leaves the index (possibly marked
invalid, which the next drop finishes), sets
`maintenance_retired_index_pending` on the wide event with a warning, and
the next night tries again. The index costs writes until then, as it did
before the release; nothing reads it.
Until the job has run, the database holds one index the schema does not
declare. Nothing compares the two at runtime, and the only place they are
compared, a `migrate diff` against the migration history, never sees it. To
check by hand:
```sql
SELECT indexname FROM pg_indexes
WHERE tablename = 'measurements'
AND indexname = 'measurements_user_id_type_measured_at_idx';
```
No row: it is gone. To drop it now rather than at night, start a run from the
admin card (any steps), or run the same statement yourself:
```sql
SET lock_timeout = '60s';
DROP INDEX CONCURRENTLY IF EXISTS "measurements_user_id_type_measured_at_idx";
```
When the app connects as its restricted role
([database-role.md](../self-hosting/database-role.md)), the job may vacuum and
rebuild (`MAINTAIN`) but not drop: `DROP INDEX` belongs to the table's owner.
A run then skips the drops, of retired indexes and of the invalid copy a
failed rebuild leaves, names what it found under
`maintenance_drops_left_for_boot` with a warning, and the next start drops
each as the owner with `DROP INDEX CONCURRENTLY`, waiting at most five
seconds. Until then a failed rebuild's copy holds its disk; restart the
container, or drop it by hand as the owner with the statement above.
For the next large index, follow the same two halves.
`src/__tests__/migration-lock-timeout-guard.test.ts` refuses a
`DROP INDEX CONCURRENTLY` in any migration and requires every retired name to
have a migration that bounds its wait and catches the timeout.
## "could not resize shared memory segment"
Up to v1.42.0 the run could stop at once with `could not resize shared memory
segment ... No space left on device`. Postgres ran the vacuum in parallel and
kept its working memory in `/dev/shm`, which Docker limits to 64 MB per
container unless `shm_size` is set. From v1.42.1 the maintenance connection
runs single-process and needs no shared segment, and the bundled
`docker-compose.yml` gives the database `shm_size: 256mb`. On v1.42.0, add
`shm_size: 256mb` to the `db` service and restart the database container.
## Not part of this
`VACUUM FULL` would shrink the table file as well, but it locks the table for
reads and writes while it runs, so no job starts it. When the nightly check
warns about the bloat factor, run it yourself in a maintenance window with the
app stopped.
@@ -0,0 +1,98 @@
# Upgrade stopped at migration 0409
v1.43 removes the readable location columns that v1.42 replaced with sealed
copies (`users.home_lat/home_lon/home_label`,
`environment_contexts.lat/lon/location_label`,
`environment_travel_locations.lat/lon/label`). Before it drops anything,
migration `0409_drop_plaintext_location` checks that the v1.42 encryption
backfill has sealed every one of those values. If any row still holds a
readable location, the migration stops and the app container does not start.
## What you see
The container exits during the migration step with:
```text
A migration failed to apply. New migrations cannot be applied before the error is recovered from. ...
Migration name: 0409_drop_plaintext_location
Database error code: P0001
Database error:
ERROR: HealthLog migration 0409 stopped: 3 home location(s), 0 location period(s) and 12 daily location(s) are still stored in plaintext. ...
```
On every later start, Prisma refuses with `P3009` ("migrate found failed
migrations in the target database") until the failed attempt is cleared.
## What it means
Nothing was lost and nothing was dropped. The check runs before any other
statement in the migration, so the location columns and their values are all
still there. The migrations before 0409 in the same image did apply; they only
add tables, columns and enum values, so the previous release runs on top of
them unchanged.
The cause is a v1.42 instance that was not running long enough for its
encryption backfill to reach every account, for example a host that went from
v1.41 straight to v1.43, or one that was stopped soon after updating to v1.42.
## How to recover
The order matters: clear the failed attempt first, because the previous
release runs `prisma migrate deploy` on start as well and refuses to boot
while a failed migration is on record.
```bash
# 1. Mark the failed 0409 attempt as rolled back. The entrypoint is replaced
# so the container does not try to migrate first.
docker compose run --rm --entrypoint sh app -c \
'NODE_PATH=/opt/prisma-cli/node_modules node /opt/prisma-cli/node_modules/prisma/build/index.js migrate resolve --rolled-back 0409_drop_plaintext_location'
# 2. Start the previous release. Set this in the compose `.env` (or your
# platform's environment) and recreate the container.
# HEALTHLOG_IMAGE_REF=:v1.42.4
docker compose up -d app
```
The previous release starts the encryption backfill about three minutes after
boot and again once a day. It works through the accounts one at a time. Watch
it with the three counts the migration checks; all three must reach 0:
```bash
docker compose exec -T db psql -U healthlog healthlog -c "
SELECT
(SELECT count(*) FROM users WHERE home_lat IS NOT NULL) AS home,
(SELECT count(*) FROM environment_travel_locations WHERE lat IS NOT NULL) AS periods,
(SELECT count(*) FROM environment_contexts WHERE lat IS NOT NULL) AS days;"
```
The app log shows each pass as
`[free-text-encryption-backfill] user=... ` lines. If the counts stop going
down, look for `[free-text-encryption-backfill] user=... failed` in the log;
the error on the line after it names the cause.
Once all three counts are 0:
```bash
# 3. Dump the database. This is the copy you go back to if anything else
# goes wrong; keep it with your encryption key.
docker compose exec -T db pg_dump -U healthlog healthlog \
| gzip > healthlog-before-0409-$(date +%F).sql.gz
# 4. Upgrade again: remove the pin (or set it to the new version) and
# recreate the container. Migration 0409 now runs through.
docker compose pull app
docker compose up -d app
curl -s http://localhost:3000/api/version
```
## What not to do
- Do not clear the readable columns by hand to get past the check. The
check exists because those values have no sealed copy yet; setting them to
`NULL` deletes the location for good.
- Do not run `migrate resolve --applied 0409_drop_plaintext_location`. That
records the migration as done while the columns are still there, and the
v1.43 schema no longer matches the database.
Background: [`deploy.md`](deploy.md) for the general update and rollback
order, [`backup-restore.md`](backup-restore.md) for restoring a dump.
+168
View File
@@ -0,0 +1,168 @@
# Moving the database to a newer Postgres major
From v1.46 the compose file runs Postgres 18. Installs from v1.45 or earlier
ran 16, and a major version cannot open the data directory of an older one,
so the compose file carries a one-time step that moves the database across
on the first start after the update. Nothing needs doing by hand on a stack
that runs the project's `docker-compose.yml`.
Installs that bring their own Postgres (Unraid, an existing database server)
are not touched: HealthLog runs on 16 and 18 alike, and the move there is
yours to schedule with the steps under [By hand](#by-hand).
## What happens on the first start
`db-upgrade` is a short-lived container next to `db`. It starts after the new
Postgres 18 database is up and before the app, and it exits in a second when
there is nothing to move. When the old `pgdata` volume holds a Postgres 16
database and the new one is empty, it:
1. checks that the database service runs Postgres 18, and that there is room
on the disk for a second copy and for the dump the app takes before its
first migration;
2. starts Postgres 16 inside its own container on the old volume, reachable
only from inside that container;
3. streams a dump into Postgres 18 in a single transaction;
4. compares the row count of every table between the two;
5. gathers the planner statistics, which a restore does not carry;
6. stops Postgres 16 and leaves a marker file on the old volume.
The app starts once that succeeded, applies the release's migrations and
serves as before. On a typical personal record the whole move takes seconds;
a database of a few gigabytes takes a few minutes. Watch it with
`docker compose logs -f db-upgrade`.
If any step fails, the new database stays empty, the app does not start, and
the log says what happened. A move cut short by a restart or a full disk is
run again from scratch on the next `docker compose up -d`.
The old `pgdata` volume keeps the database as it was. Postgres 16 runs on it
during the move, so it writes a checkpoint there (and recovers first after an
unclean stop), and the marker file is added; no data in it changes. That is
the way back.
## Going back
Stop the stack, put the two lines of the `db` service back the way v1.45 had
them, and start it with the v1.45 image:
```yaml
db:
image: postgres:16.15-alpine
volumes:
- pgdata:/var/lib/postgresql/data
```
```bash
docker compose down
HEALTHLOG_IMAGE_REF=:v1.45.0 docker compose up -d
```
Anything written after the move lives only in the Postgres 18 volume.
`db-upgrade` can stay in the file: it sees that the database service runs
Postgres 16 and leaves that volume alone. This also holds after a move that
stopped half-way.
## Updating again after going back
If you used Postgres 16 again after going back, the Postgres 18 volume holds
the state of the day of the move, not what you wrote since. `db-upgrade`
notices that the old database changed after the move and refuses to start on
the old copy. To move the current 16 database, remove the old copy and the
marker, then start again:
```bash
docker compose down
docker volume rm <project>_postgres-data
docker compose run --rm --entrypoint rm db-upgrade \
/var/lib/postgresql/data/HEALTHLOG_MOVED_TO_POSTGRES_18
docker compose up -d
```
`docker volume ls` shows the volume's full name. Removing it deletes what was
written on Postgres 18 before you went back; take a dump of it first if any
of that matters.
## Afterwards
Keep the old volume until the new database has run for a while, then remove
it. It is a complete copy of the database as it stood before the move, and
nothing that happens afterwards reaches it: an account deleted, a document
purged from the trash, or a key rotated after a leak is still there in the
old volume, and so is everything else for anyone who can read the host's
Docker volumes. Removing it is part of the move, not optional tidying.
`docker volume ls` shows its full name, prefixed with the compose project:
```bash
docker volume rm <project>_pgdata
```
`db-upgrade` then finds nothing to move. A later release drops the service
and the `pgdata` line.
## Starting over
When the move has run and the Postgres 18 volume is empty again (deleted by
hand, say), `db-upgrade` refuses to import the old state a second time, since
that would quietly bring back the database as it was on the day of the move.
To do exactly that on purpose, remove the marker and start again:
```bash
docker compose run --rm --entrypoint rm db-upgrade \
/var/lib/postgresql/data/HEALTHLOG_MOVED_TO_POSTGRES_18
docker compose up -d
```
## By hand
For a Postgres you run yourself, or a compose file you have changed so much
that the step does not fit, the move is a dump and a restore. Take the
encryption key's copy out of its safe place first: the dump is unreadable
without it, and this is the moment you would find out.
```bash
# 1. Stop the app so nothing writes while the dump runs (and app-worker, if
# you run the worker role in its own container).
docker compose stop app
# 2. Dump the database in Postgres' custom format.
docker compose exec -T db pg_dump -U healthlog -Fc healthlog \
> healthlog-pg16-$(date +%F).dump
# 3. Stop the old database. Its volume stays untouched.
docker compose stop db
```
4. Point the `db` service at Postgres 18 and a fresh volume. From 18 on, the
image keeps its data under `/var/lib/postgresql/18/docker` and expects the
volume mounted one level up, at `/var/lib/postgresql`; mounted at
`.../data` it refuses to start with "there appears to be PostgreSQL data
in: /var/lib/postgresql/data".
```bash
# 5. Start the new database and restore the dump into it.
docker compose up -d db
docker compose exec -T db pg_restore -U healthlog -d healthlog --no-owner \
< healthlog-pg16-<date>.dump
# 6. Gather the planner statistics. A restore brings the rows but not the
# statistics, and until autovacuum gets round to every table the planner
# guesses at their sizes, which can turn a sub-second page into one that
# takes many seconds.
docker compose exec -T db vacuumdb -U healthlog -d healthlog --analyze-in-stages
# 7. Start the app and confirm it.
docker compose up -d app
curl -s http://localhost:3000/api/version
```
The start log should say there are no pending migrations beyond the release's
own: the dump carried the migration history with it.
If you had run `CREATE EXTENSION pg_stat_statements;` for
[query statistics](query-statistics.md), the dump carries it and the compose
`command:` still loads the library, so nothing needs doing there.
`pg_upgrade --link` is faster on a very large database, but it needs both
majors' binaries in one container; for a personal health record the dump is
seconds to minutes and has nothing to set up.
+29
View File
@@ -0,0 +1,29 @@
# Query statistics (pg_stat_statements)
From v1.42 the bundled database loads the `pg_stat_statements` library, so the time spent in each query shape can be read from the database itself. Loading the library only collects statistics in shared memory; nothing is sent anywhere.
## Enable the view
The library is loaded through the `command:` of the `db` service in `docker-compose.yml`. The first `docker compose up` after updating restarts the database once. Then create the extension once:
```sh
docker compose exec db psql -U healthlog -d healthlog -c "CREATE EXTENSION IF NOT EXISTS pg_stat_statements;"
```
## Read it
The slowest query shapes by total time:
```sh
docker compose exec db psql -U healthlog -d healthlog -c "SELECT calls, round(total_exec_time) AS total_ms, round(mean_exec_time, 1) AS mean_ms, left(query, 120) AS query FROM pg_stat_statements ORDER BY total_exec_time DESC LIMIT 15;"
```
The statements are stored with their parameters replaced by placeholders, so values from your records do not appear in the view.
## Reset
```sh
docker compose exec db psql -U healthlog -d healthlog -c "SELECT pg_stat_statements_reset();"
```
If you run your own PostgreSQL instead of the bundled one, add `pg_stat_statements` to `shared_preload_libraries` in its configuration and restart it before creating the extension.
Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

Loaded 100 of 7056 files, more files were not shown because too many files have changed in this diff. Show more