9679 Commits
Author SHA1 Message Date
Marc-André Bombeck 90865eb485 Merge release/v1.47.0 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