Commit Graph
7469 Commits
Author SHA1 Message Date
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 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 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
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