Commit Graph
4113 Commits
Author SHA1 Message Date
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 402daa2753 Merge pull request #1251 from dareoon/feat/i18n-pt-br 2026-10-22 14:19:17 +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 803d90c6c4 Merge branch 'fix/1261-own-targets' into release/v1.44.2 2026-10-22 12:04:29 +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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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