Compare commits

..
186 Commits
Author SHA1 Message Date
Marc-André Bombeck e21fc24950 Merge pull request #660 from MBombeck/release/v1.33.1
v1.33.1 — what the app knew and did not say
2026-07-27 18:01:38 +02:00
Marc-André Bombeck 4d1d712b93 chore(release): v1.33.1 — what the app knew and did not say
Eight workstreams, two migrations, and a pass by an independent reader that
found four things this release was claiming about itself that were not true.

Two of the fixes change what people receive. Medication reminders went out
every fifteen minutes because the dedup key could not tell one dose slot from
another, and "handled by the app" was silencing every channel rather than the
one it names. Both are called out in the notes, because a user who set that
flag as a general mute will notice.

Two came from reports: an OpenAI-compatible gateway that the provider list
had no room for, after an earlier answer from us said otherwise, and four
places where a timestamp followed the browser instead of the profile.

The backup half is the one worth reading. Three record types were carried and
could not be restored, or could not be carried at all, and each failed in a
different direction: one dropped silently, one stopped the restore outright,
one was discarded while the restore reported success. What a backup holds is
now declared in one file with a written reason for everything left out, and
the roughly thirty types that are still carried by neither end are named there
instead of waiting to be discovered by someone who needed them.
2026-07-27 17:41:50 +02:00
Marc-André Bombeck 99836bb08c fix(documents): make the delete-key hint readable enough to be a hint
The Del chip added beside the document card measured 4.06:1 against the card's
background, under the 4.5:1 floor, and axe failed the vault on it. So the
change that made the destructive key visible introduced a barrier of its own.

It uses the full foreground token now. The design rule says muted for meta
text, and a key hint is meta — but it is the one kind of meta that has to be
readable at a glance, so here the contrast rule wins and the reason sits next
to the class list rather than in a commit nobody will read again.

Found by running the e2e suite locally before pushing rather than after.
2026-07-27 17:41:49 +02:00
Marc-André Bombeck ea55886445 fix(tz): close the hole that let a display option pass for a timezone
The guard tested whether the argument list contained the substring "timeZone".
`timeZoneName: "short"` contains it. That option appends "GMT+1" to the output
and leaves the zone as the browser's, so a formatter written that way sailed
through while being exactly as wrong as a bare one — and looking more careful
than a bare one, which is worse.

Tightening it to a colon-terminated property then reported nine correct call
sites, because the shorthand `{ timeZone }` is what most of this tree writes.
The honest response to that would have been to allowlist all nine, which is
how a guard stops guarding. It accepts both forms now, and a test pins both
directions rather than leaving the regex to be trusted.

Two exclusion reasons in the backup plan aged inside their own release and are
corrected: push attempts now also hold the reminder dedup anchors this release
introduced, and the thumbnail's "rebuilds from the stored bytes" only holds on
the disaster-recovery path, since a portable export carries no bytes to rebuild
from.
2026-07-27 16:10:30 +02:00
Marc-André Bombeck 4ad498dbff fix(backup): carry a custom tag's own words and its scale, and stop claiming a guard that does not exist
Four things an independent read found, three of them things this release said
about itself that were not true.

A custom mood tag travelled without its label, its icon, its sort order and
its SCALE. The label is the smaller loss: the person who named a factor
"Migräne" would have got a key back. The scale is the real one — the inverse
flag marks a factor where a high value is bad, and dropping it does not lose a
label, it silently reverses the meaning of every rating already recorded
against that tag. Custom cycle symptoms lost their label the same way. Both
now carry the ciphertext verbatim, like every other encrypted column in a
disaster-recovery payload.

The plan file claimed, in the present tense, that a guard requires both a
payload reader and a restore branch for every carried model. No such guard
exists. The commit that introduced the file said so honestly; the file itself
said the opposite, and roughly 31 models have neither end. BACKED_UP means
"must be", not "is", and both the plan and its test now say that plainly. The
wrong sentence is recorded rather than quietly swapped, because it is the same
defect the file was written to prevent.

The export route still told operators that labs, allergies, illness episodes
and workouts could be exported but never restored. The restore route
re-creates all of them, and since this release nutrient day totals too. The
scope now points at the plan instead of repeating it beside the code, which is
how that sentence came to be wrong.

And the export card promised "every record you own". It does not, so it no
longer says so — six locales.
2026-07-27 15:20:12 +02:00
Marc-André Bombeck 2449e381b9 docs(self-hosting): say that the offline tier alone does not stop the lookup
The resolver is online-first, so mounting the databases fills the ASN
and carrier but does not by itself keep login IPs on the host. Point at
the IP_GEO_LOOKUP_DISABLED section that does.
2026-07-27 14:47:57 +02:00
Marc-André Bombeck 5f3cad759a fix(dashboard): let the native-only group describe this page, not the app
The hint said switching a widget off here turns it off in the mobile
app. This page writes the flag; whether the client applies it on its
next read is the client's business, and there is an open report that it
does not. Say where the setting lives instead of promising a result
this surface does not control. Six locales.
2026-07-27 14:47:57 +02:00
Marc-André Bombeck 7997e2c61f feat(dashboard): make the native-only widgets configurable from the web
BMI renders on the native dashboard and was configurable from nowhere.
Two individually-correct decisions produced it: `bmi` is a catalogue id
with no web render path, and the web layout editor filters those out
because a toggle over something this page cannot draw would be a silent
no-op. The widgets PUT accepts and persists them all the same, and the
native client writes them, so an account ended up holding rows it never
set and could not change from any surface.

The sortable list stays exactly as it was. Below it, the ids the web
does not draw but the layout carries get their own labelled group with
the same two flags and no reordering — placement belongs to the client
that draws them. An account with no native client has nothing there and
sees nothing. The module gate applies as it does above, fail-open.

Refs #581
2026-07-27 14:47:56 +02:00
Marc-André Bombeck e4a4c996b7 fix(documents): stop Backspace deleting a document, and show the key that does
A focused card deleted on Backspace as well as Delete. The timeline's
roving tabindex gives a card focus the moment someone tabs into the
grid, Backspace is the reflex "go back" key, and a card carries no
delete control at all — so the reflex press removed a medical document
with nothing but a transient undo toast behind it.

Delete alone now deletes. The key resolution moves into a pure helper
in vault-utils so the contract is testable without a render harness,
and the card stops keeping that contract in a code comment: the overlay
button carries aria-keyshortcuts, and a kbd chip appears while the card
holds focus. The label is per-locale (Entf / Suppr / Supr / Canc).
2026-07-27 14:47:56 +02:00
Marc-André Bombeck 20824aa798 fix(documents): say that turning auto-read on reaches back over the vault
The switch was described three times as applying to what you upload
next. A genuine off-to-on flip also enqueues a pass over the documents
already stored without a summary, capped at 200 per flip — scanned
medical documents going to a third-party provider, from copy that reads
as forward-looking only.

Drop "newly uploaded" from the sub-label and give the disclosure the
sentence it was missing, including the cap. Six locales. The behaviour
is right and stays as it is; only the sentence changes.

A guard pins the number in the copy to MAX_ENQUEUES_PER_RUN, so moving
the cap without touching the copy fails.
2026-07-27 14:47:55 +02:00
Marc-André Bombeck 189024cc00 fix(geo): make GEOLITE2_DIR reachable and tell self-hosters what they can do
The runtime has always read GEOLITE2_DIR, but the variable was absent
from the compose `environment:` whitelist, from both env examples, and
from the docs. A variable that is not on that whitelist never reaches
the container even when the operator's `.env` sets it, so the override
existed in code and was unreachable from the documented path.

Put it on the whitelist, document it in both examples and the manifest,
and add `docs/self-hosting/geolite2.md`: fetch the databases with a free
MaxMind account, mount the directory read-only, point the variable at
it. The compose file carries the bind-mount line commented out beside
the app service.

The admin notification told the reader to set MAXMIND_LICENSE_KEY in
this repository's Actions secrets — something nobody running the
published image can do, about a fallback that sends their sign-in
addresses to a third party. It now names the mount, names the online
provider it is actually querying, and links the runbook. Six locales.
It also no longer claims the carrier column stays empty; the online
tier has filled the carrier from the ISP field since v1.25.8.

A blank GEOLITE2_DIR now resolves to the default instead of the process
working directory: compose substitutes an unset variable to the empty
string, which is not nullish.

Refs #659
2026-07-27 14:47:55 +02:00
Marc-André Bombeck 832a269ab3 refactor(medications): split the supply tab at the dialog boundary
The Bestand tab was 1,139 lines of one client file holding two concerns that
only meet at the row shape: the list of containers a person holds, and the
three dialogs that change it. Registration, per-item correction, packaging
economics, the summary and the row rendering all shared a scope, so reading
any one of them meant scrolling past the rest.

Move-only. The dialogs go to `inventory-dialogs.tsx` with the day-input
conversions their fields produce; the row shape and the cache-invalidation
they both need go to `inventory-shared.ts`; the section keeps the summary and
the list at 458 lines. Every export the tab offered is still offered here, so
no caller changes. No copy changes, no new keys.

Two suites pin these contracts by reading the file's own source — the register
dialog's unit conversion and the correction dialog's sparse patch. Their
`readFileSync` now names the file the code moved to. The assertions are
unchanged, byte for byte; only the path they read is.
2026-07-27 14:47:55 +02:00
Marc-André Bombeck 1c05bba56c docs(jobs): correct two migration citations and the import dedup contract
The retention-purge comments still cite migration 0277 for indexes that landed
in 0276 — the renumbering moved the directories and left the prose behind. The
schema is trustworthy because its comments are, so a wrong number is not
cosmetic.

The import worker's `already_recorded` reason claimed the skip covers a dose
recorded on any other intake surface. It does not: `source` is part of the
live-row unique, so a hand-logged dose and an imported one at the same slot are
different keys and both are written. Say what the mechanism actually does, and
say plainly that cross-surface dedup is not there.
2026-07-27 14:47:55 +02:00
Marc-André Bombeck 1a6e523b29 refactor(medications): one admission path and one replay key for intake imports
The per-medication import and the account-wide dose-history import queued
their work through ninety near-identical lines each: row lock, stale-job
sweep, active-job refusal, job row, pg-boss send, and the fallback that fails
the row when the queue is not there. Five steps written twice drift, and the
drift shows up as an import that hangs on one surface and not the other. The
status projection moved out of these routes for the same reason; this is the
write half.

Scope is the only real difference, and it is one argument. What the two
surfaces legitimately say differently — the 409 wording, the envelope, the
kickoff audit line — stays at the call site.

The replay key had the same shape of problem in smaller form: the export
reader owned a function for it and the hand-listed body inlined the same
template. Both now call one function, with a test that pins them equal for
the same medication and instant.
2026-07-27 14:47:55 +02:00
Marc-André Bombeck 146a02319d refactor(medications): give the in-use clock list one owner
The supply tab carried its own copy of the two container kinds whose first
opening starts a degradation clock, with a comment saying it mirrored the
state machine's list. Nothing enforced the mirror — the state machine types
itself against the generated Prisma client, which the browser bundle cannot
carry, so a copy was the only way to say it.

Move the list into a client-safe module with no imports and let both sides
read it. The state machine re-exports it under the Prisma enum, so a renamed
or dropped container kind is now a compile error rather than a member that
quietly stops matching.
2026-07-27 14:47:54 +02:00
Marc-André Bombeck 6ab3bc87ff test(analytics): pin the effective-range resolver's sex-split defaults
The resolver reads the profile's sex for the body-fat and pulse bands, and
nothing asserted it. A resolver that stopped reading the field, or read it and
defaulted to one side, passed the whole suite. Pin both recorded sides and the
neutral band that OTHER and an unanswered profile share.
2026-07-27 14:47:54 +02:00
Marc-André Bombeck 36f525c465 refactor(profile): route every binary-sex narrow through the one owner
`binaryReferenceSex` was already the declared owner of the shrink from the
three stored values to the two a sex-split reference table has rows for, but
eight sites still narrowed inline — including a byte-for-byte private copy in
the effective-range resolver and a ternary 37 lines above a `toProfileSex`
call in the same file. Each of those is a place the fallback can drift.

Delete the copy, replace the ternaries, and name the pair once as
`BinaryReferenceSex` so a signature that needs it stops re-listing the two
literals. No behaviour changes: every site already resolved OTHER and "no
answer" to the same neutral or absent value, and the existing suites pass
untouched.
2026-07-27 14:47:54 +02:00
Marc-André Bombeck eba9f15539 test(integrations): prove the leg backfill against rows that carry the old value
A migration container starts empty, so applying 0281 there proves the
statements parse and nothing else — the backfill's WHERE matches no rows and a
broken expression passes green. Every self-hoster runs it against rows that do
carry a leg, and those are the only rows it exists for.

Seed the pre-migration shape, run the migration's own UPDATE read out of the
committed file, and assert an attributed row comes out still knowing which leg
broke, an unattributed one is left alone, and a second run does not nest the
array.
2026-07-27 14:35:34 +02:00
Marc-André Bombeck d222aa2354 test(measurements): pin the null-trigger stamp on a batch that declares none 2026-07-27 14:35:34 +02:00
Marc-André Bombeck 7e4b9d9be9 test(integrations): cover the leg-set semantics a single slot could not hold 2026-07-27 14:35:33 +02:00
Marc-André Bombeck 4e95df9e8d feat(integrations): tell the Apple Health card how the last sync arrived
The batch route has accepted a `syncTrigger` since v1.32.8 and put it on the
wide event, where no human reads it. Half of what was promised got built: the
value is written and appears in three places in the codebase, none of which is
a screen anyone looks at.

Persist it on the account, alongside the instant of the most recent batch that
arrived without the app being opened, and publish both from
`GET /api/integrations/healthkit`. The card now names how the last sync arrived
and when data last came in on its own. `healthKitLastSyncedAt` alone reads
identically whether the phone delivers by itself or only while someone has the
app open, which is why "is background delivery working" could not be settled
from either side.

A batch that declares no trigger writes null rather than leaving the previous
one standing — the field describes this sync, and an older client's silence is
not evidence about it. A foreground batch leaves the background timestamp
alone; overwriting it would erase the only answer the card has.

Also here: WHOOP, Fitbit and Google Health join the workout-freshness table.
All three write workouts, and without an entry their cards could report every
measurement type fresh while the workout leg had been silent for weeks. And the
status pill drops a generic `aria-label` that replaced its own accessible name,
so a screen reader announced the same words for a healthy connection as for a
broken one.
2026-07-27 14:35:33 +02:00
Marc-André Bombeck 06935256b0 feat(integrations): let the ledger hold every failing leg at once
`failing_leg` was one slot, so two legs of the same provider failing in
overlapping windows overwrote each other. The row remembered whichever failed
last, and that leg recovering then cleared an error the other one was still
causing — the ledger forgot a live failure and painted the card green over it.

The slot becomes a set. A failure adds its leg, a success removes its own, and
the error state, the per-kind buckets and the streak anchor clear only once the
set is empty. The array is ordered oldest failure first, so the last element is
the leg that wrote the message `last_error` holds; when that leg recovers while
others are still failing, the message is cleared rather than left describing a
leg that is now healthy.

An unattributed failure adds nothing and removes nothing. A single-leg provider
therefore behaves exactly as before, and a multi-leg provider no longer loses
what its other legs already reported.

The column never reached a client — it is absent from the status serialiser and
from the OpenAPI contract — so it is replaced rather than shimmed.
2026-07-27 14:35:33 +02:00
Marc-André Bombeck 3088753a92 docs(reminders): drop the stale client-managed cross-reference
The module-gate branch cited the client-managed skip as its precedent for
advancing past a cycle. That skip no longer exists in this file, and the
reason the gate advances is its own: a disabled module has nothing left
to deliver.
2026-07-27 14:26:20 +02:00
Marc-André Bombeck ff076e80b1 fix(notifications): collapse APNs pushes per dose slot, not per event type
`apns-collapse-id` is a replace directive: a push carrying an id
supersedes the earlier undelivered push with the same id. Every
medication reminder carried the literal string MEDICATION_REMINDER, so
three medications due at 08:00 arrived on the phone as one notification
naming one of them. The user takes that one and misses the others with
nothing on screen to suggest anything was dropped.

Key the collapse on the per-slot tag the Web Push sender already
coalesces on, falling back to the event type when a payload carries none.
Grouping stays on `threadId`, which is what grouping is for. Discreet
mode keeps the generic id — no record-identifying string on the lock
screen — and a tag over Apple's 64-byte ceiling falls back rather than
costing the send.
2026-07-27 14:26:20 +02:00
Marc-André Bombeck c6b944b7fb fix(reminders): send a dose reminder once per slot, phase and day
The per-phase "already sent today" guard read the Telegram message
ledger, and that table is written only when a Telegram send succeeds. A
user on email, APNs or ntfy never had a row, so the 15-minute tick
re-derived the same overdue slot and sent it again on every pass — around
fifty mails for a single missed dose.

Anchor the guard in `push_attempts` instead, the ledger the daily
briefing and the illness escalation already use. Two properties are
deliberate: the anchor is stamped for every dispatched slot regardless of
what any channel returned, because keying it on a delivered push would
re-open the flood the moment a channel starts failing; and it is stamped
before the dispatch, so a crash costs one phase's notice rather than a
repeat every quarter hour.

The key carries the schedule id and the slot's real HH:mm alongside the
phase and the local date. Both are load-bearing: two legacy
single-window schedules on one medication share the empty first-class
time-of-day, so a coarser key would collapse the evening dose into the
morning one. The tests assert the other direction too — the next phase
still escalates, and the next local day starts clean.

The Telegram ledger keeps its message-tracking duties; it is no longer
the dedup key. Anchor rows carry a sentinel channel and stay out of the
operator's notification health and diagnostic reads, which count
deliveries, not decisions.
2026-07-27 14:26:20 +02:00
Marc-André Bombeck fcc4e661df fix(notifications): scope client-managed suppression to the APNs leg
`medication.clientManaged` and `measurementReminder.clientManaged` mean
the iOS client schedules the reminder locally, so the server should not
push it. That is what the OpenAPI description, the settings copy and the
iOS contract all say. What the crons did was skip the dispatch entirely,
so a user who flipped the switch also lost Telegram, ntfy, webhook,
email and Web Push — channels they may well read on a desktop while the
phone holding the local schedule sits in a drawer.

Move the gate into the dispatcher's channel loop, where it applies to
APNs and nothing else. The skip is not counted as an attempt, so
`dispatched` keeps meaning "something reached the user", and it stays
countable: the same wide-event keys the crons emitted are emitted here,
plus a `skipped` row in the push-attempt ledger.

The Vorsorge path is the sharper half. It suppressed every channel and
then advanced `nextDueAt`, which dropped the reminder out of the
preventive-care card and out of the daily digest: a reminder that never
reached anyone was recorded as handled. The advance now happens only
where it always belonged, after a dispatch that a channel actually
delivered. When nothing delivers, the reminder stays overdue and visible.
2026-07-27 14:26:20 +02:00
Marc-André Bombeck cd30cb3a52 fix(backup): carry the mood tags an account made, so its own file can be restored
The restore resolves a mood entry's rated factors by key and asks only for the
seeded catalogue. A tag the user created is not in it, so the lookup came up
short and the whole restore threw 'Unknown mood factor keys'. The file was
complete; it simply could not be read back.

That is the same cause as the cycle symptoms two commits ago, with the
opposite ending. There, an unresolvable key was filtered out and the loss was
silent. Here it stopped everything. One shape of missing definition, two
answers, neither of them the right one.

The account's own tag definitions travel now, get written before the factors
resolve, and the lookup accepts the seeded catalogue or the owner's own. The
seeded rows stay out of the file for the same reason as before: a restore must
not rewrite another instance's reference data.

Four more test mocks needed the new delegate, which is again the compiler and
the suite naming the call sites rather than me hunting for them.
2026-07-27 12:41:45 +02:00
Marc-André Bombeck d9afb23067 fix(backup): restore the nutrient day totals the export has always carried
The export has written nutrient day totals since v1.29. The canonical schema
never declared the field, so `.passthrough()` carried it through parsing and
no reader ever looked at it: every water and vitamin total in the file was
discarded on restore, and the restore reported success.

Declared in the contract, cleared and re-created beside its siblings, and
counted in both places that count — what the file holds and what the restore
replaced. A count that stops at the domains someone remembered is how this
stayed quiet.
2026-07-27 12:02:01 +02:00
Marc-André Bombeck b2e19ce0b6 fix(backup): carry the symptoms an account made, and stop dropping the ones it cannot find
restoreCycleData was loud about one missing reference and silent about
another, three lines apart. An unknown cycle reference threw; an unknown
symptom key was filtered out of the list, so the day-log came back with one of
its symptoms quietly gone and the restore reported success.

The cause was upstream: the seeded catalogue is reference data every instance
has, but a symptom the user created lives only in their account and was never
carried. The key had nothing to resolve against, and the filter turned that
into silence.

Both ends now. The account's own definitions travel with the backup, the
seeded rows deliberately do not — shipping those would let one instance's
restore rewrite another's reference data. And the resolve step throws on an
unresolvable key rather than filtering it, so the same function answers the
same question the same way twice.

Widening the builder's client type made four export tests fail on a missing
delegate, which is the sweep working: those are the call sites, named by the
compiler instead of found by reading.
2026-07-27 11:22:22 +02:00
Marc-André Bombeck a2e9587781 feat(backup): declare what a backup carries, and force a verdict on the rest
The mirror of the wipe plan, for the same reason and against a worse failure:
a list that was right when written and stops being right every time a model is
added. On the delete side that leaves data behind; here it means data does not
come back, and nothing says so until someone is restoring a backup because
something already went wrong.

The list is longer than the wipe list by sixteen models, and that gap is the
point. A wipe may lean on the database — deleting a medication cascades to its
schedules, dose changes and inventory events — but a restore has no cascade,
so every child has to be named here.

Every exclusion carries its reason in prose, and the guard enforces a length
floor on it. That floor is not pedantry: the first draft of this file said
'Same as WithingsConnection' four times, which is a pointer rather than a
reason, and a pointer silently inherits whatever the thing it points at
becomes. Writing the decision where it is made is the only way it stays
reviewable.

The guard found two things in its own author's work on the first run: a model
with no verdict at all, and a reason too thin to be one.

The restore side of the guard — that every carried model also has a restore
branch — lands with the payload work it will police.
2026-07-27 05:14:39 +02:00
Marc-André Bombeck 8e9da490a9 test(tz): refuse a date formatter that does not name its calendar
A formatter with no timeZone follows the browser, which is a different
question from the one this app answers. Four surfaces had drifted apart that
way before anyone noticed, and one of them printed a shared report a day off
from the page it came from.

The admin host chart went with them rather than onto the allowlist. Server
samples are real instants, and an operator reading their own instance should
not find a spike at 14:20 here and 15:20 two screens away.

Two deliberate limits, both written into the test. It does not match
toLocaleString, which in this tree is overwhelmingly a number formatter, and
burying four real hits under every thousands separator is how a guard gets
silenced. And it cannot tell a Date built from calendar arithmetic — a month
label, a weekday header, no instant behind it — from one parsed out of stored
data, so those four sites carry a written reason instead. The value is the
ratchet: a new bare formatter has to be argued for before it ships.
2026-07-27 00:18:22 +02:00
Marc-André Bombeck f1773cf602 fix(charts): draw bucket boundaries in the profile's calendar, not Berlin's
The bucketer hard-coded Europe/Berlin for day, week and month boundaries, so
a reading taken on the last evening of a month landed in the wrong month's
bar for everyone who does not live there. Two smaller siblings did the same
thing: the Today chip asked the browser for a time instead of the profile,
and the score card's tooltip formatter carried a locale but no timezone.

The boundary timezone is a required option rather than a defaulted one. A
default would have left any caller that forgets to pass one still wrong and
still silent; required makes the compiler name them, which is how the three
call sites and the eight test sites in this diff were found rather than
guessed at. The tests were always asserting Berlin's calendar without saying
so, and now they say so.

Two fixture mistakes worth recording, both caught by running the tests rather
than trusting them: summer time is already in force in Berlin on 31 March, so
the first instant chosen was April on both sides; and 31 March 2025 falls in
the same ISO week as 1 April, so the week case needed its own instant where
one side is still Sunday.
2026-07-26 23:18:12 +02:00
Marc-André Bombeck 87877b285f fix(share): date a shared PDF by the owner's day, not the display default
The page at c/[token] resolved the owner's timezone; the PDF download beside
it resolved nothing and fell through to the display default. Same link, same
reading, two dates — and the one a practice files is the one that was wrong.

Resolved in the shared download gate rather than in the route, so the PDF and
the FHIR bundle cannot drift apart the way the page and the PDF did. The
owner's clock preference travels with it for the same reason.

The fixture is deliberately west of UTC: Berlin is the fallback, so a Berlin
fixture cannot tell a resolved timezone from a forgotten one.
2026-07-26 22:37:10 +02:00
Marc-André Bombeck 6d800f277d fix(rotation): keep the gateway bearer's coverage guard real
The rotation-coverage guard greps the script for a quoted column name.
Listing the new column in the header comment satisfied that grep without
the script rotating anything, which made the guard pass on a script that
would have left the column behind. The comment now names it unquoted and
says why.
2026-07-26 22:27:48 +02:00
Marc-André Bombeck b74612eaae docs(ai): document the OpenAI-compatible gateway provider
Names LiteLLM, OpenRouter and vLLM, states what it does not share with
the OpenAI provider and why, and keeps the Local provider's own section
intact — it still speaks the same wire and remains the right pick for a
model on your own machine.
2026-07-26 22:27:48 +02:00
Marc-André Bombeck 3ef6a7119b test(ai): pin the gateway provider's isolation and host policy
The properties this provider has to keep: it resolves against the base
URL that was saved for it, the pinned OpenAI arm cannot see that URL and
still posts to api.openai.com, and the OpenAI key never travels to the
gateway. Each is asserted at the wire, not on internals.

The SSRF floor is checked through the same alt-notation classes the
Local provider is checked against, and the pinned tags are shown to keep
requiring a public host even when the operator opened every private one.

The AI settings connect-src is pinned to what it already allowed: the
gateway is called server-side, so no CSP change is needed, and a future
change that needs one will turn this red.
2026-07-26 22:27:48 +02:00
Marc-André Bombeck ee620b2707 feat(settings): configure the OpenAI-compatible gateway from Settings
Its own form with its own three fields, its own entry in the provider
dropdown and in the fallback-chain editor, and copy in six locales that
says plainly where the OpenAI key does not go.

The chain editor offers the entry but the default chain does not carry
it: it resolves only against a base URL somebody saved.

The read endpoint reports the gateway's base URL and model, and the
bearer as presence only. OpenAPI regenerated.
2026-07-26 22:27:48 +02:00
Marc-André Bombeck 0c7059804b feat(ai): resolve an OpenAI-compatible gateway as its own provider
The chain gains a seventh entry. It builds the OpenAI client against the
base URL the user saved, with the bearer they saved for it and the model
they named, and it reads none of the OpenAI columns — the pinned
api.openai.com arm stays pinned.

Host policy is the Local provider's, unchanged: a public host always, a
private one only where the operator allowlisted it, which is what a LAN
LiteLLM or vLLM needs. Egress keeps riding safeFetch.

The JSON-mode dialect the Local client learns per endpoint moves into a
shared module and now serves both clients, so a gateway that rejects
response_format is detected once and retried without it.

The entry stays out of the default chain: it can only resolve against a
base URL somebody configured.
2026-07-26 22:27:48 +02:00
Marc-André Bombeck d11019aebc feat(ai): add the storage for an OpenAI-compatible gateway provider
Three dedicated User columns — base URL, optional bearer, model — for a
provider whose endpoint the user chooses. They are separate from the
OpenAI columns on purpose: the OpenAI arm pins api.openai.com, so it must
share no column with a user-supplied host, and a gateway must never
receive the key issued for OpenAI.

The bearer joins the encrypted-column registry and the rotation script;
the three columns join the account-wipe reset set.
2026-07-26 22:27:48 +02:00
Marc-André Bombeck d10ae9e71c test(e2e): gate every measurement on its element, and keep it that way
Four specs navigated and then read the DOM once, with nothing in between
that waits for anything but the network. An idle network says nothing about
whether React has rendered, so the read can sample a tree that is still
mounting and the assertion then blames the application for a race inside the
test. Two such reads went red on consecutive CI runs, on different specs.

The settings over-scroll guard also slept a fixed 500 ms, which is the wrong
shape twice over: too long when the page is ready, too short on a loaded
runner, and it never says what it is waiting for.

The structural guard runs in the unit suite rather than in Playwright,
because e2e runs at PR-CI only and flaky counts as failed there — one
unguarded read otherwise costs a whole pipeline before anyone sees it. It
refuses to count a wait whose rejection is thrown away, since that reads
like a guarantee and is not one, and would leave the check defeatable by
appending a catch. The one deliberate swallow is on the allowlist with its
reason: a genuine regression there still fails at the width assertion below
it, which reports the measured number instead of a timeout message.
2026-07-26 22:04:58 +02:00
Marc-André Bombeck 26f8325c26 docs(releases): carry the v1.33.0 release image in the repository
A release asset is served with content-disposition attachment and nosniff, so
an image embedded from that URL renders as a broken image in the notes. The
raw path serves it as an image, which is the only thing that actually works.
2026-07-26 20:52:41 +02:00
Marc-André Bombeck 7e5ea64799 Merge pull request #658 from MBombeck/release/v1.33.0
v1.33.0 — what was silently failing
2026-07-26 20:35:25 +02:00
Marc-André Bombeck f5049fc59f test(e2e): gate the settings checks on the elements they measure, not on the network 2026-07-26 20:19:55 +02:00
Marc-André Bombeck 9c79ea41fa test(e2e): settle the page before the overflow self-test injects its canary 2026-07-26 20:03:55 +02:00
Marc-André Bombeck 81a8e49333 ci(integration): give the hang guard enough room to tell a hang from a slow runner 2026-07-26 19:43:17 +02:00
Marc-André Bombeck 7967224f48 docs(changelog): name the two additions this release gained and the people who asked for them 2026-07-26 19:24:46 +02:00
Marc-André Bombeck 3f355fbde4 feat(charts): put the drawn data points under the chart as a table
A chart shows the shape and hides the numbers. An appointment needs the
numbers, and so does anyone moving readings into a spreadsheet.

The table reads the same expression the chart hands Recharts, after the range
slice, the week/month bucketing and the display scale, and it formats through
the chart's own value and date formatters. Nothing is re-derived, so a cell and
the tooltip cannot print two numbers for one point.

Collapsed by default, with the point count in the trigger so the size reads
while it is closed; the region scrolls in its own box rather than growing the
page, and the table primitive already scrolls a wide row set sideways. The
caption names the grain, because a weekly mean labelled as a daily reading
would be a lie, and the grain now has one source shared with the bucketing.
Derived overlays get no column: a moving average is drawn from the points, not
measured. An empty window renders no table at all, since the chart's own empty
state already says there is nothing there.

Opt-in per mount. The metric sub-pages take it; the dashboard tiles do not,
because a table under every tile buries the dashboard it summarises.

Refs #636
2026-07-26 19:23:52 +02:00
Marc-André Bombeck 8dc5c230da feat(insights): pair a mood tag against next-day resting heart rate and HRV
The tag crosstab already ran a with-versus-without Welch test per structured
tag, with a D to D+1 pairing mode whose stated example was an evening tag
against next-day recovery. It only ever had one channel at that lag, and a
recovery score is a wearable composite plenty of accounts never record. The
rated-factor board next to it has tested next-day resting heart rate and HRV
since v1.14.0, but a rated factor is a continuous score, so a binary tag never
reached either vital.

Add both to the tag map. Same engine, same per-side day floors, same
Benjamini-Hochberg family, same card. The fetch filter derives from the map, so
the channels come along without a second query.

The delta read-out goes neutral to match the rated-factor board: with resting
heart rate on the board, a positive delta is worse on one row and better on the
next, and a green plus sign would claim a verdict the association cannot carry.

Refs #616
2026-07-26 19:23:52 +02:00
Marc-André Bombeck 4be01a67f0 chore(release): fold in the coach discriminant, the dependency bumps, and the notes the audit found wrong 2026-07-26 18:57:21 +02:00
Marc-André Bombeck 5a71f376d4 refactor(coach): keep the manifest's out-of-window rows terse
The preamble already says once what the two states mean and that neither is
absence; repeating it per row would have doubled a manifest that rides every
turn.
2026-07-26 18:34:42 +02:00
Marc-André Bombeck 2a9b5ba59d test(coach): prove the availability probe's SQL against Postgres
The unit suite mocks Prisma, so it proves the discriminant and nothing about the
query underneath it — a wrong column would have left every test green and every
account answering no_data_unconfirmed in production. Seeds the reported case and
the four other shapes: an empty record, another account's rows, tombstoned rows,
and a history that mixes units.

Rounds the out-of-window mean to one decimal, like every other mean the snapshot
surfaces, and annotates a failed manifest probe rather than letting it degrade in
silence.
2026-07-26 18:34:42 +02:00
Marc-André Bombeck 626365f138 test(coach): assert the removed instruction is gone, not only that the new one is there
The mutation check found both holes: an addendum test that only looked for the
new reason codes passed with the old declare-absence-and-move-on sentence still
in place, and a block-failure check whose window was wide enough to see the
neighbouring block's annotation.
2026-07-26 18:34:42 +02:00
Marc-André Bombeck 59a435d5ac test(coach): pin the window/absence discriminant to the reported case
The fixture is the report: a fortnight of glucose readings from April 2024 on an
account that displays them everywhere else. The clock is pinned so it cannot age
out of the window it was written to sit outside of.

Covers the four states an empty window read can resolve to, that a never-recorded
metric and a historical one differ in the SAME run, that a mean is withheld
across mixed units rather than fabricated, that the manifest advertises the range
instead of "absent", that both instructions branch on the reason code, and that a
miss carrying figures reaches the prose verifier's authoritative set. Adds the
medication-compliance page to the failed-read sweep and pins the narration
blocks' failure annotation.
2026-07-26 18:34:42 +02:00
Marc-André Bombeck a402eaaf15 fix(coach): tell a history outside the window apart from a metric never recorded
A Coach retrieval tool that read the window and found nothing returned
`reason: "no_data"` — the same code for two situations that have nothing in
common: the record holds nothing for that metric, and the record holds a
substantial history for it, all of it older than the window that was searched.
Nothing downstream could tell them apart, so the Coach said the honest-sounding
sentence for the wrong case, and then, having been told there was nothing there,
changed the subject. Someone with a year of imported glucose readings was told
the readings did not exist, on the same account whose glucose page was showing
them.

The window cap stays. It exists because a multi-year history does not fit a
prompt budget, and widening it would only move the same failure. What was
missing is the ability to say a different true sentence.

- `tools/availability.ts` — one grouped aggregate per miss, no rows
  materialised, resolving four states: `no_data` (probe confirmed empty),
  `outside_window` (rows exist, older than the searched window; carries the
  count, the date range in the user's own timezone, and a bounded per-series
  mean/min/max), `unavailable_in_scope` (rows exist inside the window and the
  domain still produced none — a module is off), and `no_data_unconfirmed` (the
  probe itself failed; absence was never established, so it is not asserted).
- Every empty read in the executor routes through it. A bare `no_data` can no
  longer be returned without the record having been checked.
- The DATA INVENTORY carries the same distinction, so a domain the window
  cannot reach is advertised with what it holds instead of as absent — the model
  no longer skips the fetch, and a vague follow-up keeps the subject.
- Both instructions that told the model to report absence and move on now
  branch on the reason code, and forbid answering about one metric by reporting
  another.
- The out-of-window aggregate joins the authoritative set for the prose
  verifier, so the sentence the fix exists to make sayable is not elided.
- A cross-cutting narration block that throws is now counted rather than
  vanishing into a `null` that looks like honest absence.
- The medication-compliance page tells a failed read apart from an account with
  no medications, instead of inviting someone to re-enter what it already holds.

Refs #648
2026-07-26 18:34:42 +02:00
Marc-André Bombeck 24d699f252 chore(bundle): re-measure the client budget for the surfaces this release adds 2026-07-26 18:26:45 +02:00
Marc-André Bombeck dbac3d3abd chore(release): v1.33.0 — what was silently failing 2026-07-26 18:18:28 +02:00
Marc-André Bombeck 9db7df1d4d test(db): follow the inventory repair to its new number 2026-07-26 18:02:35 +02:00
Marc-André Bombeck 500025f1e5 chore(db): number the inventory repair last in the chain 2026-07-26 18:00:20 +02:00
Marc-André Bombeck 5b38838594 test(medications): type the container fixture so the overrides compile
`Partial<typeof item>` inferred the literal types off the base fixture, so an
override could not swap the container kind or null a date. The fixture takes
an explicit row shape instead.
2026-07-26 18:00:08 +02:00
Marc-André Bombeck d91b7cb57a test(medications): cover the container kind on the import and refund paths
Hardcoding the container kind in the batch-import consumption path or in the
refund path left the whole suite green — neither had a scenario that read a
clock-less container. Both now do: a back-dated import chunk against a tablet
pack stays IN_USE with no deadline, and a refund that brings units back to a
long-opened pack reads IN_USE rather than expired. The pen counterpart of the
import case is pinned alongside.

The fake client's `updateMany` learns the `id: { in: [...] }` form the batch
claim uses; it only understood a single id before.

The no-runway arm of the low-stock evaluation now has its written-off count
asserted too.
2026-07-26 18:00:08 +02:00
Marc-André Bombeck 7b59388887 test(medications): make the repair's idempotency assertion able to fail
The comparison now includes `updated_at`, so a second application that
rewrites an already-correct row is visible even when the value it writes is
identical. Without it the assertion passed whether or not the guard that
skips untouched rows was there.
2026-07-26 18:00:08 +02:00
Marc-André Bombeck f226a907cb fix(medications): clear the pending clock deadlines the repair would have missed
The first pass of migration 0276 restored the rows the in-use clock had
already flipped, but every clock-less container still IN_USE carried the same
clock-derived `expires_at`, most of them with the deadline still ahead. The
daily expire scan would have worked through them over the following weeks and
reproduced the whole defect one row at a time.

A clock-less container's `expires_at` is now set from its printed expiry —
usually nothing, which is how it stays out of that scan. Pens and ampoules
keep theirs; their clock is real.
2026-07-26 18:00:08 +02:00
Marc-André Bombeck 48b8071a67 test(medications): pin the container-correction wire contract
Submitting the correction dialog is out of reach for the SSR component
tests, so the two behaviours that only show up on submit are pinned against
the module source: a date rides along only when it changed, and a cleared
field sends null rather than an empty string. The route side of the same
contract is covered by a real request test.
2026-07-26 18:00:08 +02:00
Marc-André Bombeck fd1464867d fix(medications): separate the two container dates and name the ampoule caveat
The row printed the opening date and the printed expiry with nothing between
them. It carries the same middle dot the unit figures use.

The docblock now says out loud what the enum cannot distinguish: AMPOULE
covers both a punctured multi-dose vial, where the post-opening window is
real, and a carton of single-use glass ampoules, where it is not. The kind is
never auto-selected, so picking it is a statement about an opened container,
and the opening date is editable when it turns out not to be.
2026-07-26 18:00:08 +02:00
Marc-André Bombeck a4ff11f10d fix(medications): surface and correct the dates that decide a container's state
The supply tab rendered a container's kind, its unit count and a state badge,
and nothing else. A row could read "Expired" with nothing on screen to say
which date caused it, and the correction dialog only ever offered the
remaining-unit count — so the opening date, which the intake consumption hook
writes without the user ever typing it, could not be taken back.

Each row now carries the two dates it has, and the correction dialog edits
both alongside the unit count. Each field rides along only when it actually
changed, so saving a stock correction never rewrites a date left untouched,
and clearing the opening date sends null, which the route reads as "this was
never opened" rather than "leave it alone". Beside the opening date the
helper text says whether the kind carries a 30-day window at all.

The headline stops contradicting itself. "0 of 0 doses remaining" next to
"+ 54 units expired" read as "nothing was ever recorded" beside "stock was
written off"; when the available pool is empty and only expired stock is
left, one line says so instead. The low-stock push follows: no usable supply
with written-off units on hand is a different errand from an empty pack, and
`expiredUnits` now reaches the payload, the notification metadata and the
wide event.
2026-07-26 18:00:07 +02:00
Marc-André Bombeck bb1e01e456 fix(medications): restore the containers the in-use clock wrongly wrote off
Migration 0276 reverses the rows that reached EXPIRED only through the
post-opening clock on a container that never had one. It moves a row back to
IN_USE when an opening date is recorded and ACTIVE when none is, and sets
`expires_at` from the printed expiry — which for these rows is usually
nothing at all, because they recorded none.

The predicate is exact rather than approximate: nothing but the printed
expiry and the in-use clock can write EXPIRED, so a clock-less container
whose printed expiry is absent or still ahead can only have got there through
the clock. Pens and ampoules keep their EXPIRED, so does anything whose
printed date genuinely lapsed, and so does a drained container. No date is
invented and no printed expiry is fabricated.

The integration test seeds the before-state, applies the migration SQL from
disk, and asserts both what moves and what stays — the testcontainer starts
empty, so the migration running at boot proves nothing on its own.
2026-07-26 18:00:07 +02:00
Marc-André Bombeck a1dbd9c831 fix(medications): apply the post-opening clock only where a container has one
A supply container went EXPIRED 30 days after its first use no matter what
kind of container it was. That window exists because puncturing an injection
pen exposes its contents and the product information caps how long the rest
stays usable; a blister of tablets has no such limit, every tablet sits in
its own sealed cavity, and pressing the first one out says nothing about the
rest. The state machine never read `containerType`, so a tablet pack opened
five weeks ago had its remaining stock written off.

The clock now keys off the container kind. PEN and AMPOULE carry it: both are
a single sealed reservoir that the first dose breaches. BLISTER, INHALER,
BOTTLE and OTHER do not: their units stay individually sealed, or the
arrangement is simply unknown, and a fixed 30 days would be a number nobody
entered. The printed expiry still applies to every kind, so an unknown
container is not an unbounded one.

`computeExpiresAt` takes the kind too, so a clock-less container with no
printed date now persists no `expiresAt` at all and the daily expire scan
never sees it.

The per-item PATCH gains a way back out: `markAsFirstUseAt` accepts null and
clears the opening date, which is the correction a container needs after the
intake hook auto-opened it from a back-dated dose.

Also drops `daysRemainingInUse`. It had no caller anywhere in the tree and
its docblock described a chip that does not exist.
2026-07-26 18:00:07 +02:00
Marc-André Bombeck c941e7e5cb test(e2e): measure the heading caption atomically
The wrap assertion gated on the section heading, but the signals card paints a
skeleton heading first and swaps it for the resolved one — so the element could
detach between the visibility check and the measurement, and the skeleton
heading carries no action slot to measure in the first place. Gate on the
caption itself and read both rects in a single evaluate.
2026-07-26 17:44:15 +02:00
Marc-André Bombeck 29188b2262 fix(insights): let a section heading's trailing caption wrap instead of widening the page
The Insights overview panned sideways at every phone width. SectionHeading
pinned its trailing action slot with shrink-0, which sizes a flex item to its
max-content width. That slot was designed for a compact control, but every
caller passes prose today — the provenance method caption, the device-score
subtitle, the sleep-quality subtitle. A sentence at max-content measures
1229 px in English and 1464 px in German, and since the row sits inside the
AuthShell <main> — whose overflow-y:auto makes overflow-x compute to auto —
that width turned the page into a horizontal scroller. min-w-0 lifts the flex
min-width:auto floor so the caption wraps to the row instead; the parent's
flex-wrap still moves the slot to its own line before anything is squeezed.

The existing width guards could not see this. Both measured
documentElement.scrollWidth, which never budges when <main> absorbs the
overflow into its own scroll area, and the insights case ran against an empty
mocked payload that fits any viewport by construction.

- Add e2e/utils/horizontal-overflow.ts: asserts the document AND the <main>
  scroll container, and names the widest offending element on failure.
- Add e2e/mobile-horizontal-overflow.spec.ts: sweeps /insights, /, /measurements
  and /medications at 360/390/393/402/430 px against populated fixtures, plus a
  canary proving the measurement can see through <main>, and a check that the
  insights fixture really populates the page.
- Rewrite the cycle spec's empty payload as a populated one and sweep every
  phone width; its /insights case moves to the new spec.
- Point mobile-density.spec.ts at the shared assertion — it carried the same
  document-only blind spot.
2026-07-26 17:44:15 +02:00
Marc-André Bombeck 78644ab77e test(ui): type the about-me mock and drop the es2018-only regex flag 2026-07-26 17:26:56 +02:00
Marc-André Bombeck f5f7d7d9d8 test(ui): a destructive control cannot ship without a confirmation path
Three legs, none of which stands alone.

`destructive-control-guard.test.ts` is the structural one. A registry
names every destructive control, what it takes, and how recoverable that
is; three detectors then make FORGETTING to register the failure mode.
The obvious guard — every trash icon must sit near a dialog — fails both
ways and fails quietly: it misses the control that destroyed the most
here, a PUT carrying two empty strings with no trash icon involved, and
it cries wolf on the trash icon that drops a row from an unsaved draft.
Both classes are live in this tree, and a guard suppressed on half its
hits teaches people to suppress it. So the judgement lives in the
registry, where it is written down and reviewable, and the mechanism only
guarantees that a new control cannot arrive unnoticed and a removed one
cannot leave a lie behind. What it cannot do is on the record in the
docblock: it cannot prove the registry complete, and it cannot prove an
entry true. Skipping a confirmation is possible, but it costs a written
argument, and the length floor is enforced.

`confirm-button.test.tsx` proves the dismissal contract once, where it
belongs: the trigger carries no click handler, the caller's callback
reaches exactly one node, the dialog action, and cancel never sees it.
An earlier draft also asserted that a closed dialog renders no confirm
control; that passed with `open` hard-coded true, because Radix portals
do not render server-side at all. It was measuring the renderer, so it
is gone rather than left green.

`about-me-clear-scope.test.tsx` covers the finding itself: the request
carries no allergies key at all, the clearing call reaches the control
only as `onConfirm`, and the allergy field stays editable so the targeted
way to clear it survives.

The existing settings suite grows to name every newly guarded control by
slot, plus the threshold override switch, whose off position performed
the same irreversible delete as the Reset button beside it.

Every assertion was mutation-checked.
2026-07-26 17:26:56 +02:00
Marc-André Bombeck 48ac8f7e2d fix(ui): a mis-tap must not be able to destroy an allergy list
Settings → AI "About me" had one Clear control wired to
`save.mutate({ aboutMe: "", allergies: "" })`. One tap, no dialog, and
the allergy line went with the note — while the toast it raised said
"About-me note cleared", which is what the control looks like it does.
The allergy line is the first thing a clinician reads and the column is
overwritten in place, so there is nothing to restore afterwards.

Scope the control rather than only gating it: it now writes `aboutMe`
alone and omits `allergies`, which the PUT leaves untouched by contract,
so the payload cannot carry the allergy line away even if the dialog is
ever dropped. Clearing allergies stays possible in the allergies field
itself, which is a deliberate act on the thing being emptied. The
confirmation on top guards the note.

The same one-tap shape ran through thirteen other controls that
overwrite a column or drop a credential with nothing to restore from.
They route through the shared confirmation now: both Codex disconnects
(personal and the instance-wide shared account), the AI server key, the
profile photo, the threshold reset — all, per metric, and the override
switch, whose off position performed the same irreversible delete from a
control that reads as a display toggle — the source-priority order, the
dashboard layout, the Insights overview, a single target range, a
remembered Coach fact, a past Coach plan, a logged cycle day and a custom
cycle symptom.

`ConfirmButton` moves to `components/ui`, since it is no longer a
settings-local helper, and gains `ConfirmDialog`: the same dialog without
a trigger, for destructive controls that are not buttons. A switch has to
stay on when the dialog its off position raised is cancelled, and a
control that could not reach for the primitive reached for nothing —
which is a fair part of why those were the ones that shipped unguarded.

Copy lands in all six locales.
2026-07-26 17:26:56 +02:00
Marc-André Bombeck 0afd217f9f docs(api): say which profile fields an empty string actually clears
The description promised that null or an empty string clears any field.
An empty string clears the free-text ones and now gender, but email is
validated as an address and 422s on it, and the display preferences are
not clearable at all. Name them rather than let a client find out.
2026-07-26 17:09:03 +02:00
Marc-André Bombeck 349dac3a07 test(profile): cover the third gender value where it used to be lost
Adds the cases that were missing everywhere except the FHIR conformance
suite: an empty string clears without taking its siblings down, OTHER
round-trips through the profile and onboarding writes, an unsupported
value is still refused and says which field it is about, the Strain
model scores an unanswered account on neither side, the dashboard bands
give OTHER the neutral fallback, the snapshot payload carries the value
instead of flattening it, and the doctor-report cover omits the row
rather than claiming a value the account never chose.

Refs #645
2026-07-26 17:09:03 +02:00
Marc-André Bombeck f74efc44aa fix(profile): let the third gender value through every surface that handles it
The profile validator has accepted MALE, FEMALE and OTHER for a long
time, but the published contract documented two, and a client written
against that contract had no way to know the third existed. The profile
PATCH is one write, so a rejected gender took every sibling field with
it - a time-format change lost to a field the person never touched.

Gender now clears on an empty string, the same contract every other
profile field documents, and an unsupported value is refused with a
message that names the field and its accepted values instead of the
validator's own prose. Onboarding validated against a narrower enum
than the profile schema and 422'd the whole submission; its form could
not record the third value at all.

The rest is the same defect in other clothes. The Strain model scored
an unanswered or OTHER account on the male Banister coefficients; it
now uses the midpoint of both published models, matching every other
sex-split formula here. The dashboard snapshot emitted null for an
account that answered OTHER, which is indistinguishable from no answer.
Three casts asserted a two-value type over a three-value column. All of
them now carry the stored value and narrow once, at the boundary of a
reference table that genuinely has no third row, through one named
helper in src/lib/profile/sex.ts.

Refs #645
2026-07-26 17:09:03 +02:00
Marc-André Bombeck 0ce510fa68 refactor(sleep): pin the in-bed legend before the per-stage loop reads it
The envelope was assigned after the loop, so the loop's IN_BED skip had
no observable effect - removing it changed nothing, which makes it a
dead branch rather than a guarantee. Assign the envelope first and let
the skip protect it.
2026-07-26 17:09:03 +02:00
Marc-André Bombeck a3b0af577f fix(sleep): count a re-exported sleep period once
A device can write the same period twice: an original envelope and a
corrected one, same start, later end, distinct external ids. Both rows
belong to the same writer, so the writer collapse keeps both, and the
per-stage totals added them. One night served a 10 h 13 min in-bed
headline next to a 20 h 17 min in-bed legend on the same screen, and
the hypnogram drew the night twice over.

Every per-stage total is now the measure of the union of that stage's
spans rather than the sum of the rows, and the same merged spans feed
the hypnogram segments and the time-asleep headline. Stages merge
independently: two stages describing one minute is a contest, and the
writer collapse is this file's mechanism for that - arbitrating again
would collapse the summary-shaped writers, which stamp every stage
total on the sleep end instant and nest by design.

stages.IN_BED is now the in-bed envelope itself, the same union
inBedMinutes reports, so the legend cannot contradict the headline and
a night whose envelope only a losing writer recorded still reports it.
2026-07-26 17:09:03 +02:00
Marc-André Bombeck 81ba711946 chore(db): number the account-scoped import migration after the ones ahead of it 2026-07-26 16:37:21 +02:00
Marc-André Bombeck b627b92809 feat(medications): rule on each export status for what it actually is
The seven documented statuses are four kinds of thing, so they get four
rulings instead of one.

Taken and Skipped state a decision about a dose and each maps onto something
already recorded here — a row with a take time, and a row marked skipped. That
second mapping was checked rather than assumed: skipped is documented as a
deliberate skip and held apart from autoMissed, which is the marker for a dose
nobody ever acted on, and the compliance re-fold counts a skipped slot in its
own column and never as taken. So a deliberate skip arrives as one.

Not Interacted, Not Logged and Unspecified are absences rather than
statements. Written as taken they would invent a dose; written as skipped they
would invent a decision, because not answering a notification is not choosing
not to take something; written as pending they would put outstanding doses
from years ago in front of someone today. Snoozed describes what happened to a
reminder. Notification Not Sent is about the software. None of the five
becomes a row.

All five are refused, but each under its own name, because "the file says
nothing about this dose" and "this row is about a reminder" are different
things and a person acts on them differently. Three reasons where there was
one, each with its own sentence in six locales.

An inactive medication's history imports and the medication stays inactive.
Preserving what happened and putting a medication back on the active list are
two acts, and only the first belongs to an import.
2026-07-26 16:37:10 +02:00
Marc-André Bombeck 36d589e11b test(medications): close the gaps the mutation pass found
Three things the tests said were covered and were not.

A column flipped from honoured to ignored moved both sides of its own
assertion, because the check read the verdict off the table it was checking.
The verdicts are now written out as literals, so changing one is a decision
somebody states twice.

The preview block was unreachable from an SSR-only test, so nothing would
have noticed it rendering through the outcome view — which would classify
what a preview *would* write as a success and put a tick on a run that never
ran. It moves into its own component that takes the verdict as a prop, the way
the CSV result view already does, and the preview and the archived-row report
are asserted directly.

The account-wide import narrows its in-progress check to account-wide jobs, so
an import running on one medication's page does not refuse it. Nothing pinned
that; now something does.

Also correct two comments about the earlier replay-key fault. The repeated
value in the reported file was not a dose quantity — it was copied from the
example this dialog itself displays. The field never carried identity, and the
submission was never at fault.
2026-07-26 16:37:10 +02:00
Marc-André Bombeck 8a36117a0c feat(medications): show what the importer does with each column
The dose-history import gets its own card in Settings, because the file
covers a whole regimen and the per-medication dialog on a medication's page
covers one medication.

Preview before writing. The dry run parses the file, matches every row
against the record, and answers with the whole verdict: rows read, rows that
would land, rows refused grouped by reason, and the medication names that
match nothing — verbatim, so they can be added and the file re-run. Nothing
is written until the second button.

The column rulings sit on the card before a file is chosen, one line each,
naming whether the column is used, reported, or not read and why. A column
read and dropped in silence is indistinguishable from a column honoured, and
that is how somebody comes to believe an importer looked at something it
never did.

Outcomes render through the same primitive the other three import surfaces
use, so a run that wrote nothing cannot carry a success tick. The preview
borrows the reason lines and deliberately not the outcome sentence: a preview
has written nothing, and classifying it by the counts it would produce would
put a tick on a run that never ran.
2026-07-26 16:37:10 +02:00
Marc-André Bombeck 34a220421c test(medications): prove the reported export lands, row for row
The fixture is the export a self-hoster attached, de-identified as it
arrived: 3,395 rows, sixteen medications, three and a half years, two UTC
offsets, three statuses, two units, blank cells in four columns. A
hand-built fixture would have carried one status and one offset and proved
nothing about the file that prompted the work.

The integration pass runs it through the real derivation into a real
Postgres, because the claim is about rows: 3,387 land, 8 are refused because
the file records no decision about them, and imported plus refused equals
every row read. 2,799 of the doses were taken after they were due, which is
the fact that survives only because the scheduled slot and the take are two
columns.

Structural cover so the verdicts cannot rot: every column of both shapes has
exactly one ruling, and every parse-time refusal is spelled the same as a job
skip reason — a reason that exists in only one vocabulary renders as "reason
not recorded", which is a refusal nobody can act on.
2026-07-26 16:37:10 +02:00
Marc-André Bombeck 11e6402b5a feat(medications): read a dose-history export directly
The intake importer took a hand-written list of dates and times for one
medication. A self-hoster with a ten-column export covering sixteen
medications and years of history was left translating it by hand, which is
where the scheduled time and the time a dose was actually taken get
flattened into one — the distinction the record wants most.

The export keeps them as two columns, so read the file's own shape.
`MedicationIntakeEvent` already models that pair as `scheduledFor` +
`takenAt`; an imported dose now populates both instead of anchoring itself
on the take. `Skipped` becomes a deliberate skip with no take time, which is
how every other skip path writes it. A dose whose actual amount differs from
the amount the schedule expected carries the difference as a per-dose
override; an unremarkable amount stays absent, which is what absent means
there.

Timestamps carry their own UTC offset (`+1030` and `+0930` on adjacent rows
of one file) and it is read literally: the epoch is computed from the stated
offset rather than handed to the platform's lenient date parser, and a value
without an offset is refused instead of assumed to be the server's zone.

An export covers a whole regimen, so the job is account-wide and each
payload entry names its own medication — migration 0276 widens
`medication_intake_import_jobs.medication_id` to nullable for it. The
per-medication route is unchanged and keeps naming its medication on the job
row. A medication is never created from an import file; unmatched names come
back verbatim so they can be added properly and the file re-run.

Every column gets a verdict, and `?dryRun=1` returns the whole one before
anything is written.
2026-07-26 16:37:10 +02:00
Marc-André Bombeck 69d9a164f7 docs: realign the notes table after the count changed width 2026-07-26 14:54:18 +02:00
Marc-André Bombeck 3967f886f8 docs: correct the model count the contributor notes carry 2026-07-26 14:52:37 +02:00
Marc-André Bombeck f0c5b5ae94 fix(medications): identify an imported dose by when it was taken
The intake import built its replay key from the row's optional `zaehler`
field. That reads as a per-row counter — the documented example showed one —
but nothing required it to be unique and nothing checked. A reported export
carried the dose quantity there instead: 1 tablet, on all 28 rows. Every entry
queued under the same key, one dose reached the ledger, and the other 27 came
back reported as duplicates of a dose they had nothing to do with.

The key is the instant now, and `zaehler` is out of the contract rather than
accepted-and-ignored. Nothing is lost by dropping it: the live-row unique on
`(user_id, medication_id, scheduled_for, source)` already makes an imported
dose unique per instant, so a re-import of a file first imported under the old
key still dedups.

The telling is the other half. Two different facts were counted as one number:
entries of a submission that collide with each other, and doses the ledger
already holds. They are separate reasons now, grouped with a count, and the
dialog renders them through the same classifier and the same outcome line as
the CSV and JSON import cards — so an import that wrote nothing can no longer
answer in success tone. That rule shipped for the settings import panel and
this surface was not part of it; the guard now lists every import surface by
path, so a fourth one has to be added deliberately. `skippedInvalid` is gone:
it was hardcoded to zero on every path and reported a capability the importer
never had.

Alongside it, the other half of the environment report — a scheduled job that
fails every run has to say so. `recordError()` increments one unnamed counter,
so the admin status page could show a number and not which queue produced it,
and the module's own screen showed a zero day count with nothing to
distinguish "nothing stored yet" from "every run failed". pg-boss has held the
answer the whole time: a job that exhausts its retries keeps its queue name,
its error and its timestamp. The admin status now names the failing queues
with counts and the last error, the environment overview reports its own
account's failed runs, and neither needed a change at any of the thirty
`recordError()` call sites — a visibility fix that has to be remembered per
call site is the same failure one level up.

Refs #650, #646
2026-07-26 14:37:57 +02:00
Marc-André Bombeck a8d798442f fix(environment): name the enum type the database actually has
Migration 0217 created the type as `environment_location_source`; the
datamodel declared `EnvironmentLocationSource` with no `@@map`, so the client
compiled every write against `public."EnvironmentLocationSource"`. Postgres
answered 42704, the upsert rolled the transaction back, and the module sat at
"0 days recorded" from the day it shipped — one enum out of forty-nine, and the
only one without a map.

No migration follows. `prisma migrate diff` against a database built from the
committed migrations reports the enum rename and the `environment_contexts.source`
column rebuild before the map and nothing after it: the database was never the
side that drifted, and renaming a live type to match a datamodel typo would be
the wrong direction.

Two guards, because the two questions are different. The unit guard replays
CREATE TYPE / ALTER TYPE RENAME / DROP TYPE across the migration set and holds
every enum to a type that survives it. The integration guard compares all three
name classes Prisma emits — enum type, table, column — against a real migrated
Postgres, which is the only place a name mismatch is observable at all: it
survives generation, typecheck, and every mocked test untouched.

Refs #646
2026-07-26 14:37:57 +02:00
Marc-André Bombeck 054eb51e96 chore(db): number the failure-streak migration behind the ones already released 2026-07-26 14:19:26 +02:00
Marc-André Bombeck e0ae866f32 test(whoop): assert the per-resource failure carries its leg 2026-07-26 14:19:13 +02:00
Marc-André Bombeck 4e3b029c38 test(integrations): pin the escalation ladder, leg-scoped clearing, and what the card says
Mutation-checked against the production paths they cover: the escalation
verdict, the leg guard in `recordSyncSuccess`, the streak anchor the verdict
dates itself by, the status envelope threading it, and the pill's relative
timestamp.

Also give the four legacy per-provider status routes the verdict the
consolidated envelope already carries. They answered `connected: true` plus a
`state` string and nothing else, so a client reading one of them saw a live
connection over a pipe that had delivered nothing for weeks.
2026-07-26 14:19:13 +02:00
Marc-André Bombeck 527dd6c1fa fix(integrations): escalate a long transient failure streak, and scope a success to its own leg
A sync failure the classifier bucketed as `transient` had no exit. The park
test read `kind === "persistent"` and the persistent streak anchor was only
ever stamped by a persistent failure, so a streak could run for a thousand
attempts with the persistent counter at zero, the state frozen at
`error_transient`, and no ladder it could climb. `transient` is a hypothesis
about retryability, and the classifier defaults every timeout, 429, 5xx and
unrecognised response to it precisely because the wire cannot tell a reboot
from a permanent break. That default is right for one attempt and wrong for a
thousand: an unbroken streak retried hourly for a full day has refuted it by
experiment. Record it as `persistent` from there, which feeds the park ladder
that already exists — 48 h of unbroken failure end to end before an
integration stops attempting.

The counters could not accumulate anyway. `IntegrationStatus` is keyed
(user, integration) while Withings runs four legs on four crons and WHOOP four
resources on four more, and a success cleared the error, every bucket, the
streak anchor and the state whatever leg had recorded them. The ECG tick at
:41 wiped what the sleep tick recorded at :15 — including a reauth park — so
the strike ladder was reset several times an hour and the card painted a green
"connected · 12 minutes ago" over a data class that had stopped arriving. A
success now clears only what it is entitled to clear.

Two columns carry it: `failing_since_at` dates the streak, `failing_leg` names
its owner. The migration backfills the anchor for rows already in an error
state so a connection broken for weeks escalates on its next attempt rather
than restarting its clock.
2026-07-26 14:19:13 +02:00
Marc-André Bombeck 84e38ab110 chore(db): number the retention indexes for the slot they actually take 2026-07-26 14:04:56 +02:00
Marc-André Bombeck fb9a4ee616 test(sync): follow the tombstone purge's batched outcome shape 2026-07-26 14:04:44 +02:00
Marc-André Bombeck dccd2f320b test(settings): prove the wipe against real Postgres, and state what deletion means
The defect is about rows that survive, so a mocked client proves nothing. The
new integration test seeds a row into every table in the schema, driven by
information_schema and pg_constraint rather than a fixture, runs the wipe and
asks the database what is left. A table added later is seeded and asserted
without anyone remembering to.

It holds all four halves: nothing in the wipe list survives, everything in the
exemption list does, everything reached only by a database cascade is gone,
and the account row comes back reset but able to sign in. A second file pins
the allergy and checkup-reminder deletes actually removing their row.

The reproductive-health note gains a Deletion section. It taught people to
reason carefully about a host being imaged and never said what pressing
delete does; now it says which of the three deletions removes the row and
which only hides it.
2026-07-26 14:04:44 +02:00
Marc-André Bombeck 5e66ebaa54 fix(jobs): let the retention purges finish, and let them fail loudly
The nightly tombstone purge asked for `deleted_at IS NOT NULL AND deleted_at
< cutoff` and the audit-log purge asked for `created_at < cutoff`. Neither
predicate had an index behind it: every deleted_at index in the schema is
partial on IS NULL, the live-rows predicate, and every audit_logs index leads
with user_id or action. Both purges planned a sequential scan and then paid
index maintenance per removed row, in one unbounded statement, on a
connection carrying statement_timeout=60000. Past a certain backlog the
statement aborts, the transaction rolls back, nothing is purged, and the next
night runs the identical statement against the same rows. The mechanism that
gives deletion a horizon could not close on the instances that needed it.

Migration 0277 adds the indexes the predicates ask for; the three tombstone
ones are partial on IS NOT NULL, so on a healthy instance they cover only the
tombstones and shrink as the purge drains. The purges now walk the backlog in
batches of 5 000 by primary key, capped at 200 000 rows a run.

Both handlers used to swallow every error into a wide-event warning and
return, so pg-boss recorded a successful job either way: a purge failing
every night looked exactly like a purge with nothing to do. They now rethrow,
and a run that stops at the cap records that a backlog remains.
2026-07-26 14:04:44 +02:00
Marc-André Bombeck 0919d8b962 fix(settings): make Delete All Data delete what it says
The wipe deleted thirteen tables against a schema that carries more than
eighty user-scoped ones. Mental-health assessments, lab results, the
menstrual record, allergies, family history, the document vault, every AI
conversation and live clinician share links all survived a confirmation
that promised to permanently delete all health data. So did
MeasurementRollup, the per-day reconstruction of the measurements the same
transaction had just removed.

The list now lives in src/lib/data-wipe/wipe-plan.ts next to its own
exemption list, and a structural guard enumerates schema.prisma and fails
when a model belongs to neither. Adding a model can no longer widen the set
of things that survive a wipe by default. The same guard covers the User
row's own columns, which had drifted the same way.

The boundary is explicit: everything the account owns goes, except what is
needed to sign in again. The confirmation and the toast say so before the
person types the confirmation rather than after.

Checkup reminders and allergies promised permanence over a soft delete with
no restore path; both now hard-delete, so the dialog is true. The lab
biomarker dialog was checked and is already accurate — it maps to the
hard-deleting /api/biomarkers/[id] route, not to the soft-deleting lab
result route.
2026-07-26 14:04:44 +02:00
Marc-André Bombeck 601374313c docs(changelog): record the rounding fix in v1.32.39 and credit the reporter 2026-07-26 10:08:22 +02:00
Marc-André Bombeck 417288d3f5 Merge pull request #643 from MBombeck/release/v1.32.39
v1.32.39 — the doctor report carries only what was chosen
2026-07-26 01:25:15 +02:00
Marc-André Bombeck e67bb25338 chore(release): v1.32.39 — the doctor report carries only what was chosen 2026-07-26 01:12:03 +02:00
Marc-André Bombeck 84d78d2926 fix(units): round a converted value where the conversion happens
Every entry in the display-transform registry declares the decimals its
unit is read at. That number was carried to the render layer and read by
exactly one screen out of all the ones that show a converted value, so
everywhere else the raw product of a kilogram and 2.20462262185 reached
the surface. Pounds came out with a dozen digits it never needed.

Move the rounding into `applyDisplayTransform` and
`applyDisplayTransformDelta`, so a surface gets a correctly-scaled number
whether or not it remembers to ask. Patching the weight cell would have
left the same trap behind inches, Fahrenheit, miles per hour and miles.

Two boundaries stay exempt and are documented as such. A transform that
does not rescale is the exact identity and is never rounded, so a stored
78.45 kg keeps rendering as 78.45 on the metric branch and every
untransformed metric is byte-identical. `invertDisplayTransform` — the
entry boundary — keeps full precision, because a value rounded on the way
out and inverted on the way back would drift the stored canonical number
a little further on every save; the entry surfaces quantise to their own
two-decimal canonical dialect instead.

`applyDisplayTransformUnrounded` is the one documented escape hatch, for
arithmetic whose result is not rendered. The target adapter needs it: its
guardrails round inward, and rounding the 30 kg floor to the nearest
tenth of a pound first gives an edge that inverts to 29.98 kg and the
server rejects it.

The weight page and the HealthKit scaffold were folding the factor into
their stat strips by hand. Both now convert through the helper, so the
raw multiplier is only ever handed to the chart, which owns its own
series and formatting.

Formatting is unchanged. Precision is fixed at the conversion and the
surfaces keep the formatter they already used, so switching preference
changes the unit and nothing else about how the number looks.

Refs #627
2026-07-26 00:46:03 +02:00
Marc-André Bombeck 708846f72b chore(db): renumber the report-selection migration behind the glucose-context repair 2026-07-26 00:46:03 +02:00
Marc-André Bombeck bc1633db40 fix(export): make the standard report a click, not a preselection
The scope picker arrived with the standard doctor's report already ticked,
so someone who opened the panel and pressed Generate sent material they
never chose. Nothing is selected now until a person selects it.

The template keeps its definition and loses its automatic application: it
sits on a button at the top of the picker, named, on both surfaces that
mount the picker, and it stays there so the standard set can be restored
after experimenting. One click still gets a complete doctor's report.

Generate and the share-link create refuse an empty scope, with the scope
line beside them saying what to do rather than reporting a failure after
the press; the line also describes the control, so the disabled state is
not silent for a screen reader.
2026-07-26 00:46:03 +02:00
Marc-André Bombeck 59acbed5ab fix(i18n): deliver the locale catalog before the client bundle takes its first render
The root layout shipped the active locale's catalog as `<script defer>` and
counted on it executing ahead of Next's hydration scripts. It does not: Next
emits its own chunks as `async` tags AHEAD of anything the layout renders into
`<head>`, and an `async` script runs the moment its fetch settles. So the
370 KB catalog was racing the app bundle, and on a cold load it lost about a
third of the time — `load-locale` initialised with an empty cache, every `t()`
in the hydration render returned its raw key against the server's real text,
and React tore the tree down with a text hydration mismatch. The user saw a
frame of `nav.skipToContent` before the provider's backfill landed.

Register the catalog through `next/script` at `beforeInteractive` instead.
Next's own bootstrap awaits those scripts before requiring a single app module,
and pairs the registration with a `<link rel=preload>` so the download still
starts with the document rather than after it. The CSP nonce rides along, so
the posture is unchanged.

Fold the boot global into the cache lazily rather than snapshotting it at
module evaluation. The snapshot made correctness depend on an ordering the
module cannot see; reading on demand reduces the invariant to something local
and testable — whatever the boot script assigns is visible to the first `t()`
that asks, whenever it lands.

Measured on the dashboard render spec, 30 repeats per project against a
production build: 11 of 30 failed on desktop before, 0 of 60 after.
2026-07-26 00:46:03 +02:00
Marc-André Bombeck 7b56f9ddbd test(export): stop the Gesundheitsakte specs racing hydration
Everything on that page is server-rendered, so a row is visible long before
the client boundary that owns it is live. A chevron click landed on inert
markup about one run in four, and a `boundingBox()` occasionally came back
null because the node had just been replaced.

The page is one client boundary carrying the export panel and the sharing
card, so the sharing card's own query is a sound signal that the surface is
wired. One helper opens the page and waits for it; every case on that route
goes through it, including the disclosure case that was already doing this
by hand.
2026-07-26 00:46:03 +02:00
Marc-André Bombeck e9b7d5f885 test(export): point the export specs at the surface they mean
Three of the picker assertions resolved to two elements and failed strict
mode; they now name the export mount, and a new case pins that each mount
carries exactly one of every id the picker renders.

The included-data disclosure case asserted a collapsed first render. The
panel opens on a first run on purpose — that is the only place the standard
template is applied, and a template behind a closed disclosure is opt-out
wearing a hat — so the case now asserts the contract that replaced it: open
on first render, and still a disclosure, closing and reopening under the
same control.
2026-07-26 00:45:08 +02:00
Marc-André Bombeck fcd3dd2d07 fix(export): make each scope-picker mount addressable on its own
Settings → Gesundheitsakte mounts the one scope picker twice: the export
panel at the top, the share-link create form under `#sharing`. Sharing the
component is deliberate, but it also meant every test id inside it resolved
to two elements on that page, so nothing in the picker could be addressed
without guessing which one you got.

The picker now takes a required `surface` and every id under it ends with
that surface — `report-group-row-vitals-export` and
`report-group-row-vitals-share` are two controls and read as two controls.
The summary line beside each picker follows the same rule. A third mount
picks a value and stays addressable by construction.
2026-07-26 00:45:08 +02:00
Marc-André Bombeck 2f92c0577b chore(db): renumber the report-selection migration after 0273 landed on main 2026-07-26 00:45:08 +02:00
Marc-André Bombeck ffe99caccd fix(fhir): stop advertising a credential no client can obtain
The capability descriptor named `fhir:read` as the scope a narrow token needs
for the REST face. No code path mints a token carrying it — every mint grants a
wildcard, the medication ingest scope, or an MCP scope — so the face is
reachable by the owner's cookie session or a wildcard device token and by
nothing else. Advertising an unobtainable credential invites a client to go
looking for one. The constant stays where it belongs, as the fail-closed check
each route makes; the descriptor now reports the fact instead.

End-to-end coverage for the scope picker: the first run opens it expanded with
the fenced tier rendered and no group control over it, a group row clears the
44 px floor at 390 px, and a leaf grid opens from its chevron. All on stable
test ids rather than text a breakpoint can hide.
2026-07-26 00:45:08 +02:00
Marc-André Bombeck f12290532f fix(sharing): let the owner replace a link the upgrade closed, and count only what is shown
Migration 0273 revokes every live record share link minted before the selection
model, because their frozen scope was "everything except mood and cycle" — a
scope nobody chose. The owner has to see why the link stopped and be able to
replace it without retyping what they already decided, or a deliberate closure
reads as a fault. The list now names that state, says why in one sentence, and
offers a replacement that carries the label, the window and the lifetime
across, leaving exactly the decision that link never carried.

Writing the test for it found a defect in the picker. The group count and the
group checkbox read the catalogue's group rather than the leaves the surface
actually renders, so on the share-link form — where the insurance leaf is
hidden because the server refuses it — the identity row read "1/2" and drew
itself half-selected over a leaf the owner could neither see nor reach, and its
checkbox would have tried to add it. The three helpers take the rendered leaves
now, so a control cannot describe more than it offers.
2026-07-26 00:45:08 +02:00
Marc-André Bombeck 9e68ff525f feat(share): serve the record a link already implies, and say when there is none
Two downloads under the token — the FHIR document Bundle and the clinical PDF —
behind the same unlock cookie the document route enforces, serving exactly the
link's frozen selection. That is what the removed FHIR toggle promised, in the
form a practice actually uses: open the link, unlock it, save the file. No new
credential type, no new scope, no mint path that does not exist.

The gate is the document route's, step for step: the same live check, the same
token-scoped cookie, the same blunt 404 for every miss class so a probe cannot
tell "no such link" from "revoked" from "documents-only". It differs in one
number, because generating a bundle is far more expensive than serving a stored
blob and must not share a budget with it. Each download writes its own audit
row. The scope cannot exceed the page's: both resolve the same frozen leaf list
through the same resolver, and the insurance leaf is refused at create, so the
insurance number cannot reach this path at all.

The MCP doctor-visit summary stops claiming presence it cannot support. An
account whose owner never saved a report selection resolves to the empty one on
that surface, and an empty scope produces an empty payload — which now reads as
{ present: false, reason }, the same sentinel the other reads use, instead of a
record with every section quietly missing.

Mutation-checked: removing the unlock check, the rate limit, the documents-only
guard or the audit row reddens its own case; pointing the bucket at the document
route's key reddens the bucket assertion; making presence unconditional reddens
the absence case.

The saved profile's shape moves out of the module that reads the row. The panel
is a client component and parses the blob from the /me payload; importing the
reader dragged the database client into the browser bundle, which typecheck and
the unit suite both pass and only `next build` catches.
2026-07-26 00:45:08 +02:00
Marc-André Bombeck f42934487d test(export): prove the scope holds at the artefact, not just at the switch
Five layers, because the defect had five places to hide.

The catalogue guard checks what the compiler cannot state: every leaf resolves
a label a human can read, every group resolves one, the fenced tier stays last,
and the shipped template intersects the fence at nothing.

The selection guard pins the three minting functions against each other. A
stored blob this build cannot read resolves to the empty selection, never to a
default set — that is what makes the revoked legacy links revocable rather than
silently re-scoped. A request naming an id the server does not know is refused
by name rather than dropped, because dropping it would narrow a scope the
caller did express.

The egress guard freezes the set of aggregator callers with the source of each
one's selection written down. The required brand-typed parameter already stops
a new path compiling without an answer; this stops one being added without a
human writing the answer down, which is the moment a reviewer asks the
question.

The behavioural sweep aggregates every leaf in and out against a fixture that
carries a row for each, and asserts the payload differs. It is parametrised
over the catalogue, so a leaf added later is covered the day it is added. Its
positive control asserts the all-on payload is genuinely populated first, so a
"no difference" result means the gate is inert and not that the account is
empty. Beside it: an unmapped measurement type is excluded rather than passed
through, no table behind an unchosen leaf is queried at all, and an empty
selection produces an empty record.

The artefact layer runs the real route and reads what comes out — the FHIR
bundle, the PDF text and the zip's contents — with a positive control beside
every exclusion.

Mutation-checked: making the gate admit everything reddens the sweep for every
leaf; passing an unmapped type through reddens the fail-closed case; dropping
the mood query's condition reddens the zero-read case; returning the BMI
unconditionally reddens the sweep naming BODY_MASS_INDEX; adding a fenced leaf
to the template reddens the purity guard; removing the panel's seeding block
reddens the first-run and repeat-run cases; dropping the insurance refusal
reddens the share-link case; adding a caller outside the frozen list reddens
the egress guard naming the file.

The panel suite grew past the practice name it was written for and is renamed
for what it now covers: the three states, the fenced tier having no group
control, and the scope line naming its sensitive inclusions.

Integration coverage moves to the leaf model, including the case this release
exists for: an account with data in every domain and a caller that named
nothing gets an empty record.

The OpenAPI contract catches up with the routes — the required selection, the
refused keys, the share summary's new fields, the moved `Patient/$everything`,
the Coverage search and the five typed reads.
2026-07-26 00:45:08 +02:00
Marc-André Bombeck 582db8a538 feat(export): one grouped scope picker, on both surfaces that choose a scope
The export panel rendered twenty flat switches behind a collapsed disclosure,
and the share-link form rendered none at all — it posted whatever the create
schema defaulted to. Both now mount the same picker: twelve named groups, a
count on each, the leaf grid one chevron away, and a fenced tier below a
separator.

The fence is mechanical, not a label. The sensitive tier has no group
checkbox, so no single control anywhere in the picker can turn on more than
one of mood, cycle, family history, PHQ-9, GAD-7, WHO-5 or SCI at a time.
There is no select-all. The shipped template contains none of them. And the
scope line under the button restates every sensitive inclusion by name rather
than folding it into a count, because a number cannot be checked at a glance
and "incl. Mood, PHQ-9" can.

First run opens expanded with the named standard template applied and the
fenced tier visibly empty, so the user presses Generate having seen twelve
named groups. Every later run opens collapsed carrying their own last
selection, which makes the waiting-room path two interactions.

Only leaves are ever stored. A group's checkbox is derived from its leaves, so
the two cannot disagree, and switching a group off and back on inside one
session restores the pattern instead of flooding it.

The cycle export card is deleted. It posted empty groups under a docblock
claiming every other section was off, and empty groups resolved to the
defaults — so a "cycle only" download carried weight, blood pressure, pulse,
oxygen saturation, body fat, body composition, the medication list, adherence,
labs, allergies and family history. Cycle is a fenced leaf in the one picker
now, reachable for the PDF as well as for FHIR, which is more than the card
offered.

The panel gains a 30-day preset and a custom window, and its error line says
what went wrong instead of interpolating a status code.

The Checkbox primitive learns the indeterminate state it already had the Radix
support for. A partly-selected group drawn as unchecked would say "none of
this is included", which on this surface is a false statement about what
leaves the instance.
2026-07-26 00:45:08 +02:00
Marc-André Bombeck 5e490f31ec feat(export): make the doctor report carry only what was chosen
The report resolved an absent key to "included". A caller that sent no
sections object, or an empty group inside one, received the full record at
server-chosen defaults: roughly fifty metrics plus the medication list, labs,
allergies and family history. The web panel sent every key explicitly so it
looked fine from the browser, but the share link, the FHIR REST face, the MCP
doctor-visit resource and the cycle-only export card all went through paths
where absence meant yes. No arrangement of booleans fixes a default that means
yes, so the shape goes.

A selection is now an inclusion list of leaf ids. A leaf is the smallest thing
that can be released or withheld: every measurement type, and thirteen
structured sections — identity, insurer, the glucose panel, labs, the drug
list, the administration ledger, adherence, the GLP-1 block, allergies,
illness episodes, family history, mood, cycle. Membership is inclusion,
absence is exclusion, and there is no defaults constant anywhere to drift.

Three locks keep a metric added later off a report chosen earlier. A stored
selection is a list of what was chosen, so a leaf that did not exist then is
not in it. The catalogue is keyed over the measurement enum, so adding a
member without deciding its group fails typecheck in the same commit. And the
aggregator's filter inverts: a type the catalogue does not know is excluded and
annotated, where it used to be passed through.

The aggregator's third parameter is now required and brand-typed, which turned
the three surfaces that had no scope at all into compile errors. Each one had
to name a source, and each now replays the owner's saved selection — the same
object reviewed in Settings. An account that never saved one gets nothing
there rather than a guess.

Identity and insurance become selectable, and a share link refuses the
insurance leaf outright. The share view never decrypted the number; that is
structural now instead of incidental. The KVNR is not even read unless the
leaf was chosen.

Two surfaces gain a record they always implied. A share link serves its frozen
selection as a FHIR bundle and as the PDF, behind the same unlock cookie the
document route uses, with its own rate bucket, its own audit row and the same
blunt 404 for every miss. That is what the removed FHIR toggle promised, in
the form a practice actually uses.

The AI summary leaves the report. Its text came from the cached briefing with
no relation to the selection, so its scope could not be proven, and prose whose
scope cannot be proven has no place on a document whose whole point is a proven
scope.

The PDF table stops being a nine-type whitelist. It prints every selected leaf
that has data, sub-headed by group, in the same order the panel shows — so
selecting resting heart rate no longer means seeing it in the bundle and not in
the PDF. The clinician view is grouped the same way and reads real labels
instead of "Phq9 score".

Migration 0273 adds the saved-selection column, drops the orphaned prefs column
and the two retired share-link scope columns, and revokes every live record
share link minted before this model. Their frozen scope was "everything except
mood and cycle", which is a scope nobody chose; narrowing it would still be a
scope nobody chose. Documents-only shares are untouched. The sharing list shows
each closed link in a pick-again state with its label, window and expiry kept.

The retired allowFhirApi and resourceTypes stay on the create and list
responses as the constant false and empty list. The request refuses them. They
come off the response once the native client confirms it does not decode them.

Six files are split at seams this change already touched: the aggregator into
its gate, its dense-bucket query, its series, its glucose panel, its medication
ledger and its clinical records; the export route's three emitters into their
own module; the clinician view into its sections, its document list and its
downloads.
2026-07-26 00:45:08 +02:00
Marc-André Bombeck f90219784b test(export): pin the selection controls against going inert again
Two layers, because the defect had two halves and each needs its own kind of
proof.

The structural guard walks the panel's control shape, folds every control
through the wire schema and the resolver the route uses, and fails when
flipping one changes nothing in the resolved selection. It also checks the
panel renders a row for every control it declares, and that every resolved
flag is read downstream, either by gating measurement types or by name in the
aggregator. That is the link nobody had written: the dead switches were dead
for several releases and no test connected "the panel renders this" to "the
export honours this".

The behavioural half runs the real export route and reads what comes out. The
previously-dead measurement switches are asserted against the FHIR bundle and
the PDF table, with a positive control beside each exclusion so nothing passes
on an empty fixture. The screening totals are asserted absent for a caller
that names other sections and for a caller that sends no sections at all, then
present when asked for by name. Allergies and family history are asserted
absent from the bundle and never read from the database, and the package case
opens the zip and checks the bundle inside it agrees with the PDF beside it.

Mutation-checked: reverting the allergy gate, the medication-list gate, the
screener entries or the measurement entries turns the matching cases red and
leaves the rest green; removing a control's fold entry or its aggregator entry
turns the guard red with the control named.

The remaining changes update fixtures the new shapes touch: prefs objects now
resolve the added flags from the defaults, and the share-link fixtures drop
the retired FHIR scope, with a case pinning that the create route now refuses
those keys instead of ignoring them.
2026-07-26 00:45:08 +02:00
Marc-André Bombeck 68b9903966 fix(export): make the doctor-report selection controls tell the truth
The export panel rendered twenty data switches and eight of them changed
nothing. The grouped selection folded down to twelve flat flags and dropped
oxygen saturation, body fat, body composition, resting heart rate, HRV, VO2
max, steps and distance on the way, so a user who switched any of them off
still exported the readings in the PDF, in the FHIR bundle, in the package,
through a clinician share link and over MCP. Each one now resolves to a flag
the aggregator reads, and the rows are excluded at the query rather than
fetched and dropped.

Three more controls were untruthful in their own way:

- The medications switch documented itself as excluding all medication data
  and excluded the adherence figures only. The drug list and the per-dose
  administration ledger rode along regardless. Both follow the switch now;
  the rows are still read, because compliance and the GLP-1 block are computed
  from them, and they no longer leave the aggregator.
- Allergies and family history were gated for the PDF and fetched
  unconditionally for the FHIR bundle, so a package could carry a compliant
  PDF and a violating bundle in the same zip. The reads are gated, and a
  deselected section is not read at all.
- The PHQ-9, GAD-7, WHO-5 and SCI screening totals had no per-report control.
  They rode the mental-health module alone, which has been default-on since
  v1.29.1, so depression and anxiety screeners reached FHIR bundles, share
  links and the MCP doctor-visit resource on accounts that never chose to send
  them. They get their own section key, defaulting off, with the module gate
  kept as the second lock, and the stale privacy-by-default comment that
  argued otherwise is corrected.

The share link's FHIR promise is removed rather than implemented. The create
form offered a toggle and a resource-type picker, the values were persisted
and badged on the finished link, and the API description called the toggle a
REST reachability switch, while no route has ever answered a share token with
FHIR or any other machine-readable format. Serving one is a feature with real
security surface and does not belong in a correctness fix. The toggle, the
picker, the badge and both claims are gone; the create schema rejects the two
keys instead of accepting and ignoring them; the columns stay, unread, so no
data is destroyed to undo a promise.

Two files are split along seams this change already touched. The
measurement-type gating maps and the filter that reads them leave the
aggregator for their own module, so the query gate and the output gate cannot
disagree; the export panel's control shape and the selection it builds leave
the component, so the selection contract can be extended without the
rendering. Three accepted-and-discarded wire keys (activity energy, GLP-1,
side effects) are dropped: nothing rendered them and nothing read them.
2026-07-26 00:45:08 +02:00
Marc-André Bombeck b32d1be607 Merge pull request #642 from MBombeck/fix/csv-import-silent-skip
v1.32.38 — accept a blood-glucose reading that carries no context
2026-07-25 23:16:22 +02:00
Marc-André Bombeck a23ee61184 test(e2e): route cleanup happens by construction, not per spec
A spec that arms `page.route(...)` in a `beforeEach` and never drops the
handler races its own teardown. The context closes, an async handler is
still awaiting, its next call rejects with "Test ended.", and Playwright
attributes that to the test that had just passed. With `retries: 2` plus
`failOnFlakyTests`, which spec bites is a matter of timing.

`e2e/setup/test.ts` re-exports Playwright's `test` extended with an auto
fixture that unroutes every page and the context after each test, and
clears the page event listeners a spec subscribes to; `expect` rides
along so a spec needs one import. Auto means a spec author cannot forget
it and does not have to know it exists. Every spec now takes `test` from
there, and `e2e-route-cleanup-guard.test.ts` keeps the import boundary
closed with an allowlist that carries its reason.

The per-file `afterEach` on the cycle spec goes away — leaving both would
read as not trusting the fixture.

Not covered: a page inside a context a spec builds itself. Only
`share-documents.spec.ts` does that, and it owns those contexts.
2026-07-25 23:02:03 +02:00
Marc-André Bombeck c506b72002 test(e2e): drop the cycle route handlers before teardown closes the page 2026-07-25 22:34:03 +02:00
Marc-André Bombeck ec49157e85 chore(release): v1.32.38 — accept a blood-glucose reading that carries no context 2026-07-25 22:18:03 +02:00
Marc-André Bombeck 208a2248cd test(import): cover the HealthKit glucose shape against the relaxed constraint
A HealthKit blood-glucose sample carries no context either — the mapping has
no field for one — so the batch ingest was building the same row the old
CHECK refused. Fold that source into the case that pins the contextless
shape.

Refs #640
2026-07-25 21:57:06 +02:00
Marc-André Bombeck e43e758c8d docs(import): put a contextless sensor reading in the downloadable example
The example CSV is the file people copy. It showed a glucose row with a
FASTING context and nothing else, which is the shape a meter produces, not
the shape a continuous sensor produces. It now carries both.

Refs #640
2026-07-25 21:56:10 +02:00
Marc-André Bombeck 4e95bd28f8 fix(import): stop the Apple Health card ticking an archive that landed nothing
Same shape as the CSV and JSON cards: reaching the done state rendered the
success tick whether or not a single row was upserted. The archive's count
picks the tone now, and an archive that wrote nothing says so.

The clinical-record count stays out of that decision. Skipping
electrocardiograms and lab documents is a documented, deliberate exclusion,
not a refused row, so it must not read as a warning.

Refs #640
2026-07-25 21:45:10 +02:00
Marc-André Bombeck 0c9832eb8d fix(import): accept a blood-glucose reading that carries no context
A CSV import of a continuous-sensor export wrote nothing and rendered a
green success state. Three faults compounded, and the reporter took all
three apart before filing.

The parser refused every BLOOD_GLUCOSE row with a blank glucoseContext.
Behind it, the CHECK constraint from migration 0021 read both ways at
once: a glucose row MUST carry a context, every other type MUST NOT. The
second half is right and stays. The first was written when the only
glucose writer was the entry form, where a person picks fasting or
post-meal at the moment of the reading. Every machine writer that
arrived since has no such answer per sample, so the Nightscout pull, the
MCP log tool, the Telegram capture and both bulk importers were all
building rows the database then refused.

A missing context now stores as NULL, never as a fifth enum member.
Every per-context reader selects by equality against the four members,
so the reading stays out of the doctor report's fasting rows, the
matching FHIR observations and the per-context targets, while the chart,
the all-time summary and the clinical panel keep it in full. Hand entry
still asks for a context, because at that moment you know it.

The import cards now read the counts rather than the status code.
Nothing written is a failure, a mixed file is a warning, and the refused
rows are grouped by reason with a count instead of fifty identical
lines, with the per-line detail one disclosure away. Both cards render
through one outcome table, so a tick cannot reach a result that wrote
nothing.

Refs #640
2026-07-25 21:23:26 +02:00
Marc-André Bombeck 019ad52c0d Merge pull request #641 from MBombeck/fix/reject-unstable-external-ids
v1.32.37 — refuse an identity that cannot be stable, and scope the intake replay key to its owner
2026-07-25 19:27:04 +02:00
Marc-André Bombeck 132238fd05 chore(release): v1.32.37 — refuse an identity that cannot be stable, and scope the intake replay key to its owner 2026-07-25 19:14:30 +02:00
Marc-André Bombeck 18691d4550 fix(medications): scope the intake idempotency key to its owner
`medication_intake_events.idempotency_key` carried a GLOBAL unique from
0001_init. The value is client-supplied, so two people running the same
client can mint the same key — and on a global unique the second one is
simply refused by the constraint. Their dose is not written and nothing
tells them so. The bulk drain made it worse: its replay probe read the
key with no user scope, resolved the first user's row, and reported the
second user's entry as a duplicate carrying a stranger's row id.

Migration 0273 replaces the global unique with `(user_id,
idempotency_key)`, the grain every other client-supplied identity on the
table already uses. Both unscoped reads in the bulk drain now key on the
composite; the other four reads were already narrowed by user.

On existing data this is a relaxation, so nothing needs reconciling: the
old constraint is strictly stronger than the new one, and no pair of rows
can violate an index the previous one already forbade. The migration
still carries a reconciliation step for a database whose global index was
dropped by hand at some point. It never deletes or rewrites a dose — it
clears the dedup HINT on the later row of a colliding pair and keeps
every intake event, timestamp, attribution and inventory stamp intact.
The new index is created before the old one is dropped, so a failure
aborts with the old protection still in place.

A structural guard freezes the read side: no `medicationIntakeEvent`
query may filter on the key without narrowing by `userId`.
2026-07-25 18:41:40 +02:00
Marc-André Bombeck 15b5f6f92f fix(ingest): hold the stability floor for identities no request schema sees
The dedup key on the medication intake surfaces is not only `externalId`.
`MedicationIntakeEvent.idempotency_key` is a `@unique` column persisted
with the dose row forever, with no TTL and no sweep, matched by plain
equality on the per-medication POST, the bulk drain and the external
ingest bridge. That makes it an identity, not a replay token, and it
carries the same failure mode: a key that rotates per client launch never
matches its own earlier post, so every retry logs the dose again. The
blank case is sharper still — the column is unique table-wide, so an
empty key resolves to whichever unrelated row stored it first. Refuse
both shapes at the schema on the single-entry paths and per entry on the
bulk drain.

The MCP write path hashes a host-supplied key into the `externalId` half
of its dedup key, so the shape is laundered before anything downstream
could see it and the check has to run on the input. That closes a live
one: every tool handler falls back to `""` when the host omits the
argument, and `""` digests to a constant, so each keyless write after the
first reported `already_logged` and was silently dropped.

The HTTP `Idempotency-Key` header stays out, and now says why in the
guard: `IdempotencyKey.expiresAt` bounds it and the cleanup job sweeps
it, so it is a windowed replay token and rotation is its design.

Extend the structural guard with a provenance rule for identities that
never pass a Zod request schema — a spreadsheet column, an MCP tool
argument. Inside the import and MCP trees, a module handling an identity
must apply the floor itself or import a module that does. That is a real
chain rather than a list of known offenders: a new importer that mints an
`externalId` from file input and imports nothing checked fails it, with
no allowlist to escape into.
2026-07-25 18:07:13 +02:00
Marc-André Bombeck 6e4bcffadf test(ingest): prove the external-id floor cannot be reintroduced
Harden the structural guard so it reads the file body with its imports
stripped: grepping the raw source cannot tell a file that APPLIES the
check from one that merely imports it, so deleting the call while
leaving the import slipped straight through.

Add the partial-success case on a second batch envelope — the mood bulk
ingest echoes the offending entry back with its index, status and
reason while the good rows still upsert.
2026-07-25 17:31:39 +02:00
Marc-André Bombeck 2ab66ab2e3 fix(ingest): refuse an external id that cannot be a stable identity
An external id is half of every ingest dedup key — (userId, source,
externalId), (userId, externalSource, externalId), (userId, type,
source, externalId). Re-posting the same triple is idempotent and
returns the existing row, which holds only while the id is stable
across client launches. A client that serialised an opaque platform
object with its default object description sent a per-allocation
memory address instead, so no sweep ever matched its own earlier row
and each one minted a fresh record: a live instance grew 23 phantom
medications in a day, none of which could collect a dose, because the
dose side resolved through the same rotating string.

Add one shared classifier for identifiers that provably cannot be an
identity — an angle-bracketed object description carrying a hex
pointer, a bare 0x address, and a blank value — and wire it at every
surface that accepts a client-supplied externalId.

Single-entry creates refuse through the schema, so the client gets the
standard 422 multi-issue envelope with a message naming the shape it
sent and what to send instead. Batch and bulk surfaces classify per
entry and report the offending row as skipped with reason
unstable_external_id, leaving the rest of the payload to land: one bad
row must not stop a sync. Every refusal carries pinned wide-event meta
(count, shape, surface) and never the id itself.

A structural guard freezes both routes into the floor: a request-side
externalId declaration either applies the field check or carries a
marker naming the file that checks it per entry, and the guard verifies
that file really does. The backup-restore payload is the one exemption,
with its reason recorded — it replays ids the server itself wrote.
2026-07-25 17:21:00 +02:00
Marc-André Bombeck ef9891fec5 Merge pull request #639 from MBombeck/release/v1.32.36
release/v1.32.36 — make the loose-ends failure class hard to reintroduce
2026-07-25 16:41:38 +02:00
Marc-André Bombeck 7c66413c1d chore(release): v1.32.36 — make the loose-ends failure class hard to reintroduce 2026-07-25 16:24:29 +02:00
Marc-André Bombeck c2d6adaff5 chore(audit): sweep for write-only columns from the release battery
Whether a column has a reader anywhere in the tree is a whole-program property,
and grep is the only cheap approximation. Grep is unsound both ways here:
generic names match unrelated identifiers and read as consumed, while
consumption through a select-less read plus whole-row serialization reads as
unconsumed. A gate built on that would drift towards green and stop meaning
anything, which is the failure this whole line of work exists to prevent. So it
stays a review tool with a human reading the output, run from the deep-audit
step and never in CI. The script says so itself and prints the false-positive
sources under every run.

It also carries the convention that makes the tool useful: a change that adds a
column, payload field, or preference contains its consumer, or the schema
comment carries `@internal` for something deliberately never surfaced or
`@pending-consumer(#issue)` for one still coming. The second marker is
re-listed on every run, so a follow-up that never happened surfaces at the next
release audit instead of never. Recorded in the contributor notes alongside the
two questions that are exact enough to be gates.
2026-07-25 16:24:24 +02:00
Marc-André Bombeck 99b8dfd566 test(guards): bind the account payload and the backup select to their pairs
Every finding behind this work is a two-ended change whose ends shipped in
different releases with nothing holding them together, and every gate the repo
runs proves one end: typecheck proves the export exists, knip proves it is
imported, the OpenAPI check proves the schema matches. None of them notice when
the second end never arrives.

Two questions here can be answered exactly, so they become tests.

The account endpoint rides every app boot and carries the user's preferences.
Its field names are read out of the response literal and each one must be read
by a client module — property access or destructuring, comments stripped,
because a doc comment claiming a consumer is what the remembered practice name
had for its whole dead life. That field would have failed this on the day it
shipped. One field is allowlisted today with its reason written out: the
insurer institution number has no web surface at all, which the entry keeps
visible rather than papering over.

The measurement backup select is now a named constant, compared field by field
against the model in the schema. Both sides are literals, so the comparison is
exact — no name heuristics, nothing that can drift towards accepting. A column
added to the model and forgotten by the backup fails at the commit that adds
it, which is how the aggregation provenance went missing. Scoped to the one
model; extend it as each further model becomes backup-relevant.
2026-07-25 16:24:12 +02:00
Marc-André Bombeck d8a3c08e12 fix(i18n): stop one key template from whitelisting a whole namespace
The reverse-coverage guard asserts that every key in the bundle is reachable
from a call site. It had stopped doing that for most of the bundle. A template
whose only static anchor was a namespace root registered that root as a subtree
base, so a single per-provider OAuth key marked every key under `settings.*`
reachable, and the same shape did it for `measurementReminders.*`. A cache-key
string that happens to start with a namespace word had quietly done it for
`insights.*` too. The guard stayed green throughout, which is the part worth
noticing: a pair guard degrades into a one-end guard without any signal.

Extract the matcher into a helper with its own unit tests, and change what it
accepts. A template carrying both a literal prefix and a literal suffix now
records the pair and requires both ends to match, so the OAuth template covers
the provider keys and nothing else. A template with no suffix whose anchor is a
bare namespace no longer whitelists anything — it fails the guard by name, with
the two honest remedies in the message. The same door is used by the template,
assignment and concatenation arms, so none of them can reopen it. Template
bodies are read by scanning forward from a known opening backtick rather than
by pairing backticks across the tree, which was drifting and dropping whole
files out of the scan.

Three call sites build their keys whole instead of interpolating onto a
namespace root: the two measurement-reminder due helpers, the mental-health
option labels, and the OAuth error copy, which now names its per-provider base.
Two audit-log action names that merely look like keys carry a written
exemption; the API error catalog is a runtime lookup and is allowlisted with
its reason.

With the matcher honest, 149 keys had no call site left. They belong to an
explainer popover, a hypnogram, a passkey table and an insights hero that were
all rebuilt or removed, and they are deleted from all six locales. The
explainer guard stops pinning its title leaves, which no surface has rendered
since the popover went.
2026-07-25 16:23:58 +02:00
Marc-André Bombeck f9a5e6a11f Merge pull request #638 from MBombeck/release/v1.32.35
release/v1.32.35 — remember the practice name, keep the source provenance in a backup
2026-07-25 15:44:30 +02:00
Marc-André Bombeck fe2e20a14b chore(release): v1.32.35 — remember the practice name, keep the source provenance in a backup 2026-07-25 15:28:39 +02:00
Marc-André Bombeck 2a396dd23e fix(backup): carry the aggregation provenance through backup and restore
The disaster-recovery backup selected every Measurement scalar except the
aggregation fields, so a restore brought every `stats:` aggregate back with no
provenance and the Apple Health authority ladder came back flat: an export.xml
source-day estimate could then overwrite a native HealthKit statistic.

`aggregationProvenance` now rides the select, the disaster-recovery
serialization, the payload schema and the restore mapping, following
`externalSourceVersion` exactly — including staying out of the reduced
portable shape, which carries the human-readable subset only. The schema field
is optional, so every backup written before this parses unchanged.

Restoring one of those older backups no longer recreates the ambiguity
migration 0263 repaired: an Apple Health row under a `stats:` identity with no
provenance comes back as LEGACY_UNKNOWN, the sentinel the cumulative drain
already writes in that situation, so a native sync or archive reprocess can
repair it. The gate is scoped as 0263 scoped its backfill — other providers
mint `stats:` identities for their cumulative dailies and have never carried
provenance, and ordinary point rows keep NULL.
2026-07-25 15:20:47 +02:00
Marc-André Bombeck f8f460bb66 refactor(measurements): drop the write-only aggregation columns (migration 0272)
Migration 0263 added three columns beside the Apple Health `stats:*`
authority ladder. Only `aggregation_provenance` is ever read back: the
external-measurement reconciler selects it and refuses a lower-authority write
against a higher-authority canonical row, and the cumulative drain reads it
too. That column stays.

`aggregation_contributor_count` and `aggregation_selected_source_hash` are
written by three call sites and read by none. No select, no raw SQL, no export,
no backup, no API contract, no repair path. The import-time selection keeps its
deterministic tiebreak, which compares an in-memory map key computed per flush
rather than a value read back from the table.

Both are plain nullable scalars with no index, unique, check or foreign key, so
the drop is a catalog-only operation that touches no measured value.
2026-07-25 15:03:44 +02:00
Marc-André Bombeck a3f1d780e7 feat(export): remember the practice name between doctor reports
The doctor-report contract has always described the practice / clinic name as
a stored preference, and `/api/auth/me` has returned
`User.lastReportPracticeName` since it shipped, but nothing ever wrote the
column. Every report asked for the clinic name again.

The export route now persists the sanitised name after the requested artefact
has actually been produced, so a generation that throws cannot overwrite the
remembered value with a half-typed one. Clearing the field persists null, which
keeps the cover line cleared next time. The export panel seeds its input from
the auth payload during render, gated per user id, so a late resolve never
clobbers a field the user is already typing into, and a finished export
invalidates the cached payload so a remount reads the new value.
2026-07-25 15:00:27 +02:00
Marc-André Bombeck ddc18bbd11 Merge pull request #637 from MBombeck/release/v1.32.34
release/v1.32.34 — one shell and one rhythm for the insights overview
2026-07-25 14:40:16 +02:00
Marc-André Bombeck c623745942 chore(release): v1.32.34 — one shell and one rhythm for the insights overview
Interface housekeeping. The overview sections drop their hand-rolled card
frames for a shared shell with one inner rhythm, failed reads on dense metric
pages get a named row primitive that puts the message in the content tier, and
the last off-scale spacing and text sizes go back onto the scale.

The tag chip and the sub-twelve-pixel text step are primitives now, so neither
can be hand-copied at a slightly different value again.
2026-07-25 14:21:30 +02:00
Marc-André Bombeck 338808eb5f refactor(ui): route the sleep-composition header through the tile header
The stage-composition chart card was the last insights tile with a
hand-rolled title row. It now uses the canonical header primitive; the
subtitle and the window toggles keep the shape they had, since the toggles
have to drop to their own row on a phone.

Also sweeps the last off-scale spacing: the two dense tiles inside a
recommendation move onto the sanctioned inner-tile step, and the not-found
button onto the scale. The banned twenty-pixel step is now absent from the
tree. The chart body is untouched.
2026-07-25 14:12:01 +02:00
Marc-André Bombeck 2decc37f07 refactor(insights): one shell and one rhythm for the overview sections
Seven overview sections had hand-rolled the same card literal, and the copies
had already drifted on the one thing the literal did not pin: the space
between blocks. Three used 12 px, two 8 px, one 16 px, one under the other in
the same column.

`InsightSectionCard` composes `Card`, so the frame comes from the card
contract, and fixes the inner rhythm at 12 px. The daily briefing moves onto
it as well, and the lab-changes list keeps its edge-to-edge rows through the
documented flush variant.

Visible change on the overview column: two bodies open by 4 px, one tightens
by 4 px, and all of them pick up the shadow their card neighbours already
had. Chart bodies are untouched.
2026-07-25 14:09:22 +02:00
Marc-André Bombeck b4f4cb8b55 refactor(ui): promote the compact read-failure row to a primitive
The HealthKit metric sub-pages and the vitals dashboard had each hand-rolled
the same failed-read row, byte for byte. The tall centred error card is the
right shape for a list surface but dominates a dense metric strip, so the row
stays — as a named primitive rather than a third copy.

`QueryErrorRow` carries the shipped shape with one correction: the failure
message renders in the foreground tier. An alert is the content of a failed
surface, not meta about it, and both copies had it muted. The retry glyph
matches its tall sibling. Both converted surfaces pass their existing slot
names through, so their hooks are unchanged.
2026-07-25 14:02:59 +02:00
Marc-André Bombeck 933dfcfdfb refactor(ui): mint the text-2xs token and the neutral TagChip
Three integration cards carried the same hand-copied chip literal in the
card-header accessory slot, each free to drift on the next touch; the size
itself was an arbitrary value repeated at three slightly different spellings
across the tree.

- Mint `--text-2xs` (0.6875rem with a matched line-height) as the one
  sanctioned step below `text-xs`.
- Mint `TagChip` and adopt it on the Fitbit, Google Health, and Nightscout
  cards. It stays distinct from `Badge`, which carries status meaning.
- Sweep the arbitrary micro sizes: the stepper label and the two dose-ledger
  outline badges move onto the token; the muted target caption and the muted
  ledger meta row move up to the `text-xs` muted floor.

No visible change beyond the two muted rows gaining a fraction of a pixel.
2026-07-25 14:00:25 +02:00
Marc-André Bombeck 405bebf1a0 test(e2e): stop the medications refetch spec racing its own listener
Arm the response waiter before navigating, so a response that lands during
the initial load is still observed instead of timing out on an event that
already happened, and poll for the returning refetch rather than sleeping a
fixed span that a loaded machine can outrun.
2026-07-25 12:13:47 +02:00
Marc-André Bombeck d9b8a69b7f Merge pull request #635 from MBombeck/release/v1.32.33
release/v1.32.33 — remove the moodLog integration
2026-07-25 12:13:00 +02:00
Marc-André Bombeck a006f76320 chore(release): v1.32.33 — remove the moodLog integration
Version bump, regenerated OpenAPI spec (version field only; the moodLog routes
were never registered on the public contract), and the release notes.
2026-07-25 11:36:33 +02:00
Marc-André Bombeck d0b45e36b3 feat(integrations): remove the moodLog integration
Mood has been a first-class module here for a long time, with entries,
structured tags, rated factors and notes. The bridge to the external moodLog
service had nothing left to add, and its Settings card was withdrawn in
a56e6fe61 on the promise of a dedicated removal. This delivers it.

Removed: the hourly `moodlog-sync` queue, cron and handler binding; the
inbound webhook and its shared-secret handling; the reverse push and its four
call sites; the pull-sync module; the per-user credential routes and the
instance-wide toggle; the entry on the `/api/integrations/status` envelope,
the `IntegrationKey` union, the cadence map and the display-name table; the
boot-time secret rotation pass; 22 message keys per locale.

Migration 0271 drops the five `users` credential columns and
`app_settings.mood_log_global`, clears the ledger row for the provider, and
deletes the retired pg-boss schedule so no cron keeps enqueuing work no
handler drains.

Mood data is untouched. `mood_entries` is not named anywhere in the migration
and cannot be reached from it: the dropped columns carry no index, constraint
or foreign key, and `mood_entries.user_id` references `users(id)`, not any of
them. Rows the bridge imported keep `source = 'MOODLOG'`, and every read path
- list filter, source badge, exports, backups - still resolves that value.

`src/lib/validations/moodlog.ts` held the native mood-entry schemas alongside
the bridge's own; it is now `validations/mood.ts` and carries only the former.
2026-07-25 11:36:18 +02:00
Marc-André Bombeck 4ed8c8f39b Merge pull request #634 from MBombeck/release/v1.32.31
release/v1.32.31 — the integrations page says what is actually happening
2026-07-25 10:14:50 +02:00
Marc-André Bombeck e657bf928e chore(release): v1.32.31 — the integrations page says what is actually happening 2026-07-25 09:41:58 +02:00
Marc-André Bombeck 10593a11a8 test(integrations): cover the verdict ladder and the coverage guarantee
The load-bearing one is the panel-coverage test: render the panel with an
envelope carrying every integration key and require each configured one to
surface as a card or as a fallback row. Drop the fallback row and it goes red,
which is the whole point — the previous shape of this bug was a provider on
the wire that no screen rendered, and no card-shaped test could have caught it.

Alongside it: the ladder table with the month-silent fixture that resolves to
stalled, the 24-hour and 7-day boundaries asserted against literals rather than
the constants they check, the per-metric stale flag firing only under a healthy
provider, and a structural guard that the verdict-to-pill mapping is defined in
exactly one module.

The card suites migrate with the semantics: a transient failure now expects the
warning pill everywhere, and the Apple Health suite trades its bespoke badge
states for pill assertions. The e2e mocks gain the verdict shape, the
nightscout entry, and an assertion that the card-less row is on the page.
2026-07-25 09:41:53 +02:00
Marc-André Bombeck ce74fd52a2 feat(settings): render the sync verdict and per-metric freshness
Three `pillStateFor` implementations disagreed with each other, so the same
ledger state painted red on one card and amber on the next, on one page. All
three are gone; the client projects the server verdict once.

Red now means "your action fixes this" and nothing else. A transient upstream
failure carries the warning tone: telling a user to reconnect against a 503
sends them clicking at something they cannot repair, and the detail belongs in
the inline error line anyway. Three new pill states carry the rest — no recent
data, sync stopped, waiting for first data.

Per-metric freshness has been computed on every status call since v1.27.30 and
rendered nowhere. Every connected card now opens a disclosure listing each data
type the connection has delivered and how long ago, with a quiet one flagged.
Collapsed by default so a 25-type card keeps its shape, but the collapsed
summary already names the count, or the signal hides behind a click. A type a
provider never delivered simply is not listed.

The panel iterates the envelope and renders a plain status row for every
configured entry no card claims. That is the structural half: a connection can
no longer be running, failing and absent from the page at once.

Apple Health drops its bespoke badge for the shared pill, gains the divider and
body inset every sibling carries, and says something when a week has passed
with nothing arriving. Nightscout reads the envelope instead of its own status
round-trip. The OAuth card uses the shared error primitive, all nine cards
carry the divider testid, and the four original connectors gain the "view your
data" link the newer ones already had.
2026-07-25 09:41:37 +02:00
Marc-André Bombeck de47bfa5ba feat(integrations): resolve sync health on the server
The ledger records what the last attempt did. It has no way to say what is
no longer being attempted: a row frozen at `error_transient` for a month
reads exactly like one retrying every hour, because nothing anywhere read
the age of `lastAttemptAt`.

`sync-verdict.ts` derives the missing reading as a pure read-side
projection: eight verdicts down one ladder, no writer, no cron, nothing that
can desynchronise from the row it reads. `stalled` is the new one, and it
deliberately outranks `failing` — an integration nobody is calling is not
failing, it is not running.

Every entry on `/api/integrations/status` carries the verdict, computed
inline from data the route already holds. That placement is the point: a
card-level verdict misses a card-less provider by construction, which is how
a dead sync stayed invisible. Nightscout joins the envelope so its card can
stop fetching its own status.

The freshness module gains the workout leg (Strava writes no measurements at
all) and a source-parameterised core so the Apple Health route can reuse it.
Its per-metric `stale` flag fires only while the provider itself reads
healthy: that combination is the dead-pipe signature, and when the whole
connection has stopped every metric ages together anyway.

MCP reports the verdict beside `connected`, which only ever meant "not
explicitly disconnected".
2026-07-25 09:41:20 +02:00
Marc-André Bombeck a1a978f5e6 Merge pull request #633 from MBombeck/release/v1.32.30
release/v1.32.30 — height is entered and shown in the unit you use
2026-07-25 08:38:00 +02:00
Marc-André Bombeck 41c2213fe6 chore(release): v1.32.30 — height is entered and shown in the unit you use 2026-07-25 08:21:57 +02:00
Marc-André Bombeck 7a0e2cb5b2 test(profile): cover the height round-trip, the converted bounds, and both entry surfaces
Sweep every feet + inches pair the fields can offer and every whole-inch
height across the canonical range, asserting the value comes back
unchanged and lands inside the server's window. Assert the metric branch
with toBe so a re-derivation of a stored height cannot slip through.

Render both entry surfaces under each preference: the metric case keeps
the single centimetres box with the canonical guardrails, the imperial
case shows the feet + inches pair seeded from the stored value. Extend
the structural guard so a surface that drops the adapter, hardcodes a
centimetre guardrail, or hardcodes a unit label trips a tripwire.
2026-07-25 08:21:51 +02:00
Marc-André Bombeck 1b41126fb3 feat(profile): enter and show height in the selected unit
Height is a profile column, not a MeasurementType, so the display
transform that carries the metric/imperial preference across every
reading surface never reached it. Add the adapter that closes the gap.

With imperial selected both entry surfaces render a feet + inches pair
instead of a centimetres box; with metric selected the field is what it
was. The adapter converts to canonical centimetres at the entry
boundary, so storage, the wire, the Zod bound, and every consumer of
the value stay unchanged.

Imperial entry quantises to whole inches, which map to centimetres
without a remainder, so save-then-reopen returns the typed pair. The
offered feet window rounds inward against the canonical 50-300 window
so a height the fields allow is never refused on save.

The unit select shares the Settings card with the height field, so a
flip re-expresses the in-flight draft through the new branch rather
than stranding an empty field.
2026-07-25 08:21:42 +02:00
Marc-André Bombeck d0d880d4ba docs(changelog): credit the reporter behind the unit-preference fix by name 2026-07-25 08:03:28 +02:00
Marc-André Bombeck 5b32f6ce4c Merge pull request #632 from MBombeck/release/v1.32.29
release/v1.32.29 — a re-scored Fitbit night stops double counting
2026-07-25 06:32:45 +02:00
Marc-André Bombeck fb74b686e3 chore(release): v1.32.29 — a re-scored Fitbit night stops double counting
Stable per-segment sleep keys plus the replace-by-window sweep, a one-shot
repair over the past year, Retry-After handling, and the unchanged-value write
skip. Migration 0270 records which connections the repair has been through.
2026-07-25 06:15:37 +02:00
Marc-André Bombeck a08c759763 test(fitbit): cover the stable sleep key, the sweep, and the one-shot repair
Mirrors the Google sleep suites name for name: the mapper keeps its externalIds
across a re-score that re-classifies and renumbers segments, the sweep stays
bounded to the night's window and never touches the fresh keep-set, a null
window issues no delete at all, and a cleanup failure never fails the sync.

The end-to-end fixture runs one night stored under the old keys through
`syncUserSleep` and pins the ordering the heal depends on — sweep first, then
the rescue re-keying the tombstoned rows in place, no parallel insert — plus a
second pass that writes nothing at all.

Also covers the repair's verdict gates (bounded 365-day window, marker stamped
only on a clean run, watermark untouched, dead token and reauth park returning
without a stamp), the unchanged-value skip including the re-classified segment
it must not swallow, and both Retry-After paths.
2026-07-25 06:15:37 +02:00
Marc-André Bombeck ce41d5bf17 fix(fitbit): key sleep segments on their start instant and sweep re-score leftovers
Fitbit re-scores a night after the fact, and the per-segment externalId carried
a positional index plus the stage label. Both moved on a re-score: renumbered
segments minted fresh keys, the previous scoring's rows stayed live because
nothing swept them, and the night total counted both copies from then on.

Key the segment on the stable session anchor plus its own start instant
(`<logId>:sleep:<segment-start>`), and add the replace-by-window sweep every
other segment-writing provider already has. The two ship together with the
natural-key rescue that has been sitting in the Fitbit upsert unused: the sweep
tombstones the old-keyed rows first, so the rescue re-keys those very rows in
place instead of inserting a parallel copy or losing the insert to
`skipDuplicates` against the natural unique index.

A one-shot repair queue re-reads the bounded sleep history once per connection
so no user has to do anything; it stamps `sleepRepairedAt` (migration 0270) only
on a clean run, never touches the sync watermark, and reports the rows it
removed. Nights older than the 365-day backfill horizon keep their old keys and
whatever duplicates they already hold — they can no longer gain new ones.

Alongside: `fitbitGet` now honours a `Retry-After` inside a 120 s cap by waiting
once and retrying once, and fails fast above it so a long hold cannot starve the
rest of the poll cohort or outlive a job's expiration. The measurement upsert
skips a write whose payload is unchanged, which takes the hourly overlap off the
delta feed for every Fitbit resource.
2026-07-25 06:15:37 +02:00
Marc-André Bombeck a11717e65a Merge pull request #631 from MBombeck/release/v1.32.28
release/v1.32.28 — Oura catches up after a gap, and the last four providers get manual sync
2026-07-25 05:03:46 +02:00
Marc-André Bombeck 40a746b2b7 chore(release): v1.32.28 — Oura catches up after a gap; manual sync for the last four providers 2026-07-25 04:43:18 +02:00
Marc-André Bombeck 0bcbb88db1 feat(settings): put a sync-now button on the four cards that lacked one
A route with no caller is an API detail, not a feature. The Oura, Polar,
Strava and Nightscout cards get the same action WHOOP already had, in the
same row as Test connection, rendered only once connected: post the sync,
show the count or the failure, invalidate the reads that depend on
measurements. No full-history dialog, since these providers have no such
arm. Keys added across the six locales.
2026-07-25 04:43:12 +02:00
Marc-André Bombeck a9f1629f82 feat(integrations): add manual sync for Oura, Polar, Strava and Nightscout
These four were the only connected providers with no way to ask for a
sync; they ran on their scheduled pass and nothing else. Each gets a POST
sync route on the hardened Fitbit and Google Health template: session auth,
a 5-per-minute per-user bucket, and a fixed 502 on failure. No body and no
full-sync flag, because none of the four entry points has a full-history
arm to call. Polar goes through the shared legs entry point so both legs
run with the attribution rules built for them.

The Nightscout error arm is the one that matters: a base URL carries the
API token as a query parameter, so the caught message can hold the user's
secret and never reaches the response body. The classified failure is
already on the ledger, recorded with the provider's own redaction.

Connecting also starts the first sync now. The poll handler has accepted a
single-user payload since it was written and never had a producer, so a
fresh connection sat empty for up to an hour waiting for the schedule. The
Oura and Polar callbacks and the Nightscout connect route enqueue one
targeted job each, best effort, with the hourly tick still the safety net.
Strava needs none: its callback already enqueues the history backfill,
which covers the recent window.

The four poll-queue names move into their own module so a request-path
caller can name a queue without dragging the whole worker handler graph
into the request bundle, following the backfill-queue precedent. Creation,
scheduling and handler binding stay in the registrar, and the dead-queue
guard follows the declarations.
2026-07-25 04:43:06 +02:00
Marc-André Bombeck 9bbbeb805c fix(oura): derive the sync window from the newest imported reading
The lookback was pinned at seven days, so a sync that stayed broken for
longer than a week came back with a window too narrow to reach the gap.
The quieter case did the same damage with every signal green: a ring left
unsynced holds its nights in Oura's cloud until the app is opened, every
hourly tick succeeds with empty arrays, and when the days finally arrive
they sit outside the window.

Anchor the window on the newest live Oura measurement instead, clamped
between the existing seven-day floor and a new thirty-day ceiling, plus a
day of overlap. Both failure modes leave the same fingerprint, so one
anchor closes both, and the watermark cannot drift out of step with the
data because it is the data. Tombstones are excluded: the sleep sweep
soft-deletes rows a revised hypnogram orphaned, and one of those must not
pass for a live reading.

The ceiling is thirty rather than the ninety days Oura's guidance would
allow, because the whole batch still rides one transaction. With data
flowing normally the clamp floors at seven and the tick is unchanged.
A connection with no rows at all takes the full month.
2026-07-25 04:42:52 +02:00
Marc-André Bombeck 8af5c6a0eb Merge pull request #630 from MBombeck/release/v1.32.27
release/v1.32.27 — target and threshold panels follow the unit preference
2026-07-25 03:43:29 +02:00
Marc-André Bombeck 7a4e819679 chore(release): v1.32.27 — target and threshold panels follow the unit preference 2026-07-25 03:24:24 +02:00
Marc-André Bombeck c86de7d317 test(targets): pin the target unit round-trip and extend the display guard
Cover the adapter end to end: an imperial seed, a guardrail projected
into the display unit, a save inverted back to canonical, and a reopen
that shows the number the user typed, swept across the whole editable
window at the input's own step. Temperature carries the affine case,
where a difference must take the factor without the offset.

Assert the metric path is the exact identity rather than merely close,
so a stored value cannot drift by a rounding pass.

Extend the structural guard to sweep the three target and threshold
surfaces, and add two tripwires: each must call the preference hook and
convert through the shared adapter. Reintroducing a hardcoded unit or
swapping the hook for a literal now fails the guard.
2026-07-25 03:24:19 +02:00
Marc-André Bombeck bf73755899 fix(targets): follow the unit preference in target and threshold panels
Target ranges stayed canonical while every reading surface moved to the
metric/imperial preference in the previous release, so an imperial
account saw a weight chart in pounds above a target reference in
kilograms. The panel is a coupled display and edit unit: converting only
its display would have corrupted the edit seed and the save round-trip,
which is why it was held back.

Add one adapter that owns every direction of the conversion and route
all three surfaces through it. The reference panel converts the
canonical payload once and hands the converted pair to the range bar,
the status pill, and the editor seed. The editor seeds from the stored
value converted to the display unit, validates against guardrails
projected into that same unit, and inverts the typed number back to
canonical before the PUT.

Guardrails round inward, so the 30 kg floor projects to 66.2 lb rather
than 66.1 lb, which would have inverted below the floor the server
enforces. Absolute values carry the affine offset, differences take the
factor alone. Input step comes from the transform's own precision.

Storage is untouched: thresholds stay canonical SI, and for a metric
account the adapter is the exact identity in both directions, so a
stored value is unchanged bit for bit.
2026-07-25 03:24:10 +02:00
Marc-André Bombeck cfd5cf0e0c Merge pull request #629 from MBombeck/release/v1.32.26
release/v1.32.26 — apply the metric/imperial preference across every reading surface
2026-07-25 00:48:54 +02:00
Marc-André Bombeck 2e5841c337 docs(changelog): date v1.32.26 and note the target-panel unit gap 2026-07-25 00:37:28 +02:00
Marc-André Bombeck a9b2ab2d2b chore(release): v1.32.26 — apply the metric/imperial preference across every reading surface
Surface a self-hoster's report that the unit preference was stored but read by
almost no display. Weight, body composition, waist, and temperature now follow
metric or imperial at entry and everywhere they render, storage stays canonical
SI, and the MCP logging tool converts a known unit hint or refuses an unknown
one. OpenAPI diff is version-only; the wire stays canonical.
2026-07-25 00:35:07 +02:00
Marc-André Bombeck 6cf56ce456 test: mock the unit-display hook in the crosstab, vitals, and MCP suites
The crosstab, vitals, and cycle-summary renders are static server renders with
no QueryClient; the new preferred-unit hook reads through the auth query, so
mock it to a real metric-preference implementation (identity values, canonical
symbols) that matches the render those suites assert. Register the new unit
resolver on the MCP write-tools module mock so the preview path resolves.
2026-07-25 00:34:51 +02:00
Marc-André Bombeck ddc598afcb test(measurements): cover the unit transform, MCP unit hardening, and a display guard
Add affine-correctness tests for the transform registry (°C→°F absolute vs a
delta that must stay offset-free, lb/in factors, inverse round-trips), a
resolver test for the inbound unit aliases, a preferred-unit entry-form label
test, and MCP cases for converting a lb hint to canonical kg and refusing an
unknown unit. Add the structural guard that trips if any dashboard or insight
surface reintroduces a hardcoded transformed-type unit or value scale. Ship the
diagnose/convert script pair for legacy non-canonical measurement rows.
2026-07-25 00:21:36 +02:00
Marc-André Bombeck 13b88e47f5 fix(insights): convert weight and temperature in the mood/cycle crosstabs and the plateau note
Route the weight and temperature displays in the mood-factor and cycle-phase
crosstabs and the GLP-1 plateau note through the unit transform: absolute phase
averages take the affine conversion, deltas stay factor-only, and the unit
symbol follows the preference. Thread a unit token into the plateau evidence
copy across the six locales so the sentence reads in the user's unit.
2026-07-25 00:16:41 +02:00
Marc-André Bombeck e1ca523e21 fix(insights): route every weight-class and temperature surface through the unit transform
Wire the display transform into the chart scaffold, the chart itself (with an
affine offset for the temperature conversion), the dashboard trend tiles and
weight chart, the vitals grid, the coach read strip, and the device-score
tile. Each surface resolves its unit and scale from the user's preference at a
single choke point and stops hardcoding the canonical unit, so an imperial user
sees a consistent value, axis, band, tooltip, and range hint while a metric
user's render stays byte-identical. Retire the local celsius display shim now
that the transform owns the symbol.
2026-07-25 00:10:53 +02:00
Marc-André Bombeck dd7213db5c fix(measurements): honor the unit preference at entry and in the record list
Add the affine display-transform registry (kg↔lb, cm↔in, °C↔°F with the
temperature offset carried as an absolute-only shift), a single client hook
that reads the preference, and a shared inbound unit-alias resolver. Wire the
manual entry form to label, prefill, and convert at the boundary so an imperial
reading persists in canonical SI, and route the record list value cells and
edit dialog through the same choke point. Close the one write path that could
persist a non-canonical unit: the MCP log_measurement tool now resolves a unit
hint to canonical or refuses it, reusing the resolver the CSV importer already
relied on.
2026-07-24 23:58:45 +02:00
Marc-André Bombeck 14dcab786e Merge pull request #628 from MBombeck/release/v1.32.25
release/v1.32.25 — cap external medication-mirror growth and surface provenance
2026-07-24 23:32:51 +02:00
Marc-André Bombeck bb79d28911 chore(release): v1.32.25 — surface mirrored-medication provenance and cap external mirror growth 2026-07-24 23:18:26 +02:00
Marc-André Bombeck 40aae4b66d chore(scripts): apply prettier to the phantom-med cleanup script
Formatting only; the format:check gate flagged the file. No behaviour change.
2026-07-24 23:18:26 +02:00
Marc-André Bombeck 1007ca3d1c test(medications): cover the mirror growth guard and provenance echo
A 31st new mirror create at the cap returns 422 with the limit_exceeded
errorCode; an idempotent re-post of an existing mirror at the cap still returns
200; a native create at the cap is unaffected and never runs the count. The
list read exposes externalSource as APPLE_HEALTH for a mirror and null for a
native row.
2026-07-24 23:18:26 +02:00
Marc-André Bombeck dbe3c3dc26 fix(medications): cap externally mirrored rows and surface their provenance
A client that mints a fresh externalId for the same source concept on every
sync sweep never matches the (userId, externalSource, externalId) idempotency
triple, so each sweep would otherwise create a new mirror row without bound.
Add a growth guard on POST /api/medications: once an account already holds 30
medications mirrored from an external source, a genuinely new mirror create is
refused with 422 and the errorCode medications.mirror.limit_exceeded. The guard
sits after the idempotency lookup, so an idempotent re-post of a medication the
account already mirrors still returns 200, and native creates never run the
count.

Echo externalSource on the medications list read so the web UI and an operator
can tell a mirrored row from a native one, and document both in the OpenAPI
contract.
2026-07-24 23:18:14 +02:00
Marc-André Bombeck 6c8e921faa chore(scripts): add read-only diagnose + guarded cleanup for phantom Apple-Health mirror meds
The diagnose script reports the provenance, external-id values, and intake/schedule
counts of medications by name. The cleanup script deletes only name-matched
Apple-Health mirror rows with zero intakes and zero schedules, dry-run by default,
replicating the delete route (category cleanup, token revoke, audit, cache bust).
2026-07-24 23:02:03 +02:00
789 changed files with 59552 additions and 15673 deletions

No files matched your search

+3
View File
@@ -247,3 +247,6 @@ API_TOKEN_HMAC_KEY="REPLACE-WITH-OUTPUT-OF-openssl-rand-hex-32"
# -----------------------------------------------------------------------------
# Opt-in for a plain-HTTP IP_GEO_LOOKUP_URL provider (e.g. free ip-api.com); off by default — looked-up IPs then travel unencrypted over your server's egress.
# IP_GEO_ALLOW_INSECURE="true"
#
# Directory the offline GeoLite2 databases are read from; default /opt/geolite2 (the path the image bakes its own copy into). Point it at a directory you mount yourself to bring your own MMDBs — see docs/self-hosting/geolite2.md.
# GEOLITE2_DIR="/var/lib/geolite2"
+18
View File
@@ -293,6 +293,24 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
# off the resolver uses only the offline GeoLite2 MMDB tier, if present.
# IP_GEO_LOOKUP_DISABLED="1"
# -----------------------------------------------------------------------------
# Offline GeoLite2 databases -- optional
# -----------------------------------------------------------------------------
# Where the runtime looks for GeoLite2-City.mmdb and GeoLite2-ASN.mmdb.
# Unset it reads /opt/geolite2, the path the published image bakes its own
# copy into -- but only if the image build had a MaxMind licence key, so on
# a stock `:latest` the directory is usually empty and every login IP goes
# to the online provider instead.
#
# To resolve locations on your own host: create a free MaxMind account,
# pull both databases with `geoipupdate`, mount the directory into the
# container read-only (the commented `volumes:` block in docker-compose.yml
# shows the line), and point this at the container path. Pair it with
# IP_GEO_LOOKUP_DISABLED="1" above for zero IP egress. Refreshing the files
# on the host is enough -- the reader picks them up on the next container
# restart. See docs/self-hosting/geolite2.md.
# GEOLITE2_DIR="/var/lib/geolite2"
# -----------------------------------------------------------------------------
# Environmental-context module (Open-Meteo) -- optional
# -----------------------------------------------------------------------------
@@ -39,7 +39,6 @@ body:
- Notifications
- AI Insights
- Withings integration
- moodLog integration
- Self-hosting / Docker
- Other
validations:
+1 -1
View File
@@ -35,7 +35,7 @@ jobs:
COACH_EVAL_API_KEY: ${{ secrets.COACH_EVAL_API_KEY }}
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
+1 -1
View File
@@ -12,7 +12,7 @@ jobs:
if: github.actor == 'dependabot[bot]'
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
+4 -4
View File
@@ -46,7 +46,7 @@ jobs:
contents: read
steps:
- name: Checkout full history
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
@@ -129,7 +129,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
@@ -150,7 +150,7 @@ jobs:
# Image is built but not pushed in that case.
- name: Log in to GitHub Container Registry
if: github.event_name != 'pull_request'
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
@@ -350,7 +350,7 @@ jobs:
echo "IMAGE_REF=${REGISTRY}/${IMAGE_NAME,,}" >> "$GITHUB_ENV"
- name: Log in to GitHub Container Registry
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
+1 -1
View File
@@ -45,7 +45,7 @@ jobs:
NODE_ENV: production
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
+1 -1
View File
@@ -25,7 +25,7 @@ jobs:
timeout-minutes: 5
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install pnpm
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
+11 -2
View File
@@ -12,9 +12,18 @@ permissions:
jobs:
integration:
runs-on: ubuntu-latest
timeout-minutes: 15
# This guard exists to kill a hung testcontainer or a deadlocked query, not
# to hold the suite to a performance budget. At 15 it had stopped telling
# those apart: the suite itself takes around 13 minutes of job time (128
# files against a real Postgres), so two consecutive runs of the SAME
# commit range came in at 12m50s and then over 15m, and the second was
# killed mid-run with every test to that point green. A cap that a healthy
# run clears only on a fast runner reports runner weather, not health.
# 25 leaves real headroom and still catches a hang inside one CI cycle.
# If the suite ever approaches this number, split it rather than raise it.
timeout-minutes: 25
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
+1 -1
View File
@@ -16,7 +16,7 @@ jobs:
timeout-minutes: 10
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Scan src/ for TODO/FIXME/XXX/HACK markers
run: |
+1 -1
View File
@@ -71,7 +71,7 @@ jobs:
steps:
- name: Log in to GHCR
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
with:
registry: ghcr.io
username: ${{ github.actor }}
+4 -4
View File
@@ -15,7 +15,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
@@ -66,7 +66,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
@@ -91,7 +91,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
@@ -106,7 +106,7 @@ jobs:
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Build Docker image
run: docker build -t healthlog:scan .
+1 -3
View File
@@ -4,7 +4,7 @@ Instructions for AI coding agents (OpenAI Codex, Claude Code, Cursor, etc.) work
## Project
**HealthLog** — a personal health-tracking web app (weight, blood pressure, pulse, mood, medication compliance) with Withings integration, moodLog.app sync, Dracula-themed UI, mobile-first PWA design.
**HealthLog** — a personal health-tracking web app (weight, blood pressure, pulse, mood, medication compliance) with Withings integration, Dracula-themed UI, mobile-first PWA design.
**Status**: v1.4.0 (released) → v1.4.1 in progress. v1.4.0 shipped
the UI guidelines + Skeleton/EmptyState primitives, medical-citations
@@ -102,7 +102,6 @@ src/
│ ├── import/ # JSON data import
│ ├── doctor-report/ # Doctor report data aggregation
│ ├── gamification/ # Achievements (persistent)
│ ├── integrations/moodlog/ # moodLog.app webhook + sync + status
│ └── ...
├── components/
│ ├── ui/ # shadcn/ui primitives
@@ -135,7 +134,6 @@ src/
│ ├── gamification/ # Achievement definitions + progress
│ ├── insights/ # Insight pipeline + prompts
│ ├── logging/ # Wide Events: builder, context, sampler, transports, background
│ ├── moodlog/ # moodLog.app webhook + sync
│ ├── monitoring/ # Umami / GlitchTip server-side hooks
│ └── withings/ # Withings OAuth client + sync service
├── hooks/
+647
View File
@@ -2,6 +2,653 @@
## [Unreleased]
## [1.33.1] — 2026-07-27
A patch release about the same thing as the one before it: places where the
app knew something and said something else.
### Two that change what you receive
**Medication reminders arrived every fifteen minutes.** The rule that decides
whether a reminder has already been sent was keyed on a ledger that could not
tell one dose slot from another, so an overdue dose kept re-announcing itself
for as long as it stayed overdue. One account was getting roughly fifty mails
a day. A reminder now goes out once per dose, per escalation step, per local
day, and two doses due at the same time no longer replace each other on the
lock screen.
**"Handled by the app" now silences only the app.** The setting that lets the
iOS client take over medication reminders was suppressing every channel, so
switching it on quietly turned off Telegram, ntfy, webhooks, email and web
push as well. That was never what it said it did. If you have been using it as
a general mute, those channels will start delivering again, and the setting
does what its name says.
### Two that were reported
Someone self-hosting could not use an OpenAI-compatible gateway. LiteLLM,
OpenRouter and vLLM all speak the OpenAI wire, but the provider list had no
entry that honoured a base URL of your own, and an earlier answer from us said
otherwise. There is now a provider for exactly this, with its own key, model
and endpoint. The plain OpenAI entry stays pinned to OpenAI, which a test
enforces, so a key meant for one never travels to the other.
Timestamps followed the browser rather than the profile in four places. The
worst of them: a shared report downloaded as a PDF could carry a different
date than the page it was downloaded from, for the same reading. Charts also
drew their week and month boundaries in one fixed timezone, so an evening
reading landed in the wrong bar for anyone living elsewhere.
### The backup
Three things it carried and could not restore, or could not carry at all.
A cycle symptom you created yourself was never included, so on restore its
key resolved to nothing and the link was dropped without a word. Three lines
away in the same function, an unknown cycle reference threw an error. Same
kind of missing reference, two different answers.
A mood factor you created yourself had the opposite ending: the restore
looked only for the built-in ones, found your own missing, and stopped. The
file was complete and unusable.
Nutrient day totals have been written into every backup since v1.29 and were
discarded on restore, silently, while the restore reported success.
What a backup contains is now declared in one place, with a written reason
for every record type left out. Around thirty types are still carried by
neither the file nor the restore, and they are named there rather than left
to be discovered. The export card no longer promises "every record you own",
because it does not.
### For operators
Two migrations. `0280` adds the gateway provider's columns, `0281` turns the
integration ledger's single failing-part slot into a set. Neither deletes or
rewrites a measurement.
A provider that syncs several things separately can now remember every part
that is failing at once. Until now the last one to report overwrote the rest,
which is the limit named in the v1.33.0 notes and not fixed then.
Self-hosters can point `GEOLITE2_DIR` at their own GeoLite2 databases. The
variable was read by the code and missing from the compose whitelist, so it
never reached the container. The message in the admin area used to tell
self-hosters to set a secret in this repository, which is not something they
can do.
### Credit
Reported by @k-nacion (the OpenAI-compatible gateway and the timezone
rendering), @mathewcsims (the unreachable GeoLite2 setting, found by reading
the strings inside a running container), @Nazza01 (the dose-history import),
@doenke (the table under the chart, and for narrowing his own request until it
fit one release) and @lutzkind (the dashboard layout).
## [1.33.0] — 2026-07-26
Eight defects, and every one of them had the same shape: something failed
reliably, the code recorded it precisely, and no person ever found out.
**"Delete All Data" deleted 13 tables out of 92 and reported success.** What
survived included mental-health questionnaires, lab results, the menstrual
record, allergies, family history, the whole document vault, every AI
conversation, and clinician share links that kept working. On the account row
itself, 11 of 119 columns were cleared, so name, insurer, insurance number, home
coordinates, profile photo and every integration credential stayed. The wipe now
covers everything the account owns except what is needed to sign in again, the
list is derived from the schema so a newly added table cannot quietly survive,
and the confirmation says what will go before you agree rather than after.
**A device sync failed 1149 times without anyone being told.** The escalation
had no rung above "temporary", so a connection could fail hourly for weeks while
the card read "connected". Worse, providers that sync several things separately
had one healthy part erase the error recorded by a broken one, twice an hour,
including a connection that had already been parked for a dead credential. A
failing connection now shows how long it has been failing, escalates after a
day, and stops retrying after two with a reconnect button.
One limit worth naming rather than leaving to be discovered: the ledger
remembers a single failing part per provider. If two parts fail at overlapping
times, the recovery of the later one still clears the error of the earlier. That
is narrower than the case this fixes, and it is not closed.
**The Environment module had never once saved a row, for anyone.** One
declaration in 49 was missing the line that maps it to the database, so every
write referred to a type that does not exist. The job failed every night and
recorded that failure faithfully, and no code anywhere reads a failed job. Both
halves are fixed: the module writes, and a failing background job now names
itself on the admin page and on the affected screen. Past days can be fetched
from the weather source; the nightly job fills the last week by itself and the
backfill button covers the rest.
**Sleep was counted twice.** A device that re-exports a night it already sent
left two overlapping in-bed periods, and both were added. One night read ten
hours in the headline and twenty in the legend. The same fault inflated the
time-asleep figure itself. Every stage total is now the measure of its own
period rather than the sum of overlapping ones.
**Twenty-two controls could destroy something without asking.** Among them a
single tap that cleared the allergy list along with a text note, and a switch
labelled "Custom range" that deleted the stored range when switched off. Every
destructive control is now registered, and a new one cannot ship without being
classified.
**Medication stock was written off that had not expired.** The thirty-day clock
that applies to an opened injection pen was being applied to a blister of
tablets, so pressing out the first tablet started a countdown and a month later
the rest were declared unusable. The container kind was recorded and never read.
Pens and ampoules keep the clock; blisters, bottles and inhalers do not. A
repair returns stock that was written off, and the dates that drive it can now
be seen and corrected on the item itself.
**Insights scrolled sideways on every phone.** Two guards were watching for
exactly that and both measured the document, while the app scrolls inside an
inner area that absorbed the overflow. They now measure what actually moves.
**A third value for gender was accepted by the app and missing from the
published contract in five places, and dropped or coerced in nine more.** A
fitness calculation scored anyone without a recorded gender using the male
coefficients, and the doctor report labelled any unrecognised value as
non-binary. Every place the field is read, written, documented or displayed was
gone through.
### Four additions
Medication history from a widely used iOS export tool can now be read directly,
as the file actually is, rather than converted by hand. The scheduled time and
the time taken stay two separate facts, which is what a hand-mapped file
destroys. Every column is either used, reported, or named as not read, and you
see which before you import. Seven status values are ruled on individually: only
"taken" and "skipped" become doses, and a snoozed reminder or an unanswered
notification is counted and named rather than turned into a decision you never
made.
The Coach can tell "you never recorded this" apart from "you recorded it, but
outside the range I looked at". Someone with 1,597 imported glucose readings
from April 2024 was told there was no glucose data at all, and the Coach then
changed the subject, because one reason code covered both situations and the
instruction to move on fired on both. It now says what is actually true and
offers to look further back.
A mood tag can now be paired against the next morning's resting heart rate and
heart-rate variability. Until now a tag such as alcohol had three channels to
land in, and the only one that looked at the following day needed a wearable
recovery score that plenty of accounts never record, so the question of what a
tagged evening does overnight had no path at all. The difference is printed
without a colour verdict, because a rise reads as worse on one row and better
on the next, and an association cannot carry that judgement.
Every chart on the metric pages now carries the points it drew as a table
underneath, collapsed by default with the count in the button. It is handed the
same points the chart draws and the same formatter the tooltip uses, so a cell
and a tooltip cannot print two different numbers for one reading. The caption
names the grain, so a weekly row says it is a weekly average, and units sit in
the header rather than in every cell, which is what you want when the rows go
into a spreadsheet.
### For operators
Four migrations. `0276` adds retention indexes, `0277` adds failure-streak
tracking, `0278` scopes the intake import key per account, `0279` repairs
inventory that was wrongly expired. None deletes or rewrites a measurement.
### Credit
Reported by @lutzkind (sleep double-counting, the gender contract, the
Environment enum, the Coach retrieval window, the next-day pairing for mood tags
in #616, and a run of earlier reports), @Nazza01 (the intake import, plus the
export file and the format documentation that made reading it possible),
@doenke (#636, the table under the chart) and @DrGithubble (the unit and
precision work that led here).
Two of these defects were found because someone outside this project read the
code and said what was wrong. The Environment module had been broken for every
user since it shipped and nobody inside noticed.
## [1.32.39] — 2026-07-26
The doctor report now carries what you chose, and nothing else.
**Ten of the twenty switches did nothing.** The panel offered controls for
oxygen saturation, body fat, body composition, resting heart rate, heart-rate
variability, VO2 max, steps and distance. Switching them off changed nothing:
the value was folded away before it reached the report. Two more claimed less
than they did, and the medication switch left the dose-by-dose record in place
while appearing to remove the drug list.
**Allergies and family history went out regardless.** They were read straight
from the database in the export route, so a report could exclude them and the
structured file in the same download would still contain them.
**The mental-health questionnaires went out without being asked for.** The
depression, anxiety, wellbeing and sleep scores reached the structured export,
the automation interface and a shared link, gated only by a module that had
been switched on by default some releases ago. They now have their own entry
and are off unless you pick them.
**The cycle export shipped ten other things.** The card that promised to export
only cycle data sent an empty request, and an empty request fell back to the
defaults, so it also carried weight, blood pressure, pulse, oxygen saturation,
body fat, the medication list, adherence, lab results, allergies and family
history. The comment above that code said every other section was off.
**What replaces all of it.** One picker, on both surfaces that choose a scope.
Twelve groups, each releasable or excludable as a whole and openable down to
the individual value. All 77 measurement types and 13 record sections are in
it, each in exactly one group. Sensitive material sits below a divider with no
group control, so no single click can turn on more than one of those at a time.
Nothing is preselected. A new report starts empty and one button fills in what
a general visit usually needs, which you can then change. Generating is not
possible while nothing is chosen.
A metric added to the app in future cannot appear in a scope you saved
earlier. That is not a promise, it is a compile error: a new measurement type
without a group breaks the build, and every path that produces a report has to
name where its scope came from.
**Share links.** A link can now be fetched as a PDF or as a structured file
behind the same passphrase that already protects the view, which is what a
practice actually does with one. The old promise of an interface that never
existed is gone. Links created before this release carry no explicit choice
and are therefore closed; each one can be reissued in a click with its label,
period and expiry carried over.
**The written summary has left the report.** It could not be checked against
what you chose, and prose whose scope cannot be shown has no place on a
document whose value is exactly that.
**Structured export corrections.** Someone who records their gender as
something other than female or male was exported as female. FHIR has a code
for it and it is now used, and an unrecognised value is left out rather than
guessed. The capability document described a resource twice and omitted
another that was actually served; both corrected.
**Also in this release.** Roughly one page load in three showed raw text keys
for a moment instead of words, because the language file was racing the
application bundle and losing. It is now loaded before the first render.
**Converted values were shown with more precision than they have.** A weight in
pounds could read 207.896 where the conversion declares one decimal place. The
rounding was defined for every conversion and read by exactly one screen out of
all the ones that display a converted value, so everywhere else the raw result
of the multiplication went straight through. It now happens where the
conversion happens, so a surface cannot render an unrounded value even if it
forgets to ask. Metric values are deliberately untouched, because the metric
render is meant to be exactly what is stored, and the entry path keeps full
precision so a saved value cannot drift a little further each time.
Thanks to @DrGithubble, who reported this on #627 after confirming the earlier
unit fix. It turned out to be the second half of the same defect they had found
the first time.
**For operators.** Migration `0275` stores the new scope and removes the old
preferences. Existing share links are closed by it and need reissuing.
## [1.32.38] — 2026-07-25
Blood-glucose readings from anything other than the manual entry form were
never saved.
A rule added years ago required every blood-glucose reading to carry a
context: fasting, after a meal, random, or bedtime. That is a fair thing to
ask of someone typing a reading in by hand. It is impossible for a sensor,
which measures every few minutes and cannot know what you were doing.
So every automated path failed. A CSV import reported the rows as skipped and
still showed a green tick. Nightscout counted them as failed rows and carried
on. Apple Health has no context field at all, so not one of its readings could
ever have been stored. The same for readings written through the automation
interface, through Telegram, and through the JSON importer. None of it was
visible: no error reached anyone, and the affected charts and panels simply
stayed empty as though nothing had been measured.
A reading with no context is now stored with no context, which is what it is.
Where a context is given it is still checked. Manual entry still asks for one,
because the form offers the choices and nothing is lost by asking.
Absence is now shown as absence: a reading without a context is left out of the
per-context breakdowns rather than being counted as one of them.
**Imports no longer claim to have worked when they did not.** A run that wrote
nothing is shown as a failure. A run that wrote some rows and skipped others is
shown as a partial result. Only a run that actually landed something shows as
done. Skipped rows are grouped by reason with a count instead of listing the
first fifty identical lines, and the per-row detail is still there behind a
disclosure. This covers the CSV, JSON and Apple Health cards alike.
Reported by @lutzkind, who traced it to the parser, ran it in isolation to rule
out the timestamp and unit handling, and checked the database afterwards to
prove nothing had been written. Files that were rejected can simply be imported
again.
**For operators.** Migration `0274` relaxes the constraint. It adds no column
and rewrites no row. Readings that were refused in the past were never stored,
so they need a fresh import from the source.
## [1.32.37] — 2026-07-25
Two ways a record could quietly lose data, both closed.
An identifier that a client sends along with a reading is meant to say "this is
the same reading you already have". One client was sending a value that changed
every time the app restarted, so nothing ever matched, and every sync created a
fresh copy. On one instance that produced 23 medications nobody had ever taken,
none of them with a dose or a single logged intake, because the doses were
looking for a medication under an identifier that no longer existed.
The server now refuses an identifier that cannot possibly be stable, with a 422
that says what is wrong instead of quietly making duplicates. A batch rejects
only the offending entry and still accepts the rest, so one bad row cannot stop
a sync. Twelve entry points check it. The identifier shapes that real apps and
devices actually send are pinned by tests, so a future rule cannot get greedy
and start dropping valid data.
The second one was worse and had no symptom at all. The repeat-protection key
on a medication intake was unique across the whole database rather than per
account. Two people generating the same key independently meant the second one
could not save their dose at all. The database refused it, and the intake was
gone. The key is now unique per account, and every lookup asks who it belongs
to. Recording an intake through the automation interface without sending a key
had a related problem: the first one saved and every one after it was discarded
as a repeat. Also fixed.
The migration does not touch any existing intake. It relaxes a rule rather than
tightening one, so nothing that was allowed before becomes disallowed now.
## [1.32.36] — 2026-07-25
Nothing here changes what you see. This release is about the checks that run
before a release goes out.
A recurring shape of mistake in this project is a half-built feature: something
is stored, or sent to the app, and the part that was supposed to use it never
gets written. Each end works on its own, so nothing complains. Three checks now
run on every build and refuse that. One walks the fields the app receives about
your account and insists each one is actually read somewhere. One compares the
measurement table against what a backup writes out, so a new column cannot go
missing from your backup file. The third fixes a check that had gone quiet: it
was meant to find translation entries no screen uses any more, but one line of
it had been waving through several thousand entries at once for a long time
without anyone being able to tell.
With that check honest again, 149 leftover translations turned out to belong to
screens that were rebuilt or removed some time ago. They are gone from all six
languages. Nothing that is on screen today was touched.
## [1.32.35] — 2026-07-25
The doctor report stops asking for your practice name every time.
You type a clinic or practice name onto the cover of a health record export,
and until now that was all it did. The field emptied itself again the moment
you left the panel, so next month's report started from a blank line. The name
you used last time is now kept and filled in for you. Clearing the line works
the same way and is remembered too, so a cover without a practice line stays
without one. The name is saved only once the report has actually been
produced, which means an export that fails partway leaves the previous value
alone.
A backup now carries the origin of your daily totals. Steps, distance and
similar figures that arrive from Apple Health as one number per day are stored
with a note of where that number came from, so a rough estimate read out of an
archive can never overwrite a total the phone measured itself. That note was
missing from the backup file, so a restore brought the figures back without it
and the protection went quiet. It rides the backup now. Restoring an older
backup written before this fix marks the affected daily totals as unclassified
instead of leaving them ambiguous, which is the state a later sync knows how
to repair.
Migration 0272 drops two columns that were written and never read. They held a
contributor count and a source fingerprint for archive derived daily totals,
and no screen, export or repair path has ever looked at either. The figures
themselves and the origin note above are untouched.
## [1.32.34] — 2026-07-25
Housekeeping on the interface. Most of it you will not see.
The sections on the insights overview had each been given their own card frame
by hand, and the copies had drifted apart on the one thing nobody thought to
write down: how much room to leave between the blocks inside them. Some used 8
pixels, some 12, one 16, all sitting one under the other in the same column.
They now share a single frame with a single rhythm, so a few of those cards
open or tighten very slightly and all of them pick up the same faint shadow
their neighbours already had.
When a metric page cannot load, the message now reads in the normal text
colour with a warning glyph beside it, instead of the grey reserved for
timestamps and units. A failure is what that surface has to say at that moment,
so it should not look like a footnote.
The rest is invisible from the outside. The sleep composition card uses the
same title row as every other card, a handful of one-off text sizes and
paddings went back onto the scale the rest of the app uses, and the spacing
rule that was already enforced in the build now has nothing left to catch.
## [1.32.33] — 2026-07-25
The moodLog integration is gone. It bridged mood entries to and from a separate
self-hosted mood app back when HealthLog had no real mood module of its own.
That stopped being true a long time ago: mood is tracked natively here now,
with tags, rated factors and notes, and the bridge had nothing left to add. Its
settings card was withdrawn a while back and the rest of it kept running in
the background against an upstream nobody was pointing at any more. This
finishes the job properly.
- **Your mood entries are untouched.** Every entry stays exactly where it is,
including the ones that originally arrived through moodLog. They keep their
source label, they still show up in the mood list, the filters, the charts,
the correlations and the doctor report. Nothing was deleted and nothing was
relabelled. Removing the pipe does not remove what came through it.
- **What was removed.** The hourly pull, the inbound webhook, the outbound
push, the per-account credentials and webhook secret, the admin toggle, the
entry on the integrations status feed, and the message strings behind the old
card. Migration `0271_drop_moodlog_integration` drops the six columns those
surfaces used and clears the retired sync schedule. It names no mood table.
- **One less admin switch that did more than it said.** The instance-wide
toggle was described as controlling mood-log reminders. It actually shut off
the whole integration, quietly, with no other visible effect. It is gone
along with the thing it gated.
If you were still relying on the bridge, export your mood data from the other
app and import it once; the entries land in the same place they always did.
## [1.32.31] — 2026-07-25
The integrations page could tell you a connection was failing. It could not
tell you a connection had stopped trying. Those look the same from the outside:
the page keeps showing whatever error was recorded last, and it keeps showing
it for as long as nobody looks, because nothing anywhere read how old the last
attempt actually was. This release adds that reading, and every status on the
page now comes from it.
- **A sync that has stopped running is labelled as stopped.** When a connection
has not even attempted to sync for a day, it says so, with how long it has
been. Previously it kept displaying the last error it managed to record, which
reads like something that is still retrying.
- **Per data type, when a value last arrived.** Each connected card now opens a
short list: every data type that connection has ever delivered, and how long
ago the newest one came in. A type that has gone quiet for two weeks while the
rest of the connection is healthy is called out, which is the shape a single
broken pipe takes: one revoked permission, one endpoint that started returning
nothing, while the sync itself keeps reporting success. Types the connection
has never delivered are simply not listed. Nothing is invented.
- **Apple Health has a staleness threshold.** Any timestamp at all used to paint
the green "data received" badge, so a phone that stopped delivering three
weeks ago looked exactly like one that delivered this morning. After a week of
silence the card now says so and points you at the app's Apple Health
permissions. It also lists the same per data type detail as the others.
- **A configured connection with no card of its own now appears.** If your
account has a connection set up that the page has no card for, it gets a plain
status row instead of being left off entirely. That is a structural fix: a
connection can no longer be running, failing and invisible at the same time.
- **The status colours mean one consistent thing.** Red is now reserved for the
cases your own action fixes, which is reconnecting or resuming. An upstream
outage or a rate limit carries the amber tone instead, because clicking
reconnect against a server error has never helped anyone. Every card uses the
same mapping. Three of them used to disagree with each other on the same page.
- **Nightscout reads the same status source as everything else.** It was the
last card fetching its own status separately, which is why it could show a
different colour for the same condition.
The assistant tools report the new verdict too, so asking why your data looks
stale gets the real answer instead of "connected".
## [1.32.30] — 2026-07-25
Height was the last value still asked for in centimetres regardless of the
setting. Both previous releases closed with a note saying so. It sat outside
the fix because height is not a measurement at all: it is a field on your
profile, so the conversion the reading surfaces go through never reached it.
- **Height is entered in the unit you use.** With imperial selected, the
profile card and the onboarding step ask for feet and inches in two boxes,
the way a person actually states a height. With metric selected the single
centimetre box is exactly what it was.
- **The value you type comes back.** Feet and inches are kept to the nearest
whole inch, which converts to centimetres without a remainder, so saving
5 ft 11 in and reopening the page shows 5 ft 11 in again. The limits the
fields offer are the imperial equivalents of the ones the server enforces,
rounded inward, so a height you are allowed to type is never refused on save.
- **Storage is unchanged.** Height is still kept in centimetres, the API still
sends and receives centimetres, and BMI and the weight bands still work from
that same value. A metric account's stored height is untouched by this
release.
- **Switching the unit keeps what you were typing.** The unit selector sits on
the same card as the height field, so flipping it re-expresses whatever is in
the field rather than clearing it.
With this the preference reported in #627 reaches every value the app shows you
or asks you for.
## [1.32.29] — 2026-07-25
Fitbit re-scores a night hours after you wake up: blocks get split, merged or
relabelled. HealthLog identified each block by its position in the list, so a
re-score that renumbered the blocks looked like a set of brand new ones. The
previous version stayed on file, nothing removed it, and from then on the night
counted twice. It was quiet: the sync kept reporting success, because the sync
was in fact working.
- **A re-scored night no longer double counts.** Each sleep block is now
identified by the moment it starts, which does not move when Fitbit rescores
the night. A block that changes from light to deep updates in place instead
of leaving the old copy behind, and anything the new scoring dropped is
cleared from that night's window before the new blocks are written.
- **Nights already affected repair themselves.** A one off pass runs once per
connection after the update and rereads the past year of sleep, so the
duplicates collapse without you doing anything. It covers 365 days, the same
horizon the Fitbit history import uses. Nights older than that keep whatever
they already hold; they cannot gain new duplicates, since Fitbit does not
rescore year old nights. Migration `0270_fitbit_sleep_repair_marker` records
which connections have been through the pass.
- **Rate limits are handled instead of ignored.** When Fitbit asks for a short
wait, the request now waits and tries again once. A long wait is refused
outright and left to the next scheduled run, so one throttled account cannot
hold up everybody else's.
- **Fewer pointless writes.** Fitbit rereads the last 24 hours every hour. Rows
that came back unchanged were rewritten anyway, which showed up as needless
traffic for the iOS app. Unchanged rows are now left alone.
## [1.32.28] — 2026-07-25
Oura used to look back exactly one week and no further. If the sync had been
broken for longer than that, or if the ring sat unused and the older nights
only reached Oura's cloud later, those days fell outside the window and were
never imported. Nothing said so, because a sync that finds nothing still counts
as a sync that worked.
- **Oura catches up after a gap.** The window is now measured from the newest
Oura reading actually on file rather than from a fixed week. A ten day gap
pulls eleven days on the next run and closes itself; a month is the ceiling.
When data is arriving normally the window stays at a week and nothing about
the hourly run changes.
- **A fresh Oura connection imports a month** instead of a week. Deeper history
is a separate question and is not part of this release.
- **Sync now for Oura, Polar, Strava and Nightscout.** These four had no way to
ask for a sync; they only ever ran on their scheduled pass. Each connected
card gets the same button WHOOP and Fitbit already had, limited to a few runs
per minute.
- **Connecting starts the first sync straight away.** Oura, Polar and
Nightscout used to sit empty for up to an hour after connecting, waiting for
the next scheduled pass. The first pull now runs on connect, and the schedule
stays as the fallback.
## [1.32.27] — 2026-07-25
Target and threshold panels now follow the metric and imperial preference. The
previous release listed this as the one place still reading in kilograms and
Celsius: with imperial selected a weight chart read in pounds while the target
reference right below it read in kilograms.
- **Target ranges read in your unit.** On the weight insight page the target
band, the range bar, the status pill, and the 30 day average all read in
pounds when imperial is selected. Body water and bone mass follow the same
rule in the threshold settings.
- **Editing a target works in the unit you see.** The target editor and the
threshold settings seed their fields from the stored value converted to your
unit, check what you type against limits expressed in that same unit, and
convert back before saving. A value typed at the displayed limit is accepted
instead of being rejected against a kilogram bound you never saw. Saving and
reopening shows the number you typed.
- **Storage is unchanged.** Targets are still kept in canonical SI, and a metric
account's saved values are untouched by this release.
- **Known remaining.** Height is still entered in centimetres.
This finishes the job @DrGithubble started in #627.
## [1.32.26] — 2026-07-25
The metric and imperial display preference is now applied everywhere your
weight, body composition, waist, and temperature readings appear. Until now the
setting was saved but almost nothing read it, so the numbers stayed in
kilograms and Celsius regardless of the choice. Thanks to @DrGithubble, who
reported this in #627 and traced it himself, down to the hardcoded kilograms in
the entry form and the missing imperial branch in the display transform. His
follow-up was the more useful half: a reading genuinely tagged in another unit
was being relabelled by any surface that printed the type's canonical unit. That
is what turned a one-screen fix into a proper one.
- **Weight and body composition follow the preference.** With imperial
selected, weight, muscle mass, body water, bone mass, fat mass, and grip
strength read in pounds across the dashboard tiles, the charts, the record
list, the insight pages, and the coach read strip. Body temperature, skin and
wrist temperature read in degrees Fahrenheit, and waist reads in inches.
Storage is unchanged: every reading is still kept in canonical SI, and a
metric user sees exactly the same numbers as before.
- **Manual entry converts at the point of entry.** When you log or edit a value
with imperial selected, the field is labelled in your unit and the number you
type is converted back to canonical on save, so a pound reading is stored as
the correct kilogram value rather than a mislabelled one.
- **A logging tool can no longer store a foreign unit.** The measurement logging
tool used by connected assistants now converts a recognised unit hint (such as
pounds or Fahrenheit) to canonical, and refuses anything it does not
recognise, so a reading is never saved under the wrong unit.
- **Known remaining.** Height is entered in centimetres for now. Target and
threshold panels also still read in kilograms and Celsius, so with imperial
selected a weight chart reads in pounds while its target reference below it
reads in kilograms. The stored values are correct either way; both are
separate follow-ups.
## [1.32.25] — 2026-07-24
Mirrored medications are now marked as such, and the server limits how many
medications one external source can mirror so a client bug can no longer fill
the list with duplicates.
- **Mirrored medications are marked on the list.** The medications list now
reports whether each entry was mirrored from an external source (today Apple
Health) or created in HealthLog directly. A synced row is no longer
indistinguishable from one you added yourself.
- **One external source can no longer flood the list.** The server refuses to
mirror more than 30 medications from a single external source per account. A
client that keeps re-sending the same medication under a changing identifier
can no longer create a fresh row on every sync. A repeat of a medication
already mirrored still resolves to the existing row, and medications you add
yourself are never affected.
## [1.32.24] — 2026-07-24
Polar sync is more resilient when one data type is unavailable. A single
+5 -4
View File
@@ -38,7 +38,7 @@ Prefer patch bumps. A release dominated by bug fixes plus additive features with
| Framework | Next.js 16.2.6 — App Router, `output: "standalone"` | SWC strips `console.*` in prod except `error/warn`. |
| Language | TypeScript 6, `strict: true`, `moduleResolution: "bundler"` | Path alias `@/*` → `./src/*`. `scripts/` excluded from typecheck. |
| UI | React 19 (exact pin), Tailwind 4, shadcn/ui (new-york style, zinc base), Radix, Lucide | Recharts 3 for charts — stays; replacement requires explicit approval. |
| Persistence | PostgreSQL 16 + Prisma 7.8 — 80 models | Generated client at `src/generated/prisma`. Production image ships a separate `/opt/prisma-cli` install + `/opt/pg-boss` runtime install (see `Dockerfile:93-100`). |
| Persistence | PostgreSQL 16 + Prisma 7.8 — 115 models | Generated client at `src/generated/prisma`. Production image ships a separate `/opt/prisma-cli` install + `/opt/pg-boss` runtime install (see `Dockerfile:93-100`). |
| Queue | pg-boss 12.18 | Cron + retry semantics live in Postgres tables; workers under `src/lib/jobs/`. |
| Auth | `@simplewebauthn/server` 13, `@node-rs/argon2` 2 | Passkey + Argon2id password, server-side sessions in Postgres. |
| Notifications | `@parse/node-apn` 8 (APNs), `web-push` 3 (VAPID), raw fetch for Telegram + ntfy | Per-channel `recordPushAttempt` row, hard-reject classification. |
@@ -89,6 +89,7 @@ The architecture map in `.planning/codebase/arch.md` walks each layer with file:
- **Every API route wraps in `apiHandler`, with three documented exceptions.** The MCP OAuth routes (`mcp/oauth/{authorize,token,register}`) use `withBackgroundEvent` instead, because OAuth requires RFC-format error bodies that the standard envelope cannot express. They carry the same protections by hand — rate limit, body cap, kill switch, mandatory PKCE-S256, redirect-URI allowlist. Every other route wraps. Every body-accepting route runs Zod `safeParse` and returns 422 via `returnAllZodIssues` (multi-issue envelope sanitised against echoed input through `sanitiseZodIssues`).
- **`userId` is always narrowed from session or Bearer.** No route accepts `userId` as a body field — it comes from `requireAuth()` and feeds the Prisma `where`. The batch endpoints (`/api/measurements/batch`, `/api/mood-entries/bulk`, `/api/medications/intake/bulk`, `/api/workouts/batch`) don't even declare a `userId` field in their Zod schemas.
- **No mass assignment.** Every `prisma.X.{create,update}({ data: ... })` builds its `data` object field-by-field from `parsed.data`, never by spreading the parsed object whole. `tokens.create` builds `permissions` from a hardcoded literal, never from request input.
- **A two-ended change carries both ends.** Adding a column, a payload field, or a user preference without its consumer is the failure mode this repo keeps rediscovering: the column ships with the schema change, the reader is "the follow-up", and nothing in the gate notices because every other check proves one end. So the consumer lands in the same release branch, or the schema comment says why it cannot: `/// @internal: <reason>` for something deliberately never surfaced, `/// @pending-consumer(#issue)` for a consumer that is genuinely still coming. `pnpm dlx tsx scripts/audit-column-readers.ts` sweeps for unannotated write-only columns and re-lists every `@pending-consumer` marker so a follow-up that never happened surfaces at the next release audit. Run it in the deep-audit step; it is a review tool with a grep-shaped matcher, deliberately not a gate. The two questions that can be answered exactly are gates instead — `account-payload-consumer-guard.test.ts` (every `/api/auth/me` field has a client reader) and `measurement-backup-completeness.test.ts` (every `Measurement` column reaches the backup).
- **Bearer scope is fail-closed.** `requireAuth()` with no argument admits a cookie session or a wildcard (`["*"]`) token only — a narrow-scope token is refused with 403 and an `undeclared_scope` audit row. A route that means to accept a narrow token names the scope: `requireAuth(FHIR_READ_SCOPE)`. Adding a route therefore cannot silently widen an existing token's reach, because the default arm denies. Only the login / passkey / refresh paths mint `["*"]`; every user-facing mint is narrow, and narrow now means narrow. Two surfaces resolve a Bearer token outside `requireAuth` — `/api/ingest/medication` and `/mcp` — and that set is frozen by a structural test (`src/__tests__/bearer-scope-enforcement-guard.test.ts`).
- **`requireAdmin()` is cookie-only.** Bearer tokens — even with `["*"]` scope — cannot reach admin endpoints. This is a structural boundary in `requireAdmin()` (`src/lib/api-handler.ts`), not a runtime check that future code can soften.
- **Raw SQL is parameter-bound or whitelist-spliced.** Every `$queryRaw` uses tagged-template parameters. Every `$queryRawUnsafe` / `$executeRawUnsafe` either passes positional `$N` placeholders with an argument array OR splices a value already asserted against a closed enum + regex (see the whitelist-splice block in `src/lib/rollups/measurement-rollups.ts`, around the `$queryRawUnsafe` calls). Inline comments document the whitelist at the splice point.
@@ -127,7 +128,7 @@ The architecture map in `.planning/codebase/arch.md` walks each layer with file:
| Notification dispatcher | `src/lib/notifications/dispatcher.ts` + `src/lib/notifications/senders/` | APNs → Telegram → ntfy → Web Push cascade, hard-reject classification, `push_attempts` ledger with 90-day retention. |
| Coach | `src/app/api/insights/chat/route.ts` + `src/lib/ai/coach/` | SSE stream, budget gate + per-user rate gate, refusal detector, snapshot builder, message persistence with `encryptedContent` Bytes column. |
| OpenAPI | `src/lib/openapi/registry.ts` + `src/lib/openapi/routes/` (23 modules, `index.ts` barrel) | Source of truth for `docs/api/openapi.yaml`. |
| Schema | `prisma/schema.prisma` (80 models) | `cuid()` PKs, `snake_case` columns via `@map`, encrypted columns mostly under `*Encrypted` (search the file). |
| Schema | `prisma/schema.prisma` (115 models) | `cuid()` PKs, `snake_case` columns via `@map`, encrypted columns mostly under `*Encrypted` (search the file). |
| Compose | `docker-compose.yml` | `app` + `db`. Env-var whitelist under `environment:` — vars not listed never reach the container. `pull_policy: always` is load-bearing. |
`.planning/codebase/arch.md` carries the full annotated walk for every section.
@@ -140,7 +141,7 @@ The v1.5.2 audit produced `.planning/security-audit-v1.5.2.md` and a stack of pe
- **SSRF: input-time `isPublicUrl` is the floor.** The helper `isPublicUrl()` in `src/lib/validations/notifications.ts` catches every IPv4 / IPv6 alt-notation class (octal / hex / decimal IPv4, IPv4-mapped IPv6, IPv6 ULA, CGNAT, link-local, loopback, metadata). Use it for every outbound URL that comes from a user / admin input. DNS rebinding and the redirect-follow gap remain as architectural follow-ups (issues #217 + #218). Until those land, every NEW outbound `fetch()` that touches a user-controlled host must pin `redirect: "manual"` and an `AbortSignal.timeout`.
- **Outbound egress goes through `safeFetch`.** `src/lib/safe-fetch.ts` is the one documented egress entry (`redirect: "manual"` + `AbortSignal.timeout(15_000)` defaults; opt into the connect-time DNS-rebinding pin with `requirePublicHost: true` for any operator- or user-supplied host). The in-repo ESLint rule `healthlog/safe-fetch-required` (set to `error`) bans raw `fetch(` under `src/lib/` + `src/app/` outside the wrapper internals and test files; same-origin relative-path (`/api/…`) client fetches are exempt by construction.
- **Rate limits live in Postgres.** `src/lib/rate-limit.ts` uses a single atomic SQL upsert returning `{count, reset_at}`. Multi-instance correctness is structural. Bucket-key convention: `<surface>:${identifier}` where identifier is `userId` for authenticated routes and the trusted-proxy IP for anonymous. Anonymous auth surfaces use `checkAuthSurfaceRateLimit(...)` so a trust-violation collapses every anonymous caller into a single tight bucket rather than a free-for-all under the unknown-IP bucket.
- **Webhooks: shared-secret + `timingSafeEqual` + per-source rate limit BEFORE secret verification.** Withings, Telegram, moodLog, the Coolify deploy webhook, and the CSP report endpoint all follow this exact pattern. The Withings path-segment secret is scrubbed from `http.path` / `http.route` by `redactSecrets()` via the `PATH_SECRET_PATHS` registry in `src/lib/logging/redact.ts:40`.
- **Webhooks: shared-secret + `timingSafeEqual` + per-source rate limit BEFORE secret verification.** Withings, Telegram, the Coolify deploy webhook, and the CSP report endpoint all follow this exact pattern. The Withings path-segment secret is scrubbed from `http.path` / `http.route` by `redactSecrets()` via the `PATH_SECRET_PATHS` registry in `src/lib/logging/redact.ts:40`.
- **Idempotency keys are user-scoped, length-bounded, and refuse to cache secret-shaped responses.** `src/lib/idempotency.ts`. A client cannot replay another user's idempotency key (composite unique index by `userId_key_method_path`). The cache helper refuses to persist a response body containing `hlk_` / `hlr_` / `sk-` patterns even if a future caller wraps an auth endpoint by mistake.
- **CSP: strict, per-request nonce, surgical `connect-src`.** Production CSP gates third-party hosts to the routes that need them — AI hosts only on `/settings/ai/*`, `wbsapi.withings.net` only on `/settings/integrations/withings/*` + `/api/withings/*`. Exactly one `dangerouslySetInnerHTML` in the tree (the theme bootstrap in `src/app/layout.tsx`, nonce-bound, file-literal content). No markdown library installed anywhere; Coach / briefing / insights render as plain React text children. Don't add one without a security review of the renderer's HTML-allow defaults.
- **No CORS headers anywhere.** Same-origin-only by construction. Adding `Access-Control-Allow-Origin: *` anywhere would silently open every authenticated endpoint to cross-origin POST; webhook routes accept cross-origin POSTs but they authenticate by header secret, not cookie.
@@ -153,7 +154,7 @@ These keep tripping operators; they're worth knowing before you push something t
- **`SESSION_COOKIE_SECURE`** controls the `Secure` flag on every cookie the app issues. Unset → flag tracks `NODE_ENV === "production"`. Set to `false` for LAN / Tailscale / VPN-only HTTP self-hosts where the operator deliberately serves plain HTTP. Set to `true` to force the flag even in dev. The compose `environment:` block must list it explicitly — vars not on the whitelist don't reach the container even when `.env` has them. v1.5.2 closed the missing-whitelist regression.
- **`pull_policy: always`** in `docker-compose.yml` is load-bearing. Without it Docker keeps a stale `:latest` digest cached and silently skips the registry round-trip on `compose up`.
- **`ENCRYPTION_KEYS` map vs legacy `ENCRYPTION_KEY`.** The map (`{"v1":"<hex>","v2":"<hex>"}` + `ENCRYPTION_ACTIVE_KEY_ID=v2`) is the modern path. The legacy single-key env var stays accepted as `v1`. Rotation playbook lives at `docs/ops/encryption-key-rotation.md`; do NOT drop a key from the map before the rotation script reports zero remaining rows on that key.
- **GeoLite2 databases.** Operators run `scripts/fetch-geolite2.sh` with a MaxMind licence key before `docker build`; the image bakes the MMDBs into `/opt/geolite2/`. Without them the resolver falls back to online `ipwho.is` and the admin login overview surfaces "unknown" for offline lookups.
- **GeoLite2 databases.** Two ways in. Building your own image: run `scripts/fetch-geolite2.sh` with a MaxMind licence key before `docker build` and the MMDBs bake into `/opt/geolite2/`. Running the published image: mount a directory of `.mmdb` files and point `GEOLITE2_DIR` at it — it is on the compose `environment:` whitelist and documented in `docs/self-hosting/geolite2.md`. Without either, the resolver falls back to the online provider and every login IP leaves the host.
- **APNs `.p8` key.** v1.4.47.x established `APNS_KEY_B64` as the env-var contract — pasting the PEM body verbatim through Coolify's `env_file` mangled the literal newlines. The key must be scoped "Sandbox & Production" in the Apple Developer Portal; a Sandbox-only key fails with `BadEnvironmentKeyInToken` on production builds.
## iOS handoff
+1 -1
View File
@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project
HealthLog — a personal health-tracking PWA (weight, blood pressure, pulse, mood, medication compliance) with Withings integration, moodLog.app sync, Dracula-themed UI, mobile-first design, offline caching, and doctor report PDF export.
HealthLog — a personal health-tracking PWA (weight, blood pressure, pulse, mood, medication compliance) with Withings integration, Dracula-themed UI, mobile-first design, offline caching, and doctor report PDF export.
## Commands
+2 -2
View File
@@ -1,11 +1,11 @@
{
"$comment": "Client-bundle budgets, enforced by scripts/check-bundle-budget.mjs (--check) after `next build` in CI. Values are KB gzip and include the shared rootMainFiles baseline. Measured at v1.32.0 after the correctness/UX architecture wave: / 425, /insights 411, /measurements 412, /insights/mood 428, total 2982, baseline 129. Route caps retain focused headroom; the aggregate cap allows normal chunk noise while still catching a new ~90 KB duplicated library. Message catalogs and duplicate Recharts chunks have dedicated structural guards.",
"$comment": "Client-bundle budgets, enforced by scripts/check-bundle-budget.mjs (--check) after `next build` in CI. Values are KB gzip and include the shared rootMainFiles baseline. Re-measured at v1.33.0: / 429, /insights 414, /measurements 416, /insights/mood 432, total 3060, baseline 130. The aggregate rose 78 KB from v1.32.0's 2982 because that release adds surfaces rather than libraries: a dose-history import card, inventory date editing, fourteen confirmation dialogs, and a job-failure panel. Every per-route cap still passes with headroom, and the thing this aggregate exists to catch (a newly duplicated ~90 KB library) did not happen: the Recharts chunk count is still 1 and structurally guarded. Raised 3050 to 3120 for that reason, not to make a red check green. Route caps retain focused headroom. Message catalogs and duplicate Recharts chunks have dedicated structural guards.",
"routesKbGz": {
"/page": 460,
"/insights/page": 445,
"/measurements/page": 445,
"/insights/mood/page": 460
},
"totalClientKbGz": 3050,
"totalClientKbGz": 3120,
"maxRechartsChunks": 1
}
+23 -2
View File
@@ -15,8 +15,10 @@
# Leave it unset (or true) only when a TLS reverse proxy serves HTTPS.
#
# Persistent state lives in exactly one named volume (`pgdata`, Postgres'
# data dir). The app container is stateless — GeoLite2 MMDBs are baked into
# the image — so a backup of the database is a backup of everything.
# data dir). The app container is stateless, so a backup of the database is
# a backup of everything. The optional GeoLite2 databases are either baked
# into the image at build time or mounted read-only from the host and
# pointed at with GEOLITE2_DIR — either way they are replaceable, not state.
services:
# Single-container default (HEALTHLOG_PROCESS_TYPE=all). Existing
# self-host deployments stay working with `docker compose up`.
@@ -96,6 +98,15 @@ services:
IP_GEO_LOOKUP_URL: "${IP_GEO_LOOKUP_URL:-}"
IP_GEO_LOOKUP_DISABLED: "${IP_GEO_LOOKUP_DISABLED:-}"
IP_GEO_ALLOW_INSECURE: "${IP_GEO_ALLOW_INSECURE:-}"
# Directory the offline GeoLite2 databases are read from. Default
# `/opt/geolite2` — the path the published image bakes its own copy
# into, if the build had a MaxMind licence key. Point this at a
# directory you mount yourself (see the `volumes:` block below) to
# bring your own GeoLite2-City.mmdb + GeoLite2-ASN.mmdb, refreshed
# with `geoipupdate` on your own schedule. Listed here so an operator
# value in .env reaches the container (the `environment:` block is a
# whitelist). See docs/self-hosting/geolite2.md.
GEOLITE2_DIR: "${GEOLITE2_DIR:-/opt/geolite2}"
# all = bundle web + worker in this container (default, single-host)
# web = serve HTTP only; pair with a separate app-worker service
HEALTHLOG_PROCESS_TYPE: "${HEALTHLOG_PROCESS_TYPE:-all}"
@@ -219,6 +230,16 @@ services:
# Set "true" only if your own iOS build carries Apple's Critical Alerts
# entitlement; otherwise urgent alerts use the time-sensitive level.
APNS_CRITICAL_ENTITLEMENT: "${APNS_CRITICAL_ENTITLEMENT:-}"
# Bring your own GeoLite2 databases. The published image only carries
# MMDBs if the build had a MaxMind licence key, so on `:latest` the
# offline tier is usually absent and every login IP goes to the online
# provider instead. Create a free MaxMind account, pull
# GeoLite2-City.mmdb + GeoLite2-ASN.mmdb with `geoipupdate`, mount the
# directory read-only, and set GEOLITE2_DIR to the container path.
# Steps: docs/self-hosting/geolite2.md.
#
# volumes:
# - ./geolite2:/var/lib/geolite2:ro
depends_on:
db:
condition: service_healthy
+4 -1
View File
@@ -23,7 +23,10 @@ notes, and machine-readable specs that ship alongside the source:
off-by-default MCP connector and pointing Claude.ai / ChatGPT /
Claude Desktop at it, and [`self-hosting/sso.md`](./self-hosting/sso.md)
— OIDC SSO login: IdP setup, the identity-pinning security model,
`OIDC_ONLY` consequences, and the break-glass runbook.
`OIDC_ONLY` consequences, and the break-glass runbook. Also
[`self-hosting/geolite2.md`](./self-hosting/geolite2.md) — bringing your
own offline GeoLite2 databases via `GEOLITE2_DIR` instead of sending
login IPs to a third-party lookup.
- [`migration/`](./migration/) — release-by-release migration notes.
Read the entry for the version you're upgrading from.
- [`audit/`](./audit/) — per-release audit summaries archived for
+478 -220
View File
File diff suppressed because it is too large. Load diff
+3 -3
View File
@@ -1,6 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 620" font-family="ui-sans-serif, -apple-system, Segoe UI, Roboto, sans-serif" role="img" aria-labelledby="title desc">
<title id="title">HealthLog data flow — device to insight</title>
<desc id="desc">Five-stage pipeline: sources (Withings, Apple Health export, iPhone HealthKit, manual entry, moodLog.app) feed ingest endpoints, which write to Postgres Measurement and MeasurementRollup tables, then surface on Dashboard tiles, Insights cards, the AI Coach, and the Doctor PDF.</desc>
<desc id="desc">Five-stage pipeline: sources (Withings, Apple Health export, iPhone HealthKit, manual entry, Nightscout) feed ingest endpoints, which write to Postgres Measurement and MeasurementRollup tables, then surface on Dashboard tiles, Insights cards, the AI Coach, and the Doctor PDF.</desc>
<!-- Dracula background -->
<rect x="0" y="0" width="1200" height="620" fill="#282a36" />
@@ -45,8 +45,8 @@
<text x="120" y="411" text-anchor="middle" fill="#bd93f9" font-size="11">PWA · iOS Shortcuts</text>
<rect x="30" y="444" width="180" height="56" rx="10" fill="#44475a" stroke="#8be9fd" stroke-width="1.5" />
<text x="120" y="469" text-anchor="middle" font-weight="600">moodLog.app</text>
<text x="120" y="487" text-anchor="middle" fill="#bd93f9" font-size="11">webhook</text>
<text x="120" y="469" text-anchor="middle" font-weight="600">Nightscout</text>
<text x="120" y="487" text-anchor="middle" fill="#bd93f9" font-size="11">pull sync</text>
</g>
<!-- INGEST (orange #ffb86c) -->

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

+1 -1
View File
@@ -81,7 +81,7 @@
<rect x="1010" y="240" width="160" height="100" rx="10" fill="#44475a" stroke="#8be9fd" stroke-width="1.5" stroke-dasharray="4 4" />
<text x="1090" y="266" text-anchor="middle" fill="#f8f8f2" font-size="13" font-weight="600">Withings</text>
<text x="1090" y="282" text-anchor="middle" fill="#6272a4" font-size="11">Apple Health</text>
<text x="1090" y="298" text-anchor="middle" fill="#6272a4" font-size="11">moodLog.app</text>
<text x="1090" y="298" text-anchor="middle" fill="#6272a4" font-size="11">Nightscout</text>
<text x="1090" y="314" text-anchor="middle" fill="#6272a4" font-size="11">AI provider</text>
<text x="1090" y="332" text-anchor="middle" fill="#bd93f9" font-size="11">(per-user)</text>
</g>

Before

Width:  |  Height:  |  Size: 8.1 KiB

After

Width:  |  Height:  |  Size: 8.1 KiB

+1 -1
View File
@@ -8,7 +8,7 @@ copy.
| File | Story it tells |
| ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `01-data-flow.svg` | Sources (Withings, Apple Health export.zip, iPhone HealthKit, manual, moodLog) → ingest endpoints → Measurement + MeasurementRollup → reads (rollup probe / live SQL fallback / cache) → surfaces (dashboard, insights, Coach, doctor PDF). |
| `01-data-flow.svg` | Sources (Withings, Apple Health export.zip, iPhone HealthKit, manual, Nightscout) → ingest endpoints → Measurement + MeasurementRollup → reads (rollup probe / live SQL fallback / cache) → surfaces (dashboard, insights, Coach, doctor PDF). |
| `02-coach-pipeline.svg` | User question → snapshot builder → prompt assembler → provider chain (ChatGPT OAuth / OpenAI / Anthropic / local / admin-shared) → response parser → cited reply with mini-charts. |
| `03-self-hosting-topology.svg` | Internet → reverse proxy (TLS) → Next.js app (port 3000) ↔ pg-boss worker ↔ Postgres 16, plus GHCR image pull, optional Coolify autodeploy, optional S3 off-host backup. |
| `04-source-priority.svg` | Three sources for the same metric on the same day → resolver picks one canonical row per (cumulative vs point) ladder. Losing rows stay in the audit trail. |
+58 -20
View File
@@ -9,14 +9,15 @@ Ollama endpoint can fall back from one to the other automatically.
## Provider matrix
| Provider | Key acquisition | Default model | Endpoint | Privacy stance |
| --------------- | --------------------------------------------- | ------------------- | ----------------------------------------- | ------------------------------------- |
| `OPENAI` | <https://platform.openai.com/api-keys> | `gpt-4o` | `https://api.openai.com/v1` | Measurement context sent to OpenAI |
| `ANTHROPIC` | <https://console.anthropic.com/settings/keys> | `claude-sonnet-4-6` | Anthropic SDK default | Measurement context sent to Anthropic |
| `LOCAL` | None (your endpoint) | `local-model` | OpenAI-compatible URL you control | Stays on your network |
| `CHATGPT_OAUTH` | Sign in with your ChatGPT account | Codex-routed | `chatgpt.com/backend-api/codex/responses` | Routed via your ChatGPT subscription |
| Provider | Key acquisition | Default model | Endpoint | Privacy stance |
| ------------------- | --------------------------------------------- | ------------------- | ----------------------------------------- | --------------------------------------- |
| `OPENAI` | <https://platform.openai.com/api-keys> | `gpt-4o` | `https://api.openai.com/v1` | Measurement context sent to OpenAI |
| `ANTHROPIC` | <https://console.anthropic.com/settings/keys> | `claude-sonnet-4-6` | Anthropic SDK default | Measurement context sent to Anthropic |
| `LOCAL` | None (your endpoint) | `local-model` | OpenAI-compatible URL you control | Stays on your network |
| `OPENAI_COMPATIBLE` | Your gateway's key, or none | none (you name it) | OpenAI-compatible URL you configure | Reaches whatever your gateway routes to |
| `CHATGPT_OAUTH` | Sign in with your ChatGPT account | Codex-routed | `chatgpt.com/backend-api/codex/responses` | Routed via your ChatGPT subscription |
The four spellings above are the canonical `User.aiProvider` enum
The five spellings above are the canonical `User.aiProvider` enum
values. Anything else falls through to the admin-shared OpenAI key
(if configured) or the `NoProvider` stub that surfaces "No AI
provider configured" to the UI.
@@ -54,6 +55,11 @@ order. The default is encoded in
4. `local` — self-hosted Ollama / LM Studio / vLLM endpoint.
5. `admin-openai` — operator's shared key (last-ditch).
`openai-compatible` is a sixth chain entry you can add in the editor.
It is deliberately not in the default chain: it resolves only against a
base URL you saved, so a default entry would be dead weight for
everyone who never configures a gateway.
Entries the user has no credential for are silently skipped. The
runner walks the surviving entries in order; the first to return a
non-error completion wins. A 422 fires only when every entry in the
@@ -150,34 +156,59 @@ internal endpoint.
16-24 GB GPU / M-series Pro/Max (sweet spot for BYOK-style usage),
70B+ for 48 GB+ / multi-GPU (comparable to mid-tier hosted models).
### OpenAI-compatible gateways (LiteLLM, OpenRouter, …)
## OpenAI-compatible gateways (LiteLLM, OpenRouter, vLLM, …)
The **Local** provider is also the gateway path: any service that
speaks the OpenAI `/v1/chat/completions` wire plugs in the same way,
whether it runs on your host or not. The OPENAI provider deliberately
pins `api.openai.com` (an OpenAI key is never forwarded to a custom
host), so a gateway always goes through **Local**:
Since v1.33.1 a gateway has its own provider: **OpenAI-compatible
gateway**. Any service that speaks the OpenAI `/v1/chat/completions`
wire plugs in there, whether it runs on your network or not.
1. In `/settings/ai`, set provider to **Local (OpenAI-compatible)**.
1. In `/settings/ai`, set provider to **OpenAI-compatible gateway**.
2. Base URL is the gateway's OpenAI-compatible root and **ends with
`/v1`** — e.g. `https://litellm.example.com/v1` or
`https://openrouter.ai/api/v1`.
`/v1`** — e.g. `https://litellm.example.com/v1`,
`https://openrouter.ai/api/v1`, or `http://vllm.lan:8000/v1`.
3. API key is **optional** — set it when the gateway requires a
Bearer token (OpenRouter key, LiteLLM master key), leave it blank
otherwise. It is encrypted at rest like every other credential.
otherwise. It is encrypted at rest like every other credential,
in its own column.
4. Model is whatever the gateway routes — e.g.
`anthropic/claude-sonnet-4-6` on OpenRouter or a LiteLLM alias.
Leave it blank and the model saved for your other providers is
used; with neither set the provider is skipped.
Then add **OpenAI-compatible gateway** to the fallback chain (it is
not in the default one) and put it where you want it tried.
**Why it is a separate provider and not a base-URL field on OPENAI.**
The OPENAI provider pins `api.openai.com`, and the gateway provider
reads three columns of its own: its base URL, its own key, its own
model. Neither can see the other's. A key you issued to OpenAI can
therefore never be sent to a host you typed into a different form,
and that property is structural rather than a comment — the OPENAI
arm has no code path that reads the gateway's base URL.
**SSRF guard.** The gateway's base URL passes the same floor as the
Local provider's: a public host always, a private one only when the
operator allowlisted it via `ALLOW_LOCAL_AI_PRIVATE_HOSTS` (documented
under Local endpoints above). LAN gateways are the normal case for LiteLLM and vLLM, so
the escape hatch matters here as much as it does for Ollama.
JSON surfaces send the standard
`response_format: { type: "json_object" }`; an endpoint that rejects
the field is detected on the first refusal and retried without it,
so strict gateways and older local servers both work unmodified.
**The Local provider still works for this** and nothing about it
changed — it speaks the same wire and honours the same base URL. Pick
the gateway provider when the endpoint is a gateway (it says so in
the UI, it keeps a separate key, and it can sit in the chain
alongside Local), and pick Local for a model running on your own
machine.
The **admin** server-key path has a separate guard: an admin-set
custom base URL must additionally be allowed via
`ADMIN_AI_BASE_URL_ALLOWLIST` in the container environment. The
user-level Local provider does not read that variable — it is
governed by the SSRF guard above.
`ADMIN_AI_BASE_URL_ALLOWLIST` in the container environment. Neither
the user-level Local provider nor the gateway provider reads that
variable — both are governed by the SSRF guard above.
## ChatGPT OAuth (Codex)
@@ -256,6 +287,13 @@ provider instead; their own entries always resolve first.
- **LOCAL** keeps everything on your network. Snapshots reach only
the endpoint you configured. With Ollama on the same host as
HealthLog, nothing leaves the host at all.
- **OPENAI_COMPATIBLE** reaches exactly the endpoint you configured,
and then wherever that endpoint routes. A gateway on your own
network keeps the data there; a hosted gateway forwards it to
whichever provider it fronts, under that provider's retention
policy. HealthLog cannot see past the gateway, so the honest
statement is the one above: this provider's privacy stance is your
gateway's.
- **CHATGPT_OAUTH** routes via OpenAI's Codex endpoint. Same caveat
as OpenAI; the difference is the billing axis (ChatGPT
subscription instead of API tokens).
+72 -3
View File
@@ -1,6 +1,6 @@
# Data import
HealthLog has two ways to bring data in, both reachable from
HealthLog has three ways to bring data in, all reachable from
**Settings → Export & Import**:
1. An **Apple Health `export.zip`** upload — the full archive from the
@@ -10,10 +10,18 @@ HealthLog has two ways to bring data in, both reachable from
2. A **generic JSON import** — a small, explicit payload of measurements
and mood entries, useful for restoring a partial export or migrating
from another tracker. This page documents that format.
3. A **CSV import** — one measurement per row, for a spreadsheet or a
meter / sensor export. Documented in section 3 below.
Both paths skip rows that already exist, so re-running an import is
All three paths skip rows that already exist, so re-running an import is
safe — it merges rather than duplicating.
Every import reports what it actually wrote. A file where nothing was
written is shown as a failure, a file where some rows landed and some
were refused is shown as a partial result, and the refused rows are
grouped by reason so one bad column reads as one line rather than a
scroll.
## 1. Apple Health `export.zip`
On the iPhone: **Health app → profile picture → Export All Health
@@ -56,7 +64,28 @@ skipped and counted under `skipped` in the response.
| `measuredAt` | yes | string | ISO-8601 datetime, e.g. `2026-05-01T08:00:00.000Z`. |
| `source` | no | string | Free-text origin label. Imported rows are tagged `IMPORT` server-side regardless. |
| `notes` | no | string | Optional free text. |
| `glucoseContext` | no | enum | Only for `BLOOD_GLUCOSE` (e.g. fasting / post-meal). |
| `glucoseContext` | no | enum | Only for `BLOOD_GLUCOSE`. See [Glucose context](#glucose-context). |
### Glucose context
`glucoseContext` is one of `FASTING`, `POSTPRANDIAL`, `RANDOM`,
`BEDTIME`, and it is genuinely optional on a `BLOOD_GLUCOSE` row. Leave
it out (JSON) or leave the cell blank (CSV) and the reading is stored
with no context.
That is the normal case for a continuous sensor or meter export, which
records a value and a timestamp per reading and classifies nothing. A
reading with no context still counts everywhere a reading counts: the
chart, the all-time summary, and the trailing-30-day clinical panel
(time in range, GMI, CV%, estimated A1c) are all context-agnostic. It is
left out only of the per-context breakdowns — the doctor report's
fasting / post-meal rows, the matching FHIR observations, the per-context
targets — because no classification was recorded and none is invented.
Setting the column on any type other than `BLOOD_GLUCOSE` is rejected.
Setting it to a value outside the four above is rejected. Entering a
reading by hand in the app still asks for a context, because at that
moment you know it.
### Mood entry
@@ -195,8 +224,48 @@ The response is a count envelope:
}
```
## 3. CSV import
`POST /api/import/csv` takes the raw file as `text/csv` — one measurement
per row, no JSON wrapper. The **CSV file** control in
**Settings → Export & Import** posts to it, and **Download example**
mints a file with the right header.
A header row is required; column order does not matter and header
matching is case-insensitive.
| Column | Required | Notes |
| ---------------- | -------- | --------------------------------------------------------------------------------------------------------- |
| `type` | yes | A `MeasurementType` from the table above. |
| `value` | yes | A number, in the row's `unit`. |
| `unit` | yes | The canonical unit, or a recognised alias: `mmol/L` → `mg/dL`, `lb` → `kg`, `in` → `cm`, `°F` → `°C`. |
| `measuredAt` | yes | ISO-8601 **with an explicit offset** (`Z` or `±HH:MM`, and `±HHMM` is accepted). No offset, no import. |
| `glucoseContext` | no | Only for `BLOOD_GLUCOSE`; blank means no context. See [Glucose context](#glucose-context). |
| `notes` | no | Free text, up to 200 characters. |
| `externalId` | no | A source-stable id. With it, a re-upload updates the same row; without it, a re-upload creates a new one. |
A sensor export therefore needs no glucose column at all:
```csv
type,value,unit,measuredAt,glucoseContext,notes,externalId
BLOOD_GLUCOSE,5.3,mmol/L,2024-04-03T13:15:00+1100,,,sensor-1
BLOOD_GLUCOSE,5.9,mmol/L,2024-04-03T13:30:00+1100,,,sensor-2
```
Limits: 16 MB per upload, 10 000 valid rows per import, 5 imports per
hour (shared with the JSON import). `?dryRun=1` validates and previews
without writing anything — the **Preview** button uses it.
The response carries per-row outcomes alongside the counts, and the card
renders the counts, not the status code: rows written and none skipped is
a success, a mixed result is a warning, and nothing written is a failure
with the reasons grouped by count.
## Converting a CSV into the import JSON
The CSV import above covers measurements. Mood entries have no CSV
route, so a mood history still goes through the JSON payload.
A spreadsheet export is the most common starting point. The shape is
small enough to convert by hand for a few rows, or with a one-off script
for many. The steps are the same either way:
Binary file not shown.

After

Width:  |  Height:  |  Size: 212 KiB

+125
View File
@@ -0,0 +1,125 @@
# Offline geolocation (GeoLite2)
The admin login overview shows a coarse `City, CC` location and a carrier
next to every sign-in. Two tiers can produce that:
- **Online.** A third-party lookup, `https://ipwho.is` by default. Free,
keyless, and on by default — which also means every login IP address of
every account on the instance leaves the host.
- **Offline.** MaxMind's GeoLite2-City and GeoLite2-ASN databases read
from a local directory. The ASN database is the authoritative source
for the carrier and the AS number. Note that the resolver is
online-first: having the databases in place is not by itself enough to
stop the outbound lookup, see "Turning the online lookup off entirely"
below.
The offline tier is optional and absent by default. The published image
only carries the databases when the image build had a MaxMind licence key
(`scripts/fetch-geolite2.sh`, run from the release workflow), which is a
build-time decision you do not control when you pull `:latest`. Bringing
your own copy is the way to get the offline tier on a self-host, and it
is the subject of this page.
## What you need
- A free MaxMind account. Sign up at
<https://www.maxmind.com/en/geolite2/signup>, then create a licence key
under **My Account → Manage License Keys**. GeoLite2 is free; the
account and key exist because MaxMind requires them for the download.
- `geoipupdate` (MaxMind's own downloader) or any other way to fetch the
two `.mmdb` files onto the host. Most distributions package
`geoipupdate`; there is also an official container image.
## Setup
1. Pick a directory on the host and fetch both databases into it. With
`geoipupdate`, `/etc/GeoIP.conf` looks like this:
```conf
AccountID <your-account-id>
LicenseKey <your-licence-key>
EditionIDs GeoLite2-City GeoLite2-ASN
DatabaseDirectory /srv/healthlog/geolite2
```
Then run `geoipupdate`. The directory should end up holding
`GeoLite2-City.mmdb` and `GeoLite2-ASN.mmdb`. The City database is the
one the readiness check looks for; the ASN database is what fills the
carrier and AS number.
2. Mount the directory into the app container read-only. In
`docker-compose.yml` the `app` service carries a commented `volumes:`
block for exactly this:
```yaml
volumes:
- /srv/healthlog/geolite2:/var/lib/geolite2:ro
```
3. Point the runtime at the container path:
```env
GEOLITE2_DIR="/var/lib/geolite2"
```
`GEOLITE2_DIR` is on the compose `environment:` whitelist, so a value
in your `.env` reaches the container. Variables that are not on that
whitelist never do, even when compose substitutes them elsewhere in
the file.
4. `docker compose up -d` and sign in once. **Admin → System status**
shows `Geo data: Offline (GeoLite2)` when the databases are being
read, and names the online provider when they are not.
## Turning the online lookup off entirely
Once the offline tier resolves, the third-party lookup is redundant. To
stop all IP egress:
```env
IP_GEO_LOOKUP_DISABLED="1"
```
The resolver then uses only the local databases and returns nothing when
they miss, rather than asking anyone. An instance handling other people's
health data is a reasonable place to make that trade.
## Keeping the databases current
MaxMind reissues GeoLite2 on its own schedule, and a stale city database
is quietly wrong rather than loudly broken — it keeps answering, with
last month's allocations. Run `geoipupdate` on a schedule (a weekly cron
or systemd timer is enough) against the same directory.
The reader loads each database once per worker process and holds it, so a
refreshed file on the host is picked up at the next container restart,
not immediately. A weekly `geoipupdate` followed by
`docker compose restart app` keeps the two in step.
## Licence and attribution
The databases are downloaded under your own MaxMind account, so their
terms are between you and MaxMind — read what your account agrees to at
<https://www.maxmind.com/en/geolite2/eula>. HealthLog's own attribution
("this product includes GeoLite2 data created by MaxMind") is already on
the `/about` page and covers an instance that uses them.
## Troubleshooting
**System status still says the online provider.** The readiness check
requires `GeoLite2-City.mmdb` to exist in `GEOLITE2_DIR` and no `.empty`
marker file beside it. Check the path from inside the container:
`docker compose exec app ls -l /var/lib/geolite2`. An empty listing means
the bind mount did not land; a `.empty` file means the image was built
without a licence key and you are still reading the baked-in directory
rather than your own.
**The variable has no effect.** Confirm it actually reached the process:
`docker compose exec app printenv GEOLITE2_DIR`. If it prints nothing,
the value is in `.env` but not on the `environment:` whitelist — compare
against `docker-compose.yml`.
**The carrier column is empty.** The carrier also comes from the online
provider's ISP field, so it is normally filled even without the ASN
database. If both tiers are off (`IP_GEO_LOOKUP_DISABLED="1"` and no
`GeoLite2-ASN.mmdb`), there is no source for it and it stays empty.
@@ -81,6 +81,31 @@ These are cited as factual public record to explain _why_ the
"who holds the data" question is the one that matters for this category.
HealthLog makes no claim about any other product's current practices.
## What deleting actually does
Worth knowing before you rely on it, because "deleted" means two
different things depending on which button you press.
- **Deleting one cycle or day-log entry** hides it. Every read filters
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.
- **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.
- **Deleting the account** removes the account row, and the database
cascades every record with it.
Two consequences follow, and they matter for the seizure threat model
above. A host imaged while a per-entry deletion is the only deletion you
have performed still holds that entry. And a backup taken before any of
these actions still holds whatever it captured, because a backup is a
copy of a moment. If your reason for deleting is that the data must not
be recoverable from the machine, use one of the two whole-record actions
and rotate your backups.
## Practical hardening
If reproductive-health privacy is the reason you self-host, the
+2 -2
View File
@@ -324,7 +324,7 @@ The 1.4 release splits the legacy 3150-LOC settings monolith into one
route per top-level section:
- `/settings/account` — profile, password, passkeys, account deletion.
- `/settings/integrations` — Withings, moodLog, Telegram, ntfy, Web Push.
- `/settings/integrations` — Withings, Nightscout, Telegram, ntfy, Web Push.
- `/settings/notifications` — channel matrix + quiet hours + reminders.
- `/settings/dashboard` — tile layout editor, default ranges, threshold
overrides.
@@ -426,7 +426,7 @@ control. Behavior:
- **History**: each test writes a row to a tiny per-integration log
visible in the section. Last 5 tests, timestamp + result.
- **Coverage**: required for Telegram, ntfy, Web Push, AI provider,
Withings, moodLog, GlitchTip, Umami, email (when wired), backup
Withings, Nightscout, GlitchTip, Umami, email (when wired), backup
target. Headless E2E asserts presence + working call for each.
### 4.6 Server actions and mutations
+4 -2
View File
@@ -12,10 +12,12 @@ The Playwright config (`../playwright.config.ts`) builds the app (`next build`)
## Layout
- **`setup/`** — `global-setup.ts` (auth + DB seeding), `test-helpers.ts` (shared login + navigation helpers).
- **`setup/`** — `test.ts` (the suite's `test` + `expect` entry point), `global-setup.ts` (auth + DB seeding), `test-helpers.ts` (shared login + navigation helpers).
- **`utils/`** — fixtures such as `mock-dashboard-snapshot.ts`.
- **`*.spec.ts`** — one spec per flow: auth/login/redirect, dashboard, charts, the medication-wizard cadences (`medications-wizard-*`), insights generation + scroll restoration, doctor report, settings, mobile-viewport consistency, locale switch, accessibility (`a11y.spec.ts`), and the `v1427-*` regression set.
## Convention
## Conventions
Import `test` and `expect` from `./setup/test`, never from `@playwright/test` — types (`Page`, `Route`, …) still come from the package. The entry point extends Playwright's `test` with an automatic fixture that drops route handlers and page listeners after every test, so a handler left in flight cannot fail the test it belonged to once the context closes. `src/__tests__/e2e-route-cleanup-guard.test.ts` enforces the import boundary.
Assert against stable `data-*` attributes (`data-slot`, `data-display-step`, root display attrs), not viewport-dependent visible text. Responsive `sm:hidden` UI changes break `getByText` on desktop while the underlying element is still present — anchoring on data attributes keeps the specs stable across breakpoints. See [`../CLAUDE.md`](../CLAUDE.md).
+2 -7
View File
@@ -1,11 +1,6 @@
import AxeBuilder from "@axe-core/playwright";
import {
expect,
test,
type Locator,
type Page,
type Route,
} from "@playwright/test";
import type { Locator, Page, Route } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import { POPULATED_SUMMARIES } from "./utils/mock-dashboard-snapshot";
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
/**
* The proxy at src/proxy.ts is the single gate enforcing auth on every
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import {
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import { mockDashboardSnapshot } from "./utils/mock-dashboard-snapshot";
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import { setTimeout as delay } from "node:timers/promises";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
+70 -33
View File
@@ -1,20 +1,29 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import {
expectNoHorizontalOverflow,
PHONE_WIDTHS,
settleForOverflowMeasurement,
} from "./utils/horizontal-overflow";
/**
* Pixel-5 mobile smoke for the Insights overview + the cycle vertical
* (v1.15.10). Mirrors `mobile-viewport.spec.ts`. Asserts:
* Mobile smoke for the cycle vertical (v1.15.10). Asserts:
*
* 1. `/insights` has no horizontal page scroll on the Pixel-5 width — the
* core guarantee behind the cycle-ring-width fix (the 120 px ring tile
* used to overflow narrow grid cells).
* 2. `/cycle` has no horizontal page scroll on the Pixel-5 width — covers
* 1. `/cycle` has no horizontal page scroll at any phone width — covers
* the tab-strip-overflow fix (the four long German labels used to force
* a horizontal scroll at 375 px) and the responsive cycle ring.
* 3. The cycle-calendar day cells clear the WCAG 2.5.5 mobile tap-target
* 2. The cycle-calendar day cells clear the WCAG 2.5.5 mobile tap-target
* height floor (44 CSS px) — the `min-h-10` → `min-h-11` bump.
*
* The `/insights` width case that used to live here has moved to
* `mobile-horizontal-overflow.spec.ts`. It asserted against an EMPTY insights
* payload and against `documentElement.scrollWidth`, so it was green through a
* populated page that really did pan sideways inside the `AuthShell` `<main>`
* scroll container. The replacement runs populated fixtures at several phone
* widths and measures the scroll container the app actually uses. The cycle
* fixture below is populated for the same reason.
*
* The cycle feature gate is enabled by rewriting the real `/api/auth/me`
* response with `cycleTrackingEnabled: true` (every other field stays
* authentic), and the `/api/cycle/*` reads are mocked so the wheel, tab strip,
@@ -108,22 +117,48 @@ const HISTORY_BODY = {
};
const INSIGHTS_BODY = {
data: { rows: [], headline: null, symptomPatterns: [] },
data: {
rows: buildInsightRows(),
headline: {
symptom: "HEADACHE",
phase: "LUTEAL",
rate: 0.3,
confidence: "high",
},
symptomPatterns: buildInsightRows(),
},
error: null,
};
async function assertNoHorizontalScroll(page: import("@playwright/test").Page) {
const dims = await page.evaluate(() => ({
scrollWidth: document.documentElement.scrollWidth,
innerWidth: window.innerWidth,
/**
* A populated cycle-insights payload. The previous fixture was
* `{ rows: [], headline: null, symptomPatterns: [] }` — an empty page, which
* fits any viewport by construction and proved nothing about the width
* guarantee it was written for. Long labels are deliberate: the tab strip and
* the summary rows are where narrow-width text pressure shows up.
*/
function buildInsightRows(): Array<Record<string, unknown>> {
const symptoms = [
"HEADACHE",
"CRAMPS",
"BLOATING",
"FATIGUE",
"MOOD_SWINGS",
"BREAST_TENDERNESS",
"BACK_PAIN",
"ACNE",
];
return symptoms.map((symptom, i) => ({
symptom,
phase: ["MENSTRUAL", "FOLLICULAR", "OVULATORY", "LUTEAL"][i % 4],
occurrences: 12 - i,
totalDays: 40,
rate: (12 - i) / 40,
confidence: i < 4 ? "high" : "low",
}));
expect(
dims.scrollWidth,
`scrollWidth=${dims.scrollWidth}, innerWidth=${dims.innerWidth}`,
).toBeLessThanOrEqual(dims.innerWidth + 1);
}
test.describe("cycle + insights mobile smoke", () => {
test.describe("cycle mobile smoke", () => {
test.use({ storageState: STORAGE_STATE_PATH });
test.beforeEach(async ({ page }, testInfo) => {
@@ -175,24 +210,26 @@ test.describe("cycle + insights mobile smoke", () => {
);
});
test("/insights has no horizontal scroll on Pixel-5", async ({ page }) => {
await page.goto("/insights", { waitUntil: "domcontentloaded" });
await page.waitForLoadState("networkidle");
await assertNoHorizontalScroll(page);
});
test("/cycle has no horizontal scroll and the calendar cells clear the 44 px tap floor", async ({
test("/cycle has no horizontal scroll at any phone width and the calendar cells clear the 44 px tap floor", async ({
page,
}) => {
for (const width of PHONE_WIDTHS) {
await page.setViewportSize({ width, height: 851 });
await page.goto("/cycle", { waitUntil: "domcontentloaded" });
// The cycle vertical mounted (the page redirects home if the gate is off).
const wheel = page.locator('[data-slot="cycle-wheel-tile"]');
await expect(wheel).toBeVisible();
await settleForOverflowMeasurement(page);
await expectNoHorizontalOverflow(page, `/cycle @${width}px`);
}
// Back to the Pixel-5 width for the tap-target measurement.
await page.setViewportSize({ width: 393, height: 851 });
await page.goto("/cycle", { waitUntil: "domcontentloaded" });
await page.waitForLoadState("networkidle");
// The cycle vertical mounted (the page redirects home if the gate is off).
const wheel = page.locator('[data-slot="cycle-wheel-tile"]');
await expect(wheel).toBeVisible();
// 1) No horizontal page scroll at the Pixel-5 width.
await assertNoHorizontalScroll(page);
await expect(page.locator('[data-slot="cycle-wheel-tile"]')).toBeVisible();
await page.waitForLoadState("networkidle").catch(() => {});
// 2) Calendar day cells clear the 44 px tap-target height floor.
const cells = await page.locator('[role="gridcell"]').all();
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import { mockDashboardSnapshot } from "./utils/mock-dashboard-snapshot";
+2 -1
View File
@@ -16,7 +16,8 @@
* seeds a real `document_content_index` row (`ensureVaultAiFixture`) and drives
* the real list union.
*/
import { expect, test, type Page } from "@playwright/test";
import type { Page } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import {
+2 -1
View File
@@ -15,7 +15,8 @@
* AI e2e, so it is pinned here too. Assertions target stable data-slots, never
* viewport-dependent text.
*/
import { expect, test, type Page } from "@playwright/test";
import type { Page } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import {
+2 -1
View File
@@ -22,7 +22,8 @@
* the specs time out. The seeded demo user has no AI provider, so the
* no-provider state is the default.
*/
import { expect, test, type Page } from "@playwright/test";
import type { Page } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import {
+2 -1
View File
@@ -17,7 +17,8 @@
* they create, never on the shared doctor-flow corpus.
*/
import AxeBuilder from "@axe-core/playwright";
import { expect, test, type Page } from "@playwright/test";
import type { Page } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import {
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
/**
* Locale-key drift smoke check — switch the cookie between EN and DE
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
/**
* Smoke checks on the login surface — must render, must wire the
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import {
STUB_MEDICATION_ID,
+2 -1
View File
@@ -1,4 +1,5 @@
import { expect, test, type Route } from "@playwright/test";
import type { Route } from "@playwright/test";
import { expect, test } from "./setup/test";
import {
STUB_MEDICATION_ID,
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import {
STUB_MEDICATION_ID,
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import {
STUB_MEDICATION_ID,
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import {
STUB_MEDICATION_ID,
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import {
STUB_MEDICATION_ID,
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import {
STUB_MEDICATION_ID,
+38 -16
View File
@@ -1,6 +1,7 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import { settleBeforeMeasure } from "./utils/settle";
import {
LONG_HEADLINE_BRIEFING,
MOCK_SCORE_RINGS,
@@ -8,6 +9,7 @@ import {
POPULATED_SUMMARIES,
} from "./utils/mock-dashboard-snapshot";
import { mockMoodInsights } from "./utils/mock-mood-insights";
import { expectNoHorizontalOverflow } from "./utils/horizontal-overflow";
/**
* Phone-width density guard (390×844 iPhone class, 360×780 small
@@ -17,8 +19,12 @@ import { mockMoodInsights } from "./utils/mock-mood-insights";
* squeezed into a one-word-per-line column beside a wide delta, the
* delta spilling past the tile edge at 360 px):
*
* 1. No horizontal page overflow — `scrollWidth` never exceeds the
* viewport width (1 px sub-pixel tolerance).
* 1. No horizontal page overflow — neither the document nor the `AuthShell`
* `<main>` scroll container exceeds the viewport width (1 px sub-pixel
* tolerance). The `<main>` half is load-bearing: this guard measured only
* the document until a report of sideways scroll on a populated Insights
* page showed the document never widens, because `<main>` absorbs the
* overflow into its own scrollable area.
* 2. Nothing escapes its tile — for every card / Today hero / list
* row, no descendant's visible box crosses the tile's left or
* right edge (clip-aware: content genuinely hidden by an inner
@@ -157,7 +163,16 @@ test.describe("phone-width density guard", () => {
await mockMoodInsights(page);
}
await page.goto(route, { waitUntil: "domcontentloaded" });
await page.waitForLoadState("networkidle");
if (route === "/insights") {
// The one route here that had no readiness signal, while its two
// siblings below both have one. An overflow guard against a page
// that has not finished rendering passes for the same reason an
// empty page passes: there is nothing wide on it yet.
await expect(
page.locator('[data-slot="section-heading"]').first(),
).toBeVisible();
}
if (route === "/") {
// The guard must not pass vacuously — the Today hero and its
@@ -181,19 +196,26 @@ test.describe("phone-width density guard", () => {
).toBeVisible();
}
const dims = await page.evaluate(() => ({
scrollWidth: document.documentElement.scrollWidth,
bodyScrollWidth: document.body.scrollWidth,
innerWidth: window.innerWidth,
}));
await settleBeforeMeasure(page, page.locator("main#main-content"));
// The document/body pair alone cannot see the app's sideways scroll:
// `AuthShell` scrolls inside `<main id="main-content">`, whose
// `overflow-y:auto` makes `overflow-x` compute to `auto`, so an
// over-wide child grows `main.scrollWidth` while the document stays
// exactly viewport-wide. The shared helper asserts both areas and
// names the offending element.
await expectNoHorizontalOverflow(
page,
`${route} @${viewport.width}x${viewport.height}`,
);
const bodyScrollWidth = await page.evaluate(
() => document.body.scrollWidth,
);
const innerWidth = await page.evaluate(() => window.innerWidth);
expect(
dims.scrollWidth,
`page scrollWidth=${dims.scrollWidth}, innerWidth=${dims.innerWidth}`,
).toBeLessThanOrEqual(dims.innerWidth + 1);
expect(
dims.bodyScrollWidth,
`body scrollWidth=${dims.bodyScrollWidth}`,
).toBeLessThanOrEqual(dims.innerWidth + 1);
bodyScrollWidth,
`body scrollWidth=${bodyScrollWidth}`,
).toBeLessThanOrEqual(innerWidth + 1);
const escapes = await page.evaluate(collectTileEscapes);
expect(
+255
View File
@@ -0,0 +1,255 @@
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import { mockDashboardSnapshot } from "./utils/mock-dashboard-snapshot";
import { mockPopulatedInsights } from "./utils/mock-populated-insights";
import {
expectNoHorizontalOverflow,
measureHorizontalOverflow,
PHONE_WIDTHS,
settleForOverflowMeasurement,
} from "./utils/horizontal-overflow";
/**
* The content-heavy routes must not scroll sideways at any phone width.
*
* This replaces a per-surface habit of asserting
* `documentElement.scrollWidth <= innerWidth` on whatever happened to render.
* That assertion had two holes, and a real report walked through both:
*
* 1. It measured the DOCUMENT. `AuthShell` scrolls inside
* `<main id="main-content">`, whose `overflow-y:auto` makes `overflow-x`
* compute to `auto` — so an over-wide child grows `main.scrollWidth`, the
* user pans the page sideways, and the document never widens by a pixel.
* 2. It ran against an EMPTY page. A surface with no content fits any
* viewport, so the guard proved nothing about the surface people use.
*
* `expectNoHorizontalOverflow` closes (1) by asserting both scroll areas and
* naming the widest offending element on failure. The fixtures below close (2)
* by giving each route a body before it is measured.
*
* Widths cover real iPhone (390 / 393 / 402 / 430) and common Android (360)
* CSS widths — a single width hides anything whose overflow is width-dependent.
* Runs on `chromium-mobile` only; the desktop project skips the whole file.
*/
interface RouteCase {
path: string;
/** Something that must be on-screen before measuring — proves it is populated. */
ready: string;
setup?: (page: import("@playwright/test").Page) => Promise<void>;
}
const ROUTE_CASES: RouteCase[] = [
{
path: "/insights",
// The "signals of the day" section is the one whose heading carries the
// long provenance sentence, so it is also the readiness signal.
ready: '[data-slot="coincident-deviation-section"]',
setup: mockPopulatedInsights,
},
{
path: "/",
ready: "main#main-content",
setup: (page) => mockDashboardSnapshot(page),
},
{ path: "/measurements", ready: "main#main-content" },
{ path: "/medications", ready: "main#main-content" },
];
test.describe("content-heavy routes have no horizontal page scroll at phone widths", () => {
test.use({ storageState: STORAGE_STATE_PATH });
test.beforeEach(async ({}, testInfo) => {
test.skip(testInfo.project.name !== "chromium-mobile", "mobile-only spec");
});
for (const routeCase of ROUTE_CASES) {
test(`${routeCase.path} does not scroll sideways on any phone width`, async ({
page,
}) => {
await routeCase.setup?.(page);
for (const width of PHONE_WIDTHS) {
await page.setViewportSize({ width, height: 851 });
await page.goto(routeCase.path, { waitUntil: "domcontentloaded" });
await expect(page.locator(routeCase.ready).first()).toBeVisible();
await settleForOverflowMeasurement(page);
await expectNoHorizontalOverflow(page, `${routeCase.path} @${width}px`);
}
});
}
/**
* The fixture itself has to stay honest. If `mockPopulatedInsights` ever
* stops populating the overview — a renamed route, a changed envelope — the
* width assertions above would keep passing against an empty page and the
* guard would quietly go back to proving nothing. Assert the page is
* actually carrying content, so fixture rot fails loudly instead.
*/
test("the /insights fixture really populates the overview", async ({
page,
}) => {
await mockPopulatedInsights(page);
await page.setViewportSize({ width: 390, height: 851 });
await page.goto("/insights", { waitUntil: "domcontentloaded" });
const signals = page.locator('[data-slot="coincident-deviation-section"]');
await expect(signals).toBeVisible();
// The prose in the heading's trailing action slot — the shape that widened
// the page. It must be present AND long enough to be able to.
const method = signals.locator('[data-slot="provenance-explainer-method"]');
await expect(method).toBeVisible();
const text = (await method.textContent()) ?? "";
expect(
text.length,
`provenance caption too short to exercise the overflow class: "${text}"`,
).toBeGreaterThan(60);
// And the surface as a whole is not the empty state.
await expect(
page.locator('[data-slot="section-heading"]').first(),
).toBeVisible();
const headingCount = await page
.locator('[data-slot="section-heading"]')
.count();
expect(headingCount, "overview rendered no sections").toBeGreaterThan(1);
});
/**
* A long trailing caption must WRAP inside the row, not extend it. Asserting
* the wrap directly (rather than only the page-level width) pins the fix at
* the element that broke: a `shrink-0` action slot sized itself to the
* caption's max-content width, ~1.2-1.5k px depending on locale.
*/
test("a long heading action caption wraps instead of widening the row", async ({
page,
}) => {
await mockPopulatedInsights(page);
await page.setViewportSize({ width: 390, height: 851 });
await page.goto("/insights", { waitUntil: "domcontentloaded" });
// Wait on the CAPTION, not the heading: the card first paints a skeleton
// whose heading carries no action slot, then swaps to the resolved shell.
// Both render `[data-slot="section-heading"]`, so gating on the heading
// alone can measure the skeleton — or measure nothing at all, because the
// element detaches during the swap.
const method = page
.locator(
'[data-slot="coincident-deviation-section"] [data-slot="provenance-explainer-method"]',
)
.first();
await expect(method).toBeVisible();
// Read both rects in ONE evaluate so the swap cannot land between them.
const box = await method.evaluate((el) => {
const headingEl = el.closest('[data-slot="section-heading"]');
if (!headingEl) return null;
const caption = el.getBoundingClientRect();
const heading = headingEl.getBoundingClientRect();
return {
captionWidth: caption.width,
headingWidth: heading.width,
viewportWidth: window.innerWidth,
};
});
expect(box, "caption is not inside a section heading").not.toBeNull();
expect(
Math.round(box!.captionWidth),
`caption width ${box!.captionWidth} exceeds its heading row ${box!.headingWidth} — the action slot is sizing to max-content instead of wrapping`,
).toBeLessThanOrEqual(Math.round(box!.headingWidth) + 1);
expect(
Math.round(box!.headingWidth),
"heading row is wider than the viewport",
).toBeLessThanOrEqual(box!.viewportWidth);
});
/**
* The helper must be able to SEE overflow through the `<main>` scroll
* container — the exact blind spot that let the previous guard pass. Inject
* a deliberately over-wide, un-contained block and assert the measurement
* reports it. Without this, a helper that silently measured the wrong
* element would make every assertion above vacuous.
*/
test("the overflow measurement detects an over-wide block inside <main>", async ({
page,
}) => {
await mockPopulatedInsights(page);
await page.setViewportSize({ width: 390, height: 851 });
await page.goto("/insights", { waitUntil: "domcontentloaded" });
// Wait for the same readiness signal and settle step every route case
// above uses. This was the one test in the file that measured and injected
// straight after `domcontentloaded`, against a tree that was still
// mounting, and it is the one that went flaky on CI: `main.scrollWidth`
// came back at exactly `clientWidth` with a 1500px child supposedly in it.
//
// Two candidate mechanisms were checked against a local run rather than
// assumed. A render swapping `main`'s children and taking the canary with
// it does NOT happen: the node survives the skeleton-to-content swap
// (12/12 runs, canary attached, scrollWidth 1500). That leaves `main` not
// yet BEING the scroll container at injection time, which is what settling
// rules out and what the assertions below now report on if it recurs.
await expect(
page.locator('[data-slot="coincident-deviation-section"]').first(),
).toBeVisible();
await settleForOverflowMeasurement(page);
const clean = await measureHorizontalOverflow(page);
expect(clean.mainScrollWidth).toBeLessThanOrEqual(
(clean.mainClientWidth ?? 0) + 1,
);
await page.evaluate(() => {
const probe = document.createElement("div");
probe.id = "overflow-canary";
probe.style.width = "1500px";
probe.style.height = "10px";
document.querySelector("main#main-content")!.appendChild(probe);
});
// Separate the fixture's own failure from the helper's. If the canary is
// not inside the CURRENT `main`, the measurement below proves nothing
// either way, and a failure that says so is a diagnosis rather than an
// accusation.
await expect(
page.locator("main#main-content #overflow-canary"),
"the canary is not inside the current <main> — the fixture failed, not the helper",
).toBeAttached();
// Captured so that a recurrence names its own cause instead of costing
// another round of guessing. `overflowX: clip` or a `main` that is not a
// scroll container both produce the observed "scrollWidth === clientWidth
// with a 1500px child" and are indistinguishable without this.
const state = await page.evaluate(() => {
const main = document.querySelector<HTMLElement>("main#main-content")!;
const canary = document.getElementById("overflow-canary");
const style = getComputedStyle(main);
return {
overflowX: style.overflowX,
overflowY: style.overflowY,
canaryWidth: canary?.getBoundingClientRect().width ?? null,
canaryInMain: canary ? main.contains(canary) : false,
};
});
const dirty = await measureHorizontalOverflow(page);
expect(
dirty.mainScrollWidth,
`an intentionally 1500px-wide block did not widen main's scroll area — main overflow-x:${state.overflowX} overflow-y:${state.overflowY}, canary width ${state.canaryWidth}, inside main: ${state.canaryInMain}`,
).toBeGreaterThan((dirty.mainClientWidth ?? 0) + 1);
expect(
dirty.offenders.some((o) => o.selector.includes("overflow-canary")),
`the canary was not reported as an offender; got: ${dirty.offenders
.map((o) => o.selector)
.join(" | ")}`,
).toBe(true);
// The document stayed exactly as wide as the viewport while `<main>` grew
// by 1500px — this is the blind spot, asserted rather than described.
expect(
dirty.docScrollWidth,
"document width changed, so the document-only assertion would have caught this after all",
).toBeLessThanOrEqual(dirty.innerWidth + 1);
});
});
+6 -2
View File
@@ -1,6 +1,7 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import { settleBeforeMeasure } from "./utils/settle";
import {
mockDashboardSnapshot,
WEIGHT_ONLY_SUMMARIES,
@@ -78,7 +79,10 @@ test.describe("mobile-viewport smoke", () => {
);
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.waitForLoadState("networkidle");
// Gate on the dashboard's own content, not on the network. Every read
// below is geometric and happens exactly once, so measuring a tree that
// is still mounting reports a layout nobody will ever see.
await settleBeforeMeasure(page, page.locator("main#main-content"));
// 1) No horizontal scroll
const dims = await page.evaluate(() => ({
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import {
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
+14 -9
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
@@ -41,11 +41,15 @@ test("medications list refetches on client-side navigation back to the page", as
}
});
// Initial load — the list query mounts and fetches once.
await page.goto("/medications");
await page.waitForResponse(
// Initial load — the list query mounts and fetches once. Arm the waiter
// BEFORE navigating: `goto` resolves on load, so a response that already
// arrived would never be seen by a listener attached afterwards, and the
// wait would time out on an event that had long since happened.
const firstListFetch = page.waitForResponse(
(r) => new URL(r.url()).pathname === "/api/medications",
);
await page.goto("/medications");
await firstListFetch;
expect(medsRequests).toBe(1);
// Client-side nav AWAY (dashboard) then BACK — App Router unmounts and
@@ -56,10 +60,11 @@ test("medications list refetches on client-side navigation back to the page", as
await page.locator('a[href="/medications"]').first().click();
await page.waitForURL("**/medications");
// Give a returning refetch time to fire.
await page.waitForTimeout(1500);
// Desired: the return mounts a fresh observer that refetches. Pre-fix
// the 5-min staleTime masks it and this stays at 1 (the bug).
expect(medsRequests).toBeGreaterThanOrEqual(2);
// the 5-min staleTime masks it and this stays at 1 (the bug). Poll rather
// than sleeping a fixed span: it returns the moment the refetch lands, and
// it does not turn a slow CI machine into a false failure.
await expect
.poll(() => medsRequests, { timeout: 10_000 })
.toBeGreaterThanOrEqual(2);
});
+177 -28
View File
@@ -1,4 +1,6 @@
import { expect, test } from "@playwright/test";
import type { Page } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
@@ -12,9 +14,11 @@ import { STORAGE_STATE_PATH } from "./setup/global-setup";
* and generic-JSON cards.
* 3. The full health-record export panel lives on its own top-level
* `/settings/gesundheitsakte` section (v1.18.0 S5) — including the
* "included data" checklist, a disclosure collapsed by default that
* expands on demand.
* 4. Clicking the Measurements CSV download button fires a real
* "included data" disclosure, which a first run opens and which stays
* operable in both directions.
* 4. The scope picker mounts twice on that page (the export panel and the
* share-link create form) and each mount is separately addressable.
* 5. Clicking the Measurements CSV download button fires a real
* browser download — proving the `/api/export/measurements`
* endpoint is reachable from the browser end-to-end.
*
@@ -22,6 +26,30 @@ import { STORAGE_STATE_PATH } from "./setup/global-setup";
* download is enough to lock in the wiring without doubling the e2e
* runtime.
*/
/**
* Open Settings → Gesundheitsakte and wait until the page is actually live.
*
* Everything on that page is server-rendered, so the rows are visible long
* before the client boundary that owns them has hydrated — a click or a
* `boundingBox()` taken on `domcontentloaded` alone hits inert or replaced
* markup often enough to matter. `GesundheitsakteSection` is one client
* boundary carrying both the export panel and the sharing card, and the
* sharing card's query only runs once that boundary is live, so its request is
* the signal that the whole surface is wired.
*/
async function openGesundheitsakte(page: Page): Promise<void> {
const boundaryHydrated = page.waitForResponse(
(response) =>
response.request().method() === "GET" &&
new URL(response.url()).pathname === "/api/share-links",
);
await page.goto("/settings/gesundheitsakte", {
waitUntil: "domcontentloaded",
});
await boundaryHydrated;
}
test.describe("Settings → Export & Import", () => {
test.use({ storageState: STORAGE_STATE_PATH });
@@ -45,14 +73,136 @@ test.describe("Settings → Export & Import", () => {
}) => {
// v1.18.0 (S5) — the health-record export is the hero of the
// dedicated, module-gated Gesundheitsakte section.
await page.goto("/settings/gesundheitsakte", {
waitUntil: "domcontentloaded",
});
await openGesundheitsakte(page);
await expect(page.getByTestId("health-record-export-panel")).toBeVisible({
timeout: 10_000,
});
});
test("gives each scope-picker mount its own test ids", async ({ page }) => {
// The Gesundheitsakte page carries two mounts of the same picker: the
// export panel at the top and the share-link create form under `#sharing`.
// Every id inside the picker ends with the surface that mounts it, so an
// assertion binds to one mount instead of resolving to both.
await openGesundheitsakte(page);
await expect(page.getByTestId("report-scope-picker-export")).toHaveCount(1);
await expect(page.getByTestId("report-scope-picker-share")).toHaveCount(1);
for (const id of [
"report-group-row-vitals",
"report-group-check-vitals",
"report-sensitive-tier",
"report-scope-summary",
]) {
await expect(page.getByTestId(`${id}-export`)).toHaveCount(1);
await expect(page.getByTestId(`${id}-share`)).toHaveCount(1);
}
});
test("opens the scope picker expanded on a first run, fenced tier and all", async ({
page,
}) => {
// The three panel states are the release's whole point, and on the first
// one nothing is selected at all. The picker still opens without a click,
// because with an empty scope the standard-report button and the named
// groups are exactly what the person needs in front of them. Asserted
// against the export mount; the share form runs its own picker.
await openGesundheitsakte(page);
await expect(page.getByTestId("report-scope-picker-export")).toBeVisible({
timeout: 10_000,
});
await expect(
page.getByTestId("report-group-row-vitals-export"),
).toBeVisible();
await expect(
page.getByTestId("report-group-row-sensitive-export"),
).toHaveCount(0);
await expect(
page.getByTestId("report-sensitive-tier-export"),
).toBeVisible();
// The fenced tier has no group control — there is no single click that
// turns on more than one sensitive leaf.
await expect(
page.getByTestId("report-group-check-sensitive-export"),
).toHaveCount(0);
await expect(page.getByTestId("report-scope-summary-export")).toBeVisible();
});
test("first run selects nothing and refuses to generate until it does", async ({
page,
}) => {
// The rule the whole surface exists for: nothing reaches the report that
// was not ticked. So a first run starts empty, Generate refuses the press
// and says why, and the one-click action is what makes that cost nothing —
// it fills the standard set and the control comes alive.
await openGesundheitsakte(page);
const generate = page.getByTestId("health-record-generate");
await expect(generate).toBeVisible({ timeout: 10_000 });
await expect(generate).toBeDisabled();
await expect(
page.getByTestId("report-group-row-vitals-export"),
).toContainText("0/7");
await expect(page.getByTestId("report-scope-summary-export")).toContainText(
"Nothing selected yet",
);
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.
await expect(
page.getByTestId("report-group-row-vitals-export"),
).toContainText("3/7");
await expect(
page.getByTestId("report-group-row-body-export"),
).toContainText("2/12");
await expect(
page.getByTestId("report-group-row-cardio-export"),
).toContainText("0/14");
// The fenced tier is not part of any one-click set.
await expect(
page.getByTestId("report-scope-summary-export"),
).not.toContainText("incl.");
});
test("a share link cannot be minted on an empty scope either", async ({
page,
}) => {
await openGesundheitsakte(page);
const create = page.getByTestId("share-create-submit");
await expect(create).toBeVisible({ timeout: 10_000 });
await page.locator("#share-label").fill("Cardiology");
// A label alone is not a scope: the link would serve a page with nothing
// on it, so the control stays shut until something is chosen.
await expect(create).toBeDisabled();
await expect(page.getByTestId("report-scope-summary-share")).toContainText(
"Nothing selected yet",
);
await page.getByTestId("report-apply-standard-share").click();
await expect(create).toBeEnabled();
});
test("clears the tap floor on the group rows at 390 px", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await openGesundheitsakte(page);
const row = page.getByTestId("report-group-row-vitals-export");
await expect(row).toBeVisible({ timeout: 10_000 });
const box = await row.boundingBox();
expect(box).not.toBeNull();
expect(box!.height).toBeGreaterThanOrEqual(44);
});
test("opens a group's leaf grid from its chevron", async ({ page }) => {
await openGesundheitsakte(page);
const row = page.getByTestId("report-group-row-vitals-export");
await expect(row).toBeVisible({ timeout: 10_000 });
// Collapsed: the leaves are behind the disclosure, which is what makes a
// repeat run two interactions.
await expect(page.getByTestId("report-leaf-PULSE-export")).toHaveCount(0);
await row.getByRole("button", { expanded: false }).click();
await expect(page.getByTestId("report-leaf-PULSE-export")).toBeVisible();
});
test("renders the import surfaces with stable testids", async ({ page }) => {
await page.goto("/settings/export", { waitUntil: "domcontentloaded" });
// v1.15.7 (issue #281) — the Apple Health and generic-JSON import
@@ -89,11 +239,18 @@ test.describe("Settings → Export & Import", () => {
expect(Array.isArray(parsed.moodEntries)).toBe(true);
});
test("included-data checklist is collapsed by default and expands on demand", async ({
test("included-data disclosure opens on a first run and still collapses", async ({
page,
}) => {
// v1.18.0 (S5) — the panel (and its disclosure) live on the
// dedicated Gesundheitsakte section now.
// The disclosure that fronts the scope picker on
// `/settings/gesundheitsakte`. An account with no saved selection is a
// first run, and a first run opens it: that is the only place the standard
// template is applied, and a template hidden behind a closed disclosure
// would be opt-out wearing a hat.
//
// Opening by default must not turn the disclosure into decoration, so the
// operable part is asserted in both directions — it closes, taking the
// panel with it, and it reopens.
await page.route("**/api/share-links", async (route) => {
if (route.request().method() !== "GET") {
await route.continue();
@@ -105,29 +262,21 @@ test.describe("Settings → Export & Import", () => {
body: JSON.stringify({ data: [], error: null, meta: null }),
});
});
const sharingDataReady = page.waitForResponse(
(response) =>
response.status() === 200 &&
response.request().method() === "GET" &&
new URL(response.url()).pathname === "/api/share-links",
);
await page.goto("/settings/gesundheitsakte", {
waitUntil: "domcontentloaded",
});
await sharingDataReady;
await openGesundheitsakte(page);
const toggle = page.getByTestId("health-record-included-data-toggle");
const panel = page.getByTestId("health-record-included-data-panel");
await expect(toggle).toBeVisible({ timeout: 10_000 });
// Collapsed on first render — the checklist panel is absent.
// Open on first render, with the checklist panel behind it.
await expect(toggle).toHaveAttribute("aria-expanded", "true");
await expect(panel).toBeVisible();
// Collapses on demand, and the panel goes with it.
await toggle.click();
await expect(toggle).toHaveAttribute("aria-expanded", "false");
await expect(
page.getByTestId("health-record-included-data-panel"),
).toHaveCount(0);
// Expands on demand.
await expect(panel).toHaveCount(0);
// And back — the disclosure stays a disclosure.
await toggle.click();
await expect(toggle).toHaveAttribute("aria-expanded", "true");
await expect(
page.getByTestId("health-record-included-data-panel"),
).toBeVisible();
await expect(panel).toBeVisible();
});
test("Measurements CSV download fires a real download event", async ({
+61 -30
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
@@ -45,7 +45,6 @@ test.describe("/settings/integrations Pixel-5 layout", () => {
ntfyGlobal: true,
webPushGlobal: true,
apiGlobal: true,
moodLogGlobal: true,
},
error: null,
}),
@@ -53,6 +52,9 @@ test.describe("/settings/integrations Pixel-5 layout", () => {
);
const recent = new Date(Date.now() - 12 * 60 * 1000).toISOString();
const monthAgo = new Date(
Date.now() - 28 * 24 * 60 * 60 * 1000,
).toISOString();
await page.route("**/api/integrations/status", (route) =>
route.fulfill({
@@ -72,7 +74,7 @@ test.describe("/settings/integrations Pixel-5 layout", () => {
// v1.12.1 — the cards read connection state from this
// consolidated envelope (the per-provider routes were
// dropped from the section). The Withings pill keys on
// `connected`; the moodLog pill keys on `configured`.
// `connected`; a card-less pill keys on `configured`.
configured: true,
connected: true,
connectedAt: "2026-04-01T12:00:00Z",
@@ -81,19 +83,26 @@ test.describe("/settings/integrations Pixel-5 layout", () => {
tokenExpired: false,
scope: "user.info,user.metrics,user.activity",
hasActivityScope: true,
syncHealth: { verdict: "fresh", since: null },
metricFreshness: [
{ type: "WEIGHT", lastSeenAt: recent, stale: false },
],
},
{
integration: "moodlog",
// Configured, card-less, and no longer attempting: the case
// the fallback row exists for. Synthetic since v1.32.33 —
// every shipped provider has a card again, and the row must
// stay proven before the next one is dropped.
integration: "example-provider",
state: "connected",
lastSuccessAt: recent,
lastAttemptAt: recent,
lastError: null,
consecutiveFailures: 0,
configured: true,
enabled: true,
legacyLastSyncedAt: recent,
webhookSecret: "ml_xxxxxxxxxxxxxxxxxxxxxxxxxxxx",
entryCount: 42,
legacyLastSyncedAt: monthAgo,
syncHealth: { verdict: "stalled", since: monthAgo },
metricFreshness: [],
},
// v1.28.x — Strava OAuth card reads off this consolidated
// envelope; without a `strava` entry the new card's query key
@@ -108,6 +117,23 @@ test.describe("/settings/integrations Pixel-5 layout", () => {
configured: false,
available: false,
hasOwnCredentials: false,
syncHealth: { verdict: "disconnected", since: null },
metricFreshness: [],
},
// Nightscout reads the envelope too since the card dropped its
// own status round-trip; without an entry its pill never resolves.
{
integration: "nightscout",
state: "disconnected",
lastSuccessAt: null,
lastAttemptAt: null,
lastError: null,
connected: false,
configured: false,
hasToken: false,
allowPrivateHost: false,
syncHealth: { verdict: "disconnected", since: null },
metricFreshness: [],
},
],
},
@@ -133,23 +159,6 @@ test.describe("/settings/integrations Pixel-5 layout", () => {
}),
);
await page.route("**/api/integrations/moodlog/status", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: {
configured: true,
enabled: true,
lastSyncedAt: recent,
entryCount: 42,
webhookSecret: "ml_xxxxxxxxxxxxxxxxxxxxxxxxxxxx",
},
error: null,
}),
}),
);
await page.goto("/settings/integrations", {
waitUntil: "domcontentloaded",
});
@@ -157,10 +166,27 @@ test.describe("/settings/integrations Pixel-5 layout", () => {
// Wait for the pills to mount (queries resolve after first paint).
// One pill per card: Withings, WHOOP, Fitbit, Google Health, Polar,
// Oura, Strava, Nightscout. (The Garmin info note is not an OAuth card and
// carries no pill.)
// Oura, Strava, Nightscout — plus the fallback row for the configured,
// card-less entry. (The Garmin info note is not an OAuth card and
// carries no pill; the Apple Health card's pill keeps its own testid.)
const pills = page.locator('[data-testid="integration-status-pill"]');
await expect(pills).toHaveCount(8, { timeout: 10_000 });
await expect(pills).toHaveCount(9, { timeout: 10_000 });
// The acceptance case: a configured integration with no card of its own,
// which has stopped even attempting, is visible on the page.
const fallback = page.locator(
'[data-testid="integration-fallback-row"][data-integration="example-provider"]',
);
await expect(fallback).toHaveCount(1);
await expect(
fallback.locator('[data-testid="integration-status-pill"]'),
).toHaveAttribute("data-state", "stalled");
// Card anatomy is uniform now that Nightscout, the OAuth cards and Apple
// Health all carry the divider.
await expect(
page.locator('[data-testid="integration-card-divider"]'),
).toHaveCount(9);
// Withings is connected in this fixture; WHOOP is a BYO-keys card that
// stays dormant until an operator adds credentials, so assert the one
@@ -210,7 +236,12 @@ test.describe("Apple Health guidance", () => {
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: { entries: [], lastSyncedAt: null },
data: {
entries: [],
lastSyncedAt: null,
syncHealth: { verdict: "pending_first_sync", since: null },
metricFreshness: [],
},
error: null,
}),
}),
@@ -224,7 +255,7 @@ test.describe("Apple Health guidance", () => {
await expect(card).toContainText("not a web or OAuth connection");
await expect(page.getByTestId("apple-health-status")).toHaveAttribute(
"data-state",
"setup",
"pending-setup",
);
const importLink = page.getByTestId("apple-health-import-link");
+17 -4
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
@@ -94,7 +94,11 @@ test.describe("Settings mobile consistency (Pixel 5)", () => {
page,
}) => {
await page.goto("/settings/account", { waitUntil: "networkidle" });
await page.waitForLoadState("networkidle");
// Same reason as the grid test below: gate on the element, not the
// network, because the read underneath it happens exactly once.
await expect(
page.getByRole("button", { name: /change password/i }),
).toBeVisible();
// The account page's action button(s) must live within (or above the
// bottom of) the parent card. Since v1.18.1 the "restart onboarding"
@@ -146,7 +150,13 @@ test.describe("Settings mobile consistency (Pixel 5)", () => {
page,
}) => {
await page.goto("/settings/account", { waitUntil: "networkidle" });
await page.waitForLoadState("networkidle");
// Gate on the two elements this test is about, not on the network. An idle
// network says nothing about whether React has rendered the profile form,
// and the single `evaluate` below does not retry — so without this the
// test reads an empty document and reports "language + dob fields must
// exist", which sounds like a missing feature and is really a race.
await expect(page.locator("#language-select")).toBeAttached();
await expect(page.locator("#dob")).toBeAttached();
// The v1.4.27 R1 settings audit pairs date-of-birth with language
// in a single `grid sm:grid-cols-2` row so the profile form keeps
@@ -186,7 +196,10 @@ test.describe("Settings mobile consistency (Pixel 5)", () => {
page,
}) => {
await page.goto("/settings/ai", { waitUntil: "networkidle" });
await page.waitForLoadState("networkidle");
// `evaluateAll` over an empty match set returns an empty array rather than
// waiting, so the count assertion below would be the thing that fails and
// it would blame the page for having no selects. Wait for the first one.
await expect(page.locator("section select").first()).toBeVisible();
const heights = await page
.locator("section select")
+7 -4
View File
@@ -1,6 +1,7 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import { settleBeforeMeasure } from "./utils/settle";
/**
* Vertical over-scroll regression guard (the recurring class behind the
@@ -63,9 +64,11 @@ test.describe("settings + admin vertical over-scroll guard", () => {
test(`no over-scroll on ${route} (${vp.name})`, async ({ page }) => {
await page.setViewportSize({ width: vp.width, height: vp.height });
await page.goto(route, { waitUntil: "domcontentloaded" });
await page.waitForLoadState("networkidle");
// Let entry transitions / late queries settle before measuring.
await page.waitForTimeout(500);
// Was a fixed 500 ms sleep, which is the wrong shape twice over: too
// long when the page is ready and too short on a loaded runner, and
// it never says what it is waiting for. Gate on the element the
// measurement reads instead.
await settleBeforeMeasure(page, page.locator("#main-content"));
const dims = await page.evaluate(() => {
const main = document.getElementById("main-content");
+90
View File
@@ -0,0 +1,90 @@
/**
* The E2E suite's `test` / `expect` entry point.
*
* Every spec imports from here instead of from `@playwright/test`, and the
* structural guard `src/__tests__/e2e-route-cleanup-guard.test.ts` keeps it
* that way.
*
* ## Why
*
* A spec that registers `page.route(...)` in a `beforeEach` leaves the handler
* armed until the context closes. If the handler is async — `route.fetch()`,
* an `await` before `route.fulfill()` — teardown can close the context while
* one invocation is still in flight. The callback then rejects against a dead
* target, Playwright attributes the rejection to the test that just finished,
* and a test that had already passed is reported as failed. With `retries: 2`
* plus `failOnFlakyTests` on CI, that is a release blocker whose trigger is
* timing, not code.
*
* The remedy Playwright names is `unrouteAll({ behavior: "ignoreErrors" })`
* before the test ends. Written per spec that is 36 copies of the same three
* lines and one forgotten copy away from the same failure. Written as an auto
* fixture it happens for every test in the suite, including specs nobody has
* written yet, and a spec author never has to know it exists.
*
* ## What it covers
*
* - `page.route(...)` on every page in the test's context.
* - `context.route(...)` on the context itself.
* - Page event listeners for the interactive event types a spec subscribes to.
* A listener that awaits something (a `dialog` handler, a `response` handler
* that reads a body) can reject after teardown for exactly the same reason a
* route handler can.
*
* ## What it does not cover
*
* A page that lives in a context the spec created itself — `browser.newContext()`
* inside a helper — is invisible here, because this fixture can only see the
* context Playwright built for the test. `share-documents.spec.ts` is the one
* spec that does this; it owns those contexts and has to clean them up itself.
*/
import { test as base, expect } from "@playwright/test";
/**
* Page event types cleared at teardown. Deliberately not the lifecycle events
* (`close`, `crash`, `frameattached`, …): those describe the teardown we are
* standing in, and nothing in the suite hangs work off them. These are the
* types a spec attaches a handler to, and the ones whose handler can still be
* running when the context goes away.
*/
const OBSERVED_PAGE_EVENTS = [
"console",
"dialog",
"download",
"filechooser",
"pageerror",
"popup",
"request",
"requestfailed",
"requestfinished",
"response",
"websocket",
"worker",
] as const;
export const test = base.extend<{ routeCleanup: void }>({
routeCleanup: [
async ({ context }, use) => {
await use();
// Best-effort: a spec is free to close its own context (or the browser
// can die under it), and a teardown helper must never be the thing that
// turns a green run red. Everything below is a cleanup, so a failure to
// clean up has nothing left to protect.
try {
for (const page of context.pages()) {
await page.unrouteAll({ behavior: "ignoreErrors" });
for (const event of OBSERVED_PAGE_EVENTS) {
await page.removeAllListeners(event, { behavior: "ignoreErrors" });
}
}
await context.unrouteAll({ behavior: "ignoreErrors" });
} catch {
// Target already gone — nothing left to unroute.
}
},
{ auto: true },
],
});
export { expect };
+10 -4
View File
@@ -24,7 +24,8 @@
* broader corpus (`ensureVaultFixture`) supplies the ≥50 documents the picker
* cap test and the not-attached foreign-id probe need.
*/
import { expect, test, type Browser, type Page } from "@playwright/test";
import type { Browser, Page } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import {
@@ -50,9 +51,10 @@ interface CreatedShare {
}
/**
* Drive the owner create flow: open the picker, isolate the share-fixture trio
* by search, select all three, create the link, and read the one-time token +
* passphrase off the reveal card. Also asserts the QR promotion.
* Drive the owner create flow: name the link, choose a record scope, open the
* picker, isolate the share-fixture trio by search, select all three, create
* the link, and read the one-time token + passphrase off the reveal card. Also
* asserts the QR promotion.
*/
async function createShareWithDocs(page: Page): Promise<CreatedShare> {
const label = `${SHARE_DOC_PREFIX} ${Date.now()}`;
@@ -66,6 +68,10 @@ async function createShareWithDocs(page: Page): Promise<CreatedShare> {
await page.goto("/settings/sharing");
await sharingDataReady;
await page.locator("#share-label").fill(label);
// A record link starts with an empty scope and cannot be minted on one, so
// the scope is chosen here the way an owner chooses it: one click on the
// named standard set.
await page.getByTestId("report-apply-standard-share").click();
// Open the document picker and isolate the seeded trio.
await page.getByTestId("share-attach-open").click();
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import pg from "pg";
import { E2E_USER, STORAGE_STATE_PATH } from "./setup/global-setup";
+205
View File
@@ -0,0 +1,205 @@
import { expect, type Page } from "@playwright/test";
/**
* Shared horizontal-overflow guard for phone widths.
*
* WHY THIS MEASURES `<main>` AND NOT JUST THE DOCUMENT
* ----------------------------------------------------
* The obvious assertion — `documentElement.scrollWidth <= innerWidth` — cannot
* observe the app's real sideways scroll. `AuthShell` puts every route inside
*
* <main id="main-content" class="… overflow-y-auto …">
*
* and CSS says that when one of `overflow-x` / `overflow-y` is not `visible`,
* the other computes to `auto`. So `<main>` is itself a horizontal scroll
* container: an over-wide child grows `main.scrollWidth`, the user pans the
* page sideways, and `documentElement.scrollWidth` never budges because
* `<main>` absorbed the overflow. A document-only assertion is green through
* exactly the defect a person reports as "I can scroll sideways off the page".
*
* Both measurements are therefore asserted. On failure the helper names the
* widest element that is actually extending the scroll area (skipping anything
* an ancestor already scrolls or clips — a strip that is *meant* to pan inside
* its own `overflow-x-auto` container is not a defect), with its measured
* width and the viewport width, so a future failure is actionable without a
* screenshot.
*/
/** Real iPhone + common Android CSS widths. */
export const PHONE_WIDTHS = [360, 390, 393, 402, 430] as const;
export interface OverflowOffender {
selector: string;
rectWidth: number;
rectRight: number;
scrollWidth: number;
path: string;
}
export interface OverflowReport {
innerWidth: number;
docScrollWidth: number;
/** null when the route renders outside `AuthShell` (no `<main id>`). */
mainScrollWidth: number | null;
mainClientWidth: number | null;
offenders: OverflowOffender[];
}
/**
* Measure horizontal overflow of the document AND of the `AuthShell` scroll
* container, plus the elements responsible.
*/
export async function measureHorizontalOverflow(
page: Page,
): Promise<OverflowReport> {
return page.evaluate(() => {
const vw = window.innerWidth;
const de = document.documentElement;
const main = document.querySelector<HTMLElement>("main#main-content");
const report = {
innerWidth: vw,
docScrollWidth: de.scrollWidth,
mainScrollWidth: main ? main.scrollWidth : null,
mainClientWidth: main ? main.clientWidth : null,
offenders: [] as Array<{
selector: string;
rectWidth: number;
rectRight: number;
scrollWidth: number;
path: string;
}>,
};
const overflows =
de.scrollWidth > vw + 1 ||
(main !== null && main.scrollWidth > main.clientWidth + 1);
if (!overflows) return report;
const describe = (el: Element): string => {
const parts = [el.tagName.toLowerCase()];
if (el.id) parts.push(`#${el.id}`);
const slot = el.getAttribute("data-slot");
if (slot) parts.push(`[data-slot="${slot}"]`);
const cls = (el.getAttribute("class") ?? "").trim();
if (cls) parts.push(`.${cls.split(/\s+/).slice(0, 10).join(".")}`);
return parts.join("").slice(0, 180);
};
const root = main ?? document.body;
const limit = main
? main.getBoundingClientRect().left + main.clientWidth
: vw;
const scrollLeft = main ? main.scrollLeft : window.scrollX;
// An element whose ancestor (below `root`) scrolls or clips is contained by
// design — the UI standards' "wide content scrolls in its own container".
const absorbedByAncestor = (el: Element): boolean => {
let n = el.parentElement;
while (n && n !== root) {
const ox = getComputedStyle(n).overflowX;
if (
ox === "auto" ||
ox === "scroll" ||
ox === "hidden" ||
ox === "clip"
)
return true;
n = n.parentElement;
}
return false;
};
for (const el of Array.from(root.querySelectorAll("*"))) {
const r = el.getBoundingClientRect();
if (r.width === 0 && r.height === 0) continue;
// Fixed/sticky chrome is positioned against the viewport, not the
// scroll area, so it cannot widen it.
const position = getComputedStyle(el).position;
if (position === "fixed" || position === "sticky") continue;
const right = r.right + scrollLeft;
if (right <= limit + 1) continue;
if (absorbedByAncestor(el)) continue;
const chain: string[] = [];
let n: Element | null = el;
while (n && n !== de) {
chain.unshift(describe(n).slice(0, 70));
n = n.parentElement;
}
report.offenders.push({
selector: describe(el),
rectWidth: Math.round(r.width),
rectRight: Math.round(right),
scrollWidth: el.scrollWidth,
path: chain.slice(-5).join(" > "),
});
}
report.offenders.sort((a, b) => b.rectWidth - a.rectWidth);
report.offenders = report.offenders.slice(0, 5);
return report;
});
}
function formatReport(label: string, report: OverflowReport): string {
const lines = [
`${label}: horizontal overflow at viewport ${report.innerWidth}px`,
` document.scrollWidth = ${report.docScrollWidth} (viewport ${report.innerWidth})`,
` main.scrollWidth = ${report.mainScrollWidth} (client ${report.mainClientWidth})`,
];
if (report.offenders.length === 0) {
lines.push(
" no un-contained offender found — the overflow may come from a scroll container's own content",
);
}
for (const o of report.offenders) {
lines.push(
` • width ${o.rectWidth}px, right edge ${o.rectRight}px — ${o.selector}`,
);
lines.push(` path: ${o.path}`);
}
return lines.join("\n");
}
/**
* Assert the page does not scroll sideways — neither the document nor the
* `AuthShell` `<main>` scroll container.
*/
export async function expectNoHorizontalOverflow(
page: Page,
label: string,
): Promise<void> {
const report = await measureHorizontalOverflow(page);
const message = formatReport(label, report);
expect(report.docScrollWidth, message).toBeLessThanOrEqual(
report.innerWidth + 1,
);
if (report.mainScrollWidth !== null && report.mainClientWidth !== null) {
expect(report.mainScrollWidth, message).toBeLessThanOrEqual(
report.mainClientWidth + 1,
);
}
}
/**
* Settle a route before measuring: let the network go idle, pan the whole
* scroll container so every `next/dynamic` below-the-fold block mounts, then
* return to the top. A block that only mounts on scroll can only overflow once
* it has mounted.
*/
export async function settleForOverflowMeasurement(page: Page): Promise<void> {
await page.waitForLoadState("networkidle").catch(() => {});
await page.evaluate(async () => {
const main = document.querySelector<HTMLElement>("main#main-content");
const el: HTMLElement = main ?? document.documentElement;
const step = el.clientHeight || 800;
for (let y = 0; y < el.scrollHeight; y += step) {
el.scrollTop = y;
await new Promise((r) => setTimeout(r, 100));
}
el.scrollTop = 0;
});
await page.waitForLoadState("networkidle").catch(() => {});
}
+268
View File
@@ -0,0 +1,268 @@
import type { Page } from "@playwright/test";
import {
SECTION_MOBILITY,
SECTION_VITALS,
} from "@/components/insights/derived/use-dashboard-derived";
/**
* A POPULATED Insights overview fixture.
*
* The mobile-overflow guard exists to prove that a page carrying real content
* does not scroll sideways. Against the shared e2e account — which owns no
* measurements — `/insights` resolves to its empty state, and an empty page
* trivially fits any viewport. Every section below therefore gets a body:
* populated wellness scores, a full vitals grid, and the "signals of the day"
* card whose heading carries the long plain-language provenance caption.
*
* The strings are deliberately long. Prose in a heading's trailing `action`
* slot is exactly what widened the page in the first place, and a fixture with
* short labels would not reproduce it.
*/
const NOW = "2026-01-15T08:30:00.000Z";
function provenance(inputs: string[]) {
return {
inputs,
source: "DAY" as const,
windowDays: 30,
computedAt: NOW,
};
}
function coverage() {
return {
requiredInputs: 4,
presentInputs: 4,
historyDays: 90,
missing: [] as string[],
};
}
function confidence() {
return { score: 88, band: "high" as const };
}
function deviation(
type: string,
value: number,
low: number,
high: number,
direction: "above" | "below" | "in",
) {
return {
type,
value,
center: (low + high) / 2,
low,
high,
outside: direction !== "in",
direction,
};
}
/**
* The "signals of the day" payload in its `fired` state — the branch that
* renders `SectionHeading action={<CoincidentProvenance/>}`, i.e. a full
* sentence pinned into the heading row.
*/
const COINCIDENT_FIRED = {
metric: "COINCIDENT_DEVIATION",
status: "ok" as const,
value: {
fired: true,
day: "2026-01-15",
illnessExplained: false,
vitals: [
deviation("RESTING_HEART_RATE", 71, 52, 62, "above"),
deviation("HEART_RATE_VARIABILITY", 38, 45, 78, "below"),
deviation("RESPIRATORY_RATE", 17.4, 12.5, 16.2, "above"),
deviation("OXYGEN_SATURATION", 97, 96, 99, "in"),
deviation("BODY_TEMPERATURE", 36.9, 36.2, 37.1, "in"),
],
contributing: [
deviation("RESTING_HEART_RATE", 71, 52, 62, "above"),
deviation("HEART_RATE_VARIABILITY", 38, 45, 78, "below"),
deviation("RESPIRATORY_RATE", 17.4, 12.5, 16.2, "above"),
],
},
coverage: coverage(),
confidence: confidence(),
provenance: provenance([
"RESTING_HEART_RATE",
"HEART_RATE_VARIABILITY",
"RESPIRATORY_RATE",
]),
reason: null,
};
/** A deterministic sparkline series around `center`. */
function series(center: number, spread: number): number[] {
return Array.from({ length: 30 }, (_, i) =>
Number((center + Math.sin(i / 2.5) * spread).toFixed(2)),
);
}
/**
* A populated `WellnessScoreValue` entry — the exact wire shape the three
* persisted score types share (`RECOVERY_SCORE` / `STRESS_SCORE` /
* `STRAIN_SCORE`).
*/
function wellnessScore(
metric: string,
score: number,
band: "green" | "yellow" | "red",
) {
return {
metric,
status: "ok" as const,
value: {
score,
band,
trendDelta: 3.4,
daysInWindow: 28,
asOf: NOW,
series: series(score, 6),
},
coverage: coverage(),
confidence: confidence(),
provenance: provenance(["SLEEP_DURATION", "RESTING_HEART_RATE"]),
reason: null,
assessment: null,
};
}
/** A populated `VitalsBaselineValue` tile — exact wire shape. */
function vitalsBaseline(type: string, center: number, spread: number) {
return {
metric: "VITALS_BASELINE",
status: "ok" as const,
value: {
type,
center,
low: Number((center - spread).toFixed(2)),
high: Number((center + spread).toFixed(2)),
spread,
sampleDays: 30,
k: 2,
series: series(center, spread / 2),
},
coverage: coverage(),
confidence: confidence(),
provenance: provenance([type]),
reason: null,
};
}
/**
* The honest "not enough history" arm. Metrics whose value shapes this fixture
* does not reproduce exactly are served as `insufficient` rather than as an
* invented payload — that is a real state those tiles handle (they un-mount),
* and a wrong-shaped `ok` payload would throw in the tile instead of testing
* anything.
*/
function insufficient(metric: string) {
return {
metric,
status: "insufficient" as const,
value: null,
coverage: {
requiredInputs: 4,
presentInputs: 1,
historyDays: 3,
missing: ["SLEEP_DURATION"],
},
confidence: null,
provenance: provenance([]),
reason: "not_enough_history",
};
}
/** Center + spread per vital, in each metric's own units. */
const VITAL_BANDS: Record<string, [number, number]> = {
RESTING_HEART_RATE: [57, 5],
RESPIRATORY_RATE: [14.1, 1.8],
OXYGEN_SATURATION: [97, 1.5],
BODY_TEMPERATURE: [36.6, 0.45],
BLOOD_GLUCOSE: [92, 13],
WEIGHT: [79.4, 3],
};
function buildBatchMetrics(): Record<string, unknown> {
const metrics: Record<string, unknown> = {
RECOVERY_SCORE: wellnessScore("RECOVERY_SCORE", 66, "yellow"),
STRESS_SCORE: wellnessScore("STRESS_SCORE", 41, "green"),
STRAIN_SCORE: wellnessScore("STRAIN_SCORE", 72, "yellow"),
READINESS: insufficient("READINESS"),
SLEEP_SCORE: insufficient("SLEEP_SCORE"),
FITNESS_AGE: insufficient("FITNESS_AGE"),
VASCULAR_AGE_DELTA: insufficient("VASCULAR_AGE_DELTA"),
HRV_BALANCE: insufficient("HRV_BALANCE"),
BMI: insufficient("BMI"),
SIX_MINUTE_WALK_BAND: insufficient("SIX_MINUTE_WALK_BAND"),
};
for (const type of SECTION_VITALS) {
if (type === "HEART_RATE_VARIABILITY") continue;
const [center, spread] = VITAL_BANDS[type] ?? [50, 5];
metrics[`VITALS_BASELINE:${type}`] = vitalsBaseline(type, center, spread);
}
for (const { metric } of SECTION_MOBILITY) {
metrics[metric] = insufficient(metric);
}
return metrics;
}
/**
* Route-mock a populated Insights overview onto `page`.
*
* Only the reads that decide whether a section renders AT ALL are stubbed;
* everything else (the i18n bundle, every layout decision, the real component
* tree) stays authentic, so the geometry under test is the shipped geometry.
*/
export async function mockPopulatedInsights(page: Page): Promise<void> {
// Without a non-empty comprehensive payload the page short-circuits to its
// "no data yet" empty state and no section mounts at all.
// RegExp matchers throughout, not globs: Playwright's URL glob treats `?` as
// a single-character wildcard, so `**/api/insights/derived?*` also matches
// `/api/insights/derived/batch?…` and the two handlers shadow each other.
await page.route(/\/api\/insights\/comprehensive(\?|$)/, (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: {
totalMeasurements: 4821,
moodSummary: { count: 96 },
medications: [{ id: "med-1" }, { id: "med-2" }, { id: "med-3" }],
},
error: null,
}),
}),
);
// The batched derived read behind the wellness strip + the vitals grid.
await page.route(/\/api\/insights\/derived\/batch(\?|$)/, (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: { metrics: buildBatchMetrics() },
error: null,
}),
}),
);
// The single-metric derived route. Only COINCIDENT_DEVIATION is stubbed —
// it is the card whose heading carries the provenance sentence. Any other
// metric falls through to the real handler.
await page.route(/\/api\/insights\/derived\?/, async (route) => {
const metric = new URL(route.request().url()).searchParams.get("metric");
if (metric !== "COINCIDENT_DEVIATION") return route.fallback();
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ data: COINCIDENT_FIRED, error: null }),
});
});
}
+39
View File
@@ -0,0 +1,39 @@
import { expect, type Locator, type Page } from "@playwright/test";
/**
* Gate a one-shot DOM read on the element it is about to measure.
*
* The failure this exists to prevent: a spec navigates with
* `waitUntil: "networkidle"`, then reads the DOM in a single
* `page.evaluate` / `evaluateAll` that does not retry. An idle network says
* nothing about whether React has rendered, so the read can land on a tree
* that is still mounting and the assertion then blames the application for
* something the test raced — "language + dob fields must exist" when the
* fields were simply not there yet, or a measurement helper accused of
* measuring the wrong element when it was measuring the right one too early.
*
* Two such tests went red on consecutive CI runs during v1.33.0. Neither
* reproduces locally: a developer machine is fast enough that the render
* always wins the race, so the only place this shows up is a loaded runner,
* and `failOnFlakyTests` means every occurrence costs a whole red pipeline
* even when the retry passes.
*
* `sentinel` must be something the measurement actually depends on, not just
* any element on the page. Gating on a layout shell that renders before the
* data proves nothing.
*/
export async function settleBeforeMeasure(
page: Page,
sentinel: Locator,
options: { attachedOnly?: boolean } = {},
): Promise<void> {
if (options.attachedOnly) {
await expect(sentinel).toBeAttached();
} else {
await expect(sentinel).toBeVisible();
}
// Fonts and late CSS change geometry after the element is visible, and
// every measurement in this suite is geometric.
await page.waitForLoadState("networkidle").catch(() => {});
await page.evaluate(() => document.fonts?.ready);
}
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
/**
* v1.4.27 MB6 — public surfaces.
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
/**
* /api/version is the public, unauthenticated endpoint that drives
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import {
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
+340 -259
View File
File diff suppressed because it is too large. Load diff
+355 -264
View File
File diff suppressed because it is too large. Load diff
+337 -258
View File
File diff suppressed because it is too large. Load diff
+334 -257
View File
File diff suppressed because it is too large. Load diff
+349 -262
View File
File diff suppressed because it is too large. Load diff
+358 -265
View File
File diff suppressed because it is too large. Load diff
+6 -6
View File
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "1.32.24",
"version": "1.33.1",
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
"license": "PolyForm-Noncommercial-1.0.0",
"homepage": "https://healthlog.dev",
@@ -77,13 +77,13 @@
"jose": "^6.2.3",
"jspdf": "^4.2.1",
"jspdf-autotable": "^5.0.8",
"lucide-react": "^1.18.0",
"lucide-react": "^1.27.0",
"mmdb-lib": "^3.0.2",
"next": "16.2.11",
"next-themes": "^0.4.6",
"nodemailer": "9.0.3",
"otpauth": "^9.5.1",
"p-limit": "^7.3.0",
"p-limit": "^7.3.1",
"pg-boss": "^12.26.0",
"prisma": "^7.8.0",
"qrcode": "^1.5.4",
@@ -92,7 +92,7 @@
"react-day-picker": "^10.0.1",
"react-dom": "19.2.7",
"react-hook-form": "7.81.0",
"recharts": "^3.8.1",
"recharts": "^3.10.1",
"rrule": "^2.8.1",
"sax": "^1.6.0",
"sonner": "^2.0.7",
@@ -107,7 +107,7 @@
},
"devDependencies": {
"@axe-core/playwright": "^4.12.1",
"@next/bundle-analyzer": "^16.2.9",
"@next/bundle-analyzer": "^16.2.12",
"@playwright/test": "^1.60.0",
"@tailwindcss/postcss": "^4",
"@testcontainers/postgresql": "^12.0.3",
@@ -118,7 +118,7 @@
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/web-push": "^3.6.4",
"@vitejs/plugin-react": "^5.1.4",
"@vitejs/plugin-react": "^6.0.4",
"@vitest/coverage-v8": "^4.1.5",
"eslint": "^9",
"eslint-config-next": "16.2.6",
+195 -447
View File
File diff suppressed because it is too large. Load diff
+4
View File
@@ -21,3 +21,7 @@ allowBuilds:
ssh2: false
tesseract.js: false
unrs-resolver: false
minimumReleaseAgeExclude:
- "@next/bundle-analyzer@16.2.12"
- lucide-react@1.27.0
@@ -0,0 +1,4 @@
-- One-shot Fitbit sleep duplicate repair (stable per-segment sleep keys).
-- Null = repair pending; stamped once the boot-time bounded sleep re-read
-- completes for the connection.
ALTER TABLE "fitbit_connections" ADD COLUMN "sleep_repaired_at" TIMESTAMP(3);
Loaded 100 of 789 files, more files were not shown because too many files have changed in this diff. Show more