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