Compare commits

...
488 Commits
Author SHA1 Message Date
Marc-André Bombeck 8e7788132a Merge pull request #1270 from MBombeck/release/v1.44.2
Release v1.44.2
2026-10-22 16:07:20 +02:00
Marc-André Bombeck 9497b62b20 test(e2e): let the spring-back drag see a rendered frame before release
WebKit in the CI container can go over a second without rendering a frame while events and timers run on; the trace shows the More sheet still sliding in after its animation reported finished. The sheet paints once per frame, so a drag released inside such a stall was never drawn. The spring-back check now holds the finger for two rendered frames, and the settle helper waits for two frames too.
2026-10-22 15:35:42 +02:00
Marc-André Bombeck a0e97a4e54 fix(ui): attach the bottom sheet's touch listeners when the sheet mounts
The non-passive touchmove listeners on the sheet and its backdrop were bound in an effect that ran while the portaled sheet was still unmounted, and never ran again, so they were never attached. The mounted element is now state, and the listeners follow it.
2026-10-22 15:35:41 +02:00
Marc-André Bombeck 996cc15b50 ci(e2e): run the Chromium shards in the Playwright image too
Installing Chromium's system packages from apt stalled a shard for 29
minutes, the same way WebKit's did. The shards now use the image the
WebKit job already runs in, whose tag follows the lockfile's
playwright-core, so no browser or system package is installed at run
time.
2026-10-22 15:22:03 +02:00
Marc-André Bombeck 929d83b3b6 test(e2e): record the whole drag instead of one fixed-time sample 2026-10-22 14:50:01 +02:00
Marc-André Bombeck 6cf69bbf1c test(insights): pin the clock in the two-service workout collapse case
The case built its sessions from the real clock: one copy a day before
now, the other 30 s later. The cross-source workout picker groups sessions
into fixed five-minute slots of the epoch, so whenever the suite ran in
the last 30 s of a slot the two copies landed in different slots and
counted twice. It had nothing to do with day boundaries or time zones: the
outcome followed the minute of the wall clock alone.

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

The production code reads no clock here (the block takes `now` from its
caller). What the flake exposed is the fixed slot grid itself: two copies
of one session that straddle a slot edge are not collapsed. That is a
change to the picker every workout surface shares, so it is left for a
change of its own.
2026-10-22 14:49:55 +02:00
Marc-André Bombeck 7363bcd5d3 ci(e2e): trust the checkout in the WebKit container; measure the day button once it settles 2026-10-22 14:16:08 +02:00
Marc-André Bombeck e18cd086fd ci(e2e): run the WebKit spec in the Playwright image, keep the shards on Chromium
Installing WebKit's system packages in every shard stalled one shard for
29 minutes on apt and left another without time for its tests. The
bottom-sheet spec now runs in its own job inside Microsoft's Playwright
image, whose tag follows the playwright-core version the lockfile
resolves; the shards install Chromium only and skip the WebKit project.
The e2e gate answers for the shards and the WebKit job.
2026-10-22 13:44:10 +02:00
Marc-André Bombeck 98a083c86e test(guards): scan once in the threshold and timezone guards
Both guards timed out at five seconds under a loaded unit suite. The
timezone formatter guard grepped the whole of src, the generated client
included, once per scan; it now reads the tree once without the generated
client and keeps a file only when a plain substring says a pattern could
match. The threshold literal guard parsed every client file once per rule
and twice per run; each file is parsed once, each rule's hits are found
once, and a file is parsed for a rule only when it carries one of the
rule's markers. A sample that the markers would skip fails the matcher
case, and a planted literal still fails the allowlist case.

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

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

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

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

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

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

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

Refs #1267
2026-10-22 13:01:10 +02:00
Marc-André Bombeck f08277e595 Merge pull request #1265 from MBombeck/fix/v1441-next-security
Release v1.44.1
2026-10-22 12:26:44 +02:00
Marc-André Bombeck ed04103eb6 chore(release): v1.44.2 2026-10-22 12:05:28 +02:00
Marc-André Bombeck 803d90c6c4 Merge branch 'fix/1261-own-targets' into release/v1.44.2 2026-10-22 12:04:29 +02:00
Marc-André Bombeck ac9693fac9 Merge branch 'feat/bottom-sheet' into release/v1.44.2 2026-10-22 12:04:28 +02:00
Marc-André Bombeck 03b3f6a3ba docs: record the history fixes and what 16.4.2 measured 2026-10-22 11:32:57 +02:00
Marc-André Bombeck aae7bce078 test(security): write the next/image import matchers as literals 2026-10-22 11:32:57 +02:00
Marc-André Bombeck 6cd8ffa526 fix(documents): follow the address bar for the open document
The sheet compared useSearchParams with the last ?doc= it had seen.
Next 16.4 applies its copy of a pushState after the write, and a Back
pressed in between leaves useSearchParams reading no document on both
sides, so the sheet saw no change and stayed open. The sheet now reads
the query from window.location through useSyncExternalStore, re-read on
popstate and on every render, and the selection and close paths build
their URLs from the same live value.
2026-10-22 11:32:57 +02:00
Marc-André Bombeck c496e390be fix(day): hold a step until the layer shows the previous one
A step writes ?day= with replaceState, past Next's router, which adopts
the write a render later. Since 16.4 that render can land after a second
quick step and write the first step's URL back, so [ then ] pressed in a
row ended a day short. A step that finds the layer still showing another
day than the URL now waits, and publishOpenDay makes it in one write as
soon as the layer catches up. Never past today still holds for the sum.
2026-10-22 11:32:56 +02:00
Marc-André Bombeck ff49bbb5fc fix(insights): shade the SpO2 chart from the own target when one is set
The oxygen saturation page drew a fixed clinical band, so a person with
a lower target of their own saw their normal range shaded as a warning.
The page now reads the resolved range from /api/user/thresholds and
draws it; without a target the clinical bands stay as they were.

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

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

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

Every `side="bottom"` sheet and ResponsiveSheet on a phone render through
it: the More sheet, the capture and form sheets, the Coach drawer on a
phone and the day. The day opens on the new preview step and pulls up to
90 dvh, its footer held on the bottom edge. Focus now returns to whatever
opened a sheet, not only to a Radix trigger. The command palette fills
the window and keeps no grip or drag.
2026-10-22 10:20:28 +02:00
Marc-André Bombeck 5902401ab8 chore(release): v1.44.1 2026-10-22 08:25:49 +02:00
Marc-André Bombeck 1fa33f2aee fix(security): turn the image optimizer off
Nothing renders through next/image, so /_next/image only offered a route
that fetches, decodes and re-encodes whatever local path a request names.
With images.unoptimized Next answers it with 404 before reading a
parameter. A guard fails when next/image is imported while the optimizer
is off, or when the optimizer returns with SVG input allowed.
2026-10-22 08:25:49 +02:00
Marc-André Bombeck 44c2973a64 fix(deps): update next to 16.4.2 and nodemailer to 10.1.7
Next 16.4.2 carries the fixes for GHSA-673q-mjm6-r5xr and
GHSA-2464-fhr4-w9xw (unauthenticated remote code execution) and
GHSA-p3m5-59mp-jv5r (denial of service through Server Actions).
eslint-config-next and @next/bundle-analyzer move with it, both pinned
exactly. nodemailer 10.1.7 fixes the address-parser advisories.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

eslint-plugins/healthlog/planning-tags.js defines the shape once: a head
of tag codes and planning words before an em dash, optionally after a
release version. The version and any issue reference stay; the tag goes.
A line without a version must carry a code that is neither domain
vocabulary (B12, PM2.5, UTF-8) nor used in the file's code.

scripts/strip-planning-tags.mjs locates comments with the TypeScript
parser, rewrites only their text, and refuses any file whose
comment-free printout changes. The healthlog/no-planning-tags rule uses
the same matcher, with an autofix; it is enabled together with the sweep.
2026-10-20 08:52:23 +02:00
Marc-André Bombeck b50b5fcd21 chore(deps): drop four dependencies nothing imports
react-hook-form, @hookform/resolvers, next-themes and @vitejs/plugin-react
have no import anywhere in src/, scripts/, tests/, e2e/ or the build and
test configs; knip only stayed quiet because each sat on its ignore list.
Forms keep a controlled draft and validate with the shared Zod schemas,
and the theme bootstrap is hand-written. The ignore entries, the comments
that still named the form library, and the guideline text go with them.
2026-10-20 08:39:06 +02:00
Marc-André Bombeck 9b93247ae0 test: prime only the EN catalog in the unit setup
The setup file runs once per test file and imported all eight message
catalogs, several hundred kilobytes each, which made setup the largest
share of a component-suite run. It now primes EN, the fallback floor of
the t() chain. A test that renders another locale primes it at module
scope through primeLocales(), which loads the catalog via the production
loader in a file-level beforeAll.
2026-10-20 08:38:08 +02:00
Marc-André Bombeck 5cfd129b02 Merge branch 'fix/v1437-gh-adopt' into release/v1.43.7 2026-10-20 08:18:05 +02:00
Marc-André Bombeck dee6a27ae4 Merge branch 'fix/v1437-data' into release/v1.43.7 2026-10-20 08:12:32 +02:00
Marc-André Bombeck 6e970076c7 test(insights): give the cache access guard a floor on its matches 2026-10-20 07:51:18 +02:00
Marc-André Bombeck 43da1739b2 fix(audit): drop the widget-layout breadcrumb that can no longer write
The layout write resolves the caller, so its refusal is always the
caller's own and auditLog never files it; the call, its dedup and its
tests go, and the refusal stays on the wide event. The series route
tests now pin the breadcrumb call instead of a ledger row the caller's
own request no longer writes.
2026-10-20 07:51:15 +02:00
Marc-André Bombeck 00dfc5a0c0 feat(insights): seal the cached AI insights payload at rest
users.insights_cached_text held the model's briefing, readings and
recommendations about the account in the clear. Migration 0411 adds
insights_cached_text_encrypted (AES-256-GCM, binary layout, purpose-bound
label) and a partial discovery index; the free-text encryption backfill
seals the existing rows one at a time, fail-closed, carrying updatedAt and
guarded on the value it read. Every reader, writer and clear goes through
cached-text-cipher.ts, and a guard keeps the column names there. The
column joins the encrypted-column registry and the key-rotation script,
stays out of backups as operational, and is reset by the data wipe.
2026-10-20 07:30:00 +02:00
Marc-André Bombeck eae2138f3e fix(audit): route the widget-layout refusal through auditLog
The dashboard layout route wrote its validation breadcrumb with a bare
auditLog.create, so it skipped the delegated-only rule and kept filing
the caller's own refusals.
2026-10-20 07:29:53 +02:00
Marc-André Bombeck 97d1052dd1 feat(api): say when a list was cut at its ceiling
A list that stops at its own take and says nothing lets the last row read
as the oldest there is. Capped lists now fetch one row past the limit and
answer meta.truncated and meta.limit on the success envelope, so data keeps
its shape. Applied to the capped lists without a total count: rhythm
events, ECG recordings, sharing grants and activity, illness episodes, side
effects, personal records, security activity, cycles, family history,
allergies and mental-health assessments. The activity feed no longer
reports a list that is exactly full as cut.
2026-10-20 06:53:17 +02:00
Marc-André Bombeck 2e92069c16 test(guards): name the list walk's exception instead of pending entries
The page-walk guard flagged the backfill progress codec's page > 0 check, a
validity test rather than a ceiling, and missed the list walk's own loop
(pageCount - skipped < maxPages). The matcher now reads a counter offset and
ignores a literal bound of 0 or 1. Strava and the rollup chunks walk through
walkPages, so their pending entries go. The dataPoints.list walk stays outside
as a named exception with a match allowance: it is bounded by progress rather
than by a cursor ceiling, streams pages, stops on a slice budget and keeps its
place, and its backfill decides with a sliced verdict.
2026-10-20 06:49:47 +02:00
Marc-André Bombeck 5c1b9889ec fix(google-health): page dailyRollUp chunks through walkPages
Each rollup chunk kept its own token loop with a hundred-page bound. It now
walks through walkPages: a token handed out twice ends the chunk at once
instead of re-reading the same page up to the bound, and a chunk cut off is
reported on the wide event as well as through the client outcome.
2026-10-20 06:49:45 +02:00
Marc-André Bombeck 93b3f36d96 fix(strava): walk activity pages through walkPages and gate the backfill on it
The activity loop stopped at its page ceiling and returned what it had read,
and the backfill stamped the account complete over it: a history longer than
forty pages was cut off for good. The walk now runs through walkPages, which
reports a walk cut off at its ceiling, and the backfill reads backfillVerdict
like the WHOOP and Fitbit backfills: a truncated walk leaves the marker unset
without a retry, and a connection waiting for re-authorisation returns
without walking.
2026-10-20 06:49:44 +02:00
Marc-André Bombeck 415da581f8 fix(client): bound the staged-attachment poll and list every client poll
The Coach polled a staged document every 2.5 s until its content index
existed, which for a format that is never indexed meant for as long as the
Coach stayed open. It now stops with the same upload processing window the
documents list uses. Every client poll, its interval and its stop
condition is listed beside the dashboard cadence constant.
2026-10-20 06:49:23 +02:00
Marc-André Bombeck 9bc95713cb fix(client): keep server modules out of the browser bundle
The side-effect taxonomy imported its enums from the Prisma client entry,
and the recurrence engine the medication wizard previews schedules with
imported the logging context, which brings node:async_hooks, node:crypto
and node:os along. The taxonomy reads the enums entry now, and a rule that
does not parse is reported through an observer the server registers, the
pattern the source-priority parser already uses. A guard walks the import
graph from every client component and fails on a server-only module.
2026-10-20 06:49:17 +02:00
Marc-André Bombeck 4979805d11 fix(audit): keep refusals and job telemetry out of audit_logs
A *.validation-failed row was written on every malformed request, the
caller's own included, and the PR detection enqueue wrote one per batch.
Neither is a security event and nothing reads them. auditLog now files a
refused attempt only when a delegate made it, for the owner's activity
feed; otherwise it rides the wide event as meta.rejected_attempt. The PR
detection enqueue moves to annotate().
2026-10-20 06:49:03 +02:00
Marc-André Bombeck 5eaa813b34 fix(google-health): round imported values through importValue
The mapper kept its own parseFloat(n.toFixed(2)), which rounds a half at
the third decimal down whenever the double sits just below it: a weight of
72 345 g stored as 72.34 kg, a distance of 1 000 005 mm as 1000 m. The
shared importValue rounds on the decimal the value prints as. The rounding
guard drops its last pending entry and also catches a round2 declared as a
const.
2026-10-20 06:27:58 +02:00
Marc-André Bombeck f0449574f8 i18n(nl): follow the session, sleep-rhythm and recovery copy 2026-10-20 06:20:43 +02:00
Marc-André Bombeck 45c027aee2 Merge branch 'fix/v1437-review' into release/v1.43.7
# Conflicts:
#	src/app/api/analytics/route.ts
2026-10-20 06:20:42 +02:00
Marc-André Bombeck 40cf10ba20 test: bring fixtures in line with the nap rule and the recovery read
Correlation fixtures wrote minute-long midday sleeps, which the nap
rule now reads as naps; they write nights. The analytics test mocks the
user read the empty recovery reason makes, and the control-byte guard
asserts the size of its sweep.
2026-10-20 06:06:25 +02:00
Marc-André Bombeck c2133203fb test(google-health): hold the parked walk to the consecutive-attempt rule
A day that brings one more page is progress, so the walk parks again
and counts afresh; only a day that ends at the same edge ends it.
2026-10-20 06:06:24 +02:00
Marc-André Bombeck ca937ba87a test(logging): type the probe handler's request argument 2026-10-20 06:05:56 +02:00
Marc-André Bombeck 1420641746 i18n(nl): add the last-entered-here freshness label 2026-10-20 05:58:27 +02:00
Marc-André Bombeck ea933b91cc Merge branch 'fix/v1437-jobs' into release/v1.43.7 2026-10-20 05:58:02 +02:00
Marc-André Bombeck 72214c0208 fix(logging): carry the route template in http.route
http.route held the concrete pathname, so every record id minted a route of
its own and grouping by route answered nothing. apiHandler recovers the
template from the handler's params and puts it there; http.path keeps the
concrete path, and path-secret redaction still runs on both.
2026-10-20 05:57:20 +02:00
Marc-André Bombeck fbbb535832 feat(api): give every error envelope a meta.errorCode
Roughly half of the refusals answered prose alone, so a client could branch
on the code for one 404 and had to fall back to the status for the next.
apiHandler now completes every error envelope with the status's generic
http.* code when the route named none; a route's own code still wins. The
family joins the published catalogue, and the profile form only shows a
code's sentence when the code has one.
2026-10-20 05:53:37 +02:00
Marc-André Bombeck c7f69fc212 fix(auth): answer 503 when the session store cannot be read
The cookie resolvers folded a failed query into null, so a database restart
answered 401 and every client discarded a good credential. The lookup now
raises SessionStoreUnavailableError, which reaches the wire as 503 with
auth.session.unavailable. Logout still clears the cookie on a failed lookup
and records a warning.
2026-10-20 05:43:58 +02:00
Marc-André Bombeck 9af22f2ab2 fix(account): give account deletion the wipe's transaction budget
The cascade behind user.delete removes every row the account owns, so on a
large record it outlived Prisma's 5 s interactive default and rolled back
with a 500 on every attempt. The deletion stays atomic with the Guardian
check and now carries the same budget as the data wipe.
2026-10-20 05:38:44 +02:00
Marc-André Bombeck f6e0d72193 Merge branch 'fix/v1437-reads' into release/v1.43.7 2026-10-20 05:35:06 +02:00
Marc-André Bombeck 5720b70e8e test(guards): drop the removed chip ledger from the course-anchor builders
The cadence chips no longer build a recurrence context of their own; they tally the compliance ledger, whose adapter is already on the list.
2026-10-20 05:33:21 +02:00
Marc-André Bombeck a6a106f763 docs(api): regenerate the OpenAPI contract 2026-10-20 05:14:07 +02:00
Marc-André Bombeck 248b5dfd6b fix(sleep-score): publish the rhythm floor and name it in the few-nights note
The note hard-coded "of 3" in every locale. SleepScoreValue now carries
rhythmMinNights, and the sheet reads the floor from it.
2026-10-20 05:14:03 +02:00
Marc-André Bombeck 4a8bf1963b fix(measurements): keep unchanged updates on their page and vacuum by threshold
`updated_at` sits in the sync-feed index, so every update that moved it
wrote a new row version on another page and an entry in every index.
Provider re-syncs rewrite many unchanged rows, which grew the table to
several times its live size and sent each unchanged row down the sync
feed again. Migration 0412 adds a row trigger that keeps `updated_at` and
`sync_version` when an update changes nothing else, so such an update is
heap-only and the feed stays silent; it sets `fillfactor = 85` and a 5 %
autovacuum threshold. The maintenance queue gains a nightly check that
vacuums when dead rows pass a tenth of the live ones and warns when the
heap is more than three times what its live rows need, which only a
`VACUUM FULL` in a window gives back. The admin card keeps showing the
operator's runs only.
2026-10-20 05:06:25 +02:00
Marc-André Bombeck 483c84b42e fix(integrations): report a page walk that stops short instead of ending quietly
The WHOOP, Oura and Withings list walks and the Fitbit exercise walk each
kept a page loop that, at its ceiling or on a cursor that did not advance,
returned what it had read and said nothing, and the backfill around it
stamped the history complete. `walkPages` keeps a ceiling but reports a
walk that stops with a page pending, or on a cursor it already followed,
as truncated. A backfill reads that through one verdict: a failure
retries, a truncated collection leaves the marker unset without a retry,
a clean run stamps it. The Fitbit exercise walk may read a hundred pages
during a backfill instead of twelve. A guard keeps page-counting loops out
of the provider directories, with Google Health and Strava listed until
their next change.
2026-10-20 05:06:21 +02:00
Marc-André Bombeck 9fe6f98fdb refactor(workouts): render workout dates through useFormatters
The workout list, the workout detail header and the dashboard's recent
workouts tile each rebuilt their own date formatter from the shared
cache, with a private copy of the current-year check, and so ignored
the reader's date-order preference. They now use useFormatters(), and
the copies are gone.

The timezone formatter guard now refuses any two-argument
getDateTimeFormat call in src/components, so a component cannot build
its own display formatter again. The medication card's day bucketing,
the one other such call, reads userDayKey instead.
2026-10-20 05:01:37 +02:00
Marc-André Bombeck 862361a224 fix(recovery): publish why the recovery page is empty from the server
The page worked out its empty note on the client from the integrations
status, so every client had to repeat the rule. The analytics summaries
slice now carries `recoveryEmpty` (general, oura_permission or
oura_no_resilience, null when any recovery type has a reading), decided
once in resolveRecoveryEmpty from the gated summaries and the stored
Oura grant. The page renders that value only, and its block list is
typed to the server's recovery types so the two cannot drift.

Each reason now reads as one sentence in every locale, and every reason
offers the same way on, the integrations settings.
2026-10-20 05:01:18 +02:00
Marc-André Bombeck feb8058233 fix(medications): tally the cadence chips from the compliance ledger
`/api/medications/[id]/cadence` computed its adherence chip from a
private copy of the compliance ledger that ignored pause eras, courses
and auto-missed doses, and read only the chart's window of intakes. A
medication paused for a week read 77 % there and 100 % on the detail
page. The chips now tally `trailingComplianceLedger`, the ledger the
compliance payload builds, over the same window, with the medication's
pauses and courses; the copy is removed and a guard freezes the set of
files that build dose rows.
2026-10-20 04:37:44 +02:00
Marc-André Bombeck e355c6120f fix(workouts): one canonical-workout picker with one source order
Three pickers decided which of two overlapping workouts to keep: the
read-time picker the list, the detail page and the Coach use, a
write-time batch picker with its own 90-second window, and an unwired
field-merge picker. They used different source orders (the `steps`
ladder, or a separate workout default when no order was saved), so the
batch route could keep a row the list then ranked below another.

The batch route now runs the read-time picker over the rows it is about
to insert, with the ladder from `workoutSourceLadder`, and reports what
it hides as duplicates. A session HealthKit hands over twice under two
ids is stored once, as the list already showed it. The write-time and
the unwired picker are removed.
2026-10-20 04:37:39 +02:00
Marc-André Bombeck 2861075676 fix(mood): bound the mood series fallback to the rollup window
Without mood rollups the series walked every mood entry the account ever
logged, while the rollup path reads the trailing five years. The live
fallback now reads the same window.
2026-10-20 04:37:15 +02:00
Marc-André Bombeck 9ffcdea312 fix(insights): keep chronic conditions out of the symptom series fill
The symptom-severity series fills healthy days with zero across each
illness episode's span. A chronic condition has no end, so its span
covered the whole window and the correlation engine read a near-constant
run of zeros. Only episodes that can put the account into Rest Mode
(`REST_MODE_EPISODE_WHERE`) carry a span now.
2026-10-20 04:37:08 +02:00
Marc-André Bombeck ac86199808 fix(stats): one standard deviation with its kind named at the call site
About a dozen hand-rolled spreads each picked population or sample
variance without saying so, and the two surfaces a person compares
picked differently: the series API reported the population SD of a
blood-pressure series, the MCP tools and the AI payload the sample SD of
the same readings, about 22 % apart at three readings.

`lib/stats/spread` holds `variance` and `standardDeviation` with an
explicit `population | sample` kind and `READING_SPREAD`, the kind every
surface uses to report the spread of a person's readings. The series
route and the feature payload both report it; the spreads that feed a
verdict (the normal-swing checks, the changepoint threshold) and the
clinical glucose SD stay the sample estimate, now said out loud. A guard
fails on a new hand-rolled spread and on the two surfaces drifting apart.
2026-10-20 04:36:54 +02:00
Marc-André Bombeck 7535235b82 test(guards): give the new guards a floor on what they find
The zone-shift and single-Pearson guards now assert that their matchers
find the correct shapes before concluding nothing else matches, and the
dense-read guard lists the streak's per-day existence probe with the
reason it stays bounded.
2026-10-20 04:36:25 +02:00
Marc-André Bombeck 0ea8880923 chore: drop planning tags from comments the review touched 2026-10-20 04:23:58 +02:00
Marc-André Bombeck b4333d65ff test: refuse raw control bytes in source files
Three files carried invisible control bytes in string literals, one of
them a production join key. They are escapes now, the same strings, and
a guard fails on any raw byte below 0x20 other than tab, newline and
carriage return.
2026-10-20 04:23:46 +02:00
Marc-André Bombeck ad9c9cd0cd fix(day): head the since-last-visit card with TileHeader at the compact density
The card hand-built an uppercase h3 and ran a density of its own. It
now takes TileHeader size="sm" (which gains an h3 heading for a card
under a section that owns the h2) and the compact card rhythm.
2026-10-20 04:23:32 +02:00
Marc-André Bombeck b7e48e933c fix(sleep): read a night of any age and the whole series range
The night view read a year of stage rows whatever date it was asked
for, so an older night came back with no main session while a year of
rows sat in the heap; it now reads a day and a half either side of the
night, and the latest night from the latest asleep row. A day of naps
alone answers with its naps and main null.

The series cut "All" to a year for sleep without saying so. Postgres
now names the nights and the rows are reconstructed ninety nights at a
time, so the range reaches back as far as the history does.

The metric status read the latest 2190 stage rows, a few weeks on a
stage-rich watch, starting mid-night; it now reads the latest 365
nights the same way.
2026-10-20 04:23:10 +02:00
Marc-André Bombeck 3da72136ba refactor(measurements): move the list modes out of the route and page every night
The 1500-line list route carried the day-grouped list, the sleep list
and its SQL night page, each with its own copy of the window-total
paging. They now live in src/lib/measurements/list/ with one paging
helper, and the route dispatches.

The sleep list gives every page night a row. A night whose
reconstruction held no main sleep (a day of naps alone) used to be
dropped while meta.total still counted it, which left short pages; its
row now carries 0 minutes and its naps. The SQL night cut splices the
asleep stages from ASLEEP_STAGES and folds rows into hour buckets
before the window functions, which cannot move a session boundary and
sorts a handful of rows per night instead of every stage. A drill-down
reads its night the way the night view does.

groupBy=day refuses valueMin / valueMax with 422: a value range filters
single readings, and the rows promise whole days.

An integration test runs the SQL page and reconstructSleepSessions over
one set of awkward shapes and holds them to the same nights, and the
list, the night view and the series to one answer per day. The unit
test of the day-grouped branch now pins only the branch; what the rows
hold is pinned against Postgres.
2026-10-20 04:22:53 +02:00
Marc-André Bombeck 6328117bd2 fix(sleep): let a zero-minute granular row leave the bare night alone
A REM row of no length counted as the granular partition and stripped
the bare aggregate beside it, so a six-hour night read as no sleep while
the SQL page cut of the list counted it as a night.
2026-10-20 04:22:39 +02:00
Marc-André Bombeck b79bd8370d fix(heap): report again at 95 % and leave a report on an out-of-memory exit
The pressure report re-armed only after the heap fell under 70 % and
half an hour passed, so a heap that climbed to its limit soon after an
85 % report died with only that older report on record. A 95 % tier
now writes the same report at once, whatever the cooldown, and re-arms
under 85 %.

The image sets NODE_OPTIONS so Node writes its diagnostic report to
stderr when the heap runs out, with environment variables and network
interfaces excluded. The heap-diagnostics and scaling docs say how to
keep the flags when overriding NODE_OPTIONS.

Heap figures share one megabytes helper with the support report.
2026-10-20 03:42:26 +02:00
Marc-André Bombeck a22bf91311 fix(insights): report every correlation through the significance gate
The /insights envelope (weight x BP, mood x BP, mood x weight, mood x
pulse, BP medication continuity) and the AI feature payload computed
their coefficients with an ungated Pearson that banded |r| above five
pairs, so five days of noise sloping the right way read as a strong
correlation there while every other surface refused the same points.
Both now use `significantPearsonCorrelation` (n >= 20 and p < 0.05,
through the engine's exact p-value), and the ungated function is gone.
The allowlist guard that tracked its remaining callers becomes a guard
that there is one Pearson implementation.
2026-10-20 03:41:54 +02:00
Marc-André Bombeck 3cf67d05f9 fix(ui): let the read-failure primitives decide by the error
About forty compact rows and seventy cards still offered a retry on a
spent read budget and drew an error card on a refusal, while a third
primitive (ReadFailureRow) did the right thing at eight places.

QueryErrorRow and QueryErrorCard now take the query's `error` and read
it through readFailureKind, which moves to src/lib/api next to
ApiError: a refusal (403) draws the surface's refused state or nothing,
a spent budget (429) says so without a retry, an ended session (401)
says so and offers sign-in instead of going blank, and only a real
failure offers the retry. ReadFailureRow is gone and every site passes
its error; a guard test fails on a site that does not.

The coach read strip keeps its card in the error state and holds one
body height across loading, failure and a one-line read.
2026-10-20 03:38:57 +02:00
Marc-André Bombeck 04855b1a67 perf(day): read the day view's comparison history once while paging
Every day view compares its day with the two years before it to find
extremes, so stepping from one day to the next read the same two years
of readings again. The history is now cached per record, zone, source
ladder and readable type set, starting on a 90-day block boundary so
neighbouring days share one cell, and each day takes its own slice cut
at local day keys, which is exactly the window a live read would see.
Measurement writes sweep the cell with the rest of the analytics bucket.
A lookback floor inside the window and a window reaching past today
still read live, and a type the modules switch off is never read.
2026-10-20 03:36:44 +02:00
Marc-André Bombeck 0497685db0 fix(apple-health): count readings entered here before calling a type quiet (#1247)
Weight, blood pressure and blood glucose are written back to the phone
while their entry is bidirectional. A reading entered here goes to Apple
Health and never arrives back, so a person who keeps those readings here
saw the type go quiet after two weeks. The Apple Health status read now
adds `lastEnteredHereAt` to each written-back type, the newest reading
from any source other than Apple Health, and `stale` counts it; the card
shows it as "Last entered here". The value is resolved on the server so a
client renders it as sent.
2026-10-20 03:36:31 +02:00
Marc-André Bombeck 2b61762962 fix(integrations): round imported provider values through one normaliser
Fitbit, WHOOP, Oura, Polar and Withings each kept a private
`parseFloat(n.toFixed(2))`, which mis-rounds halves on the double below
the decimal. `importValue` keeps the same two places, rounds half away
from zero on the printed decimal and returns the nearest double, so a
value prints cleanly. A guard keeps `toFixed` and private `round2`
helpers out of the provider directories; the Google Health mapper is the
one listed exception until its next change.
2026-10-20 03:35:41 +02:00
Marc-André Bombeck e452dd62e2 fix(telegram): snooze a skipped dose to the end of the person's own day
The skip button set the rest-of-day snooze with `setHours(23, 59, …)` in
the process zone, which runs in UTC, so a skip after midnight UTC but
before local midnight east of it silenced the next local day's reminders
too, and one west of it ended the snooze hours early. The snooze now ends
at the last instant of the account's local day.
2026-10-20 03:35:36 +02:00
Marc-André Bombeck 1ee73908ae fix(jobs): run one heavy job at a time per process
Web and worker share one process and one heap limit, and the jobs that
hold a large working set (the Apple Health and Health Connect imports,
restore, the weekly and off-host backups, the restore drill, the rollup
backfill, the nightly status batch, the pre-generation walk and the
narrative walk) ran side by side whenever their schedules or an upload
met, so the peak was their sum. `heavyLane` queues them in the process,
first come first served. A waiter leaves the line when pg-boss expires it
and its retry takes the place; a one-shot whose input lives outside
pg-boss keeps its place. The runtime guard binds every heavy queue in the
lane, keeps `lockedPass` outside it, and allows the one-shot rule only
where the send options forbid a retry.
2026-10-20 03:35:23 +02:00
Marc-André Bombeck 6026a28fe4 fix(dashboard): key streak and compliance days in the person's own zone
`measured_at` and `scheduled_for` are naive UTC timestamps. The dashboard
streak and the medication-compliance backfill keyed them with a single
`AT TIME ZONE tz`, which reads the value as local time and shifts it the
wrong way: in Berlin a reading at 23:30 UTC counted for the day before
instead of the day after, so a streak could break or stretch near
midnight and the backfill recomputed the wrong day.

The streak's measurement days now come from `readActivityDayKeys`: one
existence probe per local day instead of a DISTINCT over a year of
readings. The DAY rollups cannot answer this because their buckets are
UTC days. The two compliance-rollup readers had no caller left and are
removed with the coverage probe that carried the same shift. A guard
fails on any single shift of a naive column into a bound zone.
2026-10-20 03:35:19 +02:00
Marc-André Bombeck d9e56482d9 perf(jobs): poll a queue only its cron feeds every 30 seconds
pg-boss polls each idle worker slot every two seconds, so the hundred-odd
bindings asked the database for work about fifty times a second while
most of them wait for one tick a day. `createAndWork` now gives a queue
that a registrar schedules and nothing else sends to a 30-second poll.
Scheduled queues that also take sends keep the default, because with one
job per fetch a slow poll would drain a burst at one job per interval;
the runtime guard holds that list to the send sites in the tree,
including sends through a variable.
2026-10-20 03:35:09 +02:00
Marc-André Bombeck 9c51dd0d30 perf(analytics): read the latest value per type with one probe per type
The latest canonical reading per type ran as `DISTINCT ON (type)` over
every live row of the account in five places (summaries slice warm and
cold path, comprehensive aggregator warm and cold path, targets). With no
skip scan in Postgres 16 that sorted the whole account to keep one row
per type. `readLatestCanonical` drives the read from the type list
instead: one index probe for each type's latest instant, then a closed
one-day window ranked by the source ladder. The closed window also keeps
the plan under `jit_above_cost`. The targets read reuses `readLatestEver`
with a floor.

The summaries slice was built inside three cache cells (the tile-strip
route, the thick analytics body, the dashboard snapshot). It now has one
cell, read through `readSummariesSlice`; composing callers read it
fresh-only so a refresh after a write cannot cache the pre-write slice
again, and the snapshot folds the water tile into a copy instead of the
cached object.
2026-10-20 03:35:03 +02:00
Marc-André Bombeck 0770ae3590 fix(jobs): retire the six per-metric status crons behind the nightly batch
The 02:00 batch builds every status card and falls each metric it cannot
cover back to that card's own path, so the 02:05 to 02:30 crons only
re-read cards the batch had just written. Their schedules and queues are
removed on an instance that still has them (`retireQueues`), and the
fresh-switch integration test drives the batch loop instead.
2026-10-20 03:34:51 +02:00
Marc-André Bombeck 37b65fa9c8 refactor(settings): take the backfill-ended type from its source
The integration status shape hand-copied the published entry with a looser
type field; it now imports the type the status routes publish.
2026-10-20 03:34:47 +02:00
Marc-André Bombeck 87554c09f3 fix(google-health): key a waiting retry on when it is due
A slice that found nothing left but a parked collection queued the next
slice a day out under its slice number. A boot during that day ran a fresh
first slice, met the same wait and queued its own retry under a different
slice number, so two chains walked the same history. The waiting retry now
goes under a key named after the instant it is due, read from the stored
note, so every slice that meets the same wait names the same job and the
queue keeps one. A retry already due stays an ordinary next slice.
2026-10-20 03:34:46 +02:00
Marc-André Bombeck 7c23a49708 fix(google-health): annotate an ended backfill in one fixed shape
The ended note carried the whole map of ended collections, dates and all,
which duplicated the slice report and gave dashboards no stable shape. It
now names the collection that just ended, its type and reason, whether the
end is final, and counts the ends so far with their types. It stays meta:
the job names the event's action once the slice is over, which would
replace an action set here.
2026-10-20 03:34:45 +02:00
Marc-André Bombeck bfbd88b30b refactor(google-health): read and write backfill progress through one schema
The backfillProgress column was read by four hand-rolled walkers over
untyped records and written inline in the sync core. One module now owns
the column: a Zod schema reads it, lenient per entry so a malformed cursor
or end is dropped on its own, and one function writes it. The sync core
keeps only the bookkeeping of a running backfill.
2026-10-20 03:34:30 +02:00
Marc-André Bombeck 825650794c i18n(nl): add the keys v1.43.6 introduced after the catalogue was drafted 2026-10-20 01:49:43 +02:00
Marc-André Bombeck 435099dc4f fix(sleep): decide nap versus main sleep in one place
The sleep score treated a short daytime session as a nap while the
nap convention always took the longest session as the night, so a day
with only an afternoon nap showed that nap as the night in the list,
the night view and the dashboard, and as no night in the score.

The rule and its constants now live in sleep-night.ts. Each session
carries `daytimeNap`, the main night is the longest main sleep, and a
day of naps alone has no main night. reconstructSleepNights picks its
main session by the same rule, and the score reads that session.

Sessions are filed under the day their last row ends on over every
writer, the rule the SQL page cut of the sleep list can also apply.

The sleep pillar's regularity floor now counts the midpoints it uses,
not the nights.
2026-10-20 01:26:16 +02:00
Marc-André Bombeck acdea22e2b fix(google-health): count only retries that end at the same edge
A collection parked after a walk without progress is walked again a day
later. Its attempt count went up on every later end without progress, even
when the retry had reached further back before it stalled again, so a
history that kept moving in short steps was declared ended after two
attempts. The count now carries the edge it was parked at and starts over
at one when the new end lies elsewhere.
2026-10-20 00:55:53 +02:00
Marc-André Bombeck 63cf6b5516 build: budget the lazy message catalogs per catalog, not in the aggregate 2026-10-20 00:30:20 +02:00
Marc-André Bombeck d62b82d21b test(i18n): read the HRV label locales from the locale registry 2026-10-20 00:30:18 +02:00
Marc-André Bombeck 7be3714ac2 Merge pull request #1246 from robdegraaf/feat/i18n-nl 2026-10-20 00:14:49 +02:00
Marc-André Bombeck 8eb4ef3d56 Merge pull request #1248 from MBombeck/release/v1.43.6
release: v1.43.6
2026-10-19 21:21:10 +02:00
Marc-André Bombeck f6179a37af docs(changelog): describe the v1.43.6 review fixes 2026-10-19 20:57:48 +02:00
Marc-André Bombeck f05b2f71e7 Merge branch 'fix/v1436-oura' into release/v1.43.6 2026-10-19 20:20:29 +02:00
Marc-André Bombeck d833d39d7d Merge branch 'fix/v1436-gh' into release/v1.43.6 2026-10-19 20:20:28 +02:00
Marc-André Bombeck 3ff860591e Merge branch 'fix/v1436-ui' into release/v1.43.6 2026-10-19 20:20:28 +02:00
Marc-André Bombeck 1fef2c81d0 Merge branch 'fix/v1436-locks' into release/v1.43.6 2026-10-19 20:20:28 +02:00
Marc-André Bombeck 7ccb7e0acc Merge branch 'fix/v1436-heap' into release/v1.43.6 2026-10-19 20:20:27 +02:00
Marc-André Bombeck 5bb6ee1f42 ci: correct the pull-request sizes named in the long integration workflow 2026-10-19 20:10:24 +02:00
Marc-André Bombeck e4c78e1d0c fix(google-health): give a stalled walk a second attempt before it ends
A walk that stopped for lack of progress ended its data type for good, so
a passing fault in Google's paging closed that history until a reconnect.
A walk that had written nothing yet was also noted as ending today.

The first end without progress now parks the data type with its edge: it
is not finished, not published, and is walked again a day later. Once
nothing else is left, the next slice is queued for when it is due, and
the marker is never stamped over a parked type. A second end without
progress is final; a second attempt that moves on is an ordinary walk
again. A walk that stalls before writing anything records no end and
fails the slice as stuck.

Refs #1220
2026-10-19 20:10:12 +02:00
Marc-André Bombeck 9095b1037e fix(ui): draw nothing for a refused read and no retry for a spent budget
The failure rows added for supplementary reads offered a retry for any
error. The usual-range strip reads at manage level, so a viewer on a
read-only share saw a permanent failure with a retry that could never
succeed; the visit preparation reads measurements under visits that
need only the profile section, which a share can grant alone. A refused
read (401/403) now draws nothing, a spent read budget (429) says so
without a retry, and only a real failure keeps the retry row. The
cycle strip holds no cell for a refused calendar read.
2026-10-19 19:49:14 +02:00
Marc-André Bombeck bf84c0c11e fix(measurements): file sleep sessions by span, not by night label
The SQL page labels a sleep session by the last row of any writer; the
reconstruction labels it by its winning writer. A watch stopping before
midnight beside a phone whose in-bed span ran past it gave two days,
and matching the two by label dropped the night from the list while
the total still counted it. Each reconstructed session is now filed
under the page night whose span holds it and takes that night's label,
which the drill-down also asks by.

A sleep drill-down scans 36 hours either side of its day instead of the
whole history. Both day branches and the sleep list resolve the zone
through the same stored-zone resolver, so a drill-down always cuts the
days its list row summed. The list description states the real page
bounds: a day-sum page takes `limit` as asked, a sleep page caps at
1000 nights.

Refs #1245
2026-10-19 19:49:11 +02:00
Marc-André Bombeck b5285f0f65 test(recovery): settle the connection status before reading the empty note
The recovery page now holds its empty note until the integrations
status has loaded. The data-gating test rendered without that status
and saw the loading placeholder; it now seeds an account with no
integrations, the settled answer the general note belongs to.

Refs #1204
2026-10-19 19:46:03 +02:00
Marc-André Bombeck 44d8181cb4 docs(openapi): publish the sleep score value
The derived envelope types `value` as an open record, so the sleep
score's `rhythmNights` and per-sub-score `gap` were on the wire but in
no schema. `SleepScoreValue` joins `WellnessScoreValue` in the forced
components slot, the envelope's `value` description names it, and a
contract test holds the published field set to the one the engine
emits, for a scored rhythm and for one withheld for too few nights.

Refs #1204
2026-10-19 18:56:06 +02:00
Marc-André Bombeck d08e2b4e6d fix(observability): keep heap samples out of the admin log ring and harden the snapshot directory
runtime.heap.sampled is written every minute while the heap is above
70 % of its limit, which turned the 500-entry ring behind the admin log
page over in hours and pushed out the events an operator opens it for.
emitEvent takes a skipMemoryRing option; the sample sets it and still
reaches stdout and Loki, while the pressure and snapshot lines go
everywhere as before.

The snapshot directory was /tmp/healthlog-heap-<pid>, a predictable name
(pid 1 in most containers) that mkdirSync accepted when it already
existed or was a symlink. It now comes from mkdtempSync and is checked
with lstat to be a real directory owned by the process user and closed
to others before the snapshot is written.
2026-10-19 18:46:43 +02:00
Marc-André Bombeck 3bdd53ab76 fix(mcp): answer GET and DELETE with 405 so clients stop reconnecting
The route is stateless and returns one buffered JSON response per call,
so it opens no server stream and holds no session. GET let the transport
open an event stream that the handler closed again at once; a client
read that as a dropped connection and reconnected about once a second,
each attempt costing a rate-limit slot and a module lookup. GET and
DELETE now answer 405 with Allow: POST after authentication, which is
what the transport specification asks of a server without that stream,
and an unauthenticated GET still gets the 401 challenge.
2026-10-19 18:34:37 +02:00
Marc-André Bombeck c016ec8aec fix(recovery): claim no Oura permission the server cannot prove
The empty recovery note told every connected Oura account without a
recorded permission gap that its permission covers resilience. The
server cannot know that: the scope note is cleared on each reconnect
and written only by a sync that reached Oura, and a missing health
block says nothing either way. The note now asks for a reconnect only
when the gap is recorded and otherwise says only that no level has
arrived, pointing at the Oura card. It also waits for the connection
status instead of showing the general note first and swapping it.

Refs #1204
2026-10-19 18:31:45 +02:00
Marc-André Bombeck 8c745fca8d fix(sleep): treat a short night as the night, only a daytime nap leaves the clock
Keeping a nap-only day off the sleep rhythm used three hours asleep as
the line, which also took every shorter night off the clock: someone
who sleeps two and a half hours a night (insomnia, a newborn, split
sleep) lost consistency, timing, chronotype and the health score's
regularity altogether. A session now leaves the clock only when it is
short AND centred in the daytime, outside 20:00 to 10:00 local time.
A short night keeps its place, a long daytime sleep after a night
shift stays a main sleep, and the withheld-timing note now reads that
the latest day had only a nap.

Refs #1204
2026-10-19 18:30:06 +02:00
Marc-André Bombeck 3a9f619001 chore(release): v1.43.6 2026-10-19 18:18:12 +02:00
Marc-André Bombeck 9d6bfe2624 Merge branch 'fix/v1436-gh' into release/v1.43.6 2026-10-19 17:26:51 +02:00
Marc-André Bombeck 8ce8065372 test(integration): size the multi-year backfill run from the environment
The multi-year Google Health backfill run walked three and four years of
heart rate on every pull request, some 3.7 million readings in over twenty
minutes, which filled most of its shard's time budget. What a run costs is
its pages, so a shorter history only helps with shorter slices.

HEALTHLOG_BACKFILL_TEST_DAYS now sets the size. Unset, the runs walk 120
and 160 days in 50-page slices with the faults at the same places within
them and the light collections already walked, about a tenth of the
pages. The heap growth bound applies to the full run only. The full three
and four years run nightly and on demand in integration-long.yml.

Refs #1220
2026-10-19 17:26:04 +02:00
Marc-André Bombeck f6b09f07cd feat(integrations): say where an imported Google Health history ends
The two status routes publish backfillEnded: each data type whose
backfill stopped short of the start of its history, with the instant it
reaches back to and whether the horizon or a walk that stopped moving
ended it. The integration card lists them in place of an import that
would otherwise read as still loading, in all seven locales.

Refs #1220
2026-10-19 17:25:57 +02:00
Marc-André Bombeck 72c259d85c fix(google-health): bound the backfill by progress and a horizon, not pages
A resumable collection counted the pages it had written across every
backfill attempt and stopped at its ceiling (10,000 for heart rate). A
watch that records heart rate every few seconds reaches that count two to
three years back, and since it never reset, every later slice stopped at
the same place, failed as stuck, and the backfill was never stamped
complete.

A walk is now stopped only when it stops moving: 20 pages in a row that
reach no further back than the pages before them end it. A history
backfill reads back at most GOOGLE_HEALTH_BACKFILL_HORIZON_YEARS (20 when
unset). A data type that ends either way counts as finished, and where and
why it ended is kept in backfillProgress past the completion stamp. An
oldest-first list is read whole, as before. The slice chain's ceiling is
derived from sixty days of back-to-back slices instead of the old page
count. An account left at the old ceiling continues from its edge with
the next slice.

Refs #1220
2026-10-19 17:25:48 +02:00
Marc-André Bombeck 0e6e50fc7b Merge branch 'fix/v1436-ui' into release/v1.43.6 2026-10-19 17:04:31 +02:00
Marc-André Bombeck 362afe2333 Merge branch 'fix/v1436-heap' into release/v1.43.6 2026-10-19 17:04:30 +02:00
Marc-André Bombeck 09c93cd547 Merge branch 'fix/v1436-oura' into release/v1.43.6 2026-10-19 17:04:29 +02:00
Marc-André Bombeck 378de45f88 Merge branch 'fix/v1436-locks' into release/v1.43.6 2026-10-19 17:04:29 +02:00
Marc-André Bombeck d953789ecc chore: spell the value-range separator as an escape
The template key in the measurement list carried a literal NUL byte,
which made tools treat the file as binary.
2026-10-19 16:43:54 +02:00
Marc-André Bombeck 38c106e358 fix(ui): draw no empty frame and say when a read failed
"Since the last visit" was wrapped in a card by the visits section, so
the card stayed on screen empty while the window loaded, when it held
nothing and when the read failed. The component now owns its card and
draws it only with a row; a failed read shows a compact error row.

The same shapes elsewhere: the caffeine card and the mood forecast no
longer open a loading card that most often collapses, and a failed read
on them, the mood tag catalogue, the usual-range strip, the cycle ring
and the measurement drill-down now says so with a retry instead of
passing for no data. The scores strip keeps the ring's cell on a failed
calendar read. The mental-health card no longer draws an empty action
slot for a viewer without the right to start.

The frame guard now also flags a frame whose only content sits behind a
condition.

Refs #1241
2026-10-19 16:43:52 +02:00
Marc-André Bombeck d00281f35a test: count neither a comment nor a zero offset as a pager
The client fetch-limit guard took any `offset` within three lines as a
pager. The day-grouped measurement list passed it with `limit` 5000
between a comment about pagination and `offset` pinned to "0", while it
drew a whole history on one page. Comment lines, trailing comments and
inline block comments no longer count, nor does an offset pinned to a
literal zero in any of the three spellings. The old list shape is a
test case and fails the guard.

Refs #1245
2026-10-19 16:43:50 +02:00
Marc-André Bombeck cfd6219981 fix(measurements): page day sums and sleep nights in SQL
The day-grouped list read the newest `limit` raw rows (the web asked
for 5000), folded them into days and returned every day at once: one
page of thousands of rows, and every day older than the 5000th raw row
missing without a word. The sleep list did the same at a year.

Both now pick the page in Postgres. The day mode selects the page's
local days with the distinct-day total from `COUNT(*) OVER ()` in the
same query, then folds only those days into per-source groups in SQL
and lets the source ladder choose among the groups, so no raw row
reaches the process. The sleep mode cuts sessions with the same gap
rule as the reconstruction, pages wake days the same way and reads only
the page's stage rows; the 365-day cut and `meta.windowFrom` are gone.
A sleep page holds at most 1000 nights, echoed in `meta.limit`.

The web list pages both modes at 25 like the per-sample list. The
`partial` day marker is dropped from the contract; no day is cut any
more.

Refs #1245
2026-10-19 16:43:39 +02:00
Marc-André Bombeck 16024397c8 feat(observability): log heap samples and a one-time pressure report
runtime.heap.sampled records heap, old space, RSS, external memory and
the work in flight every five minutes, every minute at or above 70 % of
the heap limit. At 85 % runtime.heap.pressure lists the oldest requests
and jobs in flight and the routes most of them share, once, re-armed
only after the heap falls below 70 % and thirty minutes pass. A
heap-limit crash leaves no other record of what the process was doing.

HEALTHLOG_HEAP_SNAPSHOT=1 adds one heap snapshot at that moment into
HEALTHLOG_DIAG_DIR (0700 directory, 0600 file), off by default.
docs/ops/heap-diagnostics.md warns that a snapshot holds decrypted
health data and keys and says how to delete it.
2026-10-19 16:00:53 +02:00
Marc-André Bombeck 5923dac8aa feat(observability): keep a bounded register of requests and jobs in flight
Every HTTP request the server accepts is registered through Node's
http.server.request.start diagnostics channel and removed when its
response closes, finished or aborted, so pages, RSC payloads and server
actions are covered alongside API routes. Jobs register around their
handler in observeJob. The register holds strings only, cuts the query
string on entry, caps at 2000 entries and counts what it refuses; route
labels are scrubbed and collapsed only when a report is written.
2026-10-19 16:00:45 +02:00
Marc-André Bombeck d38ceb4beb fix(sleep): say why consistency or timing has no value
A rhythm sub-score below three nights with a main sleep was dropped from
the blend and its row read "no data", although the nights were there;
since a nap-only day no longer has a place on the clock, a ring worn
for only a few nights reaches that state more often. Each withheld
consistency or timing sub-score now carries the reason
(`few_nights`, `no_main_sleep`) and the score value carries how many
nights the rhythm is built from, so the row reads "not enough nights
yet (2 of 3)" or "latest night had no main sleep" instead. Neither is
ever shown as 0.

Refs #1204
2026-10-19 15:15:46 +02:00
Marc-André Bombeck 1606099005 build(deps): keep the Next.js packages out of the weekly groups
A red Next bump inside the production group held the twelve harmless
updates next to it red as well. The three Next packages open as single
pull requests, so each is judged on its own checks.
2026-10-19 15:09:50 +02:00
Marc-André Bombeck 7b17ec819a chore: drop personal names from comments and test fixtures
Comments referred to the maintainer by name, and a test fixture carried a
full real name. Comments describe the behaviour; fixtures use placeholders.
2026-10-19 14:57:00 +02:00
Marc-André Bombeck 146fcac079 chore: write NUL as an escape instead of a raw byte
Raw NUL bytes in source files make grep and ripgrep treat the file as
binary and skip it, so sweeps silently miss it. The affected string and
regex literals use the escape now; behaviour is unchanged.
2026-10-19 14:57:00 +02:00
Marc-André Bombeck ff3559c169 fix(workouts): render workout dates in the profile timezone
The workout list, the workout header and the dashboard tile formatted
instants through the shared formatter cache without a timeZone, so they
followed the browser's zone. The year comparison in the list used the
browser's calendar as well. All three now take the profile zone, and the
timezone guard also matches getDateTimeFormat(locale, options) calls that
leave the zone out.
2026-10-19 14:56:53 +02:00
Rob de Graaf c6f61fad33 feat(i18n): add Dutch as an eighth end-to-end locale
Adds messages/nl.json (AI-initial, informal je/jij register, full key
parity with en.json) and registers nl everywhere the locale list lives:
config, loaders, Intl/date-fns mapping, zod enums, OpenAPI spec, scripts,
the managed-profile picker and the notification locale guards.

Dutch follows the Korean pattern for the AI surfaces (English body plus a
Dutch reply-language footer, no safety-contract matrix) and gets its own
banks in the outbound safety screen (dose, risk, causal, continuation),
the prose-grounding and citation-coverage tables, the reference-range
prose and the no-key fallback copy, each with Dutch test fixtures.

PHQ-9 / GAD-7 / WHO-5 are not marked validated for nl yet: the Dutch
wording has not been read against the published forms, so Dutch readers
get the English items plus the existing note until it has.
2026-10-19 11:06:55 +02:00
Marc-André Bombeck 40b1389970 fix(recovery): name what is missing on an Oura account's empty recovery page
An Oura ring fills the recovery page through its resilience level alone.
The empty note listed every source that could fill the page, so a
connected ring with no level read the same as no wearable at all. The
note now follows the server's view of the Oura connection: when the
grant leaves out the heart-health data the level sits behind, it asks
for one reconnect and links to the integrations settings; when the grant
covers it, it says Oura has not sent a level for this account yet.

Refs #1204
2026-10-19 10:53:59 +02:00
Marc-André Bombeck 7f2f473a58 fix(sleep): keep a nap-only day off the sleep rhythm
A day whose ring or watch recorded no night has a nap as its largest
session, and its afternoon midpoint sat hours from every other night.
One such day in a month pushed the midpoint spread past the 90-minute
full scale and read a regular rhythm as Consistency 0. A day's midpoint
now needs a main session of at least three hours asleep, the line
wearables draw between a nap and a main sleep. The day keeps its
minutes; it only has no place on the clock for consistency, timing,
chronotype and the health score's regularity.

Refs #1204
2026-10-19 10:25:30 +02:00
dependabot[bot] 01bf8fbb64 build(deps): bump the codeql-action group with 3 updates (#1242)
Bumps the codeql-action group with 3 updates: [github/codeql-action/init](https://github.com/github/codeql-action), [github/codeql-action/analyze](https://github.com/github/codeql-action) and [github/codeql-action/upload-sarif](https://github.com/github/codeql-action).


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

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

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

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-18 16:35:04 +00:00
Marc-André Bombeck dac778dac7 Merge pull request #1240
Release v1.43.5
2026-10-18 13:51:27 +02:00
Marc-André Bombeck ade7a252f0 chore(release): v1.43.5 2026-10-18 13:11:47 +02:00
Marc-André Bombeck 947551cd8e fix(jobs): name the stage and condition when the tombstone purge fails
A failed run threw the bare Prisma error, which the support report showed
as "Error". The reason now carries the stage and the database condition
(statement_timeout, lock_timeout, pool_timeout, ...), and the report's
error class reads the SQLSTATE out of a Prisma message.

Refs #1239
2026-10-18 13:10:21 +02:00
Marc-André Bombeck 1d7077286f feat(admin): flag a heap limit below 700 MB with the setting to raise
The support report and the system status now say in one sentence when the
heap is the 1 GB-container size and point at APP_MEMORY_LIMIT.

Refs #1239
2026-10-18 13:10:20 +02:00
Marc-André Bombeck ac047854ba fix(backup): stream documents and ECG traces through backup and restore
The weekly copy read every stored document and ECG trace whole, then held
their base64, the section JSON and the preview copies beside them. A vault
of 190 MB peaked at 930 MB of heap and took a 524 MB process down on every
run. Documents now leave the database one at a time in 1.5 MB slices, the
traces a page at a time, every other section is written an element at a
time, and a sealed piece never exceeds its chunk size. The restore keeps
document bytes out of its first read and writes each document from a
second one. Same file format, byte for byte.

Refs #1239
2026-10-18 13:10:19 +02:00
Marc-André Bombeck 9db0f09ba5 Merge pull request #1238
Release v1.43.4
2026-10-17 12:07:08 +02:00
Marc-André Bombeck 5afa22b382 chore(release): v1.43.4 2026-10-17 11:37:20 +02:00
Marc-André Bombeck e8e3cf4cd3 Merge remote-tracking branch 'origin/main' into fix/v1434-illness-symptoms 2026-10-17 11:35:55 +02:00
Marc-André Bombeck 271e8d6ea8 Merge pull request #1236
Release v1.43.3
2026-10-17 11:09:58 +02:00
Marc-André Bombeck c1df60693b fix(symptoms): assign an occurrence to an illness by its link, not its time
The episode detail listed every symptom occurrence that fell inside the
episode's onset-to-recovery span as well as the ones filed against it, so a
long-running condition showed the symptoms of every illness overlapping it,
and picking an illness in the entry sheet changed nothing on the page. The
occurrence edit sheet carried no illness link at all, so once logged the
assignment could be neither seen nor changed.

- The episode card reads only the occurrences filed against the episode
  (`SymptomEvent.episodeId`), with a guard against any other.
- The edit sheet loads the link and edits it like the time and intensity;
  an unchanged link is not re-sent, so a link the list cannot show stays.
- One rule decides which episodes may take an occurrence
  (`isEpisodeLinkableAt`): an open one, or a resolved one whose span holds
  the occurrence's time. The server checks it against the time after the
  edit, and the entry and edit sheets offer exactly what it admits, so a
  backdated occurrence can be filed with an illness that has since ended.
- The illness page names the illness on each occurrence row.
- The day view opens a symptom's illness only while that episode exists.

The stored links were always written correctly, so no data repair is needed.

Refs #1237
2026-10-17 11:00:02 +02:00
Marc-André Bombeck d67032db96 test(e2e): wait for the document title before scanning the handover screens
The decision screen is reached by a client navigation, during which the old
route's title unmounts and the new one mounts from a suspended metadata
boundary that can reveal after the body has painted. Under load the scan ran
in that stretch and reported document-title. The helper now asserts the
title first, so a screen that never gets one still fails.

Refs #959
2026-10-17 10:43:59 +02:00
Marc-André Bombeck b254814bae test(e2e): walk the re-proof dialog when the rotation's step-up has aged out
An aged-out step-up no longer ends in a refusal sentence on the card: the
refusal names the proofs it takes and the shared re-proof dialog opens above
the pending confirmation. The journey now asserts that dialog, that
cancelling it sends nothing more and leaves the codes as they were, and that
proving the factor there runs the rotation once more and issues a fresh
batch. The proof's code waits for the step after the enrolment code, which
the replay floor would refuse.

Refs #959
2026-10-17 10:43:57 +02:00
Marc-André Bombeck 792fc89a93 fix(auth): count a passkey as the second factor on the managed-profile acts
An account whose only strong credential is a primary passkey was refused
auth.stepup.mfa_not_enrolled on every action behind requireFreshMfa, so it
could not create, edit, hand over or delete a managed profile or manage its
guardians, although a passkey sign-in or re-proof already stamps the
session the gate reads.

requireFreshMfa now defaults to the any-possession proof source: a passkey
makes the gate reachable, and a passkey re-proof through the dialog clears
it. The refusal offers passkey in meta.methods, never the password.

hasSecondFactorEnrolled keeps its narrow meaning on purpose. It decides
whether the conditional gates apply at all (account deletion, the data
reset, the password change, the export, offering manage access, the
recent-proof branches and the native Bearer rules), so none of those
change, and password sign-in still challenges only TOTP and security keys.
The MFA-management routes keep the second-factor source. The decision and
its limits are written beside FreshFactorProofSource.

Copy, the OpenAPI description and the sharing and key-rotation docs say
"second factor or passkey" where this gate applies.

Refs #959
2026-10-17 09:56:30 +02:00
Marc-André Bombeck d9b6061d28 chore(bundle): raise the aggregate budget for the re-proof dialog callers
The re-proof dialog now opens from every fresh-factor refusal, so the hook
and the dialog are imported by more on-demand chunks, which the aggregate
counts once each. The build measured 4977 KB gz against the 4975 cap; every
watched route stays inside its allowance. Loading the dialog through
next/dynamic measured 4979 and grew every watched route, so it is not kept.

Refs #959
2026-10-17 09:56:30 +02:00
dependabot[bot] 048ef0020e build(deps-dev): bump the development-weekly group across 1 directory with 8 updates (#1235)
Bumps the development-weekly group with 8 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [@testcontainers/postgresql](https://github.com/testcontainers/testcontainers-node) | `12.2.0` | `12.2.1` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `26.6.4` | `26.6.5` |
| [@vitest/coverage-v8](https://github.com/vitest-dev/vitest/tree/HEAD/packages/coverage-v8) | `5.0.3` | `5.0.4` |
| [knip](https://github.com/webpro-nl/knip/tree/HEAD/packages/knip) | `6.40.0` | `6.41.0` |
| [lefthook](https://github.com/evilmartians/lefthook) | `2.2.1` | `2.2.2` |
| [prettier](https://github.com/prettier/prettier) | `3.9.9` | `3.9.10` |
| [testcontainers](https://github.com/testcontainers/testcontainers-node) | `12.2.0` | `12.2.1` |
| [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) | `5.0.3` | `5.0.4` |



Updates `@testcontainers/postgresql` from 12.2.0 to 12.2.1
- [Release notes](https://github.com/testcontainers/testcontainers-node/releases)
- [Commits](https://github.com/testcontainers/testcontainers-node/compare/v12.2.0...v12.2.1)

Updates `@types/node` from 26.6.4 to 26.6.5
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `@vitest/coverage-v8` from 5.0.3 to 5.0.4
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v5.0.4/packages/coverage-v8)

Updates `knip` from 6.40.0 to 6.41.0
- [Release notes](https://github.com/webpro-nl/knip/releases)
- [Commits](https://github.com/webpro-nl/knip/commits/knip@6.41.0/packages/knip)

Updates `lefthook` from 2.2.1 to 2.2.2
- [Release notes](https://github.com/evilmartians/lefthook/releases)
- [Changelog](https://github.com/evilmartians/lefthook/blob/master/CHANGELOG.md)
- [Commits](https://github.com/evilmartians/lefthook/compare/v2.2.1...v2.2.2)

Updates `prettier` from 3.9.9 to 3.9.10
- [Release notes](https://github.com/prettier/prettier/releases)
- [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/prettier/compare/3.9.9...3.9.10)

Updates `testcontainers` from 12.2.0 to 12.2.1
- [Release notes](https://github.com/testcontainers/testcontainers-node/releases)
- [Commits](https://github.com/testcontainers/testcontainers-node/compare/v12.2.0...v12.2.1)

Updates `vitest` from 5.0.3 to 5.0.4
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v5.0.4/packages/vitest)

---
updated-dependencies:
- dependency-name: "@testcontainers/postgresql"
  dependency-version: 12.2.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development-weekly
- dependency-name: "@types/node"
  dependency-version: 26.6.5
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development-weekly
- dependency-name: "@vitest/coverage-v8"
  dependency-version: 5.0.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development-weekly
- dependency-name: knip
  dependency-version: 6.41.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development-weekly
- dependency-name: lefthook
  dependency-version: 2.2.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development-weekly
- dependency-name: prettier
  dependency-version: 3.9.10
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development-weekly
- dependency-name: testcontainers
  dependency-version: 12.2.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development-weekly
- dependency-name: vitest
  dependency-version: 5.0.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development-weekly
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-17 07:28:11 +00:00
Marc-André Bombeck bb53c7484f chore(release): v1.43.3 2026-10-17 09:17:59 +02:00
Marc-André Bombeck 99f0607a69 fix(auth): let every fresh-factor refusal open the re-proof dialog
A Guardian signed in through single sign-on with a second factor enrolled
could not hand a managed profile over once five minutes had passed since
sign-in: the handover answered 401 auth.stepup.required, and the panel
printed "Confirm your second factor, then try again" with no way to do so.

The same held for every action behind requireFreshMfa: creating, editing
and ending a managed profile, adding and removing a Guardian, disabling
the second factor, rotating recovery codes, removing a security key or a
passkey, changing the password, the data wipe, account deletion and the
key rotation. The recent-proof routes had the dialog; these had text.

- requireFreshMfa names the proofs that clear a stale stamp in
  meta.methods (the possession proofs the account holds, never a
  password, which stamps reproofAt only).
- The re-proof flow treats that refusal like auth.reproof.required:
  open the dialog, post the proof to /api/auth/reproof, send the request
  once more. The flow moves into a React-free module so it can be driven
  in tests; the hook binds it to the dialog.
- The managed-profile mutation hooks take a required proof guard, and
  every caller passes recentProof.run. The other surfaces wrap their
  requests in recentProof.run; disabling the second factor asks before
  its confirmation opens, so the code typed there is not spent twice.
- An account with nothing to re-prove in place (single sign-on only, no
  factor) is told to sign in again when adding a factor, instead of being
  offered a password it does not have.
- step-up-web-caller-guard freezes the gated route methods and fails when
  a web request to one of them runs outside the flow.

An account whose only second factor is the identity provider's is still
asked for an app factor on the managed-profile acts: the app cannot see
the provider's factor. Enrolling right after a sign-in needs no further
proof, and the confirmation stamps the session, so the act follows.

Refs #959
2026-10-17 09:17:03 +02:00
2852 changed files with 129785 additions and 27013 deletions

No files matched your search

+8
View File
@@ -178,6 +178,14 @@ API_TOKEN_HMAC_KEY="REPLACE-WITH-OUTPUT-OF-openssl-rand-hex-32"
# leave the host unencrypted.
# LOG_INCLUDE_STACK="true"
# Heap diagnostics, off by default. HEALTHLOG_HEAP_SNAPSHOT=1 writes ONE heap
# snapshot the first time the heap reaches 85 % of its limit, into
# HEALTHLOG_DIAG_DIR (default /tmp). A snapshot contains decrypted health data
# and keys; enable it only to chase a crash and delete the file afterwards.
# See docs/ops/heap-diagnostics.md.
# HEALTHLOG_HEAP_SNAPSHOT="1"
# HEALTHLOG_DIAG_DIR="/tmp"
# Optional: ship Wide Events to Loki. Leave blank for stdout-only logging.
# LOKI_ENDPOINT="http://loki:3100"
# LOKI_USERNAME=""
+24 -6
View File
@@ -49,6 +49,11 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
# "<NEXT_PUBLIC_APP_URL>/api/google-health/callback"; it must stay same-origin
# with NEXT_PUBLIC_APP_URL or the handshake is refused.
# GOOGLE_HEALTH_REDIRECT_URI=""
#
# How many years back the history backfill reads after a connect: a whole
# number from 1 to 100, 20 when unset. A data type whose history goes further
# back ends at that point, and the integration card names the date.
# GOOGLE_HEALTH_BACKFILL_HORIZON_YEARS="20"
# -----------------------------------------------------------------------------
@@ -148,9 +153,11 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
# Oura Cloud v2 -- optional integration (full OAuth, shared app from env)
# -----------------------------------------------------------------------------
# Register an app at https://cloud.ouraring.com/oauth/applications and set its
# redirect URI to `${NEXT_PUBLIC_APP_URL}/api/oura/callback`. Both vars together
# enable the Oura connect button. Oura uses refresh tokens; the sync refreshes
# reactively on a 401. OURA_REDIRECT_URI overrides the derived callback.
# redirect URI to `${NEXT_PUBLIC_APP_URL}/api/oura/callback`. Enable the Daily,
# Personal, SpO2, Heart health and Stress permissions on it; HealthLog asks for
# exactly those. Both vars together enable the Oura connect button. Oura uses
# refresh tokens; the sync refreshes reactively on a 401. OURA_REDIRECT_URI
# overrides the derived callback.
# OURA_CLIENT_ID=""
# OURA_CLIENT_SECRET=""
# OURA_REDIRECT_URI=""
@@ -526,9 +533,11 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
# TRUST_CF_CONNECTING_IP="1"
# Memory cap of the app container (read by docker-compose.yml, not by the app).
# Node sizes its heap from it: 1536m (the default) gives 792 MB, 1g only 524 MB,
# which a long-used account fills in normal operation. Lower it on a host with
# 2 GB or less, raise it on a busy instance.
# Node sizes its heap from it: 1536m (the default) gives 792 MB. 1536m is the
# supported floor: below it a long-used account fills the heap in normal
# operation, and the boot log, the support report and Admin > System status
# say the app runs below the supported floor. The host needs 3 GB of RAM,
# 4 GB recommended. Raise it on a busy instance; do not lower it.
# APP_MEMORY_LIMIT="1536m"
# -----------------------------------------------------------------------------
@@ -653,6 +662,15 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
# LOG_SLOW_THRESHOLD_MS="3000"
# LOG_INCLUDE_STACK="false"
# Heap diagnostics, off by default. The process always logs its heap
# (runtime.heap.sampled) and, at 85 % of the limit, one report of the work in
# flight (runtime.heap.pressure). HEALTHLOG_HEAP_SNAPSHOT=1 additionally writes
# ONE heap snapshot at that moment into HEALTHLOG_DIAG_DIR (default /tmp). A
# snapshot contains decrypted health data and keys; enable it only to chase a
# crash and delete the file afterwards. See docs/ops/heap-diagnostics.md.
# HEALTHLOG_HEAP_SNAPSHOT="1"
# HEALTHLOG_DIAG_DIR="/tmp"
# Optional log shipping to a Loki instance. LOKI_ENDPOINT turns shipping on.
# Either form works: the base URL (http://loki:3100) or the full push URL
# (http://loki:3100/loki/api/v1/push). A Loki on your own network is fine,
+4 -2
View File
@@ -1,12 +1,12 @@
name: Translation feedback
description: Report a wording, grammar, or register problem in HealthLog's AI-initial locales (FR / ES / IT / PL).
description: Report a wording, grammar, or register problem in HealthLog's AI-initial locales (FR / ES / IT / PL / KO / NL).
title: "[Translation] "
labels: ["i18n", "translation-feedback"]
body:
- type: markdown
attributes:
value: |
HealthLog ships German and English as maintainer-curated translations. French, Spanish, Italian, and Polish ship as **AI-initial translations** — the prose passes the locale-integrity test (full key parity, no empty values) but has not been hand-reviewed for register or regional vocabulary.
HealthLog ships German and English as maintainer-curated translations. French, Spanish, Italian, Polish, Korean, and Dutch ship as **AI-initial translations** — the prose passes the locale-integrity test (full key parity, no empty values) but has not been hand-reviewed for register or regional vocabulary.
This template captures concrete fixes you would like to see. Phrasing nuance, regional vocabulary (e.g. "Tú" vs "Usted"), gendered forms, and domain-specific health terms are all in scope. The maintainer applies fixes during the regular release cycle.
@@ -20,6 +20,8 @@ body:
- Spanish (es)
- Italian (it)
- Polish (pl)
- Korean (ko)
- Dutch (nl)
- Other (specify under "Surface")
validations:
required: true
+22 -13
View File
@@ -13,29 +13,38 @@ updates:
# One weekly pull request per dependency kind instead of a trickle of
# single bumps: patch and minor updates travel together and pass the
# same checks once. Majors stay single so each gets its own review.
#
# The Next.js line travels alone. Its bumps are the ones that move
# URL state after history traversal (see the note on 16.4 below), and a
# red Next bump inside the group would hold every harmless update in
# it red as well.
production-weekly:
dependency-type: "production"
update-types:
- "patch"
- "minor"
exclude-patterns:
- "next"
development-weekly:
dependency-type: "development"
update-types:
- "patch"
- "minor"
ignore:
# Next 16.4.0 breaks URL state after history traversal: with a sheet
# opened through `?doc=`, Back leaves it open, and `[` / `]` sometimes
# do not move `?day=`. Reproduced in three end-to-end journeys (3 of 40
# repeated runs on 16.4.0, 0 of 80 on 16.3.8 with everything else
# equal). 16.3.8 carries every published security fix, so the line
# waits for a 16.4 patch; the two packages that move with it wait too.
- dependency-name: "next"
versions: ["16.4.0"]
- dependency-name: "eslint-config-next"
versions: ["16.4.0"]
- dependency-name: "@next/bundle-analyzer"
versions: ["16.4.0"]
exclude-patterns:
- "eslint-config-next"
- "@next/bundle-analyzer"
# No ignore for the Next.js line any more. 16.4.0 lost URL state after
# history traversal: with a sheet opened through `?doc=`, Back left it
# open, and `[` / `]` sometimes did not move `?day=` (3 of 40 repeated
# runs of three end-to-end journeys, 0 of 80 on 16.3.8). 16.4.2 does the
# same (the day keys lost a step in 2 of 20 runs locally, the `?doc=`
# journey failed once in CI). Both were ours to fix: the sheet followed
# `useSearchParams`, which 16.4 updates after the write it copies, and a
# day step wrote the URL before Next had adopted the previous step, whose
# late adoption then put the older URL back. The sheet now follows the
# address bar and a step waits for the layer. Measured after both fixes:
# 200 of 200 repeated runs of the three journeys, and another 80 of 80
# each for the `?doc=` and day-key journeys.
- package-ecosystem: "github-actions"
directory: "/"
schedule:
+2 -2
View File
@@ -60,7 +60,7 @@ jobs:
# minutes and can produce nothing actionable, because nobody edits
# it and the fix for anything found there is `pnpm prisma generate`.
- name: Initialise CodeQL
uses: github/codeql-action/init@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3
uses: github/codeql-action/init@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0
with:
languages: javascript-typescript
build-mode: none
@@ -75,6 +75,6 @@ jobs:
- public
- name: Analyse
uses: github/codeql-action/analyze@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3
uses: github/codeql-action/analyze@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0
with:
category: "/language:javascript-typescript"
+117 -18
View File
@@ -23,9 +23,16 @@ jobs:
# Two shards grew to 14–19 minutes against the 20-minute cap once the
# mobile route sweep landed, and a slow runner tipped them over; three
# shards with a 30-minute cap leave room for both.
# Every Playwright job runs inside Microsoft's Playwright image, which ships
# the browsers with their system libraries: installing them from apt at run
# time stalled shards for 29 minutes twice, once for WebKit and once for
# Chromium alone.
shard:
runs-on: ubuntu-24.04
needs: playwright-version
timeout-minutes: 30
container:
image: mcr.microsoft.com/playwright:v${{ needs.playwright-version.outputs.version }}-noble
strategy:
fail-fast: false
matrix:
@@ -38,8 +45,6 @@ jobs:
POSTGRES_USER: healthlog
POSTGRES_PASSWORD: healthlog
POSTGRES_DB: healthlog
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U healthlog"
--health-interval 5s
@@ -47,7 +52,8 @@ jobs:
--health-retries 10
env:
DATABASE_URL: postgresql://healthlog:healthlog@localhost:5432/healthlog?schema=public
# Inside a job container the service is reached by its name.
DATABASE_URL: postgresql://healthlog:healthlog@postgres:5432/healthlog?schema=public
ENCRYPTION_KEYS: '{"v1":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}'
ENCRYPTION_ACTIVE_KEY_ID: v1
API_TOKEN_HMAC_KEY: bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb
@@ -56,10 +62,16 @@ jobs:
AUTH_RP_ORIGIN: http://localhost:3000
SESSION_COOKIE_SECURE: "false"
NODE_ENV: production
# The WebKit spec runs in the `webkit` job below.
E2E_SKIP_WEBKIT: "1"
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# The image runs as root while the checkout belongs to the runner user;
# git refuses such a tree, and the prepare hook calls git.
- run: git config --global --add safe.directory "$GITHUB_WORKSPACE"
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
@@ -73,16 +85,6 @@ jobs:
- run: pnpm db:migrate:deploy
- name: Cache Playwright browsers
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
playwright-${{ runner.os }}-
- run: pnpm exec playwright install --with-deps chromium
# `pnpm build` (not bare `next build`): the prebuild hook generates
# the versioned service-worker stamp AND the static i18n catalog
# assets (public/i18n/<locale>.js) the client boots from — without
@@ -124,19 +126,116 @@ jobs:
retention-days: 7
if-no-files-found: error
# The Playwright image must carry the browsers of the exact Playwright the
# lockfile resolves, or the run refuses them. Read the version from the
# lockfile so a dependency bump moves the image with it.
playwright-version:
runs-on: ubuntu-24.04
timeout-minutes: 5
outputs:
version: ${{ steps.read.outputs.version }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
sparse-checkout: pnpm-lock.yaml
sparse-checkout-cone-mode: false
- id: read
run: |
version=$(grep -m1 -E "^ playwright-core@[0-9]" pnpm-lock.yaml | sed -E "s/^ playwright-core@([0-9.]+):.*/\1/")
if [ -z "$version" ]; then echo "playwright-core not found in pnpm-lock.yaml"; exit 1; fi
echo "version=$version" >> "$GITHUB_OUTPUT"
# The WebKit spec (`bottom-sheet.spec.ts`, project `webkit-iphone`), in the
# same image as the shards.
webkit:
runs-on: ubuntu-24.04
needs: playwright-version
timeout-minutes: 30
name: e2e webkit
container:
image: mcr.microsoft.com/playwright:v${{ needs.playwright-version.outputs.version }}-noble
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_USER: healthlog
POSTGRES_PASSWORD: healthlog
POSTGRES_DB: healthlog
options: >-
--health-cmd "pg_isready -U healthlog"
--health-interval 5s
--health-timeout 5s
--health-retries 10
env:
# Inside a job container the service is reached by its name.
DATABASE_URL: postgresql://healthlog:healthlog@postgres:5432/healthlog?schema=public
ENCRYPTION_KEYS: '{"v1":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}'
ENCRYPTION_ACTIVE_KEY_ID: v1
API_TOKEN_HMAC_KEY: bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb
AUTH_RP_NAME: HealthLog
AUTH_RP_ID: localhost
AUTH_RP_ORIGIN: http://localhost:3000
SESSION_COOKIE_SECURE: "false"
NODE_ENV: production
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# The image runs as root while the checkout belongs to the runner user;
# git refuses such a tree, and the prepare hook calls git.
- run: git config --global --add safe.directory "$GITHUB_WORKSPACE"
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm db:generate
- run: pnpm db:migrate:deploy
- run: NODE_OPTIONS=--max-old-space-size=8192 pnpm build
- run: pnpm exec playwright test --project=webkit-iphone
env:
E2E_BASE_URL: http://localhost:3000
- name: Upload Playwright HTML report
if: always()
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: playwright-report-webkit
path: playwright-report/
retention-days: 7
if-no-files-found: ignore
- name: Upload Playwright JSON report
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: playwright-json-shard-webkit
path: playwright-json/results.json
retention-days: 7
if-no-files-found: error
# Branch protection requires a check named exactly "e2e". This job carries
# that name and answers for both shards: it fails when any shard failed or
# was cancelled, so a half-green matrix cannot satisfy the gate.
# that name and answers for every shard and the WebKit job: it fails when
# any of them failed or was cancelled, so a half-green run cannot satisfy
# the gate.
e2e:
runs-on: ubuntu-24.04
needs: shard
needs: [shard, webkit]
if: always()
timeout-minutes: 5
steps:
- name: Every shard finished green
run: |
if [ "${{ needs.shard.result }}" != "success" ]; then
echo "shard result: ${{ needs.shard.result }}"
if [ "${{ needs.shard.result }}" != "success" ] || [ "${{ needs.webkit.result }}" != "success" ]; then
echo "shard result: ${{ needs.shard.result }}, webkit result: ${{ needs.webkit.result }}"
exit 1
fi
+45
View File
@@ -0,0 +1,45 @@
name: Integration tests (long)
# The integration tests that take their size from the environment run small
# on every pull request and at full size here, once a night. Today that is
# the Google Health multi-year backfill: 120 and 160 days of heart rate on a
# pull request, three and four years (some 3.7 million readings, over twenty
# minutes) here. See the header of
# tests/integration/google-health-backfill-multi-year.test.ts.
on:
schedule:
- cron: "17 3 * * *"
workflow_dispatch:
permissions:
contents: read
jobs:
long:
runs-on: ubuntu-24.04
timeout-minutes: 75
name: integration long
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm db:generate
- run: pnpm exec vitest run --config vitest.integration.config.mts tests/integration/google-health-backfill-multi-year.test.ts
env:
HEALTHLOG_BACKFILL_TEST_DAYS: "1095"
# Under the production heap ceiling, which the test's heap check
# is about.
NODE_OPTIONS: "--max-old-space-size=524"
# Quoted for the reason given in integration.yml.
API_TOKEN_HMAC_KEY: "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"
ENCRYPTION_KEY: "0000000000000000000000000000000000000000000000000000000000000000"
SESSION_SECRET: "integration-tests"
+1 -1
View File
@@ -54,7 +54,7 @@ jobs:
publish_results: true
- name: Upload results
uses: github/codeql-action/upload-sarif@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3
uses: github/codeql-action/upload-sarif@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0
with:
sarif_file: scorecard-results.sarif
category: scorecard
+2 -2
View File
@@ -88,7 +88,7 @@ jobs:
skip-dirs: src/generated
- name: Upload results
uses: github/codeql-action/upload-sarif@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3
uses: github/codeql-action/upload-sarif@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0
with:
sarif_file: trivy-filesystem.sarif
category: trivy-filesystem
@@ -145,7 +145,7 @@ jobs:
exit-code: "0"
- name: Upload results
uses: github/codeql-action/upload-sarif@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3
uses: github/codeql-action/upload-sarif@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0
with:
sarif_file: trivy-published-image.sarif
category: trivy-published-image
+703
View File
@@ -1,5 +1,708 @@
# Changelog
## [1.44.2] — 2026-10-22
### Changed
- **Sheets that come up from the bottom can be pulled.** On a phone every
sheet now follows your finger: pull it down to close it, flick it away,
or let go early and it springs back. Content that is scrolled scrolls
first, sideways gestures such as sliders keep working, and the X, Escape
and a tap outside still close it. The day view opens at half height as
before and pulls up to almost the full screen. The page behind no longer
reloads when you pull inside a sheet, and closing a sheet puts the focus
back where you were.
### Fixed
- **Your own targets shape the dashboard charts.** Blood pressure, resting
pulse and body fat drew the standard band on the dashboard even when you
had set your own targets; they now draw yours, and the tile colours follow
them. The oxygen saturation page uses your target too. Without your own
targets nothing changes. (#1261)
- **Local thinking models finish the briefing, and regenerate waits for
them.** A thinking model such as Gemma 4 on Ollama could spend a long
briefing thinking and hand back no answer, which was logged as a
connection problem and shown as a timeout. A reply with nothing but
thinking is now asked once more without thinking, and that model is asked
that way for the next hour, through the Local provider and an
OpenAI-compatible gateway alike. The regenerate button waits as long as
your AI response timeout allows instead of giving up after 45 seconds, and
so does saving your notes about yourself; a local model allowed more than
five minutes is no longer cut off at five. A failed call is logged with
what actually went wrong, such as a refused or dropped connection, an
empty reply or a timeout, and the briefing only suggests a longer timeout
when time really ran out. The provider's reasoning setting now says that
the daily briefing thinks at its own depth. (#1267)
### Thanks
Thanks to @mills1975 for spotting that the targets never reached the chart
(#1261).
Thanks to @Tio61 for the precise report that traced the regenerate timeout
to its line and ruled out the transport with their own requests from inside
the container (#1267).
## [1.44.1] — 2026-10-22
### Security
- **Next.js 16.4.2.** The framework fixed two critical advisories that
allow unauthenticated remote code execution, one in `use cache` functions
that return typed arrays (GHSA-673q-mjm6-r5xr) and one in the image
optimizer when it handles SVG (GHSA-2464-fhr4-w9xw), and a high-severity
denial of service through Server Actions (GHSA-p3m5-59mp-jv5r). HealthLog
uses neither `use cache` nor the image component, and it defines no Server
Actions and refuses requests that call one. Every instance runs the same
framework, though, so it updates anyway.
- **The image optimizer is off.** Nothing in HealthLog renders through it,
so `/_next/image` now answers 404 instead of processing whatever image a
request names. Icons and logos load as before.
- **nodemailer 10.1.7** fixes several advisories in how email addresses are
parsed (GHSA-8c8m-p3m8-cjg8 among them).
### Fixed
- **`[` and `]` move the open day on every press.** Pressed quickly in a
row, one press could be lost and the day ended one short. A step now
starts from the day in the address bar and waits until the previous one
is shown.
- **Back closes a document opened from the vault.** Pressed right after
opening a document, Back could leave its sheet open. The sheet now follows
the address bar directly.
### Upgrade notes
- None. Pull the new image and restart.
## [1.44.0] — 2026-10-22
### Added
- **Import lab results and medications from a spreadsheet.** Settings >
Export & Import has a card that reads the head of a CSV file, suggests a
column for each field from its header, and lets you correct the
separator, decimal mark, date order and time zone. A check run lists
every refused row with its line and reason before anything is written,
and importing the same file again only finds duplicates. Templates for
both kinds download from the card. (#1215)
- **An activity calendar** on the workouts page: a week or month of steps,
active minutes, workouts by sport and training load, each day measured
against your own usual range and each period against the ones before.
Weekly and monthly goals are optional. Today counts as in progress until
it is over.
- **Fitness, fatigue and form** above the workout list, from the training
impulse HealthLog already stores. A day with activity data but no
training counts as rest; a day without any data is left out instead of
counted, and the card says how current the figures are.
- **Where a value comes from.** Each metric page links to a view that lists
every source with readings for a day, its place in your source priority
and which one the charts use, with a side-by-side comparison of two
sources over 30, 90 or 365 days.
- **Lab trends.** A marker with at least three readings over a year shows
its slope, and when the straight line would leave the reference range
within three years. A series whose newest reading is more than a year
old is shown without a projection.
- **The change at a medication start**, as a level and a trend change with
intervals, next to the existing before and after comparison. Days with
missed doses, pauses and other medications that changed in the window are
accounted for, and the view says when the data are too thin.
- **More in the doctor report:** sleep duration, HRV as SDNN and RMSSD,
daily steps, a workout summary per sport and the medication effect, each
only when its module is on. The medication effect is included only when
the medication list is shared too. The report names the window it
analysed.
- **More correlations:** water, caffeine, micronutrients, alcohol days,
workout minutes, training impulse, activity ring minutes, State of Mind
and room climate against that night's sleep. State of Mind, mindful
minutes and alcohol days stay on the correlations page and are never
handed to the Coach or the narrative cards.
- **Level shifts in thirteen vitals over 180 days** instead of five over 90.
Slow drifts and plateaus no longer count as a shift.
- **Mental health screeners** show whether a change reaches the threshold
that counts as meaningful.
- **Apple Health:** exercise, stand and mindful minutes, stand hours, State
of Mind and AFib burden. Workout GPS tracks are kept only when you turn
that on (Settings > Data & privacy); tracks already stored stay, and
accounts that already kept tracks start with it on.
- **Oura:** workouts, intraday heart rate and a full history backfill that
resumes after a restart.
- **Indoor climate** (CO2, temperature, humidity) through the measurement
API, with a Home Assistant blueprint.
- **A guardian can connect a managed profile's integrations** (Withings,
WHOOP, Oura, Polar, Strava, Google Health), and each card says whose
record it connects for.
- **FHIR:** Observations as a time series by `date` and `code`, with RMSSD
and workouts coded.
### Changed
- **Settings are sorted into nine groups:** Account, Tracking,
Arrangement, Notifications, Health profile, AI, Connections, Data and
System. Every old address still works and forwards to the new place. On
a phone one button names the current section and opens the list. Each
card saves the same way and asks once before anything destructive, and
German wording uses one word per thing.
- **All OAuth integrations refresh their tokens the same way**, so a
refresh that races another one no longer signs you out of Strava, Google
Health or Withings. One provider account can be linked to only one
record.
- **Imports from Apple Health insert large batches faster**, and the health
check answers "starting" while the background jobs come up, so a slow
boot no longer marks the container unhealthy.
- **The metric pages load about 90 KB less JavaScript** on first visit.
### Fixed
- Oura no longer imports the equivalent walking distance as distance.
- A workout synced from two sources counts once in strain, the briefing
and the day view.
- The Coach's provider and model switch is back, and notification channel
switches act on the first tap again.
- Jobs queued during start-up wait for the queue instead of being dropped.
### Upgrade notes
- **1536 MB is the supported memory floor for the app container.** This is
the existing default of `APP_MEMORY_LIMIT`; if you lowered it, raise it
back. Below the floor the app still starts, but the boot log, the
support report and Admin > System status say so. Plan for 3 GB of RAM on
the host.
- Migrations 0424, 0430 to 0434, 0440 to 0442 and 0470 run on start. They
add columns and settings, turn the GPS track setting on for accounts
that already have tracks, and drop a cached copy of the AI insights
text, which is rebuilt on the next run. Workouts with a track but no
elevation get their climb filled in by a background pass after the
first start.
- If you use Fitbit, connect Google Health before 30 October 2026, when
Google turns the old Fitbit API off.
### Thanks
Thanks to @Cnote43 for the case for a spreadsheet import of twenty years of
lab results (#1215), and to @alondin and @haritowa for staying with the
Oura connection until it held (#1204).
## [1.43.11] — 2026-10-21
### Fixed
- **Long lab reports scan completely.** A report is now read page by page
instead of in one request, so a hospital report with several panels comes
back whole, up to 20 pages and 400 readings. Where the AI provider stops
before the end of a page, the readings it already returned are kept and the
review screen names the pages to check. A scan that hits your daily AI
budget stops there and says so, and a reading whose name or unit is too
long is marked on its row instead of blocking the save. Models that think
before answering no longer use up the room meant for the readings. When a
PDF carries a text layer, it helps the model tell similar characters apart,
but the page image always wins. (#1257)
- **An upcoming appointment opens itself.** Tapping it on the dashboard, in
Today or in its reminder now opens that visit instead of the check-ups
list. The visit preparation and the doctor report offered for an
appointment start at your last visit with the same practice, otherwise the
same specialty or kind of visit, and say which; with no match they cover
the last 90 days. (#1258)
- **Withings sleep stages arrive the next morning**, not weeks later. One
failing night no longer loses the other nights of a sleep sync. Steps that
Health Mate copies in from another app are skipped only when that other
app already sends them to HealthLog for the same day.
- **Deleting a WHOOP recovery no longer removes that night's sleep.**
- **Polar** no longer asks you to reconnect when your device simply lacks a
data type, and **Strava** keeps calories and progress when its rate limit
hits.
- **Fitbit and Google Health HRV is filed as RMSSD**, apart from the SDNN
that Apple Watch and Withings report, and takes part in correlations and
illness views. Readiness, HRV balance and stress keep using SDNN sources
only; the dashboard tile, the briefing and the doctor report show RMSSD,
labelled, when you have no SDNN. Restoring a backup from before this
release keeps that split.
- **The notification settings show the mood reminder as it is delivered.**
- **Settings behave the same everywhere:** delete, revoke and remove
confirmations are red again, ending a session's kept access asks first,
cards no longer stay stuck saving after a network failure, and quiet
actions (passkeys, security keys, disconnecting an integration, share
links, injection sites, local OCR, check-up reminders) tell you how they
went.
- **Readings entered through Telegram, the assistant API or Health Connect**
now rank properly against device estimates in source priority.
### Changed
- **Fitbit stops on 30 October 2026**, when Google turns its old Fitbit API
off. From then HealthLog no longer polls Fitbit, keeps everything already
imported, and the Fitbit card explains how to move to the Google Health
integration, which reads Fitbit and Pixel data. A day recorded by both
counts once. Accounts that never used Fitbit no longer see the card after
that date.
- **Saved AI keys can be removed**, the display name can be changed in your
profile, and emergency fields can go back to "Not recorded". Setting or
removing an AI key is recorded in the audit log.
### Security
- **Hardened the markers that keep document and user text apart from
instructions** sent to an AI model. Nested markers could re-form after
cleaning; cleaning now repeats until nothing changes. This covers lab
scans, the Coach and the MCP server.
### Upgrade notes
- Migrations 0420 and 0421 run on start. 0420 refiles Fitbit and Google
Health HRV as RMSSD and clears their rollups, which are rebuilt in the
background on the first start; expect a few minutes of extra work then.
0421 adds one column for Withings sleep bookkeeping.
- If you use Fitbit, connect Google Health before 30 October. Tokens cannot
be carried over, so it is a fresh sign-in.
### Thanks
Thanks to @asmala for pinning down both limits the lab scan ran into (#1257),
and to @mills1975 for the appointment link and the report window idea
(#1258).
## [1.43.10] — 2026-10-21
### Changed
- **Range and view switches look the way the chart tabs used to.** Since
1.43.8 every row of mutually exclusive choices shares one control, and it
drew them on a grey track with the chosen option raised. Above a chart that
read heavier than the chart itself. The shared control now draws what the
dashboard's range tabs always had: no track, the chosen option filled in
the highlight colour. The range rows above charts are back to their smaller
type. Keyboard navigation and the touch size the shared control brought
stay as they are.
## [1.43.9] — 2026-10-21
### Fixed
- **Oura's resilience level syncs.** Resilience sits behind Oura's stress
permission, which HealthLog did not ask for, so it was refused even with
every other permission granted. HealthLog now asks for it. When Oura refuses
a single data type for a missing permission, the Oura card names exactly
that type, the refusal no longer triggers a token refresh and never counts
toward the reconnect warning. After updating, enable Stress for your app in
Oura's developer portal and connect Oura once more. (#1204)
- **Connecting an integration survives a repeated return link.** Some
browsers open the provider's return link twice. The second visit spent the
same one-time code again, and Oura answered by revoking the connection the
first visit had just made. Every connect flow (Oura, Polar, Strava, WHOOP,
Withings, Fitbit, Google Health) now claims the return link once; a second
visit waits for the first and lands on the same page. (#1204)
- **Two syncs no longer spend the same Oura sign-in.** Syncs that renewed the
Oura sign-in at the same moment each used the one-time renewal token, which
Oura can treat as reuse. Renewals now take turns.
- **Apple Health rows show the newest reading.** A type HealthLog also writes
back to Apple Health, such as weight entered here, showed the age of the
last sample Apple Health sent while the status above it already counted the
newer entry. The row now shows whichever is newer. (#1247)
- **An expired ChatGPT sign-in waits for you.** When the sign-in is refused as
expired, the connection is marked as needing a reconnect after the first
refusal and stays parked until you sign in again, instead of being retried
on every insight run. Signing in again puts it back in front at once. Two
renewals at the same moment take turns, so a working sign-in is never
parked by mistake.
- The demo instance no longer shows the setup checklist, whose steps cannot be
done in a read-only account.
### Changed
- **Faster syncs.** Withings syncs, full syncs included, write a batch in one
pass and skip readings that did not change, so a sync that took half a
minute finishes in about a second. Every connected service refreshes its
daily charts in one pass after a sync instead of one day at a time.
- **Faster dashboard and insights.** The all-time reading counts on the
dashboard and the all-time figures for weight, blood pressure and pulse are
read from the daily summaries instead of every reading, which makes them
many times faster for accounts with a lot of watch data. The all-time
figures fall back to the readings wherever the daily summaries do not match
them. The integrations settings card answers sooner on the first visit.
- **Lighter server.** Each signed-in request loads only the small account
fields it needs, so the profile picture and stored credentials are no longer
read into memory on every call. The server loads each page's code when it
is first used rather than all of it at start, so it starts with less than
half the memory and without a pause in its first minute; a page's first
visit after a restart takes a few milliseconds longer.
- The hourly sign-in location lookup no longer retries addresses it cannot
place on every run; it tries such an address again after a week.
- Requests that probe for server actions, which this app does not use, are
turned away with a 404 and counted in one warning line instead of filling
the error log.
- The periodic memory log line breaks the heap down by area, so a large heap
can be explained from the logs.
### Security
- The `hono` dependency floor is raised to 4.13.11 (GHSA-5r4p-p66f-jhc7).
### Upgrade notes
- Three additive migrations run at start (0417 to 0419). The first start after
the upgrade refills the daily summaries once in the background; until that
finishes, the all-time figures are read from the readings as before.
- Oura: enable the Stress permission for your app in Oura's developer portal,
then connect Oura once more and leave every permission ticked.
### Thanks
@haritowa for tracing the Oura resilience refusal to the missing permission
and for timing the repeated return link, @alondin for staying with #1204
through every round, and @mills1975 for #1247.
## [1.43.8] — 2026-10-20
### Fixed
- **Numbers and units read in your language.** Decimals follow your language
everywhere: fever, cycle and mood figures, custom metrics, nutrient totals,
chart tooltips and the sentences the server writes show 37,5 instead of 37.5
where your language uses a comma. Units such as breaths per minute, years
and minutes read in the app's language instead of the stored English token,
and the cycle and mood comparison boards use the same unit names as the rest
of the app (bpm, not S/min). File sizes read the same in the document vault
and the admin area.
- **Titles wrap instead of being cut.** Tile and card titles wrap onto a second
line with hyphenation instead of ending in an ellipsis, with room between
the lines, and tiles in a grid keep their values on one line when a long
title wraps. Dashboard trend tiles keep their full label on up to two lines.
Long health-score area names, mood tags, document titles, lab names and
token names show in full, and a long field label keeps its colon on the
last word.
- **One look for the same control.** Range, filter and choice toggles (time
ranges, card or table view, recent or all) look and behave the same
everywhere. Small text links and buttons such as "Open schedule", "Select
all" and "View all" are easy to tap on a phone and show a focus ring.
Confidence, score and range chips share one look, and medium confidence has
the same colour on every card. Empty lists and charts say "nothing here yet"
the same way across the app. Page titles on the insights pages, the
medication detail and the check-in screens match every other page title,
and compact cards keep their tight spacing on wide screens.
- The blood-pressure target card labels its systolic bar as well as its
diastolic one, and the sleep-stage legend lists only the stages the visible
nights contain.
- The Dawarich card shows one action row; set your home place from the card
menu.
- **Every published GET response is checked against its OpenAPI schema.**
Thirteen operations that disagreed now publish what they send, the single
mood entry and custom metric reads carry the same fields as their lists, and
the document chat list declares its `fenced`, `attachments` and
`documentTitle` fields.
- **Lists say when they were cut.** Practitioners, vaccinations and encounters
report a cut list and accept `offset`, and the web reads them to the end.
`hasMore` on symptom events is no longer true for a page that is exactly
full. MCP tools say when a list was cut and answer a failure with a stable
code instead of internal error text.
- **A deleted practitioner stays named where it still matters.** Visits and
doses that still show a practitioner removed from the address book keep the
name, in the app, the MCP read and appointment reminders, and the portable
export carries such a practitioner along so the visit keeps its reference
after a restore.
- Renouncing a Guardian role while that Guardian's account is being deleted no
longer answers "add another Guardian".
### Changed
- **Deleted records are removed for good after 75 days.** Deleted lab results,
visits, practitioners, allergies, vaccinations, conditions, life events,
cycles and the other soft-deleted records can be restored for 75 days and
are then purged by a nightly job. A deleted record that a live record still
points to is kept for as long as it does.
- Expired sessions, tokens, trusted devices and sign-in challenges are cleaned
up nightly.
- **Writes have one per-account limit** of 300 a minute, edits and deletes
included; bulk sync endpoints and the chat routes keep their own limits. A
temporary database problem with this limit lets the write through instead of
failing every save; the sign-in limits stay strict.
- **Cheaper writes.** Deleting a medication or an account no longer scans whole
tables for related rows, redundant indexes are gone, and writes to
measurements are cheaper. An unused measurement index is dropped at upgrade
when that can happen at once and otherwise by the nightly maintenance, so
upgrading no longer waits on a long-running database read. The unused
per-day medication compliance rollup table is removed; nothing changes in
what you see.
- **Migrations are steadier.** They run in a UTC session regardless of the
database's default time zone, so a migration that mixes `timestamp` and
`timestamptz` no longer writes shifted values on a non-UTC database. A
migration marked safe to re-run that loses a deadlock is retried like one
that timed out on a lock, and the container health check allows up to ten
minutes for a slow migration before reporting the container unhealthy.
- `/api/feature-flags` announces its deprecation and its sunset on 2027-03-31.
- The document vault and the Coach load less JavaScript.
## [1.43.7] — 2026-10-20
### Added
- **Dutch.** HealthLog speaks Dutch now, the eighth language, translated and
contributed by @robdegraaf (#1246). Friends in the Netherlands are always
welcome here. Cheers with a Grolsch! 🍺
- **Readings you enter here count for Apple Health freshness.** Weight, blood
pressure and glucose are no longer called quiet while you keep entering them
in HealthLog for the phone to pick up, and the card shows when you last did
(`lastEnteredHereAt`, Refs #1247).
### Fixed
- **Error states say what happened.** When a session has ended, a card offers
to sign in again instead of a retry that cannot work; when there were too
many requests just now, it says so without a pointless retry; a section you
are not allowed to see on a shared record stays quiet. Some thirty cards that
used to show a bare red line, a "still learning" note, an endless skeleton
or nothing at all when their data failed to load now say so and offer a
retry, and charts follow the same rule. Every read failure goes through the
same row, card and chart state, deciding by the error code first and the
status second.
- **A short database outage no longer signs you out.** A session that could not
be checked because the database was unreachable, out of connections or
otherwise failing answers 503 (`auth.session.unavailable`) and the client
keeps its credential and tries again. A malformed session cookie is treated
as signed out (401) without a database lookup.
- **A nap is not a night, but it is sleep.** A day whose only sleep is a short
daytime nap has no main night (`main: null` on `/api/sleep/night`). A day's
sleep in the list, the export and the correlations counts its naps, the same
total the chart and the sleep score show; the main night alone is
`mainAsleepMinutes`. The night view opened without a date shows the last
night with main sleep. The sleep list, the night view, the chart and the
sleep score name the same day for every night. A REM segment of zero minutes
no longer drops the night around it.
- **Deleted entries stay deleted for the AI.** Deleted mood entries and
readings no longer shape the status texts written about you, and the general
overview looks at every metric again instead of the newest few thousand rows
of the densest one.
- **Days logged for a chronic condition count** in the symptom correlations.
- **Inviting someone whose name or e-mail matches no account** says so
(`sharing.invite.no_account`) and is never confused with a profile you no
longer look after.
- **Old nights open, and "All" means all.** The night view answers any date,
however old, and the sleep series reaches back to the first night. The sleep
status read the last few weeks of stages; it now reads the last year of
nights.
- **Days are your days.** The dashboard streak and the day a dose counts for
were placed on the wrong side of midnight for readings close to it; both
follow the profile timezone now. Skipping a dose from Telegram snoozes until
the end of your own day, not the server's.
- **One number for one thing.** The spread of a series is the same in the app,
the API, MCP and AI tools. Correlations on the insights overview and in AI
data appear only when they are statistically sound (at least 20 pairs,
p < 0.05), as everywhere else; below that the value is `null`. A chronic
condition no longer fills the symptom series with zeros.
- **Medication adherence on the cadence chips** counts pauses, courses and
missed doses like the detail page; a week's pause read 77 % instead of 100 %.
- **A workout Apple Health sends twice is stored once,** and every workout
reader uses the same source order.
- **Provider histories do not end early without saying so.** A WHOOP, Oura,
Withings, Fitbit or Strava history that stops at its page limit is no longer
marked as fully imported. A Strava history of more than about 4,000
activities imports in stages from where the last stage stopped, one stage per
rate window, instead of re-reading the newest activities after every
restart. WHOOP and Oura no longer read the first page twice when a list ends
with an empty page token. Imported values from every provider and from
Health Connect are rounded the same way, with halves rounded up (a weight of
72.345 kg now stores as 72.35, not 72.34).
- **A long Google Health backfill keeps its place.** Only attempts that end at
the same edge count toward the final end, and a restart during the 24-hour
wait no longer starts a second chain.
- **The recovery page** says in one sentence why it is still empty, decided on
the server (`recoveryEmpty`), and always links to the integrations.
- **Lists that stop at a limit say so** (`meta.truncated`, `meta.limit`) instead
of passing the oldest loaded entry off as the beginning, and the web shows a
note under each such list. The activity feed keeps its `data.truncated` for
existing clients; it is deprecated in favour of `meta.truncated`.
- Reconnecting Strava, even to a different account, reads that account's full
history again instead of continuing where the previous one stopped.
- The day view's "highest since" notes pick up a new or corrected reading right
away.
- **Old days and long histories are read whole.** The sleep summary covers the
whole history, the pulse assessment reads at least a month of pulse instead
of a few hours of a dense stream, and the mood fallback reads the same five
years as the rollup path.
- The coach's daily limit on follow-up questions resets at your own midnight,
and the weather and air-quality refresh includes today east of UTC.
- **Deleting a large account** no longer aborts after five seconds.
- Workout dates follow your date format in the list, the detail and the
dashboard tile. "Not enough nights yet" names the number actually needed.
`groupBy=day` together with `valueMin`/`valueMax` answers 422 instead of
ignoring the filter.
### Changed
- **Faster reads.** The latest value per type is read through one bounded query
instead of a sort over every reading (about 60 ms down to 1 to 2 ms on a large
account), the dashboard summaries share one cache cell, and paging through the
day view computes the two-year comparison once.
- **One heavy job at a time per process.** Imports, restores, backups, rollup
backfills, provider backfills and the nightly runs queue behind each other
instead of stacking up in one heap. A job that finds the lane busy goes back
to the queue and starts once it is free, so a backup waiting behind a large
import no longer runs out of time while it waits. A long Google Health
backfill makes room at its next slice boundary for a job waiting behind it,
and a job that waits more than two hours is reported as a warning
(`job.heavy_lane.waiting_long`). Building rollups for a large account no
longer runs a second time when it takes longer than fifteen minutes. The six per-metric status
crons are gone (the nightly batch already covers them), and a queue fed only
by its cron polls every 30 seconds.
- **`measurements` stays small.** An update that changes nothing keeps its
timestamps, so it stays on its page and is not sent to the phone again; the
table leaves room on each page and autovacuum starts at 5 % dead rows. A
nightly check warns when dead rows or bloat grow past that (see
`docs/ops/measurement-maintenance.md`).
- **Migrations that wait for a lock no longer hold writers back or stop the
boot.** `prisma migrate deploy` runs a migration file statement by
statement, not in one transaction, so the lock-sensitive part of migration
0412 runs as one block that gives up after ten seconds when another process
holds `measurements`. The container then retries a migration marked safe to
re-run, with a growing pause, instead of stopping at boot; any other
migration failure still stops the boot. See "A migration that timed out on a
lock" in `docs/ops/deploy.md`.
- The day view's "highest since" notes cache fixed 90-day blocks and use less
memory when paging back through older days.
- An insights card whose refresh keeps failing stops polling after its usual
number of attempts.
- Failed provider syncs and refused uploads are logged as signals instead of
audit entries; a provider's error text no longer lands in the audit log in
plain text.
- **The cached AI health report is encrypted at rest,** existing rows included.
- Every API error carries a stable `meta.errorCode`; a route without its own
code gets an `http.*` code from the status.
- Logs group requests by route template (`/api/medications/[id]`) instead of by
concrete path.
- Failed inputs no longer fill the audit log; only attempts by someone with a
grant appear there.
- The browser bundle no longer pulls in server modules, and a test keeps it
that way.
- The Coach stops polling a document that will never be indexed.
- On an out-of-memory crash the process writes a diagnostic report to stderr
(`--report-on-fatalerror`, without environment or network details), and the
heap monitor adds a 95 % tier.
## [1.43.6] — 2026-10-19
### Fixed
- **A long measurement history no longer loads in one response.** The list for
cumulative types (steps, active energy and similar) loaded every day since
the first reading in one request and drew one table row per day; past 5000
raw rows the oldest days were cut off without a word. The server now pages
over days with a correct total, folds the days in SQL instead of reading raw
rows into the process, and the list shows 25 days per page. The sleep list
pages over nights the same way and no longer stops at one year; a night older
than a year opens its detail. For `groupBy=day`, `limit` counts days. The
`partial` flag and the `windowFrom` field are gone because nothing is cut off
any more (Refs #1245).
- **A Google Health backfill that keeps moving no longer stops at a page
ceiling.** The ceiling counted pages across every attempt and was never
reset, so a dense type such as heart rate reached it after roughly two and a
half years and every later slice stopped at the same wall. A walk now stops
only when it has not moved back in time for 20 pages in a row, or when it
reaches the horizon (`GOOGLE_HEALTH_BACKFILL_HORIZON_YEARS`, 20 by default). A
type that ends says where its history ends and why, on the integration card,
instead of showing "loading" forever. A first end without progress is only
provisional: the type keeps its place and gets a second attempt a day later
before the end is final (Refs #1220).
- **Consistency in the sleep score no longer reads 0 after a day without a
night.** A day on which the ring recorded only a nap was placed on the clock
by that nap, hours away from the other nights, and one such day in thirty
pushed the spread past the scale. A day whose only sleep is a short daytime
nap now leaves the rhythm (the nap still counts toward that day's sleep); a
short night stays on the clock. Every reader of the sleep midpoint follows the
same rule. When there are not enough nights to judge a rhythm, or the latest
day had only a nap, the row says so instead of showing a number. The sleep
score value, including `rhythmNights` and `gap`, is now published in the
OpenAPI schema (Refs #1204).
- **An empty recovery page no longer guesses.** It asks for a reconnect only
when a missing permission is recorded, and otherwise says that no level has
arrived yet and points to the Oura card (Refs #1204).
- **The MCP endpoint answers GET and DELETE with 405 and `Allow: POST`.** A
client that probed for a server-sent event stream was retried in a loop, which
produced hundreds of thousands of requests per week.
- **No empty frame under the next appointment.** The "since the last visit" card
drew its border before knowing whether it had anything to show. Optional
tiles on the insights pages no longer flash a skeleton that collapses to
nothing, and a failed read on them now says so with a retry instead of
vanishing (Refs #1241).
- Workout dates in the list, the header and the dashboard tile follow the
profile timezone instead of the browser's.
### Added
- **Heap diagnostics.** The process logs `runtime.heap.sampled` every five
minutes (every minute above 70 % of the heap limit) and, once, a
`runtime.heap.pressure` report above 85 % that lists the oldest requests and
jobs in flight and the busiest routes. With `HEALTHLOG_HEAP_SNAPSHOT=1` it also
writes one heap snapshot into `HEALTHLOG_DIAG_DIR`; a snapshot holds plain
health data, see `docs/ops/heap-diagnostics.md`.
- A nightly run of the full multi-year Google Health backfill test; the run on
every pull request uses a tenth of the pages.
### Changed
- Dependabot keeps `next`, `eslint-config-next` and `@next/bundle-analyzer` out of
the weekly groups, so a red Next bump no longer holds the other updates red.
- The timezone guard also matches `getDateTimeFormat(locale, options)` calls
that leave the zone out, and comments and test fixtures no longer carry
personal names.
## [1.43.5] — 2026-10-18
### Fixed
- **Backups of accounts with many documents no longer run out of memory.**
The weekly in-database backup, the manual backup and the restore held the
document vault and ECG traces in memory several times over (bytes, base64,
the serialised section and the preview check), and sealed one oversized
chunk; an account with a large document vault crashed a 524 MB heap. Document
bytes are now read and written in 1.5 MB slices, ECG traces page by page,
every section is written element by element and no chunk exceeds the chunk
size. The restore reads documents without their bytes first and writes each
one on its own. The file format is unchanged (Refs #1239).
- A failed tombstone purge names its phase and condition (e.g.
`statement_timeout`) instead of a bare error.
### Added
- The admin support report and System status flag a heap limit below 700 MB
and point to `APP_MEMORY_LIMIT`; `/api/admin/status` carries
`memory: { heapLimitMb, low }`.
## [1.43.4] — 2026-10-17
### Fixed
- **An illness shows only the symptoms filed under it.** The illness detail
view merged the symptom events linked to the illness with every event inside
its date range, so overlapping illnesses showed each other's symptoms. It now
shows linked events only, and every symptom row on the illness page names its
illness. The symptom edit sheet has the illness field, with the current link
preselected; an event can be filed under an ongoing illness or an ended one
whose dates contain it, and POST and PATCH accept the same set. The day view
no longer links a symptom to a deleted illness (Refs #1237).
## [1.43.3] — 2026-10-17
### Fixed
- **Every action behind a second-factor check opens the confirmation
dialog.** Handing over a managed profile, creating, editing or deleting
one, adding or removing a guardian, turning off TOTP, regenerating recovery
codes, removing a security key or passkey, changing the password, deleting
data or the account and the admin key rotation only showed a sentence when
the second factor needed confirming. They now open the confirmation dialog
and retry once confirmed, also after a single sign-on login (Refs #959).
The refusal names the methods the account has in `meta.methods`.
- **A passkey counts as a second factor for managed profiles.** An account
whose only strong sign-in is a passkey was told to set up a second factor
before it could create, edit, hand over or delete a managed profile or
manage its guardians. A passkey sign-in, or confirming the passkey in the
dialog, is now enough; the admin key rotation follows the same rule.
Password sign-in, the native app and the actions that ask for a fresh
factor only from accounts with TOTP or a security key are unchanged.
- An account that signs in only through single sign-on is no longer offered
a password field it cannot fill when it sets up a second factor; it is
asked to sign in again.
## [1.43.2] — 2026-10-16
### Fixed
+2 -2
View File
@@ -121,7 +121,7 @@ Contributors: branch off `main`; PRs target `main`.
## Translations
HealthLog ships with English (`en`) and German (`de`) maintained by the project, plus French (`fr`), Spanish (`es`), Italian (`it`), Polish (`pl`), and Korean (`ko`) that are **AI-initial, community-maintained**. The maintained locales are reviewed line-by-line; the community-maintained ones surface a small banner inviting users to improve them.
HealthLog ships with English (`en`) and German (`de`) maintained by the project, plus French (`fr`), Spanish (`es`), Italian (`it`), Polish (`pl`), Korean (`ko`), and Dutch (`nl`) that are **AI-initial, community-maintained**. The maintained locales are reviewed line-by-line; the community-maintained ones surface a small banner inviting users to improve them.
### Improving an existing locale
@@ -136,7 +136,7 @@ To suggest a fix or improvement:
### Adding a new locale
If you want to introduce a locale that isn't shipped (e.g. `nl`, `pt-BR`, `cs`):
If you want to introduce a locale that isn't shipped (e.g. `pt-BR`, `cs`):
1. Open an issue first using the **Translation request** template. We'll confirm the locale is in scope and decide whether it joins the AI-initial-community-maintained tier or the maintained tier.
2. Copy `messages/en.json` to `messages/<locale>.json`.
+22 -5
View File
@@ -76,6 +76,15 @@ ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
ENV TZ=Europe/Berlin
# When the heap runs out, Node writes a diagnostic report (the JavaScript
# and native stacks, heap spaces, libuv handles) to stderr, where
# `docker compose logs` and Loki keep it; without it the log holds one
# line. Environment variables and network interfaces are left out, so no
# key, password or address from the environment reaches the log. An
# operator who sets NODE_OPTIONS replaces this value and should carry these
# flags over (docs/ops/heap-diagnostics.md).
ENV NODE_OPTIONS="--report-on-fatalerror --report-filename=stderr --report-exclude-env --report-exclude-network"
# v1.4.43 B11 — forward NEXT_PUBLIC_APP_VERSION from the builder stage
# into the runner so /api/version reads the build-arg value instead of
# the package.json fallback. The variable is keyed to the CI tag ref
@@ -203,11 +212,13 @@ COPY --from=builder /app/src/generated ./src/generated
# application graph (`@/…`, the generated Prisma client) run from a source
# checkout instead. `src/__tests__/container-scripts-ship-guard.test.ts` holds
# the runbooks and the entrypoint against this list. `pending-migrations.mjs`
# is the entrypoint's own: it logs the pending migration count before
# `migrate deploy`. Root-owned on purpose: the app user can run them but not
# and `migrate-deploy.mjs` are the entrypoint's own: the first logs the
# pending migration count, the second runs `migrate deploy` with its
# lock-timeout retry. Root-owned on purpose: the app user can run them but not
# rewrite them.
COPY --from=builder /app/scripts/reset-password.mjs ./scripts/reset-password.mjs
COPY --from=builder /app/scripts/pending-migrations.mjs ./scripts/pending-migrations.mjs
COPY --from=builder /app/scripts/migrate-deploy.mjs ./scripts/migrate-deploy.mjs
COPY --from=builder /app/src/lib/auth/argon2-params.mjs ./src/lib/auth/argon2-params.mjs
COPY --from=builder /app/scripts/repair-intake-anomalies.ts ./scripts/repair-intake-anomalies.ts
# Prove the reset CLI's imports resolve in this tree: with every import found
@@ -215,7 +226,8 @@ COPY --from=builder /app/scripts/repair-intake-anomalies.ts ./scripts/repair-int
RUN node scripts/reset-password.mjs 2>&1 | grep -q "reset-password: usage:"
# The boot notice parses, and the `pg` it loads on a real boot resolves here.
RUN env -u DATABASE_URL node scripts/pending-migrations.mjs && \
node --input-type=module -e "await import('pg')"
node --input-type=module -e "await import('pg')" && \
node --input-type=module -e "await import('./scripts/migrate-deploy.mjs')"
# Install the migration CLI, its config dependency, and a pinned launcher for
# the maintenance scripts already shipped in the standalone tree. The Prisma
@@ -306,8 +318,13 @@ EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://127.0.0.1:3000/api/health || exit 1
# Start period: the entrypoint migrates before the server listens, and a
# migration waiting out a held lock retries for up to about eight minutes.
# Five retries: a pool saturated for a minute during a boot storm makes the
# probe's 2 s database check miss once or twice without marking the container
# unhealthy (docker-compose.yml explains the numbers).
HEALTHCHECK --interval=30s --timeout=5s --start-period=10m --retries=5 \
CMD wget --no-verbose --tries=1 --timeout=4 --spider http://127.0.0.1:3000/api/health || exit 1
ENTRYPOINT ["./docker-entrypoint.sh"]
CMD ["node", "server.js"]
+10 -14
View File
@@ -24,20 +24,16 @@
<a href="https://testflight.apple.com/join/bucuTBpa">iOS TestFlight</a>
</p>
<img width="2434" height="1247" alt="HealthLog dashboard in dark theme: the Today overview with a lead sentence, the health score ring, Today facts and worth-a-look cards, above metric tiles with 7-day trends" src="docs/assets/dashboard-hero.jpeg" />
<img width="2400" height="1500" alt="HealthLog dashboard in dark theme: a lead sentence on the latest vitals, today's medications, sleep and cycle, the health score ring, worth-a-look cards, and metric tiles with 7-day trends" src="docs/assets/dashboard.jpeg" />
<p align="center">
<img src="docs/assets/Insights.png"
alt="Insights"
width="23%">
<img src="docs/assets/resting-pulse.png"
alt="Insights RHR"
width="23%">
<img src="docs/assets/Medications.png"
alt="Medications"
width="23%">
<img src="docs/assets/Coach.png"
alt="Coach Chat"
width="23%">
<img src="docs/assets/timeline.jpeg" alt="Timeline: life events, conditions, medications, vaccinations, labs and cycles on one time axis, above blood pressure, weight and resting heart rate" width="32%">
<img src="docs/assets/day-panel.jpeg" alt="Blood pressure insights with the day panel open: what was running that day, its values and scores" width="32%">
<img src="docs/assets/resting-heart-rate.jpeg" alt="Resting heart rate insights: summary, usual range and the full history chart" width="32%">
</p>
<p align="center">
<img src="docs/assets/medications.jpeg" alt="Medications with next and last intake, adherence and streaks" width="32%">
<img src="docs/assets/labs.jpeg" alt="Lab results with reference ranges and trend sparklines" width="32%">
<img src="docs/assets/coach.jpeg" alt="Coach: a conversation about the week's recovery, grounded in the recorded data" width="32%">
</p>
## Why HealthLog
@@ -60,7 +56,7 @@ Try the [live demo](https://demo.healthlog.dev) to see a working install — sig
- **AI insights you own.** Daily briefing, health scores, correlations, and a conversational coach grounded in your own measurements and interpreted against cited guideline reference bands — where a value sits and what that means in plain words, never a diagnosis. The coach proposes concrete if-then plans you accept or decline, and suppresses proactive check-ins when there's nothing genuine to say. Bring your own OpenAI or Anthropic key, or point at a local endpoint (Ollama, LM Studio, vLLM) so AI requests stay on your network.
- **Clinician-ready output.** A doctor-report PDF rendered on your server, a read-only HL7 FHIR R4 API, and scoped, time-limited share links you revoke after the visit.
- **Private by construction.** AES-256-GCM encryption at rest for notes and other free text, documents, questionnaire answers, AI-written text and credentials, with zero-downtime key rotation. Measurement values, lab values, medication names and mood scores are stored unencrypted in the database, so the database volume and its backups need the same protection as the host. Passkey login, server-side sessions, strict CSP, and no third-party tracking anywhere.
- **Built to be lived in.** Installable PWA with offline support, medication reminders over APNs, Telegram, ntfy, and Web Push, a sub-second dashboard on years of imported history — in English, German, Spanish, French, Italian, Polish, and Korean end to end.
- **Built to be lived in.** Installable PWA with offline support, medication reminders over APNs, Telegram, ntfy, and Web Push, a sub-second dashboard on years of imported history — in English, German, Spanish, French, Italian, Polish, Korean, and Dutch end to end.
The full feature tour, integration guides, and API reference live at [docs.healthlog.dev](https://docs.healthlog.dev).
+5 -4
View File
@@ -1,13 +1,14 @@
{
"$comment": "Client-bundle budgets, enforced by scripts/check-bundle-budget.mjs (--check) after `next build` in CI. Values are KB gzip and include every emitted lazy route and locale chunk, while route budgets include the shared rootMainFiles baseline. Re-measured at v1.37.2: / 455, /insights 443, /measurements 441, /insights/mood 453, total 3328, baseline 130, lazy catalogs 849. Every per-route ceiling is unchanged. The /insights overview crept a kilobyte over its 445 ceiling when the ECG teaser began importing its overview cap from the classification module, dragging that module's label map and helpers into the eager page; the cap now lives in its own value-only module, and the Health Score's single provenance entry is split out of the full metric-provenance map, so the overview imports the bare number and citation it renders rather than every metric's label map behind them. Both are back under 445 with room to spare. The 42 KB aggregate rise from 3286 (v1.35.0) is all locale-catalog strings: v1.36 and v1.37 added the encounter, mood-context, mood-prognosis, practitioner, workout-batch and account-sharing surfaces, and their copy lands across the six locale catalogs the whole-build sum counts independently (804 to 849 KB). The non-catalog client chunks are flat against v1.35.0, so nothing a person downloads on a given route grew beyond its own ceiling. The aggregate cap therefore moves from 3310 to 3352 (24 KB headroom, matching the previous margin); per-route ceilings, the static-catalog guard and the one-Recharts-chunk guard remain unchanged. v1.37.4's honest-error-state charge then added recoverable read-failure handling to thirteen surfaces (the shared QueryErrorCard and QueryErrorRow primitives plus an isError branch per surface) and rewrote the MCP scope note and the managed-record error copy across the six locales; the measured total rose to 3404 while every per-route ceiling stayed met (/page 457, /insights 445, /measurements 443, /insights/mood 455). The aggregate cap moves from 3352 to 3428, keeping the same margin; nothing a person downloads on a given route grew beyond its ceiling. v1.37.15's async-truth charge then added the refresh settling state with its two-locale-key copy, the index-outcome plain-words line (seven keys across the six catalogs) and the reindex-count wiring; the measured total rose to 3429 while every per-route ceiling stayed met (/page 456, /insights 445, /measurements 442, /insights/mood 455). The aggregate cap moves from 3428 to 3452, keeping the same margin; nothing a person downloads on a given route grew beyond its ceiling. v1.37.27 found /insights sitting a few hundred bytes under 445 on main and a few hundred over it on the release branch — the page had been at its ceiling for three releases and the release only had to breathe on it. The weight it was carrying was not its own: the authenticated shell imported one destination predicate from the `@/lib/record-settings` barrel, and the barrel also re-exports the managed settings-patch schemas, so Zod, the module registry and three validation modules rode into the eager bundle of every authenticated route for a function that reads a URL slug. The shell imports the `classification` leaf now. Measured across all 585 client-rendered routes: none grew by a byte, 100 fell, by 4.7 KB at the median and 8.1 KB at the most; /insights 444 to 437, /measurements 442 to 435, / 456 to 450, /insights/mood 455 to 450. The whole-build sum moved the other way, 3451 to 3476, and that direction is the metric rather than the change: the aggregate counts each emitted chunk once, so a graph that leaves the chunk group every route shares gets counted again wherever it lands — here inside the deferred settings chunks that actually use it. Bytes on disk rose 25 KB; bytes a reader downloads only ever fell. The aggregate cap moves from 3452 to 3500, the same 24 KB headroom as the four re-measurements before it. Per-route ceilings keep their old values rather than tightening to the new floor: the CI runner measures each route about 1.5 KB heavier than a local arm64 build does, and a ceiling set from a local number would fail on a build that had not regressed. The static-catalog guard and the one-Recharts-chunk guard remain unchanged. Raised to 3700 at v1.37.32 for the seventh interface language (Korean): the lazy message catalogs went 6->7 chunks (1026 KB gz measured), pushing the aggregate to 3631 against the old 3500. The raise is entirely the new catalog; no route budget moved, and the four eager route ceilings are unchanged and still passing. Most of the aggregate is the dynamic locale-switch and fallback path that a first paint never loads (see issue #253), so this ceiling guards bytes almost no visitor requests; 3700 leaves ~69 KB for ordinary drift until that double-count is cleaned up. v1.38.14 re-measures the four eager routes and changes what the number means. The ceilings had been re-stated but never re-based since v1.37.4, so /inLine truncated
"$comment": "Client-bundle budgets, enforced by scripts/check-bundle-budget.mjs (--check) after `next build` in CI. Values are KB gzip and include every emitted lazy route and locale chunk, while route budgets include the shared rootMainFiles baseline. Re-measured at v1.37.2: / 455, /insights 443, /measurements 441, /insights/mood 453, total 3328, baseline 130, lazy catalogs 849. Every per-route ceiling is unchanged. The /insights overview crept a kilobyte over its 445 ceiling when the ECG teaser began importing its overview cap from the classification module, dragging that module's label map and helpers into the eager page; the cap now lives in its own value-only module, and the Health Score's single provenance entry is split out of the full metric-provenance map, so the overview imports the bare number and citation it renders rather than every metric's label map behind them. Both are back under 445 with room to spare. The 42 KB aggregate rise from 3286 (v1.35.0) is all locale-catalog strings: v1.36 and v1.37 added the encounter, mood-context, mood-prognosis, practitioner, workout-batch and account-sharing surfaces, and their copy lands across the six locale catalogs the whole-build sum counts independently (804 to 849 KB). The non-catalog client chunks are flat against v1.35.0, so nothing a person downloads on a given route grew beyond its own ceiling. The aggregate cap therefore moves from 3310 to 3352 (24 KB headroom, matching the previous margin); per-route ceilings, the static-catalog guard and the one-Recharts-chunk guard remain unchanged. v1.37.4's honest-error-state charge then added recoverable read-failure handling to thirteen surfaces (the shared QueryErrorCard and QueryErrorRow primitives plus an isError branch per surface) and rewrote the MCP scope note and the managed-record error copy across the six locales; the measured total rose to 3404 while every per-route ceiling stayed met (/page 457, /insights 445, /measurements 443, /insights/mood 455). The aggregate cap moves from 3352 to 3428, keeping the same margin; nothing a person downloads on a given route grew beyond its ceiling. v1.37.15's async-truth charge then added the refresh settling state with its two-locale-key copy, the index-outcome plain-words line (seven keys across the six catalogs) and the reindex-count wiring; the measured total rose to 3429 while every per-route ceiling stayed met (/page 456, /insights 445, /measurements 442, /insights/mood 455). The aggregate cap moves from 3428 to 3452, keeping the same margin; nothing a person downloads on a given route grew beyond its ceiling. v1.37.27 found /insights sitting a few hundred bytes under 445 on main and a few hundred over it on the release branch — the page had been at its ceiling for three releases and the release only had to breathe on it. The weight it was carrying was not its own: the authenticated shell imported one destination predicate from the `@/lib/record-settings` barrel, and the barrel also re-exports the managed settings-patch schemas, so Zod, the module registry and three validation modules rode into the eager bundle of every authenticated route for a function that reads a URL slug. The shell imports the `classification` leaf now. Measured across all 585 client-rendered routes: none grew by a byte, 100 fell, by 4.7 KB at the median and 8.1 KB at the most; /insights 444 to 437, /measurements 442 to 435, / 456 to 450, /insights/mood 455 to 450. The whole-build sum moved the other way, 3451 to 3476, and that direction is the metric rather than the change: the aggregate counts each emitted chunk once, so a graph that leaves the chunk group every route shares gets counted again wherever it lands — here inside the deferred settings chunks that actually use it. Bytes on disk rose 25 KB; bytes a reader downloads only ever fell. The aggregate cap moves from 3452 to 3500, the same 24 KB headroom as the four re-measurements before it. Per-route ceilings keep their old values rather than tightening to the new floor: the CI runner measures each route about 1.5 KB heavier than a local arm64 build does, and a ceiling set from a local number would fail on a build that had not regressed. The static-catalog guard and the one-Recharts-chunk guard remain unchanged. Raised to 3700 at v1.37.32 for the seventh interface language (Korean): the lazy message catalogs went 6->7 chunks (1026 KB gz measured), pushing the aggregate to 3631 against the old 3500. The raise is entirely the new catalog; no route budget moved, and the four eager route ceilings are unchanged and still passing. Most of the aggregate is the dynamic locale-switch and fallback path that a first paint never loads (see issue #253), so this ceiling guards bytes almost no visitor requests; 3700 leaves ~69 KB for ordinary drift until that double-count is cleaned up. v1.38.14 re-measures the four eager routes and changes what the number means. The ceilings had been re-stated but never re-based since v1.37.4, so /inLine truncated
"routeBaselineKbGz": {
"/page": 355,
"/insights/page": 304,
"/insights/page": 323,
"/measurements/page": 335,
"/insights/mood/page": 334,
"/settings/[section]/page": 307
},
"routeDriftAllowanceKbGz": 20,
"totalClientKbGz": 4975,
"maxRechartsChunks": 1
"totalClientKbGz": 3900,
"maxRechartsChunks": 1,
"maxCatalogKbGz": 210
}
+36 -6
View File
@@ -226,6 +226,10 @@ x-healthlog-env: &healthlog_env
# the authorization code somewhere else.
FITBIT_REDIRECT_URI: "${FITBIT_REDIRECT_URI:-}"
GOOGLE_HEALTH_REDIRECT_URI: "${GOOGLE_HEALTH_REDIRECT_URI:-}"
# How many years back the Google Health history backfill reads (a whole
# number from 1 to 100; 20 when unset). A data type whose history goes
# further ends there, and the integration card says so.
GOOGLE_HEALTH_BACKFILL_HORIZON_YEARS: "${GOOGLE_HEALTH_BACKFILL_HORIZON_YEARS:-}"
# Nightscout origins on your own network that the per-user connection
# validator should accept, comma-separated (e.g. "http://nightscout.lan").
# Without it a LAN/private Nightscout URL is refused as an SSRF target.
@@ -409,6 +413,13 @@ x-healthlog-env: &healthlog_env
LOG_SAMPLE_RATE: "${LOG_SAMPLE_RATE:-}"
LOG_SLOW_THRESHOLD_MS: "${LOG_SLOW_THRESHOLD_MS:-}"
LOG_INCLUDE_STACK: "${LOG_INCLUDE_STACK:-}"
# Heap diagnostics, off by default. HEALTHLOG_HEAP_SNAPSHOT=1 writes ONE
# heap snapshot the first time the heap reaches 85 % of its limit, into
# HEALTHLOG_DIAG_DIR (default /tmp inside the container). A snapshot holds
# decrypted health data and keys: turn it on only to chase a crash, then
# delete the file. See docs/ops/heap-diagnostics.md.
HEALTHLOG_HEAP_SNAPSHOT: "${HEALTHLOG_HEAP_SNAPSHOT:-}"
HEALTHLOG_DIAG_DIR: "${HEALTHLOG_DIAG_DIR:-}"
# Optional log shipping to a Loki instance. Set LOKI_ENDPOINT to the base
# URL or the full push URL to turn it on; a Loki on your own network works.
# The username/password pair is only needed when your Loki is behind basic
@@ -492,20 +503,39 @@ services:
# workload can grow large enough to starve Postgres on a shared host.
# Node sizes its heap from this limit: 1g gives a 524 MB heap, which a
# long-used account fills in normal operation, so garbage collection
# stalls requests for seconds and a weekly backup can crash the process.
# 1536m gives 792 MB. On a host with 2 GB or less, lower it with
# APP_MEMORY_LIMIT in .env; on a busy instance, raise it.
# stalls requests for seconds. 1536m gives 792 MB and is the supported
# floor (host: 3 GB RAM, 4 GB recommended). Below it the boot log, the
# support report and Admin > System status say so. On a busy instance,
# raise it with APP_MEMORY_LIMIT in .env; do not lower it.
mem_limit: "${APP_MEMORY_LIMIT:-1536m}"
healthcheck:
test:
[
"CMD-SHELL",
"wget --no-verbose --tries=1 --spider http://127.0.0.1:3000/api/health || exit 1",
"wget --no-verbose --tries=1 --timeout=4 --spider http://127.0.0.1:3000/api/health || exit 1",
]
interval: 30s
timeout: 5s
retries: 3
start_period: 30s
# Five misses in a row, two and a half minutes, before "unhealthy". The
# probe gives the database two seconds to answer SELECT 1 and says 503
# past that, so a boot storm that saturates the pool for a minute after
# the start period (backfills, a restore, another tenant's import) costs
# a probe or two, not the container's health and the proxy's routing.
# wget's own 4 s timeout ends the request inside Docker's 5 s, so a
# stalled connection reads as one clean miss rather than a killed probe.
# A database that stays away for 2.5 minutes is a real outage and shows.
retries: 5
# The entrypoint applies migrations before the server listens. A
# migration that waits out a held lock retries for up to about eight
# minutes (docs/ops/deploy.md), and failed probes in that time must not
# read as a dead container. The first probe that answers still ends
# the start period, so a normal boot is healthy within 30 seconds of
# listening. After it listens, the pg-boss worker starts in the
# background and /api/health answers 503 with reason "starting" until it
# is up (10–60 s under load, reported as "starting" for at most five
# minutes); inside the start period those probes do not count, so a slow
# worker start cannot mark the container unhealthy.
start_period: 10m
# Optional dedicated worker container — uncomment + set
# HEALTHLOG_PROCESS_TYPE=web on `app` to split the deployment.
+4 -1
View File
@@ -88,9 +88,12 @@ echo "HealthLog: Database is reachable."
# Say how many migrations this image is about to apply, with a reminder to
# back up first. Reporting only: it never stops the boot.
node scripts/pending-migrations.mjs || true
# `migrate-deploy.mjs` runs `prisma migrate deploy` and retries only a
# migration that timed out on a lock and is marked safe to re-run; any other
# failure stops the boot with Prisma's exit code (docs/ops/deploy.md).
echo "HealthLog: Running database migrations..."
NODE_PATH=/opt/prisma-cli/node_modules \
node /opt/prisma-cli/node_modules/prisma/build/index.js migrate deploy
node scripts/migrate-deploy.mjs
echo "HealthLog: Migrations complete."
# ── Start application ────────────────────────────────────────
+5 -1
View File
@@ -56,7 +56,11 @@ notes, and machine-readable specs that ship alongside the source:
— the iOS submission gate.
- [`codex-protocol-spec.md`](./codex-protocol-spec.md) — Codex / ChatGPT
OAuth protocol reverse-engineering notes.
- [`ui-guidelines.md`](./ui-guidelines.md) — UI / a11y house style.
- [`contributing/ui-standards.md`](./contributing/ui-standards.md) — the
binding UI standards: card anatomy, spacing, text colour, tokens and
which primitive to use where. Read before touching any surface.
- [`ui-guidelines.md`](./ui-guidelines.md) — UI / a11y house style; where
it disagrees with the UI standards, the standards win.
If you're a self-hoster looking to spin up an instance, start at
<https://docs.healthlog.dev> or jump to the [Quick Start in the project
+4937 -534
View File
File diff suppressed because it is too large. Load diff
Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 270 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 302 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 400 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 310 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 272 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 262 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 292 KiB

+185
View File
@@ -0,0 +1,185 @@
# UI standards
The binding reference for building or touching any surface of the web app. The rules are derived from the primitives' own contracts (`src/components/ui/card.tsx`, `tile-header.tsx`, `page-header.tsx`, `settings/_card-header.tsx`, `layout/auth-shell.tsx`) and from a review of every surface against them. When a rule here conflicts with an existing file, the file is drift: fix it when you touch it, don't copy it. Where an older code comment or [`docs/ui-guidelines.md`](../ui-guidelines.md) says otherwise, this document wins. A deviation needs a written reason in the pull request.
---
## 1. Card anatomy
**The Card is gap-based, not padding-based** (`src/components/ui/card.tsx:24`): `flex flex-col gap-4 rounded-xl border py-4 md:gap-6 md:py-6`; header/content/footer carry `px-4 md:px-6`. The header→body distance comes from the **parent flex gap**, never from slot padding.
- FORBIDDEN: `pb-*` on `CardHeader`, `pt-*` on `CardContent`. Under the gap model these add on top of the gap (or are no-ops) and invert their intent. Want a tighter card? Override the gap on the Card itself: `<Card className="gap-2">`.
- Two densities, nothing else:
- `default`: Card as shipped: `py-4 md:py-6`, `gap-4 md:gap-6`.
- `compact`: `<Card density="compact">` (stat strips, assessment blocks, small tiles); resolves to `gap-2 py-3 md:gap-2 md:py-4`. Never spell it as a class list: the hand-written `gap-2 py-3 md:py-4` kept the base `md:gap-6` and opened 24 px on desktop (v1.43.8; `card-density.test.tsx` refuses the class list on a Card).
- No `md:py-5` half-steps.
- Radius: cards are `rounded-xl` (tracks `--radius`). `rounded-md` = controls, `rounded-lg` = inner tiles, `rounded-full` = pills. `rounded-2xl` only as a deliberately marked identity surface (Coach hero/input).
- Hand-rolled `bg-card … border … p-N` shells are legacy. New surfaces compose `<Card>` (or `SettingsCard` in Settings). If a hand-rolled shell must exist, its padding is exactly `p-4 md:p-6` or `p-3` (dense tile), never `p-5`, never a static `p-6`, never a `sm:` step (`SettingsCard`'s docblock explains why the breakpoint is `md:`).
- **Insights overview sections use `InsightSectionCard`** (`src/components/insights/insight-section-card.tsx`). It composes `<Card>`, so the frame is the card contract, and pins the section's inner rhythm at one step (`gap-3`); the six hand-rolled copies it replaced had drifted to three different rhythms in the same column. A section whose body is an edge-to-edge divided list (rows owning their own inset) passes `flush`; everything else takes the padded body. Do not hand-roll a section shell beside it.
- **The dashboard/insights hero band is a sanctioned identity surface.** Its translucent `bg-card/65 … backdrop-blur-sm` tiles are deliberate, not drift: the band reads as one glass surface behind the page's opening statement. It stays out of the shell sweep. Everything below the band is a plain card.
### Header patterns; exactly three, pick by surface
| Surface | Primitive | Contract |
| ---------------------------------- | ------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Page title | `PageHeader` (`ui/page-header.tsx`) | H1 `text-2xl font-bold tracking-tight`, description `text-muted-foreground text-sm`, `space-y-1.5`, **no icon beside the H1**, optional backLink + actions slot |
| Settings card | `SettingsCard` + `SettingsCardHeader` (`settings/_card-header.tsx`) | icon `text-muted-foreground h-5 w-5`, title `h2 text-lg font-semibold`, status slot top-right, description muted below |
| Insights / dashboard / module tile | `Card` + `TileHeader` (`insights/tile-header.tsx`) | icon **`text-foreground` `h-5 w-5 shrink-0`**, title `CardTitle text-base`, `flex items-center gap-2`, optional `right` slot |
**Icon colour rule (both header contracts):** header icons are `text-foreground` (tiles) or `text-muted-foreground` (settings). Never `text-primary`, `text-info`, or any accent; primary/purple stays reserved for actions and highlight states. Pass the Lucide component (`icon={Target}`), never a pre-sized node; the primitive owns size and colour.
The gaps this section used to list are closed: `TileHeader` carries a `size="sm"` variant for compact tiles and an optional `icon` for icon-less headers, and `InsightSectionCard` is the overview container mirroring `SettingsCard`. Use them; do not invent a fourth header language.
## 2. Spacing scale
> **Scroll height is shell-owned.** Only `AuthShell` owns the scroll container's height and bottom padding (`pb-20` wrapper + `<md` bottom-nav clearance on `<main>`). Pages/sections never add viewport-height reserves (`min-h-dvh`, `min-h-[calc(100dvh-…)]`), `h-full` chains, or bottom gutters inside the scrolling `<main>`; nested reserves stack on the shell budget and scroll past the last card. One sanctioned exception: the settings-shell grid floor (budget-derived, documented; retune it when shell paddings change). Lists that need their own scrolling use `max-h-* overflow-y-auto overscroll-contain`. Guard: `e2e/settings-overscroll.spec.ts`.
- **Allowed steps:** `1 / 1.5 / 2 / 2.5 / 3 / 3.5 / 4 / 6 / 8 / 10`. `2.5` and `3.5` are allowed: the medication card's rhythm is built on them, and the in-between steps earn their keep in dense card interiors. `5` (20 px), as in `py-5` or `p-5`, is not allowed; replace it when you touch a file that still has it. `7` (28 px) is not allowed either: it was the old Settings and Admin `pl-7` body gutter, which is gone (§13).
- **Page frame:** exactly one authenticated container, owned by `AuthShell` (`layout/auth-shell.tsx:318`): `mx-auto max-w-screen-xl px-4 pt-6 pb-20 md:px-6`. Never re-implement it in a page; full-bleed pages opt out via a shell flag, not negative margins. Prose pages (about/privacy/legal): `mx-auto max-w-3xl px-4 py-8 md:px-6 md:py-12`.
- **Page rhythm:** root of every page is `space-y-6`. Grouped card sections: `space-y-10` between groups, `space-y-4` inside a group (notifications/integrations pattern). Insights sub-pages join `space-y-6`.
- **Left reading edge:** page-level text (H1, explainer, section headings) sits at the container edge. Only surfaces that paint a visible border/background may inset their content (`px-4 md:px-6`). A block without a border must not carry horizontal padding.
- **Grids:** grids **of** cards/tiles = `gap-4`; value read-outs **inside** one card = `gap-x-3 gap-y-2`; form field grids = `gap-4`.
- **Anchors:** `scroll-mt-28`, single token (31-to-1 in the tree already).
## 3. Text colour rules
The base tokens are AA-tuned in both themes (`--muted-foreground`: 5.0–6.4:1, documented in `globals.css`). The system is two tiers:
- **`text-foreground` = content.** Everything the user came to read: values, chart labels the reading depends on, AI narrative bodies, consent/safety copy, and **all user-entered data** (notes, journal text, free-text fields). User data is never muted.
- **`text-muted-foreground` = meta.** Timestamps, units, provenance, counts, placeholders, one-sentence descriptions under a heading. Heading-in-foreground + short-description-in-muted is the app-wide convention (CardDescription, PageHeader, SettingsCardHeader); keep it; do not "fix" it to foreground.
- **Description slots hold exactly one meta sentence, guideline ≤ 120 characters.** One sentence, one full stop, no paragraph stack. 120 characters is the guideline, not a hard cut: it is the p75 of the descriptions that already read well, and a description that needs 130 characters to stay true is fine while a two-sentence one never is. Multi-sentence explainer prose goes in the body as `<p className="text-sm">` (foreground), not into a muted description slot; if the body would not carry it either, the sentence is not needed on the surface at all and belongs in the docs. The description slot renders a single text run; it deliberately owns no vertical rhythm of its own, so a call site cannot stack paragraphs into it.
- **One statement, one string.** A section's page subtitle and its card description must not say the same thing twice on one screen. When a section owns a single card, the subtitle carries the statement and the card header drops its description. Same rule for a field label and its field description: the label names the field, the description adds what the label cannot.
- **FORBIDDEN: opacity modifiers on muted text.** `text-muted-foreground/70` ≈ 2.9–3.9:1 and `/80` ≈ 3.4–4.2:1, below AA, and they silently undo the token tuning. Full `text-muted-foreground` IS the de-emphasis tier; a further tier is expressed via size/weight, never via alpha. `text-foreground/80` is safe (≥6.7:1) and allowed. `/60` is icon-only (3:1 graphics floor).
- **Disabled look:** `opacity-50` on the container + `aria-disabled`, not colour dimming on the text node.
- **Size floor:** muted text is never smaller than `text-xs`. Below that, the one legal step is the `text-2xs` token (0.6875rem, minted in `globals.css`) and it is for chips, badges, and tabular-nums micro-labels with verified contrast, never for muted body or meta text. Arbitrary sizes (`text-[0.65rem]`, `text-[11px]`) are drift; chart-axis `text-[10px]`/`text-[11px]` stays tolerated as documented.
- **Status colours** carry meaning, not decoration: `text-success` / `text-warning` / `text-info` / `text-destructive` (+ `bg-*/10` washes). All four are AA-retuned per theme.
## 4. Colour token rules
- **Semantic tokens only** in components: `--foreground/--muted-foreground/--primary/--success/--warning/--info/--destructive/--chart-1..5/--border/--card`. The light theme remaps them wholesale; anything outside the token layer is invisible to a retune.
- **No raw `dracula-*` utilities in new code.** Only `--dracula-yellow` has a light override; `text-dracula-green` on a light card is 1.4:1. Status meaning → semantic token; brand identity (Coach gradient) → a named `--brand-*` token with per-theme values.
- **No hex in JS props.** Chart colours are passed as `var(--token)` strings, never `color="#22c55e"`. Data series use the `--chart-1..5` tokens (`color="var(--chart-2)"`); status tokens (`--success`, `--warning`, `--destructive`, `--info`) only where the colour carries meaning, such as a target band, time in range or a mood scale (v1.42). Recharts `stroke`/`fill` likewise. This is the known lint blind spot; review for it explicitly until the rule covers it.
- **No arbitrary-value colours** (`bg-[#…]`, `text-[oklch(…)]`).
- Exemptions (documented at the definition site): `global-error.tsx` (renders without the stylesheet), the `themeColor` meta, the QR quiet-zone `bg-white`.
## 5. Typography scale
| Tier | Classes | Where |
| ------------------------ | ------------------------------------------------------------------- | --------------------------------------------------------------- |
| App H1 | `text-2xl font-bold tracking-tight` | every module page, via `PageHeader`: weight is **bold**, always |
| Settings card title | `text-lg font-semibold` | via `SettingsCardHeader` |
| Tile title | `text-base` on `CardTitle` (`font-semibold` from the primitive) | via `TileHeader` |
| Chip / badge micro-label | `text-2xs` (11 px) | `TagChip`, `Badge`, tabular-nums counters: never muted prose |
| Section label | `text-xs font-medium uppercase tracking-wide text-muted-foreground` | list group headings |
| Body | `text-sm` (foreground) | default content |
| Meta | `text-xs text-muted-foreground` | timestamps, units, hints: the floor |
| Prose-page H1 | `text-3xl md:text-4xl font-bold` | about/privacy/landing tier only |
No `text-[9px]`. No off-scale sizes (`text-[28px]`); round to the neighbouring step. Icon sizes come from the five-step vocabulary `size-3 / 3.5 / 4 / 5 / 6`; prefer `size-N` over `h-N w-N` (identical CSS, one spelling).
## 6. Component mapping; when to use which primitive
| Situation | Use | Never |
| --------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Page title + description + actions | `PageHeader`: `headingRef` + `focusable` for a title that takes focus after navigation, `titleAccessory` for a glyph or badge beside the H1 (outside its name) | hand-rolled `text-2xl font-bold` H1 blocks (`page-header-h1-guard.test.ts` lists where another H1 is the point: heroes, sign-in, error and prose pages, first-run steps) |
| A second copy of the page title in the same shell (mobile above the nav strip, desktop inside the grid) | `PageHeader headingAs="div"`: one real `<h1>`, one `role="heading" aria-level={1}` | a hand-rolled `role="heading"` div beside a hand-rolled `<h1>` |
| Settings card | `SettingsCard` + `SettingsCardHeader` + `FieldGroup` | raw `Card` in Settings sections |
| Heading above a group of cards (Insights overview, Settings groups) | `SectionHeading` (`ui/section-heading.tsx`) | a `SettingsCardHeader` with no card under it, or a hand-rolled `h2` |
| Action row in a Settings / Admin card | `SettingsCardActions` | a hand-rolled `flex justify-end gap-2` div |
| Admin mini stat tile | `StatTile` (`admin/_stat-tile.tsx`) | a hand-rolled `bg-muted/NN rounded-lg p-3` box |
| Insights/dashboard tile | `Card` + `TileHeader` | hand-rolled icon+title rows, `bg-card rounded-xl border p-4` divs |
| Insights overview section | `InsightSectionCard` | a hand-rolled section shell beside it |
| Neutral label chip on a card header | `TagChip` | a hand-copied `bg-muted … rounded-full` literal, or a `Badge` (that means status) |
| Read-query failed (`isError` on a primary / list surface) | `QueryErrorCard` with `error={query.error}` and `onRetry={refetch}` | bare `text-destructive` paragraphs without retry; a card without `error` |
| Read-query failed inside a dense metric strip, tile or supplementary block, where the tall card would dominate the geometry | `QueryErrorRow` with `error={query.error}` and `onRetry={refetch}` | a hand-rolled row; a third read-failure primitive (`ReadFailureRow` was one and is gone) |
| Read-query failed inside a card that already has its frame and header (a tile body, a settings card section) | `QueryErrorRow inset`: keeps the card and its title, no second border | `QueryErrorCard` inside a card (card in card); replacing the card and its header with the error |
| Read-query failed on a chart body | `ChartErrorState` with `error={query.error}`: same decision as the other two | either of the above |
| A list the server capped (`meta.truncated`) | `ListCapNotice` under the list, read through `apiGetList` / `apiPostList` | a hand-written "showing the latest N" line; reading `data.truncated` |
| Mutation error inside a form/sheet | inline `role="alert"` text | `QueryErrorCard` (wrong shape) |
| Honest no-data state | `EmptyState` | reusing error copy: a failed query must never fall through to "no data yet" |
| Modal reachable on mobile | `ResponsiveSheet` (bottom sheet on phones) | raw `Dialog` (admin/desktop-only surfaces excepted) |
| A sheet from the bottom edge (phones) | `<SheetContent side="bottom">` or `ResponsiveSheet`, both of which render `BottomSheetContent` (`ui/bottom-sheet.tsx`): grip, swipe to close, X on the title row. `preview="58dvh"` for a sheet that opens part-way and pulls up to 90 dvh (its footer `data-sheet-footer`, its scroll body `data-sheet-body`); `draggable={false}` only for a sheet that fills the window | a hand-rolled grab handle or drag; a `fixed bottom-0` panel standing in for a sheet |
| Date / time entry | `DateField` / `TimeField` / `DateTimeField` | native `type="date | time | datetime-local"` inputs (browser-locale trap, v1.25.4) |
| Small text action inside a line (Open schedule, Select all, View all, Retry, Learn more) | `InlineAction` (`ui/inline-action.tsx`): tone `primary` / `muted` / `foreground`, size `xs` / `sm`, `asChild` for a `Link`; owns the focus ring and `pointer-coarse:min-h-11` | a hand-rolled underlined `<button>` / `<Link>` (`inline-action-guard.test.ts`) |
| Mutually exclusive choice on one track (range, zoom, cards vs table, recent vs all) | `Segmented` (`ui/segmented.tsx`): no track, the chosen segment filled in primary (the chart tabs' look; a muted track with a raised segment was tried in 1.43.8 and dropped in 1.43.10 as too heavy); `mode="radio"` (default, roving focus) or `"toggle"` (view switches, form sheets), `iconOnly` for icon segments, `size="sm"` for range rows in a chart header; 44 px per segment on a coarse pointer | a row of ghost/secondary Buttons, a fifth segmented variant (`segmented.test.tsx` holds the definition to one module) |
| A grade beside a reading (confidence, score gain, band, lab range flag) | `ScorePill` (`ui/score-pill.tsx`): tone `success` / `info` / `warning` / `destructive` / `neutral`, `numeric` for figures; `CONFIDENCE_TONE` maps both confidence scales | a hand-rolled `rounded-full px-2 py-0.5 text-xs bg-*/15` chip (`score-pill-guard.test.ts`); `TagChip` (neutral label) and `Badge` (connection/account state) stay what they are |
| "This vital on these days vs those days" board | `MetricCrosstab` (`insights/metric-crosstab.tsx`): caller formats the numbers, the board owns row, neutral signed delta, confidence chip, dismissal | a fourth copy of the crosstab list |
| A trend / stat tile in a grid (KPI strip, derived tiles, their skeletons and building states) | `TrendTile` (`ui/trend-tile.tsx`) | the hand-spelled `bg-card border-border … p-4 md:p-6` frame |
| A whole card that points to another page | `CrossLinkCard` (`insights/cross-link-card.tsx`) | a `Link` styled as a card |
| Picking documents from the vault | `DocumentPickerSheet` (`documents/document-picker-sheet.tsx`); the caller decides what a toggle means | a copy of the search + list sheet |
| A field / chart / list with nothing to show | `EmptyState variant="plain" size="compact"` (with `action` for the next step), `ChartEmptyState` on a chart body | a hand-written muted paragraph (`empty-state-ratchet.test.ts` freezes each remaining site as `path: key`, and a new one fails; the frozen ones are field-level "not recorded" lines and editor hints) |
| A number shown to a person | `useFormatters()` (`fmt.number` / `integer` / `percent`), `formatBytes(bytes, locale)` from `lib/format/bytes` | `toFixed()`, a locale-less `toLocaleString()`, `String(a / b)` (`healthlog/no-unlocalised-decimal`) |
| Numeric range entry (0–10 self-rating, any bounded scale) | `Slider` / `SliderField` (`ui/slider.tsx`): `SliderField` when the scale has anchors, which is nearly always | a hand-rolled `input type="range"`, or an 11-button radiogroup (eleven 32 px targets overflow a 390 px viewport) |
| Outbound fetch | `safeFetch` | raw `fetch(` (lint-enforced) |
| TanStack query keys | `src/lib/query-keys/` factory | bare arrays (lint-enforced) |
**What a failed read offers is decided by the primitive, never the call site.** `QueryErrorCard`, `QueryErrorRow` and `ChartErrorState` take the query's `error` (required) and branch on `readFailureKind` (`src/lib/api/read-failure.ts`). It reads `meta.errorCode` first and the status second, because every error envelope carries a code (`http.*` when the route names none): a step-up or re-proof code on a 401 is a failure with retry, not an ended session; an unknown code falls back to the status.
- **Session ended** (a session code, or a 401 without a more specific one) → one sentence and a Sign in button (back to the current page). Never a silent blank.
- **403, refused** → the surface's `refused` state if it has one, otherwise nothing. A refusal is an answer, not a failure; no retry.
- **429, throttled** → one sentence that the read budget is spent, no retry (an immediate retry would only be refused again).
- **anything else** → the surface's message with Retry.
The `queryFn` hands the `ApiError` through untouched: no `try { … } catch { throw new Error(t(…)) }` around the fetch, the surface's sentence goes in through `message`. A call site does not inspect `error.status` around a read-failure primitive, and does not pass `error={null}`. `query-error-error-prop-guard.test.ts` enforces the prop and the untouched error at every site, `read-failure-markup-guard.test.ts` refuses hand-rolled failure markup next to a read hook. Error codes become sentences through `src/lib/i18n/translate-code.ts`, never through a `t(key) === key` probe at the call site (`translate-probe-guard.test.ts`).
## 7. Enforcement
Live, all at `error`: `healthlog/queryKey-factory`, `healthlog/safe-fetch-required`, `healthlog/api-fetch-required`, `healthlog/no-raw-palette-color` (palette + colour-shaped JS props + arbitrary values), `healthlog/no-dracula-utility`, `healthlog/spacing-scale` (`pt-`/`pb-` on `CardHeader`/`CardContent`, and the banned `5` step on a `bg-card` + `border` shell), `healthlog/no-raw-card-shell` (since v1.43.8: `bg-card` + `border` + `rounded-xl` on one literal under `src/components` / `src/app` outside `ui/`; the files where a shell is the point (shell primitives, the hero, popovers, the chart frame shared by chart/skeleton/error state, table frames, sign-in and first-run screens) are the rule's `allow` list in `eslint.config.mjs`, each with its reason, no inline escape), `healthlog/no-unlocalised-decimal` (since v1.43.8; reasoned `eslint-disable-next-line` only, for SVG coordinates and number-input values).
Structural tests (since v1.43.8): `inline-action-guard`, `score-pill-guard`, `empty-state-ratchet`, `page-header-h1-guard`, `card-density`, `segmented` (one definition), `client-route-zod-guard` (documents and Coach ship no zod in their static client graph).
Planned (until it lands, this is a review-checklist item):
1. A tile-header icon-colour check (`text-muted-foreground` / `text-primary` on a glyph inside a `CardTitle` or tile-header row).
**PR checklist for any UI change:** both themes checked visually · no new `dracula-*`/hex/opacity-muted · headers via a primitive · card gap untouched by slot padding · `isError` → `QueryErrorCard` or `QueryErrorRow` by surface, with `error` · mobile modal = `ResponsiveSheet` · `pnpm format` + `pnpm lint` + `pnpm typecheck` green.
---
## 8. Insights left reading edge; two edges, one gutter
Every text run on an Insights surface starts on one of exactly two vertical lines, and card headings offset from the card line by exactly one gutter:
- **Edge A, reading edge (x = 0):** the AuthShell container inner edge (`px-4 md:px-6`). All **borderless page-level blocks** start here with **no extra horizontal padding**: the sub-page `<h1>` + explainer (`SubPageShell`), and every `SectionHeading`. If a sub-page heading must track the card body instead, it uses the responsive card inset `px-4 md:px-6`, **never** a flat `px-6` (that sat 8 px right of the card body on mobile and reopened the heading-vs-body step).
- **Edge B, card edge (x = card `px`):** every `<Card>` insets header **and** body to `px-4 md:px-6`. No card inset with a static `p-5` (banned) or a `p-4`-only/`sm:` shell; header and body share one inset so the body left edge is identical on every tile.
- **Heading gutter (Edge B + gutter):** every card heading routes through `TileHeader`. The icon's left edge sits on Edge B; the heading **text** sits at Edge B + gutter (icon box + `gap-2` = 28 px at `size="default"`, 24 px at `size="sm"`). Compact tiles use `TileHeader size="sm"`, never a bespoke `h-4` icon row. Tile header icons are `text-foreground` (never muted, never an accent) at both sizes.
Forbidden on any Insights card: a hand-rolled icon+title row, a card title centred by default, an icon box outside `TileHeader`'s h-5/h-4 vocabulary, and a horizontal inset other than `px-4 md:px-6` (or the sanctioned `p-3` dense inner tile). Verify at 390 px: every card body shares one left x; every card heading text shares a second x one gutter to the right; the `<h1>` + any `SectionHeading` sit on the container edge.
## 9. One scroll floor; sub-shells never re-declare bottom-space
Reinforces §2's "scroll height is shell-owned". Only `AuthShell` owns the scroll container's bottom padding and any viewport-height reserve: the `<main>` clears the mobile bottom-nav (`pb-[calc(4rem+safe-area)]`) and its wrapper clears the Coach FAB (`pb-20`). **No page, section, or sub-shell** adds a `min-h-[calc(100dvh-…)]` / `min-h-dvh` reserve or a `pb-*` bottom gutter inside the scrolling `<main>`; a nested reserve stacks on the shell budget and scrolls past the last card.
A two-column sub-shell that needs a "don't jump while loading" floor derives it from **one shared source**: `SUB_SHELL_GRID_FLOOR` (`src/components/layout/shell-metrics.ts`); applied to the **grid** (not a column), so the `1fr` content row stretches to fill without over-shooting. It never re-declares the floor per-shell (`AdminShell` drifting to a stale 12rem column reserve is exactly how this recurred). Guard: `e2e/settings-overscroll.spec.ts` covers Settings **and** Admin and measures against each element's content-box bottom, so a redundant sub-shell `pb-*` fails instead of inflating the allowed budget.
## 10. Navigation; utilities live only in the user menu
Settings + Notifications are **account utilities, not feature destinations**. They live ONLY in the user/avatar menu (mobile top-bar `DropdownMenu`; desktop sidebar avatar menu, with Settings additionally in the sidebar footer). The mobile "More" overflow hub carries **feature destinations only** (Measurements, Mood, Cycle, Labs, Coach, …), never Settings or Notifications. The hub is `mobileMoreHubDestinations()` = `visibleNavDestinations` minus the primary slots (`/`, `/medications`, `/insights`); it does **not** append the utility tail. `NAV_UTILITY_DESTINATIONS` (`visibleUtilityDestinations()`) feeds the sidebar footer + user menus, not the mobile overflow. Never surface a utility in two menus reachable from the same screen.
## 11. Inline action row; the PageHeader pattern
A title/sentence with an adjacent action is one row, not a wrapping stack: `flex items-start justify-between gap-3`, the text `min-w-0 flex-1` (wraps internally), the action `shrink-0` (stays beside the text, never pushed to its own line). This is the canonical `PageHeader` / `TileHeader` (`right` slot) shape; reuse it (e.g. the dashboard-hero verdict + CTA) instead of a one-off `flex-wrap` row whose `gap-y-*` drops the action below on a full sentence. Keep the mobile tap-target floor (`min-h-11 sm:min-h-9`).
## 12. The action row; one place, one shape
A card's actions live in **one** row, and that row is the **last thing in the card**. Reference implementations: `src/components/settings/score-section.tsx`, `src/components/settings/coach-prefs-section.tsx`.
- **Use `SettingsCardActions`** (`settings/_card-actions.tsx`). It owns the row: `flex flex-wrap items-center gap-2`, `justify-end` by default. Never hand-roll a `flex justify-end gap-2` div in a Settings or Admin card.
- **Primary action:** last child of the row (rightmost), `variant` default, `size="sm"` + `min-h-11`. One per card.
- **Secondary action:** `outline`, before the primary in the same row. Cancel/reset are secondary, never `ghost` in the same row as a primary.
- **Destructive card-wide action:** the same row, `variant="destructive"`. Never hand-tinted (`text-destructive border-destructive/30` on an `outline` is drift).
- **Nothing follows the action row.** Status lines, result lists, and confirmations belong **above** it; a result set large enough to need its own frame becomes its own card or a collapsible.
- **The header `status` slot carries status, not actions**; a badge, a pill, a switch. Not a primary, not a destructive, not a filter group. Filters get a toolbar row above the content.
- **`outline` has exactly one meaning: secondary.** A card whose only actions are deep links out (privacy, about, export) has no primary; those links stay `outline size="sm"` and the row uses `align="start"`, because a navigation row reads with the text, not against it. That is the one documented exception, and `SettingsCardActions` spells it (`align="start"`).
## 13. Settings + Admin card rhythm; owned by the container
- **`SettingsCard` is gap-based**, like `Card`: `flex flex-col gap-4 p-4 md:p-6`. The header→body distance comes from the container's gap. A call site never adds `mt-4` to its first body child, never `mb-4` to the header, never `space-y-4` to the card. A denser card overrides the gap on the card itself (`className="gap-2"`), exactly as `Card` does.
- **The body starts at the card edge.** The old `pl-7` gutter (icon box + `gap-2`) is abolished: it is off the scale in §2, it broke the moment two neighbouring cards disagreed, and it silently mis-aligned action rows against their own forms. Header text is offset from the card edge by the icon gutter; body content is not. This is the deliberate difference from §8's Insights gutter, where the tiles carry no body form.
- **Group headings above a group of cards use `SectionHeading`** (`ui/section-heading.tsx`, the heading of the Insights overview, shared by every area), not a bare `SettingsCardHeader`. `text-base` on the group, `text-lg` on the card title: that step is what says which one labels which.
- **Admin uses the Settings anatomy verbatim.** No admin-only card shell, no admin-only header, no admin-only mini tile; `SettingsCard`, `SettingsCardHeader`, `SettingsCardActions`, `StatTile` (`admin/_stat-tile.tsx`) are the whole vocabulary.
- **A loading state paints the card and its header.** A section that renders a bare spinner while loading and a card afterwards moves the page under the cursor. Reference: `admin/coach-feedback-section.tsx`.
### Enforcement
The `p-5` / `py-5` / `px-5` / `space-y-5` family does not occur in the app, and `healthlog/spacing-scale` runs at `error` over `src/components/**` and `src/app/**`, so the step cannot come back unnoticed. `no-raw-card-shell` is described in §7. The header icon colour (§1) has no lint rule; check it in review.
@@ -0,0 +1,97 @@
# Push a room's CO2, temperature and humidity to HealthLog every few minutes.
#
# Use it for one room. The readings carry no room name, so a second room would
# land in the same series, and a push in the same minute is refused (409).
#
# Needs the `healthlog_room_climate` rest_command from
# docs/integrations/home-assistant.md ("Room climate") and a measurement
# ingest token. Every sensor is optional; a sensor that reports `unknown`,
# `unavailable` or a non-number is left out of that push, and a push with
# nothing left is skipped. Temperatures in °F are converted to °C.
blueprint:
name: HealthLog room climate
description: >-
Sends indoor CO2 (ppm), temperature (°C) and relative humidity (%) to
HealthLog through a measurement ingest token. HealthLog keeps each
reading and shows the day's mean, low and high.
domain: automation
homeassistant:
min_version: 2024.10.0
source_url: https://github.com/MBombeck/HealthLog/blob/main/docs/integrations/blueprints/healthlog-room-climate.yaml
input:
co2_sensor:
name: CO2 sensor
description: A sensor reporting carbon dioxide in ppm.
default: ""
selector:
entity:
domain: sensor
device_class: carbon_dioxide
temperature_sensor:
name: Temperature sensor
description: A sensor reporting the room temperature (°C or °F).
default: ""
selector:
entity:
domain: sensor
device_class: temperature
humidity_sensor:
name: Humidity sensor
description: A sensor reporting relative humidity in %.
default: ""
selector:
entity:
domain: sensor
device_class: humidity
interval_minutes:
name: Interval
description: >-
Minutes between pushes, on the hour and every interval after it.
15 is plenty for a daily picture.
default: "15"
selector:
select:
options: ["5", "10", "15", "20", "30", "60"]
mode: single
variables:
co2_sensor: !input co2_sensor
temperature_sensor: !input temperature_sensor
humidity_sensor: !input humidity_sensor
interval_minutes: !input interval_minutes
# A list of readings (Home Assistant keeps a rendered list a list).
readings: >-
{% set at = now().replace(second=0, microsecond=0).isoformat() %}
{% set ns = namespace(items=[]) %}
{% for type, entity in [
('INDOOR_CO2', co2_sensor),
('INDOOR_TEMPERATURE', temperature_sensor),
('INDOOR_HUMIDITY', humidity_sensor)
] %}
{% if entity and is_number(states(entity)) %}
{% set v = states(entity) | float %}
{% if type == 'INDOOR_TEMPERATURE'
and state_attr(entity, 'unit_of_measurement') == '°F' %}
{% set v = ((v - 32) * 5 / 9) | round(2) %}
{% endif %}
{% set ns.items = ns.items
+ [{'type': type, 'value': v, 'measuredAt': at}] %}
{% endif %}
{% endfor %}
{{ ns.items }}
triggers:
- trigger: time_pattern
minutes: "/5"
conditions:
- condition: template
value_template: >-
{{ now().minute % (interval_minutes | int(15)) == 0
and readings | count > 0 }}
actions:
- action: rest_command.healthlog_room_climate
data:
readings: "{{ readings }}"
+18 -8
View File
@@ -16,14 +16,24 @@ Settings. The internal integration key is `fitbit`.
> consent screen with `unauthorized_client — Invalid client_id`, because Fitbit
> and Google Cloud are separate client registries.
> **Heads-up — new registrations are closed.** Fitbit has **discontinued
> self-serve app registration**: the form at `dev.fitbit.com/apps/new` is
> disabled for new applications, and the classic Fitbit Web API sunsets in
> **September 2026**. If you already hold a registered Fitbit app, it keeps
> working until the sunset and the setup below still applies. If you are
> connecting Fitbit data for the first time, use the
> [Google Health integration](google-health.md) instead — Fitbit devices sync
> into Google's health stack, which HealthLog reads directly.
> **The classic Fitbit Web API is turned off on 30 October 2026.** Google
> ended support for it on 30 September 2026 and turns it off on
> **30 October 2026**, after which it "will no longer function or sync data"
> ([Google's migration guide](https://developers.google.com/health/migration)).
> New app registrations at `dev.fitbit.com/apps/new` are already closed.
>
> From that day HealthLog stops calling the Web API for every Fitbit
> connection: no hourly sync, no backfill, no token refresh, and no new
> connection. Nothing is deleted. The connection and every reading it imported
> stay, and the Fitbit card explains the move instead of asking for a
> reconnect. To keep Fitbit and Pixel data coming, connect the
> [Google Health integration](google-health.md). It reads the same devices,
> but Google asks for consent once more: Fitbit tokens cannot be carried over.
>
> Both integrations can run side by side while you move. Where both deliver the
> same day, the source-priority ladder keeps one of them per day (Fitbit ranks
> just above Google Health by default), so steps, sleep and the other daily
> values are not counted twice. After the turndown only Google Health delivers.
## 1. Use an existing Fitbit developer app
+68 -7
View File
@@ -2,9 +2,12 @@
HealthLog reads Fitbit, Pixel Watch and Fitbit Air data through the **Google
Health API** (`health.googleapis.com/v4`), the successor to the classic Fitbit
Web API. Google retires the classic Fitbit Web API in **September 2026**, and
Fitbit access/refresh tokens do not carry over — every account re-consents
through Google OAuth. This integration is that path.
Web API. Google ended support for the classic Fitbit Web API on 30 September
2026 and turns it off on **30 October 2026**
([migration guide](https://developers.google.com/health/migration)); from that
day HealthLog's `fitbit` connection stops syncing and keeps what it imported.
Fitbit access/refresh tokens do not carry over, so every account consents once
more through Google OAuth. This integration is that path.
The OAuth app is a **Google Cloud** OAuth client, not a Fitbit developer-console
app. Credentials are brought **per user**: each user registers their own Google
@@ -126,8 +129,11 @@ Only one sync runs per account at a time. **Sync now** reports that a sync is
already in progress while the history backfill that follows a connect is
running, and shows its progress instead of starting a second one.
The backfill reads the account's whole history, and for a watch that records
heart rate every minute that is a thousand-odd pages per year. It runs in
The backfill reads the account's whole history. Heart rate is by far the
largest part: a page holds up to 1,000 readings, so a watch that records once
a minute fills some 525 pages a year, and one that records every few seconds
(as many Pixel and Fitbit watches do during the day) several thousand; one
account measured about 4,100 pages a year. It runs in
slices: a slice reads for up to five minutes or 500 pages, writes what it
read, notes how far it got and lets go of the account. The next slice starts
a minute later where the last one stopped. Two kinds of note are kept: a data
@@ -171,6 +177,44 @@ three times before they count as a failure. None of these mark the connection
as needing a reconnect. Access tokens last an hour, and a long backfill renews
its token as it goes.
### How far back the backfill reads
The backfill is not bounded by a number of pages, because how many pages a
year of history fills depends on the device. Two bounds apply instead:
- **The horizon.** It reads back at most 20 years. Set
`GOOGLE_HEALTH_BACKFILL_HORIZON_YEARS` to a whole number from 1 to 100 to
change that (it is on the compose `environment:` list; any other value
falls back to 20). A data type whose history goes back further is imported
up to the horizon and ends there.
- **Progress.** A walk has to reach older readings as it goes. If Google
hands out 20 pages in a row that reach no further back than the ones before
them (the same page again, or a page token that goes round in a circle),
the walk stops where it got to. A passing fault in Google's paging looks
the same as the end of what it holds, so the first time this happens the
data type only waits: it keeps its place, the rest of the backfill goes
on, and a day later it is walked again from there. If that second attempt
also stops without getting anywhere, the data type's history ends at the
oldest reading written so far. If it moves on, it is an ordinary walk
again. A walk that stops before it has written a single reading notes no
end at all (there is no date it could be said to end at); the slice fails
as stuck and is retried like any other. A reconnect starts the backfill
over.
Either way the data type counts as finished once its end is final, the
backfill completes once every other type is in, and the integration card
stops saying that older history is loading. Instead it says, per data type,
how far back the import reaches and why it ends there. While a type waits
for its second attempt the backfill is not complete, and the card still
says older history is loading.
Before 1.43.6 every page-by-page data type had a ceiling of pages counted
across all slices (10,000 for heart rate, 1,000 for the others). A dense
heart-rate history reaches 10,000 pages two to three years back, the count
never reset, and from then on every slice stopped at the same place without
the backfill ever completing. A backfill left in that state continues from
where it stopped with the first slice after the update.
A slice that moved nothing forward (no data type finished, no reading older
than before) is a real failure: the job fails with the reason, pg-boss retries
it a few times, and after that the next restart offers the backfill again.
@@ -204,8 +248,10 @@ reading:
upper-bound filter Google refused.
- `stopReason`: why it ended early. `time_budget` and `page_budget` are the
normal end of a slice, `rate_limited` means Google would not take more
requests for now, `job_budget` that the job itself ran out of time.
`null` means the slice read everything it started.
requests for now, `job_budget` that the job itself ran out of time,
`lane_wanted` that the slice made room for another heavy job (a nightly
run, a backup, an upload) that was waiting; the next slice starts two
minutes later. `null` means the slice read everything it started.
- `collectionsDone` of `collectionsTotal`: data types read to the end so
far.
- `collections`: one entry per data type the slice touched, with its
@@ -215,6 +261,21 @@ reading:
`edgeFrom` and `edgeTo`, the oldest reading written before and after the
slice.
- `edges`: where every unfinished page-by-page data type stands now.
- `ended`: data types whose history ended short of its beginning, each
with its measurement `type`, the instant it ends at (`at`) and the
`reason` (`horizon` or `no_progress`). A `no_progress` entry also
carries `attempts` and `endedAt`; with one attempt it is waiting for
its second, a day after `endedAt`, and is not yet published.
- `action: google_health.backfill.retry_scheduled` marks a slice that found
nothing left but a data type waiting for its second attempt and queued the
next slice for when it is due (`delaySeconds`).
- `meta["googleHealth.backfill.ended"]` appears on the slice where a data
type ended short: the `collection`, its measurement `type`, the `reason`,
whether the end is `final`, how many data types have ended short so far
(`endedCount`) and of those for good (`finalCount`), and their `types`.
The full entries are in the slice report's `ended`. A walk stopped for lack of
progress also leaves a warning:
`fetchHeartRate: 20 pages in a row reached no further back than <instant>`.
- `meta["googleHealth.backfill.edgeFilterUnsupported"]` appears on the slice
where Google refused the upper-bound filter, listing the data types noted
as refusing it.
+89 -6
View File
@@ -109,20 +109,20 @@ a new weight:
```yaml
automation:
- alias: "Push weight to HealthLog"
trigger:
- platform: state
triggers:
- trigger: state
entity_id: sensor.bathroom_scale_weight
condition:
conditions:
- condition: template
value_template: "{{ trigger.to_state.state not in ['unknown', 'unavailable'] }}"
action:
- service: rest_command.healthlog_measurement
actions:
- action: rest_command.healthlog_measurement
data:
type: WEIGHT
value: "{{ trigger.to_state.state | float }}"
```
The `condition` is not decoration. Home Assistant sensors publish `unknown` and
The condition is not decoration. Home Assistant sensors publish `unknown` and
`unavailable` as ordinary states, and forwarding one produces a 422 on every
restart.
@@ -149,6 +149,89 @@ Units are derived server-side from the type, so send the number in the unit
above and do not send a `unit` field. The full enum is in
`docs/api/openapi.yaml` under `MeasurementType`.
## Room climate
Indoor CO₂, temperature and humidity are their own types, kept apart from the
body temperatures and from the outdoor weather the environment module pulls:
| What the sensor reports | `type` | Unit expected |
| ----------------------- | -------------------- | ------------- |
| Carbon dioxide | `INDOOR_CO2` | ppm |
| Room temperature | `INDOOR_TEMPERATURE` | °C |
| Relative humidity | `INDOOR_HUMIDITY` | % |
Each push is kept as a reading, and the day's mean, low and high are what the
charts and the daily views read, so a sensor sampled every 15 minutes gives a
clear picture without anything further on your side. Pushing every few seconds
adds rows, not detail.
Add a second `rest_command` that takes a ready-made list of readings, so one
request carries all three:
```yaml
rest_command:
healthlog_room_climate:
url: "https://healthlog.example.com/api/measurements"
method: POST
headers:
Authorization: !secret healthlog_token
Content-Type: "application/json"
payload: "{{ readings | to_json }}"
```
Track one room, the one you sleep in: that is the one worth reading next to
your sleep. The readings carry no room name, so a second room would land in the
same series and blur both, and two pushes in the same minute collide on a 409
(see below).
Then import the blueprint (Home Assistant 2024.10 or newer), either from
[`docs/integrations/blueprints/healthlog-room-climate.yaml`](./blueprints/healthlog-room-climate.yaml)
or by its raw GitHub URL under Settings → Automations & scenes → Blueprints →
Import blueprint, and create one automation from it for that room. It asks for
the three sensors (each optional) and an interval of 5, 10, 15, 20, 30 or 60
minutes, counted from the full hour. Every push:
- leaves out a sensor that reports `unknown`, `unavailable` or anything that is
not a number, and skips the push when nothing is left;
- converts a temperature reported in °F to °C;
- stamps all readings with the current minute, so a retry of the same push is
refused with a 409 instead of written twice.
If you would rather write the automation yourself, this is the same thing for a
single room without the blueprint:
```yaml
automation:
- alias: "Push bedroom climate to HealthLog"
triggers:
- trigger: time_pattern
minutes: "/15"
variables:
readings: >-
{% set at = now().replace(second=0, microsecond=0).isoformat() %}
{% set ns = namespace(items=[]) %}
{% for type, entity in [
('INDOOR_CO2', 'sensor.bedroom_co2'),
('INDOOR_TEMPERATURE', 'sensor.bedroom_temperature'),
('INDOOR_HUMIDITY', 'sensor.bedroom_humidity')
] %}
{% if is_number(states(entity)) %}
{% set ns.items = ns.items + [{'type': type,
'value': states(entity) | float, 'measuredAt': at}] %}
{% endif %}
{% endfor %}
{{ ns.items }}
conditions:
- condition: template
value_template: "{{ readings | count > 0 }}"
actions:
- action: rest_command.healthlog_room_climate
data:
readings: "{{ readings }}"
```
One request takes up to five readings, so the room is one call.
## Sending several readings at once
A bare array writes up to five readings in one request — the shape to use for
+5 -3
View File
@@ -204,9 +204,11 @@ n8n, and Health Connect integrations need no change.
## URL changes
- **Settings has moved to per-section routes.** `/settings#integrations`
→ `/settings/integrations`, etc. The legacy `/settings#anchor` URLs
still 308-redirect to the new routes; sidebar deep-links and the
`<a href="/settings#thresholds">` patterns from 1.3 keep working.
→ `/settings/integrations`, etc. A legacy `/settings#anchor` URL opens
Settings (`/settings` redirects to `/settings/account`), but the part
after `#` never reaches the server, so it cannot pick the section: update
bookmarks and links of the `/settings#thresholds` shape to the
per-section route.
## What landed in v1.4 vs v1.4.1
+18 -12
View File
@@ -1,18 +1,24 @@
# `attemptNumber` audit-row vs alert-signal semantics
# `attempt_number` signal vs alert semantics
Two numbers describe a failing integration sync, and they can diverge.
On-call operators reading an audit-log row alongside an admin alert
should expect the alert number to be smaller than (or equal to) the
audit number, never the other way around.
Operators reading an `integrations.sync.failed` log line alongside an admin
alert should expect the alert number to be smaller than (or equal to) the
logged number, never the other way around.
Since v1.43.7 a failed sync is a `warn` signal in the log store, not an
audit-log row: the audit log records what a person or an admin did to an
account, and a provider answering 503 is neither. The provider's message is
not on the signal; it stays encrypted on the integration's status row and
shows in the integration's settings.
## The two numbers
`integrations.sync.failed` audit rows write
`attemptNumber = transient + reauth_required + persistent` — the **sum**
`integrations.sync.failed` signals carry
`attempt_number = transient + reauth_required + persistent` — the **sum**
of all three failure buckets. This is the historical "consecutive
failures" running total carried by the legacy
`IntegrationStatus.consecutiveFailures` column (dropped in migration
`0077`, v1.4.47). Auditors looking at a single row see one integer
`0077`, v1.4.47). An operator looking at a single line sees one integer
that captures how many failures the bucket ledger has accumulated since
the last success.
@@ -27,14 +33,14 @@ single-bucket threshold even if its sum looks alarming.
## When the two diverge
A typed-failure-streak edge case: integration fails 2× transient, then
2× reauth_required, then 1× persistent. Audit row shows
`attemptNumber: 5`. Alert signal is `max(2, 2, 1) = 2`, which is below
the default threshold of 3, so no page fires. From the audit log this
2× reauth_required, then 1× persistent. The last signal shows
`attempt_number: 5`. The alert number is `max(2, 2, 1) = 2`, which is below
the default threshold of 3, so no page fires. From the log this
looks like a sustained 5-failure outage; from the alert dispatcher it
looks like three small bumps that never crossed a single-bucket
ceiling.
Both readings are correct for their purpose. The audit log records what
Both readings are correct for their purpose. The signal records what
happened end-to-end; the alert dispatcher only fires when a single
failure mode persists, because mixed-bucket streaks usually indicate a
transient network blip layered on top of an unrelated reauth prompt —
@@ -43,7 +49,7 @@ we want to wake someone up for.
## Runbook implication
If you see an audit row with `attemptNumber >= threshold` but no alert
If you see a signal with `attempt_number >= threshold` but no alert
was dispatched, check the per-bucket counts on
`IntegrationStatus.consecutiveFailuresByKind` (DB column
`consecutive_failures_by_kind`) for that user/integration pair. The
+15
View File
@@ -230,6 +230,21 @@ rather than by the size of the record going through it: measured on the same
died and the new one finished holding tens of megabytes, writing a 9.1 MB
object that restores to the identical record.
Up to 1.43.4 that held for the tables that are long in rows, not for the
ones that are heavy in bytes. The stored documents and the ECG traces were
still read whole, and a vault of 190 MB cost about five times that in heap:
the bytes, their base64, the section's JSON and the preview's copies of each
row. A single account with that many documents took a 524 MB process down on
every weekly run, while 1.25 million measurements beside them fitted easily.
Both writers now read the traces a page at a time and the documents one at a
time, each document's bytes in slices of 1.5 MB. Measured on that account
under `--max-old-space-size=524`, the peak went from 930 MB (a crash) to
209 MB. The restore had the same problem in the other direction: it kept
every document of the file in memory and peaked at 1.25 GB on the same copy.
It now leaves the stored bytes out of its first read and writes the
documents one at a time from a second one, and the copy restores every
document byte for byte inside the 524 MB heap.
One ceiling remains, and it is structural rather than a memory bound: a
multipart upload carries 10 000 parts, so 80 GB is the largest object one
account can produce. Past it the account's backup fails with a clear refusal,
+80
View File
@@ -40,6 +40,86 @@ not reachable". The entrypoint also runs the migrations on every boot, so
step 2 is optional; it is there for when you want to see the migration
result before the new container takes traffic.
The entrypoint runs every migration in a UTC session, the zone the app's own
connections use, whatever the database's default `TimeZone` is: it adds
`-c TimeZone=UTC` to the `options` parameter of `DATABASE_URL` for the Prisma
calls (an `options` value you set yourself is kept, with the pin after it).
Behind a connection pooler that refuses `options`, the
`DATABASE_SESSION_OPTIONS_DISABLED` switch turns this off along with the
app's own session settings; set the database or role default to UTC there
(`ALTER DATABASE healthlog SET timezone = 'UTC'`).
### A migration that timed out on a lock
Some migrations bound how long they wait for a table lock, so that a
long-running holder (most often an autovacuum that is preventing
transaction-ID wraparound, which does not step aside) cannot queue every
write to that table behind the migration. Migration `0412` waits at most ten
seconds for `measurements`. When the wait runs out, the boot log shows
Prisma's report followed by a line from the entrypoint:
```
Error: P3018
...
Migration name: 0412_measurement_hot_updates
Database error code: 55P03
...
HealthLog: migration 0412_measurement_hot_updates timed out waiting for a lock (55P03) and changed nothing; marking it rolled back and retrying in 15s (retry 1 of 5).
```
A marked migration that loses a deadlock against an app transaction
(`Database error code: 40P01`) is in the same state, its block rolled back
whole, and is handled the same way; the entrypoint's line then says
`was cancelled in a deadlock (40P01)`.
What happens on its own: such a migration is written so that a timed-out
attempt changes nothing, and it says so in its file
(`-- @retry-on-lock-timeout`). The entrypoint then marks that one migration
rolled back (`prisma migrate resolve --rolled-back`) and runs
`migrate deploy` again after 15, 30, 60, 120 and 240 seconds. The app starts
as soon as an attempt gets the lock. A later boot that finds the attempt still
on record (Prisma's `P3009`) reads the cause from `_prisma_migrations` and
does the same. Nothing else is retried: any other failure, and a lock timeout
in a migration that does not carry the marker, stops the boot exactly as
before.
The retries fit inside the container's health check: its start period is ten
minutes (`docker-compose.yml` and the image's own `HEALTHCHECK`), so failed
probes while the entrypoint is still migrating do not mark the container
unhealthy. The first probe that answers ends the start period, so a normal
boot is reported healthy within half a minute of the server listening.
If all five retries time out, the log ends with
`HealthLog: ERROR — migration … timed out on a lock 6 times in a row; giving
up.` and the container exits. Something has held the table for about eight
minutes. Find it:
```sh
docker compose exec db psql -U healthlog -c "
SELECT pid, backend_type, state, now() - xact_start AS running, query
FROM pg_stat_activity
WHERE pid IN (SELECT pid FROM pg_locks
WHERE relation = 'measurements'::regclass)"
```
An `autovacuum: VACUUM … (to prevent wraparound)` row finishes on its own;
wait for it to disappear and start the container again (a restart policy does
that for you, and each boot retries). Anything else is a session you can
judge: a stuck client may be ended with `SELECT pg_terminate_backend(<pid>)`.
You never need to run `migrate resolve` by hand for this case.
### v1.44.0 notes
Migration `0430` drops the `insights_cached_text` column from `users`. A
process still running the previous image selects that column on every full
user read and fails once it is gone, so do not run old and new containers side
by side across this release: stop the old app container, let the new one
apply the migrations, then start it (the sequence above does this on a single
host). A web/worker split deployment stops both roles before the first new
container starts. Rolling back to an image older than v1.44.0 needs the column
back first (see Rollback below).
### v1.11.0 notes
Migrations `0110`–`0114` are additive (new tables / columns for the
+2 -2
View File
@@ -43,12 +43,12 @@ under `v1` (the synthetic id assigned to the existing `ENCRYPTION_KEY`).
retained.
3. **Re-encrypt the stored data under the new key.** Open **Admin →
Encryption** and press **Rotate now**. The button needs an admin account
with a second factor, confirmed recently; it queues one background run
with a second factor or a passkey, confirmed recently; it queues one background run
(`POST /api/admin/encryption/rotate`) on the app's own worker, so it
works on the published image with nothing else installed. The same page
shows the per-column counts and the result of the last run.
Without a second factor on any admin account, run the rotation script
Without a second factor or a passkey on any admin account, run the rotation script
instead. It imports the application code and the generated database
client, so it runs from a source checkout of the same release, not
inside the production container (the image has no package manager and
+48
View File
@@ -0,0 +1,48 @@
# Heap diagnostics
A Node process that reaches its heap limit stops with `JavaScript heap out of memory` and writes nothing else: no stack, no record of what it was doing. HealthLog therefore logs its own heap while it is still alive.
## What is always on
Two background wide events, no configuration needed:
- `runtime.heap.sampled` (info), every five minutes, and every minute while the heap is at or above 70 % of its limit. It goes to stdout and Loki but not to Admin > App logs, so it cannot push other events out of that page's 500-entry buffer. It carries `heap_used_mb`, `heap_total_mb`, `heap_limit_mb`, `heap_used_ratio`, the used size of each heap space (`new_space_mb`, `old_space_mb`, `code_space_mb`, `large_object_space_mb`, `new_large_object_space_mb`, `code_large_object_space_mb`, `trusted_space_mb`, `read_only_space_mb`, and `other_spaces_mb` for any space not named), `rss_mb`, `external_mb`, `array_buffers_mb`, `active_requests`, `active_jobs`, `in_flight_dropped` and `uptime_s`. The spaces add up to `heap_used_mb`: a large buffer or string (a profile picture held in memory, a long response body) shows in `large_object_space_mb`, not in `old_space_mb`, and memory outside the V8 heap shows in `external_mb` and `array_buffers_mb`.
- `runtime.heap.pressure` (warn), the first time the heap reaches 85 % of its limit. Besides the figures above it lists the 50 oldest requests and jobs in flight (`in_flight_oldest`: kind, method, route, age in seconds, whether it was a server action) and the routes most of them share (`in_flight_by_route`). Routes are reduced to their shape: no query string, secret path segments replaced by `[REDACTED]`, ids by `:id`, anything unusual by `:x`. It writes again only after the heap has dropped below 70 % and thirty minutes have passed. One exception: when the heap reaches 95 %, the same report is written again at once, with `pressure_ratio: 0.95`, whatever the cooldown; that tier re-arms as soon as the heap is back under 85 %. A heap that climbs to its limit shortly after an 85 % report therefore still leaves a report naming the work that pushed it over.
The heap is read every five seconds, a single call that takes microseconds. Tracking requests costs one map insert and one delete per request.
To find the last lines before a crash:
```sh
docker compose logs app --since 2h | grep '"runtime.heap.'
```
## The report Node writes when the heap runs out
The image sets `NODE_OPTIONS="--report-on-fatalerror --report-filename=stderr --report-exclude-env --report-exclude-network"`. When the process runs out of heap, Node writes a diagnostic report, about 10 KB of JSON with `"trigger": "OOMError"`, to stderr just before it exits: the JavaScript stack at the failed allocation, the native stack, the heap spaces and the open handles. It lands in the container log beside the `runtime.heap.*` lines:
```sh
docker compose logs app --since 2h | grep -A400 '"trigger": "OOMError"'
```
The two exclude flags keep environment variables (keys, passwords, the database URL) and network interfaces out of the report. If you set `NODE_OPTIONS` yourself, for example to raise `--max-old-space-size`, your value replaces the image's; add the four flags to it to keep the report.
## Optional heap snapshot
A snapshot shows what the heap is made of, which the events above cannot. Turn it on only while you are chasing a specific crash:
```sh
HEALTHLOG_HEAP_SNAPSHOT=1
HEALTHLOG_DIAG_DIR=/tmp # default; a path inside the container
```
With the switch set, the first `runtime.heap.pressure` event is followed by one `v8.writeHeapSnapshot()` and a `runtime.heap.snapshot` event naming the file. At most one snapshot is written per process. The file goes into a new directory with a random name, `healthlog-heap-XXXXXX`, created with mode 0700 and checked to be a real directory owned by the app's user before anything is written; the file itself is mode 0600. The event names the full path.
**A heap snapshot contains everything the process held in memory: decrypted health records, session data, API tokens and the encryption keys.** Treat it like a database dump with the keys attached.
- HealthLog never uploads or logs a snapshot. Only its path appears in the log.
- Copy it off the container only to a machine you trust, for example `docker compose cp app:/tmp/healthlog-heap-XXXXXX ./`, and open it in Chrome DevTools (Memory tab, Load).
- Delete it as soon as you are done, inside the container and wherever you copied it: `docker compose exec app rm -rf /tmp/healthlog-heap-XXXXXX`. A file under `/tmp` in the container also disappears when the container is recreated, not when it restarts.
- Do not attach a snapshot to an issue or send it to anyone.
Writing a snapshot blocks the process for several seconds and needs memory roughly the size of the heap on top of what is in use. Near the container's memory limit that can end the process sooner than the heap would have. The pressure report is written before the snapshot starts, so it is on record either way. Turn the switch off again when you have what you need.
+6 -10
View File
@@ -33,9 +33,9 @@ metadata (`medication_schedules`):
- After upgrading to v1.15.19+, when a user reports a compliance rate above
100 %, duplicate entries in the medication history, or a dose shown as
taken days away from its slot.
- The compliance rollup counts DISTINCT slots since v1.15.19, so the
percentages self-correct on recompute even without running this script.
Running it additionally clears the phantom rows from the history view.
- Compliance counts DISTINCT slots since v1.15.19, so the percentages are
right even without running this script. Running it additionally clears
the phantom rows from the history view.
- The `intake-slot-dedup` worker already collapses canonical-slot
duplicates at boot and on a daily cron; this script is the manual,
immediately-observable fallback and the only path that surfaces the
@@ -104,13 +104,9 @@ healthlog-tsx scripts/repair-intake-anomalies.ts --fix
as the duplicate losers). A medication without any derivable anchor
(PRN-only / unscheduled) is skipped — there is nothing to compare
against.
- **Affected compliance rollups are recomputed** for every touched
`(user, medication, day)`. The script executes the same DISTINCT-slot
aggregation SQL the shared helper runs (a verbatim twin of
`recomputeMedicationComplianceForDay` in
`src/lib/rollups/medication-compliance-rollups.ts` — kept inline so
the script needs no app imports), so the scheduled/taken counts and
the rate self-correct immediately.
- **No separate recompute is needed.** Every compliance reader folds the
rows that share one slot, so the scheduled/taken counts and the rate are
right as soon as the duplicates are tombstoned.
The script is idempotent: a second `--fix` run finds zero duplicate groups
and changes nothing. Exit code is 0 on success (including a clean
+100 -4
View File
@@ -137,7 +137,8 @@ it.
## Reading the result
The run's wide event `job.measurement_maintenance` carries
`maintenance_outcome` (`completed`, `refused_purge_running`, `failed`), the
`maintenance_outcome` (`completed`, `nothing_due` for the nightly check,
`refused_purge_running`, `failed`), the live and dead rows the run started from, the
table and index sizes before and after, and `maintenance_steps`, one entry
per step with its target, size before and after, and duration. The same
sizes from `psql`:
@@ -147,6 +148,101 @@ SELECT pg_size_pretty(pg_total_relation_size('measurements')) AS total,
pg_size_pretty(pg_indexes_size('measurements')) AS indexes;
```
## The nightly check
Since v1.43.7 the same queue also runs by itself at 03:40 (Europe/Berlin).
That run names no steps and changes nothing beyond dropping a retired index
(next section): vacuuming the table is
autovacuum's job, which migration 0412 starts at 5 % dead rows instead of
Postgres's 20 %. The check reads the table's live and dead rows and warns
(`maintenance_autovacuum_behind` on the wide event) when there are at least
10,000 dead rows and more than one for every five live ones, four times the
point at which autovacuum should already have run. That means autovacuum is
not keeping up: look for a transaction left open for hours
(`pg_stat_activity`, `state = 'idle in transaction'`) or for autovacuum
workers busy elsewhere. The check never rebuilds an index. It shares the
singleton key with the run you start, so the two never overlap. The admin
card keeps showing only the runs you started.
The check also measures how far the table file has grown past what its live
rows need (`maintenance_bloat_factor` on the wide event, `bloat_factor` in the
job result). Past 3 it adds a warning to the event: a vacuum makes the space
reusable but does not shrink the file, and only `VACUUM FULL` does (see
below).
Why the table grew in the first place: until v1.43.7 an update that changed
nothing still moved `updated_at`, which sits in an index, so no update could
stay on its page. Migration 0412 keeps `updated_at` and `sync_version` on such
an update and gives the table `fillfactor = 85` and a 5 % autovacuum
threshold. To check that updates stay on their page:
```sql
SELECT n_tup_upd, n_tup_hot_upd, n_live_tup, n_dead_tup,
pg_size_pretty(pg_relation_size('measurements')) AS heap
FROM pg_stat_user_tables WHERE relname = 'measurements';
```
`n_tup_hot_upd` should now grow along with `n_tup_upd`. The fill factor
applies to pages written after the migration; a `VACUUM FULL` after the
upgrade applies it to the whole table.
## Retired indexes
An index the schema no longer declares has to leave the database too, and on
`measurements` the obvious migration is the wrong one. A plain `DROP INDEX`
needs an exclusive lock on the table; `DROP INDEX CONCURRENTLY` blocks nobody
but waits, without limit, for every transaction already running on the
table. A migration runs at boot, so either one waits for whatever holds the
table (a `pg_dump`, a long backfill, a `VACUUM FULL`); past the container's
start period the container is replaced with the migration half done, and the
next boot stops at `P3009` until someone runs `prisma migrate resolve`.
So an index is retired in two halves (v1.43.8, migration 0416, for
`measurements_user_id_type_measured_at_idx`):
1. The migration tries a plain `DROP INDEX IF EXISTS` inside a `DO` block with
a two-second `lock_timeout` and catches the timeout
(`EXCEPTION WHEN lock_not_available`). When nothing holds the table the
index is gone at once; otherwise the migration still succeeds and the
index stays. The longest the table's other statements can wait behind the
attempt is those two seconds. A new database and Prisma's shadow database
always take the lock, so the migration history ends without the index,
exactly as the schema does, and `prisma migrate dev` / `migrate diff` see
no drift and generate nothing.
2. The index name goes into `RETIRED_MEASUREMENT_INDEXES` in
`src/lib/jobs/measurement-maintenance.ts`. Every run of this job, the
nightly 03:40 check included, drops each name still present with
`DROP INDEX CONCURRENTLY IF EXISTS`, waiting at most 60 seconds for its
locks. A run that runs out of time leaves the index (possibly marked
invalid, which the next drop finishes), sets
`maintenance_retired_index_pending` on the wide event with a warning, and
the next night tries again. The index costs writes until then, as it did
before the release; nothing reads it.
Until the job has run, the database holds one index the schema does not
declare. Nothing compares the two at runtime, and the only place they are
compared, a `migrate diff` against the migration history, never sees it. To
check by hand:
```sql
SELECT indexname FROM pg_indexes
WHERE tablename = 'measurements'
AND indexname = 'measurements_user_id_type_measured_at_idx';
```
No row: it is gone. To drop it now rather than at night, start a run from the
admin card (any steps), or run the same statement yourself:
```sql
SET lock_timeout = '60s';
DROP INDEX CONCURRENTLY IF EXISTS "measurements_user_id_type_measured_at_idx";
```
For the next large index, follow the same two halves.
`src/__tests__/migration-lock-timeout-guard.test.ts` refuses a
`DROP INDEX CONCURRENTLY` in any migration and requires every retired name to
have a migration that bounds its wait and catches the timeout.
## "could not resize shared memory segment"
Up to v1.42.0 the run could stop at once with `could not resize shared memory
@@ -160,6 +256,6 @@ runs single-process and needs no shared segment, and the bundled
## Not part of this
`VACUUM FULL` would shrink the table file as well, but it locks the table for
reads and writes while it runs. After the purge the remaining table is small
and the space inside it is reused by new rows, so it is not needed. If you
want it anyway, run it yourself in a maintenance window with the app stopped.
reads and writes while it runs, so no job starts it. When the nightly check
warns about the bloat factor, run it yourself in a maintenance window with the
app stopped.
+11 -8
View File
@@ -189,9 +189,11 @@ no password, no passkey and no e-mail address, and it is never presented
as something to log in as.
- It is created from **Settings → Shared access**, under a fresh second
factor. An account with no second factor enrolled is refused rather than
waved through, because the act creates a permanent management
relationship over a person who cannot object to it.
factor. A passkey counts as one: a guardian who signs in with a passkey,
or confirms it in the dialog the app opens, gets through. An account with
neither a second factor nor a passkey is refused rather than waved
through, because the act creates a permanent management relationship
over a person who cannot object to it.
- Creation takes a display name, optionally a real date of birth, and the
language and timezone the record's own days and reminders are measured
in. A year on its own is never turned into the first of January. The
@@ -356,10 +358,11 @@ Four operational notes:
and no address to look it up by.
- **The second-factor requirement is not the same on both features, and
the difference is deliberate.** Creating a managed profile, deleting
one and inviting a second guardian require a second factor
unconditionally: an account with none enrolled is refused and told to
set one up, because those acts create or end a permanent relationship
over somebody who cannot object. Offering manage access on an ordinary
invitation asks for a fresh factor only from an account that has one, so
one, handing it over and inviting a second guardian require a second
factor unconditionally, and a passkey counts as one there: an account
with neither is refused and told to set one up, because those acts
create or end a permanent relationship over somebody who cannot object.
Offering manage access on an ordinary invitation asks for a fresh factor
only from an account with TOTP or a security key, so
an instance where nobody has enrolled a factor can still use every level
of ordinary sharing. All of them are browser-only either way.
+10 -8
View File
@@ -11,14 +11,16 @@ already installed, longer if a TLS-fronted public hostname is in scope
pulls a multi-arch image from GitHub Container Registry (linux/amd64
- linux/arm64), so a Raspberry Pi 5 or an x86 VPS both work
unchanged.
- **2 GB RAM, 10 GB free disk.** The Postgres data volume grows with
measurement history; the Apple Health import worker can briefly
hold a 1.5 GB upload in `/tmp` while it parses. The app container is
capped at `APP_MEMORY_LIMIT`, `1536m` by default (Node then sizes its
heap at about 792 MB). On a 2 GB host that leaves too little for
Postgres and the system, so set `APP_MEMORY_LIMIT=1g` in `.env`
(a heap of about 524 MB); a long-used account then runs closer to
that heap, and the weekly backup with it.
- **3 GB RAM (4 GB recommended), 10 GB free disk.** The Postgres data
volume grows with measurement history; the Apple Health import worker
can briefly hold a 1.5 GB upload in `/tmp` while it parses. The app
container is capped at `APP_MEMORY_LIMIT`, `1536m` by default (Node
then sizes its heap at about 792 MB), and Postgres and the system need
the rest. 1536 MB is the supported floor for the app container: do not
lower it to fit a smaller host. Below it, a long-used account or a large
import can fill the heap and restart the app; the app still starts, but
the boot log, the support report and Admin > System status say it runs
below the supported floor. On a busy instance, raise it.
- **Outbound HTTPS** to `ghcr.io` (image pulls) plus whichever
integration endpoints you plan to enable (Withings, OpenAI, etc.).
The opt-in environmental-context module additionally reaches
+9 -5
View File
@@ -30,7 +30,7 @@ Three things must be true before `/mcp` will answer:
| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **`APP_URL` is set** | The endpoint fails closed without a pinned canonical origin. `APP_URL` (or `NEXT_PUBLIC_APP_URL`) is the origin every OAuth document and audience check is anchored to — `Host` is never trusted. Without it the surface stays invisible (`404`). |
| **The global API is on** | The operator's instance-wide API switch must be enabled (it is by default). With it off, every token path — including `/mcp` — returns `404`. |
| **The `mcp` module is on** | A per-account opt-in that ships off by default; you turn it on in Settings. Until then `/mcp` answers `404` for that account. |
| **The `mcp` module is on** | A per-account opt-in that ships off by default; you turn it on in **Settings → Modules**. Until then `/mcp` answers `404` for that account. |
`APP_URL` is the same origin you already use for passkeys and invite
links — for example `https://health.example`. It must be reachable by the
@@ -40,11 +40,15 @@ LAN-only origin works for Claude Desktop over stdio).
## 1. Enable the module
In the app: **Settings → MCP**. The card has three parts:
The switch lives with every other module: **Settings → Modules**, the
**MCP** row. Turn it on and the `/mcp` endpoint goes live for your account.
- **Enable** — the opt-in switch. Toggle it on; the `/mcp` endpoint goes
live for your account and the card shows your endpoint URL
(`<origin>/mcp`).
**Settings → MCP** is where you use it. The page has three parts:
- **State** — whether the module is on, with a link to its switch in
Modules, and your endpoint URL (`<origin>/mcp`). Turning the module off
pauses the endpoint; your tokens and connections stay and work again
once it is back on.
- **Connections** — cloud connectors that completed the OAuth flow appear
here; revoke any of them.
- **Tokens** — manual connector tokens (see below).
@@ -90,8 +90,11 @@ different things depending on which button you press.
it out and it is gone from the app, from exports, and from the doctor
report. The row itself stays in PostgreSQL as a tombstone, which is
what lets a phone that was offline at delete time learn that the entry
went away. No job removes cycle tombstones on a schedule, so the row
stays until one of the two actions below.
went away. A nightly job removes a tombstone 75 days after the delete
(the same horizon as the sync feed's); until then, or until one of the
two actions below, the row stays. A start that was folded into a later
cycle stays for as long as that cycle does, because deleting the later
cycle gives it back.
- **Settings → Delete All Data** removes the rows outright, tombstones
included. Everything the account owns goes except what you sign in
with; the confirmation dialog lists it.
+5 -1
View File
@@ -79,9 +79,13 @@ Raise the limit on the container that runs the worker and re-upload:
services:
app:
environment:
NODE_OPTIONS: --max-old-space-size=1024
NODE_OPTIONS: --max-old-space-size=1024 --report-on-fatalerror --report-filename=stderr --report-exclude-env --report-exclude-network
```
Your `NODE_OPTIONS` replaces the image's, so carry the four `--report-*`
flags over: they make an out-of-memory exit leave a diagnostic report in
the container log (see `docs/ops/heap-diagnostics.md`).
An import interrupted by a container restart or an out-of-memory kill
does not stay stuck: the worker reconciles non-terminal import jobs at
boot and every 15 minutes, flipping orphaned rows to `failed`
+1 -1
View File
@@ -13,7 +13,7 @@ one value you must keep yourself: the encryption key.
| Port | `3000` (HTTP) |
| User | runs as uid `1001`; no extra capabilities, `no-new-privileges` is fine |
| Health check | `GET /api/health`, `200` when ready |
| Start period | at least 60 s, with more retries than the image's 3 × 30 s: migrations run at every boot |
| Start period | at least 60 s, with more retries than the image's 5 × 30 s: migrations run at every boot |
| Database | PostgreSQL 16 (17 and later are not a drop-in), no extensions |
| Volumes | none for the app; the database gets its own dataset |
+8 -7
View File
@@ -1,9 +1,10 @@
# HealthLog UI Guidelines
> Single source of truth for visual & interaction patterns in the HealthLog
> PWA. Consult this document before adding a new component, page, or
> setting. If a pattern below doesn't fit your case, propose an addition
> here in the same PR.
> Visual & interaction patterns in the HealthLog PWA. The binding rules
> for card anatomy, spacing, text colour, tokens and primitives live in
> [`contributing/ui-standards.md`](./contributing/ui-standards.md); where
> this document disagrees with them, the standards win. If a pattern
> below doesn't fit your case, propose an addition in the same PR.
Last updated: 2026-05-08 (v1.4 cycle)
@@ -132,7 +133,8 @@ helper text explaining why. Silent disabled buttons frustrate users.
### 2.2 Inputs and forms
We use `react-hook-form` + Zod schemas from `src/lib/validations/`.
Forms keep a controlled draft in component state and validate it with the
Zod schemas from `src/lib/validations/`; the server's schema is the authority.
Wrap inputs in `<label>` (visible) plus `aria-describedby` for helper text.
- **Text / number / email**: `<Input>`. Always set `autocomplete`. See §4.4.
@@ -425,8 +427,7 @@ control. Behavior:
### 4.6 Server actions and mutations
We use `react-hook-form` + TanStack Query mutations, never raw fetch
calls in components. Every mutation:
Mutations go through TanStack Query, never raw fetch calls in components. Every mutation:
1. Optimistically updates the cache with a rollback handler.
2. Toast on success ("Saved" — translated) and on error (with retry).
+1 -1
View File
@@ -3,7 +3,7 @@ import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
/**
* v1.4.15 phase-B4 — /achievements navigation smoke.
* v1.4.15 — /achievements navigation smoke.
*
* The page itself has unit-test coverage for the locked-vs-unlocked
* card render; this spec answers the higher-level "can the user reach
+68
View File
@@ -0,0 +1,68 @@
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
/**
* The activity calendar on the workouts page (D1036), at phone width: the
* card renders a whole-week grid, pages back a period, switches to the week
* view, and the goals sheet saves a goal the calendar then reports.
*/
test.describe("activity calendar", () => {
test.use({
storageState: STORAGE_STATE_PATH,
viewport: { width: 390, height: 844 },
});
test("draws the month, pages back and switches to a week", async ({
page,
}) => {
await page.goto("/insights/workouts");
const card = page.locator('[data-slot="activity-calendar"]');
await expect(card).toBeVisible();
await expect(
page.locator('[data-slot="activity-calendar-label"]'),
).not.toHaveText("");
const days = page.locator('[data-slot="activity-calendar-day"]');
const monthCount = await days.count();
if (monthCount > 0) expect(monthCount % 7).toBe(0);
const label = page.locator('[data-slot="activity-calendar-label"]');
const before = await label.textContent();
await page.locator('[data-slot="activity-calendar-previous"]').click();
await expect(label).not.toHaveText(before ?? "");
await expect(card).toHaveAttribute("data-period", "month");
await page.locator('[data-slot="activity-calendar-period-week"]').click();
await expect(card).toHaveAttribute("data-period", "week");
if ((await days.count()) > 0) await expect(days).toHaveCount(7);
// Nothing in the card pushes the page sideways on a phone.
const overflow = await page.evaluate(
() =>
document.documentElement.scrollWidth -
document.documentElement.clientWidth,
);
expect(overflow).toBeLessThanOrEqual(0);
});
test("saves a goal and shows its progress", async ({ page }) => {
await page.goto("/insights/workouts");
await page.locator('[data-slot="activity-calendar-set-goals"]').click();
const field = page.locator(
'[data-slot="activity-goals-form"] [data-goal-key="stepsPerMonth"]',
);
const goal = page.locator(
'[data-slot="activity-calendar-goal"][data-goal-key="stepsPerMonth"]',
);
await field.fill("150000");
await page.locator('[data-slot="activity-goals-save"]').click();
await expect(goal).toBeVisible();
// Clear it again so the account is left as it was found.
await page.locator('[data-slot="activity-calendar-set-goals"]').click();
await field.fill("");
await page.locator('[data-slot="activity-goals-save"]').click();
await expect(goal).toHaveCount(0);
});
});
+3 -3
View File
@@ -3,7 +3,7 @@ import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
/**
* v1.4.16 A3 — `/admin/api-tokens` must not produce horizontal page
* v1.4.16 — `/admin/api-tokens` must not produce horizontal page
* overflow on a Pixel-5 viewport (393 CSS px). v1.4.15 added column-
* hide breakpoints + tighter card padding, but the maintainer still saw a
* scrollbar on prod. The v1.4.16 fix swaps the desktop `<table>` for a
@@ -75,7 +75,7 @@ test.describe("/admin/api-tokens mobile no-overflow", () => {
).toBeLessThanOrEqual(dims.innerWidth + 1);
});
// v1.4.18 phase A2 — production probe at Pixel-5 viewport pinned the
// v1.4.18 — production probe at Pixel-5 viewport pinned the
// visible "scrollbar" the maintainer reported for the third time as the
// `<AdminShell>` mobile section strip, NOT the api-tokens table. The
// strip carries 13 admin sections so its scrollWidth (~1700 CSS px)
@@ -119,7 +119,7 @@ test.describe("/admin/api-tokens mobile no-overflow", () => {
expect(stripDims.scrollWidth).toBeGreaterThan(stripDims.clientWidth);
});
// v1.4.19 phase A7 (4th attempt) — even with the mobile-strip
// v1.4.19 (4th attempt) — even with the mobile-strip
// `no-scrollbar` fix from v1.4.18, the maintainer still reported a painted
// scrollbar. The card body itself must not contribute any
// horizontal-overflow culprit: every element inside the api-tokens
+1 -1
View File
@@ -3,7 +3,7 @@ import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
/**
* v1.4.16 phase B2 — AI provider settings UX with single dropdown
* v1.4.16 — AI provider settings UX with single dropdown
* driving the form below. Three things to lock in here:
*
* 1. /settings/ai exposes the active-provider Pulldown
+536
View File
@@ -0,0 +1,536 @@
import type { Locator, Page } from "@playwright/test";
import { mockDay } from "./setup/day-mock";
import { expect, test } from "./setup/test";
import { MOBILE_ROUTES_STORAGE_STATE_PATH } from "./setup/global-setup";
/**
* The bottom sheet's drag on WebKit with an iPhone profile (the
* `webkit-iphone` project), against the three kinds of bottom sheet the app
* has: the More sheet (a plain `<Sheet side="bottom">`), the day sheet (the
* preview step) and a capture form (`ResponsiveSheet` on a phone).
*
* Playwright can tap on WebKit but not move a touch, so a drag is dispatched
* through the page as the browser would deliver it: `pointerdown` /
* `pointermove` / `pointerup` with `pointerType: "touch"` and the matching
* `touchstart` / `touchmove` / `touchend`, on the element under the finger,
* paced in real time so the release speed is real. Nothing calls into the
* sheet's own code. What synthetic events cannot do is scroll natively, so
* "content scrolls first" is proven as "a scrolled body does not move the
* sheet", with the scroll position set the way a scroll would leave it.
*
* Stable hooks only: `bottom-nav-more` / `bottom-nav-more-sheet`,
* `day-panel` with `data-sheet-snap`, `day-body`, `day-footer`,
* `sheet-grip`, `sheet-close-button`, `sheet-overlay`, `capture-picker-*`.
*/
interface Drag {
dx?: number;
dy: number;
/** Duration of the movement, ms. */
ms: number;
steps?: number;
/** Rest before lifting the finger, ms (a slow, deliberate release). */
holdMs?: number;
/**
* After the rest, keep the finger down until the page has rendered this
* many frames. The sheet writes its position once per animation frame, and
* WebKit in the CI container can go a second or more without rendering a
* frame while timers and events run on (the trace shows the More sheet
* still sliding in 1.5 s after its animation reported finished). A check
* on where the sheet was drawn needs those frames; a check on where it
* ends up does not, because the release is decided from the touch alone.
*/
holdFrames?: number;
}
/** A touch drag from (x, y), dispatched as WebKit delivers one. */
async function touchDrag(page: Page, x: number, y: number, drag: Drag) {
await page.evaluate(
async ({ x, y, dx, dy, ms, steps, holdMs, holdFrames }) => {
const target = document.elementFromPoint(x, y);
if (!target) throw new Error(`nothing under ${x},${y}`);
const id = 11;
const touchAt = (cx: number, cy: number) => {
const init = {
identifier: id,
target,
clientX: cx,
clientY: cy,
pageX: cx,
pageY: cy,
screenX: cx,
screenY: cy,
radiusX: 4,
radiusY: 4,
force: 1,
};
try {
return new Touch(init);
} catch {
return init as unknown as Touch;
}
};
const touchEvent = (type: string, cx: number, cy: number) => {
const touch = touchAt(cx, cy);
const touches = type === "touchend" ? [] : [touch];
try {
return new TouchEvent(type, {
touches,
targetTouches: touches,
changedTouches: [touch],
bubbles: true,
cancelable: true,
composed: true,
});
} catch {
const event = new Event(type, {
bubbles: true,
cancelable: true,
composed: true,
});
Object.defineProperty(event, "touches", { value: touches });
Object.defineProperty(event, "targetTouches", { value: touches });
Object.defineProperty(event, "changedTouches", { value: [touch] });
return event;
}
};
const fire = (
pointer: "pointerdown" | "pointermove" | "pointerup",
cx: number,
cy: number,
) => {
target.dispatchEvent(
new PointerEvent(pointer, {
pointerId: id,
pointerType: "touch",
isPrimary: true,
clientX: cx,
clientY: cy,
buttons: pointer === "pointerup" ? 0 : 1,
bubbles: true,
cancelable: true,
composed: true,
}),
);
const touch = {
pointerdown: "touchstart",
pointermove: "touchmove",
pointerup: "touchend",
}[pointer];
target.dispatchEvent(touchEvent(touch, cx, cy));
};
const wait = (t: number) => new Promise((r) => setTimeout(r, t));
fire("pointerdown", x, y);
for (let i = 1; i <= steps; i += 1) {
await wait(ms / steps);
fire("pointermove", x + (dx * i) / steps, y + (dy * i) / steps);
}
if (holdMs) await wait(holdMs);
for (let i = 0; i < holdFrames; i += 1) {
await new Promise((r) => requestAnimationFrame(() => r(null)));
}
fire("pointerup", x + dx, y + dy);
},
{
x,
y,
dx: drag.dx ?? 0,
dy: drag.dy,
ms: drag.ms,
steps: drag.steps ?? Math.max(4, Math.round(drag.ms / 16)),
holdMs: drag.holdMs ?? 0,
holdFrames: drag.holdFrames ?? 0,
},
);
}
/**
* The browser's pointer capture for a dispatched pointer. A real touch is
* captured by the browser; a dispatched one is not, and Radix's slider only
* follows a pointer it holds. This gives it the same bookkeeping, nothing
* more: events still go to the element they are dispatched on.
*/
async function emulateCapture(page: Page) {
await page.addInitScript(() => {
const held = new WeakMap<Element, Set<number>>();
Element.prototype.setPointerCapture = function (id: number) {
const set = held.get(this) ?? new Set<number>();
set.add(id);
held.set(this, set);
};
Element.prototype.hasPointerCapture = function (id: number) {
return held.get(this)?.has(id) ?? false;
};
Element.prototype.releasePointerCapture = function (id: number) {
held.get(this)?.delete(id);
};
});
}
async function centre(locator: Locator) {
const box = await locator.boundingBox();
if (!box) throw new Error("not on screen");
return { x: box.x + box.width / 2, y: box.y + box.height / 2, box };
}
async function topOf(locator: Locator) {
return (await locator.boundingBox())?.y ?? NaN;
}
/** Waits for the sheet to come to rest (open slide or settle finished). */
async function settled(locator: Locator) {
await expect
.poll(() =>
locator.evaluate((el) =>
el.getAnimations().every((a) => a.playState !== "running"),
),
)
.toBe(true);
// A finished animation is not a drawn one: WebKit can report the slide
// done while it has not rendered a frame of it yet. Two frames, then rest.
await locator
.page()
.evaluate(
() =>
new Promise<void>((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
),
);
await locator.page().waitForTimeout(300);
}
const more = (page: Page) =>
page.locator('[data-testid="bottom-nav-more-sheet"]');
async function openMore(page: Page) {
await page.locator('[data-testid="bottom-nav-more"]').tap();
await expect(more(page)).toBeVisible();
await settled(more(page));
}
function isoDaysAgo(days: number): string {
const d = new Date(Date.now() - days * 86_400_000);
return d.toISOString().slice(0, 10);
}
test.describe("the bottom sheet", () => {
test.use({ storageState: MOBILE_ROUTES_STORAGE_STATE_PATH });
test.beforeEach(async ({ page, context }, testInfo) => {
const baseURL = testInfo.project.use.baseURL ?? "http://localhost:3000";
await context.addCookies([
{ name: "healthlog-locale", value: "en", url: baseURL },
]);
await mockDay(page);
});
test("a slow drag past a quarter of its height closes it", async ({
page,
}) => {
await page.goto("/mood");
await openMore(page);
const title = more(page).getByRole("heading").first();
const { x, y } = await centre(title);
const { box } = await centre(more(page));
await touchDrag(page, x, y, {
dy: box.height * 0.4,
ms: 700,
holdMs: 160,
});
await expect(more(page)).toHaveCount(0);
});
test("a short drag springs back", async ({ page }) => {
await page.goto("/mood");
await openMore(page);
await expect(more(page)).toBeVisible();
const before = await topOf(more(page));
const { x, y } = await centre(more(page).getByRole("heading").first());
// Dragging: the sheet follows the finger and the backdrop lightens. A
// frame loop records the furthest the sheet travelled and the lightest
// the backdrop got for the whole gesture, so a slow runner cannot make
// a single fixed-time sample land before the drag has begun.
await page.evaluate(() => {
const w = window as unknown as {
__sheetDrag?: { moved: number; opacity: number; on: boolean };
};
w.__sheetDrag = { moved: 0, opacity: 1, on: true };
const tick = () => {
const rec = w.__sheetDrag!;
if (!rec.on) return;
const sheet = document.querySelector(
'[data-testid="bottom-nav-more-sheet"]',
) as HTMLElement | null;
const overlay = document.querySelector(
'[data-slot="sheet-overlay"]',
) as HTMLElement | null;
if (sheet) {
const moved = new DOMMatrix(getComputedStyle(sheet).transform).f;
rec.moved = Math.max(rec.moved, moved);
}
if (overlay) {
rec.opacity = Math.min(
rec.opacity,
Number(getComputedStyle(overlay).opacity),
);
}
requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
});
// Two rendered frames with the finger still down: the first paints the
// sheet where the finger is, the second lets the recorder read it.
await touchDrag(page, x, y, {
dy: 60,
ms: 300,
holdMs: 200,
holdFrames: 2,
});
const seen = await page.evaluate(() => {
const w = window as unknown as {
__sheetDrag: { moved: number; opacity: number; on: boolean };
};
w.__sheetDrag.on = false;
return { moved: w.__sheetDrag.moved, opacity: w.__sheetDrag.opacity };
});
expect(seen.moved).toBeGreaterThan(40);
expect(seen.opacity).toBeLessThan(1);
await settled(more(page));
await expect(more(page)).toBeVisible();
expect(Math.abs((await topOf(more(page))) - before)).toBeLessThan(1);
});
test("a fast flick closes it", async ({ page }) => {
await page.goto("/mood");
await openMore(page);
const { x, y } = await centre(more(page).getByRole("heading").first());
const { box } = await centre(more(page));
// Well short of a quarter of the height, but fast.
await touchDrag(page, x, y, {
dy: Math.min(70, box.height * 0.2),
ms: 40,
steps: 4,
});
await expect(more(page)).toHaveCount(0);
});
test("the backdrop, the X and Escape close it; focus goes back to the trigger", async ({
page,
}) => {
await page.goto("/mood");
const trigger = page.locator('[data-testid="bottom-nav-more"]');
await openMore(page);
await page.touchscreen.tap(195, 40);
await expect(more(page)).toHaveCount(0);
await openMore(page);
const close = more(page).locator('[data-slot="sheet-close-button"]');
const closeBox = (await close.boundingBox())!;
expect(closeBox.width).toBeGreaterThanOrEqual(44);
expect(closeBox.height).toBeGreaterThanOrEqual(44);
await close.tap();
await expect(more(page)).toHaveCount(0);
// The keyboard path: open from the focused trigger, close with Escape.
await trigger.focus();
await page.keyboard.press("Enter");
await expect(more(page)).toBeVisible();
await expect(more(page)).toHaveAttribute("role", "dialog");
await expect(more(page)).toHaveAttribute("aria-modal", "true");
await expect(more(page)).toHaveAccessibleName(/more/i);
await expect
.poll(() =>
more(page).evaluate((el) => el.contains(document.activeElement)),
)
.toBe(true);
// Tab stays inside.
for (let i = 0; i < 14; i += 1) await page.keyboard.press("Tab");
expect(
await more(page).evaluate((el) => el.contains(document.activeElement)),
).toBe(true);
await page.keyboard.press("Escape");
await expect(more(page)).toHaveCount(0);
await expect(trigger).toBeFocused();
});
test("the page behind keeps its scroll position and is never pulled to refresh", async ({
page,
}) => {
await page.goto("/");
const main = page.locator("#main-content");
await expect(main).toBeVisible();
await page.waitForTimeout(800);
// Watch for the refresh disc for the rest of the test.
await page.evaluate(() => {
(window as unknown as { ptrSeen: number }).ptrSeen = 0;
new MutationObserver(() => {
if (document.querySelector('[data-slot="pull-to-refresh-indicator"]')) {
(window as unknown as { ptrSeen: number }).ptrSeen += 1;
}
}).observe(document.body, { childList: true, subtree: true });
});
const ptrSeen = () =>
page.evaluate(() => (window as unknown as { ptrSeen: number }).ptrSeen);
// The control: the same pull on the page itself does arm the disc, so
// the watch above can see it (short of the refresh threshold).
await touchDrag(page, 195, 320, { dy: 90, ms: 400, holdMs: 120 });
await expect.poll(ptrSeen).toBeGreaterThan(0);
await page.waitForTimeout(300);
await page.evaluate(() => {
(window as unknown as { ptrSeen: number }).ptrSeen = 0;
});
// A pull on the sheet, short (it springs back) and long (it closes),
// from the dashboard at the top: the page behind never takes it.
await openMore(page);
const { x, y } = await centre(more(page).getByRole("heading").first());
await touchDrag(page, x, y, { dy: 60, ms: 400, holdMs: 150 });
await settled(more(page));
const { box } = await centre(more(page));
await touchDrag(page, x, y, { dy: box.height * 0.5, ms: 600, holdMs: 150 });
await expect(more(page)).toHaveCount(0);
expect(await ptrSeen()).toBe(0);
// Scrolled down, the page is where it was after the sheet has gone.
await main.evaluate((el) => el.scrollTo({ top: 400 }));
const before = await main.evaluate((el) => el.scrollTop);
expect(before).toBeGreaterThan(0);
await openMore(page);
const at = await centre(more(page).getByRole("heading").first());
const sheetBox = (await more(page).boundingBox())!;
await touchDrag(page, at.x, at.y, {
dy: sheetBox.height * 0.5,
ms: 600,
holdMs: 150,
});
await expect(more(page)).toHaveCount(0);
expect(await main.evaluate((el) => el.scrollTop)).toBe(before);
expect(await ptrSeen()).toBe(0);
});
test("the day opens on its preview step, rises on a pull up, and its content scrolls before it drags", async ({
page,
}) => {
// A short window, so the day's content is longer than its sheet on
// both steps and the body really scrolls.
await page.setViewportSize({ width: 390, height: 480 });
await page.goto(`/mood?day=${isoDaysAgo(3)}`);
const panel = page.locator('[data-slot="day-panel"]');
await expect(panel).toBeVisible();
await expect(panel).toHaveAttribute("data-shell", "bottom");
await expect(panel).toHaveAttribute("data-sheet-snap", "preview");
await settled(panel);
const viewport = page.viewportSize()!;
// On the preview step the footer stands on the window's bottom edge.
const footer = panel.locator('[data-slot="day-footer"]');
const footerBox = (await footer.boundingBox())!;
expect(
Math.abs(footerBox.y + footerBox.height - viewport.height),
).toBeLessThan(2);
// A pull up on the content raises it to the full 90 dvh.
const body = panel.locator('[data-slot="day-body"]');
const b = await centre(body);
await touchDrag(page, b.x, b.y, { dy: -180, ms: 500, holdMs: 150 });
await expect(panel).toHaveAttribute("data-sheet-snap", "full");
await settled(panel);
expect(await topOf(panel)).toBeCloseTo(viewport.height * 0.1, -1);
// Scrolled content: a pull down scrolls (the browser's), the sheet stays.
const scrollable = await body.evaluate(
(el) => el.scrollHeight > el.clientHeight,
);
expect(scrollable).toBe(true);
await body.evaluate((el) => el.scrollTo({ top: 60 }));
const top = await topOf(panel);
const inner = await centre(body);
await touchDrag(page, inner.x, inner.y, {
dy: 260,
ms: 500,
holdMs: 150,
});
await settled(panel);
await expect(panel).toHaveAttribute("data-sheet-snap", "full");
expect(Math.abs((await topOf(panel)) - top)).toBeLessThan(1);
// At its very top the same pull drags the sheet: down to the preview
// step, and from there out.
await body.evaluate((el) => el.scrollTo({ top: 0 }));
await touchDrag(page, inner.x, inner.y - 60, {
dy: 150,
ms: 500,
holdMs: 150,
});
await expect(panel).toHaveAttribute("data-sheet-snap", "preview");
await settled(panel);
const grip = panel.locator('[data-slot="sheet-grip"]');
const g = await centre(grip);
await touchDrag(page, g.x, g.y, { dy: 300, ms: 600, holdMs: 150 });
await expect(panel).toHaveCount(0);
await expect(page).toHaveURL(/\/mood$/);
});
test("the grip is a button that switches the day's two heights", async ({
page,
}) => {
await page.goto(`/mood?day=${isoDaysAgo(3)}`);
const panel = page.locator('[data-slot="day-panel"]');
await expect(panel).toHaveAttribute("data-sheet-snap", "preview");
const grip = panel.locator('[data-slot="sheet-grip"]');
await expect(grip).toHaveAttribute("aria-expanded", "false");
await grip.tap();
await expect(panel).toHaveAttribute("data-sheet-snap", "full");
await expect(grip).toHaveAttribute("aria-expanded", "true");
});
test("a slider inside a sheet moves sideways and the sheet stays", async ({
page,
}) => {
await emulateCapture(page);
await page.goto("/mood");
await page.locator('[data-testid="bottom-nav-capture"]').tap();
await page.locator('[data-testid="capture-picker-symptom"]').tap();
const form = page.locator(
'[data-slot="responsive-sheet-content"][data-state="open"]',
);
await expect(form).toBeVisible();
await settled(form);
const slider = form.locator('[data-slot="slider"]').first();
await slider.scrollIntoViewIfNeeded();
await settled(form);
const top = await topOf(form);
const s = await centre(slider);
const thumb = form.locator('[role="slider"]').first();
const valueOf = async () =>
(await thumb.count()) ? thumb.getAttribute("aria-valuenow") : null;
const before = await valueOf();
// A sideways drag with a little vertical wobble, across the track.
await touchDrag(page, s.box.x + 8, s.y, {
dx: s.box.width * 0.7,
dy: 6,
ms: 400,
});
await expect.poll(valueOf).not.toBe(before);
await settled(form);
await expect(form).toBeVisible();
expect(Math.abs((await topOf(form)) - top)).toBeLessThan(1);
});
test("under reduced motion it opens without a slide", async ({ page }) => {
await page.emulateMedia({ reducedMotion: "reduce" });
await page.goto("/mood");
await page.locator('[data-testid="bottom-nav-more"]').tap();
await expect(more(page)).toBeVisible();
expect(
await more(page).evaluate(
(el) =>
el.getAnimations().filter((a) => a.playState === "running").length,
),
).toBe(0);
});
});
+1 -1
View File
@@ -4,7 +4,7 @@ import { STORAGE_STATE_PATH } from "./setup/global-setup";
import { mockDashboardSnapshot } from "./utils/mock-dashboard-snapshot";
/**
* v1.4.19 A2 — mobile-viewport regression spec for chart cards.
* v1.4.19 — mobile-viewport regression spec for chart cards.
*
* Two assertions per chart visible in the dashboard's initial paint:
*
+2 -2
View File
@@ -98,7 +98,7 @@ test.describe("authenticated dashboard render", () => {
}),
);
// S2 — the Today hero reads the unified daily digest from
// The Today hero reads the unified daily digest from
// `/api/daily/digest`. Mock it with a deterministic digest (a score +
// one worth-a-look item) so the hero paints without reaching the real
// route, and so a slow/absent digest can never hang the render or
@@ -276,7 +276,7 @@ test.describe("authenticated dashboard render", () => {
const tileCount = Number(await strip.getAttribute("data-tile-count"));
expect(tileCount).toBeGreaterThan(0);
// S2 — the Today hero paints above the tile strip from the mocked
// The Today hero paints above the tile strip from the mocked
// digest: its score ring and its worth-a-look rail render.
const todayHero = page.locator('[data-slot="today-hero"]');
await expect(todayHero).toBeVisible({ timeout: 10_000 });
+6 -2
View File
@@ -49,8 +49,12 @@ async function openFromPlot(page: Page, plot: Locator, testInfo: TestInfo) {
await expect(open).toBeVisible();
// A tap alone never opens a sheet: a point is a small target.
await expect(panel(page)).toHaveCount(0);
// The button is a calm, full 44 px row.
expect((await open.boundingBox())!.height).toBeGreaterThanOrEqual(44);
// The button is a calm, full 44 px row. The tooltip re-renders while the
// tap settles, so the measurement retries instead of reading a box from
// a node that has just been replaced.
await expect
.poll(async () => (await open.boundingBox())?.height ?? 0)
.toBeGreaterThanOrEqual(44);
await open.tap();
await expect(panel(page)).toHaveAttribute("data-shell", "bottom");
} else {
+1 -1
View File
@@ -3,7 +3,7 @@ import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
/**
* v1.4.28 R3a FB-D3 — `/insights` scroll restoration + tab-strip
* v1.4.28 — `/insights` scroll restoration + tab-strip
* gesture handling.
*
* Two regressions the maintainer hit walking through v1.4.27:
+1 -1
View File
@@ -132,7 +132,7 @@ test.describe("add measurement flow", () => {
await openMenu(page, addTrigger);
// v1.5 phase-5: the menu items now have distinct labels — the
// v1.5: the menu items now have distinct labels — the
// measurement entry says "Measurement" / "Messung" instead of "Add",
// so we can target it directly.
// The desktop rail opens a dropdown, the phone shell the capture sheet
+114 -22
View File
@@ -52,7 +52,10 @@ import {
* floor advanced on every accept (`src/lib/auth/mfa/verify-factor.ts`), so
* a code already spent is refused even inside its own 30-second life;
* - the sensitive action once the step-up stamp has aged out — 401 with
* `meta.errorCode = "auth.stepup.required"`, and the card's prompt;
* `meta.errorCode = "auth.stepup.required"` naming the proofs it would
* take, and the re-proof dialog in place of the rotation. Cancelling it
* sends nothing more and changes nothing; proving the factor there runs
* the rotation once more, and only then does it go through;
* - a recovery code presented a second time — 401. The matched row is burned
* on first use;
* - one login past the documented per-IP cap — 429, with the bucket's own
@@ -125,6 +128,20 @@ async function alignToStepBoundary(page: Page): Promise<void> {
await page.waitForTimeout(nextBoundary - now);
}
function stepOf(atMs: number): number {
return Math.floor(atMs / (TOTP_PERIOD_SECONDS * 1000));
}
/**
* Park until the TOTP step after `step` has begun, so a code minted for now is
* above the replay floor that step's code left. Bounded by one period.
*/
async function alignPastStep(page: Page, step: number): Promise<void> {
const nextBoundary = (step + 1) * TOTP_PERIOD_SECONDS * 1000;
const wait = nextBoundary - Date.now();
if (wait > 0) await page.waitForTimeout(wait + 250);
}
async function sessionCookie(context: BrowserContext) {
const cookies = await context.cookies();
return cookies.find((c) => c.name === "healthlog_session");
@@ -170,17 +187,14 @@ async function submitFactor(page: Page, code: string): Promise<Response> {
}
/**
* Open the regeneration dialog, confirm it, and return the route's answer.
* Open the regeneration dialog and confirm it, without waiting for the end.
*
* The confirming click calls `preventDefault()` so the dialog survives the
* request in flight; the mutation takes it down again when the request
* settles, whatever the answer was. That matters on a REFUSAL, where the card
* behind the dialog is the only thing that says why — on a success the
* fresh-codes panel replaces the whole branch the dialog lives in and it would
* unmount either way. So the dialog being gone here is a product assertion,
* not housekeeping: nothing in this helper dismisses it.
* request in flight, and it stays up for as long as the rotation is pending —
* which, when the step-up has aged out, includes the re-proof dialog that opens
* above it. Returns the route's first answer.
*/
async function regenerateRecoveryCodes(page: Page): Promise<Response> {
async function confirmRegeneration(page: Page): Promise<Response> {
await page.getByTestId("recovery-regenerate").click();
const answer = page.waitForResponse(
(res) =>
@@ -188,11 +202,37 @@ async function regenerateRecoveryCodes(page: Page): Promise<Response> {
res.request().method() === "POST",
);
await page.getByTestId("recovery-regenerate-confirm").click();
const settled = await answer;
return answer;
}
/**
* Open the regeneration dialog, confirm it, and return the route's answer.
*
* The mutation takes the dialog down again when the request settles; on a
* success the fresh-codes panel replaces the whole branch the dialog lives in.
* So the dialog being gone here is a product assertion, not housekeeping:
* nothing in this helper dismisses it.
*/
async function regenerateRecoveryCodes(page: Page): Promise<Response> {
const settled = await confirmRegeneration(page);
await expect(page.getByTestId("recovery-regenerate-dialog")).toBeHidden();
return settled;
}
/** The first refusal of an aged-out rotation: what the server said. */
async function expectStepUpRefusal(answer: Response): Promise<void> {
expect(answer.status()).toBe(401);
const body = (await answer.json()) as {
data: null;
meta?: { errorCode?: string; methods?: string[] };
};
expect(body.data).toBeNull();
expect(body.meta?.errorCode).toBe("auth.stepup.required");
// The proofs that clear it. A password never stands in for the second
// factor, so it is not offered.
expect(body.meta?.methods).toEqual(["totp"]);
}
/** The codes currently on screen, gated on the panel being painted. */
async function visibleRecoveryCodes(page: Page): Promise<string[]> {
const panel = page.getByTestId("recovery-codes");
@@ -259,6 +299,7 @@ test.describe("second factor", () => {
let secret = "";
let spentCode = "";
let liveRecoveryCodes: string[] = [];
let enrolmentStep = 0;
await test.step("signs in with a password alone while no factor exists", async () => {
const answer = await submitPassword(page, account);
@@ -292,9 +333,11 @@ test.describe("second factor", () => {
// up enrolled against different secrets.
expect(setupBody.data.otpauthUri).toContain(secret);
const enrolledAt = Date.now();
enrolmentStep = stepOf(enrolledAt);
await page
.getByTestId("totp-confirm-code")
.fill(totpCodeAt(secret, Date.now()));
.fill(totpCodeAt(secret, enrolledAt));
const confirm = page.waitForResponse(
(res) =>
res.url().includes("/api/auth/me/mfa/totp/confirm") &&
@@ -320,20 +363,69 @@ test.describe("second factor", () => {
liveRecoveryCodes = rotated;
});
await test.step("refuses the same rotation once the step-up has aged out", async () => {
await test.step("asks for the factor again once the step-up has aged out", async () => {
await page.getByTestId("recovery-codes-dismiss").click();
await expireStepUp(account);
const answer = await regenerateRecoveryCodes(page);
expect(answer.status()).toBe(401);
const body = (await answer.json()) as {
data: null;
meta?: { errorCode?: string };
};
expect(body.data).toBeNull();
expect(body.meta?.errorCode).toBe("auth.stepup.required");
// And the card says so, on a card the closed dialog no longer covers.
await expect(page.getByTestId("totp-error")).toBeVisible();
const regenerateDialog = page.getByTestId("recovery-regenerate-dialog");
const reproofDialog = page.getByTestId("factor-reauth-dialog");
const regenerateCalls: string[] = [];
page.on("request", (req) => {
if (req.url().includes(REGENERATE_ENDPOINT))
regenerateCalls.push(req.method());
});
// The refusal opens the re-proof dialog above the pending confirmation,
// and the dialog holds the focus.
await expectStepUpRefusal(await confirmRegeneration(page));
await expect(reproofDialog).toBeVisible();
await expect
.poll(() =>
reproofDialog.evaluate((el) => el.contains(document.activeElement)),
)
.toBe(true);
await expect(page.getByTestId("totp-error")).toBeHidden();
// Cancelled: nothing more is sent, both dialogs go, no sentence and no
// fresh batch on the card. The live codes are still the live codes.
await reproofDialog.getByRole("button", { name: /cancel/i }).click();
await expect(reproofDialog).toBeHidden();
await expect(regenerateDialog).toBeHidden();
await expect(page.getByTestId("totp-error")).toBeHidden();
await expect(page.getByTestId("recovery-codes")).toBeHidden();
expect(regenerateCalls).toEqual(["POST"]);
// Proved: the rotation runs once more and goes through.
await expectStepUpRefusal(await confirmRegeneration(page));
await expect(reproofDialog).toBeVisible();
// The code the enrolment spent is below the replay floor; wait for the
// step after it when it is still current. Bounded by one period.
await alignPastStep(page, enrolmentStep);
await reproofDialog
.locator('input[autocomplete="one-time-code"]')
.fill(totpCodeAt(secret, Date.now()));
const proof = page.waitForResponse(
(res) =>
res.url().includes("/api/auth/reproof") &&
res.request().method() === "POST",
);
const retried = page.waitForResponse(
(res) =>
res.url().includes(REGENERATE_ENDPOINT) &&
res.request().method() === "POST" &&
res.status() === 200,
);
await page.getByTestId("factor-reauth-submit").click();
expect((await proof).status()).toBe(200);
expect((await retried).status()).toBe(200);
await expect(reproofDialog).toBeHidden();
await expect(regenerateDialog).toBeHidden();
expect(regenerateCalls).toEqual(["POST", "POST", "POST"]);
const rotated = await visibleRecoveryCodes(page);
expect(rotated).not.toEqual(liveRecoveryCodes);
await expect(page.getByTestId("totp-error")).toBeHidden();
liveRecoveryCodes = rotated;
});
await test.step("asks for the second factor on the next sign-in", async () => {
+7 -2
View File
@@ -4,6 +4,7 @@ import { join, relative, sep } from "node:path";
import type { APIRequestContext } from "@playwright/test";
import { ADMIN_SECTION_SLUGS } from "@/components/admin/section-slugs";
import { MODULE_SETTINGS_PAGE_IDS } from "@/components/settings/module-settings-page-ids";
import { SETTINGS_SECTION_SLUGS } from "@/components/settings/section-slugs";
import { expect, test } from "./setup/test";
@@ -80,17 +81,21 @@ const DYNAMIC_ROUTES: Record<string, Resolver> = {
ADMIN_SECTION_SLUGS.map((slug) => `/admin/${slug}`),
// `LAYOUT_GROUP_IDS` lives beside React components the test runner cannot
// load; `settings/layout/[module]` 404s an unknown group, so a stale entry
// here fails on the status check below rather than passing quietly.
// here fails on the status check below rather than passing quietly. Mood
// has no arrangement page since v1.44: its old address redirects to
// `/settings/modules/mood`, which the entry below sweeps.
"/settings/layout/[module]": async () =>
[
"dashboard",
"insights",
"medications",
"mood",
"labs",
"illness",
"documents",
"vorsorge",
].map((group) => `/settings/layout/${group}`),
"/settings/modules/[module]": async () =>
MODULE_SETTINGS_PAGE_IDS.map((module) => `/settings/modules/${module}`),
"/insights/scores/[metric]": async () =>
["sleep", "readiness", "recovery", "stress", "strain"].map(
(metric) => `/insights/scores/${metric}`,
+2 -2
View File
@@ -41,7 +41,7 @@ test.describe("mobile-viewport smoke", () => {
// mocks below stay for the reversible `=false` path.
await mockDashboardSnapshot(page, { summaries: WEIGHT_ONLY_SUMMARIES });
// v1.4.37 W-CI — match `/api/analytics` AND any sliced variant
// v1.4.37 — match `/api/analytics` AND any sliced variant
// (`?slice=summaries`). The previous string glob `**/api/analytics`
// didn't match the query-string form so the IW1 slim-slice fetch
// landed on the unmocked real route in CI.
@@ -149,7 +149,7 @@ test.describe("mobile-viewport smoke", () => {
// scroll to interact, which is its own UX problem the design
// review handles.
if (box.y < 0 || box.y > viewportH) continue;
// v1.4.37 W-CI — enforce the WCAG 2.5.5 height floor only.
// v1.4.37 — enforce the WCAG 2.5.5 height floor only.
// The original `width < 44 || height < 44` check flagged
// grouped compact controls (chart-range tabs like "7T"/"30T"
// /"90T" sitting at 44 px tall but ~30-40 px wide) as
+1 -1
View File
@@ -3,7 +3,7 @@ import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
/**
* v1.4.15 phase-A3 fix #2 — mood-list mobile card was rendering the score
* v1.4.15 #2 — mood-list mobile card was rendering the score
* twice: once in the left badge (large bold digit) and once in the title
* line as "{score} ({label})". On desktop the table view only ever showed
* one. The fix collapses the title line down to just the localized label
+3 -3
View File
@@ -7,7 +7,7 @@ import {
} from "./utils/mock-dashboard-snapshot";
/**
* v1.4.15 phase-A3 fix #3 — onboarding card flicker on dashboard load.
* v1.4.15 #3 — onboarding card flicker on dashboard load.
*
* The previous version of `<GettingStartedChecklist>` rendered against a
* default-true `shouldShowChecklist` decision while `measurementCount`
@@ -64,7 +64,7 @@ test.describe("dashboard onboarding card flicker guard", () => {
// works by NOT rendering until this resolves, so even with the
// delay the card stays invisible the whole time.
//
// v1.4.37 W-CI — match `/api/analytics` AND any sliced variant
// v1.4.37 — match `/api/analytics` AND any sliced variant
// (`?slice=summaries`). The previous string glob `**/api/analytics`
// missed the IW1 slim-slice URL so the checklist hook's analytics
// query fell through to the real route in CI; on a fresh seed user
@@ -161,7 +161,7 @@ test.describe("dashboard onboarding card flicker guard", () => {
// card mounts collapsed: header + progress meter visible, full row
// list hidden until the user clicks the chevron.
//
// v1.4.22 C4 — the onboarding redirect now lives in `proxy.ts`,
// v1.4.22 — the onboarding redirect now lives in `proxy.ts`,
// not in `<AuthShell>`. The proxy reads the `hl_onboarding` cookie
// (which the auth routes mirror from the DB). Our storageState
// belongs to a fully-onboarded user (no `hl_onboarding` cookie),
+66
View File
@@ -0,0 +1,66 @@
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
/**
* Lab results from a spreadsheet (#1215), at phone width, against the real
* server: the mapping is suggested from the headers, the check reports a
* refused row by line and writes nothing, the import writes the rest, and a
* second import of the same file only finds duplicates.
*/
function csv(): string {
const day = new Date(Date.now() - 30 * 86_400_000).toISOString().slice(0, 10);
return [
"Analyte;Value;Unit;Date",
`E2E Marker CSV;5,4;mmol/L;${day}`,
`E2E Marker CSV;1.234;mmol/L;${day}`,
].join("\n");
}
test.describe("record CSV import", () => {
test.use({
storageState: STORAGE_STATE_PATH,
viewport: { width: 390, height: 844 },
});
test("checks, imports and then only finds duplicates", async ({ page }) => {
await page.goto("/settings/export");
await page.locator('[data-slot="record-import-open-labs"]').click();
const sheet = page.locator('[data-slot="record-import-sheet"]');
await expect(sheet).toBeVisible();
const file = {
name: "labs.csv",
mimeType: "text/csv",
buffer: Buffer.from(csv()),
};
await page.locator('[data-slot="record-import-file"]').setInputFiles(file);
await expect(
page.locator('[data-slot="record-import-map-analyte"]'),
).toHaveValue("0");
await expect(
page.locator('[data-slot="record-import-map-value"]'),
).toHaveValue("1");
await page.locator('[data-slot="record-import-check"]').click();
await expect(
page.locator('[data-slot="record-import-checked"]'),
).toBeVisible();
await expect(
page.locator('[data-slot="record-import-error-row"]'),
).toHaveCount(1);
await page.locator('[data-slot="record-import-run"]').click();
await expect(
page.locator('[data-slot="record-import-done"]'),
).toBeVisible();
// Second run of the same file.
await page.keyboard.press("Escape");
await page.locator('[data-slot="record-import-open-labs"]').click();
await page.locator('[data-slot="record-import-file"]').setInputFiles(file);
await page.locator('[data-slot="record-import-check"]').click();
await expect(
page.locator('[data-slot="record-import-run"]'),
).toBeDisabled();
});
});
+6 -6
View File
@@ -137,12 +137,12 @@ async function expectSharedTabs(page: Page, chart: string, pressed: string) {
await expect(tabs).toHaveCount(4);
expect(
await tabs.evaluateAll((nodes) =>
nodes.map((node) => node.getAttribute("data-range")),
nodes.map((node) => node.getAttribute("data-value")),
),
).toEqual(RANGES);
await expect(
page.locator(`${chart} [data-slot="chart-range-tab"][aria-pressed="true"]`),
).toHaveAttribute("data-range", pressed);
).toHaveAttribute("data-value", pressed);
}
test.describe("score histories", () => {
@@ -170,7 +170,7 @@ test.describe("score histories", () => {
expect(asked).toContain(30);
await chart
.locator('[data-slot="chart-range-tab"][data-range="7"]')
.locator('[data-slot="chart-range-tab"][data-value="7"]')
.click();
await expect(chart).toHaveAttribute("data-range", "7");
await expect.poll(() => asked.at(-1)).toBe(7);
@@ -185,7 +185,7 @@ test.describe("score histories", () => {
await expect(chart).toHaveAttribute("data-range", "7");
await chart
.locator('[data-slot="chart-range-tab"][data-range="0"]')
.locator('[data-slot="chart-range-tab"][data-value="0"]')
.click();
await expect(chart).toHaveAttribute("data-range", "0");
await expect.poll(() => asked.at(-1)).toBe(3650);
@@ -231,7 +231,7 @@ test.describe("score histories", () => {
await expect(chart).toHaveAttribute("data-score", "READINESS");
await expectSharedTabs(page, '[data-slot="score-trend-chart"]', "30");
await chart
.locator('[data-slot="chart-range-tab"][data-range="90"]')
.locator('[data-slot="chart-range-tab"][data-value="90"]')
.click();
await expect(chart).toHaveAttribute("data-range", "90");
await expect.poll(() => asked.at(-1)).toBe(90);
@@ -262,7 +262,7 @@ test.describe("score histories", () => {
page.locator(`${chart} [data-slot="chart-overlay-controls-trigger"]`),
).toHaveCount(0);
await page
.locator(`${chart} [data-slot="chart-range-tab"][data-range="7"]`)
.locator(`${chart} [data-slot="chart-range-tab"][data-value="7"]`)
.click();
await expectSharedTabs(page, chart, "7");
await page.reload();
+5 -2
View File
@@ -62,7 +62,8 @@ const SETTINGS_ROUTES = [
] as const;
/**
* The Layout hub's seven children. They are the routes whose header block
* The Arrangement hub's children, plus (v1.44) the module pages under
* Modules, which use the same back-link header. They are the routes whose header block
* changed MOST — the shell used to hand-roll a `space-y-6` between the
* back-link and the title, and `PageHeader`'s documented contract is
* `space-y-1.5`. Nothing measured them before, so the step went from 24 px
@@ -73,10 +74,12 @@ const LAYOUT_CHILD_ROUTES = [
"/settings/layout/dashboard",
"/settings/layout/insights",
"/settings/layout/medications",
"/settings/layout/mood",
"/settings/layout/labs",
"/settings/layout/illness",
"/settings/layout/vorsorge",
"/settings/modules/medications",
"/settings/modules/mood",
"/settings/modules/labs",
] as const;
/** The `space-y-1.5` between a back-link and the title it sits above. */
+9 -5
View File
@@ -56,7 +56,7 @@ test.describe("Settings → Export & Import", () => {
test("renders all export surfaces with stable testids", async ({ page }) => {
await page.goto("/settings/export", { waitUntil: "domcontentloaded" });
// v1.18.0 (S5) — the full health-record export moved to its own
// v1.18.0 — the full health-record export moved to its own
// `/settings/gesundheitsakte` section. The Export & Import page keeps
// the four CSV/backup tiles with the `export-card-*` shape.
for (const id of [
@@ -72,7 +72,7 @@ test.describe("Settings → Export & Import", () => {
test("renders the health-record export panel on its own section", async ({
page,
}) => {
// v1.18.0 (S5) — the health-record export is the hero of the
// v1.18.0 — the health-record export is the hero of the
// dedicated, module-gated Gesundheitsakte section.
await openGesundheitsakte(page);
await expect(page.getByTestId("health-record-export-panel")).toBeVisible({
@@ -149,8 +149,9 @@ test.describe("Settings → Export & Import", () => {
await page.getByTestId("report-apply-standard-export").click();
await expect(generate).toBeEnabled();
// Exactly the shipped set: the three classic vitals, weight and BMI, and
// nothing at all from the groups the template leaves alone.
// Exactly the shipped set: the three classic vitals, weight and BMI, the
// two heart-rate-variability rows, and nothing at all from the groups the
// template leaves alone.
await expect(
page.getByTestId("report-group-row-vitals-export"),
).toContainText("3/7");
@@ -159,7 +160,10 @@ test.describe("Settings → Export & Import", () => {
).toContainText("2/12");
await expect(
page.getByTestId("report-group-row-cardio-export"),
).toContainText("0/14");
).toContainText("2/15");
await expect(
page.getByTestId("report-group-row-mobility-export"),
).toContainText("0/11");
// The fenced tier is not part of any one-click set.
await expect(
page.getByTestId("report-scope-summary-export"),
+1 -1
View File
@@ -6,7 +6,7 @@ import {
WEIGHT_ONLY_SUMMARIES,
} from "./utils/mock-dashboard-snapshot";
/**
* v1.4.19 A6 — Settings consistency snapshot at Pixel-5 (393 CSS px).
* v1.4.19 — Settings consistency snapshot at Pixel-5 (393 CSS px).
* v1.4.27 — input height floor lifted from 36 px (`h-9`) to 40 px
* (`h-10`) on `Input`, `Select` trigger, and the native `<select>`
* primitives per the WCAG 2.5.5 tap-target sweep (MB2). The
+47
View File
@@ -0,0 +1,47 @@
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
/**
* Settings navigation at 390 px: one button names the current section and
* opens a sheet with every section under its group. It replaced a strip of
* every section as chips, where most of Settings sat off screen.
*/
test.describe("settings navigation on a phone", () => {
test.use({ storageState: STORAGE_STATE_PATH });
test("names the section, opens the grouped list and moves on a tap", async ({
page,
}) => {
await page.setViewportSize({ width: 390, height: 844 });
await page.goto("/settings/account", { waitUntil: "domcontentloaded" });
const trigger = page.locator('[data-slot="settings-mobile-nav-trigger"]');
await expect(trigger).toBeVisible({ timeout: 15_000 });
await expect(trigger).toContainText("Account");
// The click can land before hydration; retry until the sheet opens.
const list = page.locator('[data-slot="settings-mobile-nav-list"]');
await expect(async () => {
await trigger.click();
await expect(list).toBeVisible({ timeout: 2_000 });
}).toPass({ timeout: 15_000 });
for (const group of ["Account", "Tracking", "Notifications", "System"]) {
await expect(
list.getByRole("heading", { name: group, exact: true }),
).toBeVisible();
}
await list.getByRole("link", { name: "Security" }).click();
await expect(page).toHaveURL(/\/settings\/security$/);
await expect(list).toHaveCount(0);
await expect(trigger).toContainText("Security");
// Nothing scrolls sideways.
const overflow = await page.evaluate(
() => document.documentElement.scrollWidth - window.innerWidth,
);
expect(overflow).toBeLessThanOrEqual(1);
});
});
+2 -2
View File
@@ -33,7 +33,7 @@ const ROUTES = [
// one-scroll-floor assertion below).
"/settings/layout/insights",
"/settings/layout/medications",
"/settings/layout/mood",
"/settings/modules/mood",
"/settings/modules",
"/settings/account",
"/settings/notifications",
@@ -135,7 +135,7 @@ test.describe("settings + admin vertical over-scroll guard", () => {
expect(dims, "main-content / wrapper present").not.toBeNull();
if (!dims) return;
// One-scroll-floor (UI-STANDARDS §9), structural half. `<main>` may
// One-scroll-floor (ui-standards.md §9), structural half. `<main>` may
// only be the ONLY vertical scroll surface if it also OWNS the
// containing block of its absolutely-positioned descendants: an
// abspos box with no positioned ancestor resolves against the
+1 -1
View File
@@ -1,5 +1,5 @@
/**
* v1.39 (C2) — axe over every setup screen, at both viewports, plus one
* v1.39 — axe over every setup screen, at both viewports, plus one
* keyboard-only pass through a question.
*
* The pattern is `a11y.spec.ts`: a scan waits for something the SCREEN
+1 -1
View File
@@ -1,5 +1,5 @@
/**
* v1.39 (C2) — the setup flow, path 1: blood pressure and weight, typed in.
* v1.39 — the setup flow, path 1: blood pressure and weight, typed in.
*
* From the welcome screen to the dashboard on one account of its own: the
* questions, leaving after Q3 and coming back to Q4, the units question that
+1 -1
View File
@@ -1,5 +1,5 @@
/**
* v1.39 (C2) — the setup flow, path 4: a child's managed profile.
* v1.39 — the setup flow, path 4: a child's managed profile.
*
* Q1 "someone I look after" keeps the questions in the guardian's own record
* and creates the profile on the confirm screen, after the questions — the
+1 -1
View File
@@ -1,5 +1,5 @@
/**
* v1.39 (C2) — driving the setup flow in a browser, shared by its journeys.
* v1.39 — driving the setup flow in a browser, shared by its journeys.
*
* Everything here addresses stable `data-slot` / `data-*` attributes, never
* viewport text: the copy is i18n-driven and one of the specs runs every
+2 -2
View File
@@ -1,5 +1,5 @@
/**
* v1.39 (C2) — every setup screen, in every locale, with 30% longer strings.
* v1.39 — every setup screen, in every locale, with 30% longer strings.
*
* The design spec asks for 30–50% string headroom in every layout, and the
* seven bundles are the floor, not the ceiling: a German label that fits
@@ -33,7 +33,7 @@ import {
type SetupScreen,
} from "./setup-flow-helpers";
const LOCALES = ["en", "de", "es", "fr", "it", "pl", "ko"] as const;
const LOCALES = ["en", "de", "es", "fr", "it", "pl", "ko", "nl"] as const;
const VIEWPORTS = [
{ label: "desktop 1280", width: 1280, height: 800 },
+1 -1
View File
@@ -1,5 +1,5 @@
/**
* v1.39 (C2) — the setup flow, path 2: a daily medication with a reminder.
* v1.39 — the setup flow, path 2: a daily medication with a reminder.
*
* Q3 "yes" is the one answer that makes the first medication the task the
* flow ends on, ahead of a reading; every other question is passed, which is
+4 -2
View File
@@ -1,5 +1,5 @@
/**
* v1.39 (C2) — the setup flow, path 3: a doctor's visit within a month, with
* v1.39 — the setup flow, path 3: a doctor's visit within a month, with
* lab results.
*
* The visit answer is what switches the doctor report on and puts "prepare
@@ -77,7 +77,9 @@ test.describe("setup flow — a visit within a month, with labs", () => {
await expect(checklist.locator("#getting-started-body")).toBeVisible();
const visitRow = checklist.locator('li[data-item-id="visit"]');
await expect(visitRow).toHaveAttribute("data-done", "false");
await expect(visitRow.locator('a[href="/checkups"]')).toBeVisible();
await expect(
visitRow.locator('a[href="/checkups?view=visits"]'),
).toBeVisible();
// Book the visit; the row reads the visits list's own body and flips.
const booked = await page.request.post("/api/encounters", {
+9 -3
View File
@@ -18,6 +18,8 @@
*/
import pg from "pg";
import { sealInsightsCachedText } from "@/lib/insights/cached-text-cipher";
import { E2E_AI_OPTIONAL } from "./global-setup";
function connect(): pg.Pool {
@@ -54,7 +56,7 @@ export async function resetAiOptionalAccount(): Promise<void> {
`UPDATE users SET
ai_provider = NULL,
ai_base_url = NULL,
insights_cached_text = NULL,
insights_cached_text_encrypted = NULL,
insights_cached_at = NULL,
insights_cached_locale = NULL,
disable_coach = false
@@ -80,16 +82,20 @@ export async function seedStoredBriefing(marker: string): Promise<void> {
// Generated now: only text written today is served as today's read.
briefingGeneratedAt: new Date().toISOString(),
};
// Sealed the way the app writes it; there is no readable column to seed.
// The import is static on purpose: a dynamic `import()` of app code bypasses
// the test runner's `@/` path mapping for that module's own imports.
const sealed = Buffer.from(sealInsightsCachedText(JSON.stringify(insights)));
await withAccount(async (pool, userId) => {
await pool.query(
`UPDATE users SET
ai_provider = 'LOCAL',
ai_base_url = 'http://127.0.0.1:9/v1',
insights_cached_text = $2,
insights_cached_text_encrypted = $2,
insights_cached_at = now(),
insights_cached_locale = 'en'
WHERE id = $1`,
[userId, JSON.stringify(insights)],
[userId, sealed],
);
await pool.query(
`INSERT INTO consent_receipts (id, user_id, kind, artefact, signed_at)
+4 -4
View File
@@ -585,7 +585,7 @@ export const SEAM_BANNERS_STORAGE_STATE_PATH = resolve(
);
/**
* v1.39 (C2) — the setup flow's own accounts, one per journey.
* v1.39 — the setup flow's own accounts, one per journey.
*
* Each journey walks the questions from the welcome screen to the dashboard
* and asserts what the walk wrote: the module map, the tile order, the
@@ -802,7 +802,7 @@ export default async function globalSetup(config: FullConfig): Promise<void> {
codex_connected_at = NULL,
codex_connection_status = 'disconnected',
insights_cached_at = NULL,
insights_cached_text = NULL,
insights_cached_text_encrypted = NULL,
onboarding_completed_at = EXCLUDED.onboarding_completed_at,
onboarding_tour_completed = EXCLUDED.onboarding_tour_completed,
module_preferences_json = EXCLUDED.module_preferences_json,
@@ -1550,7 +1550,7 @@ export default async function globalSetup(config: FullConfig): Promise<void> {
// can reuse them via `test.use({ storageState })`. This is the documented
// Playwright pattern (`docs/auth.md`) and the only way to avoid the
// per-spec login + rate-limit dance.
// v1.39 (C2) — the setup flow's accounts. `onboarding_completed_at` NULL
// v1.39 — the setup flow's accounts. `onboarding_completed_at` NULL
// is the whole point: it is what lands them on `/onboarding`. The tour
// flag stays true for the reason every other fixture sets it. Whatever a
// previous run wrote — the answers row, the module map, the seeded
@@ -1693,7 +1693,7 @@ export default async function globalSetup(config: FullConfig): Promise<void> {
`UPDATE users SET totp_confirmed_at = $2 WHERE username = $1`,
[E2E_GUARDIAN.username, now],
);
// v1.39 (C2) — the setup flow's jars, one per journey and one per sweep.
// v1.39 — the setup flow's jars, one per journey and one per sweep.
// The child journey's factor is stamped after its capture, for the reason
// the guardian's is: the password-only capture cannot pass a TOTP login.
await capture(E2E_SETUP_BP, SETUP_BP_STORAGE_STATE_PATH);
+1 -1
View File
@@ -1,5 +1,5 @@
/**
* v1.39 (C2) — the state the setup-flow journeys start from, and nothing else.
* v1.39 — the state the setup-flow journeys start from, and nothing else.
*
* Each journey owns one account and walks it from the welcome screen to the
* dashboard. What a walk leaves behind — the answers row, the completion
+1 -1
View File
@@ -199,7 +199,7 @@ export const CONTENT_DOC_ID = "e2evaultcontent00000001";
export const CONTENT_BODY_WORD = "pneumothorax";
/**
* v1.28 (Phase 3) — a fixed trio the clinician-share e2e attaches to a link and
* v1.28 — a fixed trio the clinician-share e2e attaches to a link and
* serves through the public `/c/<token>/d/<id>` route:
* - a PDF (Class A, inline preview via <iframe>),
* - a JPEG carrying an Exif GPS marker (Class A image, inline <img>; the
+1 -1
View File
@@ -3,7 +3,7 @@ import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
/**
* v1.4.16 A1 — Sidebar Admin entry must mirror the Settings entry
* v1.4.16 — Sidebar Admin entry must mirror the Settings entry
* exactly: a single link with no sub-item expansion in the global
* sidebar. The maintainer reported in v1.4.15 that the sidebar was auto-expanding
* admin sub-items on `/admin/*` and the gravatar dropdown felt linked to
+121
View File
@@ -0,0 +1,121 @@
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
/**
* The provenance page (#1204) at phone width, against mocked reads: the
* day's sources with the used one marked, the comparison table, and the
* link from Settings > Source priority.
*/
const DAY = {
type: "ACTIVITY_STEPS",
date: "2026-10-01",
aggregation: "sum",
ranked: true,
metricKey: "steps",
ladder: ["APPLE_HEALTH", "FITBIT"],
picked: "APPLE_HEALTH",
pickReason: "highest_on_ladder",
sources: [
{
source: "APPLE_HEALTH",
rank: 0,
readings: 24,
value: 9500,
min: 10,
max: 900,
firstAt: "2026-10-01T06:00:00.000Z",
lastAt: "2026-10-01T20:00:00.000Z",
unit: "steps",
devices: ["phone", "watch"],
pass: null,
},
{
source: "FITBIT",
rank: 1,
readings: 1,
value: 8800,
min: 8800,
max: 8800,
firstAt: "2026-10-01T23:00:00.000Z",
lastAt: "2026-10-01T23:00:00.000Z",
unit: "steps",
devices: ["band"],
pass: "lower_on_ladder",
},
],
};
const COMPARE = {
type: "ACTIVITY_STEPS",
from: "2026-09-02",
to: "2026-10-01",
aggregation: "sum",
ranked: true,
ladder: ["APPLE_HEALTH", "FITBIT"],
unit: "steps",
sources: [
{ source: "APPLE_HEALTH", days: 30 },
{ source: "FITBIT", days: 28 },
],
a: "APPLE_HEALTH",
b: "FITBIT",
days: [{ date: "2026-10-01", a: 9500, b: 8800, picked: "APPLE_HEALTH" }],
summary: {
daysA: 30,
daysB: 28,
daysBoth: 28,
meanA: 9100,
meanB: 8700,
meanDifference: -400,
meanAbsoluteDifference: 450,
pickedA: 30,
pickedB: 0,
},
};
test.describe("source provenance", () => {
test.use({
storageState: STORAGE_STATE_PATH,
viewport: { width: 390, height: 844 },
});
test("explains the day's pick and compares two sources", async ({ page }) => {
await page.route(/\/api\/measurements\/provenance\/compare/, (route) =>
route.fulfill({
contentType: "application/json",
body: JSON.stringify({ data: COMPARE, error: null }),
}),
);
await page.route(/\/api\/measurements\/provenance\?/, (route) =>
route.fulfill({
contentType: "application/json",
body: JSON.stringify({ data: DAY, error: null }),
}),
);
// Yesterday in UTC: the page refuses nothing, but a day the date field
// cannot pick (after today) would read as a fixture gone stale.
const day = new Date(Date.now() - 86_400_000).toISOString().slice(0, 10);
await page.goto(`/insights/sources?type=ACTIVITY_STEPS&date=${day}`);
await expect(
page.locator('[data-slot="provenance-source-row"][data-picked="true"]'),
).toHaveAttribute("data-source", "APPLE_HEALTH");
await expect(
page.locator('[data-slot="provenance-verdict"]'),
).toBeVisible();
await expect(
page.locator('[data-slot="provenance-compare-row"]'),
).toHaveCount(1);
const overflow = await page.evaluate(
() =>
document.documentElement.scrollWidth -
document.documentElement.clientWidth,
);
expect(overflow).toBeLessThanOrEqual(0);
});
test("is reachable from Settings > Source priority", async ({ page }) => {
await page.goto("/settings/sources");
await page.locator('[data-slot="sources-provenance-cross-link"]').click();
await expect(page).toHaveURL(/\/insights\/sources/);
});
});
+2 -2
View File
@@ -6,7 +6,7 @@ import { STORAGE_STATE_PATH } from "./setup/global-setup";
* Issue #490 — client-rendered timestamps follow the PROFILE timezone.
*
* `/api/auth/me` is mocked with an `Asia/Manila` profile; the sessions
* card on /settings/privacy renders `lastActiveAt` through
* card on /settings/security renders `lastActiveAt` through
* `useFormatters()`, which reads the localStorage timezone mirror that
* `fetchMe` fills from the mocked payload. A fixed UTC instant must
* therefore paint the MANILA wall clock (14:30), never the Berlin
@@ -75,7 +75,7 @@ test.describe("profile-timezone display (#490)", () => {
}),
);
await page.goto("/settings/privacy", { waitUntil: "domcontentloaded" });
await page.goto("/settings/security", { waitUntil: "domcontentloaded" });
// The sessions card is collapsed by default — expand it first. The
// toggle's onClick can be dropped if Playwright clicks before React has
+1 -1
View File
@@ -396,7 +396,7 @@ test.describe.serial("FENCE record-session fence in the browser", () => {
const held = deferred();
// The commit is observable: flag once the server round-trip inside the
// route handler finishes, and poll for it instead of a fixed sleep
// (C3) — the tab must close AFTER the write landed, and this waits
// The tab must close AFTER the write landed, and this waits
// exactly that long.
let switchCommitted = false;
await initiator.route("**/api/account/switch", async (route) => {
@@ -124,7 +124,7 @@ async function expectAnnounced(page: Page, selector: string) {
* Copy somebody has to act on is content, not meta.
*
* Two halves, and both are needed. The class check is the design standard —
* UI-STANDARDS §3 reserves `text-muted-foreground` for meta, and consent or
* ui-standards.md §3 reserves `text-muted-foreground` for meta, and consent or
* safety text rendered as meta is text people skip. The ratio check is the
* measurable one: it walks up for an opaque background and asserts the AA
* floor for body text, so a future token change that keeps the class and
+62 -10
View File
@@ -1,5 +1,7 @@
import type { Page } from "@playwright/test";
import { MANAGED_OAUTH_PROVIDERS } from "@/lib/integrations/oauth-kit/managed-provider";
import { expect, test } from "./setup/test";
import {
E2E_LEVEL_RECORDS,
@@ -581,7 +583,7 @@ test.describe.serial("scoped sharing browser journeys", () => {
await leaveRecord(page);
});
test("[J2-manage-edits-without-settings] keeps managed Settings on the active record and integrations status-only", async ({
test("[J2-manage-edits-without-settings] keeps managed Settings on the active record and connects integrations for it", async ({
page,
}) => {
const record = E2E_LEVEL_RECORDS.find(
@@ -628,22 +630,40 @@ test.describe.serial("scoped sharing browser journeys", () => {
);
await expect(integrations).toBeVisible();
await expect(integrations).toHaveAttribute("data-record-id", accountId);
// Read-only: the shared integration cards render, but nothing on them
// acts on a provider. The only buttons are each card's own "details for a
// bug report" toggle and its copy button.
// A guardian holds MANAGE on the profile, so each OAuth provider's card
// carries one control, connect, and says whose record it connects for.
// Fitbit takes no new connections and Nightscout is not an OAuth flow, so
// those two stay status-only. No card takes typed input: provider
// credentials and sync settings stay with the guardian's own Settings.
await expect(
integrations.locator('[data-slot="integration-card"]'),
).toHaveCount(8);
await expect(
integrations.locator(
'[data-slot="integration-primary-action"], [data-slot="integration-menu-trigger"], input, select, textarea',
),
).toHaveCount(0);
for (const provider of MANAGED_OAUTH_PROVIDERS) {
const card = integrations.getByTestId(`managed-integration-${provider}`);
await expect(
card.getByTestId(`managed-integration-${provider}-connect`),
).toHaveText(`Connect for ${record.username}`);
await expect(
card.locator('[data-slot="managed-integration-for"]'),
).toHaveText(`Connects for ${record.username}`);
}
await expect(
integrations.locator(
'button:not([data-slot="integration-details-toggle"]):not([data-slot="integration-diagnostics-copy"])',
),
).toHaveCount(0);
).toHaveCount(MANAGED_OAUTH_PROVIDERS.length);
for (const provider of ["fitbit", "nightscout"]) {
await expect(
integrations
.getByTestId(`managed-integration-${provider}`)
.locator(
'button:not([data-slot="integration-details-toggle"]):not([data-slot="integration-diagnostics-copy"])',
),
).toHaveCount(0);
}
await expect(integrations.locator("input, select, textarea")).toHaveCount(
0,
);
await page.goBack();
await expect(profile).toHaveAttribute("data-record-id", accountId);
@@ -656,6 +676,38 @@ test.describe.serial("scoped sharing browser journeys", () => {
await expect(banner).toHaveCount(0);
});
test("[J2-manage-edits-without-settings] keeps integrations shut for a view or add grant", async ({
page,
}) => {
// The connect controls above are MANAGE-only. A view or view-and-add grant
// gets no integration surface at all, and the connect route refuses it.
for (const access of ["READ", "WRITE"] as const) {
const fenceHeaders = trackFenceAssertion(page);
const record = E2E_LEVEL_RECORDS.find(
(candidate) => candidate.access === access,
);
if (!record) throw new Error(`${access} fixture is missing`);
await openRecord(page, record.username);
await page.goto("/settings/integrations");
await expect(
page.locator('[data-slot="shared-record-unavailable"]'),
).toBeVisible();
await expect(page.locator('[data-slot="integration-card"]')).toHaveCount(
0,
);
const connectStatus = await page.evaluate(async (headers) => {
const response = await fetch(
"/api/record-settings/integrations/withings/connect",
{ method: "POST", headers },
);
return response.status;
}, fenceHeaders());
expect(connectStatus).toBe(403);
await leaveRecord(page);
}
});
test("[J1-adult-levels-lifecycle] refuses direct ungranted routes before coach reads start", async ({
page,
}) => {
+12 -1
View File
@@ -30,8 +30,19 @@ function connect(): pg.Pool {
return new pg.Pool({ connectionString: url });
}
/** WCAG 2.1 AA on the screen as it stands. */
/**
* WCAG 2.1 AA on the screen as it stands.
*
* The decision screen is reached by a client navigation, and during one the
* framework unmounts the old route's `<title>` and mounts the new one from a
* suspended metadata boundary, which can reveal after the page body has
* painted. Measured under CPU throttling: the decision is on screen for about
* 200 ms before the title arrives. A scan in that stretch audits the
* navigation, not the screen, so the title is waited for first, and asserted:
* a screen that never gets one still fails here.
*/
async function expectNoAxeViolations(page: Page): Promise<void> {
await expect(page).toHaveTitle("HealthLog");
const result = await new AxeBuilder({ page })
.withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"])
.analyze();
+1 -1
View File
@@ -3,7 +3,7 @@ import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
/**
* v1.4.27 B3 — admin login-overview carrier chip + CSV column.
* v1.4.27 — admin login-overview carrier chip + CSV column.
*
* Two contracts under test:
*
+1 -1
View File
@@ -11,7 +11,7 @@ import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities";
* 1. The floating Coach FAB (`data-slot="coach-fab"`) is the single
* Coach launcher on every authenticated page. The per-metric inline
* launch icon the sub-page header used to mount was retired in
* CCH-04 — the FAB is now the only entry. An unread Coach-initiated
* The FAB is now the only entry. An unread Coach-initiated
* nudge (`GET /api/insights/coach/nudge-status`) paints an unread
* dot (`data-slot="coach-fab-unread"`) on its corner; with nothing
* unread the FAB renders without the dot.
+1 -1
View File
@@ -4,7 +4,7 @@ import { STORAGE_STATE_PATH } from "./setup/global-setup";
import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities";
/**
* v1.4.27 F17 — insights metric-availability gating.
* v1.4.27 — insights metric-availability gating.
*
* When a metric has zero observations the routed sub-page early-
* returns an empty-state component with TWO mandatory affordances:
+1 -1
View File
@@ -3,7 +3,7 @@ import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
/**
* v1.4.27 MB6 — `/measurements?add=<TYPE>` consumer.
* v1.4.27 — `/measurements?add=<TYPE>` consumer.
*
* Insight metric actions link here with `?add=<TYPE>` and an optional,
* allowlisted `returnTo`. The page opens the responsive capture primitive,
+1 -1
View File
@@ -2,7 +2,7 @@ import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
/**
* v1.4.27 MB6 — public surfaces.
* v1.4.27 — public surfaces.
*
* Three contracts under test, all reachable without a session:
*
+1 -1
View File
@@ -3,7 +3,7 @@ import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
/**
* v1.4.27 R3d MB1 — `<ResponsiveSheet>` primitive branch coverage.
* v1.4.27 — `<ResponsiveSheet>` primitive branch coverage.
*
* The primitive renders as a bottom-anchored `<Sheet>` on `<md` (768
* px) viewports and as a centred `<Dialog>` on `>=md`. Every primary
@@ -0,0 +1,111 @@
/**
* Unit tests for the `healthlog/no-planning-tags` rule: a planning-note
* identifier at the head of a comment line is reported and autofixed; a
* release version, an issue reference, domain vocabulary, ordinary prose and
* string contents are left alone.
*/
import { describe, it } from "vitest";
import { RuleTester } from "eslint";
import rule from "../no-planning-tags.js";
RuleTester.describe = describe;
RuleTester.it = it;
RuleTester.itOnly = it.only;
const ruleTester = new RuleTester({
languageOptions: {
ecmaVersion: 2023,
sourceType: "module",
parserOptions: { ecmaFeatures: { jsx: true } },
},
});
const error = { messageId: "planningTag" };
ruleTester.run("no-planning-tags", rule, {
valid: [
// A release version that dates the behaviour may stay.
"// v1.4.27 — `/about` carries the attribution.\nconst x = 1;",
"// Since v1.39 every AI decision is one resolution.\nconst x = 1;",
// Issue references stay, with or without a version.
"// v1.43 (#1234) — a later start inside the same period.\nconst x = 1;",
"// v1.43 (iOS #116 item 9) — the age band.\nconst x = 1;",
"// Issue #281 — the import surface.\nconst x = 1;",
// Domain vocabulary that looks like a code.
"// B12 — cobalamin, reported in pmol/L.\nconst x = 1;",
"// PM2.5 — fine particulate matter.\nconst x = 1;",
"// UTF-8 — the wire encoding.\nconst x = 1;",
"// APP13 — Photoshop / IPTC-IIM\nconst x = 1;",
// Enumerations and prose.
"// Step 2 — parse the payload.\nconst x = 1;",
"// Round 2 — the model answers with the tool result.\nconst x = 1;",
"// OpenAPI route table — one entry per path.\nconst x = 1;",
"/** 2026-05-12 — only one source reported. */\nconst x = 1;",
// A label the file's code also uses (a test named `T1 …`).
'// T1 — no file hand-rolls the locale.\nit("T1 no file hand-rolls the locale", () => {});',
// A tag inside a string is not a comment.
'const title = "v1.4.20 B3 — /insights mounts TrendsRow";',
// Mid-sentence references are out of the rule's reach by design.
"// The card replaced the one from W4 — see the layout.\nconst x = 1;",
],
invalid: [
{
code: "// v1.4.34 IW-B — the provider now mounts here.\nconst x = 1;",
output: "// v1.4.34 — the provider now mounts here.\nconst x = 1;",
errors: [error],
},
{
code: "/**\n * v1.4.40 W-PRIVACY (SB-3) — bilingual public privacy policy.\n */\nconst x = 1;",
output:
"/**\n * v1.4.40 — bilingual public privacy policy.\n */\nconst x = 1;",
errors: [error],
},
{
// The issue reference survives the fix.
code: "// v1.30.1 W9 (#812) — pull-to-refresh parity.\nconst x = 1;",
output: "// v1.30.1 (#812) — pull-to-refresh parity.\nconst x = 1;",
errors: [error],
},
{
// A colon separator after a version.
code: "// v1.4.15 phase-C5: upgrade to the EmptyState primitive.\nconst x = 1;",
output: "// v1.4.15: upgrade to the EmptyState primitive.\nconst x = 1;",
errors: [error],
},
{
// Without a version the tag goes and the sentence starts upper-case.
code: "// C4 — the replaced fields; the note is never quoted.\nconst x = 1;",
output: "// The replaced fields; the note is never quoted.\nconst x = 1;",
errors: [error],
},
{
code: "/** RECON1 (D5-5) — the snapshot driver block. */\nconst x = 1;",
output: "/** The snapshot driver block. */\nconst x = 1;",
errors: [error],
},
{
code: "// Phase 4 — MCP prompts.\nconst x = 1;",
output: "// MCP prompts.\nconst x = 1;",
errors: [error],
},
{
code: "// 2026-07-17 UX/IA audit (M6) — which sidebar group this renders under.\nconst x = 1;",
output: "// Which sidebar group this renders under.\nconst x = 1;",
errors: [error],
},
{
// JSX comment.
code: "const el = <div>{/* v1.4.33 IW9 — aria-label for the bar. */}</div>;",
output:
"const el = <div>{/* v1.4.33 — aria-label for the bar. */}</div>;",
errors: [error],
},
{
// Two tagged lines in one block, each reported.
code: "/**\n * v1.4.25 W4 — the routed sub-page.\n * v1.4.27 F17 — gated on one observation.\n */\nconst x = 1;",
output:
"/**\n * v1.4.25 — the routed sub-page.\n * v1.4.27 — gated on one observation.\n */\nconst x = 1;",
errors: [error, error],
},
],
});
@@ -0,0 +1,86 @@
/**
* Unit tests for the `healthlog/no-raw-card-shell` rule — a card surface is a
* <Card> or a shell primitive. Covers the three-part match, the translucent
* and inner-tile radii that stay legal, cn()/template strings, the allow
* option, and the scope gating.
*/
import { describe, it } from "vitest";
import { RuleTester } from "eslint";
import rule from "../no-raw-card-shell.js";
RuleTester.describe = describe;
RuleTester.it = it;
RuleTester.itOnly = it.only;
const ruleTester = new RuleTester({
languageOptions: {
ecmaVersion: 2023,
sourceType: "module",
parserOptions: { ecmaFeatures: { jsx: true } },
},
});
const APP_FILE = "/repo/src/components/insights/example.tsx";
ruleTester.run("no-raw-card-shell", rule, {
valid: [
{ code: '<Card className="gap-3" />', filename: APP_FILE },
// An inner tile: another radius.
{
code: '<div className="bg-card border-border rounded-lg border p-3" />',
filename: APP_FILE,
},
// The translucent hero glass and inner washes are not the card colour.
{
code: '<div className="bg-card/65 rounded-xl border p-4 backdrop-blur-sm" />',
filename: APP_FILE,
},
// No border: not a shell.
{ code: '<div className="bg-card rounded-xl p-4" />', filename: APP_FILE },
// The primitives under src/components/ui own the shell.
{
code: '<div className="bg-card border rounded-xl p-4" />',
filename: "/repo/src/components/ui/trend-tile.tsx",
},
// An allowed file.
{
code: '<div className="bg-card border rounded-xl p-4" />',
filename: "/repo/src/components/charts/health-chart.tsx",
options: [{ allow: ["src/components/charts/health-chart.tsx"] }],
},
// Outside the UI roots and in tests.
{
code: 'const c = "bg-card border rounded-xl";',
filename: "/repo/src/lib/x.ts",
},
{
code: '<div className="bg-card border rounded-xl" />',
filename: "/repo/src/components/__tests__/x.test.tsx",
},
],
invalid: [
{
code: '<div className="bg-card border-border rounded-xl border p-4 md:p-6" />',
filename: APP_FILE,
errors: [{ messageId: "rawShell" }],
},
{
code: 'cn("bg-card hover:bg-accent/40 block rounded-xl border p-4", className)',
filename: "/repo/src/app/insights/pulse/page.tsx",
errors: [{ messageId: "rawShell" }],
},
{
code: "const c = `bg-card rounded-xl border-dashed ${x}`;",
filename: APP_FILE,
errors: [{ messageId: "rawShell" }],
},
// An allow entry for another file does not cover this one.
{
code: '<div className="bg-card border rounded-xl" />',
filename: APP_FILE,
options: [{ allow: ["src/components/charts/health-chart.tsx"] }],
errors: [{ messageId: "rawShell" }],
},
],
});
@@ -0,0 +1,193 @@
/**
* Unit tests for the `healthlog/no-unlocalised-decimal` rule — a displayed
* number goes through the app's formatters. Covers the four flagged idioms,
* the rounding round-trip that stays legal, the browser time zone lookup, the
* directive contract, and the scope gating.
*/
import { describe, expect, it } from "vitest";
import { Linter, RuleTester } from "eslint";
import rule from "../no-unlocalised-decimal.js";
RuleTester.describe = describe;
RuleTester.it = it;
RuleTester.itOnly = it.only;
const ruleTester = new RuleTester({
languageOptions: {
ecmaVersion: 2023,
sourceType: "module",
parserOptions: { ecmaFeatures: { jsx: true } },
},
});
const APP_FILE = "/repo/src/components/insights/example.tsx";
const PAGE_FILE = "/repo/src/app/(app)/pulse/page.tsx";
ruleTester.run("no-unlocalised-decimal", rule, {
valid: [
// The formatter is the sanctioned path.
{ code: "const s = fmt.number(value, 1);", filename: APP_FILE },
// Rounding, not display: the result is a number again.
{ code: "const n = Number(value.toFixed(2));", filename: APP_FILE },
{ code: "const n = parseFloat(value.toFixed(2));", filename: APP_FILE },
{
code: "const n = Number.parseFloat(value.toFixed(2));",
filename: APP_FILE,
},
{ code: "const n = +value.toFixed(2);", filename: APP_FILE },
// A locale is passed.
{ code: 'const s = n.toLocaleString("de-DE");', filename: APP_FILE },
{
code: "const s = d.toLocaleDateString(locale, opts);",
filename: APP_FILE,
},
{ code: "const f = new Intl.NumberFormat(locale);", filename: APP_FILE },
// The browser time zone lookup formats nothing.
{
code: "const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;",
filename: APP_FILE,
},
// Integer arithmetic in text is fine.
{ code: "const s = `${Math.round(a / b)} h`;", filename: APP_FILE },
{ code: "const s = String(Math.round(a / b));", filename: APP_FILE },
// A tagged template is not text.
{ code: "const q = sql`${a / b}`;", filename: APP_FILE },
// Wire formats under src/app/api are not display.
{
code: "const s = value.toFixed(2);",
filename: "/repo/src/app/api/export/route.ts",
},
// Outside the source roots and in tests the rule is silent.
{ code: "const s = value.toFixed(2);", filename: "/repo/scripts/x.ts" },
{
code: "const s = value.toFixed(2);",
filename: "/repo/src/lib/__tests__/x.test.ts",
},
{
code: "const s = value.toFixed(2);",
filename: "/repo/src/components/__tests__/x.test.tsx",
},
],
invalid: [
{
code: "const s = value.toFixed(1);",
filename: APP_FILE,
errors: [{ messageId: "fixed", data: { method: "toFixed" } }],
},
// A sentence built on the server for a person: the weekday correlation
// printed "0.8 kg" in a German sentence from src/lib.
{
code: 'const delta = Math.abs(a - b).toFixed(1); t("x", { delta });',
filename: "/repo/src/lib/insights/correlations.ts",
errors: [{ messageId: "fixed" }],
},
{
code: "const s = value.toFixed(1);",
filename: "/repo/src/hooks/use-x.ts",
errors: [{ messageId: "fixed" }],
},
{
code: "const s = `${value.toFixed(1)} kg`;",
filename: PAGE_FILE,
errors: [{ messageId: "fixed" }],
},
{
code: "const s = value.toPrecision(3);",
filename: APP_FILE,
errors: [{ messageId: "fixed", data: { method: "toPrecision" } }],
},
// Number() around something else does not launder the call.
{
code: "const s = String(Number(a) + value.toFixed(1));",
filename: APP_FILE,
errors: [{ messageId: "fixed" }],
},
{
code: "<span>{total.toLocaleString()}</span>",
filename: APP_FILE,
errors: [{ messageId: "localeless" }],
},
{
code: "const s = d.toLocaleDateString(undefined, { day: 'numeric' });",
filename: APP_FILE,
errors: [{ messageId: "localeless" }],
},
{
code: "const s = d.toLocaleTimeString();",
filename: APP_FILE,
errors: [{ messageId: "localeless" }],
},
{
code: "const f = new Intl.NumberFormat();",
filename: APP_FILE,
errors: [{ messageId: "intlLocaleless" }],
},
{
code: "const f = new Intl.DateTimeFormat(undefined, { hour: '2-digit' });",
filename: APP_FILE,
errors: [{ messageId: "intlLocaleless" }],
},
{
code: "const s = String(Math.round(v * 10) / 10);",
filename: APP_FILE,
errors: [{ messageId: "division" }],
},
{
code: "const s = `${total / count} per day`;",
filename: APP_FILE,
errors: [{ messageId: "division" }],
},
],
});
/**
* Directives resolve against the rule's registered name, which RuleTester
* does not use, so they run through a Linter with the plugin registered as
* `healthlog` — the way the flat config loads it.
*/
describe("no-unlocalised-decimal disable directives", () => {
const linter = new Linter({ configType: "flat" });
const config = [
{
files: ["**/*.tsx"],
plugins: { healthlog: { rules: { "no-unlocalised-decimal": rule } } },
rules: { "healthlog/no-unlocalised-decimal": "error" },
languageOptions: {
parserOptions: { ecmaFeatures: { jsx: true } },
},
},
];
const lint = (code) =>
linter
.verify(code, config, { filename: "src/components/charts/x.tsx" })
.map((m) => m.messageId ?? m.message);
it("flags the site without a directive", () => {
expect(lint("const d = `${x.toFixed(1)}`;")).toEqual(["fixed"]);
});
it("accepts a next-line directive that gives a reason", () => {
expect(
lint(
"// eslint-disable-next-line healthlog/no-unlocalised-decimal -- SVG coordinates, not text\nconst d = `${x.toFixed(1)}`;",
),
).toEqual([]);
});
it("refuses a directive without a reason", () => {
expect(
lint(
"// eslint-disable-next-line healthlog/no-unlocalised-decimal\nconst d = x.toFixed(1);",
),
).toEqual(["missingReason"]);
});
it("refuses a whole-file disable, which would hide the next site too", () => {
expect(
lint(
"const a = 1;\n/* eslint-disable healthlog/no-unlocalised-decimal -- SVG coordinates, not text */\nconst d = x.toFixed(1);",
),
).toEqual(["nextLineOnly"]);
});
});
+13
View File
@@ -14,6 +14,13 @@
* src/lib/tz/format, never written out.
* - `no-utc-day-key` — a day is cut through the tz module, not by
* slicing an ISO string or parsing UTC midnight.
* - `no-planning-tags` — no planning-note identifier at the head of a
* comment line.
* - `no-unlocalised-decimal` — a displayed number goes through the app's
* formatters, not toFixed() or a locale-less
* toLocaleString().
* - `no-raw-card-shell` — a card surface is a <Card> or a shell
* primitive, not bg-card + border + rounded-xl.
*/
"use strict";
@@ -26,6 +33,9 @@ const spacingScale = require("./spacing-scale.js");
const jobHandlerOutcome = require("./job-handler-outcome.js");
const noDefaultZoneLiteral = require("./no-default-zone-literal.js");
const noUtcDayKey = require("./no-utc-day-key.js");
const noPlanningTags = require("./no-planning-tags.js");
const noUnlocalisedDecimal = require("./no-unlocalised-decimal.js");
const noRawCardShell = require("./no-raw-card-shell.js");
module.exports = {
rules: {
@@ -42,5 +52,8 @@ module.exports = {
"job-handler-outcome": jobHandlerOutcome,
"no-default-zone-literal": noDefaultZoneLiteral,
"no-utc-day-key": noUtcDayKey,
"no-planning-tags": noPlanningTags,
"no-unlocalised-decimal": noUnlocalisedDecimal,
"no-raw-card-shell": noRawCardShell,
},
};
Loaded 100 of 2852 files, more files were not shown because too many files have changed in this diff. Show more