Compare commits

...
708 Commits
Author SHA1 Message Date
Marc-André Bombeck f17ad1f7b3 Merge pull request #1300 from MBombeck/release/v1.46.2
Release v1.46.2
2026-10-29 12:11:46 +01:00
Marc-André Bombeck a587be009d test(backup): report a broken pipe in the dump test's docker wrapper
Under a loaded full run the wrapper around pg_dump and pg_restore crashed
with an unhandled stream error, and the dump under test reported only the
tail of that stack. The wrapper now leaves the exit code to the tool and
writes the reason to stderr.
2026-10-29 11:51:39 +01:00
Marc-André Bombeck 20585f3b3d chore(release): v1.46.2 2026-10-29 11:28:49 +01:00
Marc-André Bombeck 14eaa5c7c5 test(backup): keep the 1.46.2 backup file as a golden 2026-10-29 11:28:48 +01:00
Marc-André Bombeck d1ea803260 Merge branch 'fix/v1462-review' into release/v1.46.2 2026-10-29 11:27:58 +01:00
Marc-André Bombeck 34f8a856b4 fix(labs): register the lab repair's undecryptable count as a job fact
The count rides out in the job's outcome facts, whose vocabulary is an
allowlist, and is kept as a failure tally so the catch that feeds it is
not a silent one.
2026-10-29 11:16:18 +01:00
Marc-André Bombeck 6b820fa3c5 fix(rollups): defer the dense fold when the backfill check cannot read the queue
The check whether an account's full rollup backfill is queued or
running answered false on any error, so a failed read let the dense
fold delete raw samples the backfill still had to read. A missing queue
table still answers false, since a web-only deployment has no backfill;
any other failure now counts as pending and is annotated. A backfill
that failed for good is still not pending, as the comment now says.
2026-10-29 11:05:43 +01:00
Marc-André Bombeck ec0f67f850 fix(admin): give the single-account rollup recompute the boot fold's budget
The admin recompute for one account called the full fold without a
statement budget, so on the large accounts it is meant for each yearly
statement ran under the database's ordinary limit and timed out, as the
boot job had before it got its own. It now passes the same per-statement
budget.
2026-10-29 11:04:21 +01:00
Marc-André Bombeck 6fb50b01b8 fix(auth): offer the provider re-proof to a browser only
The MFA status read listed oidc among the proof methods for a Bearer
caller too, although the provider round trip stamps a browser session
and nothing else, and the API contract says a token is never offered
it. The status read now leaves it out on the Bearer path.
2026-10-29 11:03:09 +01:00
Marc-André Bombeck fc117d7cd9 fix(auth): date a provider re-proof by its auth_time
The re-proof accepted an auth_time up to five minutes old and then
stamped the session at callback time, so the proof held for close to
ten minutes after the person signed in at the provider. It now stamps
the authentication instant, clamped to now for a provider clock that
runs ahead, and the gates' five-minute window runs from there.
2026-10-29 11:01:25 +01:00
Marc-André Bombeck 6df0fad3b7 fix(labs): let the lab date repair skip a damaged fact and read the app's zone
One staged fact that would not decrypt threw out of the whole repair, so
the job failed, retried, and was enqueued again on every boot. It is now
skipped and counted as undecryptable.

The candidate query read an account zone Postgres does not know as UTC,
while the repair resolves it to the instance default and then
Europe/Berlin. A value confirmed near midnight stayed a candidate the
repair always left alone, so the set never converged. The query now
falls back the same way.
2026-10-29 11:00:01 +01:00
Marc-André Bombeck a6f4c386fe fix(jobs): judge a system job's failure streak per job, per account
The alert added every attempt of a failed job to the streak, so one job
with two retries read as three failures. It also judged a queue by its
newest job across all accounts: on a queue that runs one job per
account, one account failing every hour beside accounts that succeed
never reached the streak, or flipped between failing and recovered.

The streak now counts jobs, and is kept per account (the job's userId,
or the whole queue for jobs without one). A queue is failing while any
account has failed three jobs in a row, and recovered only when a
failing account has itself run successfully since. A reminder needs a
failure newer than the notice before it, so a one-off job that failed
and does not run again is announced once.
2026-10-29 10:58:04 +01:00
Marc-André Bombeck ec2ec40003 fix(measurements): report folded provider heart rate to the change feed
The dense fold deleted Google Health and Oura samples outright, on the
reasoning that no client pulls them. The change feed serves every
source, so a client mirroring it kept those samples next to the hourly
means with nothing to tell it they were gone.

The nightly increment of a server-pulled source is tombstoned again: the
feed reports each sample and the tombstone cleanup purges them after the
retention. A backlog day, such as an account's first fold, is still
deleted outright, and the account's delta cursors expire instead, since
tombstoning millions of rows would undo the point of the fold. The
compaction-tombstone class leaves these sources out, so the feed, the
backlog purge and the restore treat their tombstones like any deletion.
Apple Health and Health Connect are unchanged.
2026-10-29 10:52:54 +01:00
Marc-André Bombeck dba031d4ad test(integration): record the release's own behaviour in the goldens
The status prompts carry the unattended flag for background calls, the
queue list holds the lab date repair, and an account whose only way to
confirm is its identity provider is now offered that way.
2026-10-29 10:42:40 +01:00
Marc-André Bombeck 17d9c15dbc fix(measurements): keep a folded hour out of a blood-pressure reading
The reading delete found the cuff's pulse by owner, source and identical
instant. The dense fold anchors an hour of heart rate at the local HH:30
under the samples' source, so a reading taken at half past took that hour
with it. Aggregate (stats:) rows are never part of a reading now, for the
delete and for the restore.
2026-10-29 10:34:01 +01:00
Marc-André Bombeck ad5d154561 Merge branch 'feat/v1462-scale' into release/v1.46.2
# Conflicts:
#	src/__tests__/baselines/version-stamp-comments.json
2026-10-29 10:18:22 +01:00
Marc-André Bombeck a9c2e25099 fix(rollups): trail the rollup window from the caller's moment
readBestGranularityRollups opened its window at the wall clock, while the
live fallback beside it used the caller's now. A read anchored on another
moment saw different days depending on whether the account already had
DAY buckets; the Today overview's integration test turned red once its
fixed morning fell out of the wall-clock window. Every caller that holds a
now passes it through.
2026-10-29 10:13:22 +01:00
Marc-André Bombeck 1525be5d53 Merge branch 'feat/v1462-tests' into release/v1.46.2 2026-10-29 10:04:42 +01:00
Marc-André Bombeck f24589c294 test(integration): take a large Google Health account through the fixes
420 000 heart-rate readings, one every five minutes over about four
years, written through the real Google Health backfill with Google's
API faked at the safeFetch seam. The account's DAY rollups lose their
reading extremes, as rows folded before 0417 have them; the boot
discovery finds it, and the windowed full backfill refills every bucket
in the fold window with the raw readings' own extremes. The dense fold
then thins everything older than 90 days to hourly Google Health rows
(420 000 rows to about 59 000) while the DAY tier keeps its figures,
and a second full-history backfill over the same samples inserts
nothing. Without the ingest guard it re-inserts about 394 000 of them.

Each step reports its duration on stderr. On a developer machine:
backfill about 85 s, discovery 0.2 s, full backfill 4 s, fold 40 s,
second backfill 4 s.
2026-10-29 10:03:49 +01:00
Marc-André Bombeck 66081e7102 test(backup): restore the backup of an account that was erased
Nothing covered the operator's position after an erasure: a backup file,
an admin session, and a server that no longer knows the account. Export
the shared backup seed, delete the account through the settings route
under a 14-day window and run the purge job past its end, then:

- scan every text and JSON column for the account's id; only a
  rate-limit bucket that expires within a day may remain;
- upload the file as an admin: refused as owner_not_found, nothing
  stored (the documented contract);
- recreate the account row under its id, upload and restore: every
  section's count matches the export;
- point the file at a freshly created account, upload and restore: every
  count matches and nothing names the erased account afterwards.
2026-10-29 10:03:10 +01:00
Marc-André Bombeck 9d27b6f103 test(backup): restore every release's own backup file
Every backup test exports and restores with the same code, so a change
that moves both ends at once passes all of them and still breaks a file
a self-hoster downloaded last month. Commit the disaster-recovery file
the 1.46.1 exporter writes for the shared backup seed, with a manifest of
its counts and SHA-256, and restore each committed file through the admin
upload and restore routes into an empty account, comparing the re-exported
counts section by section.

The folder takes one file per release (GOLDEN_BACKUP_WRITE=1 writes the
current one; the README says when). The seed's own illness-symptom
catalogue key is swapped for a shipped one so every link resolves on a
fresh instance. Ciphertext is sealed under the suite's fixed key. A
measurement tombstone that ages past retention may only lower its count.
2026-10-29 09:58:20 +01:00
Marc-André Bombeck 1340200cac test(e2e): hold the header height in every app language
A translated label that runs a little longer than the English one wraps,
and a wrapping header grows; every English-only check stays green while
one locale ships a taller dashboard header. The new sweep switches its own
account through each locale the app ships (via the locale API, with
<html lang> asserted before measuring), then compares the top bar and the
dashboard's page-header row on a 390 px phone and a desktop window with
the height English gives them, within 1 px.

The locale list is read from the i18n config, so a new language is swept
without editing the spec. It runs in the desktop project only because the
account's language is one value shared by its tests.
2026-10-29 09:58:14 +01:00
Marc-André Bombeck f74fcf88b1 docs(self-hosting): add a section on large accounts
What an account with tens of millions of readings looks like when it
runs into the per-statement limit (empty cards, job.run.failed with
57014), what the full backfill, the hourly heart-rate fold and the
admin alert now do about it, and when raising
DATABASE_STATEMENT_TIMEOUT_MS still makes sense. The variable was only
named in the compose file and the example env until now (#1298).
2026-10-29 09:54:52 +01:00
Marc-André Bombeck f99f87eca6 feat(jobs): tell admins when a system job keeps failing
A full rollup backfill that failed on every boot showed only on the
admin page's card of failing background jobs. From the account's side
the only symptom was empty dashboard cards, and nothing pointed the
operator at the card (#1298).

A new hourly evaluation reads the newest terminal runs of every queue
whose failure has no account-facing surface (the system audience in
subsystem-surface.ts) from pg-boss's own ledger. A failed run adds its
attempts, retries included, and the first successful run ends the
streak. Three failed attempts in a row send every admin a SYSTEM_ALERT
once, a reminder at most once a day while the queue keeps failing, and
one notice when it runs successfully again: the decision the backup
health notices use, with the same compare-and-set claim, so two workers
never send a notice twice.

The memory is one row per announced queue in system_job_alert_notices
(migration 0490). The notice names the queue, the streak and the last
error, and opens the admin status page.
2026-10-29 09:54:43 +01:00
Marc-André Bombeck d0be3ae106 Merge branch 'feat/v1462-auth' into release/v1.46.2 2026-10-29 09:45:37 +01:00
Marc-André Bombeck 4a99658632 Merge branch 'feat/v1462-data' into release/v1.46.2 2026-10-29 09:45:37 +01:00
Marc-André Bombeck a571dcc47a refactor(measurements): drop a fold-source helper nothing calls
The ingest guard reads the per-source map through the folded-window rules, so the standalone predicate had no caller.
2026-10-29 09:43:37 +01:00
Marc-André Bombeck f92889ab88 refactor(auth): keep the account payload handler and chat schemas in size
The createdAt field and the locale import grew files the size ratchet
holds. The account payload's insurance-number decryption moves into
its own helper, the import joins the zod one, and two comments lose a
wrapped line, so no baseline has to rise.
2026-10-29 09:42:31 +01:00
Marc-André Bombeck 07cf9bba3e fix(measurements): hold the dense fold back while an account's rollups rebuild
The full rollup backfill refills each day's figures from the raw
readings, and the dense fold deletes those readings once it has their
hourly means. On a large account the backfill can run longer than the
ten minutes the fold's boot job waits, and a year it reached after the
fold had taken it would compute its days from hourly means: the day
count, minimum and maximum of every folded day would change.

The consolidation walk now asks a per-account deferUser hook before it
reads an account, and both entries of the dense fold (the per-account
boot job and the nightly drain) pass isRollupFullBackfillPending, which
looks for a queued or running full fold of the account in pg-boss. The
account is folded on a later run, once its rollup tier is rebuilt.
2026-10-29 09:28:31 +01:00
Marc-André Bombeck d7409b38d7 fix(i18n): address the French reader as "vous" throughout
The French bundle mixed the familiar and the formal address: about a
third of the strings said "tu", "ton" or "Réessaie", the rest "vous",
sometimes both on one screen and once in one string.

Every string that addressed the reader as "tu" now uses "vous" with the
matching conjugation, possessives and agreement; placeholders, plural
branches and punctuation are unchanged. The Coach prompts a person
sends address the Coach the same way, the casual "Salut" greeting of
the nudges becomes "Bonjour", and "après que" takes the indicative.
2026-10-29 09:22:57 +01:00
Marc-André Bombeck 919d64947c feat(mental-health): name the Dutch and Belgian crisis lines
A Dutch reader whose screener answer raised the safety path saw only
the international set, because no Dutch set existed.

The Netherlands gets 113 Zelfmoordpreventie (113, the free 0800-0113
and the chat on 113.nl), Belgium the Zelfmoordlijn (1813,
zelfmoord1813.be), both behind 112. The app's one Dutch bundle is read
in both countries and the account keeps no country, so a bare "nl"
lands on a combined set that names each line with its country, the way
the German and Portuguese sets already do; an explicit nl-NL, nl-BE or
region "be" gets its own country's set.
2026-10-29 09:22:00 +01:00
Marc-André Bombeck 6f60812132 fix(ai): accept every app language on the chat and status requests
The Coach chat, the document chat and the fenced chat declared their
locale field as de/en, so a body naming French, Korean, Brazilian
Portuguese or any other shipped language was refused with 422 before
resolveServerLocale, which carries every locale, ever saw it. The same
pair sat on the profile update, so an account could not store any other
language there either, and the stored language is what the cookie-blind
background work (the Coach nudge, notification texts) writes in.

All four now take z.enum(locales), and the published contract for the
metric, insight and biomarker status queries and the profile update
lists every UI locale instead of two.
2026-10-29 09:19:56 +01:00
Marc-André Bombeck 65fe5c34e3 fix(dashboard): welcome a new account instead of welcoming it back
The header always said "welcome back", including to an account the
setup flow had created a few minutes earlier.

/api/auth/me now publishes the account's createdAt, and the greeting
welcomes an account in its first day ("Welcome, name.") and welcomes
back every other one, including an account whose older server does not
send the field. Until the account is known, during the SSR pass and the
hydration render, the line is the time-of-day greeting alone, so
nothing claims "back" before the client knows it is true.
2026-10-29 09:13:48 +01:00
Marc-André Bombeck 107db1bb55 feat(admin): let an admin delete another account through the grace window
There was no way for an admin to remove someone else's account; the
users list only offered the undo for a deletion the account itself or a
Guardian had asked for.

DELETE /api/admin/users/{id} takes the account's own deletion path,
now shared as requestAccountDeletion: with a grace window the account
is locked at once (credentials revoked, record untouched) and erased by
the purge job when the window ends, and the existing undo gives it back
until then; with ACCOUNT_DELETION_GRACE_DAYS=0 it is erased in the
request. The route is cookie-only behind requireAdmin and the
recent-proof gate, takes the typed DELETE_ACCOUNT confirmation, and
refuses the caller's own account, a managed profile (its Guardians
delete it), an account already pending deletion, the last admin who
can sign in and the last Guardian of a managed profile, the last three
decided under their locks inside the transaction. Both the admin and
the account get an audit row.

The users list says who asked for a pending deletion, now including an
admin, and flags managed profiles. Each other account's row carries a
delete button whose confirmation names the purge date, or says the
erasure is immediate when the server keeps no window.
2026-10-29 09:10:26 +01:00
Marc-André Bombeck ef141f457b test(i18n): lower the version-stamp baseline for the provider
The locale effect and its dated comment left the i18n provider with the
previous commit, so the ratchet records one stamp fewer.
2026-10-29 09:09:35 +01:00
haritowa 0b32b567c0 docs(ai): record the live check of the GPT-6 rungs and the retired 5.x line
From a HealthLog container with its stored ChatGPT sign-in, gpt-6.1-sol,
gpt-6-sol and gpt-6-luna each answered text and read a lab-report image,
and the account's own model list offered them with image input.
gpt-6.1-sol refuses effort none and names low as its lowest. The spec said
the gpt-6 rungs had never been probed live, and still listed gpt-5.4,
gpt-5.4 mini, gpt-5.3-codex and gpt-5.2 as accepted, all of which OpenAI
has retired for ChatGPT sign-in, with the exact refusal the backend sends.

Also note why a fixed chain goes stale: once GPT-5.5 retired, every model
on the chain shipped at the time was retired with it.
2026-10-29 09:09:23 +01:00
haritowa 69d46f0add test(ai): pin the walk past a retired model on the default Codex list
OpenAI retired the 5.x line from Codex for ChatGPT sign-in in steps: GPT-5.2
and GPT-5.3-Codex on 2026-05-26, GPT-5.4 and GPT-5.4 mini on 2026-08-31,
and GPT-5.5 on 2026-10-14. The backend answers a retired model with 400
model_not_found and "not supported when using Codex with a ChatGPT
account".

Pin that exact refusal as a reason to try the next slug, pin that an
account refused the head of the default list walks on to the next rung
and caches it, and keep the long-retired models off the default list.
2026-10-29 09:09:02 +01:00
haritowa acf58923f7 test(ai): pin the step-up when gpt-6.1-sol refuses effort none
gpt-6.1-sol, a rung on the default list, answers reasoning "off"
(effort none) with 400 unsupported_value and lists low as its lowest
effort (seen 2026-10-24). The client must stay on the slug, retry with
low and remember it, not walk on as if the model were retired.
2026-10-29 09:08:32 +01:00
Marc-André Bombeck bf0142a626 fix(ai): give background work the High room on a model that always reasons
Background jobs ask for medium reasoning at most, and status cards ask for
none. A model that cannot be switched down (GLM behind a vLLM gateway, for
instance) ignores both, spends the whole room thinking and returns only
reasoning, so the daily briefing and the status cards fail as
reasoning_only and the chain pauses. The Coach on High works because it
leaves 12k tokens to think in.

The chain runner now marks every call of a job walk as unattended, in one
place rather than at each call site. The gateway and local clients give
such a call at least the High thinking room once its model has answered
with reasoning alone. The room has to ride on the #1267 repeat with none:
one call may send two requests, and a once-a-day job meets an expired
lesson every morning, so a room that waited for the unswitchable lesson
would never reach the first briefing of the day. It is a ceiling, not a
charge: a model that does stop at none never touches it. Calls a person is
reading, the Coach included, keep the room of their own level, and the
requested effort is unchanged.

The room calculation both clients shared is now one function.

Reported by @Tio61 in #1276.
2026-10-29 09:07:37 +01:00
Marc-André Bombeck 9eed407ccc feat(measurements): fold Google Health and Oura heart rate like the exports
The dense hourly fold only took Apple Health and Health Connect samples,
so heart rate from the provider syncs that store one row per sample
stayed raw forever. One self-hosted account held about 25 million
Google Health pulse rows after a full-history backfill, and every
reader and fold that falls back to the raw table paid for it (#1297).

The fold now walks a map of sources to the dense types each stores
sample by sample: every dense type for Apple Health and Health Connect,
heart rate for Google Health and Oura. Their daily SpO2 values are no
intraday stream and stay as they are; Fitbit does not sync intraday
heart rate, and WHOOP, Withings and Polar write dense types only once
per recovery, night or spot check. Each source folds into its own
hourly stats rows, and the DAY rollup is still taken from the raw
samples before they go.

The fold deletes the samples it averages, and the provider syncs write
first-write-wins on the externalId, so a backfill or full sync would
have put every folded sample back. Google Health would even have
re-keyed an hourly row into a sample that fell on its anchor through
the natural-key rescue. The folded_window rule now has one hour rule
per fold source, and both syncs drop the samples of an hour the fold
already took before they write. Recent samples cost no lookup.

The boot discovery of the fold probes each account and source for the
first raw sample it would take instead of grouping every such row, and
the one-time fold repair stays on the two sources it was written for.
2026-10-29 09:06:17 +01:00
Marc-André Bombeck 2490a2d218 fix(i18n): mirror the language to the account only when signed in
The provider sent PUT /api/auth/me/locale on mount and on every switch,
signed in or not. Signed out the route answers 401, the once-per-tab
marker was never set, and every signed-out page load repeated the
refused write.

The mirror moves out of the provider into LocaleServerSyncBridge, which
watches the account payload without fetching it and writes only once an
account is known. The choice made on the sign-in page therefore reaches
the account right after signing in, and the marker is keyed on the
account as well as the language, so another account signing in on the
same tab is written for too. The browser test stub no longer treats the
PUT as ambient, which is what lets its test see a stray write.
2026-10-29 09:00:04 +01:00
Marc-André Bombeck bbef4eea99 feat(auth): let a single-sign-on account confirm it is you at its provider
An account created through OIDC holds no password, passkey or second
factor, so the recent-proof gate in front of token minting, MCP
connections, exports and the admin reset had nothing to ask it for and
the web could only say "sign in again" (#1296).

GET /api/auth/oidc/reproof sends the browser back to the configured
provider with prompt=login and max_age=0, its own state, nonce and PKCE
verifier, and the signed-in account and session sealed into the state
cookie. The shared callback branches on that intent: it refuses unless
the browser still carries the bound session (checked before the code is
redeemed), the ID token names the account's linked issuer and subject,
and auth_time lies inside the gate's five-minute window. It then stamps
Session.reproofAt exactly as a password re-proof does, creates no
session, and returns to the page it left with ?reproof=done|failed.

recentProofMethods offers "oidc" only to an account without a second
factor whose identity is linked while a provider is configured, and the
fresh-factor gate never lists it. GET /api/auth/me/mfa publishes the
list as proofMethods, so the passkey card offers what the account can
actually give, and register-options takes { method: "oidc" } on a fresh
provider re-proof, so such an account can add a passkey as a second way
to confirm. Both re-proof dialogs offer the provider and a toast reports
the outcome on return.

The callback's missing-state branch and the passkey card's enrollment
flow move into their own functions to keep both handlers under the size
ratchet.
2026-10-29 08:56:51 +01:00
Marc-André Bombeck e2998183c8 fix(measurements): delete a blood-pressure reading as one
Systolic, diastolic and the cuff's pulse are three rows with no link
column, and the single DELETE tombstoned only the row it was given, so
deleting a reading's systolic left its diastolic and pulse behind as half
a reading. The bulk delete had the same gap.

A reading is the rows with the same owner, the same source and the
identical measuredAt: that is how the entry form, the MCP write, a
Withings measure group and a HealthKit correlation all store one, and how
the FHIR read pairs a diastolic onto its systolic. The looser five-minute
pairing of the chart reads is right for drawing a point and wrong for
taking rows away.

Deleting a systolic or diastolic now tombstones the rest of its reading in
the same transaction with the same deletedAt, each row with its own
syncVersion bump so paired clients see every tombstone. The restore route
brings back the rows deleted together with a named half (identical
deletedAt), so the undo, which names only the row that was tapped, puts
the whole reading back, from the web list and from any client that calls
it the same way. A pulse is still deleted on its own, and a row deleted at
another moment stays deleted. The response shapes do not change; the
OpenAPI descriptions say what the counts now include.
2026-10-29 08:56:19 +01:00
Marc-André Bombeck 1d8e2e0ee3 fix(rollups): fold the full backfill a year at a time under its own budget
On an account with tens of millions of readings the boot full backfill
never finished. It ran four aggregates over the whole five-year window,
each held to the connection's 60 s statement limit, so every attempt
was cancelled with 57014, the reading extremes added by 0417 stayed
NULL, and the readers that need them fell back to the raw table and
timed out as well (#1298).

The full backfill now folds the window one UTC calendar year per pass,
newest first, and every statement runs under its own five-minute limit,
the shape the pulse day-mean rebuild already uses. Calendar-year cuts
write exactly the buckets one pass writes: day, month and year buckets
fall inside one window and a week across New Year is folded whole by
both. Each window commits on its own, so a run that reaches its job
budget hands the remaining years to a follow-up job instead of failing
and starting over on the retry.

The boot discovery read every live reading in the window to find
uncovered days, the same scan that kept the fold from finishing. It
now walks the days per account and type off the live index, so its
cost follows the number of days with readings, and runs under the same
limit. The full backfill code moves to its own module.
2026-10-29 08:52:36 +01:00
Marc-André Bombeck d88313e929 fix(labs): move document lab values filed under their confirmation day
Before the confirm step dated an undated value by its document, a lab
value confirmed from a report that prints its collection date only in the
header was stored with takenAt set to the moment of confirmation. The fix
covers new confirms; the values already filed that way stay on the wrong
day until something moves them.

A one-shot maintenance job, sent at boot only while there is something to
repair, moves exactly those values to the document's report date (else its
filing date) at noon UTC, the same rule the confirm step now uses. A value
qualifies only when the chain is certain: written by the document confirm
path, named by exactly one approved observation fact, takenAt still within
five seconds of its own createdAt, no calendar date of its own in the
decrypted fact, and a document day that differs from the day the value is
filed under in the owner's zone. A value re-dated since, one really taken
on its confirmation day, or one whose document has no date is left alone.
A moved value no longer sits at its creation moment, so the pass converges
and a second run changes nothing.

The job definition lives in a new registry file beside maintenance.ts so
that file stays under the size ratchet.
2026-10-29 08:49:40 +01:00
Marc-André Bombeck cb80296a2a Merge pull request #1293 from haritowa/documents-fact-date-from-document 2026-10-29 08:37:56 +01:00
Marc-André Bombeck 35a0ceb12c Merge pull request #1294 from MBombeck/fix/throttle-test-clock
fix(auth): read a remembered browser's expiry on the throttle's clock
2026-10-26 15:36:37 +01:00
Marc-André Bombeck b180d09fae fix(auth): read a remembered browser's expiry on the throttle's clock
knownSignInSource compared a trusted device's expiry with new Date()
while the throttle around it times its waits with Date.now(). The test
mocks Date.now() only, so it set the device to expire 30 days after its
fixed clock, which the real date passed on 2026-10-26 at noon UTC; from
then on the remembered browser read as expired and the test failed on
every run. One clock decides both now, and the test's expiry is fixed
against it. A further case pins that an expired device gets no
exemption.
2026-10-26 15:08:06 +01:00
Marc-André Bombeck 460a4fa8f9 Merge pull request #1291 from MBombeck/release/v1.46.1
Release v1.46.1
2026-10-26 08:28:30 +01:00
Marc-André Bombeck 444ff66086 refactor(charts): decide in the overlays hook where a chart's labels go
The narrow-chart rule lived in HealthChart's body and grew it past its
size baseline. The overlays hook, which already sizes the room beside
the plot, now takes the chart's width, keeps no room on a narrow chart
and marks each target zone whose label sits inside it; the x axis
helper works out its own tick layout. The tiered series unit test mocks
the lookup for readings older than the rollups and covers the head the
read puts in front of them.
2026-10-26 07:59:58 +01:00
Marc-André Bombeck f4cb15d823 chore(release): v1.46.1 2026-10-26 07:47:25 +01:00
Marc-André Bombeck 13860422cf fix(series): show a record's history older than the rollups on the All range
A range longer than a year reads its series from the week or month
rollups, which are kept for the last five years. When the rollups held
any rows the read returned them alone, so on a record that goes back
further the All range started where the rollups start: a weight history
from 2015 charted from late 2021. The readings older than the first
rollup day are folded live at the tier's grain and lead the series; the
head ends where the first tier bucket begins, so no month is counted
twice. One indexed lookup when there are none.
2026-10-26 07:46:50 +01:00
Marc-André Bombeck 467d5dcb48 fix(charts): give the plot its width back on a phone
1.46 moved the average's and the target zones' labels beside the plot,
in a right margin sized to the longest label. On a phone that margin
took about a third of the plot's width, and the legend, wrapping into
the narrower space, took more of its height: a pulse chart drew at
160 x 66 px instead of 232 x 108. Below 520 px the labels sit inside
the plot again, the average's at the top left as before and a zone's
at the top right of its band, and the plot keeps no room beside it.

The x ticks assumed an 80 px date label. A 90-day chart's month starts
sit about 78 px apart on a phone, so the month grid never fit and the
axis showed one date. The label width now comes from a date in the
reader's format (10/01/2026 draws at 58 px). A yearly grid is labelled
with the year alone, narrow enough for one tick a year, and with the
year it marks when its point falls in the December before.

The y axis shows the decimals its tick step needs: 57, 58, 59 bpm
instead of 57.0, 58.0, 59.0.
2026-10-26 07:42:27 +01:00
dependabot[bot] 0497d60a89 build(deps): bump docker/login-action from 4.6.0 to 4.7.0 (#1289)
Bumps [docker/login-action](https://github.com/docker/login-action) from 4.6.0 to 4.7.0.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/dbcb813823bdd20940b903addbd779551569679f...81c6f93d4bc991e510b342a00b419b3bb4efc5c6)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-version: 4.7.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-26 01:32:51 +00:00
haritowa 4ca7573f90 test(documents): pin an undated lab value's date through the confirm route
Against a real Postgres and the real confirm route: an undated value takes
the report date over the filing date, a value's own date is kept, an
impossible own date counts as none, and a document without a report date
dates the value by its filing date.
2026-10-26 01:08:43 +00:00
haritowa 58141cb48b fix(documents): date an undated lab value by its document
A lab report usually prints the collection date once, in its header. The
extraction puts that date on the document (reportDate) and leaves each
value's own date empty, and the confirm step filed every such value under
the moment it was confirmed, so an old report showed up as today's
results.

The confirm route now reads the document's report date and filing date
with the document it already loads, and an undated value takes the report
date, else the filing date, else now: the order the day view places a
document by.

A stated date, a lab value's or a condition's onset, now counts only when
it names a real calendar day. 2021-02-30 used to be stored as 2 March, and
2021-13-01 became an invalid date that failed the whole confirm.
2026-10-26 01:08:43 +00:00
Marc-André Bombeck eceb81ff16 Merge pull request #1283 from MBombeck/release/v1.46.0
Release v1.46.0
2026-10-26 02:00:56 +01:00
Marc-André Bombeck b7b34c95d8 test(backup): attach stdin to the dump forwarder only when feeding a file
The forwarder ran every tool as docker exec -i with its own standard
input piped through, also for pg_dump, which reads none. With -i and an
input already at its end the docker client may close the stream before
all output has arrived; one CI run saw the second of three nightly dumps
fail where every local run and the two runs before passed. Attach stdin
only for a file fed to pg_restore, and put the run's error into the
assertion so a failure says why.
2026-10-26 01:26:54 +01:00
Marc-André Bombeck 085864e63a fix(medications): classify a plan's cadence the same for every range
realisedFamily probed for the closest two slots up to range.to only, so
the answer moved with the range. A Saturday + Sunday plan started the
weekend before the autumn DST change, read around its Saturday dose: the
±24 h range ends at 06:00 UTC on the Sunday, whose 08:00 slot is at 07:00
UTC after the change. Cut off, the closest slots were six days apart
(weekly); the wide re-mint saw the 25 h gap (daily). The two passes
disagreed, no band survived, and the dose was filed as unscheduled, so a
second backdated dose within a minute came back as the first one (200).
Probe the same reach on both sides of the range's middle.
2026-10-26 00:47:24 +01:00
Marc-André Bombeck 5765059737 fix(medications): end today's intake list at the next local midnight
The list ended 24 hours after local midnight. On the 25-hour day of an
autumn DST change that left the last hour out, so a dose due then could
not be offered from it; on the 23-hour spring day it reached into the
next day. Take the day's bounds from getUserTodayBounds, as the dashboard
tile does. Anchor the health-status test's history to local noon: now
minus whole days is still today on the long day.
2026-10-26 00:05:21 +01:00
Marc-André Bombeck 9501d540d0 fix(backup): create a directory one level at a time
Node's recursive mkdir retries ENOENT/EEXIST forever below /proc on Linux,
inside one native call, so neither a timer nor a test timeout runs again.
A DB_BACKUP_DIR or GEOLITE2_DIR pointing there froze the process; the
database-backup integration file, which uses such a path for its
unwritable case, never finished on a Linux runner. Walk from the nearest
existing parent with plain mkdir instead.
2026-10-25 23:27:12 +01:00
Marc-André Bombeck 65414be936 test(ci): rebalance the integration shards on this release's timings
The new backup, dump and deletion files were unknown to the duration
sequencer and gathered in one shard, which ran past its job timeout
while the other three finished in under fifteen minutes.
2026-10-25 22:08:22 +01:00
Marc-André Bombeck fa9218053c chore(release): carry the service worker's fallback version to 1.46.0 2026-10-25 21:57:38 +01:00
Marc-André Bombeck f6c62f6f39 chore(release): v1.46.0 2026-10-25 21:10:00 +01:00
Marc-André Bombeck c4b300ad78 fix(account): stop calling a delete with a grace window permanent
With the deletion grace window on, the confirm dialog still asked to
delete the account permanently and its button said so, while the body
promised fourteen days and an admin undo. The title and the button now
match the window, and the success message that names the erase date stays
long enough to read before the sign-in page.
2026-10-25 20:48:30 +01:00
Marc-André Bombeck 789beff176 Merge remote-tracking branch 'origin/release/v1.46.0' into fix/v146-e2e-locale 2026-10-25 20:33:35 +01:00
Marc-André Bombeck 9b1e27f200 fix(i18n): keep the backups page explainer on one line
The new account-copy explainer under the Backups heading ran to 99-113
characters in German, Spanish, French, Italian, Polish and Portuguese and
wrapped to a second line at desktop width, so that page's header sat
20 px taller than every other settings and admin page. The German run of
the settings/admin header measurement failed on it. The Korean text was
two sentences in a one-sentence slot.

Each is now one sentence that fits the 1280 px column; English and Dutch
already did and stay as they are.
2026-10-25 20:16:48 +01:00
Marc-André Bombeck 78de978dcd fix(recovery): check a pasted token key against its slow fingerprint
The kit's check compared keyed SHA-256 digests of the pasted value and the
configured key, which code scanning still reads as a fast hash of a
password. It now compares the scrypt fingerprints in constant time.
2026-10-25 19:58:40 +01:00
Marc-André Bombeck 3939c9c0a5 Merge main (the dependabot updates the release already carries) 2026-10-25 19:35:04 +01:00
Marc-André Bombeck ae0b8d604a fix(recovery): fingerprint a chosen secret with scrypt, not a fast hash
API_TOKEN_HMAC_KEY can be a phrase, and its fingerprint is shown on the
admin page and written into dump manifests in the bucket; a fast hash
there lets a guessed phrase be checked offline. Text secrets now use a
scrypt fingerprint; the random encryption keys keep theirs. The kit's
check of a pasted token key compares keyed digests that never leave the
process.
2026-10-25 19:09:27 +01:00
Marc-André Bombeck 135d5baf28 fix(medications): return a medication's schedules in one order everywhere
The list reads ordered schedules by their window and id, but the detail,
create, update, compliance, dose-history, GLP-1 and efficacy reads took
them in whatever order Postgres found them, so two schedules created in
the same millisecond could swap places between two answers. Every read
now uses MEDICATION_SCHEDULE_ORDER, and the guard checks every
schedules include, not only the list reads.
2026-10-25 19:09:27 +01:00
Marc-André Bombeck a42b29a160 perf(ai): read the deletion lock with the usage level, not on its own
The capability gate's check for an account pending deletion added a
point read of users to every request that resolves AI capabilities, one
statement over the budget on /api/auth/me, the dashboard snapshot and the
batch ingest. The usage-level read already loads the record's row once
per request; it now carries the deletion lock too.
2026-10-25 18:54:33 +01:00
Marc-André Bombeck 52a80957f9 test(jobs): record the erasure and readiness queues in the registration golden 2026-10-25 18:45:42 +01:00
Marc-André Bombeck bcb31c80db Merge the release with the security review fixes into the data-safety review fixes 2026-10-25 18:36:54 +01:00
Marc-André Bombeck 722c6d1db5 Merge remote-tracking branch 'origin/release/v1.46.0' into fix/v146-sec-review 2026-10-25 18:19:39 +01:00
Marc-André Bombeck b240d8e829 docs(api): regenerate the OpenAPI document for account.deletion.no_guardian 2026-10-25 18:08:38 +01:00
Marc-André Bombeck d2b8da7839 refactor: keep the size, cast and error-code guards where they were
The fixes above grew six files over 800 lines and three long functions, added
casts in tests and one error code the catalogue did not list. The claimable
profile check moves into its own module, the guardian count is reused by the
revoke path, the lifecycle fallback lives beside the lifecycle rules, the
key-needs list is its own component, the coach sweep wraps its filters with
ownedByActiveAccount, comments are tightened, and the test fixtures are typed
instead of cast. The enumeration guard reads a write keyed by one handed id
(id: runId, id: job.data.jobId) as scoped, so those need no marker.
account.deletion.no_guardian joins the catalogue; two baselines go down.
2026-10-25 18:06:32 +01:00
Marc-André Bombeck f63d5d5819 fix(ai-runs): do not count time in the document reader against a run's deadline
A background AI run's deadline is armed in model time plus a fixed margin.
Time spent waiting in the document reader's queue, and the read itself, came
out of that margin, so a person's lab scan or document read could be timed
out by the reaper because other work held the reader. When the reader grants
the slot it now moves the run's deadline by the wait plus the most the read
may take, and gives back the unused part when the read ends, never past the
job's own expiry and only while the run is running. The interactive queue
wait stays within the margin, which covers the run until the slot is
granted.
2026-10-25 18:02:45 +01:00
Marc-André Bombeck 42940c3a98 fix(jobs): the encryption backfill still runs for an account pending deletion
The per-account free-text encryption backfill seals what is already stored
and changes nothing the record holds, which is why its module is exempt from
the frozen-account rule; the new pending-deletion gate dropped its per-user
jobs anyway. A queue can now say it acts for pending accounts
(actsForPendingAccounts), and this one does.

Also types the ingest route test's token fixture to the row it now reads,
and drops an unused variable from the deletion grace test.
2026-10-25 17:51:13 +01:00
Marc-André Bombeck 698312863d test(labs): check the labs gate per read, and only the labs module's
The guard asked whether a file that reads the lab table mentions the gate
anywhere, so one ungated read in a file with a gated one passed, and
whenModuleOn for any module counted as the labs gate. It now walks the syntax
tree: each read must sit in a function that asks the labs module, or in a
private helper whose every caller in the file does, or be listed by file and
function with its reason. whenModuleOn counts only with "labs".
2026-10-25 17:44:31 +01:00
Marc-André Bombeck 70df2009d9 fix(deploy-webhook): keep DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP's meaning, add a signature-only flag
DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP had been changed to mean signed requests
only, so an operator who set it for a sender that adds a timestamp but cannot
sign would have had every deploy notification refused after the upgrade. It
means the secret header plus a fresh timestamp again. Signature-only, the
setting that stops a replay, is DEPLOY_WEBHOOK_REQUIRE_SIGNATURE, on the
compose whitelist, in the env manifest and examples, and in the deploy
guide.
2026-10-25 17:44:31 +01:00
Marc-André Bombeck d87718407e fix(http): let operators name their proxy, and survive a bad TRUST_PROXY_HOPS
Forwarding headers were believed from any connection on a private address.
Behind Docker Desktop, OrbStack and docker-proxy every connection to a
published port arrives from the gateway, a private address, so a caller
reaching the port directly could again choose the client address that rate
limits, the audit log and sign-in notices record. TRUSTED_PROXY_CIDRS
(comma-separated CIDRs) now names the proxies: when set, only those peers may
report a client address. The private-range default stays when it is unset,
the boot log states the rule in force in one line, and the reverse-proxy
guide says what to set for a proxy on the same host, on a Docker network, or
on a public address (which, unlisted, puts all clients in one bucket).

An invalid TRUST_PROXY_HOPS threw on every request that resolved the client
address, so every route answered 500, the health probe included. It now
resolves no address, warns once, and is named at boot.
2026-10-25 17:44:31 +01:00
Marc-André Bombeck 0b830e6b02 fix(http): exempt only a Bearer request without a session cookie from the cross-site check
The cross-site write check let through any request that carried an
Authorization header, while the caller is resolved from the session cookie
before any header. A browser attaches Basic or Negotiate credentials on its
own once a proxy or host asked for them, so a cookie-authenticated write from
a sibling subdomain could pass. Only Authorization: Bearer is exempt now, and
only when no session cookie rides along; a request carrying both is checked
as the cookie request it resolves to.

The share-link passphrase POST under /api/c/ is exempt by path: its
credential is the token in the path and the passphrase in the body, and from
the shared view's no-referrer page a browser without Sec-Fetch-Site sends
Origin: null, which was refused.
2026-10-25 17:44:31 +01:00
Marc-André Bombeck 5a875d2840 fix(auth): issue a Secure session cookie under the __Host- prefix
A cookie named healthlog_session can be set by any host under the same
registrable domain, so a page on a sibling subdomain could plant a session of
its choosing in a visitor's browser. Wherever the session cookie is Secure it
is now issued as __Host-healthlog_session, which a browser accepts only from
this exact host. Plain-HTTP installs, which cannot hold a __Host- cookie,
keep the old name.

Nobody is signed out: the old name is still read after the new one, a
session found under it is re-issued under the new name and the old cookie
expired, and a browser holding the new cookie is never answered from the old
one. Every reader and writer goes through one module, which the proxy, the
native-caller check, the native handoff and the OIDC callback use; the
session-surface guard follows the new reader.
2026-10-25 17:44:31 +01:00
Marc-André Bombeck d3257a1b6b fix(documents): tell people when the document reader is busy, and attribute its work
A read a person started now runs as interactive work of their account, and
the document jobs as background work of theirs, so the reader's queue can
order and cap them. When the reader is busy or stopped before it answered,
a lab scan, label read or document read fails with aiRuns.readerBusy (503,
nothing read or charged) instead of a misleading unreadable-file error, and
the index and summary jobs throw so the queue runs them again rather than
recording an outcome about the document.
2026-10-25 17:38:25 +01:00
Marc-André Bombeck 4d970561fa fix(documents): decode thumbnails in the reader child, share it fairly, judge exits by cause
Three defects in the bounded document reader.

Image uploads were decoded into their thumbnails in the server process. A
header check is all that stood between a decoder and an allocation the file
chooses, and the GIF check showed how that fails. The image is now decoded in
the same child process PDFs run in, under its memory ceiling and time limit;
the header check stays in front of it.

The reader's one slot went to whoever asked first, and a waiting item waited
without end, so a few heavy PDFs from one account held everyone's lab scans
behind them. The slot now goes to a read a person is waiting on before
background work, to accounts in turn, to a few waiting items per account, and
an item that waits past its lane's deadline is answered busy, which callers
treat as retryable.

Any child that ended without an answer counted as a document past its limits,
so a kernel kill caused by the container's overall memory marked a good PDF
as permanently failed. Only a bound the reader measured (the sampled memory,
the time limit, the child's heap cap) is final now; any other end is run once
more and then answered interrupted, which the thumbnail job retries within its
ordinary attempts.
2026-10-25 17:38:25 +01:00
Marc-André Bombeck e6c709876d fix(auth): the ingest and idempotency Bearer resolvers refuse a pending account
Both resolve a Bearer token outside resolveBearerToken and checked only
revoked and expiry. The deletion revokes every token in its own transaction,
so only a token issued in that same instant could get through, but the rule
should hold everywhere a token is resolved: both now refuse an account
pending deletion.
2026-10-25 17:33:16 +01:00
Marc-André Bombeck 69c5c65376 fix(data-wipe): say what undoing a wipe does not bring back
A restore of the copy a data wipe took replaces the record with that copy,
so anything added after the wipe is gone, and the wipe ended integration
connections, sharing in both directions, Guardian roles and share links,
none of which are in a copy. Neither the person's confirmation nor the
admin's restore dialog said so. Both do now, in every locale.
2026-10-25 17:32:33 +01:00
Marc-André Bombeck 2b79c01952 fix(offhost): judge the bucket's rules for database/ against this instance's folder
The dumps now live under database/<host of APP_URL>/, so the rule check reads
a sample key in that folder: a rule scoped to this instance's folder counts,
one scoped to another instance's does not.
2026-10-25 17:30:58 +01:00
Marc-André Bombeck a7691223fd fix(db-backup): keep instances apart in a shared bucket, and no decrypted dump behind
Off-host dumps were named by their time alone. Two instances sharing a bucket
(nothing forbids it, and it was safe for the account objects) overwrote each
other's dump at the same second of the same default schedule, and
latest --from-bucket restored whichever instance's dump was newest. Each
instance now uploads under database/<host of APP_URL>/, the object key (and so
the encryption's associated data) carries it, and the restore takes only this
instance's dumps: it refuses another's and names the instances it found,
unless --instance=<host> or the dump's full key says which one on purpose.

A restore killed outright (out of memory, docker kill) left its .restore- or
.download- directory, a whole database decrypted, in the backups directory,
where nothing removed it. A restore now removes them before it starts and on
SIGINT and SIGTERM, and the nightly retention sweeps any older than twelve
hours.
2026-10-25 17:30:29 +01:00
Marc-André Bombeck 6051488b65 fix(restore-drill): a drill that read nothing back is no pass
A drill that stopped on its time budget before the first object recorded
ok with zero accounts checked, and the readiness tile said passed. Such a
run now records its time and counts and no verdict, so the last pass or
failure stands, and readiness calls it attention (nothing_checked) in every
locale.

The upsert that keeps a pass and a failure apart was stubbed in the drill's
tests and tested nowhere; it now has an integration test read back through
the readiness rule. The drill test that claimed to keep a failed verdict
when keeping it fails is named for what it checks.
2026-10-25 17:26:40 +01:00
Marc-André Bombeck a857fd0264 fix(backup-restore): a restored pre-wipe copy withdraws only its own wipe's purge
Restoring the copy a data wipe took withdrew every held data_wiped purge of
the account. With two wipes in one window, restoring the first copy also
withdrew the second wipe's purge, and the off-host copies of what the person
entered between the wipes, and wiped, stayed until the bucket's rule removed
them. Only the request written after this copy and before the next one goes
now. The withdrawal had no test; it has one against Postgres.
2026-10-25 17:26:39 +01:00
Marc-André Bombeck 2bb030b656 fix(offhost): keep the bucket's last lifecycle answer through a failed read
A lifecycle read that timed out once was cached as unknown for ten minutes,
which kept every key an off-host object ever needed in the recovery kit: the
kit turned due, the admins were pushed attention, and the next good read
pushed recovered. A failed read now stands on the last answer the bucket gave,
for up to a day.
2026-10-25 17:23:51 +01:00
dependabot[bot] 68a363bd48 build(deps): bump the production-weekly group across 1 directory with 8 updates (#1286)
Bumps the production-weekly group with 8 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [@aws-sdk/client-s3](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-s3) | `3.1152.0` | `3.1155.0` |
| [@aws-sdk/lib-storage](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/lib/lib-storage) | `3.1152.0` | `3.1155.0` |
| [@napi-rs/canvas](https://github.com/Brooooooklyn/canvas) | `1.0.11` | `1.0.13` |
| [@tanstack/react-query](https://github.com/TanStack/query/tree/HEAD/packages/react-query) | `5.104.4` | `5.104.5` |
| [@tanstack/react-virtual](https://github.com/TanStack/virtual/tree/HEAD/packages/react-virtual) | `3.14.21` | `3.14.22` |
| [jose](https://github.com/panva/jose) | `6.2.12` | `6.2.13` |
| [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react) | `1.59.0` | `1.61.0` |
| [nodemailer](https://github.com/nodemailer/nodemailer) | `10.1.7` | `10.2.1` |



Updates `@aws-sdk/client-s3` from 3.1152.0 to 3.1155.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-s3/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1155.0/clients/client-s3)

Updates `@aws-sdk/lib-storage` from 3.1152.0 to 3.1155.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/lib/lib-storage/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1155.0/lib/lib-storage)

Updates `@napi-rs/canvas` from 1.0.11 to 1.0.13
- [Release notes](https://github.com/Brooooooklyn/canvas/releases)
- [Changelog](https://github.com/Brooooooklyn/canvas/blob/main/CHANGELOG.md)
- [Commits](https://github.com/Brooooooklyn/canvas/compare/v1.0.11...v1.0.13)

Updates `@tanstack/react-query` from 5.104.4 to 5.104.5
- [Release notes](https://github.com/TanStack/query/releases)
- [Changelog](https://github.com/TanStack/query/blob/main/packages/react-query/CHANGELOG.md)
- [Commits](https://github.com/TanStack/query/commits/@tanstack/react-query@5.104.5/packages/react-query)

Updates `@tanstack/react-virtual` from 3.14.21 to 3.14.22
- [Release notes](https://github.com/TanStack/virtual/releases)
- [Changelog](https://github.com/TanStack/virtual/blob/main/packages/react-virtual/CHANGELOG.md)
- [Commits](https://github.com/TanStack/virtual/commits/@tanstack/react-virtual@3.14.22/packages/react-virtual)

Updates `jose` from 6.2.12 to 6.2.13
- [Release notes](https://github.com/panva/jose/releases)
- [Changelog](https://github.com/panva/jose/blob/main/CHANGELOG.md)
- [Commits](https://github.com/panva/jose/compare/v6.2.12...v6.2.13)

Updates `lucide-react` from 1.59.0 to 1.61.0
- [Release notes](https://github.com/lucide-icons/lucide/releases)
- [Commits](https://github.com/lucide-icons/lucide/commits/1.61.0/packages/lucide-react)

Updates `nodemailer` from 10.1.7 to 10.2.1
- [Release notes](https://github.com/nodemailer/nodemailer/releases)
- [Changelog](https://github.com/nodemailer/nodemailer/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodemailer/nodemailer/compare/v10.1.7...v10.2.1)

---
updated-dependencies:
- dependency-name: "@aws-sdk/client-s3"
  dependency-version: 3.1155.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production-weekly
- dependency-name: "@aws-sdk/lib-storage"
  dependency-version: 3.1155.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production-weekly
- dependency-name: "@napi-rs/canvas"
  dependency-version: 1.0.13
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production-weekly
- dependency-name: "@tanstack/react-query"
  dependency-version: 5.104.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production-weekly
- dependency-name: "@tanstack/react-virtual"
  dependency-version: 3.14.22
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production-weekly
- dependency-name: jose
  dependency-version: 6.2.13
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production-weekly
- dependency-name: lucide-react
  dependency-version: 1.61.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production-weekly
- dependency-name: nodemailer
  dependency-version: 10.2.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production-weekly
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-25 16:23:27 +00:00
Marc-André Bombeck db4d5928fd docs(ops): name what the v1.46 migrations drop and what a rollback to v1.45 does
The notes said the other migrations only add columns and one concurrent
index. 0487 drops four app_settings columns the previous image reads on
nearly every request, and 0488 builds its index on users without
CONCURRENTLY. The rollback step now names the columns to hand back, and the
notes say what v1.45 does with accounts pending deletion and held wipe
purges, and how to settle them before going back.
2026-10-25 17:23:03 +01:00
Marc-André Bombeck 756885eec0 fix(db-backup): take the pre-migration dump when pending migrations cannot be counted
Any error counting pending migrations (a connect or statement timeout, a
database still starting up) exited the step with "no dump before migrating",
and the migration that ran a second later went ahead without its way back.
An unknown count now takes the dump; a database that cannot be reached at
all then fails the dump, and the boot stops naming the switch that skips it.
2026-10-25 17:22:33 +01:00
Marc-André Bombeck 6f83e7e685 fix(db-backup): age pre-migration dumps out with the nightly ones
Pre-migration dumps were kept by count alone, so an install that updates
rarely kept its last two, and every account deleted since, indefinitely,
while the docs and the backups card stated the nightly retention as the
retention of deleted accounts. A pre-migration dump older than the oldest
nightly dump kept now goes, except the newest one, the way back from the
latest update, which stays until the next update. The card, the disaster
recovery guide and its variable table say so.
2026-10-25 17:21:52 +01:00
Marc-André Bombeck 26a9efdf52 fix(recovery): the database dumps count in the keys the backups still need
The recovery kit, "Keys the backups still need" and safeToDropRetiredKeys
read only the stored copies and the off-host account objects. After a
rotation the page called the old key safe to drop while every dump taken
before it, the pre-migration dumps that are the way back from an update
among them, could only be restored with that key: db-restore refuses a dump
whose active key is missing.

A dump now counts for the key its rows were written under, the manifest's
active key: a dump on this server while it is kept (read from the manifests),
an off-host one until the bucket's rule for database/ expires it, or
indefinitely when that rule cannot be read. The nightly run records the key
on its ledger row (migration 0489, database_backup_runs.active_key_id). The
encryption view and the kit list the dumps per key, in every locale, and the
rotation and backup runbooks say so.
2026-10-25 17:20:16 +01:00
dependabot[bot] fe5ab2459a build(deps): bump the codeql-action group with 3 updates (#1285)
Bumps the codeql-action group with 3 updates: [github/codeql-action/init](https://github.com/github/codeql-action), [github/codeql-action/analyze](https://github.com/github/codeql-action) and [github/codeql-action/upload-sarif](https://github.com/github/codeql-action).


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

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

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

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-25 16:15:53 +00:00
Marc-André Bombeck 40c310f0ad fix(account-deletion): hold frozen accounts out of the work the guard missed
The enumeration guard read any query that named userId as scoped, including
an orderBy tiebreak, so the nightly recovery, strain and stress score jobs
grouped every account's measurements, wrote scores into frozen records and
spent cap slots on them. It also looked only at findMany and groupBy under
lib/jobs.

The guard now reads a scope from a query's filter only (orderBy, by,
distinct, select and include are dropped first), covers count, aggregate,
updateMany, deleteMany and executeRaw, and walks the boot discoveries and
sweeps that live beside their domain: the rollup and mood rollup discovery,
the Fitbit jobs, the cumulative record rederivation, the intake slot dedup,
the inventory expiry and the climb backfill. What it then found is filtered
(the score cohorts through excludePendingOwners, which filters before the
cap; Fitbit sync, backfill and sleep repair; coach proposal lapsing and
reminder nagging; expired coach reminders; snooze clearing; inventory
expiry) or says why it needn't be (retention and expiry sweeps over rows,
writes scoped by id).

Jobs queued before a deletion ran anyway, since handlers act on the userId
they carry without asking again. The binding of every queue now completes a
job whose payload names an account pending deletion without loading its
handler (pending-deletion-gate). The silent medication intake push, which
does not go through the dispatcher, refuses a pending account itself.
2026-10-25 17:13:09 +01:00
Marc-André Bombeck 8783182b1e build(deps): keep @aws-sdk/client-s3 on the same release as lib-storage 2026-10-25 17:06:17 +01:00
Marc-André Bombeck 328268d894 build(deps): bump the production-weekly group and codeql-action
@aws-sdk/client-s3 and lib-storage 3.1155.0, @napi-rs/canvas 1.0.13,
@tanstack/react-virtual 3.14.22, jose 6.2.13, lucide-react 1.61.0,
nodemailer 10.2.1, and github/codeql-action 4.39.1.
2026-10-25 17:05:54 +01:00
Marc-André Bombeck 763cb48273 fix(recovery): readiness leaves accounts pending deletion out
The weekly and off-host passes skip an account pending deletion on purpose,
and the readiness read counted it anyway: within days of any deletion the
weekly copy read failing and the off-host copies stale, every admin was
pushed a problem and then a recovery after the purge, and a real failure
hid behind a signal already red. The read now filters with ACTIVE_ACCOUNT,
and the enumeration guard covers the file.
2026-10-25 17:03:10 +01:00
Marc-André Bombeck be78517500 fix(managed-profiles): pending deletion closes handovers and the Guardian count
A handover link stayed claimable after its profile was deleted: the claim
gave the profile credentials that every sign-in then refused, and the purge
erased the account someone had just taken over. Locking an account for
deletion now revokes the open links for that profile and the links that
account minted, and creating, previewing and claiming a link refuse a profile
pending deletion, the claim under the record lock.

The last-Guardian rule counted Guardians pending deletion and profiles
pending deletion. A Guardian who had deleted their only profile could not
delete their own account; two Guardians sharing a profile could both leave,
and the second one's erasure was then refused on every run. The rule now
counts only Guardians whose account is not pending deletion, ignores profiles
pending deletion, and is decided under the record lock for account deletion,
data wipe, revoking and renouncing a Guardian. An admin's undo of a managed
profile with no such Guardian left is refused (409
account.deletion.no_guardian).
2026-10-25 17:02:32 +01:00
Marc-André Bombeck b37e4f5df7 fix(admin): the last-admin rule counts only admins who can sign in
Demoting an admin counted every admin, pending deletion included, so after
one admin deleted their account the other could step down and leave the
instance with no admin who can sign in or undo the deletion. Both the
demotion and an admin's own deletion now count the other admins not pending
deletion, inside the transaction that makes the change, under one roster
lock, so two at the same moment cannot each count the other.
2026-10-25 17:02:32 +01:00
Marc-André Bombeck 4137aa546e fix(account-deletion): one failing erasure no longer holds up the others
The hourly purge rethrew any error that was not the Guardian refusal, so the
oldest due account, failing on every run, stopped every later erasure and the
sweep of expired pre-wipe copies for good.

Each account is now its own try: a failure is counted, reported through the
worker error path with its first error, and told to the admins once per
request (user.account.erasure_failed), and the run goes on. The pre-wipe
sweep and the off-host kick run in a finally. Every account the run starts is
marked in backup_pass_attempts under the purge's queue name and the mark
closes only on erasure or when the account is no longer due, so an account
that failed or was refused goes behind every other due account next time.
The run stops starting accounts once its job budget is spent, since fifty
erasures of up to two minutes each did not fit the queue's expiry.
2026-10-25 16:56:57 +01:00
Marc-André Bombeck 933be858c5 fix(images): size a GIF by its largest frame, not only its logical screen
The header check before a thumbnail decode read only the GIF logical screen.
A decoder sizes its buffer from the frame's image descriptor, so a file with a
small screen and a huge frame passed the pixel cap and allocated the full
frame. Walk every block, read each image descriptor, size the image by the
largest of screen and frames, and refuse a file that ends inside a header or
descriptor, carries an unknown block, or has no frame.
2026-10-25 16:53:31 +01:00
Marc-André Bombeck 664b46d2c6 fix(db-backup): read the off-host leg of the dump on its own
A run that kept its dump on the volume and failed the upload is the newest
good dump and the newest failure at the same instant. Compared with ">", the
card showed no failure and readiness said fresh, night after night, while no
dump left the host.

The health read now carries the off-host leg separately: when the newest dump
reached the bucket, and the newest one that did not. One pure reading of it
(databaseDumpProblems) feeds both the backups card and the readiness model.
A configured bucket that missed the newest dump is attention (offhost_failed)
with the upload's error, and failing once the newest dump there is older than
36 hours (offhost_stale) or none ever arrived (offhost_never). The card shows
the refused upload and when the bucket last got a dump. A failure at the
instant of the newest dump for another reason, retention throwing, is now
shown too.
2026-10-25 16:53:24 +01:00
Marc-André Bombeck 0085915a56 Merge remote-tracking branch 'origin/release/v1.46.0' into fix/v146-move-safety 2026-10-25 16:49:06 +01:00
Marc-André Bombeck 4887ed8071 fix(db-backup): name the archive to pg_restore --list instead of streaming it
pg_restore reads the table of contents and exits; the read stream piped into
it stayed open on the dump. The long-lived worker checks one dump a night, so
every dump retention deleted stayed allocated until the worker restarted and
the backups volume filled. runTool no longer takes a stdin stream at all.
2026-10-25 16:47:26 +01:00
Marc-André Bombeck e8c96eea88 docs: say what deletion keeps and for how long, and drop stale claims
The reproductive-health page promised that deleting all data or the
account erases at once. Both wait for the grace window now, the nightly
dumps keep a record until they age out, and the old pgdata volume is a
full copy until it is removed; the page says so and how to make deletion
final. The README, the getting-started note on majors, the move guide and
the Unraid and Portainer templates catch up with Postgres 18, the backups
volume and the recovery kit. The demo seed's spread bound leaves room for
the seed's own one-in-150 spread.
2026-10-25 16:47:00 +01:00
Marc-André Bombeck 41f2f5ac6c fix(db): leave a Postgres 16 volume alone while the database runs on it
Going back to Postgres 16 after a move that stopped half-way left the
moving marker, and the next start emptied the live 16 database and
started a second server on its data directory. db-upgrade now waits for
the database service and does nothing unless it runs Postgres 18.

Using 16 again after a finished move and then updating started the app
on the move-day copy without a word. The marker now records the old
database's last checkpoint, and a later start refuses when it moved on;
the guide says how to move the current database instead. The disk check
leaves room for the first pre-migration dump, and the move check seeds
the old database with v1.45's own migrations.
2026-10-25 16:45:19 +01:00
Marc-André Bombeck 104bb35919 test(dr): read the no-volume start log from a file
grep -q stops at the first match, and with pipefail the SIGPIPE that then
ends docker logs read as a miss, so the drill failed on a line that was
there. The Dockerfile notes that the dump directory keeps nothing without
a mount.
2026-10-25 16:35:01 +01:00
Marc-André Bombeck 6b9b9a3f8d test(documents): type the index fixtures instead of casting them away
The resource-limit cases and two older mocks now pass typed values, so the
file carries fewer as-never casts than before; the outcome list's comment
is a line shorter to keep its file at its length. Both ratchet baselines
are written down, the source size one also for offhost-backup.ts, which
had shrunk without its number following.
2026-10-25 16:24:15 +01:00
Marc-André Bombeck 430468932c docs(ops): rollback list, how admins hear about backups, GlitchTip's place
The fourth rollback step had been joined onto the paragraph after the
list; it is a list item again, with the way back to the data after it.
The disaster recovery page now says how the server tells admins about
its copies: the recovery readiness card, the recovery kit and the hourly
Backup health notification. The support page put GlitchTip under Admin,
General; the card is under Admin, Integrations, as the deploy runbook
says. The pre-migration dump notes say that without the backups volume
no dump is taken, also where the image's own directory is writable.
2026-10-25 16:01:46 +01:00
Marc-André Bombeck c7040ccdaf docs(backup): a personal export is refused when it lists a document
The restore refuses a file that carries any document as metadata only
(findIncompleteDocument), whatever the account holds; the page said it
refused when the account held a document.
2026-10-25 16:00:49 +01:00
Marc-André Bombeck aebd7ea612 docs(deploy): describe the deploy webhook's signature and strict mode
The compose comment still called DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP a
requirement for a fresh timestamp header, which the route stopped
accepting in strict mode once requests could be signed, and the env
manifest named only the secret header and lacked the strict switch. Both
now say what the route checks: the secret header, which has no replay
protection, or X-Deploy-Webhook-Signature over the timestamp and body,
and that the strict setting takes signed requests only. The development
example says the same and points at the deploy runbook instead of a page
that no longer exists.
2026-10-25 16:00:35 +01:00
Marc-André Bombeck 877db9a5b7 docs(proxy): say TRUST_CF_CONNECTING_IP needs a Cloudflare-only origin
The flag makes the app believe CF-Connecting-IP from every connection,
before the peer check and whatever TRUST_PROXY_HOPS says, which is safe
only when nothing but Cloudflare can reach the origin. The reverse-proxy
page now says so with the ways to get there, the compose comment and both
example files carry the same condition, and the variable is in the env
manifest. The page's list of peers whose forwarding headers are read now
matches the code: loopback, 0.0.0.0/8, private, CGNAT, link-local,
unique-local and site-local, the reserved blocks and multicast. Those last
two stay trusted, since no internet host can connect from them; a test
pins every listed range and the Cloudflare precedence.
2026-10-25 16:00:35 +01:00
Marc-André Bombeck 8471e0edc9 fix(documents): say a PDF past the reader's limits is too large to read
A text read that crossed the isolated reader's memory or time bound came
back as a plain read error, which the index job records as decrypt-error,
so the document said its stored file could not be opened. The local
extractor now answers resource-limit for PdfResourceLimitError, the index
records it under that name, and the detail view says the PDF is too large
or complex to read here, in every locale. Like the thumbnail's refusal of
the same bytes it is final: nothing retries it on its own.
2026-10-25 16:00:35 +01:00
Marc-André Bombeck 66e92f36fe feat(recovery): judge the bucket's rule for database dumps in readiness
Only the backups page said when the bucket's lifecycle rules left
database/ out, so every dump, and every deleted account inside it, stayed
in the bucket forever without anyone being told. Readiness gains a
dump_retention signal that reads the same cached lifecycle probe: attention
when dumps go off-host and no rule covers database/, ok with the expiry
when one does, quiet when the bucket will not say, inactive without a
bucket or without off-host dumps. The hourly evaluation pushes it to the
admins like every other signal.
2026-10-25 16:00:35 +01:00
Marc-André Bombeck 351e7d1fe0 fix(backup): keep a dump locally only where it survives the container
The image creates DB_BACKUP_DIR owned by the app user, so an install that
runs it without the backups volume could write there, and every nightly and
pre-migration dump landed in the container's own layer. The next update
threw them away while the admin page, the boot line and the readiness card
called them kept on this server.

A dump directory now counts as kept only when it survives the container:
inside a container (Docker, Podman, Kubernetes, a container cgroup or an
overlay root) the directory must sit on a mount of its own that is not a
tmpfs, read from /proc/self/mountinfo with a device-number fallback;
outside one, a writable directory is a disk. Otherwise no dump is written
there: the nightly run uploads off-host only and records not_persistent,
the pre-migration dump is skipped with a warning, the boot line says the
directory is inside the container, the backups page stops promising local
dumps and ignores local-only ledger rows, and readiness fails with no
bucket and notes the bucket-only setup otherwise.

The Unraid template gains a path for the dumps, the Unraid and TrueNAS
pages say why it is needed, and the restore drill first runs the stack
without the volume and checks that nothing is kept or claimed.
2026-10-25 16:00:34 +01:00
Marc-André Bombeck fb4ab29aad Merge remote-tracking branch 'origin/release/v1.46.0' into feat/v146-grace 2026-10-25 15:28:23 +01:00
Marc-André Bombeck 1b39d93a8a docs(auth): say that credentials from before a deletion answer 401
The deletion revokes every session, refresh token and API token, so they
answer like any revoked credential; only new sign-ins get the 403
auth.account.deletion_pending, through the shared login response.
2026-10-25 15:21:10 +01:00
Marc-André Bombeck 9db5e755e6 Merge the release into the deletion grace work
The translation files merge key by key (no key changed on both sides), the
backups list carries both the database dump and the pre-wipe copy window,
and the backup-restore doc keeps the shorter rewrite plus the dump in the
bucket and a lifecycle rule that covers database/.
2026-10-25 15:18:46 +01:00
Marc-André Bombeck 8678a3edc4 test(recovery): keep the weekly-copy push test to its one signal
With the real dump health wired in, an empty test database also reports
a missing dump. The file follows the weekly copy alone; the dump signal
is judged in the readiness unit tests.
2026-10-25 15:16:14 +01:00
Marc-André Bombeck da9d941a24 Merge the release with the security fixes into the backup health work 2026-10-25 15:03:17 +01:00
Marc-André Bombeck 13e62bbb92 docs(coach): keep the labs block note to its old length 2026-10-25 14:50:05 +01:00
Marc-André Bombeck e88dcd2c53 Merge the release into the backup health work and read the real dump health
The readiness model now takes the database dump's state from
getDatabaseBackupHealth, and the dump test reaches the off-host key id
where it moved.
2026-10-25 14:40:31 +01:00
Marc-André Bombeck c25e9fa4e9 refactor: drop an unused test reset for the public-peer warning 2026-10-25 14:28:32 +01:00
Marc-André Bombeck d902125371 feat(account): tell admins about a refused erasure, and what undo leaves revoked
When the grace window ends for an account that has become the last
active guardian of a managed profile, the purge job refuses to erase it
and the account stays locked. Until now only the worker error log said
so. Every admin now gets one localized notification naming the account
and the ways out (another guardian, deleting the profile, or undoing
the deletion). An audit row on the account records that it was reported,
so the hourly retries stay quiet; a deletion undone and asked for again
is reported afresh.

The undo confirmation in the admin users list now says that sign-in
works again but API tokens, share links and trusted devices stay revoked
and have to be set up again (for a managed profile: its share links and
tokens).
2026-10-25 14:28:15 +01:00
Marc-André Bombeck 694befd635 fix(admin): say what actually ages a stored account copy out
The backups page stated "Retention: 90 days", from a constant whose
comment said it matched a backup-prune job. There is no such job: nothing
deletes a stored copy by age. The weekly copy is replaced in place, one
per account; the copy taken before "Delete all data" is deleted by the
account-deletion purge when the grace window ends; an uploaded copy stays
until its account is erased.

The list now carries preWipeCopyDays (the grace window, 0 when no such
copy is taken) instead of retentionDays, and the page says exactly the
three facts above, in every language.

Also drops a re-export of the JSON import row limit nothing imports.
2026-10-25 14:28:07 +01:00
Marc-André Bombeck 459b5baa9d fix(backup): read ECG traces a strip at a time
The shared backup reader kept documents without their stored bytes but
kept every ECG strip whole, trace included. A long watch history is
hundreds of strips of tens to hundreds of kilobytes each, so an upload,
preview or restore of such a copy held the whole history in memory.

The first read now keeps each strip without its trace (a short stand-in,
after the key it was written under is recorded and its form checked),
and forEachEcgTrace reads the traces again one strip at a time. The ECG
restore writes each strip from that second read, as the document restore
already did, and refuses a file whose two reads disagree on the count.

The upload memory test gains an ECG-heavy file: 150 strips, about 200 MB.
The old reader held 197 MB of it and failed the 48 MB bound; the new one
holds about 1 MB, also under a 524 MB heap.
2026-10-25 14:27:56 +01:00
Marc-André Bombeck 0a053d6884 feat(admin): name a switched-off dump and a newly listed kit key plainly
A switched-off scheduled dump is attention now, sent once and never
reminded, with how to turn it back on (DB_BACKUP_ENABLED). Dumps are on
by default, so off is a choice that leaves no copy able to rebuild the
host. An install that never wrote a dump and has nowhere to write one
stays quiet.

The out-of-date kit notice says what changed: the kit now also lists a
key the last confirmation did not cover, for example API_TOKEN_HMAC_KEY
on an instance upgraded from the one-key step, and that nothing broke.

The French kit and readiness strings use vous, the form the rest of the
French bundle uses.
2026-10-25 14:09:41 +01:00
Marc-André Bombeck e79ea08b13 Merge remote-tracking branch 'origin/release/v1.46.0' into fix/v146-security 2026-10-25 14:07:01 +01:00
Marc-André Bombeck 21aa791c3a Merge remote-tracking branch 'origin/release/v1.46.0' into fix/v146-security
# Conflicts:
#	src/lib/ai/coach/labs-snapshot.ts
#	src/lib/ai/coach/snapshot.ts
2026-10-25 14:05:23 +01:00
Marc-André Bombeck 2f19ca1f73 test(ratchet): write back the smaller sizes the fixes left
apiHandler's request wrapper fell under the 200-line mark, and the
reminder-check route and the Coach snapshot shrank by a line or two.
2026-10-25 13:52:04 +01:00
Marc-André Bombeck f7030feba7 docs(ops): say that the pre-upgrade volume keeps data erasure never reaches
The Postgres major-upgrade guide suggested removing the old pgdata volume
"when you are sure". That volume is a full copy of the database as it stood
before the move: account deletions, trash purges and key rotations after it
never reach it. The guide now says so and makes removing it part of the
move.
2026-10-25 13:52:04 +01:00
Marc-André Bombeck a20897b305 fix(monitoring): sign the GlitchTip relay in and strip Prisma call arguments
The browser error relay forwarded anything posted to it into the operator's
GlitchTip without a session, limited only per address, so anyone could
fill the incident list. Separately, a Prisma client error prints the
failed call into its message and stack, with the arguments object (a
reading's value, a note's text) when no source map is at hand, and the
server sent that to GlitchTip and the logs after a credential scrub that
does not look for it.

The relay now needs a signed-in session and is limited per account; errors
on pages before sign-in go unreported. The central redaction cuts a Prisma
call's printed arguments, keeping the call, a reason line that names a
field, and the stack frames, and the GlitchTip sender applies the
redaction to every event at the egress boundary, whichever caller built it.
2026-10-25 13:52:04 +01:00
Marc-André Bombeck f2a9b0fc0e fix(deploy-webhook): accept signed requests and stop calling a bare timestamp replay protection
The deploy-status webhook authenticated with a static secret in a header,
and the timestamp the docs called replay protection was covered by nothing:
whoever captures one request holds the secret and can send a fresh request
with any content. The strict setting, documented as closing the replay
window completely, only made that timestamp mandatory.

A sender can now sign instead: X-Deploy-Webhook-Signature is the
HMAC-SHA256 of the timestamp and the exact body under the shared secret,
with the timestamp within five minutes, and the secret never travels. The
strict setting accepts only signed requests. The secret header still works
by default for senders that cannot sign, and the docs describe its
timestamp as the staleness hint it is.
2026-10-25 13:52:04 +01:00
Marc-André Bombeck 807d483b89 fix(egress): check every redirect hop and close the reserved-range gaps
safeFetch with requirePublicHost vetted the first URL and pinned DNS
answers in the connector, but Node never looks up an IP literal, so a
followed redirect to a literal private or metadata address was dialled
unchecked. No caller follows redirects to a user-chosen host today; the
floor did not hold for the next one that would.

With requirePublicHost, redirects are now followed one hop at a time and
every location goes through isPublicUrl before it is dialled, at most five
hops. isPublicIp also refuses the benchmarking, protocol-assignment,
multicast, reserved and broadcast IPv4 ranges, site-local and multicast
IPv6 and the IPv4-translated spelling of a private address, and isPublicUrl
strips a trailing dot before the hostname denylist.
2026-10-25 13:52:04 +01:00
Marc-André Bombeck e021adde40 fix(notifications): limit quick answers per account, junk in a bucket of its own
The quick-answer route charged one per-address bucket before it read the
token. Where the client address cannot be read, every caller shares that
bucket, so two junk requests a minute switched off every person's
notification quick answers.

The token is an HMAC and is now checked first, which costs one hash and no
database read. A genuine token is limited per account; anything else is
limited per address in a separate bucket that genuine answers never draw
from.
2026-10-25 13:52:04 +01:00
Marc-André Bombeck cd96cb3e60 fix(proxy): keep API paths ending in a file extension behind the proxy
The proxy matcher left out every path ending in an image or text extension
so static files skip it. The exclusion was not anchored, so an API path
whose last segment the caller chooses (a custom tag name ending in .png)
skipped the proxy too, and with it the demo-mode write refusal, the
worker-only refusal and the security headers. Authentication was unaffected.

The extension exclusion now applies outside /api/ only.
2026-10-25 13:52:04 +01:00
Marc-André Bombeck 0039e112dd fix(mcp): fence medication and lab names in tool results
The MCP server instructions promise that the person's free text comes back
wrapped in USER_TEXT markers, but get_medication_schedule returned
medication names and doses as plain strings, and get_labs returned analyte,
panel and unit names that can come straight out of an uploaded document.
Text written to read like an instruction reached the connecting client's
model unmarked. The marker scrub also removed only the exact uppercase
spelling, so a lower-case or spaced marker survived inside fenced text.

Both tools now fence those fields, and the scrub removes a marker in any
case and with any spacing inside the brackets.
2026-10-25 13:52:04 +01:00
Marc-André Bombeck e3598985f5 fix(ingest): answer a scoped token the same for unknown and out-of-scope names
A token scoped to one medication got 404 for a name that matched none of
the owner's medications and 403 for one that matched outside its scope, so
it could confirm which medications exist by name. Its idempotency replay
also ran before the scope check and returned any of the owner's doses
stored under the key.

A token without the wildcard now gets the same 403 for both, the replay is
looked up only after the scope check, and a key already spent on another
medication answers 409 without that dose. The wildcard token keeps its 404.
2026-10-25 13:52:04 +01:00
Marc-André Bombeck 989381b3f6 fix(share): stop serving shared documents once the documents module is off
A share link gated its report on the doctorReport module but listed and
served its attached documents unconditionally, although the comment beside
the gate said the documents kept a gate of their own. An owner who switched
the documents module off still had every attached file decrypted for anyone
holding the link and its passphrase.

The share page lists no document while the module is off, and the document
route answers 404, the same as for an unknown document, before it looks the
membership up.
2026-10-25 13:52:04 +01:00
Marc-André Bombeck d1fcbca63b fix(mcp): name the host an authorization goes to on the consent page
Dynamic client registration is open by specification and the client names
itself, so anyone could register under a well-known assistant's name with a
callback on a host of their own and send a signed-in person the authorize
link. The consent page showed the borrowed name and "Unverified
application", never where the code would be sent.

Every consent page now says where the access goes: the callback's host, or
an application on this device for a loopback callback, and the Allow button
names the host. For a self-registered client whose callback leaves the
device, a warning that names the host leads the page, above the
self-reported name. Registration stays open: the hosted assistants register
this way with their own https callback.
2026-10-25 13:52:04 +01:00
Marc-André Bombeck 77ace106b3 fix(http): refuse state changes a browser sends from another site
The session cookie is SameSite=Lax, and SameSite compares the registrable
domain, so a page on a sibling subdomain of the instance could send a
CORS-simple POST with the person's cookie attached. safeJson accepted any
Content-Type containing "application/json", so a text/plain body that
mentions it in a parameter, which needs no preflight, was parsed as the
person's own request: readings added, entries deleted, integrations
disconnected, a delegate invited within the re-proof window.

apiHandler now refuses a POST, PUT, PATCH or DELETE before authentication
when the browser marks it Sec-Fetch-Site same-site or cross-site, or, from
a browser that sends no fetch metadata, when its Origin names a host other
than the one the request went to or APP_URL. A request with an
Authorization header, or with neither header (the phone app, scripts,
webhook senders), is unaffected, as are the webhook and URL-credential
routes that authenticate with their own secret. The JSON media type is
compared exactly, with parameters allowed, in safeJson and in every route
that branched on a substring of it.
2026-10-25 13:52:04 +01:00
Marc-André Bombeck fa50bcd1cb fix(ai): keep lab values out of every model prompt while Labs is off
The Coach snapshot attached its labs block regardless of the module, a
decision from before Labs could be switched off, while the snapshot's own
prelude says a disabled domain never reaches the model. With Labs off, the
Coach prompt, its get_labs tool, the daily briefing's flagged-marker block,
the biomarker status note, the reaction line to a new panel and the MCP
lab_trend_brief prompt all still read lab values for a model.

Each of those reads now asks the labs module first and reads nothing when it
is off: the Coach tool answers module_disabled, the briefing omits the
block, the status note neither writes nor serves text, the reaction line
gets no value, and the MCP prompt refuses like get_labs and the doctor-visit
prompt already did. The data routes stay as they are. A structural guard
holds every lab-table reader under the model-input paths to the gate or to
a listed reason its rows never reach a model.
2026-10-25 13:52:04 +01:00
Marc-André Bombeck 6fbfe71955 fix(auth): stop exempting a previously used address from the sign-in throttle
The per-account password throttle skipped every attempt that came from an
address one of the account's sessions or device logins had used in the last
thirty days. An address is the easiest thing about a request to share or
claim: in a household or an office the guesser has the owner's address, and
a direct caller could name it in a forwarding header. Either way the
throttle switched off and guessing was bounded only by the hash cost.

A remembered browser (the trusted-device cookie) and a phone presenting the
device id of one of the account's logins still pass; everything else gets
the growing wait.
2026-10-25 13:52:04 +01:00
Marc-André Bombeck d6d0f7e73f fix(http): read forwarding headers only from a connection a proxy could make
The client-IP resolver trusted one forwarding hop by default and read the
rightmost X-Forwarded-For entry. Next fills that header with the socket's
address only when the request carries none, so a caller reaching the
published port directly could send its own and choose a fresh address per
request: every per-IP limit (sign-in, second factor, passkey, share unlock,
webhooks) became per-request, and sessions, the audit log and sign-in
notices recorded the chosen address.

The server now writes each connection's own peer address into an internal
header from Node's http.server.request.start diagnostics channel, installed
at boot and overwriting anything a caller sent under that name. The resolver
reads forwarding headers only when that peer is loopback, private,
unique-local or CGNAT, where a reverse proxy sits; a public peer is its own
client address, and a log line says once that its forwarding headers were
ignored. With TRUST_PROXY_HOPS=0 the peer is used instead of no address, so
a directly exposed host no longer shares one bucket across all anonymous
callers. Proxies on the same host, Docker network or tailnet need no change.
2026-10-25 13:52:04 +01:00
Marc-André Bombeck ba22932101 fix(documents): read a JPEG's size past fill bytes and refuse an unreadable one
The thumbnail's header walk took the byte after any 0xFF as a marker. A
single 0xFF fill byte after the start-of-image marker, which the JPEG
standard allows and every decoder skips, threw it off; it read no size, and
an unread size let the image through to a decode that allocates the full
bitmap. A small JPEG declaring 20000 x 20000 pixels allocated 1.6 GB in the
web process before the post-decode check could see it.

The avatar upload already had a walker that skips fill bytes. Both now use
one reader (lib/image-dimensions) that follows the marker rules: fill bytes,
the length-less markers, and no size when a scan or the end of the image
comes before a frame header. The thumbnail treats an unreadable size as a
refusal (size-unreadable) for every format it decodes, and the backfill
counts that refusal as final.
2026-10-25 13:52:04 +01:00
Marc-André Bombeck 518ce2fd37 fix(documents): read and render PDFs in a bounded child process
pdf.js decodes an image at the size the file declares, whatever size the
page is drawn at, and the server opened documents without an image limit.
A small upload declaring a very large image made the web process allocate
gigabytes until the container was killed, for every user at once, and the
thumbnail job ran the same document again after each restart. An image
whose soft mask declares the size does the same past pdf.js' own limit, and
nothing bounded how long a text read could take.

pdf.js is now opened with maxImageSize (20 million pixels, which keeps one
image inside the reader's ceiling and admits a 300 dpi A3 scan), and every
read and render runs in a child process, one at a time: its V8 heap is
capped, its resident memory is sampled and the child killed past 640 MB, on
Linux it volunteers to the out-of-memory killer first, a time limit ends a
read that runs on, and it starts with an almost empty environment. The
loader and the rendering moved into pdf-engine.mjs, which the child runs
with plain Node and which is traced into the standalone image.

A document that crosses a bound answers resource-limit. The thumbnail job
records it as a final failure, so the backfill does not queue it again.
2026-10-25 13:52:04 +01:00
Marc-André Bombeck 0c5f29303b docs(backup): say what account copies and the JSON import can do
Several surfaces promised more than the per-account copies deliver:

- The full-backup export card said it had the same shape as the weekly
  copy. It is a portable export with documents as metadata only; it now
  says so, and that it cannot recreate an account on another server.
- The export page offered to "import from a backup". The JSON import adds
  measurements and mood entries; it is now named that way, and its help
  states the 16 MB and 10,000-entries-per-list limits from the constants
  the route enforces.
- The weekly copy is labelled an account rollback on this server, and the
  backups page says a copy cannot rebuild a lost server. The upload says
  the account must exist here. Two restore refusals pointed to a
  "disaster-recovery" copy; they now name the copies that exist.
- The instance-settings restore option was described as being for
  rebuilding a host.

docs/ops/backup-restore.md is rewritten short and accurate: what each
copy is, the deletion grace window, that database dumps keep a deleted
account until they age out, how to restore one account, and a link to
the disaster-recovery procedure for a lost server.
2026-10-25 13:51:15 +01:00
Marc-André Bombeck 63574b98a6 feat(account): show the deletion grace window and its undo
The deletion dialogs, the managed-profile delete dialog and the Data &
Privacy page read the window from the privacy summary and say what
happens: the account is locked at once, erased after the stated number
of days, and an admin of the server can undo it until then; a wipe keeps
a copy an admin can restore for the same time. Copies in the server's
backups age out after their retention period, and the text says that
too. With the window set to 0 they keep the immediate wording.

The sign-in page names the erasure date when a sign-in, a passkey or the
single sign-on callback is refused for an account pending deletion. The
admin users list shows a pending account with its date and an "Undo
deletion" action, and the backups list labels the copy taken before a
wipe. Every new string is translated in all nine languages.
2026-10-25 13:51:01 +01:00
Marc-André Bombeck a69666fa16 feat(account): hold account deletion and data wipe for a grace window
Deleting an account, a guardian deleting a managed profile, and "Delete
all data" were final the moment they were confirmed: the account row
cascaded into every table and every stored copy, and the off-host objects
were purged in the same breath. Nothing short of rolling the whole
instance back could return one account.

ACCOUNT_DELETION_GRACE_DAYS (14 by default, at most 30, 0 erases at once
as before) now sets a window:

- Account and managed-profile deletion lock the account (migration 0488:
  deletion_requested_at, deletion_purge_at, deletion_requested_by_id).
  Every session, refresh token, API token, connector, share link and
  step-up elevation is revoked in the same transaction, and the record,
  its stored copies and its off-host objects are left as they are.
- Sign-in is refused with 403 auth.account.deletion_pending and the
  erasure date, after the credential was proved and before a second
  factor is asked for. The session and Bearer resolvers and the acting-
  account resolver refuse a pending account too, so a credential minted
  in the moment before the lock is useless.
- POST /api/admin/users/{id}/undo-deletion (cookie-only, audit-logged)
  clears the lock. The admin users list reports the pending state.
- The hourly account-deletion-purge job erases accounts whose window has
  ended, exactly as the immediate deletion did, with the off-host purge
  request committed alongside. The Guardian invariant is decided again;
  a refused erasure stays pending and is reported.
- A wipe first stores the whole record as a PRE_WIPE_<ms> copy through
  the writer the weekly copy uses, keeps it out of the wipe, and holds
  its off-host purge back with a new not_before on the request. The
  ordinary restore brings the record back; restoring that copy withdraws
  the held purge. The purge job deletes the copy when its window ends.

Background work skips accounts pending deletion through one shared
predicate (src/lib/account-deletion/active-account.ts): every reminder,
digest, Coach, sync, backfill, rollup, score and backup enumeration
filters with it, and account-deletion-enumeration-guard.test.ts fails on
a query in background work that neither filters nor says why it need
not. Notification delivery and the AI capability loader refuse a pending
account as chokepoints behind it, and the Withings, WHOOP and Telegram
webhooks and the notification quick actions ignore one.

The privacy summary publishes the window. Two pieces of the reminder tick
and the off-host pass moved into modules of their own to keep the files
inside their size ratchet.
2026-10-25 13:50:14 +01:00
Marc-André Bombeck adc96fde3e Merge remote-tracking branch 'origin/release/v1.46.0' into feat/v146-dump 2026-10-25 13:49:51 +01:00
Marc-André Bombeck 726a73fcad refactor(admin): drop an unused kit type re-export 2026-10-25 13:49:22 +01:00
Marc-André Bombeck 4ffa3352bd ci: restore the drill from the bucket after the host is lost
The drill now runs an S3-compatible bucket beside the stack (Versity S3
Gateway v1.8.0, pinned by digest; the MinIO images are no longer published
on Docker Hub or quay.io) on a network and a volume of its own, so it
outlives the stack as a real bucket does. The app takes the dump through the
scheduled job and uploads it there; the drill waits for a run that both
kept and uploaded it, destroys every volume of the stack, the local backups
included, and restores on a new stack with only the bucket and the keys:
`docker compose run --rm db-restore latest --from-bucket`. The independent
row count reads the manifest from the bucket with the restore's own reader.

DRILL_SOURCE=local keeps the earlier path (dump files copied off the host,
then `db-restore latest`), and CI runs both. A new mutation,
wrong-backup-key, restores with a different BACKUP_ENCRYPTION_KEY and must
be refused. Ports are free ones picked at start.
2026-10-25 13:43:23 +01:00
Marc-André Bombeck 30a2ba2102 docs(ops): restore from the bucket after a host loss, and state backup retention
The new-server steps lead with `db-restore latest --from-bucket`, the copy
that survives when the host and its local dumps are gone, and keep the
restore from downloaded files beside it. Both backup pages now say that the
database dumps are not purged per account: a deleted account stays in them
until they age out, which is the retention period an operator states to the
people using the instance.
2026-10-25 13:43:23 +01:00
Marc-André Bombeck faf6dfbaef ci: wait for the published port in the database move check
The check failed once with "Can't reach database server at
127.0.0.1:55416": the container reported healthy before its published port
forwarded, and the migrate on the host ran in that gap. It now waits until
the port answers, retries a migrate that could not connect, both within
thirty seconds, and fails with Prisma's own output otherwise. The port is a
free one picked at start unless CHECK_DB_PORT names it, so the check cannot
collide with another stack on the same machine.
2026-10-25 13:43:23 +01:00
Marc-André Bombeck e825a4c4d3 feat(admin): say how long dumps keep deleted accounts, and check the bucket rule for database/
A database dump is the whole database at one moment, so an account deleted
since stays in the dumps taken before until they age out. The database row
on the backups card now says so with the numbers that decide it: how many
nightly dumps this server keeps, and after how many days the bucket's
lifecycle rule removes the off-host copies.

The lifecycle probe used to answer with the shortest expiry of any enabled
rule, whatever its prefix, so a rule scoped to the account copies read as
covering the dumps too. It now also answers for `database/` alone, counting
only rules whose filter takes in a dump's key (prefix, legacy prefix, an
And filter; a tag filter never matches untagged objects), and the card
warns when no rule covers that prefix: dumps there, and the deleted accounts
in them, would never be removed. The reading moved into its own module.
2026-10-25 13:43:23 +01:00
Marc-André Bombeck 82db3de30a feat(backup): restore a dump straight from the off-host bucket
A lost host takes its local dumps with it, and the copy left is the one in
the bucket. `docker compose run --rm db-restore latest --from-bucket` now
lists `database/` in the bucket the BACKUP_S3_* variables name, takes the
newest dump whose manifest is there too (or the one named), downloads both
with the S3 client the image already ships, and continues as for any
off-host copy: opened with BACKUP_ENCRYPTION_KEY, checked against the
manifest, restored into an empty database. `list --from-bucket` shows what
is there. The sealed download is removed once it is opened.

The image build now proves the S3 client resolves from the shared module's
place in the tree, so the restore cannot lose it unnoticed.
2026-10-25 13:43:23 +01:00
Marc-André Bombeck ed51211e82 docs(self-hosting): describe the recovery kit and the readiness push
The key backup sections become "Save the recovery kit": which keys the kit lists and why, the fingerprint of the token key, and the readiness card with its Backup health notification.
2026-10-25 13:32:43 +01:00
Marc-André Bombeck 7dab9cc5e9 feat(admin): judge every backup signal and push changes to admins
Every backup failure was a pull, on a page nobody opens until the day it
matters: the weekly copy stopped for 46 days and showed an ordinary row,
a failed monthly drill was on the failing-jobs card three days in thirty
(job-failures.ts reads 72 hours), and an off-host bucket that was never
set up showed only on the backups page.

One readiness model (src/lib/recovery/readiness-model.ts) judges six
signals as ok, attention or failing, with a reason code, the time it is
about and where to fix it:

- database dump: failing once the newest good dump is older than 36 h
  while dumps are on, or when it can be kept nowhere; read through a
  provider that reports "not available" until the dump lands;
- weekly account copies: worst account decides, attention past 9 days,
  failing past 14; an account without a copy ages from its creation;
- off-host copies: attention when any account is two nights behind;
- restore drill: failing until a later drill passes, with no time
  window; the drill now keeps its verdict in restore_drill_outcomes;
- off-host setup: attention 7 days after the first admin exists;
- recovery kit: attention when a needed key is not confirmed, by id,
  7 days after setup or as soon as a confirmed kit is out of date.

A "Recovery readiness" card shows them on the admin overview and the
backups page. An hourly job (recovery-readiness, :33) sends each change
to every admin through the localised admin path, under a new
admin-only BACKUP_HEALTH event that the channel preferences route: once
when a signal turns to attention or failing, once a day while it stays
failing, once when it recovers. Each notice is claimed with a
compare-and-set before it is sent, so two workers never send the same
one, and nothing is claimed while no admin exists.

The backups page calls the weekly copy stale at the same instant as the
model (past nine days rather than past ten).
2026-10-25 13:32:43 +01:00
Marc-André Bombeck d3c672cc3f feat(admin): replace the key backup step with a recovery kit
The step confirmed one key, the active application key. A restore needs
more: off-host copies are sealed under BACKUP_ENCRYPTION_KEY, a copy
written before a rotation needs the retired application key it was
written under, and every session, token and share link hangs off
API_TOKEN_HMAC_KEY. An operator could do everything the step asked and
still be unable to read a single backup.

The recovery kit lists every key a restore of this server needs, by id
and fingerprint, never key material:

- each id in ENCRYPTION_KEYS, plus any retired id a stored copy
  (innerKeyIds) or an off-host object inside the bucket's retention
  (OffhostBackupKeyUse) still lists, also once the server dropped it;
- BACKUP_ENCRYPTION_KEY and each BACKUP_ENCRYPTION_PREVIOUS_KEYS entry,
  named by the id their objects record;
- API_TOKEN_HMAC_KEY.

Confirming records the set the admin was shown and refuses a stale one
with 409 recovery.kit.stale. The kit is due again as soon as a key it
lists is not in that set (a rotation, a new backup key, a re-keyed
install); a key that merely drops out does not make it due. "Check my
copy" now checks any key in the kit.

Migration 0487 carries a confirmation given under the old step forward
as a kit holding that key, so that part stays confirmed, and drops the
four app_settings columns. It also creates the two tables the readiness
evaluation uses (restore_drill_outcomes, recovery_readiness_notices).

The routes move to /api/admin/recovery-kit; the reader of the keys the
backups still need moves out of the encryption status route into
src/lib/recovery/backup-key-needs.ts so the kit and the status view
share one definition.
2026-10-25 13:32:29 +01:00
Marc-André Bombeck 1a4289545e ci: rebuild a lost server from its dump in a compose drill
scripts/compose-dr-drill.sh plays docs/ops/disaster-recovery.md against
real containers: it builds the image from the checkout, starts the bundled
compose stack with fixed throwaway keys, seeds the demo account and a
Guardian with a managed profile (through the app's own code), writes a
reading with an encrypted note through the API, and waits for the
scheduled database-backup job to take the dump. It then keeps only the
dump files, destroys every volume, starts a new stack, restores with
`docker compose run --rm db-restore latest`, counts every table against
the manifest apart from the restore, and boots the app: health, the demo
login, the note read back, a document opened, the Guardian still seeing
the profile.

DRILL_MUTATION breaks one step on purpose (wrong-key, skip-table,
non-empty-target), and the drill fails on each. The workflow runs it on
pull requests that touch the compose file, the image, the entrypoint or
the dump and restore code, nightly, and by hand.
2026-10-25 13:05:47 +01:00
Marc-André Bombeck ca644647a0 docs(ops): describe disaster recovery from the database dump
docs/ops/disaster-recovery.md says what is dumped and where, that local
dumps are unencrypted and why, which keys a restore needs and what each
one costs when missing, how to restore onto a new server or back to the
state before a migration, and how the procedure is tested. The deploy
runbook and the getting-started guide now describe the dump the
entrypoint takes before migrating, and the off-host account copy page
points at the dump for rebuilding a server.
2026-10-25 12:56:33 +01:00
Marc-André Bombeck 3646502a86 feat(admin): show the newest database dump on the backups page
GET /api/admin/backups now carries `database`: the newest good dump (when,
how big, kept on the server, off-host or both), the newest failed run,
whether this server can write its backup directory, and whether off-host
copies are configured. The backups card leads with one row built from it,
with a notice when the last run failed and when dumps can only go
off-host or cannot be taken at all. The route stays an unpublished admin
console route, so the OpenAPI document does not change.
2026-10-25 12:56:33 +01:00
Marc-André Bombeck e665f17f52 feat(backup): dump before migrating, and restore a dump into an empty database
The image now carries postgresql18-client (pg_dump and pg_restore 18.6)
and a /var/lib/healthlog/backups directory owned by the app user, which
the bundled compose file backs with a new `backups` volume for `app` and
the commented `app-worker`; a fresh named volume inherits that owner.

Before `migrate deploy`, when migrations are pending on a database that
has applied some, the entrypoint writes healthlog-<time>-pre-migration.dump
with its manifest and keeps the last two. A failed dump on a writable
directory stops the boot and names DB_BACKUP_BEFORE_MIGRATE=false; without
a writable directory it warns and continues.

`docker compose run --rm db-restore <file|latest|list>` runs
scripts/db-restore.mjs in the app image with plain node. It opens an
off-host .dump.enc with BACKUP_ENCRYPTION_KEY (or a previous key), checks
the archive against the manifest's SHA-256, compares the keys in the
environment with the manifest's fingerprints and refuses on a different
active encryption key unless --ignore-key-mismatch, refuses a database
that holds any table, restores in one transaction, runs ANALYZE, and
compares every table's row count with the manifest, emptying the database
again on a difference.

DB_BACKUP_ENABLED, DB_BACKUP_DIR, DB_BACKUP_SCHEDULE, DB_BACKUP_KEEP and
DB_BACKUP_BEFORE_MIGRATE join the compose whitelist, both example files
and the env manifest.
2026-10-25 12:56:33 +01:00
Marc-André Bombeck 6d3a1582fa feat(backup): take a nightly whole-database dump with a manifest
The per-account copies roll one account back on the same server; nothing
the app made could rebuild a lost one. A scheduled job now runs pg_dump
over the whole database (every schema, pg-boss included) into
DB_BACKUP_DIR as healthlog-<UTC time>-daily.dump, with a manifest beside
it: app version and build, newest migration, Postgres version, size and
SHA-256, exact row counts of every table, and the fingerprints of the
keys the database needs.

The counts read the same exported snapshot pg_dump reads, so a dump
taken while the app writes still matches its manifest. The archive is
written as .partial, listed back with pg_restore --list, and renamed
after its manifest; a failed run leaves nothing named like a good dump.
Retention removes only files this job wrote, oldest first, and never
the newest good one (DB_BACKUP_KEEP, default 7; two pre-migration dumps).

With the off-host bucket configured, each dump and its manifest go up
under database/ in the envelope the account copies use, streamed. Every
run lands in the new database_backup_runs ledger (migration 0486), and
getDatabaseBackupHealth() answers the last good dump, the last failure,
whether the directory is writable and whether off-host is configured.
The boot readiness summary says where dumps go, or that none can be kept.

The dump, naming, retention and restore rules live in one plain ESM
module so the image's own scripts can share them without tsx.
2026-10-25 12:56:33 +01:00
Marc-André Bombeck 2440d740a9 Merge remote-tracking branch 'origin/release/v1.46.0' into fix/pr1284-labs-schema 2026-10-25 12:40:19 +01:00
Marc-André Bombeck a895f16e08 Merge #1284: declare the labs availability metadata in get_labs
The tool module was split into one file per tool on this branch, so the
change lands in tools/get-labs.ts, and the advertised tool list snapshot
gains the two declared fields.
2026-10-25 12:32:41 +01:00
Usama Muhammad 93fb0f68d8 fix(mcp): declare labs availability metadata
(cherry picked from commit c1a5861fdcbfe374fcbb5362eed3717a5bd9b15d)
2026-10-25 22:29:12 +11:00
Marc-André Bombeck ec8191d18f Merge remote-tracking branch 'origin/release/v1.46.0' into fix/v146-doc-findings 2026-10-25 12:26:24 +01:00
Marc-André Bombeck 41c6eea2a1 fix(backup): stream every large section of an uploaded backup
The admin upload streamed only the measurements while it checked a file,
so a document-heavy account copy was held whole in memory and an upload of
a few hundred megabytes could take the process down.

The sections a reader takes an element at a time are now named once
(BACKUP_STREAMED_SECTIONS in streamed-backup.ts): measurements, intake
events, mood entries, documents and ECG recordings. The upload reads its
file through readStreamedBackup like the restore and the preview, and the
restore drill and the restore script use the same key set.

A behavioural test pushes a 271 MB document-heavy file through the real
upload route and fails when the heap grows with the file. The stream guard
now asserts that every reader goes through the shared reader or key set
and that no file declares its own.
2026-10-25 12:25:15 +01:00
Marc-André Bombeck a641a615c3 docs: point the Strava and Dawarich notes at their site guides
Both files had drifted copies of the docs-site guides, which carry the same
sections and more, and nothing in the app, the scripts, the other docs or
the site links to them. Each is now a short pointer to its guide.
2026-10-25 12:12:15 +01:00
Marc-André Bombeck 12f8265fdb docs: correct claims and anchors the code no longer backs
Fix two dead anchors (the MCP trust model, the README quick start) and the
statements that no longer match the app: a guardian can connect a managed
profile's services, the environment module is on by default and reaches the
air-quality host too, its readable location columns were dropped in 0409,
settings sections carry their current names, the Withings card lives on
/settings/integrations, the TrueNAS start period matches the image, the
deploy check names HEALTHLOG_PROD_URL, and the docs index describes what
self-hosting/ and integrations/ hold.
2026-10-25 12:12:15 +01:00
Marc-André Bombeck f85035a8ec docs(coach): say what the labs block reads since it covers the whole record
The labs block reads the whole record, or up to the lookback limit, not just
recent readings, and a named read also comes back empty when no marker
matches. The header of the labs snapshot and the note at its call site
still described the earlier twelve-month window.
2026-10-25 12:12:15 +01:00
Marc-André Bombeck 72d90282d5 fix(settings): open the docs pages themselves, not their old addresses
The integration runbooks, the Garmin note, the guardian note on a managed
record's connections, the backups admin link and the README's self-hosting
link all pointed at retired docs paths that only resolve through redirects
(or not at all, on a host without them). They now go to the final guide
pages, and the settings tests assert the new addresses.
2026-10-25 12:12:15 +01:00
Marc-André Bombeck a57d2ffe83 test(fhir): pin the search parameters the CapabilityStatement declares
The statement already declares `date` and `code` on Observation beside
paging on every search; the route test now asserts that list per resource,
so a parameter the search honours cannot drop out of it unnoticed.
2026-10-25 12:12:14 +01:00
Marc-André Bombeck d6bb7bc905 docs(api): describe the legacy integration-status fields as still served
The published contract said the nine health-shaped fields that predate
`health` were removed in v1.44, but every status read still sends them:
older native builds decode them, the current one falls back to them against
a server without `health`, and the web card's copied diagnostics read the
failure streak. They stay deprecated, without a removal release.
2026-10-25 12:12:14 +01:00
Marc-André Bombeck 856eb036f8 fix(documents): point the on-device scan hint at the Labs switch
The hint shown when a text-only provider needs on-device text recognition
sent people to the AI settings, where no such switch exists. It now names
the switch by its label under Settings → Modules → Labs in every locale and
links straight to that card. Inside somebody else's record the link is
withheld, since Settings there is the caller's own and closed; the sentence
stays.
2026-10-25 12:12:14 +01:00
Marc-André Bombeck 953c64f7a4 Merge remote-tracking branch 'origin/release/v1.46.0' into fix/demo-bp-story 2026-10-25 11:49:03 +01:00
Marc-André Bombeck 28340e7c66 fix(coach): restore every step a turn records on reload
The turn records up to 64 steps while the read path kept only the first
48, so the tail of a long answer's step list vanished on reload. Both ends
now read one MAX_TURN_STEPS constant, and a unit plus an integration test
round-trip a full 64-step answer through the stored provenance.
2026-10-25 11:42:37 +01:00
Marc-André Bombeck d839fbdb11 fix(demo): lift the busy-stretch blood pressure far enough to read
The blood-pressure chart scales one axis to fit systolic and diastolic, so
the 12 mmHg the busy stretch added came out as a nearly flat line on a 60 to
140 axis, even with the 7-day trend on.

The stretch now climbs over two days to about 144/91, holds for some ten
days, and comes back down over the following days after ramipril goes from
5 to 10 mg. A dose change and a family-practice visit on the day it turns
carry that step, and the medication now reads 10 mg. The day-to-day walk is
quieter, with a little reading scatter on top, so the 7-day mean draws an
arc rather than jitter.

Two limits keep the rest of the page honest. The lift stops short of
149 mmHg: one more and the padded axis rounds out to 50 to 200 and flattens
the rise again. And the stretch stays well under half of the last 30 days:
the usual range is the median ± 3 MAD of that window, and a longer stretch
moved the median onto it and widened the band to about 87 to 181 mmHg.

The stored texts that describe the last weeks mention the stretch and the
dose increase. The seed-shape test holds the larger swing, the ceiling, the
smaller day-to-day steps, the median of the window and the dose change on
the turn.
2026-10-25 11:40:04 +01:00
Marc-André Bombeck 27c8533f40 docs: describe the Postgres 18 move and the versions HealthLog runs on 2026-10-25 11:18:14 +01:00
Marc-André Bombeck d4d91994e8 feat(db): move the bundled database to Postgres 18
The compose file runs Postgres 18. An install from v1.45 or earlier keeps
its Postgres 16 data in `pgdata`, which 18 cannot open, so a one-shot
`db-upgrade` service moves it on the first start: it starts Postgres 16 on
the old volume inside its own container, streams a dump into 18 in one
transaction, compares every table's row count, gathers planner statistics
and marks the old volume as moved. The app waits for it to succeed.

The old volume is never changed apart from the marker, so the way back is
the previous image and volume lines. A move cut short leaves a "moving"
marker and runs again from scratch; an emptied new volume after a finished
move is refused rather than refilled with the old state.

scripts/compose-db-upgrade-check.sh plays all of it against real
containers from the v1.45.0 compose file, and runs in CI on every change
to the compose file. Pull requests run the integration suite and e2e on
Postgres 18; the nightly integration run keeps 16 covered for installs
that bring their own database.
2026-10-25 11:18:14 +01:00
Marc-André Bombeck 6b3c1cc433 docs(ops): gather planner statistics after restoring into the new major 2026-10-25 10:54:50 +01:00
Marc-André Bombeck b597627a39 test(mental-health): press an answer once the step's question holds the focus
The keys followed each other without waiting for the step to move the focus,
so on a slow run Tab started from the previous answer and the number chose
nothing.
2026-10-25 09:24:19 +01:00
Marc-André Bombeck 4853e9cee6 fix(mental-health): focus the first question once its wording has loaded
Each step focuses its question, but the first step's question only exists
once the language's wording chunk has arrived; the focus ran while the step
still showed its loading state and was never moved again, so a keyboard or
screen reader user started on the page body.
2026-10-25 09:24:19 +01:00
Marc-André Bombeck 9ff35efb47 test(e2e): measure the Coach button's clearance once the page has settled
Scrolling to the end can start a section's load, and its skeleton sat under
the button for a moment on the Insights page in landscape. The check now
scrolls to the end and measures again until the last line is in place;
content that stays under the button still fails it.
2026-10-25 08:45:25 +01:00
Marc-André Bombeck eadf4c0f2b docs(ops): stop a separate worker container before the Postgres dump too 2026-10-25 08:04:28 +01:00
Marc-André Bombeck 78bc7183bd fix(migrations): leave an index another instance is still building alone
A concurrent build that is still running is INVALID with its migration not
yet finished, exactly like a failed one. With the web and worker roles in
two containers, the second boot dropped the index the first was building,
which cancelled the first one's migration and left it failed until someone
resolved it by hand.

The check and the drop now run in one transaction that first takes Prisma's
migration lock without waiting and does nothing when another instance holds
it; an index Postgres reports as being built is never dropped. One
transaction also keeps lock_timeout on the statement it bounds behind a
transaction pooler, and the applied-migrations lookup quotes the schema, so
a mixed-case schema no longer reads as having none applied.
2026-10-25 08:04:27 +01:00
Marc-André Bombeck 1ddd072a08 test(series): settle a same-rank, same-instant tie in the BP pick parity
Two sources off every ladder now read at the very same instant on thirty
days: on fifteen the name decides, on the other fifteen the first name's
systolic is under the floor and the second takes the day. A dedicated
case asserts which source each of those days shows, under a UTC and a
Berlin session; the parity cases compare them with the reference as
well. Dropping the name from the window's order turns 26 of 59 cases
red on Postgres 16 and 18.
2026-10-25 07:16:44 +01:00
Marc-André Bombeck 6982f60f4d test(series): seed the unanalysed guard's blood pressure at a fixed hour
The guard seeded each day's pair six hours before the clock, with the
second source ten minutes later. Run between 05:50 and 06:00 UTC, the
newest day's second pair landed just past midnight, alone on a UTC day
of its own; the pick rightly chose that source for that day, the old
DISTINCT ON statement chose the same, and the assertion that every
series row is the first source failed for those ten minutes of the day,
on every Postgres major.

The pairs now sit at 10:00 and 10:10 UTC from yesterday back, whatever
the clock. With the clock pinned to 05:55 UTC the old fixture fails the
three series shapes and the new one passes, on 16 and 18 alike.
2026-10-25 07:16:44 +01:00
Marc-André Bombeck ca77d507f6 test(rollups): compare migration 0372's untouched columns right after it ran
A later case in the file folds the rollups again, and a fold sums in the
order its plan reads, so the second fold's sums could differ from the
first's in the last bit; the comparison read the table after that fold.
2026-10-25 06:51:51 +01:00
Marc-André Bombeck f66e7e9a93 test(backup): start the follow-up run strictly after the retry's last write
An account counts as done for a run when it succeeded at or after the run's
start, and the follow-up run's start taken with new Date() could share the
millisecond of the retry's last ledger write.
2026-10-25 06:43:51 +01:00
Marc-André Bombeck 8aab59cdc1 Merge branch 'fix/analytics-live-regression' into fix/review-meds-docs 2026-10-25 06:28:23 +01:00
Marc-André Bombeck 4e79098cc0 perf(measurements): fold per-source days without joining a regrouping back
Three readers grouped the same rows twice and joined the second grouping
back to the first: the live buckets joined each hourly-mean day's hour
means to the per-source days, the all-time extremes joined the day
weights the same way, and the doctor report's dense buckets joined each
bucket's latest reading and hour means to the bucket aggregates. On a
measurements table without planner statistics (a new install, or an
import under the autoanalyze threshold) the planner estimated one row on
each side and ran every one of those joins as a nested loop that re-ran
the inner grouping for each outer row.

On ten years of readings over six types (51,000 rows, no statistics) the
dense buckets and the every-type live buckets ran into the 60 s statement
timeout and the all-time extremes took 11 s. With statistics all three
answered in 0.1 to 0.2 s.

Each is now one chain of folds over one scan. The live buckets fold the
readings per local hour and the hours per local day, carrying the day of
hours beside the counts. The all-time extremes take the day weights from
the same windowed rows they count, the shape the pre-fold remainder
already has. The dense buckets carry each bucket's latest reading and each
hour's count and mean on the rows as window values, and one grouping folds
them all. Same fixture without statistics: 0.2 s, 0.1 s and 0.1 s, the same
as with them.

A parity suite runs the joined statements as the reference against the
readers across ladders, zones with whole, half-hour and negative offsets,
grains, overlapping and off-ladder sources, glucose contexts and device
types, tombstones and both DST changes; sums and means agree to
floating-point summation order, everything else exactly. The
unanalysed-table guard now runs the three readers under EXPLAIN ANALYZE:
the joins measured 200 to 3,000 row visits per row stored, the folds about
10.

The dense-bucket loader drops under the 200-line function cap, so its
baseline entry goes.
2026-10-25 06:27:44 +01:00
Marc-André Bombeck 6d338760d8 perf(series): pick the blood-pressure source with one window over one scan
The blood-pressure series chose each UTC day's source with DISTINCT ON
over the day's floor-passing systolic rows and joined the choices back
to the readings. That is the shape the analytics pick had, with the
same weakness: on a measurements table without planner statistics the
planner estimated one row on each side and ran the join as a nested
loop that rescanned the pick for every reading.

The ten-year series over 6,000 freshly written rows from one source
took 0.57 s for the statement alone, 0.86 s with a second source on
every third day; the plan visited about 4,900 rows per row stored, so
the cost grows with the square of the history.

The pick is now one window over one scan: the day's rows ordered
systolic first, then by ladder rank, then by source name, and the first
row's source taken only when it is a systolic one. The scan starts at
the window's first UTC day, so the pick still sees the whole edge day,
and a day without a floor-passing systolic still shows nothing. Both
fixtures above read in about 10 ms, with or without statistics.

A parity suite runs the old statement as the reference against the new
one across overlapping and off-ladder sources, half readings, sub-floor
rows, tombstones, both DST changes, window starts on and either side of
UTC midnight, and four session zones. The unanalysed-table guard now
covers the series' ten-year, quarter and week reads.
2026-10-25 05:57:31 +01:00
Marc-André Bombeck 2b8eb2c1c2 perf(analytics): pick the canonical source with one window over one scan
The canonical-source pick grouped the window to (type, day, source),
chose each day's source with DISTINCT ON, and joined the choices back to
the rows. Whether that join ran as a hash join or as a nested loop that
rescanned the whole pick for every row was the planner's call, made from
table statistics. On a measurements table without them (a new install,
or an import still under the autoanalyze threshold) it estimated one row
on each side and took the nested loop.

Binding the request's clock as a parameter instead of reading NOW()
flipped which side of that loop the pick landed on: the analytics read
over 6,000 freshly written blood-pressure rows went from 1.6 s to 8.4 s,
and the demo seed's dashboard headline to 20 s. On a ten-year account
without statistics the 90-day read took 4 s with either clock and the
all-time read ran past two minutes.

The pick is now one window function over one scan: FIRST_VALUE of the
source per (type, UTC day) in ladder order, keeping the rows of that
source. Same rows, same order of preference, so every day keeps its
source; the parity suite against the DISTINCT ON statement holds that.
The analytics read on the fixture above takes about 0.1 s, the headline
0.4 s. On the analysed ten-year account the 90-day and all-time reads
are as fast as the join was, and they no longer depend on statistics.

The rows carry one extra column, canonical_source; every reader selects
its columns by name.

A new integration test runs every reader shape under EXPLAIN ANALYZE on
a table with no statistics and bounds the rows the plan produces or
discards per row seeded. The statement count never moved, so a statement
budget could not see this; the join measured 200 to 8,600 row visits
per row seeded and the window stays at 4.
2026-10-25 05:46:08 +01:00
Marc-André Bombeck 4d4a83fb7d chore(bundle): measure the Recently deleted page's first load
The page is new in this release and had no baseline, which the budget check
refuses; 290.8 KB gz as the CI build measured it.
2026-10-25 05:03:54 +01:00
Marc-André Bombeck 240a135b88 refactor(ai): keep one name for one completion's ceiling
A status run is one completion, so its ceiling was the completion ceiling
under a second name. The Local client also re-exported a JSON-mode helper
nobody imported from it.
2026-10-25 04:50:25 +01:00
Marc-André Bombeck 8fee138256 fix(backup): type the alias order as Prisma's mutable order list 2026-10-25 03:35:14 +01:00
Marc-André Bombeck d07f7f5bbf Merge branch 'docs/postgres-major-upgrade' into fix/review-meds-docs 2026-10-25 03:19:55 +01:00
Marc-André Bombeck 2556da645b test(jobs): wait for the expired import's retry instead of a fixed window
The retry is fetched about a second and a half after the overrun ends, later
on a loaded machine, and the twelve-second window turned that lateness into a
red run. Both cases now wait for the state they assert, bounded by a
deadline, and read the call count once the retry's own run has finished.
2026-10-25 03:19:54 +01:00
Marc-André Bombeck ffd06ce289 docs(ops): describe the move to a newer Postgres major
The compose file stays on 16. Changing only the image line stops the stack:
the 18 image refuses a data directory from an older major and expects its
volume one level up. The runbook walks the dump and restore into a fresh
volume, keeps the old one as the way back, and is linked from the update
section and the compose pin.
2026-10-25 03:06:37 +01:00
Marc-André Bombeck 84064ea045 Merge branch 'fix/bound-sync-lab-reads' into fix/review-meds-docs 2026-10-25 03:02:57 +01:00
Marc-André Bombeck 698ed87c22 test(parity): a reading or writing delegate is not offered End
End needs MANAGE over medications, so the course flag now reads false for a
delegate who may only read or write.
2026-10-25 03:02:57 +01:00
Marc-André Bombeck 3853ce7f1a fix(backup): order the source aliases a deleted document's keys are read from
The backup export is totally ordered so two exports of the same account are
byte-identical; the alias relation the import-key ledger reads was the one
nested read without an order.
2026-10-25 03:02:56 +01:00
Marc-André Bombeck fd299cad61 fix(labs): plan every report read against a schedule
The lab scan runs only in the background worker, which hands it the
run's deadline, but its body still took the schedule as optional and
planned a missing one without a bound: a twenty-page report as forty
model calls. Nothing calls it that way today; the signature allowed the
next caller to, and so did every read planner beneath it.

The scan and the planners (maxReadsFor, planTextReads, planVisionReads,
planInboundTextReads, planInboundReads) now require a schedule. A run
with room for every read says so with an unbounded one; a request uses
SYNCHRONOUS_READ_SCHEDULE. No route, wire or response changes.
2026-10-25 02:58:35 +01:00
Marc-André Bombeck 474fc08649 fix(documents): bound the lab staging an index request continues into
The index route without Prefer: respond-async, which the iPhone app
calls, continues into the lab staging with no run schedule. The staging
then planned a long report's text as up to twenty reads with a retry
each: forty model calls inside one HTTP request, the same class the
extract route was bounded against.

The staging now plans a missing schedule as one request does
(SYNCHRONOUS_READ_SCHEDULE, at most three reads): a longer report has
its text shared out between them, and what does not fit is left out and
reported as extractionIncomplete on the document. The request bound
moves into ocr-plan beside the schedule it bounds, so the extract run
and the staging share one definition. Background runs and the automatic
index job keep planning against their own deadline.
2026-10-25 02:52:10 +01:00
Marc-André Bombeck 2b27b4b501 chore(ratchet): keep the version-stamp baseline in the order the writer emits 2026-10-25 02:45:31 +01:00
Marc-André Bombeck 048407756f Merge branch 'chore/pg18-check' into fix/review-meds-docs 2026-10-25 02:45:24 +01:00
Marc-André Bombeck d6c2760415 Merge remote-tracking branch 'origin/release/v1.46.0' into fix/review-meds-docs
# Conflicts:
#	src/__tests__/baselines/source-function-lines.json
#	src/__tests__/baselines/version-stamp-comments.json
#	src/__tests__/sharing-surface-guard.test.ts
2026-10-25 02:45:23 +01:00
Marc-André Bombeck 4390614463 fix(migrations): rebuild an index a failed concurrent build left invalid
A CREATE INDEX CONCURRENTLY that fails half-way leaves an INVALID index,
and the migration re-run after migrate resolve --rolled-back finds it
through IF NOT EXISTS and does nothing: recorded applied, with an index
the planner never uses. 0477 has this shape.

The boot now drops an INVALID index that a not-yet-applied migration
builds concurrently before it runs migrate deploy, with a bounded lock
wait, so the re-run builds it again. An INVALID index whose migration is
recorded applied is named on stderr with the rebuild that mends it. The
deploy runbook says both.
2026-10-25 02:42:09 +01:00
Marc-André Bombeck b2c99e5c8d fix(migrations): bound the lock wait of 0475 and 0480, and of every file after
0475 and 0480 added their columns with a bare ALTER TABLE, which waits
for its table lock behind every open transaction while every write to
the table queues behind it. Both now run in a DO block with a five
second lock_timeout and carry the boot's retry marker; the effect is
the same and both stay safe to run again.

Neither has shipped in a release. A database that already applied them
is unaffected: prisma migrate deploy and migrate status ignore the
checksum of an applied migration (checked against a database migrated
with the old files), so nothing is re-run and nothing is reported.

The lock-timeout guard now refuses, from 0475 on, any statement that
takes a table lock outside such a block.
2026-10-25 02:42:09 +01:00
Marc-André Bombeck 205d8f257c fix(backup): carry the import ledger, and the keys of deleted imports
The backup carried neither the import ledger nor the tombstones of
deleted documents, so after a restore onto a fresh instance a token
importer stored every imported document the person had deleted on
purpose again.

The document filing section now carries documentImportKeys: the
ledger's rows plus the own and alias keys of the documents waiting in
Recently deleted, which still do not travel as documents. The restore
writes them back, minus any key a restored live document holds, and
counts them in the restore summary and the preview. DocumentImportKey
moves from the excluded models to the two-ended ones, and the round
trip, the column audit and the parity goldens carry a ledger row.
2026-10-25 02:42:09 +01:00
Marc-André Bombeck 988af10a34 fix(documents): keep module-off accounts from filling the index retry boot
The boot discovery for the documents 0479 marked took the first thousand
by id and skipped those whose account has the documents module off.
Such an account keeps its marks, so with enough of them it held every
place of every boot and the accounts after it never got their retry.

The marks are walked by id past the last row read, an account found
with the module off is left out of the query from then on, and the
boot stops at a thousand documents enqueued rather than read.

Refs #1277
2026-10-25 02:14:17 +01:00
Marc-André Bombeck ea3bdf42b7 fix(documents): settle an index backfill read once and ask the wire before each
The index-all backfill billed the read and wrote the index in one try:
an index write that failed after the read was settled again in the
catch, handing the reservation back twice. The read is settled once,
and a failed write charges nothing back.

The backfill also checked consent and the operator's switches once, at
the pick, then read up to two hundred documents. The wire is asked
again before every read; a refusal hands that document's reservation
back and ends the walk with no-consent or unavailable.
2026-10-25 02:10:17 +01:00
Marc-André Bombeck da4056ce05 fix(documents): key an undated fact on its report's date in the merge
The extraction merged facts across reads on a key whose only date was
the fact's own effective date. Facts without one collapsed across reads
of different reports, so the same value on two days staged once. An
undated fact is now keyed on its read's report date, falling back to
the document's, as the lab scan keys a row.

Refs #1257
2026-10-25 02:03:27 +01:00
Marc-André Bombeck 41acdbfd54 test(integration): let the suite run against another Postgres major
INTEGRATION_PG_IMAGE overrides the container image, so the same suite can
prove a major the compose file does not ship yet.
2026-10-25 02:00:57 +01:00
Marc-André Bombeck 875a2a98d6 Merge remote-tracking branch 'origin/release/v1.46.0' into fix/ai-chain-review 2026-10-25 01:11:30 +02:00
Marc-André Bombeck a172e377c1 fix(ai): close the notes' windows on a fresh Codex sign-in
A save of a person's provider settings and of the operator's admin AI
settings forget the health ledger's record and close the status notes'
negative windows. The two Codex sign-ins, the operator's central one and a
person's own, forgot the ledger record only, so a fixed connection still
had every note wait out the window the refused one earned, up to two hours.

The central sign-in now closes every record's windows, since every account
may fall back on that connection, and a person's own sign-in closes theirs.
Proven against the real rows in the Codex sign-in integration suite.

Refs #1279
2026-10-25 01:03:50 +02:00
Marc-André Bombeck c9481b672c fix(documents): bound an extraction that runs inside the request
Without Prefer: respond-async the extract route ran the read with no
schedule, so a twenty-page report read a page at a time, with a retry
each, could make forty model calls in one HTTP request. The iPhone app
sends no Prefer header and reads the document detail from the answer,
so the request keeps its 200 and its body.

An extraction without a run deadline now plans at most three reads (six
calls): a longer document has its pages or text shared out between
them, and what does not fit is reported as extractionIncomplete on the
document. The background run keeps reading it a page at a time.

Refs #1257
2026-10-25 01:01:13 +02:00
Marc-André Bombeck 958d230b9f fix(documents): keep an incomplete extraction on the document
A long document read in parts can end with a part cut at the provider's
output limit, a part never read, or more facts than one review takes.
That was told once, as a notice when the run finished; a review opened
later looked complete.

InboundDocument.extractionIncomplete (migration 0483) is written with
the facts by both stagings, the extract route or run and lab
auto-staging. The document detail publishes it and the review shows the
same note whenever it is opened. The disaster-recovery backup carries
it; an older file restores it as false.

Refs #1257
2026-10-25 01:01:13 +02:00
Marc-André Bombeck 8b47d033e7 fix(ai): learn a refused reasoning field set by the entry itself
A gateway or local entry can carry its own reasoning setting (#1126), sent
on every call that has no level of its own. An endpoint that refuses
reasoning_effort answered each of those calls with a 400 that was never
learned, because only the call's own level counted as sent: every status
note, every summary and every other level-less call failed, for as long as
the setting stood.

The refusal is now learned whichever level put the field on the wire, by
the same path as any refused field: the call is asked once more without it
inside its two-request budget, later calls send no field for that endpoint
and model, a model seen reasoning unasked keeps its thinking room, and one
ai.provider.reasoning_field_refused line says so per lesson instead of a
failure per call.

Refs #1279
2026-10-25 01:01:02 +02:00
Marc-André Bombeck 05cb5cf7e9 fix(insights): stop a briefing's model calls once its bound gives up
The nightly warm, the forced warm and the morning refresh bound a
comprehensive generation and abort it when the bound fires, but the abort
only reached the generator's final check before the cache write. The
provider calls never saw it: the call in flight ran on detached, and a
reply that landed after the bound could still start the JSON-shape,
grounding or screen retry, each a full generation nobody was waiting for.

runBriefingCompletion now takes the caller's signal, hands it to the
provider call, and reserves and sends nothing once it has fired. The
generator passes it at all five of its calls (the generation, its three
retries and the phrasing re-roll). The on-demand route passes none on
purpose: it finishes and writes its briefing after the browser stopped
waiting.

Refs #1279
2026-10-25 00:54:00 +02:00
Marc-André Bombeck 0671cb6e0a chore: hold the size and version-stamp ratchets
Tighten the warm-up comments in the summaries slice and the comprehensive
aggregator so neither file grows past its line baseline, drop a release
opener from the sharing-surface note, and record the version stamp that
left the settings section list.
2026-10-25 00:51:33 +02:00
Marc-André Bombeck 0f9ee08a48 test(sync): give the module-gate double the mirror walk's token lookup
The walk now asks the token table first; the route test's Prisma double
answers it with no switched token.
2026-10-25 00:51:32 +02:00
Marc-André Bombeck cc284c4307 fix(jobs): log a failed preload report instead of swallowing it
The sweep keeps going when reporting a load failure itself throws, and
now says so on stderr; the error-level guard refuses a silent catch in
src/lib/jobs. The preload test reads the worker source through
readSource with anchors.
2026-10-25 00:51:32 +02:00
Marc-André Bombeck 2ae23f4ca8 refactor(settings): remove the preview-feature opt-ins
The per-account preview switch was kept for the topic overview pages,
which were dropped before they shipped. Its one flag was stored, served
and backed up, and read by nothing; the column-reader audit listed it as
a pending consumer that will not come.

Removed completely:

- `/api/settings/preview-features` (GET, PATCH) is retired: it answers
  410 Gone with `meta.errorCode: route.retired` and `removedIn: 1.46.0`,
  and the contract publishes it under Retired. The route, its validation
  schema, the `PreviewFeatures` OpenAPI schemas and the settings module
  are deleted.
- Migration 0485 drops `users.preview_features_json` under a bounded lock
  wait, with the retry marker.
- The backup no longer writes the field. A file from v1.44 or v1.45 that
  carries it still restores: the schema accepts it and no codec reads it,
  so nothing is refused or written for it. The backup-parity goldens
  lose the field and apply one setting fewer.
- The data-wipe plan entry and the sharing-surface actor entry go with
  the route (frozen count 273 -> 272).

No client build reads the route: neither iOS repository nor any local
checkout references it.
2026-10-25 00:44:15 +02:00
Marc-André Bombeck 402a4a546c fix(admin): lift the bench when the operator fixes the shared provider
The health ledger records failures per account and provider, and since
background work leaves out a benched provider, an operator who fixed the
instance-wide provider in the admin AI settings still saw every account's
background work sit out the pause the broken setup had earned, up to a day.
Only a person's own provider settings and the Codex sign-ins forgot the
record.

A successful save of the admin AI settings now forgets what the ledger
recorded for admin-openai on every account and closes the status notes'
negative windows, the same reset a person's own save makes for theirs
(clearStatusNegativeWindows without an account covers every record). The
ledger comments that still said a benched row only deprioritises now say
what it does for background work.

Refs #1279
2026-10-25 00:42:20 +02:00
Marc-André Bombeck f1163d5efb fix(insights): do not count a status run the bench sent nothing for
A status run whose whole chain was benched for background work sent no
request, yet the caller opened the note's negative window as for any
failure and raised its failure streak. The streak doubles the window up to
two hours, so a note kept waiting long after its provider recovered.

AllProvidersFailedError now says when no hop sent anything (sentNothing:
every provider benched, or refused by the operator cost cap). The status
chokepoint then holds the note for the first window without touching the
streak (holdStatusNoteWhileBenched), and the caller's own failure write
lands inside that window and neither moves nor counts it. A run that
reached a provider counts as before.

Refs #1279
2026-10-25 00:40:23 +02:00
Marc-André Bombeck 5cfaf4869d fix(medications): give back the doses an End took when the End is undone
End course tombstones the pending placeholders at or after its instant.
Moving or removing the end clears the instant, but the tombstoned rows
kept their unique slots, so the today projector treated them as present
and minted nothing: the later dose of that day was neither listed,
reminded nor missed.

The window's one writer now revives the placeholders the End stamped
with its instant once that instant is gone, when the slot is inside the
courses as they stand and no live row holds it.

Refs #1280
2026-10-25 00:37:35 +02:00
Marc-André Bombeck f3b9329fb2 fix(sync): mark the read caches stale after a provider backfill
`afterProviderBackfill` refolded the rollups over the touched range and
re-warmed the status cards, but never marked the analytics caches stale,
so after a Fitbit or Google Health history backfill the dashboard and
analytics reads served the pre-backfill body until its TTL ran out. The
per-write tail has always done this through `afterMeasurementMutation`.

The backfill tail now calls `invalidateUserMeasurements` with
`touchedFrom` set to the first touched day, after the fold and also when
the fold fails, since the rows changed either way.
2026-10-25 00:35:40 +02:00
Marc-André Bombeck c6bd27f06f fix(jobs): load every job handler once at worker boot and report failures
Since handlers load on their queue's first job, a module the image
cannot load no longer fails the worker's boot. The worker comes up
healthy, the queue burns its retries when it first has work, and a
one-shot job (a restore, an import tail) ends failed with nobody told at
deploy time.

After binding every queue the worker now runs `preloadHandlers` in the
background, without awaiting it, so readiness is unchanged. It loads
each handler once through the same memoised loader its queue is bound
to, so a module that loads stays loaded for the first job. Each failure
is reported on the `job.run.failed` line at error, with the queue's name
and `reason: "handler_load_failed"`, plus the worker error report
(stderr and GlitchTip). A summary line on stderr names every queue that
failed. The sweep never throws, and a failing report does not stop it.

docs/ops/deploy.md says what to look for after a deploy.
2026-10-25 00:35:36 +02:00
Marc-André Bombeck f7b89f96e1 perf(sync): walk the mirror domain from the switched tokens
`/api/sync/changes` read the `measurementMirror` domain with one query
that ordered every token-linked reading by the joined token's
`mirror_changed_at`. No index on `measurements` can serve that order, so
every poll read and sorted every row of every token ever switched, even
when nothing had changed past the client's watermark.

The walk now reads the account's tokens with `mirror_changed_at` at or
past the watermark first. Nearly every poll finds none and stops there.
Otherwise it walks each change stamp's readings by
`(ingest_token_id, id)`, the order the index holds, with tokens that
share a stamp walked together so rows interleave by id as before. Rows,
order, page boundaries and the cursor are unchanged; the wire contract
is untouched.

EXPLAIN (ANALYZE, BUFFERS) on a seeded Postgres 16: one account, a
token with 300,000 readings switched at T0, a second token with 50
readings switched at T1, 900,000 untokened readings, page size 200.

  steady-state poll (watermark past every switch)
    before: 53 ms, Merge Join over ~300,000 index rows, 10,827 buffers
    after:  0.07 ms token lookup + 0.02 ms resume probe, 10 buffers
  first page after the switch
    before: 51 ms, top-N heapsort over ~300,000 rows, 10,948 buffers
    after:  0.08 ms + 0.17 ms index scan on (ingest_token_id, id)
  resume in the middle of the big token
    before: 53 ms
    after:  0.02 ms + 0.20 ms, Index Cond on (ingest_token_id, id)

A new integration file pages both forms side by side (tied stamps, a
watermark inside a tie, soft-deleted rows, another account's token,
page sizes from 1 to 100) and asserts identical pages, and that a poll
with nothing switched past its watermark reads no reading at all.
2026-10-25 00:35:10 +02:00
Marc-André Bombeck da2cd62876 fix(rollups): register read-path warm-ups with the background registry
The mood rollup warm-up on the dashboard summary was started with a bare
`void`, so `settleBackgroundTasks()` never saw it. On a stale account the
warm-up started a full recompute that could still be writing when the
statement counter closed its window, and the hot-route budget read 30
against 28 depending on where it landed.

Every read-path start of that shape now goes through
`trackBackgroundTask(runDetached(...))`: the measurement and mood rollup
warm-ups (seven call sites), the dashboard's today-briefing warm, and the
two delegated-access bookkeeping writes in the record guard. A guard
refuses a bare `void` on any of them.

`trackBackgroundTask` now wraps its handle in `Promise.resolve`: a test
double that returns nothing used to leave `undefined` in the set, and
settle spun on it at full CPU.

The budget fixture mints the mood rollups in its seed, as it already did
for the measurement rollups. Eight serial runs of
query-budget-hot-routes read the same counts every time (dashboard
summary 28, snapshot 99, day 47, health-status 59, correlations 43).
2026-10-25 00:33:55 +02:00
Marc-André Bombeck b0ea3b1654 fix(ai): charge what failed hops spent when a later hop answers
A chain whose first provider reasoned without answering and whose second
one answered billed only the answer: result.tokensUsed is the answering
call's alone, and the failed hop's spend, already reported on its
FallbackHop, was dropped by every caller that reconciles the day's ledger.

The raw runners now report tokensSpent on success too, the sum the failed
hops before the answer reported. The briefing, status, Coach (no-tools path
and every tool-loop round) and medication-extract reconciles charge it on
top of the answer. On failure, the Coach turn ledger's close keeps what the
failed chain reported spending instead of refunding everything, and the
medication-extract and document-chat routes charge it too, as the status
and briefing paths already did. Document indexing resolves a single provider
and already charged its failed call.

Refs #1279
2026-10-25 00:33:18 +02:00
Marc-André Bombeck 186fc30fcc test(insights): build the benched re-run request without casts
The benched-provider case for "Re-run analysis" passed its ledger row and
its request through `as never`, which the test-realism ratchet counts. It
now seeds a complete provider_health row and sends a real NextRequest.

Refs #1279
2026-10-25 00:32:02 +02:00
Marc-André Bombeck f0b6854a91 fix(medications): offer a new course only on a day the start accepts
canStartCourse answered true on the day a course was ended with End
course, while a course starting that day shares its last day and the
start refused it with 422 overlap. The start is now offered from the
day after the last course's end, the first day validation and the
engine accept.

A course ended at an instant is over for every rule that asks about
today, whatever the zone it is read from: after a move west the course
rows read CURRENT while the medication's own status read ENDED. The
course rows, the coach summary, the overlap rule and the previous-end
read take the instant-aware reading.

Refs #1280
2026-10-25 00:26:55 +02:00
Marc-André Bombeck 8b66f647cb fix(compliance): carry endedAt into the streak and per-window timelines
calculateCompliance and buildTimelineForWindow built the cadence
engine's context field by field and left out endedAt, so a dose slot
after End course counted as missed in the streak and the timeline while
the rate left it out.

Both now take the context from one builder, endedAt is required on
ComplianceMedicationContext and CadenceEngineContext, and the course
anchor guard holds every builder of either type to naming it.

Refs #1280
2026-10-25 00:26:55 +02:00
Marc-André Bombeck 8fd1d543a6 fix(medications): let a manager end a course and serve End only to who may press it
POST /api/medications/{id}/courses/end resolved the caller with
requireAuth(), so a delegate managing the record got 403 while the
medication PUT that used to carry the end date admits MANAGE, and the
reads still served canEndCourse true. The action now resolves the record
at MANAGE over medications and files the previous end (C4) with the
count of tombstoned placeholders.

The medication reads narrow canEndCourse and canStartCourse to the
caller: End needs MANAGE, starting a course stays owner-only. The list
cell stays per record and the narrowing runs after it. Create and edit
answers pass the grant they were resolved under instead of null.

Registered in the sharing surface guard, the MANAGE handler matrix and
the activity feed's verb lines.

Refs #1280
2026-10-25 00:26:55 +02:00
Marc-André Bombeck bdfcea5e7c fix(insights): let "Re-run analysis" reach a benched provider
The on-demand briefing (POST /api/insights/generate) runs through the
briefing chokepoint, which named its chain walk "job" for the background
share of the day's ceiling. Since background work leaves out a provider the
health ledger benched, a person pressing "Re-run analysis" got no request at
all: the call failed with a connection refusal that never happened, and a
refused credential was never reported back.

The chain runners now take the walk apart from the budget surface: a call
can be rationed as a job and still have a person waiting on it
(interactive). runBriefingCompletion takes the flag and the on-demand
route's calls (the first pass and the grounding retry) pass it, so they keep
the job share and walk a benched provider at the tail, as background-bench
promises for interactive walks. Every other job-surface caller is a worker
or is enqueued; the status, narrative and warm routes only read and queue,
and document reads and lab scans resolve a single provider without a chain.

Refs #1279
2026-10-25 00:24:51 +02:00
Marc-André Bombeck b90980f2a2 fix(ai): keep a cut answer from a model never seen reasoning
A reply that ran into the token ceiling with unlabelled text was read as
reasoning the server did not separate whenever the ceiling held room for
reasoning, which is every call that carries a level, Off included. A model
that does not reason at all (llama3.1) lost its own truncated answer: the
call failed as reasoning_only, the briefing never reached its JSON-shape
retry, the Coach named the wrong error, the failure counted toward the
bench, and the client learned to ask with "none" for an hour.

Such text is now reasoning only once the endpoint and model have shown that
they reason: a reasoning field or a think tag in an earlier reply, or a
lesson that says so (knownReasoner). A sighting on a request that asked to
reason speaks only for requests that ask too. Otherwise the text is the
answer, cut off, with finishReason "length".

A lone closing </think> now splits a reply only where the chat templates put
it, at the start of a line, or anywhere once the model is known to reason. An
ordinary answer that mentions the tag in a sentence stays whole.

Two expectations encoded the old rule and move: the Coach loop case whose
first reply is unlabelled template-opened text from a model never seen
reasoning, and the Local case that read a never-seen model's cut text as a
thought. The streamed think-block splitting moved into local-reasoning.ts.

Refs #1276
2026-10-25 00:20:35 +02:00
Marc-André Bombeck 6e4d26c0ce test(ai): find the wire clients that dial through the request budget
The call-timeout guard found its wire clients by a direct safeFetch call.
The Local and the OpenAI-compatible gateway clients now send through the
call's request budget (hops.fetch), so the guard looked at two clients and
failed its floor. It finds both spellings again.

Refs #1279
2026-10-25 00:17:10 +02:00
Marc-André Bombeck 73cee35917 fix(ai): bound one completion call to two requests
A gateway that refuses reasoning_effort outright (LiteLLM in front of
Ollama: "does not support parameters: ['reasoning_effort']") in front of a
model that reasons without answering sent requests until something outside
stopped it. The answerless rule learned to ask with "none", the refusal of
that "none" was learned as "send no field" over it, the next request came
back answerless again, and each lesson undid the other.

Each call of the Local and the OpenAI-compatible gateway clients now carries
one request budget through every repeat (request-hops.ts): at most two
requests, after which the call ends with the failure it has. A structural
guard holds both clients to sending only through the budget and to handing
it to every repeat.

The refusal is now learned as what it is: an endpoint that refuses the
field, in front of a model already seen reasoning unasked, keeps both facts
(FIELD_REFUSED_REASONS_REGARDLESS), so it is never asked with "none" again
and keeps its thinking room. An answerless reply from an endpoint that
refuses the field is final. When the refused "none" was the repeat of an
answerless reply, the call fails as reasoning_only, which is what it ran
into. The refusal handling of both clients lives in wire-refusal.ts.

A status run that hits its bound now aborts the call it gave up on instead
of leaving it running detached.

Refs #1279
2026-10-25 00:11:42 +02:00
Marc-André Bombeck 3984ceb01c Merge remote-tracking branch 'origin/release/v1.46.0' into fix/status-parity-flake 2026-10-24 23:16:47 +02:00
Marc-André Bombeck 595a883440 fix(ai): write the provider health ledger on the app's clock
The ledger stamped last_failure_at, last_ok_at, next_retry_at and
updated_at with the database's NOW(), while every reader compares them
with the app's clock: the runner's skip hints and the background bench,
the admin provider-health card, the server-health tri-state and the
support report. A backoff or pause therefore sat wherever the skew
between the two clocks put it. With the app clock pinned, a five-minute
backoff lasted until the database's clock plus five minutes, months away,
or was over before it began.

Both writes now bind the instant they took as a parameter
(`$N::timestamp`, the naive UTC columns), the same one-clock rule the
summary reads follow. The escalating pause arithmetic is unchanged.

The pause integration test pins the app clock years behind and ahead of
the database's and checks the stored instants, a backoff that lifts after
five minutes, a pause that benches background work for its full length,
and a success the server health reads as recent.

Refs #1279
2026-10-24 23:16:24 +02:00
Marc-André Bombeck dbe05c958c test(insights): hold the health ledger still in the status parity harness
The batch golden lost a card's request about one run in eight. When the
batched call fails, every pending card falls back to its own call at once,
and each failure is recorded on the provider health ledger without being
awaited. Background work leaves a provider out from its third failure in a
row on, so a card that read the ledger after two of its siblings' writes
had landed was benched and never reached the model. Which card, if any,
was scheduling.

The stored notes were the same either way, so only the request list moved.
The parity harness now runs on a ledger that records and benches nothing;
the bench keeps its own tests.

Refs #1279
2026-10-24 23:06:00 +02:00
Marc-André Bombeck a35ff5f5ea Merge remote-tracking branch 'origin/release/v1.46.0' into fix/end-medication-contributor-base
# Conflicts:
#	src/__tests__/baselines/source-file-lines.json
#	src/__tests__/baselines/source-function-lines.json
2026-10-24 22:39:30 +02:00
Marc-André Bombeck ffe41941f1 test(contract): seed a deleted document so Recently deleted answers with a row
The GET contract run requires every list operation to answer at least
once with a row, and the new list of deleted documents had nothing to
list. The seed now ingests a second document and tombstones it the way
the delete route does.
2026-10-24 22:39:19 +02:00
Marc-André Bombeck 8649e0413f test(parity): add the course end fields to the medication route snapshots
The medication reads now carry `endedAt` and `canEndCourse`, and every
course its `endedAt`; the row dumps carry the new columns. Additions only.

Refs #1280
2026-10-24 22:37:43 +02:00
Marc-André Bombeck fd2c20dffa test(modules): list the course end route with its sibling course routes
Ending the running course writes the same course rows the other course
routes do, so it follows the same data-layer reasoning: the medications
module gates the surface, not the row store.

Refs #1280
2026-10-24 22:28:20 +02:00
Marc-André Bombeck 3df06b137d chore(ratchet): lower the size baselines the course end shrank
Refs #1280
2026-10-24 22:25:18 +02:00
Marc-André Bombeck 275367f6e9 feat(medications): end a course now through its own action
"End course" now posts to `POST /api/medications/{id}/courses/end`, which
ends the running course at that moment: its last day becomes today on the
person's clock and the instant is stored as `endedAt`, so a dose later today
is not due, not reminded and never missed, while one taken earlier still
counts. Pending placeholders already minted for later slots are tombstoned.
A medication without dates gets the course its window describes, ended now.

End stays its own verb instead of `PUT { endsOn }`: an end date set through
the PUT or the course editor keeps meaning the whole day, and the PUT keeps
accepting it on any course, since moving an end is a legitimate edit.

The list and detail reads serve `canEndCourse`, true exactly while a course
is running, and the action refuses with 409 `medications.course.notRunning`
when it is false, so no client can reopen an ended course or invent a
one-day course from a planned one by ending it. The web controls render End
from the served flag; the contributed tests keep their cases against it.

Refs #1280
2026-10-24 22:24:15 +02:00
Marc-André Bombeck 23a67f61e6 feat(medications): let a course end at an instant
A course's end date is a calendar day and counts in full: the doses of that
day stay due, reminded and expected until local midnight. That is right for
an end date set in the editor and wrong for "End course", whose dialog
promises that nothing more is due.

A course now carries `endedAt` (migration 0482, additive, nullable), the
instant it was ended on its `endsOn` day, projected onto the medication next
to `startsOn` / `endsOn` by the one course writer. From that instant:

- the schedule engine emits no slot, so a dose later that day is not due,
  not reminded, not projected onto the today list and never expected by
  compliance, while a dose earlier that day stays inside the course;
- the course reads ENDED (`courseStatusAt` / `courseStatusOn`), so the dose
  actions, `remindersActive` and the reminder gate stop at once, follow-ups
  of an earlier dose included;
- every engine context builder, compliance context, dose history, Coach
  snapshot, doctor report and the adherence storyline read it, through one
  select and one picker for the medication window.

Any write that moves a course's `endsOn` clears the instant, so an end date
chosen in the editor keeps meaning the whole day. Backups carry it in both
purposes and a restore projects it again. `courseStatusAt` moves next to the
other course rules.

Refs #1280
2026-10-24 22:20:06 +02:00
Marc-André Bombeck a773d839d4 Merge pull request #1281
End a medication course on the person's day, as a date, and offer End
only while the course is running (#1280).
2026-10-24 21:32:39 +02:00
Marc-André Bombeck 3f17b40092 Merge remote-tracking branch 'origin/release/v1.46.0' into fix/answerless-retry-bound
# Conflicts:
#	src/__tests__/baselines/source-file-lines.json
#	src/__tests__/baselines/source-function-lines.json
2026-10-24 21:31:24 +02:00
Marc-André Bombeck f6342ebca3 test(jobs): simulate an hour of background work against a model that never answers
Drives the status-card path the way an idle instance runs it: a data sync
every minute dirtying five cards, the production enqueue with its queue
name, singleton key and retry options, the queue's consumer, the card's
single call and fallback, the status chokepoint, the chain walker with the
health ledger's in-memory twin, the gateway client and the note store's
negative windows. Only the wire, pg-boss, the clock and the unrelated
Postgres reads are faked; the wire answers every request with reasoning
and finish_reason "length".

Against the code before this series the hour counted 151 requests, 150 of
them sent with "none", none charged to the day's ledger, and 300 when the
reasoning lesson did not survive from one job to the next (the pattern
behind the reported log). Now it counts six, charges all of them, sends
"none" once, and logs the pause once per window.

Refs #1279
2026-10-24 21:27:58 +02:00
Marc-André Bombeck ed9261264a test(jobs): hold every model-bound queue to bounded, backed-off retries
pg-boss gives a send that names no retry options the queue's defaults:
two retries, at once, without a backoff. A job that reached a model and
failed then ran again immediately, twice, against the provider that had
just failed it. Two model-bound queues still relied on those defaults:
the Coach memory refresh send and the nudge cron. Both now ask for the
same two retries, a minute apart and backed off.

ai-job-retry-bound-guard holds the rule: every send to a queue whose jobs
reach a model names retryLimit (at most 3) and, unless it is 0,
retryBackoff with a retryDelay of at least 30 s; every such cron carries
the same in its schedule options. The queues come from the model-run
sites the expiry guard already keeps complete (now a shared helper), plus
the job files that call a provider directly, which the new guard keeps
complete itself. A planted case runs the checker over sources shaped like
the tree before this change. The queue golden records the nudge cron's
new options.

Refs #1279
2026-10-24 21:22:08 +02:00
Marc-André Bombeck 86c2cb3360 fix(documents): stop the index-all walk when the provider keeps failing
The content-index backfill capped a run at 200 documents, but counted
only the ones it indexed. Against a provider that failed every call (a
model that reasons and never answers) the cap was never reached, so the
walk transcribed every un-indexed document in turn, one provider call
each, until the job's time ran out after more than two hours, and the
refunded failures never reached the day's token ceiling.

The cap now counts provider calls, the walk stops with reason
provider-failing after three failed calls in a row (one document the
model cannot read is still walked past), and a failed read charges what
its reply reported spending. The per-document read moves into its own
function.

Refs #1279
2026-10-24 21:22:01 +02:00
Marc-André Bombeck 17c1518b35 fix(insights): back a failing status note off, and let no path skip the wait
A status note whose generation stalled or failed opened a fixed five-minute
negative window, and only the read path honoured it. The ingest
invalidator counted only a stored note as fresh, so every data sync
re-enqueued each note whose last generation had just failed, and the
worker generated it again whatever the window said. With a model that
never answers, every sync bought another round of calls per note.

The window now grows with the generations in a row that ended without a
note (failure_streak, migration 0480): five minutes, doubling per failure,
up to two hours. A written or re-dated note resets it. A failure inside an
open window neither moves nor counts it.

runStatusCompletion waits the window out before any provider work,
whichever path selected the note again (a sync, a warm pass, a queued
job), and the ingest invalidator no longer enqueues a note inside its
window. A derived-score assessment that fails now opens the same window.
The person's own regenerate closes the record's windows first, and saving
the AI provider settings closes them and forgets the provider's health
rows, so a fixed setup does not sit out a pause the old one earned.

The window code moves to status-negative-window.ts; the status parity
golden records that the mood note screened earlier in the same minute is
no longer generated again inside its window.

Refs #1279
2026-10-24 21:08:45 +02:00
Marc-André Bombeck 94658e9af2 Merge remote-tracking branch 'origin/release/v1.46.0' into feat/documents-recently-deleted 2026-10-24 20:57:49 +02:00
Marc-André Bombeck 52fa5a06b1 feat(documents): let the picker bring back a deleted document
The document picker showed a document deleted in HealthLog as "Deleted
in HealthLog" and would not let it be ticked, so a person who deleted an
imported document by mistake met a dead end exactly where they would look
for it again.

Such a row can now be ticked, and the import sends revive: true for it.
A document still in Recently deleted is restored through the shared
restore, nothing is fetched, and the row reads "Restored from Recently
deleted". One the purge already removed is fetched and stored again, and
only once it is stored does the ledger forget its key, so a failed fetch
leaves the deletion standing. Without revive the route answers "deleted"
as before, and the token upload has no such switch: a Paperless workflow
or the import script re-sending a deleted document is still refused,
whether it is in Recently deleted or long purged.

The search names the document a deleted key still points at (documentId,
null once purged), so the row can say "In Recently deleted" rather than
"Deleted in HealthLog". The outcome gains "restored", sent only in answer
to revive. The row logic moves to picker-rows.ts.

Refs #1275
2026-10-24 20:55:19 +02:00
Marc-André Bombeck 4752115d7d feat(documents): add Recently deleted for deleted documents
Deleting a document only stamps a tombstone and the purge removes it
after 30 days, but the only way back was the undo toast, gone a few
seconds after the delete. Recently deleted lists the deleted documents
of the record with type, filing date, where an import came from, when
each was deleted and how long it has left, and offers Restore, Restore
all and Delete now.

GET /api/documents/inbound/deleted lists them (owner-only, capped at 200
with the list-cap meta). POST /api/documents/inbound/{id}/purge removes
one deleted document for good through the same function the nightly
purge runs, so its import keys and aliases still move into the ledger
and an importer re-sending it is still answered "deleted"; a live
document answers 409 documents.inbound.notDeleted. The usage read gains
deletedCount, 0 under a grant. The restore route and bulk restore
share one restore, which now also clears ledger entries for the
document's own keys.

The vault header shows a quiet "Recently deleted (n)" link only while
something is in it, and the delete toasts and confirmations say that
deleted documents stay there for 30 days. The purge's queue facts and
grace window move to document-purge-queue.ts so the worker registry
still loads none of the work.

Refs #1275
2026-10-24 20:51:35 +02:00
Marc-André Bombeck ba4774e48a fix(ai): keep background work off a benched provider and charge failed replies
The health ledger already counted every failed call and paused a provider
that kept failing, with a pause that grows. A paused provider only moved to
the tail of the chain, though, and the tail is still walked: a chain whose
only entry answered every call with reasoning and no answer was called by
every background job regardless.

A walk for background work (surface "job") now leaves out every provider
the ledger has benched until its window lifts; the local floor only once
it is paused, past a brief backoff. One ai.chain.background_paused line
per window says so. A person's own call still reaches the provider at the
tail and reports its own error. The ledger is in Postgres, so the bench
holds across workers and restarts.

The status, briefing and document-index paths refunded a failed call in
full, so background spend on replies without an answer never reached the
day's token ceiling. A failed chain now carries the tokens its hops
reported (tokensSpent) and those paths charge them.

The two clients share one answerless step (askAgainUnreasoned), which
keeps both inside the source size ratchet; its baselines go down.

Refs #1279
2026-10-24 20:42:42 +02:00
Marc-André Bombeck 79cd9ab493 fix(ai): stop asking a model with none once none did not help
The #1267 rule asks a reply that reasoned without answering once more with
reasoning_effort "none", and asks every later call that way for an hour.
That is what Gemma 4 on Ollama needs. gpt-oss behind vLLM accepts only
low / medium / high and ignores "none", and a GLM chat template reads an
effort it does not know as its maximum, so for those models the repeat and
every later "none" were wasted requests or longer thoughts.

When a reply sent as "none" comes back without an answer as well, the
Local and gateway clients now learn that too: later calls go out as the
call asks, with the thinking room #1276 keeps, and an answerless reply is
final. One call costs two requests at most and one after that, and the
log gets one line for each lesson instead of one per call.

A failed answerless call now carries what it cost on its error, and a
repeat that answers reports both requests, so the day's token ledger can
charge them. The learned lessons are pinned on globalThis, so the Coach's
route handlers and the worker's jobs share one copy per process instead
of learning the same thing twice.

Refs #1279
2026-10-24 20:24:50 +02:00
Marc-André Bombeck b716ee590b test: fold the two one-pdf.js guards into one
Two guards with the same file name froze the same failure from two sides:
one stopped a runtime import of pdf-parse, the other pinned one pdfjs-dist
in package.json, the lockfile and the checkout, and allowed only the shared
loader to import it. They are one guard now. It strips comments before it
matches, so prose naming a package does not count, walks every runtime
extension and skips __mocks__ as well as __tests__, treats a type-only
import of pdfjs-dist outside the loader as an import, and keeps the import
shapes each side's self-test proved, including the near-miss name that must
not match.

Refs #1277
2026-10-24 20:16:07 +02:00
Marc-André Bombeck 1fd34e4d74 test(documents): keep one pair of one-process order tests
Two pairs of tests ran a text read and a rasterization in one process,
one file per order. The pdfjs-one-version pair stays; the other pair and
its shared helper go. The kept pair now first asserts that nothing in the
process has claimed the pdf.js worker yet, so a file that lands in a shared
process fails instead of passing on an order it never ran, and it mocks the
failure signal the text read can emit.

Refs #1277
2026-10-24 20:16:02 +02:00
Marc-André Bombeck 540c6bfaeb refactor(documents): read the text layer through the one shared loader
The text read and the rasterizer each opened pdf.js on their own: the text
read with bare options, the rasterizer through src/lib/documents/pdfjs.ts
with the standard font, CMap and wasm directories. The loader now serves
both, so a CID font with a predefined CMap is mapped to characters on the
text path too, and there is one set of options to keep in step.

The loader is typed with pdf.js' own declarations instead of a hand-written
shape. That drops isEvalSupported, which pdf.js 6 no longer has and which did
nothing. Only the render slice the DOM-typed declarations cannot describe
(the native canvas factory) keeps a named local type in the rasterizer.

The run joiner with its named steps moves into the loader as joinTextRuns,
and readPdfText is its one page walk; the second layout copy that mimicked
the previous library's tabs and blank lines is gone. extractPdfText keeps
its outcomes and page cap on top of it, and a failed read still says what
pdf.js threw (documents.local_extract.failed). A test draws a word in two
fonts into a real PDF and reads it back whole.

Refs #1277
2026-10-24 20:16:01 +02:00
Marc-André Bombeck d3621617c6 Merge pull request #1278
Read a PDF's text with the app's own pdfjs (#1277).
2026-10-24 20:10:04 +02:00
haritowa 5e8fb126a3 refactor(medications): hand the lifecycle controls the medication
LifecycleManageBody takes the medication (id, name, active, course
status) instead of one prop per field. The call site in
medication-detail-tabs.tsx shrinks to one line, so the course status
reaches the controls without growing a file the size ratchet holds.
2026-10-24 17:53:18 +00:00
Marc-André Bombeck 1ea14e9100 fix(documents): index once more the PDFs a failed local read left out
While two pdf.js versions broke each other, a local text read after a
page render failed and was recorded as decrypt-error, and a scanned PDF
that had to be rasterized for the provider was recorded as raster-failed.
Neither has a content index, and on an instance without an AI provider
nothing offers to index it again.

Migration 0479 clears the attempt time of exactly those PDFs and keeps
their reason, a state no index attempt leaves behind. A new boot
discovery, document-index-retry, enqueues the upload's auto-index job for
each such row, so it runs the same provider-first, local-fallback decision
under the same consent rules; a deferred import stays local because its
row says so. The attempt writes its time back, so every document drops out
after one try, and one that really cannot be read records its reason again
and stays settled. An account with the documents module off keeps its
marks until the module is on.

An integration test seeds both kinds of damage, runs each migration's
statement and only what a boot runs on its own, and sees a preview and an
index appear, while a blank page, a broken ciphertext and a file that is
not a PDF settle after one retry and are not found by the next boot.

Refs #1277
2026-10-24 19:50:20 +02:00
Marc-André Bombeck 98fb6d81c0 fix(documents): render previews that failed to rasterize once more
While two pdf.js versions broke each other, every PDF preview rendered
after a local text read failed and was recorded as raster-failed, which
the thumbnail backfill treats as final. Migration 0478 deletes those
failure rows, so the boot-time thumbnail backfill finds each document
again and renders it once. A stored failure cannot tell the clash from a
page that really renders blank; such a page fails once more and settles
with one attempt. Only raster-failed is cleared: the clash surfaced as a
failed rasterization, which the thumbnail path maps to that reason alone.

Refs #1277
2026-10-24 19:50:19 +02:00
Marc-André Bombeck 8e216a8d4c test: fail when a second pdf.js could load in the server
The lockfile must resolve exactly one pdfjs-dist, the version package.json
pins, and no package may depend on it except the app; every lockfile line
naming pdfjs-dist is either understood or reported. The checkout must
resolve that version from the working directory, and no production module
may import pdfjs-dist except src/lib/documents/pdfjs.ts, nor pdf-parse at
all. Run against the previous lockfile it reports the 5.4.296 dependency
and the old override; an extra import in the rasterizer fails it as well.

Refs #1277
2026-10-24 19:35:21 +02:00
Marc-André Bombeck 30c96ff591 chore(deps): drop pdf-parse and the second pdfjs-dist it carried
Nothing imports pdf-parse any more, so its pdfjs-dist 5.4.296 and the
older @napi-rs/canvas that came with it leave the lockfile. The override
that kept that transitive copy past a renderer advisory has nothing left
to move and goes too. The standalone tracing stops naming pdf-parse; its
pdfjs-dist glob now matches the one copy the app pins. The Dockerfile and
canvas guard comments describe the second copy as history.

Refs #1277
2026-10-24 19:32:20 +02:00
Marc-André Bombeck 0b1757ff6f fix(documents): read the text layer through the same pdf.js that renders
Under Node, pdf.js runs its worker in the server process and keeps it on
a global, and whichever copy of pdf.js opens a document first owns that
global until a restart. The local text read went through pdf-parse, which
pins its own pdfjs-dist 5.4.296; the rasterizer uses the app's 6.4.299. A
text read first made every later rasterization fail with "The API version
does not match the Worker version" (thumbnails raster-failed, the vision
read of a scan 422), and a rasterization first made every later local text
read fail, which left the document out of the search index with nothing in
the logs.

src/lib/documents/pdfjs.ts is now the one place pdf.js is loaded and
opened, with one set of options (asset directories, no eval, no system
fonts). The text read walks the pages with getTextContent and lays the
text out the way pdf-parse did with the options it was called with: line
breaks, tabs between far-apart items, a blank line after each page, the
200-page cap. Text extracted from the PDF fixtures and from generated lab
tables, encrypted files and doctor reports is identical before and after.

A failed text read now emits documents.local_extract.failed with the
error pdf.js threw, and annotates the index event with it, instead of
returning a bare error.

The report tests read PDF text through the same reader. Two tests, one per
order, run a text read and a rasterization in one process; both failed
before this change.

Refs #1277
2026-10-24 19:32:13 +02:00
haritowa fa20b1fe3a refactor(medications): give End its own component
End and its confirmation now live in EndCourseControl, which renders only
while the course runs. When End stops being offered, the open dialog and
its state unmount with it, so the state no longer has to be reset during
render.
2026-10-24 17:04:26 +00:00
haritowa 9b8c763901 refactor(medications): tidy the End guard after review
The course-status prop uses the shared CourseStatus type instead of a
spelled-out union, and the End confirmation's open state is dropped when
End stops being offered, so a dialog closed by a refetch cannot pop up
by itself once a later course offers End again.
2026-10-24 16:57:21 +00:00
haritowa 92911283be fix(medications): offer "End" only for a course that is running
Ending sends today as the end of the medication's latest course. While
the PUT was refused this did nothing; now that it succeeds, pressing End
on a course that had already ended reopened it up to today (doses due
again, the days between counted as missed), and on a course that had not
started yet it became a one-day course today.

LifecycleManageBody now takes the server-resolved courseStatus and shows
End only while the course is CURRENT (a medication with no dates is
CURRENT). Pausing stays available whatever the course.
2026-10-24 16:57:20 +00:00
haritowa 19ae91f80a fix(medications): end a course on the person's day, as a date
"End medication" sent new Date().toISOString(), a full instant, against
an endsOn that only accepts YYYY-MM-DD, so every press answered 422 and
the toast said the medication could not be ended. The instant's date was
also the UTC one, a day off around midnight for anyone not on UTC.

The button now sends the person's calendar day in their timezone
(userDayKey), the same day key the medication card uses.
2026-10-24 16:57:20 +00:00
Marc-André Bombeck 567cb19afc Merge remote-tracking branch 'origin/release/v1.46.0' into fix/coach-reasoning-gateway 2026-10-24 18:48:45 +02:00
Marc-André Bombeck 7697ac8970 refactor(ai): move reasoning helpers and the Coach error resolver into their own files
The reasoning separation grew three files past their size ratchet. The Local
client's reasoning collector and the new separation helpers move to
local-reasoning.ts, the gateway client's thinking room and reply separation
become two private methods, and the Coach's error-code resolver moves out of
chat-bubble.tsx into coach-error-key.ts, still re-exported from
message-thread.tsx. Code motion apart from comments that said when a branch
was added rather than what it does. The baselines record the shorter files.

Refs #1276
2026-10-24 18:47:04 +02:00
Marc-André Bombeck 1191e8416a test(labs): expect the minimum's thinking room on wires that cannot promise off
The lab-scan read asks every provider for no reasoning. On an
OpenAI-compatible gateway or a local server that "none" is a request the
model behind it may ignore, so these wires now add the low thinking budget on
top, the same rule the file already holds for a model that always thinks:
the answer keeps its full allowance either way.

Refs #1276
2026-10-24 18:47:03 +02:00
Marc-André Bombeck a61fe1c715 fix(demo): give the short-window blood pressure a shape to show
Every 90-day series in the seed is a mean-reverting walk, and around a
settled target that is flat noise. A 7-day trend line over the last 30
days of blood pressure drew a straight line, which is the view the demo
exists to show.

A busy stretch three to four weeks back now lifts blood pressure and
pulse and lets them settle again, the mood dips with it, and weekend
readings sit a touch higher. Blood pressure also pulls back towards its
target harder, so a seed no longer ends several mmHg above the band its
stored texts describe; today's reading settles on the days after the
stretch. The two stored texts that summarise the last weeks mention the
stretch.

An integration test runs the real seed and holds the 7-day mean to a
visible swing and today's reading to the settled side of it.
2026-10-24 18:28:19 +02:00
Marc-André Bombeck 31e107b96a fix(coach): say so when the model answered with reasoning alone
When every provider in the chain answered a Coach round with reasoning and
no reply, even after being asked once more without reasoning, the turn ended
with "could not reach a provider, please try again in a moment". The
provider was reached, and trying again in a moment changes nothing for a
model that cannot stop reasoning.

Such a chain now ends with its own code, coach.provider.reasoning_only, and
the drawer says the model only reasoned and never wrote an answer, and
suggests another model. The choice between credential_expired, rate_limited,
reasoning_only and unavailable moved into one function beside the other turn
error rules, where it is tested on its own. An un-wrapped reasoning-only
error maps the same way. Clients that do not know the code keep showing the
generic provider message.

Refs #1276
2026-10-24 18:20:41 +02:00
Marc-André Bombeck c9673883b1 fix(ai): run gateway tool calls whatever finish reason comes beside them
The Coach loop runs a round's tool calls only when the round finished with
"tool_calls". The Anthropic and Codex clients already derive that from the
calls themselves; the OpenAI-compatible client copied the server's
finish_reason, and vLLM, Ollama and LiteLLM can report "stop" beside a
parsed tool call. The loop then took the round as the answer, the call never
ran, and what the person saw was whatever text came with it.

A reply that carries tool calls now reports "tool_calls", as on the other
wires.

Refs #1276
2026-10-24 18:20:35 +02:00
Marc-André Bombeck 652dda050c fix(ai): keep a model's reasoning out of the answer
A gateway or local server in front of a model that always reasons (GLM
behind vLLM, for one) accepts reasoning_effort "none" and ignores it. Two
things went wrong with such a model.

Once it answered a round with reasoning alone, the #1267 lesson asked every
later call with "none", and the token ceiling followed the wire: "none" meant
no thinking room, so a Coach round got its bare 600-token answer budget. The
model spent that thinking and stopped at finish_reason "length" before it
reached a tool call or an answer, for the hour the lesson holds. A turn set
to Off had the same ceiling from the start.

Where the server did not separate the reasoning (no reasoning parser, or one
that falls back to content), the reply's content was the thought itself: an
inline <think> block, the text before a lone </think> when the chat template
opened the block in the prompt, or unlabelled text cut off at the ceiling.
The gateway client passed all of it through as the answer; the Local client
split a leading <think> block, and only for callers that asked to reason.

On these wires the ceiling now follows the call's level instead of the wire
effort: Off keeps the low room, a call sent as "none" keeps its own level's
room, and a call without a level gets the low room once the model has shown
it reasons unasked. It is a ceiling, not a charge; a model that does switch
off never touches it. Both clients now take reasoning out of content for
every caller: a leading or a lone closing think tag, and a reply that filled
a ceiling sized for thinking with unlabelled text and no reasoning field
beside it. What comes out goes to the reasoning channel when the caller asked
for it and nowhere else. A reply left without an answer takes the #1267 path
and ends as reasoning_only, never as the thought.

The loop test drives a Coach turn through the gateway client onto an
upstream that answers like the reported wire capture, with and without a
reasoning parser.

Refs #1276
2026-10-24 18:20:18 +02:00
Marc-André Bombeck 087d0011fc fix(deps): raise the floors for body-parser, fast-uri and undici 7
body-parser 2.3.0 (CVE-2026-111181, high) and fast-uri 3.1.8
(CVE-2026-110180, CVE-2026-110306) arrive under the MCP SDK on a
production path. undici 7.30.0 (CVE-2026-109869, CVE-2026-109841,
CVE-2026-109924) is the development-only copy under shadcn; the app's
own undici 8 is already past the fix. Each bound moves the flagged copy
and nothing else.
2026-10-24 18:11:21 +02:00
Marc-André Bombeck 6748a5b843 fix(demo): seed each reading with the source that writes it
The 90-day core series (weight, blood pressure, pulse, body fat, resting
heart rate, sleep, steps) were all tagged MANUAL. The measurements list
badges every row that is not MANUAL, so the newest weight and
blood-pressure rows carried no source while Settings > Sources named
Withings for those metrics (from the older history rows). The body
composition series claimed Apple Health for fat mass, fat-free mass,
muscle, water, bone and visceral fat, none of which HealthKit has.

Weight, body fat, blood pressure and its pulse are now WITHINGS with no
device type, the way the Withings sync writes them; resting heart rate
and the sleep aggregate are APPLE_HEALTH from the watch; steps are the
iOS daily total under its `stats:` key, so the legacy-step consolidation
leaves them alone. The Withings body-composition components move to
WITHINGS; lean body mass and BMI stay APPLE_HEALTH from the scale. The
waist stays MANUAL, since nothing syncs a tape measure.

An integration test runs the real seed and holds every Withings and
Apple Health row against the type maps those integrations ingest
through, and pins a device source on the newest reading of each synced
vital.
2026-10-24 13:11:07 +02:00
Marc-André Bombeck 2b9315ad8b fix(demo): stamp the baked briefing so the readers serve it
Every briefing reader serves `dailyBriefing` only when the payload's
`briefingGeneratedAt` falls on the reader's today. The demo seed wrote the
comprehensive payload without that stamp and without the language tag, so
on a fresh seed the advisor read returned no briefing, the dashboard
snapshot stayed "preparing", and a warm was enqueued that can only fail
against the placeholder key. The /insights card sat empty while the hero
said "Updated today".

The seed now writes the cache the way a generation does: the payload
stamped through `withBriefingGeneratedAt`, the cache stamp from the same
instant, and the `en` tag. The baked paragraph drops its "Good morning"
opener, since the hero already greets by the hour.

The hero's freshness caption dates the subtitle, so it now shows only
when the subtitle is a briefing. The cache stamp also moves when a warm
keeps yesterday's text, which the read then withholds; the caption then
sat under the generic fallback sentence.

`seed()` is exported behind a main-module guard, and an integration test
runs the real seed and reads its output back through the advisor read,
the dashboard snapshot, the status cards and the period narratives.
2026-10-24 13:07:46 +02:00
Marc-André Bombeck 59a6ee9d05 fix(insights): keep a stat value and its unit on one line
With the day panel docked beside the blood-pressure page (1280 px and up),
each half of the stat card is a narrow column, and its four cells still
went side by side because the step followed the window's width. Every cell
came out narrower than "112 mmHg": the systolic values broke onto two
lines, the two-digit diastolic ones next to them did not.

The stat strip now joins a value to its unit with a no-break space, and
each series is a container whose cells go four-up only when the series
itself has the room: from 28rem for a half-card column, from 36rem for a
full-width strip (where the 640 px window step put it, so the longest unit
written, mL/(kg·min), keeps a cell it fits in). The loading shell follows
the same steps. Every page with a stat strip (pulse, weight, the
HealthKit, lab and custom-metric pages) shares the component.

A component test pins the no-break pair and the container steps; the
day-view spec measures every blood-pressure value beside the docked day
at 1280 px and fails on the old markup.
2026-10-24 12:18:39 +02:00
Marc-André Bombeck 9cc77c79fc fix(analytics): cut every summary window on the request's clock
The summaries slice and the comprehensive aggregator took their JS windows
(the DAY-bucket bounds, the rollup slope anchors, lastSeen staleness) from
the process clock and their SQL windows (avg7, avg30, the month before, the
median, the 90-day canonical pick, the cold-path slope evidence) from
NOW(), the database's clock. One response mixed two clocks, so a reading
on a window's edge fell in or out with whichever host answered, and with
the process clock pinned the analytics parity walk changed its avg7 with
the hour it ran at.

Each read now takes one `now` and binds it into its statements
(`request-clock-sql.ts`): `$N::timestamp`, which keeps the driver's
offset-free UTC string as written in any session zone. The canonical-pick
helper takes its window as an instant plus an interval instead of reading
NOW(); the series and calendar readers anchor it on their own `since`, the
BP-in-target read on its 365-day start. The window-mean and live-slope
columns the two aggregates each spelled out now come from one builder.

The streak's local day series read its bounds through `::timestamptz`,
which follows the session zone: outside a UTC session today's reading fell
out of the streak. It reads them as UTC first.

The parity walks for analytics, the dashboard summary and the
comprehensive overview pin a fixed day instead of today: their records are
laid out from the anchor day, so the chronotype's work and free nights and
the weight-by-weekday points moved with the weekday the walk ran on. The
snapshots are unchanged. A new suite pins the clock to four times of day,
far from the database clock, with readings ten minutes either side of
every edge, on both read paths, in a UTC and an Auckland session.
2026-10-24 12:14:33 +02:00
Marc-André Bombeck cf7302b50a Merge remote-tracking branch 'origin/release/v1.46.0' into feat/v146-ai
# Conflicts:
#	src/__tests__/baselines/source-file-lines.json
2026-10-24 11:29:56 +02:00
Marc-André Bombeck a1ee2ac003 fix(documents): keep document reads inside their job's expiry
Since the lab staging reads a long report in parts, one auto-index could
make the transcription plus up to twenty reads of two calls each at the
person's response timeout, hours past pg-boss's fifteen-minute default,
which failed the job and retried it beside the reads still running. The
staging now takes the run's deadline as a schedule and plans only the
reads that fit before it. The index job is long, with an expiry above the
calls that run before the plan can say no, a stop on jobDeadline and a
lock per document; the document run hands the same staging its own
deadline, which moves the run's expiry as the reads are planned. The
document summary, one call and its reasoning repeat, gets an expiry above
that worst case.

ocrReadCallMs in the read core is the one per-call timeout both deadlines
divide by. The expiry guard now names every importer of the document and
lab read entry points with the queues that reach it.

The runtime guard's planted cases stripped every file's comments again on
each rescan, which took over a second alone and timed out under a full
parallel run; the stripped text is now memoised.

Refs #1274
2026-10-24 11:28:57 +02:00
Marc-André Bombeck 8b6b3bccce Merge remote-tracking branch 'origin/release/v1.46.0' into fix/trend-slope-span 2026-10-24 11:18:12 +02:00
Marc-André Bombeck c15ff8401b fix(analytics): withhold a trend slope when the window's readings cover too few days
A least-squares slope per day over readings minutes apart explodes: three
blood-pressure readings in one sitting, the only ones of the week, fitted
thousands of mmHg per day, and the dashboard tile printed seven days of it
as the 7-day trend. The 30-day fallback had the same flaw.

A window now earns a slope only when its readings fall on at least three
UTC days whose first and last lie at least half the window apart (4 days
for the 7-day window, 15 for the 30-day one). The rule lives once in
window-confidence.ts and gates every path that publishes a windowed trend:
trendSlope (summarize and its callers), the rollup composition and the
live REGR_SLOPE fallback of the summaries slice and the comprehensive
aggregator, and the per-bucket slope of folded week, month and year
buckets. Slope, direction and confidence are present or absent together.
The fit itself stays over individual readings, as the window means do.

The parity snapshots change only where the fixture falls short of the
rule: a weekly body-fat series has two days in its 7-day window, and a
ten-day oxygen-saturation series spans nine days of its 30-day window.
2026-10-24 11:13:25 +02:00
Marc-André Bombeck 96eeec6080 Merge origin/release/v1.46.0 into feat/v146-ai 2026-10-24 11:03:44 +02:00
Marc-André Bombeck 9460fb2b51 Merge remote-tracking branch 'origin/release/v1.46.0' into fix/v146-tablet-left-strip 2026-10-24 11:01:44 +02:00
Marc-André Bombeck ccd7ee38c9 fix(shell): let the rail take the left inset and mirror its account band
An iPad in a Safari tab laid the app shell out 10 px in from the left edge
in portrait. The shell padded that inset, so a strip of page colour ran
beside the rail from the logo band to the account row, and the rail's band
borders stopped short of the edge.

The desktop shell no longer pads the sides. The rail runs to the window's
edge and takes the left inset inside its own background, widening by it so
its content keeps its 4rem / 16rem; the logo band, the footer group and the
account band reach back over the inset so their lines meet the edge. On the
right the top bar and main already run to the edge. The phone shell keeps
padding both sides. The side insets are read through --safe-area-left and
--safe-area-right, so a test can set one.

The account band takes the logo band's height (4rem) with the avatar
centred in it, instead of 61 to 69 px of padding with more room below the
avatar than above; the home-indicator inset stays under the band.

tablet-shell.spec.ts sets a 10 px side inset and a 20 px home indicator and
checks that the rail owns the window's left column of pixels, that its band
lines start at x = 0, that nothing in it sits inside the inset, and that the
account band mirrors the logo band. shell-viewport.test.ts holds the routing.
2026-10-24 11:00:56 +02:00
Marc-André Bombeck 02d3d8b7bb fix(insights): let slow-model status notes and AI jobs outlast their calls
A status run (a metric card, the batched assessment, a derived score, a
workout line, a Coach memory pass) is one model call plus the one repeat
the Local and gateway clients send when a thinking model answered with
reasoning alone, each with the person's response timeout. The run's outer
bound was a single call, so the repeat was cut off, and the open card
stopped polling after 48 s, so a note from a model that needs minutes
never reached the page. The bound is now the per-call timeout times
STATUS_MODEL_CALLS, and the card's poll ceiling follows the published
response timeout (fast for the first 48 s, every 15 s after), only while
the route reports the note in flight; the fallback text stays meanwhile.
The status queue is exclusive, so the longer poll cannot queue a second
generation beside a running one.

The morning refresh is one comprehensive generation of up to five calls
in a row, with no outer bound and pg-boss's fifteen-minute expiry, so a
large setting had pg-boss fail and retry it while it was still running.
It now runs behind the same bound as the warms, and the job carries an
expiry derived from that worst case. The per-metric note, the workout line
and the Coach memory refresh get expiries derived the same way.

queue-runtime.ts gains a bounded class for these jobs: model time read off
model-time.ts, an expiry above it, carried on every send. A new guard
names every caller of runStatusCompletion and generateComprehensiveInsight
with the queues that reach it, and holds each such queue to bounded above
its runs or long under a run lock.

Refs #1274
2026-10-24 11:00:32 +02:00
Marc-André Bombeck 6259c56a85 Merge remote-tracking branch 'origin/release/v1.46.0' into feat/v146-truenas 2026-10-24 10:35:46 +02:00
Marc-André Bombeck 5a8f59e0c0 fix(documents): say when an extraction stopped short instead of blaming the copy
The document review now names the two outcomes the extraction can report
since it reads a long document in parts. A read that stopped at the
provider's output limit before a single fact closed shows its own
sentence (try a model that allows longer answers) rather than the
generic "Couldn't read the document". A run that staged facts but had a
part cut or not reached opens the review as before and adds a warning
that some values may be missing, so the list is checked against the
document. Both strings are in every locale bundle.

Refs #1257
2026-10-24 10:34:37 +02:00
Marc-André Bombeck 5a7bd95c7b fix(documents): read a long document through the lab scan's read core
Extracting lab values from a stored document still sent the whole text
in one call. A multi-page report produced more JSON than one call's
output ceiling, the answer was cut mid-fact, the parse failed twice, and
the read came back as 422 "Couldn't read the document. Try a clearer
copy." The lab scan had already learned to read the same report a page
or a part at a time, but each surface carried its own copy of the read,
so the document path never got it. It also ran under the shared 60 s
timeout, dropped every fact past 120 (or every fact when one did not
match the schema), and charged a flat reservation.

The read now lives once, in lib/documents/ocr-read.ts and ocr-plan.ts:
the per-read limits, the corrective retry, the salvage of a cut
answer's complete rows, the wire re-check and budget reservation before
each read, and the planning of a PDF a page at a time and text in parts
at line boundaries. The lab scan and the document extraction (the
extract route, its background run and the lab auto-staging) both read
through it, and the background run plans its reads on the same deadline
as a lab scan. The per-read budget moves to a shared metered budget.

The document path validates the answer fact by fact, keeps the lab
scan's row cap (400), and says why nothing came back: 422
documents.inbound.outputLimit when the reads stopped at the output
limit, 429 when the budget refused them, extractFailed only for answers
that were not the schema. A run whose reads were cut or not reached
reports `incomplete` on its result.

A guard holds the class: the output ceiling is set only in the core,
both surfaces read through runOcrReads, both runs share the deadline,
and a lab read and a document read go out under the same limits.

Refs #1257
2026-10-24 10:34:31 +02:00
Marc-André Bombeck 8d9386d73a docs(truenas): cover the Community train app, its backups, and when the catalog needs a pull request
The TrueNAS page now walks through the catalog install field by field, how
to generate the two secrets, where TrueNAS keeps the key and why that is not
a backup, a dump and restore from the postgres container shell, and updates
with forward-only migrations. The listing is marked as pending review, and
the install-via-YAML values stay for the time until it is live.

The deploy notes list what needs a matching truenas/apps pull request in the
same release cycle: a new or stricter required variable, a port or health
check change, a Postgres major or extension, and any other compose-shape
change.
2026-10-24 10:32:17 +02:00
Marc-André Bombeck c1c2ba766c fix(encryption): point the TrueNAS key backup step at the catalog app's Encryption Key field
The TrueNAS app in the Community train takes the key in its own form field,
not in an environment list. The step now names that field and keeps the
environment-variable wording for an app installed via YAML.
2026-10-24 10:32:16 +02:00
Marc-André Bombeck 8787d71d1c fix(ai): recognise the gpt-6 model family in every model gate
The ChatGPT Codex backend and the OpenAI API serve gpt-6-sol, gpt-6-luna,
gpt-6-astra and gpt-6.1-sol, but the reasoning, Chat Completions and vision
gates only knew the gpt-5 line. A gpt-6 model got no reasoning control and
no real "off", went out on the legacy wire with max_tokens and a
temperature that reasoning models refuse, and was denied image input.

The three gates now read one classifier, openai-model-family.ts, so a new
generation is taught in one place. Every GPT from 5.1 on, the gpt-6 line
included, is the family that takes effort "none"; every GPT from 5 on reads
images. The upstream catalog (codex-rs/models-manager/models.json) lists
image input for every gpt-6 slug and the same reasoning presets as the
gpt-5.6 line; the presets omit "none" for gpt-5.5 too, which accepts it.

The Codex slug ladder moves into codex-slug-chain.ts and gains gpt-6-sol,
gpt-6.1-sol and gpt-6-luna. gpt-5.6-terra stays first as the slug verified
against the backend; the catalog names gpt-6-sol as its upgrade, and a
rotation retires a whole line at once, so the gpt-6 rungs follow before
terra's own siblings. The ladder test now holds every rung to a recognised
reasoning family with a real off and image input. The OpenAI presets offer
gpt-6.1-sol and gpt-6-luna.
2026-10-24 10:11:16 +02:00
Marc-André Bombeck 3d47c1e6b4 test(guards): hold the whole regenerate route to its two model stages
The browser's abort for a forced regenerate waits three calls at the
response timeout, fewer than the five the background generator may make.
That is correct: the route runs its own pipeline, refuses an unparseable
reply or a screened payload instead of retrying, and never reaches the
background generator, so its longest run is the generation, the
grounding retry and the one reasoning repeat.

The guard only read two of the pipeline's files, so a retry stage added
anywhere else on the route would have lengthened the run past the
browser's abort unnoticed. It now sweeps the route and the whole
briefing-request folder for stages and for any call into the background
generator, and the advisor states why its count is the shorter one.

Refs #1274
2026-10-24 09:29:38 +02:00
Marc-André Bombeck 552510c654 fix(insights): keep polling for the briefing while the warm can land
The briefing read polls while the warm it enqueued runs, and it stopped
after ten polls (225 s) whatever the response timeout. A briefing that
landed later never reached the open page, which kept the empty card, or
the previous attempt's "timed out" hint, on screen until a reload.

The read's poll ceiling now follows the published response timeout over
the generation's sequential calls, never less per call than the
briefing's 180 s default, plus one interval for the queue to pick the
warm up. A settling regenerate and the overview body keep their ten
polls. A test walks the poll schedule against the warm's own bound for
every setting, and the client-abort guard holds the read to the derived
ceiling.

Refs #1274
2026-10-24 09:23:47 +02:00
Marc-André Bombeck 73e98c6ef5 fix(insights): give the briefing warm every call of its generation
The out-of-band warm the Insights page relies on bounded one
comprehensive generation at the account's response timeout plus 30 s.
A generation is up to five model calls in a row: the generation, the
JSON retry, the grounding retry, the outbound-screen repair, and the one
repeat a thinking model gets when it answered with reasoning alone. Each
call honoured the setting, but a local model needing 300 s per answer
under a 600 s setting met the 630 s bound in the middle of its first
corrective retry, and the briefing was dropped.

The bound is now the per-call timeout times COMPREHENSIVE_MODEL_CALLS
plus the headroom, for the nightly pass and the forced warm alike. A
test drives the real warm, generator, chain runner, Local client,
safeFetch and pinned dispatcher against a loopback server that answers
after 300 s; under the old bound it reports a timeout on the third
answer.

The response-timeout guard now names every outer bound around model
work and holds both briefing generators' stages to the call counts the
outer waits multiply by.

Refs #1274
2026-10-24 09:23:20 +02:00
Marc-André Bombeck 810a949d2b fix(ai): move the Codex slug ladder onto the gpt-5.6 line
The ChatGPT Codex backend now rejects every slug from gpt-5.5 down, so a
deploy without a CODEX_MODEL_FALLBACK_CHAIN override walked the whole
default ladder into "all configured Codex slugs were rejected". The
default now starts at gpt-5.6-terra, with its luna and sol siblings and
gpt-5.5 as the last rung.

The gpt-6 line stays out of the default: the reasoning, sampling and
vision gates do not recognise that family yet, and a test now holds every
default slug to a family the reasoning gate knows.
2026-10-24 08:06:33 +02:00
Marc-André Bombeck 685b46fa04 fix(ssr): keep a server render's query cache from outliving the request
Every server render left its request reachable for ten minutes after the
response was sent. Providers builds its QueryClient once per render, and
the app's defaults give queries a ten-minute gcTime. On the server each
entry the render built, disabled observers included, started a timer of
that length, and a Node timer keeps the async context it was created in.
That context is the request's: the response, the React Flight stream and
the compressed body. A heap snapshot after a run of unauthenticated
probes showed one 600 s query-core timer per request and the retained
Flight chunks, ServerResponses and gzip buffers all hanging from them:
about 150 KB of heap and 90 KB of buffers per not-found render.

TanStack Query already defaults gcTime to Infinity on the server for this
reason; an explicit value from the defaults or a hook overrides it.
AppQueryClient folds Infinity back in at defaultQueryOptions, the merge
point every observer, cache build and hydration passes through, so the
server cache goes with the client at the end of the request. The browser
keeps its configured lifetimes.

The retention test reproduces the chain without a server: a response
object held only by an AsyncLocalStorage store must be collectable after
the render's client is dropped, with a positive control on a plain client
that must keep it alive. A guard keeps client modules from building a
QueryClient themselves and allows only option-less clients elsewhere.
2026-10-24 02:12:31 +02:00
Marc-André Bombeck 3e8846929c test(guards): load no module graph inside a guard's test body
step-up-elevation-guard timed out under load in three separate gates
and passed every time on its own. One of its tests imported
@/lib/auth/step-up inside the test body to read the fresh-factor set,
and that module reaches the database client and the generated Prisma
client: the import alone ran against the test timeout, and how long it
took depended on the machine, not on the code under test.

The fresh-factor definitions (StepUpMethod, FRESH_FACTOR_METHODS,
isFreshFactorMethod) move to step-up-factors.ts, which imports nothing;
the guard imports it statically and the test is synchronous. Two more
guards did the same: next-image-optimizer-guard loaded next.config and
its plugins per test, and proxy-relative-redirect-guard loaded the
redirect helper with next/server. Both import statically now.

guard-dynamic-import-guard.test.ts parses every file of the guard
project with the TypeScript parser and fails on any import() call, so
a guard that needs a value imports it with the file or reads the
source; a test that must load late belongs to the isolated project.
2026-10-23 22:51:51 +02:00
Marc-André Bombeck 2357807706 feat(import): bring the account's own mood vocabulary along
A full backup carries the account's own mood tags, rated factors and
tag groups, and the JSON import left them out: a mood entry imported
into another account landed without every link to a tag the person had
made.

Before the entries are written, every key they or the file name is
resolved once. A catalogue tag or one the account owns is used as it
is; an own tag whose label the file states differently is kept, never
overwritten, and named in keptMoodTagKeys (keptMoodCategoryKeys for a
group). A tag the file defines and the account lacks is created in its
group, the group created the same way, within the account's caps of 50
tags and 12 groups. A key another account of the server holds comes
over under a key derived from the account and the file's key, so a
second import of the same file finds the copy instead of minting
another. The links are written through that resolution, own rated
factors included, inside the entry's transaction; anything unresolved
is still dropped and named. The import card names kept keys in all
nine locales.

The field-coverage guard now holds the backup's customMoodTags and
customMoodTagCategories to the import schema. The round trip moves a
backup to another server (the same seeded catalogue, none of the
account's rows), imports it into a fresh account and compares the
re-export, own tags, group and rated factor included; a second case
imports into another account of the same server, and a third keeps a
renamed own tag.
2026-10-23 22:41:36 +02:00
Marc-André Bombeck d1545cf554 refactor(jobs): keep the lane's hold grace private to the lane 2026-10-23 22:13:00 +02:00
Marc-André Bombeck 111047491e fix(medications): end every medication and intake read on the row id
Postgres returns rows that tie on an ORDER BY in no particular order,
and not the same one twice. The medication reads ordered by columns two
rows share all the time: every read that hands schedules on included
them with no order at all, medications were listed by createdAt alone
(one import writes many in a millisecond), intakes by scheduledFor (a
twice-daily medication puts every morning dose on one instant), and
"the latest intake" lookups by takenAt. A client read the same account
in a different order from one request to the next, and a latest-intake
lookup picked either of two rows.

Every findMany / findFirst on a medication table, nested includes
included, now ends its order on the id in the direction of its last key,
and schedules are read by window, then id (MEDICATION_SCHEDULE_ORDER, the
order the backup already wrote). medication-read-order-guard.test.ts
holds the tree to it. medication-read-order.test.ts writes tying rows in
the opposite order to the one each read promises, through the medication
list and today's intakes, and fails without the tie-breakers.

The parity suite's own state reader had the same gap: it sorts rows by
content with the ids blanked, and rows that tie then kept whatever order
the unordered read returned, so their <id:N> labels swapped under a
loaded run. Its reads are ordered by id, which for a cuid is the order
the rows were written in.

The JSON import's schema left the route, so its entry in the inline
schema strictness register goes; the schema module says why its objects
are not strict.
2026-10-23 21:54:25 +02:00
Marc-André Bombeck 7936ab9a57 feat(admin): say what a queued restore is waiting for
A restore never runs beside a backup pass or another large job: the
heavy lane puts its delivery back in the queue for a minute at a time.
The job's own row says queued for the whole wait, and the console said
the restore starts "as soon as the background worker picks it up",
which read as a stuck worker while a backup ran.

The status routes now ask the queue. A delivery the lane put back sits
in created with a start_after ahead of now, and the active row of a
heavy queue is what holds the lane. The job view carries
waiting: "backup" | "busy" | null, and the console says it waits for
the running backup, or for another large job, and since when it has
been queued, in all nine locales. A queue that cannot be asked answers
no wait and is reported at warn.

The job view and its reads move to backup-restore-view.ts, which keeps
the worker module under its size baseline.
2026-10-23 21:43:55 +02:00
Marc-André Bombeck 6a87810dbf fix(jobs): take the heavy lane from a holder that outlived its expiry
A hold ended only when its handler settled. pg-boss fails a job at its
expiry and fires its abort signal but does not stop the handler, so a
backup that hung held the lane for the life of the process: every heavy
job, a restore an operator started among them, went back to the queue
every minute from then on, and the only trace was one warn line after
two hours.

A hold now carries the longest expiry of the jobs it runs. A job that
finds the lane held past that expiry and one deferral of grace reports
job.heavy_lane.holder_expired at error and takes the lane; the old
handler, should it ever settle, releases only a hold that is still its
own.
2026-10-23 21:43:55 +02:00
Marc-André Bombeck a840e30fac feat(import): carry every field the app's own files write
The JSON import read six measurement fields and six mood fields while
the full backup and the JSON export carry twice that. A sleep night came
back as stageless rows, a range reading as a single value, an ECG alert
without its verdict, and a mood entry without its note, its level-A
values, its zone, its tag and factor links or its day context.

The import now reads valueMin/valueMax, sleepStage,
rhythmClassification and deviceType on a reading, and note, a1 to a5,
tz, factors, structuredTags and context on a mood entry. Each mood entry
is written with its links and context in one transaction, the way the
bulk mood ingest writes one. A link to a tag the importing account does
not have is dropped and named in droppedTagKeys / droppedFactorKeys, and
the import card says which. The schema moves to
src/lib/import/json/schema.ts so the guards can read it.

json-import-field-coverage.test.ts runs the JSON export and both backup
variants on an account with every column filled in: every field they
write is either read by the import or named with the reason it is not
(row ids, source, deletion records, ciphertext, sync bookkeeping), and a
reason for a field no exporter writes fails as stale. The round trip
imports each file into a fresh account and compares the re-export field
for field. The published contract is held to the runtime schema.
2026-10-23 21:33:06 +02:00
Marc-André Bombeck 43002d37e8 Merge remote-tracking branch 'origin/release/v1.46.0' into feat/v146-ui 2026-10-23 21:12:51 +02:00
Marc-André Bombeck a306a8617a test(e2e): give a queued restore longer than one lane deferral
The restore shares the heavy lane with the backup pass, and a restore
that finds the lane taken goes back to the queue for 60 seconds. The
journey waited exactly 60 seconds for it. On a loaded machine the pass
the journey had just started was still finishing its tail when the
restore came in, so the restore was deferred and started one second
after the wait ran out. The wait now holds one deferral and a second
yield, and the test timeout makes room for it.
2026-10-23 20:58:30 +02:00
Marc-André Bombeck ce0b6a27bf test(e2e): scan the command palette once it has finished opening
Both palette scans ran axe as soon as the dialog was visible. The
dialog fades in, and a scan that landed mid-fade read every colour
blended with the page behind it: on a loaded machine the selected
option's text measured 4.47:1 against the 4.5:1 floor. The scans now
wait, bounded, for the finite animations to finish first.
2026-10-23 20:56:41 +02:00
Marc-André Bombeck 7405e41119 test(e2e): read the glucose journey's list filtered to glucose
The journey counted glucose rows on the unfiltered list, which shows
the account's newest page of every type. Other specs keep writing
readings to the same account, so on a database a few runs old the
reading typed for yesterday fell off that page and the count read one
short. The list now opens with `?type=BLOOD_GLUCOSE`, so it holds
exactly the rows the spec owns.
2026-10-23 20:40:31 +02:00
haritowa ce6c752864 refactor(documents): name the steps of the page text joiner
pageText worked on raw text-matrix slots and bare numbers. Each step now
has a name: positionOf reads where a run starts and ends and its font
size, startsNewLine says when the baseline moved by more than half a font
size, and leavesWordGap says when the gap is wider than a tenth of the
font size. The output is the same on every test PDF.
2026-10-23 18:37:23 +00:00
haritowa 2c5e9a80b9 refactor(documents): type the text read with pdfjs' own declarations
extractPdfText cast the dynamic import to a hand-written shape
(`as unknown as`), so the compiler could not check the call against
pdfjs. It now uses pdfjs' own types: PDFDocumentLoadingTask, TextItem and
TextMarkedContent, all type-only imports that are erased at build.

That check found a dead option at once: pdfjs 6 has no isEvalSupported.
The option went away with the font-program eval path it used to switch
off, so it did nothing here; it is removed.
2026-10-23 18:35:17 +00:00
haritowa 85bef667dd test(documents): read files the way the realism ratchet expects
The test-realism ratchet counts a test that calls readFileSync and names
a source file. The guard now reads source through readSourceText, and the
two one-process tests no longer name their twin file in a comment. Their
doc comments are rewritten in plainer words.
2026-10-23 17:56:38 +00:00
Marc-André Bombeck 879473305c Merge remote-tracking branch 'origin/release/v1.46.0' into feat/v146-ui
# Conflicts:
#	src/__tests__/baselines/source-function-lines.json
#	src/__tests__/baselines/version-stamp-comments.json
2026-10-23 19:54:08 +02:00
Marc-André Bombeck 803ef73a4d Merge remote-tracking branch 'origin/release/v1.46.0' into feat/v146-tablet 2026-10-23 19:53:46 +02:00
Marc-André Bombeck fd34ee13b9 fix(charts): round y ticks, calendar x ticks and labels beside the plot
The value chart's axes and labels, as drawn on a 90-day blood pressure
view on a tablet:

- Y ticks were whatever Recharts split the padded domain into, so the
  labels read 57, 83, 109. The domain is now widened to a 1/2/5 step
  and every tick is handed to the axis, evenly spaced and all drawn.
- X ticks were every n-th point, so their dates drifted with the data.
  They now fall on a calendar grid (days, Mondays, two weeks, months,
  quarters, years), each on the point nearest the grid line, and the
  step is the smallest whose labels fit the measured plot width.
- The "your average" label sat inside the plot over the readings, and a
  target zone's label was clipped to its first letter. Both now sit
  beside the plot, in a right margin sized to the longest label.

A browser test draws the axes with Recharts and pins the tick values,
the Monday grid, and both labels outside the plot clear of every dot.
2026-10-23 19:53:28 +02:00
Marc-André Bombeck d5ca594bd7 fix(charts): mark only the chart's own point days in the day rug
On an index or band axis the rug placed every day that holds anything
by interpolating it between the two nearest points. A sparse series
beside a daily one squeezed a week of other days into one gap, and the
dots ran together into bars. A day now gets a dot on those axes only
when it is one of the chart's points; a time axis keeps every day of
its span.
2026-10-23 19:53:28 +02:00
Marc-André Bombeck 4bf4631b6a test(e2e): check the shell's bottom edge under a home-indicator inset
With a 20 px inset set through --safe-area-bottom, at iPad sizes in both
orientations and in Split View: the last row of pixels belongs to the
rail and main, or to the bar, never to the page behind the shell; the
rail runs to the edge while its account row stops above the inset; the
root carries the content colour; and the end of the page clears the
inset, the bar and the Coach button.

The Coach button is also checked against the last line of the nested
layouts (an Insights metric page, a values list, the Insights overview,
two Settings sections) at inset 0 and 20, with an ai block that makes it
render.
2026-10-23 19:44:52 +02:00
Marc-André Bombeck d2be2b181c fix(shell): read the home-indicator inset through one property
The rail, main, the bottom bar and the Coach button each read
env(safe-area-inset-bottom) on their own. They now read
--safe-area-bottom, which is that env() value, so the four always agree
and a test can set an inset that no desktop browser reports.

The app shell's root also states the content colour itself instead of
leaving it to propagate from the body. A WKWebView browser on iPadOS
(Edge, Chrome, Firefox) can keep the strip over the home indicator out
of the page's layout and fill it with the colour it takes from the root;
that strip now meets main in the same colour.
2026-10-23 19:44:52 +02:00
haritowa 5090a6fdc1 chore(deps): say what the pdf-parse copy of pdfjs is still for
The override comment said pdf-parse's pdfjs 5.4.296 drives local text
extraction. Extraction now reads with the app's own pdfjs; only the tests
still use pdf-parse, which has to stay on pdfjs 5 because its teardown
calls an API pdfjs 6 removed. The range itself is unchanged.
2026-10-23 17:31:25 +00:00
Marc-André Bombeck f55a86a08c Merge remote-tracking branch 'origin/release/v1.46.0' into feat/v146-server
# Conflicts:
#	src/__tests__/baselines/source-function-lines.json
2026-10-23 19:28:58 +02:00
haritowa 275121391b test(documents): keep pdf-parse out of the server's runtime
A structural guard fails when any runtime file under src/ imports
pdf-parse again, which would bring its second pdfjs back into the server
process. The next.config comment on the tracing includes now describes the
text path as it is, and the extractor no longer turns an empty
end-of-line run into a blank line of its own.
2026-10-23 17:28:28 +00:00
haritowa bb762135df fix(documents): read a PDF's text with the app's own pdfjs
Local text extraction went through pdf-parse, which pins its own
pdfjs-dist (5.4.296) beside the app's (6.4.299), and both copies take
their worker from globalThis.pdfjsWorker. Whichever ran first in a process
broke the other: after a text extraction every raster failed with "The
API version "6.4.299" does not match the Worker version "5.4.296""
(thumbnails raster-failed, vision reads of a PDF 422 extractFailed), and
after a raster the text extraction failed and the local content index
stayed empty without a trace.

extractPdfText now reads the text layer with getTextContent on the
pdfjs-dist the rasterizer uses, so only one pdfjs runs in the server.
Pointing pdf-parse at 6.4 instead is not enough: its destroy() calls
PDFDocumentProxy.destroy(), which pdfjs 6 no longer has, so every
document would skip its teardown. Two tests run text and raster in one
process, each order in its own file.
2026-10-23 17:28:28 +00:00
Marc-André Bombeck d3385478a7 feat(settings): switch Apple Health write-back per measurement token
Each measurement token row carries a switch, the mint form an opt-in, and
the card a notice when readings exist that no token reaches. Copy in all
nine locales. The endpoint catalogue moves to its own file and the
revoked list out of the token card.
2026-10-23 19:28:05 +02:00
Marc-André Bombeck d6774b770d feat(openapi): publish the Apple Health write-back contract
Declare appleHealthMirror on measurement rows and sync upserts, the list
filter, the measurementMirror sync domain, the token setting on the list,
the mint and the new PATCH, and the unattributed-readings summary.
2026-10-23 19:28:05 +02:00
Marc-André Bombeck bfa6873d96 feat(tokens): add a per-token Apple Health setting
PATCH /api/tokens/{id} sets mirrorToAppleHealth on a measurement token:
cookie session only, the caller's own tokens only, revoked ones included,
any other token refused with tokens.mirror.not_measurement_token. It
stamps mirrorChangedAt and audits only when the value changes. The mint
accepts the setting (off by default), the token list returns it, and
GET /api/tokens/measurements counts the EXTERNAL readings no token
reaches.
2026-10-23 19:28:05 +02:00
Marc-André Bombeck aab2e0ae7d feat(sync): re-deliver Apple Health write-back changes
Measurement upserts in /api/sync/changes carry appleHealthMirror. A new
measurementMirror domain delivers { id, appleHealthMirror } for the live
readings of every token whose setting changed past the cursor, keyed on
(token.mirrorChangedAt, measurement.id). Switching the setting writes the
token row only, so the readings keep their updatedAt and syncVersion and
nothing keyed on them recomputes. The domain is additive inside the v2
cursor and is left out of the retention expiry, since its watermark is a
setting's age, not a deletion's.
2026-10-23 19:28:04 +02:00
Marc-André Bombeck 8d10e5ab94 feat(measurements): resolve each reading's Apple Health write-back
A reading sent with a measurements:write token keeps its EXTERNAL source
and now points at the token that carried it (measurements.ingest_token_id).
Every stored row answers a server-resolved appleHealthMirror: true for
WITHINGS, IMPORT and MANUAL, the token's choice for EXTERNAL, false
otherwise. The plain list accepts appleHealthMirror=true so a client can
page exactly those rows.

Migration 0476 adds the columns and links existing EXTERNAL readings only
where one measurement token provably sent them; 0477 builds the index
concurrently. Backups carry the link and a restore keeps it only for a
token the account still holds. A guard holds every EXTERNAL writer to
setting the link.
2026-10-23 19:27:52 +02:00
Marc-André Bombeck 9d1147a052 fix(dashboard): fetch a snapshot restored from the offline copy on first mount
The offline persister restores the last dashboard snapshot at boot and
marks it invalidated so the first mount refetches it, but the snapshot
cell read refetchOnMount: false and never did. A page reloaded right
after a write, before the persister had flushed the newer snapshot,
painted the pre-write dashboard until the two-minute poll; the weight
just entered had no tile. A cell with live data still reads as it is on
remount; a restored, invalidated one fetches.
2026-10-23 19:25:30 +02:00
Marc-André Bombeck 0470e4e850 test(guards): read the stripper parity sweep through the shared source index
A test that reads source by path counts against the realism ratchet;
the sweep reads every file through readSourceText like the other
guards, which also shares the reads with them.
2026-10-23 18:54:32 +02:00
Marc-André Bombeck 4748091636 Merge branch 'release/v1.46.0' into feat/v146-ui 2026-10-23 18:30:00 +02:00
Marc-André Bombeck 5273a8d899 test(e2e): give the vault specs their own accounts, and clear every bucket at setup
The vault's layout is account state. documents.spec read the default
while the layout journey in v142-qol turned it on the shared account,
so which one failed depended on the order they ran in. Each now runs on
a vault account of its own; the vault fixture seeds the same corpus
under ids tagged per account, so two vaults never claim one row.

Global setup clears every rate-limit bucket keyed on a fixture account
or on the loopback addresses the suite calls from, not only the login
and share buckets: the check-in, write, export and import buckets
outlive a run by up to an hour, and a second run on the same database
met the first run's counts. A developer's own accounts are left alone.
The layout journey waits for its own write rather than whichever layout
write answers first.
2026-10-23 18:29:54 +02:00
Marc-André Bombeck 0e7a186677 test(ratchet): lower the shell and rail baselines
sidebar-nav.tsx drops under 800 lines with its scroll region in a module
of its own, and AuthShell and SidebarNav are shorter.
2026-10-23 18:24:30 +02:00
Marc-André Bombeck 9cd2236295 Merge remote-tracking branch 'origin/release/v1.46.0' into feat/v146-tablet 2026-10-23 18:23:55 +02:00
Marc-André Bombeck 798747f987 test(e2e): walk the app shell at every tablet size
tablet-shell.spec.ts runs on WebKit with an iPad profile (webkit-tablet,
in the WebKit job) and on Chromium with an Android tablet profile
(chromium-tablet, in the shards), across iPad mini to iPad Pro 13 in
both orientations, Safari's bars, Split View and Slide Over. It checks
that the shell fills the window and leaves the document nothing to
scroll, that every rail destination scrolls into view on top and takes a
tap, that the scroll cue follows the overflow, that main reaches its
last line clear of the bottom bar, that the page scrolls again after a
sheet and a dialog close, and that a tab strip leaves the vertical pan
to the page.

ui-standards records the shell and rail rules.
2026-10-23 18:23:49 +02:00
Marc-André Bombeck 22976337cb fix(tabs): leave the vertical pan to the page
touch-pan-x on the tab strip refused a vertical pan that began on it, so
a swipe up or down over a tab strip scrolled nothing. The strip already
pans sideways through its own overflow.
2026-10-23 18:23:49 +02:00
Marc-André Bombeck 1f4c1bd2f0 fix(rail): keep every destination reachable under a finger
A touch tablet keeps the rail's 44 px rows, so in landscape three to
seven destinations sit below the fold, and the list has no scrollbar at
rest. The edge fade alone did not read as "swipe here", and a swipe that
reached an end chained to the document, which held the next one.

The list region moves into its own module. Under a coarse pointer it
contains its overscroll, keeps a focused row clear of a new scroll cue,
and shows that cue at an edge with more destinations; a tap scrolls the
list most of a screen on. The cue stays out of the focus order and the
accessibility tree: the keyboard and screen readers already reach every
row. The rail takes the home indicator's inset so the account row stays
clear of it. A mouse sees no change.
2026-10-23 18:23:48 +02:00
Marc-André Bombeck 6ad61cdd1f fix(shell): take the window's edges and keep the document still
The authenticated shell was sized by h-dvh. On iPadOS Safari that value
and the area the browser lays out disagree after a rotation and under the
floating toolbars: a short value left a strip of page colour under the
shell with the last card cut off above it, a tall one let the document
scroll by the difference and take the swipes meant for main.

The shell now takes the window's edges (absolute inset-0 against the
initial containing block), and the document under it gets no scroll and
no rubber band (data-scroll="shell": overflow hidden, overscroll none,
height 100% so the box survives a dialog's scroll lock making the body
relative). Not fixed: a fixed box re-rasterises every page in a layer of
its own and traps the bottom bar's z-index.

The desktop shell's main now clears the home indicator of an installed
app, and the page frame clears the Coach button there with pb-24: the
button sits 5.5rem up, so pb-20 left the last 8 px under it. The
sub-shell grid floor follows the new padding.
2026-10-23 18:23:48 +02:00
Marc-André Bombeck 3b6e0bf0fb fix(i18n): write the locale to the account once per tab, not on every page
Every page load mounts the locale provider, and its mount effect sent
PUT /api/auth/me/locale with a value that had not changed. Every
authenticated write is charged to the account's shared write bucket, so
each page view spent one write. The mount write now happens once per tab
session and locale; a switch still writes at once.
2026-10-23 18:09:15 +02:00
Marc-André Bombeck c0d387025d test(guards): strip comments the way the compiler reads them, in every guard
Forty-two guard files carried their own comment stripper, block comments
first in one non-greedy pass, so a /* inside a line comment opened a
block that hid the code after it, and the stricter copies hid code
behind a /* inside a string, which every e2e route glob has. They all
read through the shared stripComments now, and it scans the way the
parser does: strings, template literals and regular expressions keep
every character, a comment inside a template expression goes, and line
numbers are kept. A parity test strips every source file both ways,
with the scanner and with the TypeScript parser, and fails on the first
that differs.

No guard reports anything new. Each one fails when the stripper returns
nothing; three of them passed on an empty file and assert that the code
they ban is read at all now.
2026-10-23 18:09:10 +02:00
Marc-André Bombeck 584f799da3 test: drop the timings of test files that no longer exist
Thirteen of the table's files had been deleted or renamed since it was
written. The entries were dead weight; nothing reads a timing for a file
that is not there.
2026-10-23 17:54:22 +02:00
Marc-André Bombeck 3d26cee566 fix(import): take the app's own full backup and JSON export back
The JSON import refused the app's own files: the full backup writes an
empty note, tag list or glucose context as null and the JSON export as
an empty string, and the import schema read either as a value of the
wrong type. The file is now read in the exporter's terms before it is
validated: the export's envelope is opened, every null or empty field
is treated as absent, a backup row carrying a deletion is left out and
counted as skipped, and a unit the app converts on every other way in
is converted to the stored one.

The round trip also showed the portable backup dropping what a reading
itself says: glucose context, sleep stage, range bounds, rhythm and
device. They ride the portable file now, and a portable restore puts
them back. Sync bookkeeping stays out.

An integration test exports an account three ways (full backup, JSON
export, measurement CSV), imports each into a fresh account and compares
what the import carries.
2026-10-23 17:33:58 +02:00
Marc-André Bombeck 8a11b7bfc6 fix(charts): measure axis labels at the size they are drawn under reduced motion
The reduced-motion backstop gives every element a near-zero transition,
and the default transition property is all, so the font size Recharts
writes into its measurement span started a transition and the read in
the same task returned the previous size. Labels were sized at whatever
the last one had, or at the inherited 16 px: the last date label moved
in from the edge and value labels that fit were dropped as colliding,
differently from one visit to the next. Nothing in the measurement host
is ever seen, so nothing in it transitions now. A browser test pins the
read under reduced motion and without it.
2026-10-23 17:21:06 +02:00
Marc-André Bombeck a0dce10c1a fix(activity): word the refusals that now reach a delegate's feed
The custom-metric create and update refusals and the medication layout
refusal are filed through auditLog now, so a delegate's refused request
reaches the owner's activity feed, where it read as the generic change.
Each gets its own sentence in every bundle; the refusal sentences live in
their own function. The audit write guard states its floor, and the
schedule-reader guard names the medication create and edit, which answer
through medicationDetail.
2026-10-23 17:19:21 +02:00
Marc-André Bombeck fb9cd9b965 refactor(ai): move the stored-key opener and the Anthropic host check out of provider.ts
Both are self-contained; provider.ts stays within its size baseline.
2026-10-23 17:19:21 +02:00
Marc-André Bombeck c82ed69549 Merge remote-tracking branch 'origin/release/v1.46.0' into feat/v146-server
# Conflicts:
#	src/__tests__/baselines/source-file-lines.json
2026-10-23 17:18:53 +02:00
Marc-André Bombeck 51cba52751 fix(medications): answer writes with the shapes the contract declares
The create, its mirror replay and the edit answered the written row
without the read-model fields the declared resource carries (reminder
state, stock and runway, actionability, course status, composition,
links), while the detail read answered them. They answer the read model
now, built from the row they just wrote (medicationDetail), so a client
reads back exactly what GET /api/medications/{id} would say.

A dose write that converged onto an existing slot answered the slot
upsert's narrow row, without userId, medicationId, updatedAt,
autoRecorded, autoMissed or attributionSource. The upsert reads and
returns the whole event row, the same shape a write that inserted its own
answers. The parity walks drop their contract skips and are re-pinned.
2026-10-23 17:10:02 +02:00
Marc-André Bombeck 521b85bfbd fix(openapi): declare the mirror replay 200 of a medication create
A mirror create re-posting a pair the caller already holds has always
answered the existing medication with 200, as the description says; only
the 201 was declared.
2026-10-23 17:10:02 +02:00
Marc-André Bombeck 55a6287069 fix(audit): file every refused request through auditLog()
Twelve routes wrote their <surface>.validation-failed row with a bare
prisma.auditLog.create, so they skipped the two decisions auditLog makes:
an owner's own refused request stays on the wide event
(meta.rejected_attempt) instead of becoming a row, and a delegate's names
the delegate as the actor. The bulk medication and mood ingests, the
layout, overlay, device, health-sync, metric and ingest routes all go
through auditLog now, and a structural guard keeps raw writes to the one
writer and the briefing failure marker. The unit tests assert the auditLog
call, and the bulk walks are re-pinned without the owner's refusal row.
2026-10-23 16:54:57 +02:00
Marc-André Bombeck 89b6189206 test(measurements): call the fire-and-forget mock with a starter function 2026-10-23 16:54:56 +02:00
Marc-André Bombeck cf37f833e8 fix(insights): keep the briefing blocks of a reply that validates
The briefing prompt asks for dailyBriefing, trendAnnotations and
storyboardAnnotations beside the insight fields, and the briefing card,
the dashboard snapshot and the today read consume them. insightResultSchema
was a strict object, so it dropped exactly those blocks from a reply that
validated, while a reply that failed validation kept them. It is a loose
object now: the named fields are checked and the rest of the reply stays.
2026-10-23 16:44:50 +02:00
Marc-André Bombeck b02caed221 fix(ai): treat a stored provider key that cannot be opened as no key
A key id retired from ENCRYPTION_KEYS, or a corrupt row, made decrypt throw
inside the provider builders, and a briefing generation answered 500. An
unreadable key now drops its entry from the chain like a missing one, with a
warning on the event, so the generation answers the no-provider 422
(ai.provider.none). The connection test reads it as no stored key. The
parity walk holds that case to the contract again.
2026-10-23 16:44:50 +02:00
Marc-André Bombeck 551fbbbdec Merge branch 'release/v1.46.0' into feat/v146-ui 2026-10-23 16:44:26 +02:00
Marc-André Bombeck 976b2ba0bc fix(workouts): read the stored route sample shape for splits and the GPX export
Both ingests store each route point's sample as { t, speedMs?, hr? }, while
computeSplits parsed every sample as a bare instant and the GPX export
skipped anything that was not a string. Splits were therefore null for
every ingested track, and an exported track carried no times. One reader,
routeSampleInstant, takes either shape. The workout detail walk is
re-pinned: its ingested run now answers its first kilometre split.
2026-10-23 16:37:58 +02:00
Marc-André Bombeck 06d55afc46 fix(deps): raise the ip-address floor to 10.7.4
10.7.3 and earlier take exponential time on a crafted Address6 zone
identifier. The copy under the MCP SDK's rate limiter reaches runtime, and
the one under the registry client resolves to the same version now.
2026-10-23 16:33:05 +02:00
Marc-André Bombeck 4e484c3dd2 test(e2e): clear the account's write bucket when the setup flow resets
Every authenticated write is charged to the shared per-account bucket,
and the locale sweep drives one account through eighteen setup runs in
well under a minute, so its last case met the write ceiling. The reset
already cleared the flow's own buckets; it now clears that one too.
2026-10-23 16:32:50 +02:00
Marc-André Bombeck b4166cdc84 fix(logging): keep detached work from renaming the request's wide event
Work a request starts and does not wait for runs inside the request's
context, so an annotate({ action }) from it landed on the request's event
whenever it ran before the event was emitted. The measurement tail's
status re-warm logged a hand-entered reading as
insights.status.invalidate.skipped on some requests and as
measurement.create on others.

runDetached starts such work so that its action is dropped for the event
it was detached from; meta, warnings and the auth it reads stay as they
were, and an event the work opens for itself keeps its own action.
fireAndForget now takes a function and starts it detached, and every
trackBackgroundTask handle on a request path is built with runDetached,
which a structural guard holds.
2026-10-23 16:32:10 +02:00
Marc-André Bombeck cfa5fe537b Merge remote-tracking branch 'origin/release/v1.46.0' into feat/v146-server 2026-10-23 16:15:37 +02:00
Marc-André Bombeck 419d47c862 test(e2e): hand the shared account back on the default vault layout
The layout journey left the shared account on the stacked arrangement,
and the month-grid tests that read the default flowing one failed
whenever they ran after it.
2026-10-23 16:15:36 +02:00
Marc-André Bombeck 453bf6c6f3 test(guards): let the file-size floor follow the ratchet down
The splits on this line took the files over 800 lines from 115 to 96,
under the liveness floor of 100. The floor guards the matcher, so it
moves to 80, below the real count.
2026-10-23 15:54:10 +02:00
Marc-André Bombeck 18ff14ba1a fix(openapi): declare the body-cap 413 and the outbound-screen 502 of a briefing generation
Both have always been answered; neither was declared. The parity walk now
checks them against the contract instead of skipping it.
2026-10-23 15:53:59 +02:00
Marc-André Bombeck 31048e5e63 test: tighten the size and version-stamp baselines after the insights moves 2026-10-23 15:45:16 +02:00
Marc-André Bombeck af15be4708 Merge branch 'feat/v146-server-insights' into feat/v146-server 2026-10-23 15:44:47 +02:00
Marc-André Bombeck 1a5d79f24e refactor(insights): move the briefing read and generation out of their route 2026-10-23 15:44:04 +02:00
Marc-André Bombeck c22c302ebe test(analytics): read the default body where it now lives 2026-10-23 15:41:54 +02:00
Marc-André Bombeck db623ef8bc test(insights): pin the briefing read and generation before moving them 2026-10-23 15:31:36 +02:00
Marc-André Bombeck 356d947ab0 refactor(dashboard): move the summary builder out of its route 2026-10-23 15:25:48 +02:00
Marc-André Bombeck 98875debf4 refactor(insights): move the comprehensive overview builder out of its route 2026-10-23 15:25:20 +02:00
Marc-André Bombeck 1b32217844 test(dashboard): pin the summary before moving its builder 2026-10-23 15:20:27 +02:00
Marc-André Bombeck af5cf1ace2 refactor(auth): move the SSO callback's account resolution and sign-in endings out of its route 2026-10-23 15:17:27 +02:00
Marc-André Bombeck d76497ec13 test(auth): pin the SSO callback before moving its sign-in resolution 2026-10-23 15:13:25 +02:00
Marc-André Bombeck 91a5464aa3 refactor(documents): move the vault upload reading and the list read out of their route 2026-10-23 15:10:26 +02:00
Marc-André Bombeck 2e13b7754e test(insights): pin the comprehensive overview before moving its builder 2026-10-23 15:05:10 +02:00
Marc-André Bombeck 69527d926d test(documents): pin the vault upload and list before moving them 2026-10-23 15:04:39 +02:00
Marc-André Bombeck b75219d704 fix(openapi): declare the duplicate flags and the missing-link 404 of a document upload
A duplicate upload has always answered meta.duplicate (and meta.deleted for
a deleted source key) beside the row, as the description says, but the
envelope schema declared neither. An episode or visit id that is not the
caller's has always answered 404, which no response declared.
2026-10-23 15:04:37 +02:00
Marc-André Bombeck de96891ff5 refactor(import): move the CSV import writer out of its route 2026-10-23 15:00:35 +02:00
Marc-André Bombeck 94ad53756e test(import): pin the CSV import before moving its writer 2026-10-23 14:58:14 +02:00
Marc-André Bombeck 5d9a9107fa test(import): record the status re-warm tail in the JSON import walk
Run for real it is detached from the request but inside its context, so
its annotate lands on the request's wide event on some runs and misses it
on others.
2026-10-23 14:58:14 +02:00
Marc-André Bombeck 7f06e28c37 test(kit): let callRoute send a raw text body 2026-10-23 14:58:13 +02:00
Marc-André Bombeck c6238cbe14 test: tighten the size and version-stamp baselines after the medication moves 2026-10-23 14:52:43 +02:00
Marc-André Bombeck 40eb1255b7 Merge branch 'feat/v146-server-meds2' into feat/v146-server
# Conflicts:
#	src/__tests__/external-id-stability-guard.test.ts
2026-10-23 14:52:03 +02:00
Marc-André Bombeck 11a7124d12 refactor(import): move the JSON import writers out of their route 2026-10-23 14:51:51 +02:00
Marc-André Bombeck a1ab7136f5 test(medications): reach the actions of moved handler bodies in the activity inventory
The inventory reads a delegated route and its direct imports. With the medication handlers moved into lib/medications, the lifecycle reconciliation a dose write files was two levels from its route and dropped out of the walk. A route's handler-body modules now count at the route's own level, listed in one map whose every entry must be a direct import of its route.
2026-10-23 14:49:42 +02:00
Marc-André Bombeck ed12625ab6 test(import): pin the JSON import before moving its writers 2026-10-23 14:48:51 +02:00
Marc-André Bombeck 24af254249 refactor(medications): move the bulk dose ingest out of its route
POST /api/medications/intake/bulk keeps authentication, its own bulk bucket, the body with its size and schema refusals, the payload schema and the HTTP mapping. The ingest, one 846-line function, moves to lib/medications/intake-bulk/ in the order it always ran: the ownership read, the Apple mirror gate and the batch pre-reads (ingest.ts), each entry's refusals, site, write bookkeeping and P2002 answer (entry.ts), its slot attribution (entry-attribution.ts) and its slot, replay, probe or standalone write (entry-write.ts), then the audit row, the dose days, the snooze, the tails, the arrival record and the checkpoint. The two identical canonical-slot write blocks of the loop are one function now. The route falls from 1044 to 231 lines; no function in the new modules is over 70.

The parity walk recorded before the move (medication-intake-bulk.parity) is unchanged. The per-entry external-id markers name the module that classifies each entry, the batch-surface and key-scope legs read the moved code, and the intake-tracking allowlist and the dense-read guard follow it.
2026-10-23 14:47:57 +02:00
Marc-André Bombeck 7a21796b63 docs: keep block openers out of line comments in server sources
A '/*' inside a line comment opens a block for every guard that strips
block comments first, and the code after it disappears from that guard's
view. Four server comments and one new one did this; reword them.
2026-10-23 14:47:00 +02:00
Marc-André Bombeck ad27e5b89d refactor(sync): move the delta feed's domain pages out of its route 2026-10-23 14:45:26 +02:00
Marc-André Bombeck 1b5e586a06 test(medications): strip line comments first in the intake tracking guard
The guard stripped block comments before line comments, so a '/*' inside a
line comment opened a block that swallowed the code after it. The delta
feed's intake read sat behind such a comment and was never seen. The
shared stripper handles that order; with it the feed shows up, and it is
listed: it mirrors recorded rows and computes no rate.
2026-10-23 14:45:08 +02:00
Marc-André Bombeck 2a7833d7dc test(medications): pin the bulk dose ingest before it moves
One walk through POST /api/medications/intake/bulk: a mixed batch with one entry per per-entry outcome (unknown and record-only medications, unstable keys, band, pinned and refused pins, named-slot skips, pending echoes, a standalone dose with site, override and key and its replay, a disallowed site, Apple doses with an in-batch repeat and an extra dose on a held slot, a skip converging onto the standalone dose), the same batch again, a background and a backfill sync with the arrival rows and checkpoint they write, the batch refusals, and the callers the route admits and refuses.
2026-10-23 14:42:32 +02:00
Marc-André Bombeck 29620ffa49 test(sync): pin the delta feed before moving its domain pages 2026-10-23 14:40:37 +02:00
Marc-André Bombeck 2fcc2bdab2 refactor(medications): move the today list, the compliance buckets and the status update out of their route
GET and POST /api/medications/intake keep authentication, the query and body parsing with their refusals, the 404 and the HTTP mapping. The today list moves to lib/medications/intake-today.ts and the cached compliance buckets to intake-compliance.ts. The status update, one 432-line callback, moves to lib/medications/update-intake-status/ in the order it always ran: the event lookup, the record-only rule, the delegated refusals and the injection site (admission.ts), the take in place or onto another slot (take.ts), then the skip or snooze, the audit row, the rollups and the tails (update-intake-status.ts). The route falls from 634 to 149 lines; no function in the new modules is over 70.

The parity walk recorded before the move (medication-intake-status.parity) is unchanged. The sharing and activity-verb guards read the status-update modules beside the route, and the dense-read guard keeps the moved code in scope.
2026-10-23 14:37:47 +02:00
Marc-André Bombeck 3e9ed8e2e3 test(mood): name the raced-upsert winner read the bulk ingest now carries 2026-10-23 14:37:20 +02:00
Marc-André Bombeck c40d728b47 Merge branch 'release/v1.46.0' into feat/v146-server
# Conflicts:
#	src/__tests__/baselines/source-function-lines.json
#	src/__tests__/baselines/version-stamp-comments.json
2026-10-23 14:35:19 +02:00
Marc-André Bombeck 704b51ca02 test: size the dense pulse seed's transactions for a loaded host
Each month of the million-row seed runs in an interactive transaction with
an explicit timeout. The batch form ran under Prisma's 5 s default and a
month took 9.9 s at a high load average, expiring the transaction before
the heap assertion ran.
2026-10-23 14:35:01 +02:00
Marc-André Bombeck 315ee0ff98 refactor(mood): move the bulk ingest out of its route 2026-10-23 14:34:36 +02:00
Marc-André Bombeck 6555acefe1 Merge branch 'release/v1.46.0' into feat/v146-ui 2026-10-23 14:33:21 +02:00
Marc-André Bombeck baa1ba018d test(medications): pin the today list, the compliance buckets and the status update before they move
One walk through GET and POST /api/medications/intake: today's list with its projection and every status a row reads as, the compliance buckets cold and cached, a take in place, a take that re-binds to another slot, a pin and a pin that is no slot, a skip, both snoozes, the not-found answers, the record-only rule both ways, the injection-site refusal and an allowed site, the schema refusals, and a delegate marking an open dose and refused a snooze and a correction.
2026-10-23 14:32:52 +02:00
Marc-André Bombeck 74e08e6906 test(mood): pin the bulk mood ingest before moving it into a service 2026-10-23 14:30:19 +02:00
Marc-André Bombeck c4ee519012 refactor(medications): move the per-medication dose write and history out of their route
POST and GET /api/medications/{id}/intake keep authentication, the intake and ownership guards, the body and query parsing with their refusals, the delegated outcome-change refusal and the HTTP mapping. The dose write, one 607-line function, moves to lib/medications/record-intake/ in the order it always ran: the injection-site check and the slot attribution (attribution.ts), the replay and dedup answers and the slot, probe or standalone write (write.ts), then the inventory, the audit row, the rollup, the lifecycle and the tails (record-intake.ts). The history list moves to list-intake-events.ts. The route falls from 767 to 200 lines; no function in the new modules is over 75.

The parity walk recorded before the move (medication-intake.parity) is unchanged. The activity-verb guard now reads the awaited audit actions of a delegable write's handler modules beside the route, for this route and for the medication create that moved before it, and holds each module to its route's imports; the sharing guard, the intake-tracking allowlist and the dense-read guard follow the moved code.
2026-10-23 14:29:58 +02:00
Marc-André Bombeck af53d3d6f0 test(medications): pin the per-medication dose write and history before they move
One walk through POST and GET /api/medications/{id}/intake: band, standalone, named-slot, pinned and backfilled doses, skips onto slots and onto a standalone dose, the dedup window, both idempotency forms and their replays, the delegated refusal on both write paths, the injection-site and pin refusals, a one-shot dose, the schema refusals, and the history list in its filters. The parity step can now rewrite an id carried inside a longer string, and a refusal's unawaited audit row is waited for on the real timer so the pinned clock does not move.
2026-10-23 14:21:24 +02:00
Marc-André Bombeck a60111cc9d Merge branch 'feat/v146-ui-browser' into feat/v146-ui
# Conflicts:
#	src/__tests__/baselines/test-source-reads.json
#	src/__tests__/baselines/version-stamp-comments.json
2026-10-23 14:20:08 +02:00
Marc-André Bombeck 8a6c6bfdd7 Merge branch 'feat/v146-ui-e2e' into feat/v146-ui 2026-10-23 14:19:29 +02:00
Marc-André Bombeck da937718bc refactor(measurements): move the series readers out of their route
GET /api/measurements/series keeps authentication, the query and its
refusal; readSeries (lib/measurements/series/read-series.ts) picks the
reader in the old order (sleep nights, paired blood pressure, the bucketed
read of a dense kind, the raw rows) and adds the direction, change,
reference lines and stat strip. The kinds, units and point shape live in
kinds.ts. The route falls from 752 to 95 lines.

The parity walk recorded before the move is unchanged. The guards on the
verdict resolver, the spread, the pulse day mean and the unit literals read
the new files, the last two with their scope widened to the series folder.
2026-10-23 14:13:52 +02:00
Marc-André Bombeck 870c8e053a Merge branch 'release/v1.46.0' into feat/v146-integrations 2026-10-23 14:13:32 +02:00
Marc-André Bombeck 967fa2c56b refactor(medications): move the medication read, edit and delete out of their route
GET, PUT and DELETE /api/medications/{id} keep authentication, the ownership guard, the body and the HTTP mapping. The read moves to lib/medications/read-medication.ts; the edit, one 886-line callback, moves to lib/medications/update-medication/, split along its stages in the order they always ran: the preflight checks and refusals, the schedule normalisation, the transaction (era archive, tombstones, the row update, the pause era, the course write) and the commit tail with its audit row. The delete moves to delete-medication.ts. The route falls from 1270 to 100 lines; no function in the new modules is over 115.

The parity walk recorded before the move (medication-detail.parity) is unchanged. The sharing guard reads the edit and delete modules for the handler body, the intake-tracking allowlists name the modules that now read the schedule and the open slots, the published-DTO leg reads the read module, and the dense-read guard keeps the moved code in scope.
2026-10-23 14:10:17 +02:00
Marc-André Bombeck b2f6c37c37 test(kit): pin parity walks to today where a route reads the database clock
Some read routes cut their windows in SQL against the database's now(),
which a pinned Date cannot reach; a walk pinned to a fixed date then reads
less of its record every day the calendar moves on. relativeToAnchor writes
every instant and day key of a snapshot relative to the start of the
current UTC day, so a walk can lay its record out from today and still read
the same snapshot on any day.

The series walk uses it with the clock unpinned; the analytics walk pins
the clock to noon of today, keeps its account in UTC and is re-recorded in
that form (its earlier snapshots proved the builder move before this
change).
2026-10-23 14:09:41 +02:00
Marc-André Bombeck 8feeaed473 test(measurements): pin the measurement series before moving its readers
A parity walk over GET /api/measurements/series through every reader it chooses between: the plain and ladder-picked rows, pulse with its spread, the paired blood pressure (two sources and a corrupt systolic), the sleep nights, glucose raw and day-bucketed, a dense stream bucketed per hour, a refused query and a delegate in another language. The readers cut their windows against the database clock, so the record is laid out from the start of the current UTC day and the snapshot holds instants and day keys relative to it.
2026-10-23 14:05:55 +02:00
Marc-André Bombeck 53bc663cbf test(security): follow a fresh-proof request into the hook that sends it
The backups section now hands its flow to the download, restore and
upload hooks. Each caller entry may name the file that sends the
request; the component still owns the flow and the dialog and must
import that hook and hand it the flow.
2026-10-23 14:04:03 +02:00
Marc-André Bombeck ad9e0d8732 test(day): read a chart and its same-named folder as one
The HealthChart split moved its layers, tooltip and day footer into
health-chart/, so the guard read the composer as a date-axis chart with
no doors. A component and the folder of its name are now one entry, a
tooltip action handed whole to the tooltip counts as wired, and the mount
floor counts the dashboard's one builder mount for its seven charts.
Drop two return-type exports nothing imports.
2026-10-23 14:01:17 +02:00
Marc-André Bombeck 817a922df5 refactor(workouts): move the workout detail read and the manual delete out of their route
GET keeps authentication, the module gate and the AI capability, and hands
the rest to readWorkoutDetail (lib/workouts/detail.ts): the stored row, the
canonical winner of its cluster, the enrichment reads and the body, as
named stages. DELETE keeps authentication and the HTTP answers; the
ownership check, the synced-source refusal and the transactional removal
with its records, audit, cache and detection tail move to
lib/workouts/delete-manual-workout.ts.

The parity walk recorded before the move is unchanged; the guards that name
the canonical-identity reads and the track readers point at the service.
2026-10-23 14:00:28 +02:00
Marc-André Bombeck 38baafcdd7 fix(openapi): admit the bare-instant route samples older tracks carry
A track stored before the object form holds one ISO instant per point, and the detail answers it as stored. The route-sample declaration accepts both forms.
2026-10-23 14:00:05 +02:00
Marc-André Bombeck e8510ae167 test(medications): pin the medication read, edit and delete before they move
One walk through GET, PUT and DELETE /api/medications/{id}: the read with stock, composition and courses, every edit branch the handler tells apart (schedule replace and echo, the grace bridge, pause and resume, the category rules, the record-only rule and both tracking transitions, the as-needed and one-shot refusals, the course refusals, a composition change), the refusals, and delegated access at each level. The parity step can now wait for a write the route does not await and read state outside the medication tables.
2026-10-23 13:59:41 +02:00
Marc-André Bombeck 32dec72564 test(workouts): pin the workout detail and delete before moving them into services
A parity walk over GET and DELETE /api/workouts/{id} on workouts landed through the batch ingest: the canonical row, the compact body, a hidden twin, a ride, another account's row, a missing one and the module switched off; a synced, a missing, another account's and a hand-entered workout with its record for the delete.
2026-10-23 13:57:18 +02:00
Marc-André Bombeck ab15096a6a fix(openapi): declare the route samples a workout detail answers
WorkoutRouteGeometry.sampleTimestamps was declared as an array of strings. Both track ingests (the workout batch and the Apple Health export) store one object per point, { t, speedMs?, hr? }, and the detail answers them as stored. The response is unchanged; the contract now says what it holds.
2026-10-23 13:57:17 +02:00
Marc-André Bombeck 51835fe1d0 refactor(analytics): move the default body builder out of its route
The route keeps authentication, the slice refusal, the summaries slice, the
cache read and the headers. buildAnalyticsResponse (446 lines) moves to
lib/analytics/response/default-body.ts and calls one module per block, in
its old order: the blood-pressure in-target windows, the glucose summaries
and clinical panel, the correlation hypotheses with their stored patterns,
the Health Score with its Rest Mode framing, and the sleep stages. The
route falls from 761 to 155 lines.

The parity walk recorded before the move is unchanged; the guards on the
glucose window and the bounded reads follow the code.
2026-10-23 13:52:57 +02:00
Marc-André Bombeck d24c544e98 test(analytics): pin the analytics response before moving its builder
A parity walk over GET /api/analytics on an account with a full record
(record-fixture.ts, reusable by the other read routes): an unknown slice,
the summaries slice and the default body cold and warm, the modules off, a
second language with a personal blood-pressure target, an empty account and
an anonymous call. Background work a request leaves running is settled
before the next one. The blood-pressure fast path's rollup row counts are
left out of the pinned wide event: they follow the rollup tier's coverage,
not the code, while the answer they fed stays pinned in the body.
2026-10-23 13:49:24 +02:00
Marc-André Bombeck c2a63307ed test: point guards at the split provider modules
The page-walk exception for the Google Health list walk follows
forEachDataPointPage into data-points.ts, and the workout provenance guard
reads the raw Polar and Strava sport labels where the mappers now live.
The write-step helper's tests assert the result it returns, including a
failed fold and a caller that re-warms the status cards itself.
2026-10-23 13:44:21 +02:00
Marc-André Bombeck 905d4b167c test(insights): write the status goldens with sorted keys
The goldens held their fields in the order the code assigned them. They are now written with every object's keys sorted, so the text depends only on content and diffs and compresses well; the parsed content of every golden is unchanged. They carry no clock reading or generated id: the clock is pinned and the ids are fixed.
2026-10-23 13:42:58 +02:00
Marc-André Bombeck 4794cdec88 refactor(insights): call normalizeLocale by its own name in the status routes
The status routes kept their old resolve*StatusLocale names as import aliases when the per-card locale resolvers were folded into normalizeLocale. They call it directly now.
2026-10-23 13:41:47 +02:00
Marc-André Bombeck 3f8c2a98dd fix(measurements): answer the day drill-down without the sealed note
The dayKey drill-down of GET /api/measurements answered the stored rows as
they were, notesEncrypted ciphertext included and the note itself unread.
It now selects the contract's columns by name and shapes each row like the
plain page: the note decrypted onto notes, the ciphertext never on the wire.

A route test walks the measurement reads over rows with a sealed note
(the page, the drill-down, the day-grouped list, an aggregate, the detail)
and fails on any sealed field; it failed on the drill-down before this
change. The parity walk drops its contract exemption for the drill-down.
2026-10-23 13:41:11 +02:00
Marc-André Bombeck 7b9737c7b1 test(kit): refuse a sealed field in any response the route kit drives
callRoute now fails a call whose JSON body carries a key ending in
Encrypted, or bytes serialised as an index-keyed object or a Buffer, on
every route it drives and regardless of the contract exemption. A sealed
column is decrypted or dropped before a response is built; one that leaks
is a defect, whatever the contract says. The kit's own test proves the
check finds both shapes and leaves an hour map alone.
2026-10-23 13:41:11 +02:00
Marc-André Bombeck 8b3c095df5 refactor(fitbit): keep the Fitbit jobs inside the Fitbit folder
The backfill, the sleep repair, the OAuth-state cleanup and the sync
handlers move from lib/jobs into lib/fitbit/jobs, beside the queue facts
and definitions the registry already spreads. Their tests move with them.
Removing the integration is now the folder, its routes, the registry
spread and the drain's two cases.

The updatedAt backfill guard also walks provider jobs folders, so the
moved backfill stays covered.
2026-10-23 13:33:11 +02:00
Marc-André Bombeck 82562a8df9 chore(knip): reach the browser test helper
The next/navigation shim is reached only through the browser project's
alias, and the helper's render module is a .tsx file under tests/,
which the project globs did not include.
2026-10-23 13:31:29 +02:00
Marc-André Bombeck 7e54b6be81 refactor(integrations): split the provider clients into wire types, endpoints and mappers
Oura, Polar, WHOOP and Strava each keep the transport and endpoints in
`client.ts` and gain `wire-types.ts` (the record shapes) and `map.ts` (the
pure record-to-row mappers, no I/O). Google Health's 1,600-line client
becomes `budget.ts` (slice budget and outcome tally), `request.ts` (error
detail, retry, rate limits), `data-points.ts` (the paginated walker) and a
small `client.ts` for the profile and roll-up reads; its `export * from
"./mappers"` facade is gone and its importers name the module that defines
what they use.

The code moved verbatim; the mapper goldens written before the move and
the provider integration tests pass unchanged.
2026-10-23 13:30:10 +02:00
Marc-André Bombeck 5eafed8dd5 refactor(admin): split the backups section
The section becomes a composer over backups/: the restore dialog with its
preview, the refusal, skip and schedule notices, the restore jobs and
their request, the upload flow, the run and download actions, and the
two row layouts sharing one action pair. The upload total is a pure
function and tested as one. The read-failure guard's 250-character
window had stopped reaching the restore preview's own failure lines; the
preview now sits in a smaller component, the guard sees it, and it is
listed as a deliberate exception with its reason.
2026-10-23 13:20:48 +02:00
Marc-André Bombeck 591824044b docs(testing): describe the critical journeys and their accounts 2026-10-23 13:19:47 +02:00
Marc-André Bombeck 232c3db6ef docs(testing): describe the browser project and its helper
Say which claims belong in *.browser.test.tsx, how to run the project
alone, and what tests/browser/ provides. The browser install guard now
names the browser project beside the hero-geometry check, since both
launch the headless shell the CI slices install.
2026-10-23 13:16:56 +02:00
Marc-André Bombeck 36fe53bbaa test: write the ratchet baselines back after the medication and widget moves 2026-10-23 13:16:46 +02:00
Marc-André Bombeck 392b2c47a9 test(mental-health): drive the check-in wizard in a real browser
One question at a time in the source order, Back keeping the answer,
the number keys and arrows, the review with a way back to every answer,
the optional PHQ-9 follow-up, a failed submit, and the note over an
unvalidated translation staying inside the viewport on every step. The
switch to the English original is carried in the answers, and the full
path from the overview posts the instrument, the reader's language and
the item scores. Questionnaire text loads through the production chunk
loader.

The wizard's markup cases are removed from the surface suite; the
loading-frame case stays there, since a browser loads the chunk before
a test can look. The browser project inlines an empty process.env for
Next's client modules.
2026-10-23 13:16:02 +02:00
Marc-André Bombeck c9917d2de0 refactor(settings): split the managed record settings forms
Each family form moves into managed-record/ beside a small kit (the
coercion helpers, the form footer and the switch row); the section keeps
the record-scoped read, the save and the family switch.
2026-10-23 13:13:55 +02:00
Marc-André Bombeck 041c33ec55 test(e2e): wait for hover transitions to settle before the check-in axe scans
The scan waited a fixed 300 ms after moving the pointer away. It now waits
until no CSS transition is running, which is the state the wait stood for.
2026-10-23 13:13:48 +02:00
Marc-André Bombeck 4e0e3019ad test: drop an auth-guard helper nothing reads 2026-10-23 13:13:06 +02:00
Marc-André Bombeck de970ac079 test(e2e): restore a person's full backup into their recreated account
The backup journey restored a snapshot over the account it came from. A
fifth act now follows the runbook's new-host path: a second account writes a
reading and a mood entry and downloads its full backup from the export card,
the account is recreated empty under the same id, the admin uploads the file
in the backups console and restores it, and the person signs in to the same
rows under the same ids. The restore wait is shared by both restores.
2026-10-23 13:12:45 +02:00
Marc-André Bombeck e626f2123f refactor(integrations): run one write step after every provider sync
`integrations/sync-kit/after-write.ts` is the tail every provider write
ends in: the arrivals go out, the touched days are refolded, the read
caches are marked stale back to the earliest touched reading, and the
touched status cards are re-warmed (through `afterMeasurementMutation`,
shared with the interactive routes); Withings also evicts the dashboard
snapshot. A full-history backfill refolds its whole touched range once
through `afterProviderBackfill`.

Withings, WHOOP, Polar, Nightscout, Google Health, Fitbit and Oura drop
their hand-written tails, and no provider module imports the comprehensive
insight generator any more; a guard holds the provider folders to that.
Differences from the copies: the status cards are re-warmed even when the
fold failed, and the caches are marked stale back to the earliest touched
reading instead of wholesale, so older closed blocks survive a sync.

Several provider unit tests mocked an export the old tails never reached
(their failures were caught and only logged); the mocks now name what the
code calls.
2026-10-23 13:12:23 +02:00
Marc-André Bombeck 7c1ed25915 refactor(documents): split the document detail sheet
The sheet becomes a composer over detail/: the original-file preview and
download controls, the detail read with its writes, the AI layer with its
gate and review-first apply, and the field, meta and footer sections.
Guards that read the sheet's source now read the detail folder.
2026-10-23 13:11:20 +02:00
Marc-André Bombeck b161ed3bd2 test(workouts): drive the manual workout form in a real browser
Pick a sport, type a duration and distance, press Save and read the
batch body: miles asked of an imperial reader arrive in metres, a blank
start counts back from now, a retry after a failed save reuses the
form's external id, and an edit re-posts the stored instants and the
fields the form does not show. Field errors, the pending state and the
phone layout are measured in the page.

The markup and edit-render cases are deleted from the old suite, which
keeps the save and delete helper checks as manual-workout-save.test.ts.
2026-10-23 13:09:01 +02:00
Marc-André Bombeck 3ab85f85f9 test(measurements): drive the measurement form in a real browser
Type into the form, press Save and read the request body off the fetch
boundary: blood pressure as one batch, a weight typed in lb stored in
kg, a decimal comma, glucose typed in mmol/L stored in mg/dL with its
meal context, a sleep typed as 7:30 stored as 450 minutes. Native
required validation, the pending state, a refused write's localised
message, the module-off type list, the last-used type and the menu's
Reset are covered too.

The unit-label and glucose-unit markup tests are deleted; their source
pins are replaced by the wire assertions. The module-types file keeps
its pure ownership checks and drops its render.

The browser helper gains a Date-only clock pin, clears localStorage
between tests, carries an envelope's meta, and answers the providers'
locale mirror without counting it as the component's request.
2026-10-23 13:07:29 +02:00
Marc-André Bombeck 34a93b883f test(e2e): a settings change persists on the server
The time-format choice is changed through the account settings and read
back after a reload, in a second browser context that shares only the
session cookie, and on /api/auth/me, on the settings journey's own account.
2026-10-23 13:04:04 +02:00
Marc-André Bombeck ac77e52e77 refactor(measurements): hand both row layouts one row context
The table and the card list each took nine props per row and passed
them on to the shared row actions. One MeasurementRowContext carries
them, and rowState derives a row's grouping, selection and drill-down
state in one place for both layouts.
2026-10-23 13:03:39 +02:00
Marc-André Bombeck 0e9daa51f9 test(e2e): read a WHO-5 check-in word for word in German and Brazilian Portuguese
The check-in journeys anchor on data attributes so they read the same in
every language, which leaves the words themselves unchecked. Two journeys on
the wellbeing account now read the stem, the first and last item and the
answer labels in German (the validated WHO translation, reproduced exactly)
and in Brazilian Portuguese (the translation written for the app, with its
note), submit, and check the score on screen against the assessment the
server stored, with its language and kind of wording.
2026-10-23 13:02:32 +02:00
Marc-André Bombeck cc36f46f10 test(filters): drive the URL filter rail in a real browser
A FilterBar over useUrlFilterSync now proves the contract end to end:
a facet picked from the open menu pushes a history entry, typed input
replaces the current one, a pill's clear and Reset leave a bare URL,
two writes in a row both land, Back and an edited address re-seed the
pills, and the open menu rests inside the viewport under its pill.

The markup tests for FilterBar and useUrlFilterSync are deleted; the
popper-placement source pin is replaced by the measured menu position.
The browser project re-bundles its dependencies on every run so a
cache warmed before a new file existed cannot load a second React.
2026-10-23 13:00:19 +02:00
Marc-André Bombeck 0bf6a54be0 test(e2e): import lab results from a CSV on an account of its own
The import journey ran on the shared account in both projects, so one
project's duplicate verdict could rest on the other's import. It now runs on
the labs journey's account, emptied first, checks that the dry run writes
nothing and the import writes exactly the accepted row, and finds the result
on the labs page before the second run.
2026-10-23 13:00:00 +02:00
Marc-André Bombeck d7f0d88d59 refactor(checkups): split the checkups section into form, card and postpone sheet
The checkups file held two components of about 620 lines each. Their
parts move to components/measurement-reminders/checkups/:

- reminder-form: the type groups, cadence presets, the form state and
  its mapping to and from a reminder (pure)
- reminder-form-sheet: the create / edit sheet and its cadence fields
- checkup-card: the card in both views, its menu, details, skip and
  snooze lines, next / last lines and action row
- postpone-sheet: the postpone chips and date, the honest skip and its
  confirmation, and the calendar-day offsets

vorsorge-section.tsx keeps the list, the value capture and the visit
filing. Markup and behaviour are unchanged; the guards name the modules
that now hold what they check.
2026-10-23 12:59:49 +02:00
Marc-André Bombeck f185855d76 test(e2e): log a mood at a chosen time against the real server
The mood timestamp spec answered the POST and the list read itself. It now
runs on the mood journey's account: the instant typed into the form is the
instant stored, and the entry is in the list, also after a reload.
2026-10-23 12:58:39 +02:00
Marc-André Bombeck 7445b69842 test(e2e): add a weight through the dashboard against the real server
The add-measurement flow answered its own POST and list read, so it proved
that the list renders a row the test typed in. It now runs on the
measurement journey's account, emptied first, and checks that the reading
the server stored is the one entered and that the dashboard tile, the weight
chart's data table and the day view all show it.
2026-10-23 12:58:36 +02:00
Marc-André Bombeck b6f45c0d88 refactor(integrations): move every provider OAuth client onto one factory
`oauth-kit/client.ts` builds a provider's consent URL, code exchange,
refresh and deauthorization from a declaration of what differs: endpoints
and scope, client authentication (form or HTTP Basic, with or without the
client id beside it), the fields a grant adds, how an answer is classified
and the refusal named, the error type, and where the token sits in the
body. The seven hand-written copies of the redirect URI, authorization URL,
token post, Basic header and PKCE pair are gone; each provider keeps an
`oauth.ts` with its declaration.

Shared behaviour is now uniform: a 2xx without a token object (an empty or
non-JSON body) is a transient `empty_token_body` failure for every
provider. WHOOP, Strava and Google Health used to return null, and
Withings threw an unclassified SyntaxError; the OAuth wire golden records
exactly these lines changing and nothing else. Oura's live/legacy endpoint
fallback, Google's invalid_grant lift, Strava's Fault summary and Fitbit's
errorType are declared per provider.
2026-10-23 12:57:47 +02:00
Marc-André Bombeck 518a381ff9 test(e2e): sign in with a password and a passkey, and sign out, against the real server
The login spec stubbed the login route to prove the form shows an error. It
now spends a real 401, and three journeys run on the sign-in account of
their project: a password sign-in that lands on the dashboard as that
account, a sign-out whose old cookie is refused afterwards (with a positive
control before it), and a passkey registered in settings through Chromium's
virtual authenticator that signs the account back in.
2026-10-23 12:55:19 +02:00
Marc-André Bombeck ca1cceb3a9 test(segmented): drive the control in a real browser
Roving focus, Home/End, wrap, toggle mode, disabled and the chosen
segment's fill are now asserted through key presses and computed styles
instead of markup greps. The static render test is deleted; its two
source sweeps move to segmented-single-implementation.test.ts.

The browser setup sets the default theme class so colour tokens
resolve, and pre-bundles the browser files' dependencies so a cold
cache does not reload the page mid-run.
2026-10-23 12:54:24 +02:00
Marc-André Bombeck 25b5833b11 refactor(settings): split the dashboard layout section into draft, lists and hero settings
DashboardLayoutSection was a 620-line component. Its parts move to
components/settings/dashboard-layout/:

- layout-model: the reorder and hero-kind helpers and the label maps
- use-dashboard-layout-draft: the tokened read, the draft, save and
  reset (with the snapshot and digest refresh) and the draft edits
- hero-settings-fieldset: the top card's switch, lead and highlights
- widget-lists: the sortable web list, the native-only group and rows

The section composes them. Markup and behaviour are unchanged.
2026-10-23 12:52:36 +02:00
Marc-André Bombeck b826642853 test(e2e): tag the unmocked release journeys @critical and hold them to the real API
Backup and restore, the adherence journey, delegated writes and the offline
page already run against the real server. They carry the @critical tag now,
and a structural guard refuses any /api interception in a file that carries
it, directly or through an imported helper, with a floor on the tagged count.

Global setup seeds one account per critical journey and per project, so the
journeys still to come can run at both widths without writing into each
other or into the shared fixture account. pnpm e2e:critical runs the set.
2026-10-23 12:52:12 +02:00
Marc-André Bombeck 3d1692204b test: add a browser Vitest project for interactive components
Run *.browser.test.tsx in headless Chromium through the Playwright
provider. The shared helper in tests/browser/ mounts the app's own
providers, stubs only the fetch boundary, and shims next/navigation
onto the page's real location and history.
2026-10-23 12:50:42 +02:00
Marc-André Bombeck 0e0e661cf0 Merge branch 'feat/v146-server-insights' into feat/v146-server 2026-10-23 12:48:08 +02:00
Marc-André Bombeck f942389a4b Merge branch 'feat/v146-server-meds' into feat/v146-server 2026-10-23 12:41:47 +02:00
Marc-André Bombeck 58215f3338 refactor(medications): split the dose-history ledger into its hook, row and dialogs
The ledger file held two components of 404 and 314 lines. It now reads:

- dose-history/use-dose-history-ledger: the window read, summary, day
  groups and the optimistic mark, plus the owner's edits (toggle, pin,
  release, delete) and one reconcile step after every write
- dose-history/ledger-row-item: the row and, apart from it, the owner's
  row menu
- dose-history/ledger-summary-chart and ledger-confirms

dose-history-ledger.tsx keeps the ledger layout. Markup and behaviour
are unchanged; the guards name the modules that now hold what they
check.
2026-10-23 12:41:46 +02:00
Marc-André Bombeck 3a4e97733d refactor(medications): move the medication create out of its route
POST /api/medications keeps authentication, the body, the validation
answer and the HTTP mapping; the create (the mirrored-medication dedupe and
cap, the one-shot shape refusal, the initial course, the nested schedules,
the insert and its tail) moves verbatim to lib/medications/create-medication.ts
and answers created, existing or refused. The route falls from 541 to 94
lines. The parity walk recorded before the move is unchanged, and the
sharing guard reads the service beside the route for the handler body.
2026-10-23 12:41:46 +02:00
Marc-André Bombeck 069e07b510 Merge branch 'release/v1.46.0' into feat/v146-server
The size baselines take the lower value of the two sides per entry, and every ratchet baseline is written back to what the tree measures now (file and function sizes, version stamps, source reads, import cycles, route body parsing).
2026-10-23 12:39:42 +02:00
Marc-André Bombeck 1a84cf27a8 Merge branch 'release/v1.46.0' into feat/v146-integrations
# Conflicts:
#	src/__tests__/baselines/source-file-lines.json
#	src/__tests__/baselines/source-function-lines.json
2026-10-23 12:39:13 +02:00
Marc-André Bombeck 9725ba394d refactor(measurements): move the list modes and the hand-entry writes out of their route
GET /api/measurements keeps authentication and the query refusal and hands
the validated query to measurementListResponse (lib/measurements/list),
which tries the list modes in their old order. POST keeps authentication,
the body, the bridge-source refusal and the three validation answers; the
single and combined writes with their shared tail move to
lib/measurements/create/create-readings.ts, the named sleep-stage write to
create-sleep-stage.ts. The route falls from 938 to 274 lines.

The parity walk recorded before the move is unchanged; the guards that name
the measurement writers, the write tail and the bounded reads follow the
code.
2026-10-23 12:38:27 +02:00
Marc-André Bombeck 4807a70c1d refactor(jobs): declare every queue in a typed registry and load handlers on first job
The five hand-written registrars (about 5,500 lines, one 997-line function)
are replaced by `jobs/registry/`: one `defineJob` entry per queue with its
cron, schedule send options, policy, work options, lock, lane and a handler
loader. `registerJobGroup` creates, schedules, retires and binds each group
in the order the registrars used; the worker boot walks the groups and runs
their boot discovery.

Handlers load on their queue's first job. The facts a declaration needs at
boot (names, crons, concurrency, send options) moved out of the heavy job
modules into light `*-queue.ts` siblings, and their importers point there,
so loading the registry evaluates 288 modules instead of the 1,164 the
registrars pulled in. The formerly inline handlers moved verbatim into
handler modules under `jobs/reminder/`.

Fitbit's queues live in `fitbit/jobs/definitions.ts` and enter the registry
through one spread, so removing the integration later is that folder and
that line.

`tests/integration/queue-registration-parity.test.ts` proves pg-boss holds
exactly what the registrars left (queues, schedules, bindings, byte for
byte) and loads every handler once. The queue tests and the runtime guard
assert on the registry objects instead of reading registrar source.
2026-10-23 12:36:09 +02:00
Marc-André Bombeck 3eb5aa9364 refactor(documents): split the vault page into filters, library, selection and detail
DocumentsView carried 970 lines of state before its first element. The
state moves to components/documents/vault/:

- selection-history: the `?doc=` history entry helpers (pure, tested)
- use-vault-filters: URL filters, the debounced search and the `/` key
- use-vault-library: usage, the paged list and its processing poll,
  layout, uploads and their intake paths, the corpus-index affordance
- use-vault-filter-choices: conditions, procedures, visits, years
- use-vault-selection and use-vault-bulk: the selection with ranges and
  Escape, the confirmed delete, re-filing, linking and the bulk share
- use-vault-detail: the detail sheet driven by `?doc=`, its close and
  the check that the close took
- vault-page-actions, vault-drop-overlay

The page composes them in about 300 lines; markup and behaviour are
unchanged, and the guards follow the code they watch.
2026-10-23 12:35:46 +02:00
Marc-André Bombeck 05b9697848 Merge branch 'feat/v146-server-status' into feat/v146-server 2026-10-23 11:47:13 +02:00
Marc-André Bombeck 8d48733c6a test(measurements): pin the measurement list and create before moving them into services
A parity walk over GET and POST /api/measurements: every create arm (single, combined, named sleep stage) with its duplicate and refusal, a bridge token, a delegated write, and every list mode. Sealed bytes are recorded by length, since each write carries a fresh nonce.
2026-10-23 11:47:09 +02:00
Marc-André Bombeck f7051ee15f fix(openapi): declare what the measurement list and create answers carry
The sleep list rows of GET /api/measurements carry the main night's stage
minutes, time in bed, time awake and awakenings, and the aggregate buckets
carry the bucket's lowest and highest reading; none of these were in the
contract. The create answer's union tried the plain row before the named
sleep-stage result, so a contract check read a named-stage answer as a plain
row and dropped its status. The responses are unchanged; the contract now
says what they hold.
2026-10-23 11:47:08 +02:00
Marc-André Bombeck a76258f985 refactor(insights): keep the card temperature private to the pipeline 2026-10-23 11:44:48 +02:00
Marc-André Bombeck 8bb58d8760 test(medications): pin the medication list and create before the create moves 2026-10-23 11:44:26 +02:00
Marc-André Bombeck c1052c4f17 refactor(dashboard): move the widgets layout logic out of its route
The route keeps authentication, the body, the base token, the layout schema
(the inline-schema inventory reads it there), the conflict mapping and the
responses. The rest moves verbatim into lib/dashboard/widgets/, one module per
stage: the layout read and the availability probe for GET, the unknown-id
drop, the refusal report and the stored-field merge for PUT, and the locked
reset for DELETE. The route is 317 lines, down from 513; its longest
function is 85 lines, down from 199.

The parity walk recorded before the move (dashboard-widgets.parity) is
unchanged. The tombstone-read guard now also scopes lib/dashboard/widgets/,
so the moved HRV probes stay policed.
2026-10-23 11:43:10 +02:00
Marc-André Bombeck 3c0063edb2 refactor(measurements): split the measurement list into view state, data, rows and sheet
MeasurementList was a 1,586-line component with fourteen pieces of
state. Its parts move to components/measurements/list/:

- list-model: the row type, page size, labels and formatting helpers
- use-measurement-list-filters: URL-owned filters, the value-range
  draft, page, sort and the page-scoped selection
- use-measurement-list-data: the list read, the soft deletes with undo
  and the selection operations
- use-measurement-edit: the edit draft, its unit conversion both ways,
  the save and the discard guard
- use-measurement-row-display: how a row shows its reading, as a pure
  rule (rowDisplayFor) bound to the account
- measurement-filter-rail, measurement-table (one row component),
  measurement-card-list (one card component), row-actions shared by
  both layouts, measurement-edit-sheet, day-drill-down

measurement-list.tsx lays these out in about 330 lines. Markup and
behaviour are unchanged. The glucose edit checks call the conversion
functions instead of matching source, and the guards that named the
old file name the module that now holds what they check.
2026-10-23 11:42:43 +02:00
Marc-André Bombeck 62b050f711 refactor(medications): move the dose correction and undo out of their route
The PUT handler was one 390-line callback. The route keeps the record
resolver, the 404, the body and its schema refusal; the correction moves
verbatim into lib/medications/update-intake-event.ts, split along its
stages in the order they always ran: the start-day floor, the slot
attribution, the in-place edit or the move onto another slot, then the
audit row, the event and the tails. The undo moves into
delete-intake-event.ts. The route is 124 lines; no function in either
module is over 140.

The parity walk recorded before the move (medication-intake-event.parity)
is unchanged. The sharing guard's audit, tombstone and overwrite legs read
the route together with the modules it hands its bodies to, listed in one
map that each route must still import, and the moved-dose provenance leg
now also asserts the original source is carried.
2026-10-23 11:40:06 +02:00
Marc-André Bombeck 2bfc5aee4b test(dashboard): pin the widgets layout route before moving its logic
Record the parity walk for GET, PUT and DELETE /api/dashboard/widgets on the
current code: the first and the cached read, a full save with ids outside the
catalogue, a partial save that keeps stored fields and the per-chart range,
the schema, empty-body and base-token refusals, a save on the current token,
the recovery-off availability probe with and without an HRV series, a
delegate's read and refused writes, the reset, and an anonymous call.
2026-10-23 11:39:48 +02:00
Marc-André Bombeck 899b86f4d4 refactor(workouts): move the batch ingest out of its route
postBatch was a 700-line function. The route keeps authentication, the
per-account preference read, the body caps, the rate limit, the parse and
the bridge-source refusal; the ingest moves verbatim into
lib/workouts/batch-ingest/: the first pass (entries), the duplicate probe
and write plan with the manual-correction rules (plan), the transaction
that writes corrections, workouts, tracks and series (write), and the
orchestrator with the audit, record detection, wide event, cache and
arrivals (ingest). The route is 275 lines.

The parity walk recorded before the move is unchanged; the guards that
name the track writer, the Workout writer set and the per-entry
external-id check point at the service files.
2026-10-23 11:39:00 +02:00
Marc-André Bombeck dd52a8d92a test(workouts): pin the batch ingest before moving it into a service
A parity walk over POST /api/workouts/batch: a mixed first batch with every per-entry refusal and the cross-source twin, the same batch with an enrichment, a corrected hand entry, a kept track, both whole-batch refusals and a narrow bridge token. Responses, wide events, queue tails and stored workouts with their series and tracks are pinned.
2026-10-23 11:34:11 +02:00
Marc-André Bombeck 4c9dc2be5b test(medications): pin the dose correction and undo before they move 2026-10-23 11:33:51 +02:00
Marc-André Bombeck 7ea247e193 Merge branch 'feat/v146-server-parse' into feat/v146-server 2026-10-23 11:30:47 +02:00
Marc-André Bombeck 25c3c050ee refactor(measurements): move the batch ingest out of its route
postBatch was one 1255-line function. The route keeps authentication, the
rate limit, the body, the entry cap, the schema and the bridge-source
refusal; the ingest moves verbatim into lib/measurements/batch-ingest/, one
module per stage in the order they always ran: the first pass (prepare),
the measurement writes with their prefilters and set-based insert, the
hourly sums, the sync checkpoint, the audit and wide-event report, and the
cache and arrival tail. The route is 274 lines; no function in the folder
is over 200.

The parity walk recorded before the move (measurements-batch.parity) is
unchanged, as is the set-insert golden. The guards that read the route for
the per-entry external-id check, the plausibility validator, the write
tail, the dense-read bound and the unit audit now read the service files.
2026-10-23 11:30:46 +02:00
Marc-André Bombeck ca04ef01b3 refactor(api): read the remaining settings and admin route bodies through parseJsonBody
Admin, coach, consent, chart-overlay, device registration, insights
feedback and provider chain, ECG ingest, onboarding, search and the AI
provider settings move to parseJsonBody with the same message, error
code, status and wide-event action they answered with. A refusal whose
annotate meta is computed from the issues, or puts its keys in another
order than the helper would, keeps that annotate verbatim in onInvalid.

The routes that still call safeJson do something between the read and the
schema: an optimistic-concurrency token split off the body, a count cap
answered first, a refunded quota slot, a conditional read, a non-Zod
check, or the body handed to a service.
2026-10-23 11:29:23 +02:00
Marc-André Bombeck af14d0aa7e refactor(api): read the health-record route bodies through parseJsonBody
Labs, biomarkers, custom metrics, cycle, anamnesis, illness, measurement
reminders, the measurement edit, medication sub-routes, mood and mental
health assessments still spelled out safeJson, safeParse and the 422 by
hand. Each becomes one parseJsonBody call with the size cap, action,
issue count, error code and status it had. The audit rows and the
unstable-externalId breadcrumbs move into onInvalid, in the order they
ran; where a breadcrumb reads the body, the wide-event annotate moves with
it. A `body ?? {}` or a payload unwrapped from the body becomes `input`.

The medication ingest route reads through the helper too; its Bearer
resolution happens before the read and is unchanged.
2026-10-23 11:29:22 +02:00
Marc-André Bombeck f06df5fd71 test(api): count parseJsonBody refusals in the issue-list guard
A route that reads its body through parseJsonBody has no
`if (!parsed.success)` block, so the guard lost sight of every migrated
site: its floor of 200 failed at 139 and the token inventory reported the
coach message feedback entry as stale while that route still answers
`feedback.body.invalid` in `error`, now through the `message` option.

Each parseJsonBody call is now a site of its own, carrying the issue list
by construction unless it names a status other than 422, and its `message`
option is read as the refused string by the token sweep.
2026-10-23 11:25:36 +02:00
Marc-André Bombeck 5075536f7f refactor(charts): split HealthChart into its read, view, layers and header
HealthChart was a 1,929-line component. Its parts move to
components/charts/health-chart/:

- series-math: series names, day keys, moving average, personal
  baseline, annotation and marker placement (pure)
- use-health-chart-series: the fetch window, the batched-slice decision,
  the daily read and the data-ready latch
- use-health-chart-view: the visible slice, bucketing, the average and
  trend lines, the prior period, the visible-range stats and the y domain
- use-health-chart-overlays: value bands, target zones, baselines, the
  trend read-out, the spoken summary and the table columns
- chart-header, band-overlay, chart-tooltip-content
- chart-layers: the reference layers and series as plain functions that
  return the Recharts elements, so the plot's child list is unchanged

health-chart.tsx keeps the component and its path (chart-runtime still
imports it from there) and shrinks to 488 lines. Output is unchanged.
The guards pinned to the file follow the code into the folder, and the
baseline-gating check now calls the layer instead of matching source.
2026-10-23 11:25:16 +02:00
Marc-André Bombeck 17e0f47411 refactor(insights): move the feature window arithmetic to a leaf module so signals-of-day no longer imports the extractor 2026-10-23 11:23:32 +02:00
Marc-André Bombeck daaa44fd82 refactor(insights): read the mood bands from a leaf module so mood-patterns no longer imports its hub 2026-10-23 11:21:29 +02:00
Marc-André Bombeck 3df80389ab test(kit): record route parity snapshots, starting with the batch ingest
tests/kit/parity.ts pins what a route answers, stores and reports for a
fixed walk of cases, so the code behind it can move under a byte-for-byte
comparison: the response, the rows read back, the request's wide event and
the queue and arrival tails it asked for, with generated ids, server
timestamps and request ids replaced by stable placeholders.

The first walk covers POST /api/measurements/batch: a mixed first sync with
every per-entry refusal, the same sync again, a background day total, the
manual and HealthKit mirror merge, a backfill, both whole-batch refusals and
a narrow bridge token. The clock is pinned, so the snapshot holds on any day.
2026-10-23 11:19:46 +02:00
Marc-André Bombeck 6a58a7df78 refactor(insights): compose the biomarker card from the pipeline stages 2026-10-23 11:18:46 +02:00
Marc-André Bombeck 1a99b8f4f0 refactor(insights): compose the generic metric card from the pipeline stages 2026-10-23 11:17:06 +02:00
Marc-André Bombeck a812a902dd refactor(dashboard): build tiles and charts from shared card builders
Most strip tiles differ only in their series, label, unit and icon, and
most chart cards share the reveal report, the batched slice, the time
zone and the day doors. One metric-card and one chart builder carry the
shared props, so each registry entry states only what is its own. The
dashboard route's client code shrinks by 0.2 KB gz; rendering is
unchanged. The tile-destination guard now builds every strip tile from
the registry instead of reading ids out of the source.
2026-10-23 11:14:47 +02:00
Marc-André Bombeck d448e6c9c6 refactor(insights): move the medication card onto the pipeline with its own note codec 2026-10-23 11:14:00 +02:00
Marc-André Bombeck 08403a93b4 Merge branch 'feat/v146-server-tools' into feat/v146-server 2026-10-23 11:13:05 +02:00
Marc-André Bombeck 5b49fa6680 refactor(api): read route bodies through parseJsonBody
132 route handlers spelled out safeJson, safeParse and the 422 refusal by
hand in one of six shapes. Each block becomes one parseJsonBody call with
the same size cap, wide-event action, issue count, error code, message and
status it had, so every response is the one it was. The comments inside
the replaced refusal blocks go with them.

A ratchet (route-body-parse-ratchet) counts the route files that still call
safeJson themselves; the count may only fall.
2026-10-23 11:12:39 +02:00
Marc-André Bombeck 07e1e83ee8 feat(api): read and validate a JSON request body in one call
parseJsonBody composes safeJson, the Zod safeParse, the validation-failed wide-event action and the multi-issue 422 envelope, with the variations routes use (size cap, input derivation, issue count, error code, message, status, an onInvalid hook) as options. The refusals are the existing helpers' bytes, which the unit test compares directly.
2026-10-23 11:12:38 +02:00
Marc-André Bombeck 6f3ad9defa refactor(insights): reduce the mood card to its snapshot and its relation board 2026-10-23 11:11:13 +02:00
Marc-André Bombeck 87a35b670d refactor(insights): reduce the blood-pressure card to its snapshot 2026-10-23 11:08:35 +02:00
Marc-André Bombeck 0a88155aec Merge branch 'release/v1.46.0' into feat/v146-ui
# Conflicts:
#	src/__tests__/baselines/source-file-lines.json
#	src/__tests__/baselines/source-function-lines.json
2026-10-23 11:08:24 +02:00
Marc-André Bombeck 6d3cfbba05 refactor(insights): reduce the overview card to its snapshot 2026-10-23 11:06:42 +02:00
Marc-André Bombeck 49b1888009 refactor(insights): reduce the BMI card to its height check, input gate and snapshot 2026-10-23 11:06:41 +02:00
Marc-André Bombeck e25b6365fd refactor(dashboard): compose the page from a tile registry
The dashboard page held every tile and chart card in one 2,100-line
function: a 1,279-line inline block pushed 24 cards behind 36 show flags.
The cards now live in components/dashboard/tiles/, each with its layout
widget, its own visibility rule (data floor, module, resolved layout) and
its render; the layout toggle and order are applied once by the registry.

- use-dashboard-data: the snapshot or legacy reads, the layout, mood,
  thresholds, the batched chart series and the loading gate
- tile-context: summaries, display units, comparison deltas, freshness,
  directions and target bands, derived once per render
- strip-tiles / chart-tiles: the two registries and their builders
- dashboard-tiles: the strip, the chart row, their skeletons and the
  empty state

The page is a composer of about 170 lines. Rendering is unchanged. The
guards that read page-client.tsx read the whole registry through one
helper; the weight-direction, HRV freshness, BP-in-target comparison and
chart-reveal checks now build the real tiles instead of matching text.
2026-10-23 11:06:32 +02:00
Marc-André Bombeck b1400a8130 refactor(cycle,medications): break the gate and compliance import cycles
The module gate and the cycle gate imported each other for one pure
predicate. isCycleEnabled moves to lib/cycle/enabled.ts and both gates
import it from there.

The band minter read DOSE_WINDOW_DEFAULTS through the analytics/compliance
re-export barrel, which loads the compliance display and with it the
scheduling modules that import the minter. It now imports the constant from
the module that defines it. Runtime import cycles fall from 6 to 4.
2026-10-23 11:05:08 +02:00
Marc-André Bombeck de866107ad refactor(insights): reduce the weight card to its input gate and snapshot 2026-10-23 11:04:15 +02:00
Marc-André Bombeck ea01610a56 refactor(insights): run the status cards through one staged pipeline, starting with pulse 2026-10-23 11:04:14 +02:00
Marc-André Bombeck 78ac2c5aeb refactor(sharing): read and judge grants from a leaf module
lib/sharing/grants.ts held both the grant transitions and the predicates
every reader asks (state, level, scope, the live grant for a pair). The
transitions reach into the managed-profile lifecycle and the OAuth connect
target, and both of those read a live grant, so grants, lifecycle and
connect-target formed an import cycle.

The predicates, findActiveGrant and touchGrantUsage move verbatim to
lib/sharing/grant-access.ts, which imports only the database client and
the scope vocabulary. Importers and the mocks that replaced those
functions point at the new module. Runtime import cycles fall from 7 to 6.
2026-10-23 11:02:10 +02:00
Marc-André Bombeck e84b80ee3c refactor(boot): register the offline-geo notice from its own helper
Keeps register() under the function-size cap; the test pins that register() still calls it.
2026-10-23 11:01:47 +02:00
Marc-André Bombeck 5a8c736017 chore: keep the v1.45 size baselines for the navigation the topics preview had split
Reverting the topics preview puts sidebar-nav, bottom-nav and the
/api/auth/me handler back to their v1.45 code; their baselines return to
the v1.45 values, nothing grows beyond them.
2026-10-23 11:00:45 +02:00
Marc-André Bombeck 0139c22ae3 refactor(geo): move the offline-geo admin notice out of the resolver
auditLog resolves login locations through lib/geo.ts, and the resolver
imported the notification dispatcher for the offline-database notice. The
dispatcher's collaborators write audit rows, so audit, geo, the dispatcher,
channel state, delivery identity, managed-profile lifecycle, the OAuth
connect target and the grants formed one strongly connected cluster.

The notice now lives in lib/notifications/offline-geo-notice.ts. The
resolver keeps deciding when it runs (first public-IP lookup of a process,
again after a runtime database fetch) through a slot the notice module
fills at boot from instrumentation.ts. Runtime import cycles fall from 13
to 7.
2026-10-23 10:57:00 +02:00
Marc-André Bombeck df6e182e0b test(integrations): pin what each provider mapper makes of sanitised wire fixtures 2026-10-23 10:56:03 +02:00
Marc-André Bombeck ee1561496b refactor(auth): move the request guards out of api-handler into lib/auth/guards
api-handler.ts held two modules: the route wrapper and thirteen guards. The
guards now live in lib/auth/guards/, one module per family (the caller,
the acting-account carrier, the record modes, the cookie-only helpers, the
fresh-factor and recent-proof gates, the Bearer elevation, MFA management).
api-handler.ts keeps the wrapper and re-exports every guard, so it stays the
one public entry and no route changes its import.

The bodies move verbatim; only the version prefixes of moved comments are
dropped. The structural guards that read the auth layer now find the module
that declares a guard by name (helpers/auth-guards.ts) instead of a fixed
path, and a new guard holds the entry: nothing outside the folder but
api-handler.ts imports a guard module.
2026-10-23 10:54:23 +02:00
Marc-André Bombeck faeebfc63a refactor(coach): run each Coach tool from a module of its own under tools/run 2026-10-23 10:54:19 +02:00
Marc-André Bombeck 4308682c96 test(insights): pin every status card's prompts, results and stored notes in golden files 2026-10-23 10:52:49 +02:00
Marc-André Bombeck e375a0e0ba refactor(mcp): give every read tool a module of its own under mcp/tools 2026-10-23 10:49:40 +02:00
Marc-André Bombeck 0182f91092 Revert "feat(topics): compute topic overviews and topic pages on the server"
This reverts commit cc04e0944d.
2026-10-23 10:46:55 +02:00
Marc-André Bombeck c1911e71e3 Revert "feat(topics): add the Topics preview area, its navigation entry and its switch"
This reverts commit c68e833dcc.
2026-10-23 10:46:54 +02:00
Marc-André Bombeck ce0c2a3f3f Revert "test(e2e): walk the Topics preview and sweep its pages at 390 px and with axe"
This reverts commit 957c2f279e.
2026-10-23 10:46:43 +02:00
Marc-André Bombeck b2ebcb198d test(integrations): pin what every OAuth client sends and makes of each token-endpoint answer 2026-10-23 10:46:42 +02:00
Marc-André Bombeck 5c6a1cbb36 test(jobs): pin every queue, schedule and handler binding the worker registers 2026-10-23 10:44:53 +02:00
Marc-André Bombeck b085bb8771 test(mcp,coach): pin the advertised tool lists and the Coach dispatch table 2026-10-23 10:42:58 +02:00
Marc-André Bombeck 9beaa8f640 build(deps): bump the AWS SDK S3 packages to 3.1153.0 and react-query to 5.104.5 2026-10-23 10:37:41 +02:00
Marc-André Bombeck 9de64cae3e Merge branch 'release/v1.46.0' into feat/v146-questionnaires
# Conflicts:
#	src/__tests__/baselines/source-file-lines.json
#	src/__tests__/baselines/source-function-lines.json
2026-10-23 10:20:34 +02:00
Marc-André Bombeck e94b7f828c fix(mental-health): keep the unvalidated-translation note on screen with every step
On a phone, arriving on a step scrolled to the question and left the note
and the switch to the English original at the top of the page, out of
view. They now sit at the top of every step's card, above the stem, and
the step scrolls its top block (note, stem, question) into view as one,
so a block already on screen does not move and focusing the question
finds it visible. The review step carries the note the same way.

The phone e2e run asserts the note is in the viewport on the first and a
later step.
2026-10-23 08:26:24 +02:00
Marc-André Bombeck 957c2f279e test(e2e): walk the Topics preview and sweep its pages at 390 px and with axe
A journey on its own account, seeded with 40 days of heart and sleep,
weight, blood pressure, a short run of steps and mood entries, and lab
markers with and without a trend: switched off there is no More tile
and no sidebar entry, the pages leave for Insights and the routes
answer 404; switched on in Settings the More sheet leads with the
Topics tile and the sidebar carries the entry below Insights; every
offered topic renders, with the pair that has 14 paired days stated
and the pairs short of it saying so.

The phone sweep opens every topic page with the preview on, and the
axe matrix scans the overview and two topics in both themes. The
Settings sweep gains the Preview features page.
2026-10-23 08:19:01 +02:00
Marc-André Bombeck c68e833dcc feat(topics): add the Topics preview area, its navigation entry and its switch
/topics shows a tile per topic the record holds data for, and names the
empty topics and the module-hidden ones once each. /topics/<topic>
shows the topic today, its signals side by side over 7, 30, 90 days or
a year against their usual range, how they move together, their trends,
its blocks, the single-metric pages behind it and what it is missing.
Every topic links into the metric pages, which stay as they are, and
each page ends on a neutral note that this is a preview.

Settings > System > Preview features is listed again and switches the
area on for the person reading. While it is on, the sidebar carries a
Topics entry with a Beta badge directly below Insights and the phone's
More sheet leads with a Topics tile; the bottom bar does not change.
The pages leave for Insights when the switch is off.

The charts load through the shared chart runtime. The sidebar rows move
into their own module and the More sheet tile into its own component,
so neither shell file grows; the lab trend wording is shared between the
marker page and the metabolism topic. Copy in all nine locales.
2026-10-23 08:18:53 +02:00
Marc-André Bombeck cc04e0944d feat(topics): compute topic overviews and topic pages on the server
GET /api/topics answers one entry per topic the record holds data for:
a headline value, a rule-based sentence and a 30-day sparkline.
GET /api/topics/{topic} answers a topic's signals with their usual
ranges, side-by-side series for a chosen window, the relationships
between its signals, 90-day trends, what it holds nothing for yet, and
its blocks (training load, lab markers with their trend, screener
trends, the cycle day).

Every figure comes from reads the metric pages already make: the local
daily values, the reconstructed nights, the mood score, computeLabTrend,
the training load and the screener trends. No model is involved. A
relationship says nothing below 14 paired days and is only called a
pattern at p < 0.05; a lab trend needs three readings spanning a year.

The preview is the viewer's own opt-in: with it off both routes answer
404 topics.preview_off. Topics, signals and blocks follow the record's
module switches and the sections the viewer's grant opens; the routes
read across sections, so a scoped grant is refused like the timeline.
/api/auth/me publishes the caller's opt-ins as previewFeatures.
2026-10-23 08:18:43 +02:00
Marc-André Bombeck 14d0184b63 fix(ui): give the neutral score pill a label colour that passes AA
The neutral tone set muted text on the secondary fill, which measures
3.6:1 in the dark theme. The secondary surface's own foreground reads
the same in light and passes in dark.
2026-10-23 08:18:33 +02:00
Marc-André Bombeck 39092aa936 test(e2e): take the check-in in the reader's language
For German, Dutch, Korean and Brazilian Portuguese, the note and the
switch to the English original appear on an unvalidated translation and
nowhere else, the question carries the language it is written in, and
the result of an unvalidated translation says so. The switch shows the
English original and the result then carries no note, and a Brazilian
PHQ-9 with item 9 above zero leads with the crisis card. Each screen is
scanned with axe, at desktop and phone width.
2026-10-23 08:14:32 +02:00
Marc-André Bombeck 14273051db feat(doctor-report): mark screener totals answered in an unvalidated translation
The report data counts, per screener score type, how many of the
window's results were answered in a translation that has not been
validated (`screenerUnvalidatedTranslations`, read only when a screener
total is in the report). The PDF marks such a row with an asterisk and
says under the vitals table how many of how many read as orientation;
the clinician share view says the same under the row.

To keep the aggregator and the PDF section from growing, the wellness
summary and the per-context glucose rows move into helpers; the size
ratchets only fall.
2026-10-23 08:14:32 +02:00
Marc-André Bombeck 02cc0631fa feat(mental-health): record which wording a check-in was answered in
An administration now stores `wording`: `validated` (the published
validated version of the reader's language, the original for English),
`unvalidated_translation` (HealthLog's own translation) or `original_en`
(the English original, chosen by a reader of another language). The
client sends only `englishOriginal`; the server resolves the rest from
the wording registry, so a client cannot claim a validated wording the
registry does not hold. `locale` keeps the reader's language, which
still selects the crisis resources.

Additive migration 0475: a nullable column with no backfill, since which
text earlier rows showed depended on releases since replaced. Every read
returns the field, the OpenAPI contract carries it and `englishOriginal`,
and the disaster-recovery backup carries and restores it (the parity
goldens gain the column; their pinned timestamps move by one step
because the fill writes one more column).

The PHQ-9 item-9 crisis signpost is answered from the answers alone and
is proven in every wording through the real route.
2026-10-23 08:14:15 +02:00
Marc-André Bombeck 3843705296 feat(mental-health): keep questionnaire wording in code, one cited file per language
The PHQ-9, GAD-7, WHO-5 and SCI now have text in every app language
instead of falling back to the English original. Each instrument and
language is one file under src/lib/mental-health/wording/ that declares
what it is: a published validated translation reproduced word for word,
with its source and terms in `source`, or a translation written for
HealthLog, faithful to the English and on the same scale.

Validated versions added: the Pfizer PHQ-9 for France, the Netherlands,
Korea and Brazil, the Pfizer GAD-7 for the Netherlands, Korea and
Brazil, the PRU Dutch WHO-5, the Korean WHO-5 validation (J Korean Geriatr Psychiatry 2010) and
the Polish SCI validation (Sci Rep 2026). The SCI in the other
languages and the WHO-5 in Brazilian Portuguese carry HealthLog's own
translation; each file names any validated version found and why it is
not used.

Where the text is unvalidated, the check-in shows a note that the result
is orientation and a one-tap switch to the English original. Each
language loads as its own chunk when a questionnaire surface needs it.

The item text, answers, stems and the PHQ-9 follow-up leave every message
bundle, so a translator never sees them; the i18n guard now requires that
no bundle carries them, and the registry test requires every instrument
in every language with the item count and anchor scores the scoring
expects. CONTRIBUTING explains where the wording lives and how to propose
a validated version.
2026-10-23 08:13:56 +02:00
Marc-André Bombeck cf61bd1c85 Merge pull request #1264 from MBombeck/release/v1.45.0
Release v1.45.0
2026-10-23 03:26:55 +02:00
Marc-André Bombeck 8c43ff7cf9 fix(bench): move the heap snapshot and read the commit without a shell 2026-10-23 02:50:02 +02:00
Marc-André Bombeck 3b2ebc1081 docs(changelog): filters keep the address they were given 2026-10-23 02:06:12 +02:00
Marc-André Bombeck d8e4cf6000 fix(url): write page state into the query string through one serialised helper
Next 16.4 adopts a native history write a render later. A second write
inside that gap was undone when the router adopted the first, and a
write computed from useSearchParams started from the URL before the
last one. Clearing the vault's filters could therefore leave
?encounter= behind: the debounced search wrote the filters it had
rendered back over the clear.

@/lib/url-state is now the one way a page writes its own query: each
write starts from the live address bar, at most one waits for the
router at a time, and later updates queue on top of it and go together
once the router has it (or after a short grace). useUrlSearch reads the
same live value and reports each adoption. The filter hook (documents,
measurements, mood), the open document, the day layer, the timeline's
zoom and range, the medication tabs, the AI provider choice, the
Coach's ?c= and the one-shot parameter strips all go through it; the
filter changes merge into the live filters. A guard refuses raw
history writes and router writes built from the rendered params
outside the helper.

The timeline spec now waits for the values menu to hand focus back
before scrolling to the last row; it measured mid-animation and the
focus return scrolled the page back.
2026-10-23 02:04:21 +02:00
Marc-André Bombeck 333dfe8746 fix(backup): give every export read a total order
Several export reads sorted only by a column two rows can share (a
creation time, a day, a name), and a few to-many reads (medication
schedules, mood tag links, cycle and illness symptom links) had no order
at all. Tied rows then came back in whatever order they sat on disk, so
two exports of one account could differ, and the parity goldens flapped
once the fixture's child rows shared their pinned timestamps.

Each read now ends its order in the model's primary key: the existing
sort key plus `id`, or the remaining key columns of a compound key
(nutrient `source`, hidden tag `moodTagId`, link `symptomId` /
`moodTagId` / `documentId`). Schedules sort by window start, then id.
The restore re-export reads through the same modules, so it inherits
the order.

The parity goldens are unchanged: they already hold the tie-broken
order, so no regeneration was needed and they still pin the output of
the pre-split exporter.

A new integration test runs both purposes, built and streamed, through
a client that records every findMany and every nested to-many read, and
fails on any whose order does not cover the primary key (keys pinned by
an equality in `where`, or by the foreign key to the parent, count as
covered). A second case writes the same tied rows in forward and
reverse physical order and requires byte-identical files.
2026-10-23 00:28:38 +02:00
Marc-André Bombeck b09a6d2df8 test: fail a test whose code caught and logged a missing mock export
A module mock that leaves out an export the code under test reaches throws
at the call, and where that code catches and logs (a best-effort tail, a
fail-soft read) the test went on green while the path it meant to cover
never ran. The Apple Health import tests mocked the rollup module without
its fold window, so the import tail's rollup fold and cache invalidation
never ran in any of them; four route test files mocked the logging context
or the rollups without what the measurement tail reaches.

Both setup files now watch the console and fail the test in which such a
line was logged. The ECG import test keeps the real rollup module and
stubs only the fold, and the four unit mocks gain what they lacked.
2026-10-22 23:15:43 +02:00
Marc-André Bombeck 2844088251 test(integration): seed the large pulse account a month at a time
The heap test for the pulse day-mean rebuild wrote its million readings in
one INSERT ... SELECT, which outran the 60-second statement timeout on CI's
shared Postgres. The fixture is now written a month per statement, each in
its own transaction with the timeout lifted for that transaction only, and
ANALYZE the same way; the code under test keeps the timeout, and the
production-heap assertions are unchanged. With the timeout at 4 seconds the
old seed is cancelled with 57014 and the new one passes.
2026-10-22 22:56:05 +02:00
Marc-André Bombeck 294be53883 fix(admin): keep a sideways-scrolling table from widening the page on a phone
The login overview's IP button gained screen-reader text, which is
absolutely positioned. Inside a scroll container that is not positioned,
its containing block lies outside the container, so the span escaped the
container's clipping and every row panned past the edge widened the page:
the admin page scrolled sideways at 390 px. Every overflow-x-auto wrapper
around a table is now relative (the admin sections, the settings token
tables and the Coach result table), and a guard holds that.
2026-10-22 22:52:48 +02:00
Marc-André Bombeck dda4c57f5a docs(changelog): the maintenance job and today's mood dimensions 2026-10-22 22:05:24 +02:00
Marc-André Bombeck c10446f0d7 Merge branch 'release/v1.45.0' into fix/v145-review-data 2026-10-22 21:48:34 +02:00
Marc-André Bombeck a787cad7f9 chore(deps): take @tanstack/react-query 5.104.4, knip 6.42 and shadcn 4.22.1
The three updates the newest dependency pull requests carry, pinned the way each already was: exact for the query client, a caret floor for the two tools (knip ^6.42.0 resolves to 6.42.1). The dead-code gate is clean under the new knip.
2026-10-22 21:48:27 +02:00
Marc-André Bombeck 2ed863f877 fix(jobs): report the measurements bloat as whole percent, and keep finished runs finished
The nightly measurements check reported its bloat factor, a ratio such as
1.3, as a job fact. A fact is a non-negative whole number, so the outcome
validation refused it on every run, after the vacuum and the reindex had
already done their work, and the run failed. It now reports bloat_percent
(130), and the duration goes through the same rounding.

A malformed fact no longer fails a run whose work is done: runJob drops it
from the result and emits job.outcome.fact_invalid at error level with the
queue and the key, where failing would have made pg-boss retry finished
work. A failed outcome is unchanged. wholeFact and percentFact are the
intended way to report a measured value or a ratio, and a guard reads
every outcome producer and refuses a fact that is a division or names a
ratio unless it is rounded on purpose. The other producers were checked:
their numeric facts are counts and integer millisecond durations.

Refs #1271
2026-10-22 21:43:08 +02:00
Marc-André Bombeck 67135b2ce8 fix(charts): measure chart text in the app font again
Recharts measures every label in a span the chart runtime parks under
<html>, beside <body>, and the host copies body's classes to pick up the
app font. With next/font/google the font stack variable came from one of
those classes; since Inter is self-hosted, globals.css sets --font-sans on
body itself, so the host outside it measured in the default font and every
tick was sized for text the chart did not draw. The rule now sets the stack
on the measurement host too, and the dashboard's strings measure the same
inside the host as under <body> again.
2026-10-22 21:35:01 +02:00
Marc-André Bombeck 70c1077121 perf(mood): write a rollup fold in one statement per chunk
The full mood fold upserted every day bucket with a statement of its own,
so the read that refreshed the tier paid one statement per day of mood
history. The dashboard summary in the hot-route budget fixture did exactly
that: 57 to 59 single-row inserts out of its 85 to 87 statements, a count
that moved with the calendar the fixture is dated against, which is why it
crossed its budget of 84 on CI without a new query in the route. The fold
now writes each chunk of up to 500 buckets as one multi-row upsert, and the
route's budget goes to the measured 28.
2026-10-22 21:31:28 +02:00
Marc-André Bombeck bafda973ee refactor(insights): read the mood block's daily points in a module of their own
The rollup-or-fallback read of the per-day mood points moves out of features.ts into mood-daily-points.ts, unchanged, which keeps features.ts under its size baseline after the calendar-day ages.
2026-10-22 21:31:27 +02:00
Marc-André Bombeck 239319a762 fix(mood): count the mood block's ages in calendar days, and fold today in every zone
The Coach's mood block gave its newest and oldest entry ages as elapsed
hours rounded to days. A point from the rollup tier sits at its day
label's UTC midnight, so from noon UTC on this morning's entry read as a
day old; the integration run failed at 19:02 UTC for that reason. The ages
and the span are now whole days between the entries' own day labels and
the reader's today.

Pinning the clock to the edges of the Berlin day showed a second gap: the
open-ended rollup fold stopped at now, and a day label runs up to fourteen
hours ahead of UTC, so between a reader's local midnight and UTC midnight
the fold left their today out and the tier called yesterday the newest
day. The open-ended fold now reaches a day past now.
2026-10-22 21:23:19 +02:00
Marc-André Bombeck 5dab1488d7 fix(ai): let deferring dose sentences through and pair continuations with a named planned dose
The number-free dose shapes added for this release blocked harmless
replies: a trailing negation the pattern never reached ("Erhöhe dein
Insulin heute nicht", "Verhoog je insuline vandaag niet zelf"), a
Spanish subjunctive after "antes de que", a question ("Increase your
dose? Not without your doctor."), a clinician-gated condition, and
Polish "dose" of caffeine, sugar or exercise. These shapes now stand
down in a sentence that asks, negates, restricts ("nur wenn", "alleen
als") or names a clinician; shapes with a stated amount still block.
The Korean trial pattern stands down the same way, which also clears a
pharmacist question it already blocked.

The continuation exemption fell back to the whole plan when a sentence
named a drug the plan does not carry, so "Take Eliquis 5 mg as before."
and swapped doses passed. Every stated amount must now sit beside one
medication on the plan, named, at exactly that dose.

A false-positive corpus (at least 15 harmless replies per language)
sits beside the positive one. The Brazil crisis set's comment now says
what reaches it.
2026-10-22 21:19:01 +02:00
Marc-André Bombeck 8a9bcffaa2 build(deps): raise the dompurify floor to 3.4.17
GHSA-cg2g-xh64-v4q7 (critical, a SAFE_FOR_TEMPLATES bypass) and GHSA-mrv2-3pqr-j5jr (high, an IN_PLACE hook that removes a parent) cover dompurify up to 3.4.16, which jspdf and jspdf-autotable pull in. jspdf 4.2.1 is the newest release and still allows that range, so the existing override moves its floor. The doctor-report PDF renders as before (unit and integration suites).
2026-10-22 21:16:31 +02:00
Marc-André Bombeck bf12b9840c chore: drop an unused guard helper and a type re-export nothing imports
sourcesContaining in the guard helpers and the DeferredRows type re-export from full-backup-payload had no importer, which the dead-code gate reports. isDeferredRows stays re-exported; the backup job's test mocks it there.
2026-10-22 21:15:38 +02:00
Marc-André Bombeck 9da0e0c405 chore(release): v1.45.0 2026-10-22 20:51:30 +02:00
Marc-André Bombeck 32b71a7df9 Merge branch 'release/v1.45.0' into fix/v145-sheet-motion
# Conflicts:
#	src/__tests__/baselines/source-file-lines.json
#	src/__tests__/baselines/source-function-lines.json
#	src/__tests__/baselines/version-stamp-comments.json
2026-10-22 20:51:07 +02:00
Marc-André Bombeck 6d23cca4a3 fix(day): keep focus where the person moved it when the day's heading mounts late
Opening the conversations beside a docked day dropped focus to the page
in most runs. A day opened on load asks for its heading to take focus;
when the heading had not mounted yet the request waited, and it was
honoured whenever the heading appeared, even after the person had moved
on and opened the conversations. Focus jumped into the folding day and
fell to the page as it went inert. A waiting request is now honoured
only while focus is still where it was asked from, never inside an inert
panel, and it ends with the day.

The timeline range check measured the popover during its slide-in and
read it up to 8 px too high; it now waits for the entry animation.

The label-in-name fixes move their name composition into small helpers
(score ring link, IP copy button, sortable header, period marker, the
day's title focus), so every function ends at or below its old length
and the size baselines only fall.
2026-10-22 20:50:07 +02:00
Marc-André Bombeck 562ff8a277 fix(a11y): name every control by the text it shows
axe 4.14 checks WCAG 2.5.3 (label in name), and the app frame failed it
on every page: the account link, the user menu and the day strip carried
an aria-label that left out their visible text. The same class sat in
the coverage meter, the score ring, medication links, instrument cards,
filter pills with a value set, cycle calendar days (named by an ISO
key), admin user and login rows, a few sort and action buttons, and the
French, Spanish, Italian and Polish weekday chips, whose abbreviations
were not part of the day names.

Controls with visible text are now named by it, with any extra purpose
added as screen-reader text; the select pill and calendar days spell
out a name that contains what they show. The day and conversation
strips keep their action as the tooltip.
2026-10-22 20:25:48 +02:00
Marc-André Bombeck 811fcf5509 fix(backup): restore row stamps and carry sealed labels, course notes and marker context as ciphertext
A disaster-recovery restore wrote these rows stamped with the restore's own
time: the medication's updatedAt (moved again by the course window), the
category assignment, custom category labels, courses and composition, the
mood day context and tag links, illness and cycle symptom links, nutrient
days, and a flare's updatedAt after the parent pointer pass. The recovery
file now carries each stamp (additive fields: linkCreatedAt on a mood
entry, symptomLinkCreatedAt on a cycle day log, createdAt on an illness
symptom, categoryUpdatedAt on a medication, updatedAt where missing) and
the restore writes it back. A file without them restores as before.

A custom category label, a course note and a marker's context rode the
recovery file as readable text and were sealed again under the active key.
They now ride as ciphertext (labelEncrypted, noteEncrypted,
contextEncrypted) and come back as the same bytes; a file that carries the
readable value instead is sealed on the way in, as before. The portable
file keeps the readable values. The rotation script and the
encrypted-column writer guard already cover all three columns.

The parity golden copies change for exactly this: the new fields in the
recovery file, the restored stamps in place of <now>, and the three values
coming back under the key they were sealed with. The legacy readable coach
title and custom metric note are still sealed on restore, since the row
holds no ciphertext to carry. The round-trip column audit no longer
excludes the four link stamps.
2026-10-22 19:57:47 +02:00
Marc-André Bombeck 038f8b10cd feat(mental-health): one check-in pattern for every questionnaire, worded as published
Every instrument now runs the same wizard: the form's own question stem
above each item (the PHQ-9 and GAD-7 stems were missing), the item's
exact answer options as one radio group in source order, progress, Back,
number-key answers, and a review step that lists every answer, lets any
of them be changed and is the only step that submits. The result leads
with the crisis card when it applies, then shows the score on the
instrument's scale, the band, what the band means, the change since the
last check-in against the meaningful-change threshold, and the next
step. Scoring is untouched.

The wording was read against the published versions. German follows the
PHQ-D and Löwe's GAD-7 verbatim, including the follow-up question in its
published formal address. Spanish, Italian and Polish carry the official
PHQ-9 and GAD-7 translations, French the official GAD-7, and the GAD-7
gets its own answer set because the Italian forms word it differently.
Where a version could not be matched to a published form (the French
PHQ-9, every Korean form) the language now shows the English original.
Each locale list names its sources, and spot checks pin the corrected
wording.
2026-10-22 19:28:29 +02:00
Marc-André Bombeck 6c4c097517 fix(workouts): start the windowed workout read after the plain reads beside it
The windowed reader is async, so a Promise.all that listed it first held a rejected promise of it whenever a later element threw while the array was built, and nobody handled that rejection. It now comes last in each of those arrays.
2026-10-22 19:15:58 +02:00
Marc-André Bombeck cec7ce2ad5 docs(api): regenerate the OpenAPI contract for the family-history backup fields
The backup payload schema the admin restore routes publish now declares notesEncrypted and deletedAt on a family-history entry, which the disaster-recovery file carries since the previous change.
2026-10-22 19:10:55 +02:00
Marc-André Bombeck 760f261a5f test(guards): count fixed-path reads through readSourceText in the source-read ratchet
The ratchet on tests that read a source file by path matched readFileSync
and readFile only, so a guard that called readSourceText with a fixed path,
directly or through a local read(rel) wrapper, read the file without the
anchors readSource demands and was never counted. The classifier now counts
both shapes, and leaves alone a readSource call with anchors and a sweep that
feeds the reader from a walk.

The twelve guards that read a fixed file that way now go through readSource
with an anchor each read asserts on, so the baseline stays where it was.
2026-10-22 19:08:15 +02:00
Marc-André Bombeck 298515d156 ci: fail the mutation floors step, and scale nightly property runs by a factor
The floors step piped scripts/mutation-floors.mjs into tee without
pipefail, so it took tee's exit status and a module under its floor could
never fail the run. It now runs under bash with pipefail, as do the other
steps that pipe into tee, and a guard reads every workflow and fails on a
tee pipe without it. The other piped steps were checked: each either
validates what the pipe produced or pipes a command that cannot fail.

The nightly runs set FC_NUM_RUNS, an absolute count that replaced every
property's own: the database backup round trip, which states twelve cases
for its cost, ran a hundred times against a timeout sized for twelve.
FC_RUNS_FACTOR now multiplies each property's count (five for the unit
suite, four for integration), propertyTimeout grows a test's timeout by
the same factor, and the backup round trip uses it.
2026-10-22 19:04:20 +02:00
Marc-André Bombeck 422adf2986 test(measurements): pin that a stats re-post with a new source version, device or sleep stage still writes
The unchanged-stats shortcut skips the write when every overwritten column matches the stored row. The kit test covered a new value and a new device; it now also re-posts a step total that only names a new HealthKit source version and a sleep row that only changes its stage, and checks each is written with its syncVersion bumped and the tail started.
2026-10-22 19:00:54 +02:00
Marc-André Bombeck 3b2c2c45f3 fix(backup): carry deleted family-history entries in the disaster-recovery file
Every record with a tombstone (allergies, labs, conditions, vaccinations,
life events, measurements and the rest) rides the disaster-recovery file
deleted rows included, so a restore gives back the account as it was. The
family history read only live entries for both files, so a restore lost
its deletions, and it carried the note as readable text that the restore
sealed again under the active key.

The disaster-recovery slice now carries every entry with its deletion and
the note as the ciphertext at rest, and the restore writes both back
verbatim; the portable file keeps the readable live entries. The parity
golden copies change for exactly that: the deleted entry, its note's key
and the shifted ids after it.
2026-10-22 18:59:07 +02:00
Marc-André Bombeck faedcbba93 test(backup): pin seeded timestamps, tombstones, self-references and key ids
The parity harness normalised any timestamp written during the run to
<now>, and the seeded rows' createdAt and updatedAt were themselves written
during the run, so a restore that dropped a row's createdAt still matched
its golden copy. Every run-time timestamp of the seeded account is now
pinned to a fixed historical value, and the seeded snapshot is asserted to
hold none, so a lost createdAt shows as <now> against a historical value.

The account also carries a tombstone of every model that keeps them, a
flare under its condition and a superseded profile fact, so the second
restore pass resolves real pointers. The fixture is sealed under the
legacy key and a second key is active from the export on; sealed values
render as the key id they name and the text they open to, so a value the
restore re-encrypts instead of carrying over differs.

The golden copies are written from the backup code as it stood before the
export and restore moved into per-domain modules (64fa65b46), and the
current code matches them.
2026-10-22 18:55:56 +02:00
Marc-André Bombeck 6ab1f2d8a2 docs(deploy): order a split deploy for the job-queue schema upgrade in v1.45
A web-only process opens its queue connection without migrating and refuses a job-queue schema older than its own, so in a web/worker split the new worker has to start, and upgrade the schema from 45 to 46, before the new web containers. The v1.45.0 notes say so, name the one additive migration, state that a single-container install needs no action, and point a forked compose file at the new stop grace period.
2026-10-22 18:44:44 +02:00
Marc-André Bombeck 7fda400a8c fix(i18n): serve English for an unreadable catalog, refuse to boot without it
The server reads messages/<locale>.json from disk on first use. A missing
or corrupt file threw, and since the matchers iterate every locale, one bad
non-English catalog turned every request that touched them into a 500.
That locale is now served the English catalog, and the failure is logged
once as i18n.catalog.fallback with the locale and the reason. English is
the fallback itself: a process whose messages/en.json is missing or does
not parse refuses to start, with the file and the reason in the message.

The image build checks the catalogs twice with
scripts/verify-message-catalogs.mjs: in the builder against the locale
list in the source, and in the runner stage against the copies the image
carries.
2026-10-22 18:43:03 +02:00
Marc-André Bombeck ac31130bc0 fix(cache): mark measurement caches stale after every bulk write
Closed analytics blocks survive a write that says it reached no further
back, so a writer that never invalidated left them on their pre-write
values. The Apple Health and Health Connect imports, the CSV and JSON
imports, the score engines, the WHOOP webhook, the Withings activity, sleep
and ECG legs, the Oura history backfill and the sleep-segment sweep wrote
measurements without touching the read caches; a long-history import
showed the old all-time figures until the entries aged out.

The shared post-mutation tail now marks the caches stale back to the
oldest row it is handed, and the Withings and Oura legs that hand-rolled
the same tail run through it. The two export imports share one tail that
refolds the rollups, marks the caches stale back to the oldest sample
written (all of them when none was) and re-warms the status assessments.
A sweep that tombstones a sleep segment drops every cached block.
2026-10-22 18:37:25 +02:00
Marc-André Bombeck 49a999ab94 test: tighten the version-stamp baseline and sweep instrument wording through the source helper
The wizard drops its slug alias to stay inside its function-size
baseline, the bare-t() sweep reads the tree through readSourceFolder,
and the version-stamp baseline takes the lower counts the dose-screen
and questionnaire changes left.
2026-10-22 18:31:05 +02:00
Marc-André Bombeck c15c1d18f3 fix(coach): read one lab marker's history in full
With an analyte, the Coach's labs block read the newest 4,000 lab rows of
every marker and matched the asked-for one in memory afterwards. An account
with that many newer readings of other markers lost the marker's older
readings, so its count and first date named a shorter history than the
record holds, or the marker vanished. The match now runs in SQL before the
row cap, on the biomarker's name for a linked row and the row's own name
otherwise, and the history compares units with sameLabUnit, so a reading
stored as "MG/DL" beside "mg/dL" stays in the series.
2026-10-22 18:22:36 +02:00
Marc-André Bombeck e78d5f86a6 fix(i18n): say what the Coach reads of older lab results when the lookback has no limit
The no-limit option promised every lab result, and the German detail
sentence ended on a dangling clause. The Coach keeps the latest value
of the most recently measured markers and, for a marker asked about,
its five previous results with older ones summarised per year for ten
years; years before that are only counted. Every language now says
that in complete sentences, with the two numbers pinned to the lab
history constants.
2026-10-22 18:21:58 +02:00
Marc-André Bombeck 466dfb3785 fix(workouts): resolve duplicate clusters the same way on every surface
The cross-source picker anchors each duplicate cluster on its first start,
so which cluster a workout lands in depends on the workouts around it. The
detail route still read the old fixed five-minute clock slot, and every
other reader handed the picker only the rows of its own range. The detail
of a twin that straddled a slot boundary declared itself canonical while
the list hid it, and runs at 09:58, 10:02 and 10:06 were grouped one way by
a read starting at 09:55 and another by a read starting at 10:00.

Every reader that decides which workout is canonical (list, detail and its
previous session, activity calendar, day view, doctor report, Coach,
briefing and correlation blocks, strain, per-sport context, the activity
insight's history, the batch write) now reads through one windowed reader.
It reads a duplicate window past the end of the range and reaches back
until every sport in the range starts at a workout that is an anchor
whatever came before it, then picks over everything read and keeps the
rows that start inside the range. The per-sport baseline and the insight
history leave out the whole cluster of the current session instead of the
row alone, which left its twin behind as a session of its own. The batch
write clusters its rows together with the stored workouts around them and
drops only a row that loses to another row of the same batch.

A property test checks the windowed read against a whole-history read for
random dense histories and ranges; route tests pin list, detail, deep link,
previous session and the batch write against the database.
2026-10-22 18:21:02 +02:00
Marc-André Bombeck 3354631a85 fix(ai): screen numberless halving and dose changes, and hold continuations to the plan
The outbound screen let a halving with no number through in every
language but Dutch ("Reduce your dose by half.", "Reduza a dose pela
metade.", "Tome meia dose."), let a bare change imperative through
("Zmniejsz dawkę.", "복용량을 늘리세요.") and did not know the Polish
spelling of the generic stems, so "Odstaw ramipryl." passed. Each
language's bank now carries a halving and fraction shape and a bare
change shape on a medication noun, and the stems include the Polish
-pryl, -statyn and -glutyd.

The continuation exemption trusted "as before" on any surface that
passed no schedule, so "Take 40 mg as before." went out to people who
had never taken 40 mg. The screen now takes the dose plan (each active
medication with its dose) instead of a bare list of numbers, and
exempts a stated amount only when it is the planned dose of the
medication the sentence names. Without a plan no stated amount is
exempt.
2026-10-22 18:20:02 +02:00
Marc-André Bombeck 47ac2c6889 fix(support): redact host names under Brazilian, Portuguese and other app-region TLDs
The support report's host-name step only knew a short list of top-level
domains, so saude.minhacasa.br:3000 or vida.example.com.br reached a
report meant for a public issue unredacted, as did hosts under .pt, .be,
.lu, .li, the Nordic codes and the English-speaking ccTLDs. The list now
covers the country codes of every app language's home regions, pinned
by a test against the shipped locales.
2026-10-22 18:09:22 +02:00
Marc-André Bombeck 38941c55d8 fix(mental-health): stop sending Portuguese readers in Portugal to Brazil-only crisis lines
The app ships one Portuguese bundle, and pt-PT, pt-AO and bare pt all
fold into pt-BR, so a stored pt-BR does not mean Brazil. A positive
PHQ-9 item 9 still answered it with SAMU 192 as the emergency number
and CVV 188 as the only line, neither of which connects from Portugal,
under a comment claiming pt-PT kept the international set.

With no region signal, Portuguese now gets the DE_AT_CH treatment: 112
first, the worldwide directory, then CVV and SAMU labelled Brazil and
SNS 24 (808 24 24 24, option 4) and SOS Voz Amiga labelled Portugal.
An explicit pt-PT tag gets the Portugal set and br the Brazil set.
2026-10-22 18:08:31 +02:00
Marc-André Bombeck 5448a43651 fix(mental-health): show unvalidated languages the English questionnaire wording
The PHQ-9, GAD-7 and WHO-5 are not validated in Dutch or Brazilian
Portuguese, and the SCI is validated in English only, yet the check-in
wizard and the answer breakdown read the item text, answer options and
stems through t() in the reader's language. Dutch and Brazilian
Portuguese readers saw unvalidated translations of every item,
including PHQ-9 item 9, under a note saying the questions were in
English.

Every surface now resolves instrument wording through instrumentText,
which returns the English source for a language outside the
instrument's validated set. The unvalidated wording is removed from
the bundles; the locale-parity check accepts exactly those keys as
absent and fails when a bundle carries one, and a structural test
keeps item text out of bare t() calls. The PHQ-9 follow-up question
regains its original wording. CONTRIBUTING states the rule for
translators.
2026-10-22 18:05:16 +02:00
Marc-André Bombeck 3decab47a0 fix(i18n): name dengue and tuberculosis in Brazilian Portuguese 2026-10-22 17:45:43 +02:00
Marc-André Bombeck d600d6f9d2 Merge pull request #1266 from asmala/feat/vaccines-dengue-tuberculosis 2026-10-22 17:45:16 +02:00
Marc-André Bombeck a3bf826fe7 Merge branch 'main' into feat/vaccines-dengue-tuberculosis 2026-10-22 17:27:17 +02:00
Marc-André Bombeck f38954f2ae test(e2e): read the reduced-motion sheet before its first frame
The check sampled getAnimations() once after the sheet showed, which passes or fails on whether WebKit drew a frame in between. It now reads the sheet and backdrop the moment they are inserted, the worst case, and is deterministic: it fails every time against the old classes. The other measurements in the spec already wait on polls, rendered frames or layout.
2026-10-22 17:02:27 +02:00
Marc-André Bombeck d6ed165ac6 fix(ui): open the bottom sheet without any slide under reduced motion
data-[state=open]:animate-in carries an attribute selector and outranked motion-reduce:animate-none, so the slide stayed, only cut to 0.01 ms by the global reduced-motion rule. It still existed and ran until the next drawn frame. The none is important now, on the sheet and the backdrop.
2026-10-22 17:02:26 +02:00
Marc-André Bombeck cdad3057cf test(guards): carry main's sizes into the ratchet baselines
Every raised entry is main's own number for a file this branch had not touched since the fork, so no value is above both sides; the version-stamp counts that fell are recorded lower.
2026-10-22 17:02:26 +02:00
Marc-André Bombeck 9c8972bc96 Merge origin/main (v1.44.2) into fix/v145-sheet-motion
next stays on main's 16.4.2 (the RCE and Server Actions advisories) with its two companions; release's newer dev tools and its own additions are kept. The lockfile is release's, re-resolved. pt-BR, which main does not have, takes the one key main added.
2026-10-22 16:43:28 +02:00
Marc-André Bombeck abb79ee6ae fix(ai): let the accented-word safety and grounding patterns match
JavaScript's \b is an ASCII word boundary, so next to an accented
letter it never matches. That left patterns, or alternatives of them,
dead: the French "en bonne santé", "à cause de", "causé" and
"continuez à", "probabilité" and "probabilità" in the French and
Italian risk banks, the Dutch "één procent", the German "Ärzte", and
the Polish "przedział", "średnia" and the verb endings in "-ą". A \w*
stem cannot run through "ł" either, so "doprowadziło do" missed its
causal pattern, and the Polish adult pattern was spelled "dorosli"
without the "ś".

Those boundaries now use lookarounds that count the Latin letters
with diacritics as word letters (WORD_START / WORD_END in the shared
screen module, the same class inline in the regex literals), the
Polish stems take accented letters, and "dorośli" is spelled both ways.

Two guards keep the class out: every pattern of every locale's screen
and population-norm bank has to match at least one realistic sentence,
so a dead pattern fails the suite, and no pattern may put \b next to
an accented letter. Both fail on the old patterns.
2026-10-22 15:57:08 +02:00
Marc-André Bombeck b89cb6a29f test(guards): drop the raised size baselines and record the lower ones
Undoes the raise from the pt-BR tables commit. After the screen banks,
population-norm patterns, insights footers, settings labels and
Telegram words moved into their own modules, every file that grew is
below its old line count, outbound-screen.ts leaves the over-800 list
and buildDoseChangeClass the over-200 list.
2026-10-22 15:26:38 +02:00
Marc-André Bombeck dcb2598bb2 refactor(ai): keep each language's outbound-screen banks in its own module
outbound-screen.ts held every locale's dose, risk, causal, continuation
and change-stem patterns plus the shared units and slot builder, and
grew with each language. The banks now live in
outbound-screen/locales/<locale>.ts, one per language, each exporting
the same LocaleBank shape; the units, target-dose shape, object slot
and generic-name stems are in outbound-screen/shared.ts. The screen
composes them per locale and keeps the matching logic, the
continuation rule and the general rationale.

No pattern changed: compiling the old and the new module and comparing
every bank of every locale, with and without a medication-name list,
gives the same regular expressions (108 of 108 identical), and the
screen tests still block and pass the same sentences in every locale.
The screen is 457 lines instead of 1,597, and buildDoseChangeClass is
a four-line composition.
2026-10-22 15:26:37 +02:00
Marc-André Bombeck 8c11bb8385 refactor(coach): keep the population-norm banks in one module per locale
The reference-sentence exemption's patterns move out of
coach-prose-grounding.ts into population-norms/<locale>.ts, composed
into the same Record the check already reads. The patterns themselves
are unchanged. A test feeds every locale one reference sentence the
English bank cannot match, so a module that is not wired fails.
2026-10-22 15:21:10 +02:00
Marc-André Bombeck f987efd9f3 refactor(insights): move the insights reply-language footers into their own module
The per-locale footer table is prompt text, not prompt logic; it moves beside the generator, which drops back under its size baseline after pt-BR joined the table.
2026-10-22 15:19:33 +02:00
Marc-André Bombeck 50a6247177 refactor(settings): move the managed-record option lists and label keys into a data module
The Coach metric and cluster options and the label keys of the managed-record settings forms are data; they now sit beside the forms instead of inside the component file, which drops back under its size baseline after the locale picker started reading the locale list.
2026-10-22 15:18:58 +02:00
Marc-André Bombeck 945e998516 refactor(telegram): keep the bare help and greeting words in a per-locale table
The two word lists lived inside the webhook handler and had no entry
for Dutch or Korean. They move to their own module, keyed by locale so
the compiler names a language that has none, and gain "hulp", "hoi",
"goedemorgen", "도움말", "도움", "안녕" and "안녕하세요" beside the
Portuguese words. The bot still matches the union on the folded text.
2026-10-22 15:16:42 +02:00
Marc-André Bombeck 6c15a66cde feat(mental-health): show Brazil's crisis line to pt-BR readers
A Brazilian Portuguese reader who answered PHQ-9 item 9 saw the
international set: 112 and two directories. They now see CVV (Centro de
Valorização da Vida), 188 by phone and the chat at cvv.org.br, free and
answering around the clock, with SAMU's 192 as the emergency number.
Both were checked against cvv.org.br and the Ministry of Health's SAMU
page. A bare "pt" or "pt-PT" keeps the international set, because 188
does not connect from Portugal.

The CVV label is translated in every bundle, and a test pins that every
resource of every locale's set resolves its label in that locale.
2026-10-22 15:15:48 +02:00
Marc-André Bombeck 36a1485b91 style: format the database helper test 2026-10-22 15:08:14 +02:00
Marc-André Bombeck 2d16183b58 feat(i18n): let translators edit the catalogues in the browser with Fink
Add an inlang project so Fink opens the repository directly:
English as the source, the nine shipped locales, and the
message-format plugin pinned to 4.5.5 reading messages/{locale}.json.
That plugin keeps the nested keys and {placeholder} tokens as they
are; the i18next and next-intl plugins Fink also supports either
rewrite numeric-keyed objects as arrays or misread quotes around a
placeholder as ICU escapes.

Checked against these files with the inlang CLI and SDK: all nine
locales load with no errors and no findings, a load and save without
edits leaves every file byte-identical, and an edit changes exactly
the edited lines. inlang's working files beside settings.json ignore
themselves in git and are kept out of the format check.

CONTRIBUTING gains the browser workflow (fork, push, pull request),
what a reviewer checks, and that machine translation is a draft a
reader of the language goes through before the pull request; the
new-locale steps now name the real locale list. A test keeps the
project's locales equal to the shipped ones and the plugin pinned.
2026-10-22 15:03:16 +02:00
Marc-André Bombeck dc9beaefb7 fix(i18n): spell the Korean singular forms the way English does
Korean has no singular plural category, so the two ...One strings are
never chosen at run time, but they carried {count} where English writes
the number. They now read "1일 후" and "검사 수치 1개", which leaves no
placeholder that English lacks.
2026-10-22 15:02:15 +02:00
Marc-André Bombeck 1bc5df17bc test(guards): write back the lower source-read counts
Since the guards read the source tree through the shared index, 35 of
them no longer read a source file by path, and the realism ratchet
failed until the lower numbers were recorded.
2026-10-22 14:57:59 +02:00
Marc-André Bombeck 10bd2e5d94 test(guards): raise the size baselines for the pt-BR tables
The outbound screen, the prose-grounding check and the insights and
Coach prompt files keep one table per locale on purpose: the dual-bank
rule (the reader's bank plus English) reads in one place, and every
other locale lives there. A ninth locale adds its rows, mostly to the
dose, risk and causal banks of outbound-screen.ts and its
buildDoseChangeClass table (+134 and +40 lines). The managed-profile
settings section and the Telegram handler grow by an import and the
Portuguese bare words.
2026-10-22 14:57:33 +02:00
Marc-André Bombeck 51f7d38b3c test(i18n): give the per-locale copy guards their pt-BR vocabulary
The delete-confirmation, sleep-debt, mood-prognosis, MCP-token and
AI-consent guards match each locale against its own phrases, and the
sharing-boundary guard against the word for account; pt-BR now has
them. The locale-count floors read nine, and the managed-profile picker
test counts the locale list instead of a literal.
2026-10-22 14:57:32 +02:00
Marc-André Bombeck 71123e5fae test(i18n): hold every catalogue to the English placeholders
Key parity let 38 pt-BR strings through that renamed or dropped a
{placeholder}: a renamed one renders as a raw token, a dropped one
loses what the sentence was for. Every non-English bundle now has to
use exactly the placeholders of the English string, with one
allowance: a singular plural form may spell out or add {count}.
2026-10-22 14:57:09 +02:00
Marc-André Bombeck 5c2f91463c fix(i18n): state the glucose band edges in the five locales that left them out
Since the explainer started naming the fasting bands in the reader's
glucose unit, the English, Dutch and Korean text states where normal,
prediabetes and diabetes begin and that one high reading is not a
diagnosis. The German, French, Spanish, Italian and Polish text kept
the older sentence without the bands or the caveat. They now carry
both, with the same placeholders as English.
2026-10-22 14:57:00 +02:00
Marc-André Bombeck fed49f9b76 fix(i18n): translate the last English leftovers and a stray backslash in pt-BR
Two badge and plan strings still carried the English text, and the
Coach plans description escaped its quotes twice, so the reader saw
\"se… então…\" with literal backslashes.
2026-10-22 14:57:00 +02:00
Marc-André Bombeck 34ee0ea6e8 fix(i18n): separate the intake from the amount in the pt-BR medication wording
"Dose" stood for both the amount of a medication and the act of taking
it, so "Editar dose" could mean either, and the mechanical toma-to-dose
pass had turned two questions into "Com que frequência você dose?".
Where English says intake, the Brazilian strings now say "tomada"
(tomadas registradas, histórico de tomadas, editar tomada); "dose"
stays for the amount and where English itself says dose.
2026-10-22 14:50:52 +02:00
Marc-André Bombeck 4df28c5f80 feat(i18n): fill the pt-BR keys added since the translation was drafted
411 English keys had no Brazilian Portuguese string yet: the 135 that
were already missing when the translation was opened, and everything
v1.44 and v1.45 added since (the activity calendar, source provenance,
the lab and medication CSV import, the doctor-report workout and
medication-effect sections, the segmented medication-effect view, lab
trends and OCR outcomes, the mental-health trend lines, and the new
settings, integration and error strings). The bundle now has exactly
the English key set.

Terminology follows the existing catalogue: medicamento, exame,
treino, faixa de referência, chave de acesso, você.
2026-10-22 14:50:52 +02:00
Marc-André Bombeck 4e47c52aed fix(i18n): match the pt-BR wording and placeholders to the English source
38 strings used other placeholders than English: {when} for {date},
{provider} for {name}, a {unit} English dropped since, and the
integration-card, document-archive and Apple Health freshness lines lost
{since}, {list}, {relative} or {time} altogether, so the reader saw a
label where the sentence belonged. Most of those lines had been
translated from the key name ("Plano limitado", "Erros do provedor")
rather than from the English text; the whole of those sections is
translated from the source now, along with the Dawarich, glucose
reference, setup, re-confirmation and connector-token lines that had
the same problem, and the 19 strings whose English changed after the
translation was drafted.

The Telegram help documented "tomado <nome>", but the bot only accepts
"taken <name>"; it now shows the command the bot understands.
2026-10-22 14:50:28 +02:00
Marc-André Bombeck 3577da6966 fix(coach): narrow the pt-BR population-norm patterns
A sentence the grounding check reads as population guidance has its
numbers exempted from the evidence check. Two of the pt-BR patterns
matched sentences about the reader's own readings: "(entre|de) ...
geralmente" fired on "Sua pressão de 158/97 geralmente fica...", and a
singular "médico ... recomenda" with a 30-character gap fired on "Seu
médico recomenda manter a pressão em 158/97". Either let an invented
figure through unchecked.

Every pt-BR entry now needs a population subject or a reference noun:
plural guideline, doctor or specialist subjects next to their verb,
plural adults needing something, and "a/o <metric> normal" without a
possessive. Portuguese month names join the written-date pass so "21 de
julho" is not graded as a figure, and quilos, por cento and passos
join the unit words.

Negative cases prove the reader's-own sentences are checked again;
positive cases keep the reference sentences exempt.
2026-10-22 14:50:17 +02:00
Marc-André Bombeck 68b428e3f5 feat(ai): screen Brazilian Portuguese model output for dose, risk and causal claims
The outbound screen runs the reader's bank plus English. For a pt-BR
reader that meant English only, so "Pare de tomar a metformina",
"Dobre a dose de insulina" or "Seu risco é de cerca de 12%" passed every
surface unchanged.

Add the pt-BR banks the other locales carry: the dose-change class
(change, take, skip, stop, halve, double and one-more imperatives in
the você form, with the medication nouns, drug classes and stop words a
Brazilian reply uses), the target-dose patterns, the risk-figure and
risk-engine patterns, the causal phrases, and the continuation and
change-stem pair that keeps "continue tomando conforme prescrito"
exempt. UI joins the insulin units, and the count words and dosage
forms gain the Portuguese spellings.

Tests pin each class blocking in pt-BR and the restatements, negated
warnings, referrals, past changes and dietary advice passing.
2026-10-22 14:50:10 +02:00
Marc-André Bombeck 764794496c feat(i18n): register Brazilian Portuguese as a locale
Add pt-BR to the locale list and everything that hangs off it: the
client loader, Intl and date-fns mapping, the reply-language footers of
the Coach and insights prompts (English body, pt-BR footer, the same
path Korean and Dutch take), the citation-coverage normative words, the
no-key fallback copy, the AUDIT-C standard-drink line, country labels,
period search, CSV yes/no cells and the Telegram bare-word aliases.

The locale enums in the extract, managed-profile and record-settings
schemas now derive from the locale list instead of repeating it, and
the five notification and login-alert routes share one isLocale guard
instead of hand-rolled chains, so a later locale is one edit there.
A language tag is matched exactly and case-insensitively before its
primary subtag, so pt-BR and pt-PT browsers land on the Portuguese
bundle rather than English, and AUDIT-C no longer cuts a region tag to
two letters.

PHQ-9, GAD-7 and WHO-5 are not marked validated for pt-BR: the wording
has not been read against the published Brazilian forms, so readers get
the English items plus the existing note.
2026-10-22 14:50:00 +02:00
Marc-André Bombeck 1619b2406a fix(i18n): reshape the Brazilian Portuguese catalogue onto the English key structure
The pt-BR bundle carried 420 top-level sections that duplicated the
nested settings and onboarding keys (1,835 strings, of which 538 differed
from the nested copy), wrapped thresholds, gettingStarted and trendHints
in one-element arrays, and kept 48 keys English no longer has.

Rebuild it in en.json's order and shape: unwrap the arrays, drop the
stray top-level duplicates in favour of the nested copies (which match
the terminology of the rest of the catalogue), and drop keys English
does not have. No wording changes.
2026-10-22 14:41:17 +02:00
Marc-André Bombeck 402daa2753 Merge pull request #1251 from dareoon/feat/i18n-pt-br 2026-10-22 14:19:17 +02:00
Janne Asmala 8ea0ba5297 feat(vaccinations): Add dengue and tuberculosis to the catalogue
Dengue: Two doses three months apart and no booster. It's listed with the
travel vaccines.

Tuberculosis: One dose at birth and no booster. It's listed under childhood,
since that's where it appears on most vaccination cards.

Both cite the relevant WHO position paper. The names are translated in
all eight languages, and the OpenAPI spec is regenerated to include the
two new vaccines.
2026-10-22 18:58:15 +08:00
Marc-André Bombeck 08675a4f13 Merge branch 'release/v1.45.0' into feat/v145-issues 2026-10-22 11:00:45 +02:00
Marc-André Bombeck 7bbe64ad1a refactor(healthkit): fold the per-type arrival tally into the ledger module
The batch route counted arrivals inline and grew past its size baseline
with the new-sample rule. tallyHealthKitArrivals now owns the fold, the
route hands it each landed entry and hour with whether it brought a new
value, and the size baselines move down to what the route is now.

Refs #1259
2026-10-22 11:00:06 +02:00
Marc-André Bombeck 190a83c513 feat(coach): read lab results over the whole record
The Coach read lab results from the last twelve months only, even with
the lookback set to its widest option, so a slow change in LDL or PSA
across years stayed out of reach. Labs are sparse, so they are no
longer held to the one-year window that bounds the dense readings: they
reach back over the whole record, and a lookback limit the person set
still applies.

The snapshot block keeps the latest value per marker, now over that
wider window, at its old size: it shares the prompt's size cap and is
never shed. get_labs with an analyte adds each matching marker's
history, summarised so it stays bounded: the newest five earlier
readings in full and older ones as one mean / min / max / count per
year, ten years at most, older years counted. With a heavy fixture
(30 markers, quarterly for 20 years) the overview stays at 4.3k
characters and a named LDL read grows from 0.2k to 1.2k characters for
80 readings. The MCP get_labs read and the lab resource follow, since
they run the same tool.

The widest lookback option no longer reads "All": it names what it
reads, twelve months in detail and every lab result, in every locale.

Refs #1263
2026-10-22 10:50:56 +02:00
Marc-André Bombeck 6a2247e422 fix(healthkit): count a type as arrived only when the batch brought something new
A batch that carried a type with duplicates only moved its
lastReceivedAt, lastTrigger and lastBackgroundAt, so a type whose live
delivery had stalled kept reading as freshly arrived for as long as a
replay, a retry or an older client re-sent its old samples.

The arrival ledger now moves those stamps only for a type the batch
brought at least one new or changed row for. An unchanged stats:
re-post and an hour posted again with its stored value count as
duplicates here; the wire keeps answering updated for them. A
duplicates-only type stamps the new last_seen_duplicate_at column on
its existing row and nothing else. The intake bulk route's dose entry
follows the same rule through the shared recorder. lastSyncedAt and
healthKitLastSyncedAt are unchanged.

GET /api/integrations/healthkit serves the stamp additively as
metricFreshness[].lastSeenDuplicateAt, and the Apple Health card names a
re-send that came after the last arrival.

Refs #1259
2026-10-22 10:34:45 +02:00
Marc-André Bombeck 743a1e742f test(guards): stop the slowest guards repeating their own work
Each of these asked the same question several times or did the expensive
part before the cheap one: the threshold guard parsed every client file
once per rule and twice per run, the i18n reverse check rebuilt its scan
three times and compared every leaf with every literal key, the step-up
caller guard stripped comments and searched every route path in every
file per case, the plausibility guard ran its writer sweep three times,
and the cycle verdict guard parsed every client file to find out whether
it mentions a cycle. Each now works a result out once, and a file is
parsed or searched only when a substring says it can match. A planted
violation still fails the threshold, plausibility, cycle verdict and
backup plan guards.
2026-10-22 10:30:59 +02:00
Marc-André Bombeck 4dc3bd46bf test(guards): read the source tree once per worker through a shared index
walkSourceFiles now lists each indexed folder (src, e2e, prisma) once
per worker and filters it by prefix, readSource and readSourceFolder read
through the same memoised file cache, and readSourceText is the cached
read for guards that read files themselves. The guards project shares a
module graph per worker, so a file is read once however many guards ask
for it. sourcesContaining adds the substring pre-filter for sweeps that
look for a few markers. Every guard over 1.5 s now reads through it;
anything outside the indexed folders is read from disk as before.
2026-10-22 10:30:50 +02:00
Marc-André Bombeck 601d5ea447 test(guards): walk the tree once in the timezone formatter guard
Each of the guard's scans started its own grep over the whole of src,
the generated client included, five times per run, and the floor case
timed out at five seconds under a loaded unit suite. The tree is read
once now, without the generated client, and a file is kept only when a
plain substring says a pattern could match; each scan's result is
reused by the cases that ask about it. The file runs in 1.9 s instead of
6.5 s alone, and the floor case in 2 ms instead of 2.3 s.
2026-10-22 10:19:11 +02:00
Marc-André Bombeck 703c92c776 test(integration): take the clock and the probe budget out of two verdict tests
The grant expiry case gave the grant 400 ms and slept 500: on a loaded
runner accepting, signing in and switching took longer than the grant
lived, and the grant expired before it was accepted. The grant now runs
30 seconds and the case moves the clock the resolver reads to the
instant the grant names, so the row still does not change and nothing
sleeps.

The key check's start-up probe is bounded at ten seconds and two per
query. Under load a probe query ran past two seconds and the check
answered inconclusive where the data says mismatch. The verdict tests
now run the check with time to finish; the cases that test the bounds
pass their own budget or sampler and keep them.
2026-10-22 10:14:03 +02:00
Marc-André Bombeck 0cefac9d68 Merge branch 'release/v1.45.0' into feat/v145-backup
# Conflicts:
#	src/__tests__/baselines/source-file-lines.json
#	src/__tests__/baselines/source-function-lines.json
#	src/__tests__/baselines/version-stamp-comments.json
2026-10-22 09:59:37 +02:00
Marc-André Bombeck 6dfb7eada6 Merge branch 'release/v1.45.0' into feat/v145-runtime 2026-10-22 09:41:54 +02:00
Marc-André Bombeck 3524d7c289 test(integration): lower the hot-route statement budgets to the measured counts
/api/auth/me 18 to 13, dashboard snapshot 103 to 99, summary 86 to 84,
the 365- and 3650-day pulse series 6 to 5, the day view 48 to 47,
insights health status 60 to 59, correlations 44 to 43 and a 500-row
batch ingest 31 to 28.
2026-10-22 09:34:42 +02:00
Marc-André Bombeck a451a114a2 docs(bench): record the boot profile and the runtime results
The profile section in docs/contributing/benchmarks.md says how to run
scripts/bench/boot-profile.ts, what the profile of the v1.44 runtime
found (the worker graph as one 753 ms block, a live heap of about
165 MB of which the query compiler holds 50 and the catalogs 33) and what
changed. scripts/bench/results/v1.45.0-*.md hold the before and after
numbers per area, from back-to-back runs on the same machine. The size
baselines take the lowered numbers.
2026-10-22 09:10:14 +02:00
Marc-André Bombeck 83661319d2 refactor(cache): keep the closed-block key helpers in a leaf module
Readers that tag a cell as a closed block no longer import the
invalidation module, which several tests replace wholesale. The cache
route test counts the slice's pre-fold cell apart from the route's two
cells, and the provenance test reads the shared pick's ORDER BY over its
grouped rows.
2026-10-22 09:09:44 +02:00
Marc-André Bombeck 4c86b68363 perf(import): skip the update of a re-imported row that is already stored
Re-importing an Apple Health export the account already holds sent one
UPDATE per row the insert skipped, about two million statements for a
large export, nearly all writing back what was stored. One read per chunk
now finds the rows whose stored values equal what the update would write;
they count as updated as before, without the statement. A key the chunk
carries twice is written in order as before. The natural-key rescue moves
into the same module.
2026-10-22 09:09:44 +02:00
Marc-André Bombeck f50b6b6637 perf(series): read a dense window's rows once for buckets and figures
A long-window read of a dense series sent two statements over the same
canonical rows, the day or hour buckets and the strip's figures, so a
ten-year pulse read picked the canonical source per day and scanned the
readings twice. Both aggregates now run in one statement over the window
materialised once, with the same SQL each had (dense-series-read.ts).

An integration test keeps the two old statements as the reference and
requires the same buckets and figures for pulse (two ranked sources),
glucose and oxygen saturation, day and hour grains, in UTC,
Europe/Berlin, Asia/Kolkata and America/New_York, and the same empty
answer for a type without readings.
2026-10-22 09:09:42 +02:00
Marc-André Bombeck 8f1f4dc691 build: keep the test harness out of the image and the font bindings out of the export list 2026-10-22 08:38:04 +02:00
Marc-André Bombeck 3a4dc16e1d test(guards): read each file and the schema once in the backup plan check
The two-ended checks ask every declared file about every model and each
of its relations, and read the schema and the files again for each
question. With the backup split into more files that took two seconds a
case and timed out on a loaded runner; the reads are memoised now.
2026-10-22 08:36:35 +02:00
Marc-André Bombeck 4fc6b0e386 Merge branch 'release/v1.45.0' into feat/v145-backup 2026-10-22 08:12:22 +02:00
Marc-André Bombeck 6f20ae4a8d test(backup): hold each schema slice's own rules at their edges
The whole-payload tests meet a slice's refinements only by accident. Each
pattern, bound and two-ended free-text rule now has the value just inside
and just outside it, with the issue it raises: the day keys, a long day's
time away, the Coach's table and trail caps, a life event's title and a
symptom's label, a profile fact's readable value and validity. The backup
validation mutation score goes from 25.5 to 41.6 %; the floor follows.
2026-10-22 08:12:00 +02:00
Marc-André Bombeck 12749f6839 refactor(backup): give each domain its own slice of the payload schema
The element schemas move out of validations/backup.ts into one
<domain>-backup-schema.ts per domain, beside its export and restore, with
the shared primitives in backup-schema-kit.ts. validations/backup.ts now
composes the slices into backupPayloadSchema in the same key order, with
the same defaults and the checks that span sections, so the parsed
payload, every validation message and the OpenAPI contract are unchanged.

The lazy-schema guard now covers the slices too: a slice is reached only
through the payload schema or another slice, never from a domain's export
or restore. The external-id guard carries the payload's exemption to its
slices, and mutation testing scores the slices with the payload schema.
2026-10-22 08:02:14 +02:00
Marc-André Bombeck 5a9a6ce78b Merge branch 'release/v1.45.0' into feat/v145-deps 2026-10-22 07:54:27 +02:00
Marc-André Bombeck 322652a86e refactor(backup): build the export from per-domain slices
buildFullBackupPayload now asks every domain for its slice at once and
lays the slices out in the file's key order (backup-export-sections.ts),
merging the manifest where it first appears. The measurement, medication,
mood, instance-settings and nutrient readers and serialisers move out of
the payload builder into their domain modules; the records section keeps
its composition and manifest, while labs, illness, allergies, family
history, workouts and documents each export from their own module. The
measurement restore half moves to measurements-restore.ts so the export
graph does not pull in the restore's dependencies.

The file, its counts and their order are unchanged; the golden copies
still match. The measurement completeness guard now reads the domain
module and also checks that every carried column is written back.
2026-10-22 07:46:02 +02:00
Marc-André Bombeck 508e19f190 test(backup): fill the parity account by key order and match audit rows by copy
A table read without an order comes back in whatever order its last
updates left on disk, so the column fill could pick a different row of a
two-row table from run to run. The audit rows of a restore are now found
by the copy's id instead of by time, since the database clock and the
test's clock do not agree to the millisecond. The golden copies are
written again from the code before the restore and export moved.
2026-10-22 07:45:19 +02:00
Marc-André Bombeck 5199df56db test(jobs): restate the backfill lane's expiry behaviour for pg-boss 12.38
Up to 12.37 pg-boss retried a job whose handler outlived its expiry in
the same worker beside the run it had given up on, and the lane test
pinned that as an executable statement. 12.38 fails the job and fires
the signal as before, but the worker fetches nothing more until the
handler returns, and the retry starts after it. The test now pins that:
one run at a time, the retry starting after the overrun ends, and the
lane's handler importing once per delivery with never two at once. The
lane's lock stays, as the guard for a second worker or instance.
2026-10-22 07:33:01 +02:00
Marc-André Bombeck 5d3794e734 build(fonts): self-host Inter so the build fetches nothing
next/font/google downloads Inter from fonts.googleapis.com and
fonts.gstatic.com while the app builds, and when that request fails the
build fails with it, which is what broke the arm64 image build and
several CI jobs. The files it downloaded now live in src/app/fonts/inter
under the SIL Open Font License, and next/font/local declares them.

They are the same bytes: Inter 4.001, one variable file per script
subset (Cyrillic and its extension, Greek and its extension,
Vietnamese, Latin Extended, Latin), each used for weights 400 to 700.
The declarations rebuild the stylesheet Google answered, the twenty-eight
faces with Google's unicode ranges in Google's subset order, swap
display, and only the Latin file preloaded. The local loader emits one
unicode range per call, so each subset is its own call under the
family Inter.

The Arial fallback face and the font-sans stack move into globals.css
with the metric overrides next/font/google shipped for Inter; the local
loader would have computed slightly different ones from the file.

A production build with every outbound request sent to a dead proxy
completes, where the same build with next/font/google fails on the
fonts.googleapis.com request. The content security policy already
allowed fonts from self only. A guard refuses a next/font/google import
anywhere under src.
2026-10-22 07:31:16 +02:00
Marc-André Bombeck eda90eeb56 fix(strava): read a dead refresh token from Strava's own fault shape
Strava's token endpoint does not answer RFC 6749 errors. A refresh
token it no longer honours, after the athlete revoked the app or the
token was rotated away, is a 400 with the API's generic Fault:
{"message":"Bad Request","errors":[{"resource":"RefreshToken",
"field":"refresh_token","code":"invalid"}]}. The classifier looked for
invalid_grant, which never arrives, so the failure was booked
persistent and the connection never asked for a reconnect.

The token error now carries the fault reduced to its resource, field
and code (or an RFC error code, should one arrive), and the classifier
lifts a 400 naming RefreshToken to reauth_required, from the error and
from its message after a retry loses the prototype. Every other refusal
is classified by its status as before, including the faults that name
the Application.

Strava documents the Fault and Error model but not these values; the
body is the one several unrelated client libraries captured from live
traffic between 2023 and 2026.
2026-10-22 07:31:15 +02:00
Marc-André Bombeck a57fa8335a style(mcp): tighten the metric index note 2026-10-22 07:25:08 +02:00
Marc-André Bombeck f8a88d1361 perf(auth): read the operator settings once per request
An app boot's /api/auth/me read the AppSettings singleton six times, each
reader with its own few columns: the MFA policy, the Assistant switches,
the module availability, the provider presence, the AI usage bounds and
the reasoning controls. They now take their columns from one read,
memoised for the request and read afresh inside a background job like the
readers themselves. No answer changes.
2026-10-22 07:25:00 +02:00
Marc-André Bombeck e00943ff7f perf(day): read the record sections six at a time
The day view started about twenty-five section reads at once beside the
readings, scores and movement, more than the pool holds, so a
neighbouring request waited for a connection. The sections now run six at
a time and come back in the same order.
2026-10-22 07:25:00 +02:00
Marc-André Bombeck f5d6710670 perf(analytics): keep closed blocks of past readings cached across syncs
Every sync marked all of an account's analytics cells stale, so the slice
re-read the pre-fold remainder (every reading older than five years,
folded per source and day, the slice's largest statement on a long
history) and the day view re-read its past history blocks, although a
sync of today's readings cannot change either.

A cache key can now name the instant its value's readings end before
(closedBlockKeyPart). invalidateUserMeasurements takes touchedFrom, the
earliest measuredAt a write created, changed or removed, and leaves such
blocks in place when it is not earlier; without it every block goes as
before. The batch route passes it from the identities it wrote. The
pre-fold remainder moves into its own module and is cached as a closed
block; the day view's past history blocks carry their end, with a day of
margin for a night across midnight.

The batch route's two up-front reads move into batch-stored-rows.ts with
the once-per-batch key rule they share.
2026-10-22 07:24:59 +02:00
Marc-André Bombeck b6e9eb98f7 test(integration): size the all-time count case to its measured cost
The rollup parity case writes over half a million readings and folds them
into DAY buckets: about 35 s on a CI runner alone. Run beside two other heavy
files on the same Postgres it took 1.7 times as long and crossed the 60 s
default. Nothing in the case waits on a lock: its advisory locks are scoped
to the worker's own database, and the measurement with the database capped
at two CPUs (14.6 s alone, 25.3 s beside the sharing matrix and the restore
memory test) shows plain contention. The case gets a 150 s budget, twice the
contended estimate. HEALTHLOG_INTEGRATION_PG_CPUS caps the database
container so a CI runner's contention can be reproduced locally.
2026-10-22 07:18:57 +02:00
Marc-André Bombeck 7248347188 test(integration): release the held lock when the migration has met it
The 0412 lock-retry case let go of the lock on a 2.5 s timer. On a loaded
runner the migrate CLI took longer than that to start, so the first attempt
found the lock already free, applied cleanly and never produced the 55P03
the case is about. The lock is now released once the attempt is seen queued
behind it in pg_stat_activity and gone again, which is the moment its own
timeout fired, and the case fails outright if no attempt ever queued. It
holds with the CLI start delayed by four seconds.
2026-10-22 07:18:56 +02:00
Marc-André Bombeck d396945f44 fix(api): publish the backup upload
POST /api/admin/backups/upload stores a backup file as a new copy, the
copy whose id the published restore takes, and it refuses a file for
reasons an operator has to be able to look up: a key this server does
not hold (backup.key.missing, with the key ids), an owner account that
does not exist here, a newer schema, a copy too large to store
(backup.upload.too_large). It was exempted from the contract as console
plumbing while its two siblings were published for exactly that kind
of refusal.

It is registered with the three bodies it reads (raw JSON, raw gzip, a
small multipart form) and every status it answers; the summary is the
three identity fields plus one integer count per class, so a class the
backup format gains later does not break a decoder. The round-trip
test calls it through the contract now.
2026-10-22 07:08:02 +02:00
Marc-André Bombeck 579789ec38 fix(api): declare the 400 and 415 the single-value profile writes answer
The six /api/auth/me scalar writes (unit preference, glucose unit,
automatic document reading, local lab OCR, workout routes, the central
Codex switch) read their body through safeJson with a 1 KB cap, so each
answers 415 for a body that is not JSON by content type and 400 for one
that does not parse, beside the 413 they already declared. Their
descriptions said so and the responses did not. All three now come from
one block.

The modules and disable-coach route tests had been skipping the
contract check over a 400 and a 409 the registry already declares; the
skips are gone.
2026-10-22 07:07:46 +02:00
Marc-André Bombeck 5ca3994523 fix(api): declare the FHIR refusals as the OperationOutcomes they are
A /api/fhir/* handler answers its own refusals as FHIR: a bad search
parameter is a 400 OperationOutcome, the hourly fhir bucket a 429
OperationOutcome (issue code throttled), both served as
application/fhir+json, the same way the 404 on a read already was. The
contract declared no 400 and the generic JSON 429 for the 429, and no
403 for the doctorReport module gate, which every data route runs.

The searches now declare the 400, every data route the 403
module.disabled envelope and the FHIR 429 with the rate-limit headers
apiHandler attaches to it. The module gate keeps answering the JSON
envelope: it is the errorCode every client already branches on for a
disabled module, and it runs before the FHIR face does. The route tests
drop their skips and assert the 429's content type and Retry-After.
2026-10-22 07:07:34 +02:00
Marc-André Bombeck c272324c1e fix(api): declare the auth gate's 403 on every operation behind it
requireAuth answers 403 auth.scope.insufficient to a narrow Bearer token
on a route that names no scope it holds, and requireAdmin answers 403
auth.admin.required to a session that is not an administrator's. Both
run before any route body, and the contract only said so in the prose
of the shared 401, so a client generated from it had no typed branch
for either. The route kit accepted the scope refusal on any operation
that declared a 401 for the same reason.

The 403 is added to the assembled path table rather than to
stdResponses. That set is spread into most operations, about three
hundred of them carry a 403 of their own, and most spread the set after
it, so a shared 403 there would have overwritten every module-off and
sharing refusal in the document. withScopeRefusal adds the shared
response where an operation has none and prefixes an operation's own
403 with the gate's code, keeping the sharing sentence last. Operations
that take no credential, and the three surfaces that authenticate by a
credential of their own, are left as they were.

The kit no longer tolerates the refusal on an operation's behalf, and a
guard holds every operation behind the gate to declaring it.
2026-10-22 07:07:19 +02:00
Marc-André Bombeck 4f2af7c544 test(release): hold the image's Prisma CLI install to the app's versions
The guard pinned the npm-installed dotenv in the Dockerfile to a literal,
which is how that install came to trail the app tree by a patch without
anything noticing. It now reads the Prisma and dotenv versions from
package.json and asks the Dockerfile for the same ones, engines included.
2026-10-22 07:06:51 +02:00
Marc-André Bombeck 901cd884e8 perf(measurements): skip unchanged stats: re-posts in the batch route
The phone re-sends the same daily totals on every sync. Each went through
the reconciler, about ten round trips in its own transaction, to write the
same values back, and as an update it started the DAY refold, the rollup
jobs, PR detection and a cache stale for the account.

One read over the batch's stats: ids now finds the rows whose live stored
row already holds every value the update would write; those are reported
updated exactly as before, per entry and in the summary, and nothing is
written or started for them. A changed column, a tombstoned row, a key
that occurs twice in the batch or a value the plausibility gate refuses
still goes through the reconciler.
2026-10-22 07:01:04 +02:00
Marc-André Bombeck 850988c832 perf(analytics): group before the canonical-source pick, and run without JIT
The shared canonical-source CTE sorted every raw reading in its window by
a CASE expression no index can supply; on the benchmark account the pulse
pick over ten years sorted 260,000 rows and spilled to disk. Its order
depends only on (type, source), so the pick now groups the window to its
distinct (type, day, source) triples first and runs DISTINCT ON over
those. The picked source per day is the same by construction; an
integration test runs the old statement beside the new one over a fixture
with overlapping sources, sources off the ladder, tombstones, empty days
and readings either side of midnight, under default and reordered ladders,
for every reader shape, and requires identical rows.

Every app session now sets jit=off. Postgres compiled the larger folds to
machine code, which took 100 to 140 ms of a 220 ms statement and saved
nothing for statements that run once and are bound by reading rows. JIT
never changes a result.
2026-10-22 07:01:04 +02:00
Marc-André Bombeck b5f5a5a580 perf(i18n): build the every-locale match indexes on first use
Seven matchers built their index over every shipped catalog at module
load (the GLP-1 tag map, the nutrient and metric names of the MCP reads,
the SNOMED and LOINC lab names, the Coach's health words, the lab-name
pattern), so loading the worker graph read all eight catalogs at boot.
Each index is now built the first time it is asked for.

The boot profile records the change: the longest busy stretch of the
main thread at boot fell from 753 ms to 241 ms, the live heap after five
idle minutes from 164 to 152 MB, and the catalog chunk sources (two of
9.7 MB) are gone.
2026-10-22 07:01:03 +02:00
Marc-André Bombeck e7b31fe8e0 refactor(backup): run the restore as ordered steps from per-domain modules
restoreBackup becomes an orchestrator: the checks before the first delete
move to restore-preflight.ts, and the transaction runs the steps listed in
backup-domains.ts in dependency order, emptying the account first and then
rebuilding it domain by domain. The inline restores for measurements,
medications, mood, labs, illness, allergies, family history, workouts,
nutrient days, documents, the instance settings and the cleared
notification channels move into one module per domain, beside the
existing section modules. Cross-domain references travel through one
typed context instead of local variables.

The order of every write, the refusals and their audit rows, the skip
report, the cleared counts and the reported phases are unchanged; the
golden copies of the restored account still match. Guards that pinned
the restore by path now point at the domain files.
2026-10-22 06:59:54 +02:00
Marc-André Bombeck 96062d198a chore(deps): keep vite on the 8.1 line its advisories were fixed on
Re-resolving Vitest 5.0.5 had carried vite from 8.1.5 to 8.3.4, two
minors nobody asked for: 8.2 resolves the root through real paths,
which moves file ids in a symlinked worktree, and 8.3 warns on every
run about config-loader features the test configs use. 8.1.6 carries
the same five fixes as a backport, so the lockfile takes that and the
floor in the workspace file stays as it is.
2026-10-22 06:58:12 +02:00
Marc-André Bombeck 01c935261b chore(deps): take nodemailer 10.1.7 for the address parser advisories
GHSA-8c8m-p3m8-cjg8 and two sibling advisories against the address
parser are fixed in 10.1.4; 10.1.5 to 10.1.7 are linear-time and
encoding fixes on the same line. The email channel hands the parser a
recipient address each account sets for itself.
2026-10-22 06:53:16 +02:00
Marc-André Bombeck 73362dc0ae chore(deps): take the weekly production and development updates
Production: the AWS SDK S3 client and lib-storage 3.1152.0, Prisma
(client, adapter-pg, CLI) 7.10.1, TanStack Query 5.104.3 and Virtual
3.14.21, dotenv 18.0.8, jspdf-autotable 5.0.9, lucide-react 1.59.0,
nodemailer 10.1.2, pg-boss 12.38.0, React and React DOM 19.3.1 (still
exact) and undici 8.12.

React 19.3.1 and undici 8.12.2 are security releases. Prisma 7.10.1
stops a nested interactive transaction from leaking its id into a
later top-level one.

pg-boss moves its schema from 45 to 46; the worker applies the
migration on start and the index builds continue in the background.
Its list and delete helpers now page and batch, which touches only the
queue retirement at boot, and that is idempotent. The retention test
pins the installed version, so it moves to 12.38.0 after re-checking
every source line it asserts.

Development: @axe-core/playwright 4.14.0, Playwright 1.64.1, Vitest and
its coverage provider 5.0.5, shadcn 4.22.0. The Stryker runner patch
still applies; it touches the runner's own name join, and 5.0.5 changes
nothing in how Vitest builds a test's full name. axe-core 4.14 turns on
label-content-name-mismatch for the WCAG 2.1 A tag the a11y specs run.

The S3 client is held on the same release as lib-storage rather than
the newest one its range allows, so the two share one SDK core.

Vite gets a floor at 8.1.6 for GHSA-587h-7wmj-h5pm and four sibling
advisories against its dev server and bundled launch-editor. It arrives
only under Vitest and never reaches the image.

The image's separate Prisma CLI install moves with the app tree to
7.10.1, and its dotenv to 18.0.8, which it had trailed by a patch.
2026-10-22 06:53:04 +02:00
Marc-André Bombeck dcf49dd14e refactor(backup): let the account-settings domain own the User column verdicts
The SETTING verdicts are now the keys of the codec table, so a column
becomes a setting by getting a codec and nothing else has to name it. The
verdicts for every other User column move beside it into
account-settings-classes.ts, and the restore half into
account-settings-restore.ts. The backup plan keeps the model verdicts.

The classification guard now also refuses a column that has both a codec
and another verdict, since the composed table would quietly carry it as a
setting.
2026-10-22 06:50:58 +02:00
Marc-André Bombeck 66f8b18cf8 Merge branch 'release/v1.45.0' into feat/v145-backup 2026-10-22 06:42:29 +02:00
Marc-André Bombeck a859adb59c Merge branch 'release/v1.45.0' into feat/v145-runtime 2026-10-22 06:41:30 +02:00
Marc-André Bombeck a20cf17539 fix(jobs): let running jobs finish on stop, and give the worker four pg-boss sockets
Next's signal handler closed the server and called process.exit, which
won the race against boss.stop, so a running job was cut off and stayed
active until its expiry. The shutdown handler now holds process.exit until
the boss has stopped, at most 30 s plus a margin, and compose gives the
container 45 s to stop.

A process that runs the worker gets four pg-boss connections instead of
two for its pollers, cron, supervision and job writes; a web-only producer
keeps two. Both come out of the same per-process budget.
2026-10-22 06:41:17 +02:00
Marc-André Bombeck 27e83f1a83 feat(observability): guard the container's memory, not only the heap
The heap monitor's tick now also reads the cgroup working set (usage minus
reclaimable page cache) against the container limit. At 85 % it writes one
runtime.memory.pressure line with the work in flight, and the heavy lane
sends new heavy jobs back to the queue for a minute and asks a sliced
holder to give way, for at most fifteen minutes per episode. Heap lines
carry the working set and the limit. Outside a cgroup with a limit nothing
changes.
2026-10-22 06:41:17 +02:00
Marc-André Bombeck 2ccac728f4 perf(i18n): read server catalogs from disk, one locale at a time
The eight catalogs were static JSON imports, so every server bundle
carried the whole eight-locale chunk as a source string and parsed all of
them: 32 parsed copies and two 9.7 MB chunk sources in an idle process.
The server now reads messages/<locale>.json the first time a translator,
the SSR cache or a matcher asks for that locale, and keeps one parsed copy
per process on globalThis. allMessages keeps its shape; its entries load on
first read. The image copies messages/ next to the server.

foldLabel moves to a leaf module, so the client medication map no longer
imports the catalog index.
2026-10-22 06:41:16 +02:00
Marc-André Bombeck 7e932c4740 perf(boot): watch the loop from the first line and load the worker in steps
The event-loop monitor starts first in register(), so loading the
database client and the worker graph is inside its view, and a stall line
now carries the process uptime, the startup phase, the boot step running
at the time and the oldest work in flight. Each boot step is timed, and
the boot ends with one boot.steps line.

The five registrars and the lifecycle helpers are imported one at a time
after boss.start(), with a turn of the event loop in between, instead of
all at once when the worker module loads. The longest stall is then the
largest registrar graph rather than the sum of all five.
2026-10-22 06:41:16 +02:00
Marc-André Bombeck 63e8630fcb perf(bench): profile a production boot and the idle heap
scripts/bench/boot-profile.ts starts the production build with every role
under the production heap ceiling against the seeded benchmark database,
and records the time to a healthy probe and to a ready worker, every
event-loop stall the process logs, the heap lines, the full collections
from --trace-gc, the main thread's busy stretches during the boot with
their self time by source module (resolved through the chunk source maps),
and one heap snapshot after an idle wait, summarised by node type,
constructor, largest strings and largest retained subtrees.

The baseline on the current runtime: a 753 ms busy stretch at 0.7 s, most
of it the worker graph, and a live heap of 164 to 172 MB after five idle
minutes, of which the query compiler holds about 50 MB and the eight
locale catalogs about 33 MB (32 parsed copies and two 9.7 MB chunk
sources).
2026-10-22 06:41:15 +02:00
Marc-André Bombeck 64fa65b460 test(backup): pin the file and the restored account against golden copies
Seed one account as far as the schema allows (a row of every two-ended
model, every column of those rows and of the account row, the instance
settings, a sharing grant), export it both ways and restore each file
into a fresh account, over the live account, and from a version-1 file
in the old shapes. The files, counts, parsed payload, preview, restore
outcome, reported phases, audit rows, wide-event fields and every row the
account holds afterwards are compared with golden copies after ids,
ciphertext and run-time timestamps are normalised. Refusals, skip reports
and the schema's issue list for malformed files are pinned the same way.

The seed moves out of the round-trip test into a shared fixture module
so both files fill the same account.
2026-10-22 06:22:30 +02:00
Marc-André Bombeck 0f6d668df1 fix(workouts): match cross-source duplicates by a start window, not a clock slot
Two copies of one session were collapsed only when both starts fell in the
same fixed five-minute clock slot, so a pair thirty seconds apart that
straddled a slot boundary (08:04:50 and 08:05:20) stayed as two workouts in
the list, the batch write, the calendar, strain and the Coach.

Workouts of one user and sport are now clustered by start time: a workout
joins the open cluster while it starts less than five minutes after the
cluster's first start. Wherever no pair within five minutes straddles a slot
boundary the clusters are exactly the old slots, which a parity test checks
against the previous picker on thousands of generated inputs, strain impulses
included. A property test shows any offset under five minutes now collapses,
five minutes or more never does, and a uniform shift of every start keeps the
picked set.
2026-10-22 06:16:54 +02:00
Marc-André Bombeck d98920415e docs(contributing): how the test suites are organised and written
docs/contributing/testing.md says which suite a test belongs in and why,
how to write a route test with the kit and what its contract check proves,
how the settings round trip and the integration database work, how the
shards are balanced and the timings regenerated, when to write a property
test, how the mutation floors are kept, and the flaky-test policy with the
quarantine format. Linked from the docs index.
2026-10-22 06:09:43 +02:00
Marc-André Bombeck f40cdd3dc3 test(guards): lower the realism and version-stamp baselines
The mocked route tests moved onto the kit take the ratchets down: route
tests mocking @/lib/db wholesale 321 to 274, route tests stubbing
apiHandler 138 to 133, as never casts in tests 6 014 to 5 496, and version
stamp comments 10 634 to 10 592 with the deleted files.
2026-10-22 06:09:11 +02:00
Marc-André Bombeck ae2636c9e5 Merge branch 'release/v1.45.0' into feat/v145-testkit
# Conflicts:
#	src/app/api/tokens/__tests__/route.test.ts
2026-10-22 06:08:47 +02:00
Marc-André Bombeck f5d4dd2ec6 test: mutation testing on the core libraries with per-module floors
Stryker runs against encryption at rest, medication compliance, the rollups,
source ranking, unit conversion and backup validation, with the unit tests
that live next to them (vitest.mutation.config.mts). It is too slow for a
pull request and runs nightly and on demand (.github/workflows/mutation.yml).
scripts/mutation-floors.mjs reads the JSON report and fails when a module
scores below its floor in stryker.floors.json, so one well-tested module
cannot hide a weak one. The floors sit two points below the first measured
scores: crypto 46.9 %, compliance 66.8 %, rollups 41.7 %, source ranking
57.0 %, units 87.4 %, backup validation 25.5 %.

@stryker-mutator/vitest-runner 10.0.0 predates Vitest 5, which names a test
"describe > test" where the runner still built "describe test". Every test
inside a describe block was filtered out of the mutant runs, so nearly every
mutant survived (5.5 % across the set). A pnpm patch builds the name the way
Vitest 5 does; the Docker dependency stage copies the patches directory,
which the frozen install needs.
2026-10-22 06:08:20 +02:00
Marc-André Bombeck 1915f91526 test(insights): pin the clock in the two-service workout collapse case
The case built its sessions from the real clock: one copy a day before
now, the other 30 s later. The cross-source workout picker groups sessions
into fixed five-minute slots of the epoch, so whenever the suite ran in
the last 30 s of a slot the two copies landed in different slots and
counted twice. It had nothing to do with day boundaries or time zones: the
outcome followed the minute of the wall clock alone.

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

The production code reads no clock here (the block takes `now` from its
caller). What the flake exposed is the fixed slot grid itself: two copies
of one session that straddle a slot edge are not collapsed. That is a
change to the picker every workout surface shares, so it is left for a
change of its own.
2026-10-22 05:58:45 +02:00
Marc-André Bombeck 9bdd0b1a60 perf(bench): record the baseline on the v1.44.0 runtime
One run of scripts/bench/run.ts against the seeded account (1,266,549
measurement rows, anchor 2026-10-22), 20 measured requests per row after 3
warm-up requests, web-only production build, 792 MB heap ceiling, Postgres
16.15. The commit adds only lint rules, guards, tests and scripts on top of
v1.44.0, so the server code measured is the v1.44.0 code.

Cold (caches evicted before each request), p95 and statements per request:
/api/auth/me 17 ms / 19, dashboard summary 233 ms / 31, dashboard snapshot
2253 ms / 158, pulse series 30 d 59 ms / 8, 365 d 311 ms / 8, 3650 d
2118 ms / 8, day view 78 ms / 49, insights health status 140 ms / 68,
correlations 105 ms / 45. Warm, the snapshot is 24 ms / 13 and the 3650-day
series still 1506 ms, since that read is not cached. The 5,000-row batch
ingest took 1722 ms and 424 statements over ten requests.

The machine was shared with other work during the run (load average 8 to
20), so latency is indicative; the statement counts do not depend on it.
The results directory is written by the script and left out of prettier,
so a re-run diffs cleanly.
2026-10-22 05:55:38 +02:00
Marc-André Bombeck d539eee90b test(integration): expect the worker's own database in the environment check
The environment check pinned the database a worker talks to as the single
shared one. Each worker now runs against its own clone, so the check names
the clone and asserts it is not the migrated template the clones copy.
2026-10-22 05:48:05 +02:00
Marc-André Bombeck d010ed2301 ci: a nightly run that shuffles both suites and tries new property inputs
Once a night both suites run with files and tests in a random order, seeded
and printed, so order dependence surfaces on purpose rather than on somebody's
pull request; a failure replays with --sequence.seed. The same run gives every
fast-check property a fresh seed and five times the cases. The integration
reports are merged into a regenerated timing file and uploaded for a
maintainer to commit when the shards drift apart. Not a gate.
2026-10-22 05:39:55 +02:00
Marc-André Bombeck e9f89bbebf ci: shard both suites by measured duration and split the unit suite
Vitest's --shard cut the file list into slices of equal length, which gave
the integration shards 954 s to 1 449 s. A sequencer now deals files out by
their measured duration, plus a fixed per-file cost for import and setup,
longest first to the shard with the least work, and starts the longest files
first inside a shard. Timings live in tests/durations/*.json and are
regenerated from Vitest JSON reports with scripts/test-durations.mjs; every
CI shard uploads its report.

Integration shards run three workers each, one cloned database per worker.
The unit suite runs as three projects decided per file from its source:
guards and unit share a module graph inside a worker, isolated keeps a fresh
one for every file that mocks, spies, stubs or resets. In CI it is three
duration-balanced shards next to a job for lint, format, types and the
OpenAPI drift check.

The required check names stay as they were. "Lint, Typecheck & Test" is now
the job that answers for the static checks and every unit shard, the way
"integration" already answers for its shards. The browser-install guard
recognises a direct, flags-only vitest run as a run of the unit suite.
2026-10-22 05:39:35 +02:00
Marc-André Bombeck d640eea32a test(integration): hold statement budgets on the hot routes
tests/integration/query-counter.ts counts the statements the app sends
while a function runs, by wrapping Client.prototype.query on the copy of pg
the Prisma adapter itself resolves (so a second pg in node_modules cannot
leave it reading zero; a window with no statement throws). Background tasks
are settled before the window closes, so fire-and-forget writes count with
the request that started them.

query-budget-hot-routes.test.ts seeds a small account (four months of the
main types with overlapping sources, stats rows, sleep, glucose, a
medication, workouts and mood), warms each route once, evicts the user's
caches and counts the next call. Budgets, measured on this fixture:
/api/auth/me 18, dashboard summary 86, dashboard snapshot 103, pulse series
30/365/3650 days 6 each, day view 48, insights health status 60,
correlations 44, a 500-row batch ingest 31. A failure prints every
statement grouped by text.

Proof: one extra `SELECT 1` before every handler in apiHandler failed all
ten budgets by exactly one (18 → 19, 86 → 87, … 31 → 32), which also shows
the counts are deterministic; with the probe removed all ten pass.
2026-10-22 05:38:45 +02:00
Marc-André Bombeck 375ca12b05 perf(bench): add a large-account seed and a request benchmark
scripts/bench/seed-large-account.ts (moved from scripts/, extended) writes
one synthetic account of ten years: 1,265,561 measurement rows across the
main types and five sources (Apple Health dense spot rows, Withings blood
pressure and scale, a weekly manual weight, three years of Oura nightly
values, 90 days of Nightscout CGM), `stats:` and per-sample cumulative rows,
nightly sleep stages, 3,020 workouts, 1,095 mood entries and three
medications with 3,285 intake events. Everything goes in through set-based
INSERT … SELECT generate_series; values and timestamps are functions of the
row index and `--anchor`, so a run is reproducible. The rollup tier is
folded with recomputeUserRollups, as the boot backfill does, and the tables
are analysed. About 80 s on the Mac mini.

scripts/bench/run.ts boots the production build once with every role (so
the queues exist), then measures against a web-only process under the
production heap ceiling: p50, p95 and max latency and SQL statements per
request (pg_stat_statements, read once nothing is in flight and the count
holds still) for /api/auth/me, the dashboard summary and snapshot, the pulse
series over 30, 365 and 3650 days, the day view, insights health status and
correlations, each cold (caches evicted by a one-row manual-sync ingest
before every request) and warm, plus a 5,000-row batch ingest as ten
requests of 500. It writes JSON and a markdown table.

docs/contributing/benchmarks.md describes the setup against a throwaway
Postgres in Docker with the production pg_stat_statements settings.
2026-10-22 05:38:36 +02:00
Marc-André Bombeck 28f9b1948b test: a flaky-test quarantine with an expiry the guard enforces
tests/quarantine/quarantine.json is the only way to take a flaky unit or
integration test out of a run. An entry names the file, the full test name,
the issue, the reason, the day it was added and the day it expires, at most
14 days later. Both setup files skip a listed test while its entry is live,
reported as skipped with its issue rather than retried into green. Past its
expiry date the test runs again, and quarantine-guard.test.ts fails until the
entry is removed; it also fails on an entry naming a file or test that does
not exist. The list starts empty.
2026-10-22 05:33:09 +02:00
Marc-André Bombeck 4f5adb4fae fix(units): show zero rather than negative zero after a unit conversion
A converted value that rounds to zero from below came out as -0, and
Intl.NumberFormat prints that as "-0": 0 °F typed and shown back read
"-0 °F", and a body-temperature deviation of a few hundredths below zero read
"-0.0 °F". The rounding now returns plain zero. Found by the new unit
round-trip properties, which check that every value a person can type in
their own unit is shown back exactly as typed.
2026-10-22 05:32:49 +02:00
Marc-André Bombeck 4ff3f3eb5d test: property tests for zones, statistics, cadence, crypto and backups
fast-check properties with one shared configuration (tests/property/): a
fixed seed on every pull request, a fresh one and more cases at night through
FC_SEED and FC_NUM_RUNS, and a printed seed with the shrunk counterexample on
failure.

- Time zones: wall clocks agree with Intl in twenty awkward zones, a wall
  clock maps back to an instant showing it, a skipped hour resolves forward
  on the same day, every instant lies inside the window of the day it is
  filed under, and day keys shift and count as inverses.
- Statistics: every median in the tree says the same number, medians and
  spreads obey order, shift and scale, a least-squares fit recovers an exact
  line and composes from stored sums exactly, and the segmented regression
  recovers a noiseless interrupted line.
- Cadence and compliance: weekly and daily schedules expect exactly what a
  day-by-day oracle expects, in any zone and on transition days, and the
  rate stays within bounds and never falls when a missed dose is taken.
- Encryption: every codec opens what it sealed under the key it names,
  rotation moves the key id and nothing else, and a tampered value never
  opens to anything but the sealed bytes.
- Backups: randomly generated accounts come back from a disaster-recovery
  restore with an identical canonical dump of every generated table.
2026-10-22 05:32:48 +02:00
Marc-André Bombeck 8b89b65f25 build(deps): add fast-check and Stryker as development dependencies
fast-check 4.10.2 for property tests; @stryker-mutator/core and the Vitest
runner 10.0.0 for mutation testing of the core libraries. Exact versions,
each published more than two weeks ago; the audit reports nothing new.
2026-10-22 05:32:37 +02:00
Marc-André Bombeck bc3522aabb test(integration): move medication sub-route tests onto Postgres
Phase config, cadence, side effects, inventory, intake purge, bulk delete and
the import status run through the real handlers against the database. The
advisory-lock ordering the mocked tests read off call order is shown with a
real concurrent transaction holding the inventory lock, cache eviction through
the next list read with a control that the cached list would otherwise stay
stale, and soft deletes on the tombstone and sync counter they leave.
2026-10-22 05:32:22 +02:00
Marc-André Bombeck 43b86e46da test(integration): move notification channel and workout route tests onto Postgres
The channel settings routes, their test-message routes, the reminder
thresholds and the workout detail, delete and batch-scope tests run through
the real handlers against the database. Only the far ends are faked (the
relay, the ntfy server, the Telegram Bot API, SMTP), and the tests assert what
those received. The private-origin source guard stays a unit test and now
points at the two files that carry its runtime proof.
2026-10-22 05:32:22 +02:00
Marc-André Bombeck ff9ef0883b test(integration): move the mood tag route tests onto Postgres
The effective tag read, hide and show, custom tags and groups, and the tag
layout are tested through the real handlers on stored rows instead of a
mocked Prisma client: the migration-seeded catalogue served after a truncate,
owner scoping that leaves another account's tag byte-identical, the caps,
sealed labels, soft delete against purge, a deleted group re-homing its tags,
and the layout's optimistic token from 200 through 409 to a successful retry.
2026-10-22 05:32:21 +02:00
Marc-André Bombeck 26df61f638 test(integration): drive the Strava sync against Postgres
The Strava writer had eight mocked test files and no test against a
database. strava-sync.test.ts runs the real sync behind a fake Strava API at
the safeFetch boundary: field mapping, the after= watermark, idempotent
re-sync on the (user, source, external id) key, in-place overwrites that keep
what only the detail read carries, a cursor that never moves backwards or
past a row that failed to write, the paged backfill with its resume point
and requeue, token refresh with both rotated tokens stored sealed, and the
integration ledger after transient and permanent failures. The sync, the
disconnect and the credential routes are covered through the kit.

The mocked cases that asserted call counts and mock ordering are removed;
the pure retry-after parsing stays a unit test.
2026-10-22 05:32:09 +02:00
Marc-André Bombeck c0c0fdb421 test(integration): move the account preference route tests onto Postgres
Fourteen route tests for the /api/auth/me preference family replaced Prisma
with a mock and asserted the calls it received. Their claims are now shown
through the real handler on the stored row and the response: validation
refusals with their codes, normalisation and defaults on read, optimistic
lock conflicts against a real second writer, rate limits driven to their
first refusal in the Postgres limiter, audit rows read back, cache entries
evicted, and the jobs a change queues observed on a real pg-boss queue.

Assertions that were only about the shape of a Prisma call are replaced by
the behaviour they stood for, and two that described a state only a mock
can reach (a missing column) by the stored value that state resolves to.
2026-10-22 05:31:59 +02:00
Marc-André Bombeck 09f088e8a7 fix(auth): record a self sign-out as one in the session revoke audit
DELETE /api/auth/me/sessions/{id} takes the session's public handle, an HMAC
of the row id, and compared that handle with the caller's row id to decide
whether the caller signed out the session it was using. The two never match,
so every revoke was audited and annotated with self: false, including the
ones where a person ended their own session. The handle is compared with the
handle of the caller's session now.
2026-10-22 05:31:51 +02:00
Marc-André Bombeck f353f55212 test(integration): sessions, devices, security activity and FHIR against Postgres
The mocked route tests for the session list and revoke, the device list and
revoke, the security-activity feed and the FHIR surface move onto the kit.
Each claim is now shown on the stored rows and on the next request: a
revoked session, device login or token is refused when it is presented again,
another account's session or device answers 404 and stays signed in, the
current-session and current-device flags follow the credential that anchors
them, and no row id, full IP or push token reaches a response.

The FHIR read set, covered against a database only by the scope check until
now, is driven with seeded readings and medications: every resource carries
the seeded values, another account's rows never appear, a fhir:read token
reaches every data route and an unrelated narrow token is refused, the module
gate closes all eleven data routes, and $everything serves the same entries
as the document builder with references that resolve inside the response.
The module-gate inventory now names the new file as its behavioural proof.
2026-10-22 05:31:09 +02:00
Marc-André Bombeck b179e34f58 test(integration): round-trip every account setting through the API and a backup
One table holds an entry per write route, and every User column the backup
classifies as a setting appears in exactly one entry; a new setting column
fails the inventory case until it has one. For each entry the file sets a
non-default value through its write route, reads it back through its read
route and through /api/auth/me where that publishes it, and checks the stored
column moved off what a fresh account holds.

The backup leg runs through the routes too: the portable full backup a
person downloads, the operator's upload, and the restore into an account
recreated under the same id. Every setting must read back identically on the
restored account, through the same routes and the account payload, and every
stored column must come back with its value.
2026-10-22 05:30:40 +02:00
Marc-André Bombeck 3d837cdd77 test(kit): call route handlers through the real API kit against Postgres
tests/kit/ holds typed fixture factories and callRoute(handler, options).
The factories write through the application's Prisma client, so a fixture has
the shape production stores and the compiler checks it; sessions and Bearer
tokens are minted by createSession and issueApiToken, so a fixture cannot
drift into a shape authentication no longer accepts.

callRoute builds a real NextRequest, fills the next/headers stand-in from the
auth fixture and runs the exported handler, apiHandler included. Every call is
also a contract check: the status must be declared for that path and method in
the OpenAPI registry, a JSON body must parse with the registered schema, and a
key the schema does not declare fails the call. The scope gate's generic 403,
documented on the shared 401, is the one answer accepted without its own
entry. kit-contract.test.ts proves each refusal can fire.

The token list and revoke move onto the kit. The mocked unit test asserted
the list's where clause and proved the API switch is not consulted by reading
api-handler.ts for a string; the kit test authenticates a Bearer token while
the switch is off, revokes it, and shows it refused on the next request.
2026-10-22 05:30:31 +02:00
Marc-André Bombeck a6787cfc03 test(integration): run files in parallel, one cloned database per worker
Global setup migrates one template database and reads what a fresh install
holds: its schemas, its tables and the rows the migrations seed. Each Vitest
worker clones the template on its first file with CREATE DATABASE ... TEMPLATE
and keeps the clone for the rest of the run, so files run in parallel without
seeing each other's rows, advisory locks or queue schema. Schemas created at
runtime (pg-boss, migration probes) are dropped when the next file starts.

The truncate list is the catalogue now, every table in public except the
migration bookkeeping, instead of a hand-kept list of 42 of 166 tables. The
seeded catalogues are put back after each truncate in foreign-key order, so a
test starts from what a fresh install has. The settings singleton is the one
seeded table a test starts without, declared with its reason.

next/headers and the boot compatibility probe are mocked once in the setup
file. Two lock-wait probes now look at their own database only, since other
workers' backends share pg_stat_activity.
2026-10-22 05:30:00 +02:00
Marc-André Bombeck 45ea61261e build(bundle): gate every page's first load and the distinct client code
The gate summed every emitted chunk once and watched five pages. The sum
counts a module once per chunk group it lands in, so it rose whenever a
page got lighter by moving code into on-demand chunks, and it needed a
raise in most releases while no reader downloaded more; and a heavy import
into a page outside the five went unseen.

scripts/check-bundle-budget.mjs now measures:

- the first load of every page: rootMainFiles plus the page's
  client-reference chunks, each file counted once by content hash;
- p50, p95 and max of those first loads across pages;
- the distinct client code: every chunk evaluated in a node:vm sandbox
  with a stub TURBOPACK array, each module factory counted once by its
  module id (not its text: each chunk is minified on its own, so copies of
  one module differ in mangled names), catalogs excluded, gzipped once.

The summed aggregate is still printed but no longer gated. The catalog,
per-catalog and one-Recharts checks are unchanged. `--write-baselines`
rewrites the per-page baselines, `--dir` measures another build directory,
`--json` writes the report.

bundle-budget.json is re-based from the v1.44.0 build (local arm64): 110
pages, first load p50 336, p95 437, max 451 KB gz; distinct client code
1904 KB gz in 1224 modules; summed aggregate 3875 KB for comparison. Every
page has a baseline with the same 20 KB allowance; caps are p95 447, max
461 and distinct code 1930 KB. A page without a baseline fails until one
is written in the same change.

Proof: importing zod into the weight insights page measured
/insights/weight at 427.8 KB gz, 88.8 KB over its 339 KB baseline, and the
gate failed; the old gate passed the same build, because the page was not
watched and the summed aggregate did not move (3875 KB). The import was
removed again.
2026-10-22 04:36:21 +02:00
Marc-André Bombeck f64b49cf55 test(guards): freeze runtime import cycles and re-export-only modules
The runtime import graph (static value imports and re-exports, side-effect
imports, dynamic import() with a literal; `import type` erased) is built
with the TypeScript compiler's module resolution under the `@/*` mapping,
over every production module. No new dependency.

- Cycles: 13, counted one per back edge of a sorted depth-first walk (the
  number `madge --circular` reports), in 7 strongly connected components:
  the audit/geo/notifications/managed-profiles cluster (10 modules), the
  compliance facade cluster (9), command palette ↔ store ↔ shortcuts,
  modules ↔ cycle gate, boss-instance ↔ boss-ready, mood aggregates ↔
  patterns, features ↔ signals-of-day. A new component or a component that
  draws in another module fails whatever the count says; a lower count or a
  shrunk component fails until written back. The target is zero.
- Re-export-only modules (every statement an `export … from`): 16 listed;
  a new one fails.

Proof: two planted modules importing each other failed with
"lib/zz-probe/a.ts <-> lib/zz-probe/b.ts", a planted `export * from "./a"`
module failed the re-export ratchet, and a baseline raised to 14 cycles
failed with the write-back message until RATCHET_WRITE=1 restored it.
2026-10-22 04:29:57 +02:00
Marc-André Bombeck 2604c3e3c6 test(guards): ratchet source size, test realism and version-stamp comments
Add a ratchet mechanism (helpers/ratchet.ts): per-key counts against a JSON
baseline under src/__tests__/baselines/. A new key or a higher count fails;
a lower count fails until it is written back with
`RATCHET_WRITE=1 pnpm vitest run <guard>`, which only ever lowers numbers.
Raising one is a hand edit in a reviewed change. The baselines are
machine-written, so prettier leaves them alone.

helpers/source-metrics.ts measures the production tree through the
TypeScript compiler: the file list (dot directories included, tests and
generated code out), line counts, every function node with a stable
"outer > inner" name and its length, and the runtime import graph.

Guards and their baselines, measured on this tree:

- source-size-ratchet: 129 files over 800 lines and 415 functions over 200
  lines (catalog and data files exempt, each named with its reason; slack
  25 and 10 lines before a shrink must be written back).
- test-realism-ratchet: 321 route tests mocking @/lib/db wholesale, 138
  stubbing @/lib/api-handler, 6014 `as never` casts in 570 test files, and
  400 test files that read a source file by path (reads through readSource
  are not counted).
- version-stamp-ratchet: 10634 comment lines in 2985 files that open with a
  release version; none may be added.

Proof each can fail: a planted 953-line file with a 253-line function failed
both size ratchets, and one line appended to app/page-client.tsx failed the
file ratchet (2326 → 2327); a planted route test with vi.mock("@/lib/db")
and one `as never` failed both of those ratchets; a planted comment
"// v1.45.0 — probe stamp" failed the stamp ratchet. Raising one as-never
baseline entry by 5 failed with the write-back message, and RATCHET_WRITE=1
restored the file byte for byte. All probes were removed again.
2026-10-22 04:29:47 +02:00
Marc-André Bombeck f3d7ab31e2 test(guards): make path-pinned source reads fail when their subject moves
A test that reads a source file by path and asserts an absence
(`expect(source).not.toMatch(…)`) stays green after a split: the code it
watched moved next door and the old path no longer holds it. A missing file
throws; a file that kept its name but lost its contents does not.

- `readSource(path, anchors)` in helpers/source-files.ts reads one file and
  requires every anchor (a string or pattern the watched code carries) to
  match at least once; it throws on a missing file, a lost anchor, or a read
  without an anchor.
- `readSourceFolder(dir, { floor })` is the folder-scan form for a subject
  that was split into several files.
- The twelve reads that were asserted only negatively now go through
  `readSource` with an anchor: the batch route, the integration-sync
  registrar, the dashboard client, the mood insights client, the sleep
  overview and sleep page, the sidebar, the baseline form, the account
  section (two guards), api-handler's requireAuth and the Google Health sync.
- The two schema parsers the wipe and enum guards share now refuse a parse
  of the real schema that finds fewer than 50 models (and 10 enums), so a
  moved file or a drifted block regex cannot leave those guards comparing
  an allowlist against nothing. Synthetic schemas a test writes stay exempt.

Proof: renaming `setSaving` in the baseline form made the converted
confirm-failure guard throw "the file no longer contains setSaving"; the
helper's own test plants a missing file, a lost string anchor, a lost regex
anchor, an empty anchor list and an under-floor folder, each of which throws.
2026-10-22 04:29:35 +02:00
Marc-André Bombeck c86c7d1a79 refactor(lint): enforce the lib → components and client → server boundaries
Add the in-repo rule `healthlog/import-boundaries`:

- a file under src/lib may not import src/components or src/app (alias,
  relative, re-export and dynamic forms, type-only included);
- a "use client" file may not make a value import of the database client,
  the crypto module, the Prisma client entry, the job queue, pg, pg-boss or
  next/headers. Type-only imports and the Prisma enums entry stay legal.

The four lib files that still import upward are listed in the rule's allow
map with a reason each; a listed file that stops importing upward is
reported, so the map can only shrink.

Five of the nine upward imports are gone by moving pure modules to their
layer, without behaviour change:

- supply-runway estimate → src/lib/medications/supply-runway.ts
  (read by the low-stock job and the medication list read);
- weekday BYDAY tokens → src/lib/medications/scheduling/weekday-tokens.ts
  (read by the medication extraction prompt);
- settings section slugs → src/lib/settings/section-slugs.ts
  (read by the record-settings classification);
- the command palette index → src/components/command-palette/ (it is a
  client index built from component registries).

Proof the rule can fail: an import of @/components/ui/card appended to
src/lib/medications/supply-runway.ts reported libUpward, and a planted
"use client" file importing @/lib/db reported clientServerOnly; both were
removed again.
2026-10-22 04:15:11 +02:00
dareoon 12065bc0c7 feat(i18n): add Brazilian Portuguese support to medication and insight flows
Add pt-BR locale coverage across medication extraction, coach prose
grounding, no-key insight fallbacks, coach read dates, and lab reference
range parsing.

- Accept pt-BR natively in NaturalLanguageExtractorProps.
- Add Brazilian Portuguese population-norm framing patterns.
- Add pt-BR no-assessment fallback copy for all insight floors.
- Add pt-BR expected date coverage to the Coach read strip test.
- Add pt-BR lab range prose vocabulary.
- Keep existing locale behavior and safety-contract coverage unchanged.
2026-10-19 22:32:17 -03:00
dareoon da3edbf729 chore(i18n): revisão de pt-BR e normalização de 'toma' para 'dose' 2026-10-19 18:28:51 -03:00
dareoon 1223476280 i18n(pt-BR): translate demoBanner 2026-10-19 15:34:27 -03:00
dareoon 29ac3af002 i18n(pt-BR): translate offlineBanner 2026-10-19 15:34:27 -03:00
dareoon 5d479491f8 i18n(pt-BR): translate notFound 2026-10-19 15:34:27 -03:00
dareoon 985c047a4c i18n(pt-BR): translate errorBoundary 2026-10-19 15:34:27 -03:00
Dareeon a5344b7c85 i18n(pt-BR): translate dashboard section 2026-10-19 15:25:27 -03:00
Dareeon 5a2ccb657c i18n(pt-BR): translate nav and auth sections 2026-10-19 14:05:46 -03:00
Dareeon 13dac2d89c i18n(pt-BR): translate doctorReport section 2026-10-19 14:01:06 -03:00
Dareeon 50e901f807 i18n(pt-BR): add initial translation (common, apiErrors) 2026-10-19 13:46:00 -03:00
3265 changed files with 485005 additions and 97976 deletions

No files matched your search

+6
View File
@@ -28,6 +28,12 @@ playwright/.cache
playwright.config.ts
vitest.config.mts
vitest.integration.config.mts
vitest.mutation.config.mts
# The unit setup imports tests/ (excluded above); the image never runs it and
# the build's type check must not see a half-present test harness.
vitest.setup.ts
stryker.config.mjs
stryker.floors.json
tests
tests/
e2e
+55 -6
View File
@@ -202,7 +202,10 @@ API_TOKEN_HMAC_KEY="REPLACE-WITH-OUTPUT-OF-openssl-rand-hex-32"
#
# Set this to "1" ONLY when every public request to HealthLog terminates
# at Cloudflare (Tunnel, Workers, classic proxy) before it reaches your
# origin. Deployments behind nginx, Caddy, Traefik, an AWS ALB, or any
# origin, and the origin accepts connections from Cloudflare alone (a
# Tunnel, or a firewall that admits only Cloudflare's address ranges): the
# header is believed from every connection, before any other check.
# Deployments behind nginx, Caddy, Traefik, an AWS ALB, or any
# direct-to-Next.js exposure MUST leave this off.
#
# Security implications when the flag is mis-set:
@@ -224,10 +227,19 @@ API_TOKEN_HMAC_KEY="REPLACE-WITH-OUTPUT-OF-openssl-rand-hex-32"
# Number of trusted reverse-proxy hops in front of the app. The client IP is
# read that many entries from the right of the X-Forwarded-For chain. Set 0
# for a directly-exposed host; 1 (the default when unset) behind a single
# reverse proxy. X-Real-IP is read only at 1. A wrong value makes the app
# reverse proxy. X-Real-IP is read only at 1. Forwarding headers are read only
# from a connection whose own address is not public (loopback, private, CGNAT,
# unique-local, link-local, reserved or multicast; the full list is in
# docs/self-hosting/reverse-proxy.md). A wrong value makes the app
# refuse the headers, log one warning, and share one rate-limit bucket across
# all anonymous callers.
# TRUST_PROXY_HOPS="1"
# The addresses the reverse proxy connects from (comma-separated CIDRs). Set,
# only those connections may report the client's address; unset, any private
# address may, which behind Docker Desktop, OrbStack or docker-proxy includes
# every caller of the published port. Same host: 127.0.0.1/32,::1/128; Docker
# network: the proxy's subnet. See docs/self-hosting/reverse-proxy.md.
# TRUSTED_PROXY_CIDRS=""
# TRUST_CF_CONNECTING_IP=""
@@ -277,6 +289,33 @@ API_TOKEN_HMAC_KEY="REPLACE-WITH-OUTPUT-OF-openssl-rand-hex-32"
# BACKUP_ENCRYPTION_KEY="" # 64-hex-char or base64 32-byte; SEPARATE from ENCRYPTION_KEY
# BACKUP_ENCRYPTION_PREVIOUS_KEYS="" # retired off-host keys, comma-separated, read-only during a rotation
# -----------------------------------------------------------------------------
# Database dumps (optional, on by default)
# -----------------------------------------------------------------------------
# The whole-database dump that rebuilds a lost server: nightly, and before
# every update that brings migrations. Written to DB_BACKUP_DIR with a
# manifest beside it (row counts, key fingerprints, versions); with the
# off-host bucket above set, each nightly dump is also uploaded under
# `database/`, sealed with BACKUP_ENCRYPTION_KEY. Local dumps are not
# encrypted: they sit beside the database volume, in the same trust zone.
# Restore: `docker compose run --rm db-restore latest`. See
# docs/ops/disaster-recovery.md.
#
# Set "false" to stop the nightly dump.
# DB_BACKUP_ENABLED="true"
# Where dumps are written. The bundled compose file mounts the `backups`
# volume here; change it only together with that mount. In a container a
# directory that is not on a mount of its own sits in the container's layer
# and is lost at the next update, so no dump is kept there.
# DB_BACKUP_DIR="/var/lib/healthlog/backups"
# Five-field cron, in Europe/Berlin time like every scheduled job.
# DB_BACKUP_SCHEDULE="30 1 * * *"
# Nightly dumps kept (at least 1). The last two pre-migration dumps are kept
# besides.
# DB_BACKUP_KEEP="7"
# Set "false" to start an update without the dump before its migrations.
# DB_BACKUP_BEFORE_MIGRATE="true"
# -----------------------------------------------------------------------------
# Stored backups in the database (optional)
# -----------------------------------------------------------------------------
@@ -328,11 +367,21 @@ API_TOKEN_HMAC_KEY="REPLACE-WITH-OUTPUT-OF-openssl-rand-hex-32"
# Auto-deploy webhook (v1.4.15 — phase C2)
# -----------------------------------------------------------------------------
# Coolify (or any CI) pings POST /api/internal/deploy-webhook after a deploy
# attempt with success/failure. We verify the request via this shared secret
# carried in the `X-Deploy-Webhook-Secret` header (timing-safe compare).
# Generate with `openssl rand -hex 32`. Configure the SAME value in Coolify's
# Notifications → Webhook custom-headers panel. See docs/audit/v1415-auto-deploy.md.
# attempt with success/failure. A request authenticates with this shared
# secret in the `X-Deploy-Webhook-Secret` header (timing-safe compare; what
# Coolify's stock sender does, with no replay protection), or by signing:
# `X-Deploy-Webhook-Signature: sha256=<hex>`, the HMAC-SHA256 under this
# secret of the `X-Deploy-Webhook-Timestamp` value, a dot and the raw body,
# the timestamp within 5 minutes. Generate with `openssl rand -hex 32`.
# Configure the SAME value in Coolify's Notifications → Webhook
# custom-headers panel. See docs/ops/deploy.md.
# DEPLOY_WEBHOOK_SECRET=""
# "true" also requires a fresh X-Deploy-Webhook-Timestamp on a secret-header
# request; leave unset for Coolify's stock sender, which sends none.
# DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP=""
# "true" accepts signed requests only (the setting that stops a replay);
# leave unset for Coolify's stock sender.
# DEPLOY_WEBHOOK_REQUIRE_SIGNATURE=""
# -----------------------------------------------------------------------------
+81 -14
View File
@@ -295,11 +295,17 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
# Optional: deployment-dashboard URL (e.g. the Coolify UI) linked in
# deploy-failure notifications. When unset the notification omits the link.
# DEPLOY_LOGS_URL=""
# Optional replay hardening: require a fresh X-Deploy-Webhook-Timestamp
# header (unix seconds / ms or ISO-8601, +/- 5 minutes) on every webhook
# call. Leave unset for Coolify's stock sender (it attaches no
# timestamp); see docs/ops/deploy.md.
# Optional: require an X-Deploy-Webhook-Timestamp within +/- 5 minutes on a
# request that authenticates with the secret header. Keeps a stale capture
# out, not a fresh forgery by someone who saw the secret. Leave unset for
# Coolify's stock sender, which sends no timestamp.
# DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP="false"
# Optional replay protection: accept only signed webhook calls
# (X-Deploy-Webhook-Signature, an HMAC of the timestamp and body under the
# secret, with a timestamp within +/- 5 minutes); the secret header alone is
# refused. Leave unset for Coolify's stock sender, which can only send the
# secret header; see docs/ops/deploy.md.
# DEPLOY_WEBHOOK_REQUIRE_SIGNATURE="false"
# -----------------------------------------------------------------------------
@@ -414,6 +420,33 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
# key once the lifecycle rule has retired every object written under it.
# BACKUP_ENCRYPTION_PREVIOUS_KEYS=""
# -----------------------------------------------------------------------------
# Database dumps (optional, on by default)
# -----------------------------------------------------------------------------
# The whole-database dump that rebuilds a lost server: nightly, and before
# every update that brings migrations. Written to DB_BACKUP_DIR with a
# manifest beside it (row counts, key fingerprints, versions); with the
# off-host bucket above set, each nightly dump is also uploaded under
# `database/`, sealed with BACKUP_ENCRYPTION_KEY. Local dumps are not
# encrypted: they sit beside the database volume, in the same trust zone.
# Restore: `docker compose run --rm db-restore latest`. See
# docs/ops/disaster-recovery.md.
#
# Set "false" to stop the nightly dump.
# DB_BACKUP_ENABLED="true"
# Where dumps are written. The bundled compose file mounts the `backups`
# volume here; change it only together with that mount. In a container a
# directory that is not on a mount of its own sits in the container's layer
# and is lost at the next update, so no dump is kept there.
# DB_BACKUP_DIR="/var/lib/healthlog/backups"
# Five-field cron, in Europe/Berlin time like every scheduled job.
# DB_BACKUP_SCHEDULE="30 1 * * *"
# Nightly dumps kept (at least 1). The last two pre-migration dumps are kept
# besides.
# DB_BACKUP_KEEP="7"
# Set "false" to start an update without the dump before its migrations.
# DB_BACKUP_BEFORE_MIGRATE="true"
# -----------------------------------------------------------------------------
# Stored backups in the database (optional)
# -----------------------------------------------------------------------------
@@ -446,6 +479,16 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
# 90 rather than repeat the default.
# COACH_MESSAGE_RETENTION_DAYS="365"
# How long deleting an account, a managed profile or "all data" stays
# undoable, in days. Default 14, ceiling 30. The account is locked at once
# (signed out everywhere, sign-in refused, nothing runs for it) and an admin
# can undo the deletion from the users list until the window ends; the purge
# job erases it then, together with its stored and off-host copies. A wipe
# keeps a copy of the record an admin can restore for the same time. 0 erases
# immediately, as releases before 1.46 did. The Data & Privacy page states
# this number, so it has to reach the container.
# ACCOUNT_DELETION_GRACE_DAYS="14"
# How long the host CPU/memory samples behind the admin monitoring charts are
# kept, in days. Default 7. Raise it for a longer trend; lower it on a host
# where disk is the scarce resource.
@@ -517,19 +560,43 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
# -----------------------------------------------------------------------------
# Client-IP resolution -- optional (required behind Cloudflare)
# -----------------------------------------------------------------------------
# When the stack sits behind Cloudflare, the real visitor IP arrives in the
# `cf-connecting-ip` header; the X-Forwarded-For chain otherwise yields the
# Cloudflare edge IP, so the audit-log overview stores the CF datacentre
# address and the geo lookup resolves the wrong location. Set this to "1"
# ONLY when Cloudflare is in front -- a direct-exposed self-host must leave
# it unset, or an attacker could forge the header. Off by default.
# Number of trusted reverse-proxy hops in front of the app; the client IP is
# read that many entries from the right of the X-Forwarded-For chain. 0 for a
# directly-exposed host, 1 (the default when unset) behind a single reverse
# proxy; X-Real-IP is read only at 1. A wrong value makes the app refuse the
# headers, log one warning, and collapse anonymous callers into one
# rate-limit bucket.
# directly-exposed host (the connection's own address is used), 1 (the
# default when unset) behind a single reverse proxy; X-Real-IP is read only
# at 1. Forwarding headers are read only from a connection whose own address
# is not public: loopback, private, CGNAT, unique-local, link-local, reserved
# or multicast (the full list is in docs/self-hosting/reverse-proxy.md). A
# wrong value makes the app refuse the headers, log one warning, and collapse
# anonymous callers into one rate-limit bucket.
# TRUST_PROXY_HOPS="1"
#
# The addresses your reverse proxy connects from, as comma-separated CIDRs
# (IPv4 or IPv6; a bare address is one host). When set, only those
# connections may report the client's address; every other connection is
# its own client. When unset, any private address may, which is the old
# behaviour and is NOT safe behind Docker Desktop, OrbStack or docker-proxy:
# there every caller of the published port arrives from the gateway, a
# private address, and could name its own client address. Set it whenever a
# reverse proxy is in front:
# proxy on the same host (proxying to 127.0.0.1:3000): 127.0.0.1/32,::1/128
# proxy container on a Docker network: that network's subnet (docker
# network inspect <network>), e.g. 172.18.0.0/16
# proxy on another machine with a public address: that address, /32
# A proxy on a public address that is not listed is not believed: every
# request through it shares the proxy's address and one rate-limit bucket.
# The boot log names the rule in force in one [proxy-trust] line.
# TRUSTED_PROXY_CIDRS="127.0.0.1/32,::1/128"
#
# Behind Cloudflare the real visitor IP arrives in the `cf-connecting-ip`
# header; the X-Forwarded-For chain otherwise yields the Cloudflare edge IP,
# so the audit-log overview stores the CF datacentre address and the geo
# lookup resolves the wrong location. "1" believes that header from EVERY
# connection, before the checks above, so set it ONLY when the origin accepts
# connections from Cloudflare alone (a Cloudflare Tunnel, or a firewall that
# admits only Cloudflare's address ranges). An origin that also answers on
# its own public address lets any caller forge the address that rate limits
# and the audit log record. Off by default.
# TRUST_CF_CONNECTING_IP="1"
# Memory cap of the app container (read by docker-compose.yml, not by the app).
+4 -3
View File
@@ -1,16 +1,16 @@
name: Translation feedback
description: Report a wording, grammar, or register problem in HealthLog's AI-initial locales (FR / ES / IT / PL / KO / NL).
description: Report a wording, grammar, or register problem in HealthLog's community-maintained locales (FR / ES / IT / PL / KO / NL / PT-BR).
title: "[Translation] "
labels: ["i18n", "translation-feedback"]
body:
- type: markdown
attributes:
value: |
HealthLog ships German and English as maintainer-curated translations. French, Spanish, Italian, Polish, Korean, and Dutch ship as **AI-initial translations** — the prose passes the locale-integrity test (full key parity, no empty values) but has not been hand-reviewed for register or regional vocabulary.
HealthLog ships German and English as maintainer-curated translations. French, Spanish, Italian, Polish, Korean, Dutch, and Brazilian Portuguese ship as **AI-initial, community-maintained translations** — the prose passes the locale-integrity test (full key parity, no empty values) but has not been hand-reviewed for register or regional vocabulary.
This template captures concrete fixes you would like to see. Phrasing nuance, regional vocabulary (e.g. "Tú" vs "Usted"), gendered forms, and domain-specific health terms are all in scope. The maintainer applies fixes during the regular release cycle.
For wholly new locale requests (e.g. "please add Portuguese"), open a [Discussion](https://github.com/MBombeck/HealthLog/discussions) instead.
For wholly new locale requests (e.g. "please add Swedish"), open a [Discussion](https://github.com/MBombeck/HealthLog/discussions) instead.
- type: dropdown
id: locale
attributes:
@@ -22,6 +22,7 @@ body:
- Polish (pl)
- Korean (ko)
- Dutch (nl)
- Brazilian Portuguese (pt-BR)
- Other (specify under "Surface")
validations:
required: true
+3 -1
View File
@@ -79,6 +79,8 @@ updates:
# The first run of this ecosystem proposed 16.15 -> 18.6, which would
# have handed self-hosters a database their data directory cannot be
# read by. Minor releases still arrive as PRs; a major is a deliberate
# project decision with a migration guide behind it.
# project decision with a migration guide behind it. The ignore also
# keeps `db-upgrade` on 16: it exists to read Postgres 16 data, and a
# bump to 18 would leave it unable to start on the volume it moves.
- dependency-name: "postgres"
update-types: ["version-update:semver-major"]
+2 -2
View File
@@ -60,7 +60,7 @@ jobs:
# minutes and can produce nothing actionable, because nobody edits
# it and the fix for anything found there is `pnpm prisma generate`.
- name: Initialise CodeQL
uses: github/codeql-action/init@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0
uses: github/codeql-action/init@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
with:
languages: javascript-typescript
build-mode: none
@@ -75,6 +75,6 @@ jobs:
- public
- name: Analyse
uses: github/codeql-action/analyze@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0
uses: github/codeql-action/analyze@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
with:
category: "/language:javascript-typescript"
+51
View File
@@ -0,0 +1,51 @@
name: Compose database move
# Plays the Postgres 16 -> 18 move in docker-compose.yml against real
# containers: a v1.45 database with every migration applied, then the current
# compose file's `db-upgrade` on its first start, a second start, a move cut
# short, an emptied new volume and a new install. The service runs once on an
# operator's host and has no second chance there, so every change to the
# compose file or to the check runs it here first.
# `scripts/compose-db-upgrade-check.sh` is the same check, runnable locally.
on:
pull_request:
branches: [main]
paths:
- docker-compose.yml
- scripts/compose-db-upgrade-check.sh
- .github/workflows/compose-db-upgrade.yml
push:
branches: [main]
paths:
- docker-compose.yml
- scripts/compose-db-upgrade-check.sh
- .github/workflows/compose-db-upgrade.yml
workflow_dispatch:
permissions:
contents: read
jobs:
move:
name: Postgres 16 -> 18 move
runs-on: ubuntu-24.04
timeout-minutes: 20
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# The check reads the v1.45 compose file from its tag.
- name: Fetch the v1.45.0 tag
run: git fetch --no-tags --depth=1 origin tag v1.45.0
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- name: Move check
run: bash scripts/compose-db-upgrade-check.sh
+70
View File
@@ -0,0 +1,70 @@
name: Disaster recovery drill
# Rebuilds a lost server from the nightly database dump and the keys alone,
# against real containers: the app image built from this checkout, the
# bundled compose file with an S3-compatible bucket outside it, the demo seed
# plus a Guardian with a managed profile, the dump taken and uploaded by the
# scheduled job, every volume of the stack destroyed, a new stack, the
# restore, and the app booted on the result (health, login, an encrypted
# note, a document, the Guardian's profile, every table's row count against
# the manifest). Two sources: from the bucket (`db-restore latest
# --from-bucket`, the disaster path) and from dump files copied off the host
# (`db-restore latest`).
# `scripts/compose-dr-drill.sh` is the same drill, runnable locally; its
# header lists what it checks and the mutations that must turn it red.
on:
pull_request:
branches: [main]
paths:
- docker-compose.yml
- Dockerfile
- docker-entrypoint.sh
- scripts/compose-dr-drill.sh
- scripts/dr-drill-seed.ts
- scripts/db-dump.mjs
- scripts/db-restore.mjs
- scripts/pending-migrations.mjs
- src/lib/database-backup/**
- src/lib/jobs/database-backup-queue.ts
- src/lib/jobs/reminder/backup-handlers.ts
- .github/workflows/compose-dr-drill.yml
schedule:
# Nightly, 02:41 UTC: off the hour, after the evening merges.
- cron: "41 2 * * *"
workflow_dispatch:
permissions:
contents: read
concurrency:
group: compose-dr-drill-${{ github.ref }}
cancel-in-progress: true
jobs:
drill:
name: Restore a lost server from its dump (${{ matrix.source }})
runs-on: ubuntu-24.04
timeout-minutes: 60
strategy:
fail-fast: false
matrix:
source: [bucket, local]
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: 22
cache: pnpm
# The seed scripts run from the checkout against the drill's database.
- run: pnpm install --frozen-lockfile
- run: pnpm db:generate
- name: Drill
env:
DRILL_SOURCE: ${{ matrix.source }}
run: bash scripts/compose-dr-drill.sh
+2 -2
View File
@@ -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@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
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@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
+13 -11
View File
@@ -40,7 +40,7 @@ jobs:
name: e2e shard ${{ matrix.shard }}/3
services:
postgres:
image: postgres:16-alpine
image: postgres:18-alpine
env:
POSTGRES_USER: healthlog
POSTGRES_PASSWORD: healthlog
@@ -62,7 +62,7 @@ jobs:
AUTH_RP_ORIGIN: http://localhost:3000
SESSION_COOKIE_SECURE: "false"
NODE_ENV: production
# The WebKit spec runs in the `webkit` job below.
# The WebKit specs run in the `webkit` job below.
E2E_SKIP_WEBKIT: "1"
steps:
@@ -95,11 +95,12 @@ jobs:
- run: NODE_OPTIONS=--max-old-space-size=8192 pnpm build
# Client-bundle budget gate — rides this job because it is the one
# place CI already has a production build on disk. Catches the
# regressions the dead webpack-era tooling (ANALYZE=1, the old
# bundle-report) let through: a statically imported message catalog
# (~100 KB gz on every route) or a chart import bypassing the shared
# chart-runtime boundary (a duplicated ~92 KB gz recharts chunk).
# place CI already has a production build on disk. Measures the first
# load of every page (files deduplicated by content hash) against its
# baseline, the p95 and max across pages, and the distinct client code
# (modules deduplicated by id); see the script header. Catches a
# statically imported message catalog, a duplicated recharts chunk, or
# a heavy module (Zod) pulled into a page that had none.
- run: pnpm bundle-check
- run: pnpm exec playwright test --shard=${{ matrix.shard }}/3
@@ -145,8 +146,9 @@ jobs:
if [ -z "$version" ]; then echo "playwright-core not found in pnpm-lock.yaml"; exit 1; fi
echo "version=$version" >> "$GITHUB_OUTPUT"
# The WebKit spec (`bottom-sheet.spec.ts`, project `webkit-iphone`), in the
# same image as the shards.
# The WebKit specs, in the same image as the shards: the bottom sheet on an
# iPhone (`bottom-sheet.spec.ts`, project `webkit-iphone`) and the app shell
# on an iPad (`tablet-shell.spec.ts`, project `webkit-tablet`).
webkit:
runs-on: ubuntu-24.04
needs: playwright-version
@@ -156,7 +158,7 @@ jobs:
image: mcr.microsoft.com/playwright:v${{ needs.playwright-version.outputs.version }}-noble
services:
postgres:
image: postgres:16-alpine
image: postgres:18-alpine
env:
POSTGRES_USER: healthlog
POSTGRES_PASSWORD: healthlog
@@ -201,7 +203,7 @@ jobs:
- run: NODE_OPTIONS=--max-old-space-size=8192 pnpm build
- run: pnpm exec playwright test --project=webkit-iphone
- run: pnpm exec playwright test --project=webkit-iphone --project=webkit-tablet
env:
E2E_BASE_URL: http://localhost:3000
+30 -13
View File
@@ -10,20 +10,23 @@ permissions:
contents: read
jobs:
# The suite outgrew a single runner, then a half-suite outgrew a slow one,
# and then a third of it did too. Three shards of about 68 files cleared in
# 13 minutes on an ordinary runner, but a slow runner day ran every test at
# twice its usual duration and the first shard was still working through
# its list when the 25-minute guard cut it off: three of four pushes to main
# between 2026-08-31 and 2026-09-01 failed that way, with green tests logged
# up to the last second. Four shards keep each slice around 50 files, and
# the guard sits at three times the ordinary run rather than under two. A
# wedged test still surfaces through vitest's own 60-second budget, so the
# job timeout only needs to catch a container that never boots. Each shard
# boots its own Postgres testcontainer.
# Four shards of about equal duration, each running its files in parallel
# against one database per worker. The shards used to be four slices of
# equal file count, run serially: 954 s to 1 449 s, because a slice holding
# the sharing matrix and the backup files took half again as long as one
# holding small files. `--shard` now goes through the duration sequencer
# (`tests/durations/sequencer.ts`, timings in
# `tests/durations/integration.json`), and every file in a shard runs on
# one of three workers, each against its own clone of the migrated template
# (`tests/integration/worker-database.ts`). Three workers leave a core of
# the four-core runner to the Postgres container. The job guard stays well
# above an ordinary run; a wedged test still surfaces through vitest's own
# 60-second budget, so the guard only needs to catch a container that never
# boots. Each shard boots its own Postgres testcontainer and uploads its
# JSON report, which `scripts/test-durations.mjs` turns into the timing file.
shard:
runs-on: ubuntu-24.04
timeout-minutes: 40
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
@@ -48,8 +51,13 @@ jobs:
# vitest a stray positional argument and the shard flag never applies —
# both "shards" then run the full suite. Proven by a paired local run
# before this workflow shipped.
- run: pnpm exec vitest run --config vitest.integration.config.mts --shard=${{ matrix.shard }}/4
- run: >-
pnpm exec vitest run --config vitest.integration.config.mts
--shard=${{ matrix.shard }}/4
--reporter=default --reporter=json
--outputFile.json=integration-report-${{ matrix.shard }}.json
env:
HEALTHLOG_INTEGRATION_WORKERS: "3"
# The integration suite boots a Postgres testcontainer and points
# Prisma at it via DATABASE_URL set inside `tests/integration/setup.ts`.
# These env vars satisfy code paths that read them at module load.
@@ -63,6 +71,15 @@ jobs:
ENCRYPTION_KEY: "0000000000000000000000000000000000000000000000000000000000000000"
SESSION_SECRET: "integration-tests"
- name: Upload the timing report
if: always()
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: integration-report-${{ matrix.shard }}
path: integration-report-${{ matrix.shard }}.json
if-no-files-found: ignore
retention-days: 14
# Branch protection requires a check named exactly "integration". This job
# carries that name and answers for every shard: it fails when any shard
# failed or was cancelled, so a half-green matrix cannot satisfy the gate.
+53
View File
@@ -0,0 +1,53 @@
name: Mutation tests
# Stryker on the core libraries (stryker.config.mjs): encryption at rest,
# medication compliance, rollups, source ranking, unit conversion and backup
# validation. Too slow for a pull request; it runs once a night and on
# demand, and fails when a module's score drops below its floor in
# stryker.floors.json. The HTML report is uploaded either way.
on:
schedule:
- cron: "23 1 * * *"
workflow_dispatch:
permissions:
contents: read
jobs:
mutation:
runs-on: ubuntu-24.04
timeout-minutes: 120
name: mutation (core libraries)
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm db:generate
- name: Run Stryker
run: pnpm exec stryker run --concurrency 4
# Without pipefail the step takes the exit status of `tee`, which is
# always 0, so a module under its floor could never fail the run.
- name: Check the per-module floors
shell: bash
run: |
set -o pipefail
node scripts/mutation-floors.mjs | tee -a "$GITHUB_STEP_SUMMARY"
- name: Upload the report
if: always()
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: mutation-report
path: reports/mutation/
if-no-files-found: ignore
retention-days: 30
+2 -2
View File
@@ -56,7 +56,7 @@ jobs:
services:
postgres:
image: postgres:16-alpine
image: postgres:18-alpine
env:
POSTGRES_USER: healthlog
POSTGRES_PASSWORD: healthlog
@@ -71,7 +71,7 @@ jobs:
steps:
- name: Log in to GHCR
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
with:
registry: ghcr.io
username: ${{ github.actor }}
+1 -1
View File
@@ -54,7 +54,7 @@ jobs:
publish_results: true
- name: Upload results
uses: github/codeql-action/upload-sarif@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0
uses: github/codeql-action/upload-sarif@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
with:
sarif_file: scorecard-results.sarif
category: scorecard
+94 -30
View File
@@ -15,11 +15,72 @@ permissions:
contents: read
jobs:
quality:
name: Lint, Typecheck & Test
# The static checks: everything in the old single quality job except the
# unit suite, which runs sharded below.
static:
name: Lint, Typecheck & OpenAPI
runs-on: ubuntu-24.04
if: github.event_name != 'schedule'
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: 22
cache: "pnpm"
- run: pnpm install --frozen-lockfile
- run: pnpm db:generate
# Blocking since v1.15.20 — the settings-split refactor that kept
# this warn-only is long done and the tree lints clean.
- name: ESLint
run: pnpm lint
# Prettier formatting gate. The format pass excludes generator-owned
# artifacts via .prettierignore (notably docs/api/openapi.yaml, which
# the OpenAPI drift check below enforces byte-for-byte).
- name: Prettier format check
run: pnpm format:check
- name: TypeScript strict check
run: pnpm typecheck
# OpenAPI spec drift gate — hard-fail (flipped from warn-only in v1.4.25).
#
# The Zod registry is the source of truth for the public API contract.
# Any route signature change must be reflected in `docs/api/openapi.yaml`
# so downstream consumers (notably the v1.5 iOS Swift codegen) stay in
# lockstep. If this step fails, run `pnpm openapi:generate` locally and
# commit the regenerated spec.
- name: OpenAPI spec drift check
run: |
pnpm openapi:generate
if ! git diff --exit-code docs/api/openapi.yaml; then
echo "::error::OpenAPI drift detected — regenerate the spec via 'pnpm openapi:generate' and commit the result. The Zod registry under src/lib/openapi/ is the source of truth for the public API contract."
exit 1
fi
echo "::notice::OpenAPI spec in sync with Zod source schemas."
# The unit suite in three slices of about equal duration. `--shard` goes
# through the duration sequencer in `vitest.config.mts`, which deals files
# out by their measured time (`tests/durations/unit.json`) instead of by
# count. Each slice uploads its JSON report, which is what
# `scripts/test-durations.mjs` regenerates the timing file from.
unit:
runs-on: ubuntu-24.04
if: github.event_name != 'schedule'
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3]
name: unit shard ${{ matrix.shard }}/3
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
@@ -38,7 +99,8 @@ jobs:
# real browser and measures the boxes. Without a browser here it caught
# its own launch failure and passed anyway, so from its first day until
# v1.38 it measured nothing on any run of this job. The browser is part
# of the gate now; the test refuses to skip when CI is set.
# of the gate now; the test refuses to skip when CI is set. Every slice
# installs it, because the sequencer decides which slice the file lands in.
#
# Headless shell only: `chromium.launch()` resolves to
# chromium_headless_shell, which is what the failing runs named, and the
@@ -54,38 +116,40 @@ jobs:
- run: pnpm exec playwright install --only-shell chromium
# Blocking since v1.15.20 — the settings-split refactor that kept
# this warn-only is long done and the tree lints clean.
- name: ESLint
run: pnpm lint
# Prettier formatting gate. The format pass excludes generator-owned
# artifacts via .prettierignore (notably docs/api/openapi.yaml, which
# the OpenAPI drift check below enforces byte-for-byte).
- name: Prettier format check
run: pnpm format:check
- name: TypeScript strict check
run: pnpm typecheck
# vitest directly, not `pnpm test --`: pnpm hands the separator through
# literally and the shard flag would never reach vitest.
- name: Unit tests
run: pnpm test
run: >-
pnpm exec vitest run --shard=${{ matrix.shard }}/3
--reporter=default --reporter=json
--outputFile.json=unit-report-${{ matrix.shard }}.json
# OpenAPI spec drift gate — hard-fail (flipped from warn-only in v1.4.25).
#
# The Zod registry is the source of truth for the public API contract.
# Any route signature change must be reflected in `docs/api/openapi.yaml`
# so downstream consumers (notably the v1.5 iOS Swift codegen) stay in
# lockstep. If this step fails, run `pnpm openapi:generate` locally and
# commit the regenerated spec.
- name: OpenAPI spec drift check
- name: Upload the timing report
if: always()
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: unit-report-${{ matrix.shard }}
path: unit-report-${{ matrix.shard }}.json
if-no-files-found: ignore
retention-days: 14
# Branch protection requires a check named exactly "Lint, Typecheck & Test".
# That used to be one job running everything in turn; it is now this one,
# which answers for the static checks and every unit slice, so a half-green
# set cannot satisfy the gate.
quality:
name: Lint, Typecheck & Test
runs-on: ubuntu-24.04
needs: [static, unit]
if: always() && github.event_name != 'schedule'
timeout-minutes: 5
steps:
- name: The static checks and every unit slice finished green
run: |
pnpm openapi:generate
if ! git diff --exit-code docs/api/openapi.yaml; then
echo "::error::OpenAPI drift detected — regenerate the spec via 'pnpm openapi:generate' and commit the result. The Zod registry under src/lib/openapi/ is the source of truth for the public API contract."
if [ "${{ needs.static.result }}" != "success" ] || [ "${{ needs.unit.result }}" != "success" ]; then
echo "static: ${{ needs.static.result }}, unit: ${{ needs.unit.result }}"
exit 1
fi
echo "::notice::OpenAPI spec in sync with Zod source schemas."
# Not on pull requests. `pnpm audit` answers a question about the published
# advisory database, not about the diff: the same lockfile passes on Monday
+182
View File
@@ -0,0 +1,182 @@
name: Tests (nightly, shuffled)
# Looks for what a pull request run cannot see by design.
#
# - Order dependence. Both suites run with files and tests in a random order
# (`--sequence.shuffle`), seeded from the clock and printed at the top of
# the log and in the job summary. A failure that does not reproduce on a
# pull request reproduces here with `--sequence.seed=<seed>`.
# - New property-test inputs. Pull requests run every fast-check property
# with a fixed seed; this run picks a fresh one (`FC_SEED`) and five times
# the cases, and fast-check prints the seed and the shrunk counterexample
# on a failure (see tests/property/fast-check.ts).
# - Stale shard timings. The integration reports of this run are merged into
# a regenerated `tests/durations/integration.json`, uploaded as an
# artifact for a maintainer to commit when the shards drift apart.
#
# Not a gate: nothing here blocks a merge. A red run is an issue to open.
on:
schedule:
- cron: "41 2 * * *"
workflow_dispatch:
permissions:
contents: read
jobs:
unit:
runs-on: ubuntu-24.04
timeout-minutes: 45
name: unit (shuffled)
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm db:generate
- name: Cache Playwright headless shell
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ~/.cache/ms-playwright
key: playwright-shell-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
playwright-shell-${{ runner.os }}-
- run: pnpm exec playwright install --only-shell chromium
- name: Pick the seeds
run: |
echo "TEST_SEED=$(date +%s)" >> "$GITHUB_ENV"
echo "FC_SEED=$(( $(date +%s%N) % 2147483647 ))" >> "$GITHUB_ENV"
- name: Unit tests, shuffled
shell: bash
env:
# Five times each property's own case count (tests/property/fast-check.ts).
FC_RUNS_FACTOR: "5"
run: |
set -o pipefail
echo "Order seed ${TEST_SEED}, property seed ${FC_SEED}" | tee -a "$GITHUB_STEP_SUMMARY"
pnpm exec vitest run --sequence.shuffle --sequence.seed="${TEST_SEED}" \
--reporter=default --reporter=json --outputFile.json=unit-report.json
- name: Upload the timing report
if: always()
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: nightly-unit-report
path: unit-report.json
if-no-files-found: ignore
retention-days: 14
integration:
runs-on: ubuntu-24.04
timeout-minutes: 45
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4]
name: integration shard ${{ matrix.shard }}/4 (shuffled, Postgres 16)
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm db:generate
# One seed per run, shared by the shards through the run id, so every
# shard shuffles the same way and a failure names one seed to replay.
- name: Pick the seeds
run: |
echo "TEST_SEED=${{ github.run_id }}" >> "$GITHUB_ENV"
echo "FC_SEED=$(( ${{ github.run_id }} % 2147483647 ))" >> "$GITHUB_ENV"
- name: Integration tests, shuffled
shell: bash
env:
HEALTHLOG_INTEGRATION_WORKERS: "3"
# Pull requests run the suite on Postgres 18, the compose default.
# Installs that bring their own database may still be on 16, so the
# nightly run keeps that major covered without doubling every PR.
INTEGRATION_PG_IMAGE: "postgres:16-alpine"
# Four times each property's own case count; a property's timeout
# grows by the same factor (`propertyTimeout`).
FC_RUNS_FACTOR: "4"
# Quoted for the reason given in integration.yml.
API_TOKEN_HMAC_KEY: "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"
ENCRYPTION_KEY: "0000000000000000000000000000000000000000000000000000000000000000"
SESSION_SECRET: "integration-tests"
run: |
set -o pipefail
echo "Order seed ${TEST_SEED}, property seed ${FC_SEED}" | tee -a "$GITHUB_STEP_SUMMARY"
pnpm exec vitest run --config vitest.integration.config.mts \
--shard=${{ matrix.shard }}/4 \
--sequence.shuffle --sequence.seed="${TEST_SEED}" \
--reporter=default --reporter=json \
--outputFile.json=integration-report-${{ matrix.shard }}.json
- name: Upload the timing report
if: always()
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: nightly-integration-report-${{ matrix.shard }}
path: integration-report-${{ matrix.shard }}.json
if-no-files-found: ignore
retention-days: 14
# Merge the timings of this run into fresh timing files. Uploaded, not
# committed: the workflow has read-only access, and a maintainer decides
# whether the drift is worth a commit.
durations:
runs-on: ubuntu-24.04
needs: [unit, integration]
if: always()
timeout-minutes: 10
name: regenerate shard timings
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: 22
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
pattern: nightly-*-report*
path: reports
- name: Regenerate
shell: bash
run: |
set -o pipefail
shopt -s nullglob
integration=(reports/nightly-integration-report-*/*.json)
unit=(reports/nightly-unit-report/*.json)
if [ ${#integration[@]} -gt 0 ]; then
node scripts/test-durations.mjs integration "${integration[@]}"
fi
if [ ${#unit[@]} -gt 0 ]; then
node scripts/test-durations.mjs unit "${unit[@]}"
fi
git diff --stat -- tests/durations | tee -a "$GITHUB_STEP_SUMMARY"
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: test-durations
path: tests/durations/*.json
retention-days: 14
+3 -3
View File
@@ -88,7 +88,7 @@ jobs:
skip-dirs: src/generated
- name: Upload results
uses: github/codeql-action/upload-sarif@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0
uses: github/codeql-action/upload-sarif@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
with:
sarif_file: trivy-filesystem.sarif
category: trivy-filesystem
@@ -113,7 +113,7 @@ jobs:
# this covers `linux/amd64`. The arm64 manifest shares every
# application layer and differs only in native binaries.
- name: Log in to GitHub Container Registry
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -145,7 +145,7 @@ jobs:
exit-code: "0"
- name: Upload results
uses: github/codeql-action/upload-sarif@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0
uses: github/codeql-action/upload-sarif@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
with:
sarif_file: trivy-published-image.sarif
category: trivy-published-image
+3
View File
@@ -98,3 +98,6 @@ next-env.d.ts
# same reason in a different tool.
/.wt-*/
/.worktrees/
# Mutation testing (stryker.config.mjs): reports and the sandbox copy.
/reports/mutation
/.stryker-tmp
+3
View File
@@ -11,6 +11,9 @@ paths = [
# All test files contain hardcoded fake tokens / idempotency-keys.
'''(^|/)__tests__/''',
'''\.test\.ts$''',
# Golden copies of the backup file written by the parity test: synthetic
# fixture values, and every `…Encrypted` value already opened to its text.
'''^tests/integration/__golden__/''',
# OpenAPI spec contains example tokens (`hlk_example…`) used to document the schema.
'''^docs/api/openapi\.yaml$''',
]
+23
View File
@@ -16,3 +16,26 @@ docs/api/openapi.yaml
# Build-generated service worker (the PWA build emits it).
public/sw.js
# Ratchet baselines, written by the guards themselves (RATCHET_WRITE=1). The
# writer's JSON layout is the canonical one, so a write-back never fails the
# format check.
src/__tests__/baselines
# Benchmark results, written by scripts/bench/run.ts and committed as they
# come out, so a re-run diffs cleanly against the last one.
scripts/bench/results
# Golden copies of the backup file and the restored account, written by the
# parity test itself (BACKUP_PARITY_WRITE=1), so a rewrite diffs cleanly.
tests/integration/__golden__
# A release's backup file exactly as its exporter wrote it; the golden-backup
# test checks the bytes against the manifest's SHA-256, so it is never
# reformatted. The manifests are written by the same test.
tests/fixtures/golden-backups/*.json
# inlang writes its working files (cache, metadata, README) next to the
# project settings when its CLI or SDK runs; only settings.json is tracked.
project.inlang/*
!project.inlang/settings.json
+625
View File
@@ -1,5 +1,630 @@
# Changelog
## [1.46.2] — 2026-10-29
### Fixed
- **Large accounts get their summaries.** On an account with tens of
millions of heart-rate readings the job that rebuilds the summary tier
after an upgrade ran one aggregate over five years under the database's
60 second limit, never finished, and left the dashboard cards reading the
raw table until they timed out too. It now works through the history one
calendar year at a time, every step gets five minutes of its own, and a
run that is out of time hands the remaining years to a follow-up instead
of starting over. Finding the accounts that need the rebuild no longer
reads every reading. If you raised `DATABASE_STATEMENT_TIMEOUT_MS` as a
workaround, you can set it back. (#1298)
- **Lab values from a document land on the day of the report.** A report
usually prints the collection date once, in its header, so confirming it
filed every undated value under the day you confirmed it. An undated
value now takes the report date, or the filing date when the report
names none, and an impossible date such as 30 February counts as no date.
Values filed under the wrong day before are moved once after the update:
only those that came from a confirmed document, had no date of their own,
still sit at the moment they were confirmed, and whose document names a
different day. The server log says how many moved. (#1293)
- **Deleting a blood pressure reading deletes all of it.** Deleting the
systolic or the diastolic value used to leave the other half and the
pulse behind. It now removes the whole reading, Undo brings the whole
reading back, and deleting only the pulse still removes only the pulse.
- **Daily briefings work with models that always reason.** Background work
asks for medium reasoning at most, and a model that cannot be told to
stop thinking used all of that room and returned nothing. Once HealthLog
has seen a model answer with nothing but its reasoning, background work
gives it the room the Coach gets on High. (#1276)
- **Single sign-on accounts can confirm it's you.** Creating a token,
connecting an assistant or resetting a password asks for a fresh
confirmation, and an account with no password, passkey or second factor
had nothing to offer. It now confirms through its identity provider with
a fresh login, accepted only for the linked identity and a sign-in from
the last few minutes. The same confirmation can add a passkey. (#1296)
- **A personal baseline no longer depends on the wall clock.** Reads that
ask for a baseline as of a given moment saw a different window through
the summary tier than through the raw readings.
- Signed out, the language switch keeps your choice in the browser instead
of asking the server to store it.
- A new account is greeted with a welcome instead of "welcome back".
- The Coach chat, the document chat and the profile settings accept every
language the app ships.
- The French translation says "vous" throughout.
### Added
- **Admins hear about a background job that keeps failing.** After three
failed runs in a row every admin gets a push on their system alert
channels, a reminder at most once a day while it keeps failing, and one
message when it works again. Retries of one run count once, and a job
that runs per account counts per account, so one account in trouble is
reported once and not every hour. (#1298)
- **Heart rate from Google Health and Oura is folded like Apple Health's.**
Readings older than 90 days become one value per hour, and a later sync
does not bring the folded readings back. The first fold of a large
history works through the backlog over a few nights and waits until that
account's summaries are rebuilt; afterwards the index rebuild under
Admin, System status gives the space back. (#1297)
- **Admins can delete another account** from the users list, through the
same grace window as deleting your own. Your own account, the last admin
and managed profiles are excluded.
- Crisis lines for the Netherlands and Belgium on the support card.
- A section on large accounts in the self-hosting guide.
- Tests that restore every release's own backup file, restore the backup
of an erased account, and hold the header height in every app language.
### Thanks
@Cnote43 for #1297 and #1298, written up so precisely that the fix mostly
followed the report. @haritowa for finding, fixing and live-testing the lab
date bug (#1293) and for the Codex model checks and tests (#1288). @Tio61
for testing every step of #1276. @sposschip for #1296.
## [1.46.1] — 2026-10-26
### Fixed
- **Charts on a phone are their old size again.** 1.46 moved the labels
for your usual level and for a target zone out of the chart and beside
it. On a phone that room took about a third of the chart's width, and
the legend took more of its height. On a narrow screen the labels sit
inside the chart again, as they did before; a tablet or a wide window
keeps them beside it.
- **The date axis shows more than one date on a phone.** A 90-day chart
is labelled with the first of each month again, and the All range with
the years.
- **The value axis writes whole numbers when its steps are whole**: 57,
58, 59 bpm rather than 57.0, 58.0, 59.0.
- **The All range shows your whole history.** Long ranges are read from
summaries the server keeps for the last five years, and readings older
than that did not appear: a weight history going back to 2015 started
in late 2021. The older readings are now read directly and lead the
chart.
## [1.46.0] — 2026-10-25
### Added
- **A database dump every night, and a restore that rebuilds a server.**
Until now every copy the app made was per account and could only roll an
account back on the same server; a lost server needed a dump you had to
make yourself. The app now takes a whole-database dump every night (01:30
Europe/Berlin) and before every update that brings migrations, keeps the
last seven on the new `backups` volume, and also puts it in your off-host
bucket when one is set up. Each dump has a manifest beside it: app
version, migration, Postgres version, key fingerprints and the row count
of every table. `docker compose run --rm db-restore latest` (or
`latest --from-bucket` after losing the host) restores it into an empty
database only, checks the keys and the checksum first and every table's
row count after, and refuses anything else. A drill in CI does exactly
that every night and on every change to the backup path: it builds a
server, fills it, destroys every
volume, rebuilds it from the bucket and the keys alone, and checks that
people can sign in and read their notes and documents.
- **Recovery readiness, pushed to the admins.** One card on the admin
dashboard and the backups page says whether a server could be recovered
today: the nightly dump, the weekly account copies, the off-host copies,
the restore drill, whether off-host copies are set up, and the recovery
kit. When one of them goes wrong, every admin gets a notification, once,
and again daily while it stays broken, and once when it recovers. A
stopped backup used to show only if someone opened the page.
- **A recovery kit instead of "save the active key".** The kit lists every
key a copy still needs, retired ones included, with their fingerprints:
the encryption keys, the backup key and its predecessors, and the token
key. It becomes due again whenever that set changes, for example after a
key rotation. An upgraded server asks once, because the kit now also
lists the token key.
- **Fourteen days to change your mind.** Deleting an account or a managed
profile now locks it at once (every session, token, share link and
connection is revoked) and erases it after 14 days
(`ACCOUNT_DELETION_GRACE_DAYS`, 0 keeps the old immediate deletion). An
admin can undo it in the users list until then. "Delete all data" first
keeps a copy of the account for the same 14 days, and the copy restores
like any other. Nothing in the background touches an account while it
waits.
- **Apple Health write-back for measurement tokens.** A measurement token,
the narrow kind a scale or Home Assistant uses to add readings, now has a
"Write readings to Apple Health" switch, when you create it and in the
token list. With it on, the HealthLog app on your iPhone adds that
token's readings to Apple Health. The readings still show that they came
from an external device, and the token still cannot read or change
anything. The server decides which readings go to Apple Health and tells
the app, so switching it on later also covers what the token already
sent. Readings from before this release are matched to a token only
where the account has exactly one measurement token; the card says so
when older readings could not be matched. (#1272)
- **Recently deleted for documents.** While it holds anything, the
Documents page links to Recently deleted. It lists each deleted document
with its type, its date, where it came from (Paperless-ngx, for
example) and how many of its 30 days are left, and each one can be
restored or deleted for good right away. The import picker now lets you
tick a document you deleted: one still in Recently deleted comes back
with everything that belonged to it, one already removed is imported
again. Automatic imports, such as a Paperless workflow or the import
script, still never bring back a document you deleted. (discussion
#1275)
- **Questionnaires in every app language.** PHQ-9, GAD-7, WHO-5 and the
sleep questionnaire no longer fall back to English where no validated
translation exists. New validated versions: PHQ-9 in French, Dutch,
Korean and Brazilian Portuguese, GAD-7 in Dutch, Korean and Brazilian
Portuguese, WHO-5 in Dutch and Korean, and the sleep questionnaire in
Polish. Elsewhere HealthLog shows its own faithful translation with a
note on every step that the result is for orientation, and one tap
switches to the English original. Each check-in remembers which wording
it was answered in, and the doctor report marks a total from an
unvalidated translation with an asterisk.
- **Your own files import in full.** The JSON import reads every field
the full backup and the JSON export write: a night's sleep stages, range
readings, an ECG's verdict, the recording device, and a mood entry's
note, detailed ratings, time zone, tags, rated factors and day context.
A full backup also brings your own mood tags, factors and groups along.
Missing ones are created within the usual limits, a tag you already
have is never overwritten, and anything that cannot be matched is named
on the import card.
- **GPT-6 models.** gpt-6-sol, gpt-6-luna and gpt-6.1-sol get reasoning
control with a real "off", the request shape reasoning models expect,
and image input, through an OpenAI key and the ChatGPT sign-in alike.
The OpenAI presets offer gpt-6.1-sol and gpt-6-luna.
- **A queued restore says what it is waiting for.** A restore never runs
beside a backup or another large job. The admin console now says which
of the two it waits for and since when, instead of reading like a stuck
worker.
### Changed
- **Postgres 18.** The compose file now runs Postgres 18 instead of 16. An
install from 1.45 or earlier moves its database across by itself on the
first start: a short-lived `db-upgrade` step copies it into a new
`postgres-data` volume, compares the row count of every table, gathers
the planner statistics, and only then lets the app start. If anything
fails, the app does not start and the log says why; a move cut short is
run again from scratch on the next start. The old `pgdata` volume is
left as it was, so going back is the previous image and two lines of the
compose file. On a real 600 MB instance the move took 29 seconds.
HealthLog keeps running on Postgres 16 for installs that bring their own
database, and the tests run on both. (discussion #1282)
- **A quicker, lighter start.** On an account with ten years of data the
app answers its first request about a tenth sooner after a start, holds
about 55 MB less memory right after it, and the background worker is ready
after loading about a quarter of the code it used to need, with the rest
loaded in the background. Within a minute it has tried every job once and
reports any that cannot load, so a broken image shows up at deploy time
instead of when a job fails hours later. Pages and charts answer as fast
as in 1.45, with the same number of database reads; a ten-year chart takes
a few milliseconds longer because it now checks that a trend has enough
days behind it.
- **Chart axes that read cleanly.** Value ticks fall on round steps (60,
80, 100 rather than 57, 83, 109), date ticks fall on a calendar grid
(days, Mondays, months, quarters, years) and keep their place as data
arrives, and the "your average" and target zone labels sit beside the
plot instead of over the readings or clipped to one letter. The dots
under a chart mark only the days the chart itself shows.
- **Tidier code underneath.** Large parts of the code were split up so
the next changes are smaller and safer to make: files over 800 lines
went from 123 to 84, functions over 200 lines from 411 to 346. Nothing
you use should look different. Signing in with a password or a passkey,
adding a weight, logging a mood, importing lab results, changing a
setting and restoring a full backup are now tested end to end against a
real server.
- **TrueNAS guide.** It covers the app in the TrueNAS Community train and
its backups, and the encryption key backup step points at the app's
Encryption Key field.
### Fixed
- **Slow local models get the time you allow them.** The response timeout
reached each model call, but not the work around it: a daily briefing is
up to five calls in a row and had one fixed bound of the timeout plus 30
seconds, so a model needing several minutes per answer was cut off in
its second call. The briefing now gets your timeout for every call, the
Insights page keeps waiting as long as that allows, and the status cards
and the other AI background jobs follow the same setting instead of
fixed limits. (#1274)
- **The Coach no longer shows a model's reasoning as its answer.** With a
model that always reasons, served through an OpenAI-compatible gateway
or a local server, the Coach could show the thought instead of the
reply, cut off, and sometimes skip the tools it meant to call. The token
limit now keeps room for reasoning whatever the model is asked,
reasoning is taken out of the answer in every shape servers send it,
tool calls run whenever the reply carries them, and a model that still
produces nothing but reasoning gets its own message suggesting another
model. A model that does not reason keeps its own answer even when it
is cut off. (#1276)
- **A model that never answers can no longer run up requests.** One idle
hour against a model that reasoned into its token limit could cost well
over a thousand requests. One call now makes two requests at most. A
reply with reasoning and no answer is asked once more without
reasoning, and if that does not help either, that model is not asked
that way again for an hour. Background work leaves a provider that
keeps failing alone: after three failures in a row it waits five
minutes, from five on it pauses for half an hour, doubling while the
failures continue, up to a day. The Coach and "Re-run analysis" still
reach it and show its error, and saving your AI settings, the admin AI
settings or a fresh ChatGPT sign-in ends the pause. A status card whose
generation failed waits from five minutes up to two hours before the
next try, even when new data arrives. Tokens spent on replies without
an answer count toward the daily AI budget, "Index all documents" stops
after three failed calls in a row, and a gateway that refuses the
reasoning setting is noticed once instead of failing every call.
(#1279)
- **"Extract lab values" reads long documents.** On a stored document it
still sent the whole report in one call, which a long report outgrew,
and the answer came back as "Couldn't read the document. Try a clearer
copy." It now reads the way the lab scan does: a PDF page by page and
text in parts, up to 400 readings, with the same timeout and budget. If
the provider stops early, the readings already found are kept and the
review warns that some values may be missing, every time it is opened;
a read that stopped before the first value says so instead of blaming
the copy. An extraction the iPhone app waits for reads in at most three
parts and marks the document incomplete when that was not enough.
(#1257)
- **PDF previews, vision reads and search work together.** The server
loaded two versions of pdf.js, and whichever ran first broke the other:
after a local text read every preview failed and a vision read of a PDF
was refused, after a preview the local text read failed and the document
stayed out of search. The server now ships one pdf.js. Previews and
search entries the clash broke are repaired once on the first start
after the update. (#1277, PR #1278)
- **"End" on a medication works, and ends it now.** The button had failed
with an error on every press since it was added. It now ends the
running course at that moment: a dose taken earlier today still counts,
and a later one is no longer due, reminded or counted as missed. End is
offered only while a course is running, someone who manages your record
can use it too, undoing the end brings the later doses back, and a new
course can start from the next day. (#1280, PR #1281)
- **Memory stays flat under bursts of requests.** Every page the server
rendered stayed in memory for ten minutes after it was sent, about
250 KB each, so a run of automated requests could push the container
towards its memory limit. A render is now released with its request.
- **Fast right after an import, not only once the database caught up.**
Postgres learns what a table holds in the background, some time after
rows arrive. Until it had, on a new install or right after a large
import, several reads chose a plan that grew with the square of the
data: the analytics page over a few thousand blood pressure readings
took seconds, and the long-range charts and all-time highs and lows of
a ten-year history could run past the database's time limit. Those
reads now take one pass over the readings whatever Postgres knows, with
the same results, and take a fraction of a second.
- **Trends need readings on enough days.** Three blood pressure readings
taken in one sitting, the only ones that week, produced a 7-day trend of
thousands of mmHg. A trend is now shown only when the readings fall on
at least three days spread over at least half the window (4 days of 7,
15 of 30), wherever a trend appears.
- **iPad and tablets.** The app fills the window after a rotation and
under Safari's floating toolbars, without a strip of page colour at the
bottom, a cut-off last card or a page that scrolls under the app. In
landscape every sidebar destination is reachable, with a cue when more
are below. A vertical swipe over a row of tabs scrolls the page, and the
area over the home indicator matches the page in Edge, Chrome and
Firefox.
- **The app's own backup and export import again.** The JSON import
refused both, because they write an empty field as `null` or `""`. It
reads them as the app wrote them now, converts display units such as lb
or mmol/L, and skips deletion records. The portable backup also carries
a reading's glucose context, sleep stage, range, rhythm verdict and
device, which it used to drop.
- **Deleted imports stay deleted after a restore.** A backup restored onto
a fresh server let a Paperless workflow or the import script store again
every document you had deleted. The backup now carries which imports
were deleted.
- **ChatGPT sign-in works without a model override.** The ChatGPT backend
rejects every model from gpt-5.5 down, so an install without its own
model list ended in "all configured Codex slugs were rejected". The
default list now starts at gpt-5.6-terra.
- **Workout splits and GPX times.** Splits were missing for every synced
route, and an exported GPX track had no times. Both read the
stored route points correctly now.
- **A hung backup no longer blocks restores.** A backup that hung held the
slot for large jobs until the container restarted, so a restore waited
forever. A job that outlives its time limit now gives the slot up.
- **API answers match the published contract.** Creating or editing a
medication answers with the same fields as reading it, a dose answers
the full entry, medications and doses come back in the same order on
every request, readings opened by day carry their note, and the
contract now declares several answers the server already gave.
- Every page view saved your language to the account again and counted
against your write limit; it is saved once per tab now.
- A page reloaded right after you entered a reading shows that reading
instead of the dashboard from before.
- The daily briefing's chart notes and storyline were dropped from replies
that passed validation; they are kept now.
- A stored AI key that can no longer be decrypted counts as no key instead
of failing the briefing with a server error.
- After a Fitbit or Google Health history import, the dashboard and
analytics show the new data right away instead of after their cache ran
out.
- Averages and streaks take one clock per request, so a reading at the
edge of a window no longer moves in and out, and today's reading counts
for the streak whatever time zone the database runs in.
- A refused request by someone you share your record with shows in your
activity feed with its own wording.
- A stat's value and unit stay on one line beside the docked day view;
the neutral score label is readable in the dark theme; axis labels are
sized correctly when reduced motion is on.
- **Large backups upload without filling the memory.** Uploading a copy
with many documents or ECG recordings held them whole while it was
checked, and a big vault could crash the server. Every reader of a
backup now streams the large parts; a 271 MB file needs about 2 MB.
- **Backup texts say what each copy can do.** The weekly copy is an
account rollback on this server, the personal export is for the person,
the JSON import reads measurements and mood within its stated limits, and
only the database dump rebuilds a server. Nothing claims more anymore.
- **Long Coach answers keep all their steps.** After a reload an answer
with more than 48 steps lost the rest.
- **The hint for reading scans on the device points to the right switch**,
Settings, Modules, Labs, in every language.
- **Help links open the right page** instead of going through redirects.
- **A directory setting below `/proc` no longer freezes the server.** Node's
way of creating a folder with its parents loops forever there on Linux,
so a `DB_BACKUP_DIR` or `GEOLITE2_DIR` pointing below it stopped the
process. Such a directory now reads as not writable.
- **The last hour of a long DST day keeps its doses.** On the day the
clocks go back, the list of today's doses that the apps offer from ended
an hour early, so a late evening dose was missing from it; on the day
they go forward it reached an hour into tomorrow. It now spans the real
local day, as the dashboard already did.
- **A dose logged for a two-day plan lands on its slot in the week the
clocks go back.** A plan with doses on two neighbouring weekdays, started
shortly before the change, could file a later dose as unscheduled, and
logging it again gave back the previous one. Whether a plan counts as
daily or weekly no longer depends on the stretch of days it is read
through.
### Security
- **A crafted PDF or image can no longer take the server down.** A small
PDF with one huge compressed image, or a JPEG with a padded header, made
the server reserve gigabytes and get killed, for every account on it.
Everything pdf.js does now runs in its own process with a memory and a
time limit, image sizes are read by the JPEG rules and an unreadable size
is refused, and a document over the limits is marked as such instead of
being tried again.
- **Rate limits count the real caller.** Forwarding headers are believed
only from a connection that comes from the server's own network or a
proxy on it; a caller reaching the port directly no longer picks its own
address, and the sign-in throttle no longer exempts an address an account
used before.
- **Writes from other sites are refused.** A request that changes something
and comes from another site, including a sibling subdomain, is refused
before it is read. The iPhone app and other token clients are not
affected.
- **The MCP consent page names where the access goes**, and warns when a
self-registered application sends it to a remote host.
- **Switching Labs off keeps lab values out of every AI feature.** The
Coach, the daily briefing, status notes and the MCP lab prompt read no
lab values while the module is off. Before, the Coach still sent them.
- Smaller fixes: share links show no documents while the documents module
is off, a narrow medication token can no longer probe medication names,
MCP tools fence medication and lab text like every other user text,
reading documents takes turns between accounts, so one account's heavy
files cannot hold up everyone else's, the session cookie cannot be
planted from a sibling subdomain over HTTPS, file-like paths under `/api`
go through the request checks, the deploy
webhook can be signed, the error relay to GlitchTip needs a signed-in
session, and database error details are redacted before they leave.
- Raised the minimum versions of body-parser and fast-uri, which the MCP
server runs on (CVE-2026-111181, high; CVE-2026-110180;
CVE-2026-110306), of ip-address, which took exponential time on a
crafted IPv6 zone identifier, and of a development-only copy of undici.
### Upgrade notes
- Migrations 0475 to 0485 (0475, 0476, 0477, 0478, 0479, 0480, 0482, 0483
and 0485; 0481 and 0484 are not used). Most add a column under a
bounded lock wait and retry on a lock timeout. These do more:
- 0476 links existing readings to the measurement token that sent them
where the account has exactly one such token. It sets only that link,
so the readings do not count as changed.
- 0477 builds an index on `measurements` concurrently; writes carry on
during the build.
- 0478 clears the recorded preview failures and 0479 re-queues the PDFs
whose search entry failed, so the first start renders those previews
once more and indexes those PDFs once more. The indexing uses your AI
provider where one is set up and you have allowed it, as an upload
would.
- 0485 drops the unused preview-features column from `users`.
On a copy of a real instance all of them together took under a second.
- If a concurrent index build fails half-way, for example because the
container was stopped, mend the cause and mark the migration rolled
back; the next start drops the invalid index and builds it again. See
`docs/ops/deploy.md`.
- After a deploy, the worker reports any job it cannot load within a
minute; `docs/ops/deploy.md` says what to look for and when to roll
back.
- `/api/settings/preview-features` is retired and answers 410. It served
a switch for pages that never shipped, and no client reads it. A backup
from 1.44 or 1.45 that carries the field still restores.
- The Apple Health switch takes effect with an iPhone app build that
reads it; until then the app keeps mirroring Withings, imported and
hand-entered readings as before.
- If you set `CODEX_MODEL_FALLBACK_CHAIN` only to get past the rejected
ChatGPT models, you can remove it.
- Migrations 0486 to 0489 add the dump ledger and its key column, the
readiness and kit tables and the deletion columns. 0487 also drops the four old key-backup
columns from `app_settings` and carries an existing confirmation over;
as with 0485, do not run the previous image beside the new schema.
- **A new `backups` volume** holds the local dumps. The compose file
mounts it; a stack built without it keeps dumps off-host only and says
so. In the bucket, dumps go under `database/<your host>/`, so two
servers sharing a bucket never mix them up; add `database/` to the
bucket's lifecycle rule, and the backups page warns when no rule covers
it. Dumps keep a deleted account until they
age out, so state that period as your backup retention.
- **New settings:** `DB_BACKUP_ENABLED`, `DB_BACKUP_DIR`,
`DB_BACKUP_SCHEDULE`, `DB_BACKUP_KEEP`, `DB_BACKUP_BEFORE_MIGRATE` and
`ACCOUNT_DELETION_GRACE_DAYS`, all on sensible defaults. If the dump
before a migration fails on a writable directory, the start stops and
names the switch to skip it.
- **Behind a reverse proxy,** forwarding headers are believed only from a
private or loopback address. If your proxy reaches HealthLog from a
public address, set `TRUSTED_PROXY_CIDRS` to that address; otherwise
every user shares one sign-in rate limit. `TRUSTED_PROXY_CIDRS` also
narrows the default when you know your proxy's network
(`docs/self-hosting/reverse-proxy.md`). Behind a proxy that rewrites the
Host header, make sure `APP_URL` is right, or writes from the browser
are refused as cross-site.
- The deploy webhook can now be signed (`X-Deploy-Webhook-Signature`);
`DEPLOY_WEBHOOK_REQUIRE_SIGNATURE=true` accepts signed requests only.
`DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP` keeps its meaning, and the stock
Coolify setup needs no change.
- Over HTTPS the session cookie is now `__Host-healthlog_session`. The old
name is still read and swapped on the next request, so nobody is signed
out.
- The app image is about 6 MB larger: it now carries the Postgres 18
client for the dumps. The supported memory floor stays at 1536 MB for
the app container.
- The first start with the new compose file moves the bundled database to
Postgres 18 (see Changed). Use `docker compose up -d` with the new
compose file; pulling only the new image keeps Postgres 16, which this
release still runs on. The move needs free disk space about the size of
the database. Rollback, the steps for a database you run yourself, and
how to remove the old volume later: `docs/ops/postgres-major-upgrade.md`.
### Thanks
Thanks to @haritowa for two reports that came with their fixes: the clash
between two pdf.js versions that broke previews and search (#1277), fixed
in PR #1278, and the "End" button that never worked (#1280), fixed in PR
#1281. Both pull requests are part of this release as written.
Thanks to @Tio61 for the precise wire captures behind the Coach showing a
model's reasoning as its answer (#1276) and for the exact request counts
that exposed the runaway requests against a model that never answers
(#1279).
Thanks to @gesus14 for noticing that the daily briefing gave up long
before the response timeout allowed (#1274).
Thanks to @asmala for coming back to report that "Extract lab values" on
a stored document still failed on long reports (#1257).
Thanks to @cnrd for the careful proposal that became the Apple Health
switch for measurement tokens (#1272).
Thanks to @frubino for asking how to get a deleted document back, which
led to Recently deleted (discussion #1275).
Thanks to @muhdusama for noticing that the MCP labs tool declared a
narrower answer than it gives, and for the fix with its tests (PR #1284).
Thanks to @sttrebo for asking about Postgres 18, which turned into the
move itself, not just a guide for it (discussion #1282).
## [1.45.0] — 2026-10-23
### Added
- **Brazilian Portuguese.** The whole app, the Coach and the reports are
available in pt-BR, with Brazil's crisis lines (CVV 188, SAMU 192) next
to the international ones. Portuguese readers outside Brazil see 112 and
Portugal's SNS 24 and SOS Voz Amiga as well.
- **Translate in the browser.** Translators can edit any language on the
web with their GitHub account and send the change as a pull request; the
contributing guide links the editor and explains what reviewers check.
- **Dengue and tuberculosis in the vaccine catalogue.** Dengue is listed
with the travel vaccines (two doses three months apart) and tuberculosis
with the childhood ones (one dose at birth); neither has a booster. Each
cites its WHO position paper, and searching "bcg" finds tuberculosis.
- **The Coach knows your whole lab history.** Asked about a marker such as
LDL or PSA, it now sees your recent values and a yearly summary going
back ten years, instead of stopping at twelve months. Other readings keep
the one-year window. (#1263)
### Changed
- **Faster.** On an account with ten years of data the dashboard loads in
well under half the time, a ten-year chart about five times faster and a
one-year chart about three times faster. The values are the same: the
new reads are checked against the old ones across time zones, daylight
saving changes and multiple sources.
- **Steadier start and less memory.** The app no longer freezes for most of
a second while it starts, holds only the language you use in memory, and
pauses heavy background work when the container gets close to its memory
limit instead of being restarted. Stopping the container lets running
jobs finish.
- **The questionnaires follow the validated originals.** PHQ-9, GAD-7,
WHO-5 and the sleep questionnaire now share one flow: the question's time
frame is always shown, answers are a proper choice list you can use with
the keyboard, and a review step lets you check every answer before it is
saved. The result explains the score in plain words and whether it moved
meaningfully since last time. The German, Spanish, Italian, Polish and
French wording now matches the published validated versions word for
word; where no validated translation exists, the original English is
shown rather than an unvalidated one.
- **Re-sent Apple Health data no longer counts as new.** When the iPhone
sends values the server already has, nothing is rewritten and the Apple
Health card no longer claims fresh data arrived; it says when data was
last re-sent without anything new. (#1259)
- Fonts are now part of the app instead of being fetched from Google when
an image is built.
### Fixed
- A workout synced by two services counts once everywhere, including when
the two copies fall on either side of a five-minute mark, and opening
either copy shows the same session.
- A backup restored for disaster recovery keeps the original creation
dates of medications, courses, ingredients, nutrient days and mood and
symptom links, and keeps sealed labels and notes sealed. Deleted family
history entries are part of that backup too.
- The Coach refuses more ways of telling you to change a dose, such as
halving it or taking "the same as before" when that is not your plan, in
every language. Several of its safety checks never matched words with
accents; they do now.
- A Strava connection whose refresh token expired now asks you to
reconnect instead of failing quietly.
- Ending a session from the session list records correctly whether it was
your own.
- Values that round to zero no longer show as "-0".
- Clearing a filter on the documents, measurements or mood pages, closing
a document or zooming the timeline no longer brings the old address back
a moment later.
- The nightly measurement maintenance no longer reports every run as
failed: it recorded the table's bloat as a decimal where only whole
numbers were accepted, although the maintenance itself had run. (#1271)
- Mood dimensions logged between local and UTC midnight count for today
again instead of going missing until the next day.
- Sheets on a phone open without a slide when your device asks for reduced
motion, and every control's accessible name now matches its visible
label.
- A large import from Apple Health, Health Connect, Withings or Oura
refreshes the charts that depend on it straight away.
### Upgrade notes
- Migration 0471 adds one column for Apple Health bookkeeping.
- Single-container installs need nothing else. If you run the web and
worker roles in separate containers, start the worker first: the job
queue updates its schema on start, and a web-only process waits for it.
See `docs/ops/deploy.md`.
- The supported memory floor stays at 1536 MB for the app container.
### Thanks
Thanks to @dareoon for the Brazilian Portuguese translation (#1251), to
@asmala for adding dengue and tuberculosis to the vaccine catalogue
(#1266) and for asking the Coach to look further back in the lab history
(#1263), and to @mills1975 for spotting the failing maintenance job
(#1271).
## [1.44.2] — 2026-10-22
### Changed
+36 -7
View File
@@ -121,7 +121,7 @@ Contributors: branch off `main`; PRs target `main`.
## Translations
HealthLog ships with English (`en`) and German (`de`) maintained by the project, plus French (`fr`), Spanish (`es`), Italian (`it`), Polish (`pl`), Korean (`ko`), and Dutch (`nl`) that are **AI-initial, community-maintained**. The maintained locales are reviewed line-by-line; the community-maintained ones surface a small banner inviting users to improve them.
HealthLog ships with English (`en`) and German (`de`) maintained by the project, plus French (`fr`), Spanish (`es`), Italian (`it`), Polish (`pl`), Korean (`ko`), Dutch (`nl`), and Brazilian Portuguese (`pt-BR`) that are **AI-initial, community-maintained**. The maintained locales are reviewed line-by-line; the community-maintained ones surface a small banner inviting users to improve them.
### Improving an existing locale
@@ -134,16 +134,45 @@ To suggest a fix or improvement:
3. Edit `messages/<locale>.json`. Keep the JSON valid; the i18n integrity test will flag missing or extra keys against `en.json` before CI accepts the PR.
4. Open a PR titled `i18n(<locale>): <area>`. Describe what was unclear, awkward, or wrong in the previous wording.
### Translating in the browser
Every locale can be edited in [Fink](https://fink.inlang.com/?repo=MBombeck/HealthLog&project=project.inlang), inlang's browser editor, without cloning the repository. Fink reads `project.inlang/settings.json`, which points it at `messages/<locale>.json`, shows each message next to its translations, and flags strings that are missing or use other placeholders than English.
1. Open the link and sign in with GitHub.
2. Without write access to this repository, choose **Fork**. Fink asks you to install the Inlang GitHub App on your own account so it can push to your fork.
3. Edit, then choose **Push**. Fink commits to the fork; **Open pull request** takes you to GitHub's compare page, where you submit the change against `main`.
Fink writes the same nested JSON this repository uses and changes only the lines you edited. A string you add for a key the locale did not have yet goes to the end of its section instead of the position it has in `en.json`, which is harmless.
What a reviewer checks on a translation pull request:
- Only `messages/*.json` changed, and only the strings the description names.
- Every `{placeholder}` of the English string is still there and spelled the same. The i18n integrity test fails the build otherwise.
- Medical terms are the ones the language's clinical guidelines use, and the register matches the rest of the locale (`du` in German, `você` in Brazilian Portuguese).
- Words the app or the Telegram bot matches literally stay as they are: slash commands, `taken <name>`, and the unit symbols.
- Machine translation is fine as a first draft, but someone who reads the language has gone through every string before the pull request is opened, and the description says so.
- No questionnaire text. The bundles hold the app's own copy around the questionnaires (titles, notes, buttons), never an item, an answer option or a stem; see the next section.
To run the checks Fink shows on a local checkout: `npx @inlang/cli@3.4.4 check --project ./project.inlang`. It reads the files and changes nothing.
### Questionnaire wording
The PHQ-9, GAD-7, WHO-5 and SCI are not translated through the bundles, and Fink never shows them. Their text lives in code, one file per instrument and language under `src/lib/mental-health/wording/<instrument>/<locale>.ts`, because changing a word in a validated questionnaire changes what it measures. Each file declares what it is:
- `validated: true` with a `source`: a published validated translation, reproduced word for word (stem, items, answer options and, for the PHQ-9, the follow-up question, in the form of address the source uses). The source names who made it, the document it was read from with its link, and the terms that allow reproducing it in software. The file's header comment names anything that differs from the printed form, such as a corrected misprint.
- `validated: false`: no validated version exists that may be reproduced, so the file carries a translation written for HealthLog, faithful to the English and on the same scale. The app shows a note beside it that the result is orientation, offers the English original in one tap, and records on the result that it was answered in an unvalidated translation. The header comment names any validated version that was found and why it is not used (paywalled, terms that allow private study only).
To propose a validated version for a language that has none, or a better source for one that does, open an issue or a pull request with the publication, a link to the PDF it can be read from, and its terms of use. A pull request replaces the language's file: the text copied exactly from the PDF, `validated: true`, and the `source` filled in. The tests in `src/lib/mental-health/__tests__/` check that every instrument has text in every language, that every validated entry names its source, and that each answer is bound to the score the instrument gives it; the spot checks in `instrument-wording.test.ts` are the place to pin a line that is easy to transcribe wrongly. A correction to HealthLog's own translation of an unvalidated entry is welcome too; it stays `validated: false`.
### Adding a new locale
If you want to introduce a locale that isn't shipped (e.g. `pt-BR`, `cs`):
If you want to introduce a locale that isn't shipped (e.g. `sv`, `cs`):
1. Open an issue first using the **Translation request** template. We'll confirm the locale is in scope and decide whether it joins the AI-initial-community-maintained tier or the maintained tier.
2. Copy `messages/en.json` to `messages/<locale>.json`.
2. Copy `messages/en.json` to `messages/<locale>.json`. Questionnaire text is not in it; the new language needs a wording file per instrument instead (see Questionnaire wording above), which step 4 makes impossible to forget.
3. Translate. AI assistance is welcome as a starting point but the PR description should note whether it's an AI-initial pass or a fully human-reviewed one — that information drives the banner state.
4. Add the locale to the supported list in `src/lib/i18n/locales.ts` (or wherever the registry lives — grep `SUPPORTED_LOCALES`).
5. The Coach system prompts at `src/lib/ai/prompts/coach-prompt.ts` and `src/lib/ai/prompts/insight-generator.ts` and the doctor-report PDF strings need translation too. Include those in the same PR.
6. Run `pnpm test -- i18n-integrity` locally before submitting.
4. Add the locale to `locales` in `src/lib/i18n/config.ts` and to `locales` in `project.inlang/settings.json`, then run `pnpm typecheck`. Every per-locale table that still lacks the new locale fails to compile, and that list is the work: date and number formatting, the reply-language footers of the Coach and insights prompts, the outbound safety banks, the prose-grounding patterns, the no-key fallback copy and the questionnaire wording registry. Include those in the same PR.
5. Run `pnpm test` before submitting. The i18n guards hold the new bundle to the English keys and placeholders.
### Style guide for translators
@@ -156,7 +185,7 @@ If you want to introduce a locale that isn't shipped (e.g. `pt-BR`, `cs`):
### Long-term
When the community grows beyond ad-hoc PRs, we'll spin up a self-hosted [Weblate](https://weblate.org/) instance. That replaces the JSON-PR workflow with a web UI but keeps every translation under our own roof — no third-party telemetry. Tracking issue: `v1.6 — Weblate self-hosted on edge-01`.
Fink covers browser editing today. It edits the files in this repository directly and keeps no copy of the translations anywhere else. If the community outgrows it, a self-hosted [Weblate](https://weblate.org/) instance remains the fallback. Tracking issue: `v1.6 — Weblate self-hosted on edge-01`.
## Code of Conduct
+59 -12
View File
@@ -4,6 +4,9 @@ RUN corepack enable && corepack prepare pnpm@11.15.1 --activate
WORKDIR /app
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
# The dependency patches pnpm-workspace.yaml names; the frozen install
# refuses to run without them.
COPY patches patches
COPY scripts/prepare.mjs scripts/prepare.mjs
RUN pnpm install --frozen-lockfile --prod=false
@@ -57,6 +60,11 @@ RUN node -p "require('/app/node_modules/pdfjs-dist/package.json').version" \
# The same for @napi-rs/canvas, whose native binary must match its JS loader.
RUN node -p "require('/app/node_modules/@napi-rs/canvas/package.json').version" \
> /app/.canvas-version
# Every shipped locale's message catalog is present and parses, checked
# against the locale list in the source; the list is written out so the runner
# stage can check the catalogs the image actually carries.
RUN node scripts/verify-message-catalogs.mjs --dir messages \
--config src/lib/i18n/config.ts --write /app/.message-locales
# ── Stage 3: Production runner ─────────────────────────────
FROM node:22-alpine@sha256:16e22a550f3863206a3f701448c45f7912c6896a62de43add43bb9c86130c3e2 AS runner
@@ -69,7 +77,13 @@ FROM node:22-alpine@sha256:16e22a550f3863206a3f701448c45f7912c6896a62de43add43bb
# security patches from the Alpine repo override it deliberately —
# CVE-2026-14456 in openssl was the first instance the container scan
# caught between base-image rebuilds).
RUN apk add --no-cache tzdata && apk upgrade --no-cache
#
# `postgresql18-client` carries `pg_dump` and `pg_restore` for the nightly
# database dump, the pre-migration dump in the entrypoint and the
# `db-restore` service (docs/ops/disaster-recovery.md). Major 18 matches the
# bundled database; a client dumps servers of its own major and older, so it
# also serves an install still on its own Postgres 16.
RUN apk add --no-cache tzdata postgresql18-client && apk upgrade --no-cache
WORKDIR /app
ENV NODE_ENV=production
@@ -103,8 +117,26 @@ ENV NEXT_PUBLIC_APP_BUILD_SHA=$NEXT_PUBLIC_APP_BUILD_SHA
RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 nextjs
# Where the database dumps go (`DB_BACKUP_DIR`). Owned by the app user, so a
# fresh named volume mounted here starts with that owner: Docker fills an
# empty named volume from the image directory it covers, ownership included.
# Without a mount the directory stays in the container's layer; the app reads
# its mount table, sees that, and keeps no dump here (dump-core.mjs).
RUN mkdir -p /var/lib/healthlog/backups && \
chown nextjs:nodejs /var/lib/healthlog/backups && \
chmod 700 /var/lib/healthlog/backups
# Copy built assets
COPY --from=builder /app/public ./public
# The server reads its message catalogs from here on first use, one locale at
# a time (src/lib/i18n/server-catalogs.ts); they are not bundled.
COPY --from=builder /app/messages ./messages
# A missing or broken catalog is only noticed when a page asks for that
# locale, so check the copies in this stage against the builder's list.
COPY --from=builder /app/.message-locales /app/scripts/verify-message-catalogs.mjs /tmp/catalog-check/
RUN node /tmp/catalog-check/verify-message-catalogs.mjs --dir /app/messages \
--locales /tmp/catalog-check/.message-locales && \
rm -rf /tmp/catalog-check
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
# Next's standalone output must carry the externalized worker dependencies.
@@ -137,7 +169,7 @@ RUN node -e "require.resolve('pg-boss'); require.resolve('pg')"
# before the platform package, so it decides which binary runs.
#
# Hoist the version the app pins, by name, exactly like pdfjs-dist below. The
# store holds a second, older canvas that pdf-parse's own pdfjs-dist pulls in,
# store once held a second, older canvas that a second pdfjs-dist pulled in,
# and this step used to take whichever `find | head -1` returned first. It
# returned the old binary, which then sat next to the new loader: rendering
# still worked, but `loadImage` called an `Image.decode` the old binary does
@@ -173,13 +205,15 @@ RUN set -e; \
# `import('pdfjs-dist/legacy/build/pdf.mjs')`. Node resolves that up to
# /app/node_modules, so hoist the real pnpm-store copy to the top level too.
#
# Hoist the version the app pins, by name. More than one pdfjs-dist reaches the
# store — `pdf-parse` carries its own, older copy for the text-extraction path —
# and this step used to take whatever `find | head -1` returned first. That is
# readdir order, so it could hoist the OLDER copy and the version named in
# package.json would never run in the image: the renderer resolving one version
# while every local check exercised another. Name the version and fail the build
# when it is absent, instead of degrading to whichever copy comes back first.
# Hoist the version the app pins, by name. More than one pdfjs-dist once reached
# the store (a text-extraction dependency carried its own, older copy), and this
# step used to take whatever `find | head -1` returned first. That is readdir
# order, so it could hoist the OLDER copy and the version named in package.json
# would never run in the image: the renderer resolving one version while every
# local check exercised another. Name the version and fail the build when it is
# absent, instead of degrading to whichever copy comes back first. Two copies in
# one process also break each other (#1277); the lockfile now resolves one, and
# `src/__tests__/single-pdfjs-guard.test.ts` keeps it that way.
COPY --from=builder /app/.pdfjs-version /tmp/.pdfjs-version
RUN set -e; \
PDFJS_VERSION="$(cat /tmp/.pdfjs-version)"; \
@@ -214,11 +248,16 @@ COPY --from=builder /app/src/generated ./src/generated
# the runbooks and the entrypoint against this list. `pending-migrations.mjs`
# and `migrate-deploy.mjs` are the entrypoint's own: the first logs the
# pending migration count, the second runs `migrate deploy` with its
# lock-timeout retry. Root-owned on purpose: the app user can run them but not
# rewrite them.
# lock-timeout retry. `db-dump.mjs` takes the dump before migrating, and
# `db-restore.mjs` is the `db-restore` service; both share
# `src/lib/database-backup/dump-core.mjs` with the app. Root-owned on purpose:
# the app user can run them but not rewrite them.
COPY --from=builder /app/scripts/reset-password.mjs ./scripts/reset-password.mjs
COPY --from=builder /app/scripts/pending-migrations.mjs ./scripts/pending-migrations.mjs
COPY --from=builder /app/scripts/migrate-deploy.mjs ./scripts/migrate-deploy.mjs
COPY --from=builder /app/scripts/db-dump.mjs ./scripts/db-dump.mjs
COPY --from=builder /app/scripts/db-restore.mjs ./scripts/db-restore.mjs
COPY --from=builder /app/src/lib/database-backup/dump-core.mjs ./src/lib/database-backup/dump-core.mjs
COPY --from=builder /app/src/lib/auth/argon2-params.mjs ./src/lib/auth/argon2-params.mjs
COPY --from=builder /app/scripts/repair-intake-anomalies.ts ./scripts/repair-intake-anomalies.ts
# Prove the reset CLI's imports resolve in this tree: with every import found
@@ -228,6 +267,14 @@ RUN node scripts/reset-password.mjs 2>&1 | grep -q "reset-password: usage:"
RUN env -u DATABASE_URL node scripts/pending-migrations.mjs && \
node --input-type=module -e "await import('pg')" && \
node --input-type=module -e "await import('./scripts/migrate-deploy.mjs')"
# The dump and restore scripts load, the client tools they run are the
# major the bundled database runs, and the S3 client `db-restore --from-bucket`
# loads resolves from the shared module's place in the tree.
RUN node scripts/db-dump.mjs 2>&1 | grep -q "db-dump: usage:" && \
node --input-type=module -e "import { createRequire } from 'node:module'; createRequire('/app/src/lib/database-backup/dump-core.mjs').resolve('@aws-sdk/client-s3')" && \
node scripts/db-restore.mjs 2>&1 | grep -q "db-restore: usage:" && \
pg_dump --version | grep -q "(PostgreSQL) 18\." && \
pg_restore --version | grep -q "(PostgreSQL) 18\."
# Install the migration CLI, its config dependency, and a pinned launcher for
# the maintenance scripts already shipped in the standalone tree. The Prisma
@@ -265,7 +312,7 @@ RUN mkdir -p /opt/prisma-cli && \
npm pkg set 'overrides.mysql2=^3.22.0' && \
npm pkg set 'overrides.@hono/node-server=^1.19.15' && \
npm pkg set 'overrides.valibot=^1.4.2' && \
npm install --omit=dev prisma@7.10.0 @prisma/engines@7.10.0 tsx@4.23.1 dotenv@18.0.6 && \
npm install --omit=dev prisma@7.10.1 @prisma/engines@7.10.1 tsx@4.23.1 dotenv@18.0.8 && \
ln -sfn /opt/prisma-cli/node_modules/.bin/tsx /usr/local/bin/healthlog-tsx && \
ln -sfn /opt/prisma-cli/node_modules/dotenv /app/node_modules/dotenv && \
ln -sfn /opt/prisma-cli/node_modules/prisma /app/node_modules/prisma && \
+14 -13
View File
@@ -50,13 +50,13 @@ Try the [live demo](https://demo.healthlog.dev) to see a working install — sig
- **Clinical signals, opt-in and additive.** Validated self-checks for mental wellbeing and sleep (WHO-5, PHQ-9, GAD-7, Sleep Condition Indicator — official questionnaire wording, calm crisis-resource handling), grip strength, a 0–10 pain score, waist and body measurements, and a longevity lab panel — each added through one signal registry, so a new signal wires into charts, correlations, the coach, the FHIR map, and the API at once.
- **Records, structured.** Allergies and family history as structured records that flow into the doctor report (reactions and notes are encrypted, the substance and condition names are not), plus lab values you key in or scan from a printed report — OCR extracts the stated values, units, and dates for you to review and confirm before anything is saved. HealthLog transcribes what a report says; it never interprets or diagnoses from it. A vaccination record keeps a lifetime immunization log, doses grouped under the disease they protect against, and prints in the doctor report.
- **A document vault for the paper trail.** An opt-in module stores every letter, report, and scan encrypted at rest — PDFs and photos preview inline, Office and text files stay download-only, and nothing user-uploaded can ever execute in the app. One searchable timeline with type, condition, and year filters, drag-and-drop and paste-to-upload, bulk filing, undo-able delete with a 30-day grace, and per-file/per-account limits the operator tunes.
- **Context that explains the numbers.** An opt-in environmental module folds weather, daylight, and temperature in as influence signals (winter darkness against mood, heat against blood pressure) using a home location you set, so a pattern has somewhere to point.
- **Context that explains the numbers.** An environmental module folds weather, daylight, temperature, air quality, and pollen in as influence signals (winter darkness against mood, heat against blood pressure) using a home location you set, so a pattern has somewhere to point. It is on by default, and nothing leaves the host until someone sets that location.
- **Your devices, one server.** Withings, WHOOP, Oura and Polar sync over OAuth2, Google Health connects Fitbit and Pixel Watch through your own Google Cloud client, Nightscout streams CGM glucose, an Apple Health `export.zip` folds your full history in, and the native iOS app streams HealthKit live. A per-metric source priority decides which reading is canonical when wearables overlap.
- **Medication tracking that tells the truth.** Flexible schedules (weekly injections, weekday-only, intervals, PRN, cyclic), a configurable intake window per dose, and a traceable dose history where every slot reads taken, late, skipped, or missed — the same ledger the compliance rate is computed from, so the percentage can never disagree with the timeline.
- **AI insights you own.** Daily briefing, health scores, correlations, and a conversational coach grounded in your own measurements and interpreted against cited guideline reference bands — where a value sits and what that means in plain words, never a diagnosis. The coach proposes concrete if-then plans you accept or decline, and suppresses proactive check-ins when there's nothing genuine to say. Bring your own OpenAI or Anthropic key, or point at a local endpoint (Ollama, LM Studio, vLLM) so AI requests stay on your network.
- **Clinician-ready output.** A doctor-report PDF rendered on your server, a read-only HL7 FHIR R4 API, and scoped, time-limited share links you revoke after the visit.
- **Private by construction.** AES-256-GCM encryption at rest for notes and other free text, documents, questionnaire answers, AI-written text and credentials, with zero-downtime key rotation. Measurement values, lab values, medication names and mood scores are stored unencrypted in the database, so the database volume and its backups need the same protection as the host. Passkey login, server-side sessions, strict CSP, and no third-party tracking anywhere.
- **Built to be lived in.** Installable PWA with offline support, medication reminders over APNs, Telegram, ntfy, and Web Push, a sub-second dashboard on years of imported history — in English, German, Spanish, French, Italian, Polish, Korean, and Dutch end to end.
- **Built to be lived in.** Installable PWA with offline support, medication reminders over APNs, Telegram, ntfy, and Web Push, a sub-second dashboard on years of imported history — in English, German, Spanish, French, Italian, Polish, Korean, Dutch, and Brazilian Portuguese end to end.
The full feature tour, integration guides, and API reference live at [docs.healthlog.dev](https://docs.healthlog.dev).
@@ -85,11 +85,11 @@ Verify the running version:
curl -s http://localhost:3000/api/version # returns version + buildSha + builtAt
```
`SESSION_COOKIE_SECURE=false` is the one setting plain-HTTP self-hosts must set: without it the session cookie carries the `Secure` flag, the browser drops it over HTTP, and login silently fails. Leave it unset (or `true`) only when a TLS reverse proxy serves HTTPS — and then set `NEXT_PUBLIC_APP_URL` and `APP_URL` to the public URL first (see the [self-hosting guide](https://docs.healthlog.dev/self-hosting/)).
`SESSION_COOKIE_SECURE=false` is the one setting plain-HTTP self-hosts must set: without it the session cookie carries the `Secure` flag, the browser drops it over HTTP, and login silently fails. Leave it unset (or `true`) only when a TLS reverse proxy serves HTTPS — and then set `NEXT_PUBLIC_APP_URL` and `APP_URL` to the public URL first (see the [self-hosting guide](https://docs.healthlog.dev/getting-started/self-host-in-10-minutes/)).
Persistent state lives in exactly one named volume (`pgdata`, the Postgres data dir); the app container is stateless, so backing up the database backs up everything.
Persistent state lives in the database volume (`postgres-data`) and in `backups`, which holds the nightly database dumps; the app container is stateless. An install from v1.45 or earlier moves its Postgres 16 database from `pgdata` into the new volume on the first start ([details](docs/ops/postgres-major-upgrade.md)); recovering a lost server is in [disaster-recovery.md](docs/ops/disaster-recovery.md).
NAS and homelab users have ready-made paths: [Unraid Community Applications](docs/self-hosting/unraid.md) and [Portainer app templates](docs/self-hosting/portainer/README.md).
NAS and homelab users have ready-made paths: [Unraid Community Applications](docs/self-hosting/unraid.md), [Portainer app templates](docs/self-hosting/portainer/README.md) and [TrueNAS](docs/self-hosting/truenas.md) (the Community train app is pending catalog review).
## Self-hosting
@@ -105,17 +105,18 @@ The App Store listing carries a small price, which exists to cover Apple's yearl
## Tech stack
Next.js 16 (App Router, React Server Components), TypeScript strict, PostgreSQL 16 with Prisma, pg-boss for jobs, Tailwind CSS 4 + shadcn/ui, Recharts, WebAuthn passkeys, Vitest + Playwright, multi-stage Alpine Docker image. The OpenAPI 3.1 contract for native clients is generated from the Zod schemas at [`docs/api/openapi.yaml`](docs/api/openapi.yaml).
Next.js 16 (App Router, React Server Components), TypeScript strict, PostgreSQL 18 with Prisma, pg-boss for jobs, Tailwind CSS 4 + shadcn/ui, Recharts, WebAuthn passkeys, Vitest + Playwright, multi-stage Alpine Docker image. The OpenAPI 3.1 contract for native clients is generated from the Zod schemas at [`docs/api/openapi.yaml`](docs/api/openapi.yaml).
## Documentation
| | |
| ---------------------- | ------------------------------------------------ |
| User and operator docs | [docs.healthlog.dev](https://docs.healthlog.dev) |
| Release history | [CHANGELOG.md](CHANGELOG.md) |
| API contract | [`docs/api/openapi.yaml`](docs/api/openapi.yaml) |
| Security policy | [SECURITY.md](SECURITY.md) |
| Contributing | [CONTRIBUTING.md](CONTRIBUTING.md) |
| | |
| ---------------------- | ------------------------------------------------------------ |
| User and operator docs | [docs.healthlog.dev](https://docs.healthlog.dev) |
| Release history | [CHANGELOG.md](CHANGELOG.md) |
| API contract | [`docs/api/openapi.yaml`](docs/api/openapi.yaml) |
| Security policy | [SECURITY.md](SECURITY.md) |
| Contributing | [CONTRIBUTING.md](CONTRIBUTING.md) |
| Translating | [CONTRIBUTING.md#translations](CONTRIBUTING.md#translations) |
## Status
+118 -10
View File
@@ -1,14 +1,122 @@
{
"$comment": "Client-bundle budgets, enforced by scripts/check-bundle-budget.mjs (--check) after `next build` in CI. Values are KB gzip and include every emitted lazy route and locale chunk, while route budgets include the shared rootMainFiles baseline. Re-measured at v1.37.2: / 455, /insights 443, /measurements 441, /insights/mood 453, total 3328, baseline 130, lazy catalogs 849. Every per-route ceiling is unchanged. The /insights overview crept a kilobyte over its 445 ceiling when the ECG teaser began importing its overview cap from the classification module, dragging that module's label map and helpers into the eager page; the cap now lives in its own value-only module, and the Health Score's single provenance entry is split out of the full metric-provenance map, so the overview imports the bare number and citation it renders rather than every metric's label map behind them. Both are back under 445 with room to spare. The 42 KB aggregate rise from 3286 (v1.35.0) is all locale-catalog strings: v1.36 and v1.37 added the encounter, mood-context, mood-prognosis, practitioner, workout-batch and account-sharing surfaces, and their copy lands across the six locale catalogs the whole-build sum counts independently (804 to 849 KB). The non-catalog client chunks are flat against v1.35.0, so nothing a person downloads on a given route grew beyond its own ceiling. The aggregate cap therefore moves from 3310 to 3352 (24 KB headroom, matching the previous margin); per-route ceilings, the static-catalog guard and the one-Recharts-chunk guard remain unchanged. v1.37.4's honest-error-state charge then added recoverable read-failure handling to thirteen surfaces (the shared QueryErrorCard and QueryErrorRow primitives plus an isError branch per surface) and rewrote the MCP scope note and the managed-record error copy across the six locales; the measured total rose to 3404 while every per-route ceiling stayed met (/page 457, /insights 445, /measurements 443, /insights/mood 455). The aggregate cap moves from 3352 to 3428, keeping the same margin; nothing a person downloads on a given route grew beyond its ceiling. v1.37.15's async-truth charge then added the refresh settling state with its two-locale-key copy, the index-outcome plain-words line (seven keys across the six catalogs) and the reindex-count wiring; the measured total rose to 3429 while every per-route ceiling stayed met (/page 456, /insights 445, /measurements 442, /insights/mood 455). The aggregate cap moves from 3428 to 3452, keeping the same margin; nothing a person downloads on a given route grew beyond its ceiling. v1.37.27 found /insights sitting a few hundred bytes under 445 on main and a few hundred over it on the release branch — the page had been at its ceiling for three releases and the release only had to breathe on it. The weight it was carrying was not its own: the authenticated shell imported one destination predicate from the `@/lib/record-settings` barrel, and the barrel also re-exports the managed settings-patch schemas, so Zod, the module registry and three validation modules rode into the eager bundle of every authenticated route for a function that reads a URL slug. The shell imports the `classification` leaf now. Measured across all 585 client-rendered routes: none grew by a byte, 100 fell, by 4.7 KB at the median and 8.1 KB at the most; /insights 444 to 437, /measurements 442 to 435, / 456 to 450, /insights/mood 455 to 450. The whole-build sum moved the other way, 3451 to 3476, and that direction is the metric rather than the change: the aggregate counts each emitted chunk once, so a graph that leaves the chunk group every route shares gets counted again wherever it lands — here inside the deferred settings chunks that actually use it. Bytes on disk rose 25 KB; bytes a reader downloads only ever fell. The aggregate cap moves from 3452 to 3500, the same 24 KB headroom as the four re-measurements before it. Per-route ceilings keep their old values rather than tightening to the new floor: the CI runner measures each route about 1.5 KB heavier than a local arm64 build does, and a ceiling set from a local number would fail on a build that had not regressed. The static-catalog guard and the one-Recharts-chunk guard remain unchanged. Raised to 3700 at v1.37.32 for the seventh interface language (Korean): the lazy message catalogs went 6->7 chunks (1026 KB gz measured), pushing the aggregate to 3631 against the old 3500. The raise is entirely the new catalog; no route budget moved, and the four eager route ceilings are unchanged and still passing. Most of the aggregate is the dynamic locale-switch and fallback path that a first paint never loads (see issue #253), so this ceiling guards bytes almost no visitor requests; 3700 leaves ~69 KB for ordinary drift until that double-count is cleaned up. v1.38.14 re-measures the four eager routes and changes what the number means. The ceilings had been re-stated but never re-based since v1.37.4, so /inLine truncated
"routeBaselineKbGz": {
"/page": 355,
"/insights/page": 323,
"/measurements/page": 335,
"/insights/mood/page": 334,
"/settings/[section]/page": 307
},
"$comment": "Client-bundle budgets, enforced by scripts/check-bundle-budget.mjs --check after `pnpm build` in CI; the script header describes the method. Numbers are KB gzip. routeBaselineKbGz is the measured first load of every page (the shared rootMainFiles plus the page's client-reference chunks, each file counted once by content hash), written with --write-baselines; a page fails when it is more than routeDriftAllowanceKbGz over its baseline, and a page without a baseline fails until one is written in the same change. firstLoadP95KbGz and firstLoadMaxKbGz cap the distribution across pages. uniqueClientKbGz caps the distinct client code: every module counted once by its module id, the lazy message catalogs excluded. maxCatalogKbGz holds each catalog, maxRechartsChunks the one shared chart chunk. The summed-chunk aggregate the gate used up to v1.44.0 (3875 KB there, capped at 3900) is still printed but no longer gated: it counted a module once per chunk group it landed in, so it rose whenever a page moved code into on-demand chunks and needed a raise in most releases while no reader downloaded more. Re-based at v1.45.0 from the v1.44.0 build, measured on a local arm64 machine: 110 pages, first load p50 336, p95 437, max 451 KB; unique client code 1904 KB in 1224 modules. The five pages watched before keep their numbers within a kilobyte (/ 359, /measurements 339, /insights/mood 337, /insights 323, /settings/[section] 311), and the metric pages that no watched route covered before are gated now too (/insights/weight 339, /insights/pulse 339). Caps: p95 447 and max 461 KB (10 KB over the measurement), unique client code 1930 KB (about 25 KB, roughly two releases of new client code). A raise or a re-base is a reviewed change with the measured numbers and the reason written here; earlier re-bases are in the history of this file.",
"routeDriftAllowanceKbGz": 20,
"totalClientKbGz": 3900,
"firstLoadP95KbGz": 447,
"firstLoadMaxKbGz": 461,
"uniqueClientKbGz": 1930,
"maxRechartsChunks": 1,
"maxCatalogKbGz": 210
"maxCatalogKbGz": 210,
"routeBaselineKbGz": {
"/_global-error/page": 133,
"/_not-found/page": 282,
"/about/page": 282,
"/achievements/page": 292,
"/admin/[section]/page": 451,
"/admin/page": 297,
"/auth/claim/page": 298,
"/auth/login/page": 290,
"/auth/register/page": 288,
"/c/[token]/page": 284,
"/checkups/page": 365,
"/checkups/practitioners/page": 289,
"/claim/[token]/page": 282,
"/coach/conversations/page": 294,
"/coach/page": 355,
"/coach/plans/page": 288,
"/confirm-access/page": 287,
"/custom-metrics/[id]/page": 303,
"/custom-metrics/[id]/values/page": 311,
"/cycle/page": 412,
"/documents/deleted/page": 291,
"/documents/page": 378,
"/enroll-mfa/page": 305,
"/illness/[id]/page": 447,
"/illness/page": 421,
"/insights/active-energy/page": 336,
"/insights/audio-events/page": 337,
"/insights/blood-glucose/page": 340,
"/insights/blood-pressure/page": 336,
"/insights/bmi/page": 331,
"/insights/body-fat/page": 336,
"/insights/body-temperature/page": 336,
"/insights/body-water/page": 336,
"/insights/bone-mass/page": 336,
"/insights/breathing-disturbances/page": 337,
"/insights/cardio-fitness/page": 336,
"/insights/cardio-recovery/page": 337,
"/insights/catalog/page": 317,
"/insights/daylight/page": 336,
"/insights/double-support-time/page": 336,
"/insights/ecg/[id]/page": 316,
"/insights/ecg/page": 316,
"/insights/environmental-audio/page": 337,
"/insights/falls/page": 336,
"/insights/fat-free-mass/page": 336,
"/insights/fat-mass/page": 336,
"/insights/flights-climbed/page": 336,
"/insights/grip-strength/page": 337,
"/insights/headphone-audio/page": 336,
"/insights/health-score/page": 315,
"/insights/hrv/page": 337,
"/insights/lean-body-mass/page": 336,
"/insights/medications/page": 338,
"/insights/mood/page": 337,
"/insights/muscle-mass/page": 337,
"/insights/nutrients/page": 319,
"/insights/oxygen/page": 337,
"/insights/page": 323,
"/insights/pain/page": 336,
"/insights/pulse-wave-velocity/page": 336,
"/insights/pulse/page": 339,
"/insights/recovery/page": 330,
"/insights/respiratory-rate/page": 336,
"/insights/resting-pulse/page": 337,
"/insights/scores/[metric]/page": 328,
"/insights/six-minute-walk/page": 336,
"/insights/skin-temperature/page": 338,
"/insights/sleep/page": 337,
"/insights/sources/page": 321,
"/insights/stair-ascent-speed/page": 336,
"/insights/stair-descent-speed/page": 336,
"/insights/step-length/page": 336,
"/insights/steps/page": 337,
"/insights/values/[type]/page": 350,
"/insights/vascular-age/page": 337,
"/insights/visceral-fat/page": 336,
"/insights/waist-to-height/page": 336,
"/insights/waist/page": 336,
"/insights/walking-asymmetry/page": 336,
"/insights/walking-distance/page": 336,
"/insights/walking-heart-rate/page": 337,
"/insights/walking-speed/page": 336,
"/insights/walking-steadiness/page": 336,
"/insights/weight/page": 339,
"/insights/workouts/[id]/page": 437,
"/insights/workouts/page": 437,
"/insights/wrist-temperature/page": 336,
"/invite/[token]/page": 282,
"/labs/[biomarkerId]/page": 327,
"/labs/[biomarkerId]/values/page": 335,
"/labs/page": 423,
"/measurements/page": 339,
"/medications/[id]/history/page": 283,
"/medications/[id]/page": 402,
"/medications/new/page": 282,
"/medications/page": 355,
"/mental-wellbeing/page": 297,
"/mood/page": 438,
"/notifications/page": 289,
"/onboarding/[step]/page": 445,
"/onboarding/handover/page": 295,
"/onboarding/page": 286,
"/page": 359,
"/privacy/page": 282,
"/profile/page": 285,
"/settings/[section]/page": 311,
"/settings/layout/[module]/page": 380,
"/settings/modules/[module]/page": 374,
"/settings/page": 282,
"/timeline/page": 331,
"/vaccinations/page": 327
}
}
+272 -23
View File
@@ -1,4 +1,4 @@
# HealthLog — two-container stack (app + Postgres 16).
# HealthLog — two-container stack (app + Postgres 18).
#
# First run (clean host):
# cp .env.example .env
@@ -14,9 +14,13 @@
# and login silently fails. This is the single most common self-host snag.
# 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, so a backup of the database is
# a backup of everything. The optional GeoLite2 databases are either baked
# Persistent state lives in the named volume `postgres-data` (Postgres' data).
# The app container is stateless, so a backup of the database is a backup of
# everything. `backups` holds the database dumps the app takes every night and
# before every migration (docs/ops/disaster-recovery.md); keep a copy of them
# off this host, or set the off-host bucket below. `pgdata` is where Postgres 16 kept the data up to
# v1.45; on an install that has it, `db-upgrade` moves it into Postgres 18 once
# and then leaves it alone as the way back (docs/ops/postgres-major-upgrade.md). 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.
@@ -88,8 +92,10 @@ x-healthlog-env: &healthlog_env
TELEGRAM_WEBHOOK_SECRET: "${TELEGRAM_WEBHOOK_SECRET:-}"
# v1.18.11 (W3) — client-IP resolution + geo lookup. Behind Cloudflare
# the real visitor IP is in `cf-connecting-ip`; set
# TRUST_CF_CONNECTING_IP=1 ONLY when Cloudflare is in front (off by
# default — a direct-exposed host must not trust a forgeable header).
# TRUST_CF_CONNECTING_IP=1 ONLY when the origin accepts connections from
# Cloudflare alone (a Tunnel, or a firewall admitting only Cloudflare's
# ranges): the header is believed from every connection (off by default;
# see docs/self-hosting/reverse-proxy.md).
# IP_GEO_LOOKUP_URL overrides the default ipwho.is provider (HTTPS by
# default); IP_GEO_LOOKUP_DISABLED=1 stops all third-party IP egress.
# IP_GEO_ALLOW_INSECURE=true permits a plain-HTTP provider URL (e.g. the
@@ -102,7 +108,14 @@ x-healthlog-env: &healthlog_env
# collapses every anonymous caller into one rate-limit bucket. Listed here
# so an operator value in .env reaches the container (the `environment:`
# block is a whitelist).
# TRUSTED_PROXY_CIDRS names the addresses the reverse proxy connects from
# (comma-separated CIDRs). Set, only those connections may report a client
# address; unset, any private address may, which behind Docker Desktop,
# OrbStack or docker-proxy includes every caller of the published port.
# Same host: 127.0.0.1/32,::1/128. Docker network: the proxy's subnet.
# See docs/self-hosting/reverse-proxy.md.
TRUST_PROXY_HOPS: "${TRUST_PROXY_HOPS:-}"
TRUSTED_PROXY_CIDRS: "${TRUSTED_PROXY_CIDRS:-}"
TRUST_CF_CONNECTING_IP: "${TRUST_CF_CONNECTING_IP:-}"
IP_GEO_LOOKUP_URL: "${IP_GEO_LOOKUP_URL:-}"
IP_GEO_LOOKUP_DISABLED: "${IP_GEO_LOOKUP_DISABLED:-}"
@@ -145,6 +158,20 @@ x-healthlog-env: &healthlog_env
# serves anyway. A last resort for a refusal you are certain is false: it
# disables a safety check. See docs/self-hosting/getting-started.md.
ENCRYPTION_KEY_CHECK: "${ENCRYPTION_KEY_CHECK:-}"
# The whole-database dump (docs/ops/disaster-recovery.md). Nightly at
# DB_BACKUP_SCHEDULE (five-field cron in Europe/Berlin time, default
# "30 1 * * *") into DB_BACKUP_DIR (default /var/lib/healthlog/backups, the
# `backups` volume below), keeping DB_BACKUP_KEEP daily dumps (default 7)
# and the last two taken before a migration. With the off-host bucket set,
# each nightly dump is also uploaded under `database/`, sealed with
# BACKUP_ENCRYPTION_KEY. DB_BACKUP_ENABLED=false stops the nightly dump;
# DB_BACKUP_BEFORE_MIGRATE=false skips the dump before migrations. Change
# DB_BACKUP_DIR only together with the volume mount.
DB_BACKUP_ENABLED: "${DB_BACKUP_ENABLED:-}"
DB_BACKUP_DIR: "${DB_BACKUP_DIR:-}"
DB_BACKUP_SCHEDULE: "${DB_BACKUP_SCHEDULE:-}"
DB_BACKUP_KEEP: "${DB_BACKUP_KEEP:-}"
DB_BACKUP_BEFORE_MIGRATE: "${DB_BACKUP_BEFORE_MIGRATE:-}"
BACKUP_ENCRYPTION_KEY: "${BACKUP_ENCRYPTION_KEY:-}"
BACKUP_ENCRYPTION_PREVIOUS_KEYS: "${BACKUP_ENCRYPTION_PREVIOUS_KEYS:-}"
BACKUP_S3_ENDPOINT: "${BACKUP_S3_ENDPOINT:-}"
@@ -166,6 +193,7 @@ x-healthlog-env: &healthlog_env
# the block exists: `environment:` is a whitelist, and a value in .env that
# is not named here never arrives.
AUDIT_LOG_RETENTION_DAYS: "${AUDIT_LOG_RETENTION_DAYS:-}"
ACCOUNT_DELETION_GRACE_DAYS: "${ACCOUNT_DELETION_GRACE_DAYS:-}"
# How long Coach conversations are kept, in days (default 365, floor 30).
# Like the audit window this number is PUBLISHED to the user on the Data &
# Privacy page, so an instance running 90 has to be able to say 90.
@@ -278,17 +306,23 @@ x-healthlog-env: &healthlog_env
OIDC_SCOPES: "${OIDC_SCOPES:-}"
OIDC_BUTTON_LABEL: "${OIDC_BUTTON_LABEL:-}"
OIDC_ONLY: "${OIDC_ONLY:-}"
# Deploy-result webhook (optional). DEPLOY_WEBHOOK_SECRET authenticates
# POST /api/internal/deploy-webhook; DEPLOY_LOGS_URL is the
# deployment-dashboard URL (e.g. the Coolify UI) linked in
# deploy-failure notifications. Listed here so operator values in
# .env reach the container (the `environment:` block is a whitelist).
# Deploy-result webhook (optional). POST /api/internal/deploy-webhook
# authenticates either with DEPLOY_WEBHOOK_SECRET in the
# X-Deploy-Webhook-Secret header (what Coolify's stock sender can do; no
# replay protection) or with X-Deploy-Webhook-Signature: sha256=<HMAC of
# "<X-Deploy-Webhook-Timestamp>.<body>" under the secret>, the timestamp
# within 5 minutes. DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP=true also requires a
# fresh X-Deploy-Webhook-Timestamp on a secret-header request;
# DEPLOY_WEBHOOK_REQUIRE_SIGNATURE=true accepts signed requests only, the
# one setting that stops a replay. DEPLOY_LOGS_URL is the
# deployment-dashboard URL (e.g. the Coolify UI) linked in deploy-failure
# notifications. Listed here so operator values in .env reach the
# container (the `environment:` block is a whitelist). See
# docs/ops/deploy.md.
DEPLOY_WEBHOOK_SECRET: "${DEPLOY_WEBHOOK_SECRET:-}"
DEPLOY_LOGS_URL: "${DEPLOY_LOGS_URL:-}"
# Optional replay hardening: when "true", the deploy webhook
# requires a fresh X-Deploy-Webhook-Timestamp header (±5 min).
# See docs/ops/deploy.md.
DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP: "${DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP:-}"
DEPLOY_WEBHOOK_REQUIRE_SIGNATURE: "${DEPLOY_WEBHOOK_REQUIRE_SIGNATURE:-}"
# TLS leaf SPKI-change alarm baseline (optional). The native client
# pins the served TLS leaf; this is the known-good pin set the monitor
# alarms against on a rotation. Comma-separated for the dual-pin
@@ -473,6 +507,11 @@ services:
- "3000:3000"
environment:
<<: *healthlog_env
# The database dumps (DB_BACKUP_DIR). Without this mount the app still
# copies its nightly dump off-host when the bucket is set, but keeps none
# here and takes none before a migration; the boot log says so.
volumes:
- backups:/var/lib/healthlog/backups
# 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
@@ -482,13 +521,15 @@ services:
# matches GEOLITE2_DIR's default (/opt/geolite2), so this works as-is
# once uncommented. Mounting at a different container path also works,
# just also set GEOLITE2_DIR in .env to match it.
# Steps: docs/self-hosting/geolite2.md.
# Steps: docs/self-hosting/geolite2.md. The line goes under `volumes:`
# above, beside the backups line:
#
# volumes:
# - ./geolite2:/opt/geolite2:ro
depends_on:
db:
condition: service_healthy
db-upgrade:
condition: service_completed_successfully
restart: unless-stopped
# Bounded log rotation: the default json-file driver keeps every line
# forever, which is exactly what fills the disk on the small NAS /
@@ -508,6 +549,11 @@ services:
# support report and Admin > System status say so. On a busy instance,
# raise it with APP_MEMORY_LIMIT in .env; do not lower it.
mem_limit: "${APP_MEMORY_LIMIT:-1536m}"
# On `docker stop` (a redeploy) the app stops taking requests, lets the
# requests in flight finish and gives running jobs up to 30 s to end
# before it exits. Docker's default of 10 s cut that short and left
# jobs marked active until their expiry, holding exclusive queues.
stop_grace_period: 45s
healthcheck:
test:
[
@@ -554,9 +600,14 @@ services:
# environment:
# <<: *healthlog_env
# HEALTHLOG_PROCESS_TYPE: "worker"
# # The worker takes the nightly database dump, so it needs the volume.
# volumes:
# - backups:/var/lib/healthlog/backups
# depends_on:
# db:
# condition: service_healthy
# db-upgrade:
# condition: service_completed_successfully
# restart: unless-stopped
# logging:
# driver: json-file
@@ -564,14 +615,14 @@ services:
# max-size: "10m"
# max-file: "3"
# mem_limit: "${WORKER_MEMORY_LIMIT:-1536m}"
# stop_grace_period: 45s
db:
# Pinned to the concrete minor instead of the floating `16-alpine`
# Pinned to the concrete minor instead of the floating `18-alpine`
# alias so a database update is always a deliberate, reviewable
# change (at pin time `16-alpine` resolved to this same digest, so
# existing deployments see no image change). Minor releases share
# the on-disk format — bumping this pin is safe without a dump /
# restore; a major-version bump (17+) is not.
# change. Minor releases share the on-disk format, so bumping this pin
# is safe without a dump / restore; a major version is not, which is
# what `db-upgrade` below and docs/ops/postgres-major-upgrade.md are for.
#
# The pin only works as long as something watches it: PostgreSQL
# publishes minor releases with security and data-corruption fixes and
@@ -581,7 +632,7 @@ services:
# `.github/dependabot.yml` now raises a PR for each new minor;
# `src/__tests__/database-pin-is-watched.test.ts` keeps both halves
# (concrete pin, active watch) from being dropped.
image: postgres:16.15-alpine
image: postgres:18.6-alpine
container_name: healthlog-db
# Loads pg_stat_statements so slow queries can be found from measurements
# instead of guesses. Loading the library only collects statistics; the
@@ -611,8 +662,12 @@ services:
POSTGRES_USER: healthlog
POSTGRES_PASSWORD: "${POSTGRES_PASSWORD}"
POSTGRES_DB: healthlog
# From 18 on, the image keeps its data under /var/lib/postgresql/18/docker
# and expects the volume one level up, so the next major can sit beside
# this one in the same volume. Mounting it at .../data, as 16 had it,
# makes the container refuse to start.
volumes:
- pgdata:/var/lib/postgresql/data
- postgres-data:/var/lib/postgresql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U healthlog"]
interval: 5s
@@ -633,5 +688,199 @@ services:
max-size: "10m"
max-file: "3"
# Moves a Postgres 16 database from v1.45 or earlier into Postgres 18, once,
# on the first start after the update. A major version cannot read the data
# directory of an older one, so changing the image line alone would leave
# the database refusing to start.
#
# On every start it decides in a second and exits:
# - no `pgdata` data (a new install): nothing to do;
# - the database service runs Postgres 16 (after going back): nothing to
# do, the live 16 volume is left alone;
# - 16 was used again after an earlier move: refuses, so the app does not
# start on the move-day copy;
# - Postgres 18 already holds the database: nothing to do;
# - otherwise it starts Postgres 16 inside this container on the old
# volume, streams a dump into the new database in one transaction,
# compares the row count of every table, gathers planner statistics and
# marks the old volume as moved. `app` starts only after it succeeded.
# A move cut short (a restart, a full disk) leaves a "moving" marker, and
# the next start empties the new database and runs it again from scratch.
#
# The old volume is read, never changed, apart from that marker file, so
# going back is the previous image and volume lines. If anything fails, the
# new database stays empty, `app` does not start, and the log says why.
# It costs one small image pull; a later release removes the service.
db-upgrade:
image: postgres:16.15-alpine
container_name: healthlog-db-upgrade
# Runs once per `compose up` and exits. "no" also keeps hosts such as
# Coolify from reporting the exited container as a failed service.
restart: "no"
security_opt:
- no-new-privileges:true
environment:
POSTGRES_PASSWORD: "${POSTGRES_PASSWORD}"
volumes:
- pgdata:/var/lib/postgresql/data
# Read-only, only to check there is room for the copy before starting.
- postgres-data:/var/lib/postgresql/next:ro
depends_on:
db:
condition: service_healthy
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
# `$$` is how compose spells a literal `$`.
entrypoint: ["/bin/sh", "-c"]
command:
- |
set -eu
set -o pipefail
log() { echo "db-upgrade: $$*"; }
OLD=/var/lib/postgresql/data
MARK="$$OLD/HEALTHLOG_MOVED_TO_POSTGRES_18"
MOVING="$$OLD/HEALTHLOG_MOVING_TO_POSTGRES_18"
export PGPASSWORD="$$POSTGRES_PASSWORD"
new() { psql -h db -U healthlog -d healthlog -v ON_ERROR_STOP=1 -Atqc "$$1"; }
old() { psql -h /tmp -U healthlog -d healthlog -v ON_ERROR_STOP=1 -Atqc "$$1"; }
empty_new() {
for S in $$(new "SELECT quote_ident(nspname) FROM pg_namespace WHERE nspname NOT LIKE 'pg\\_%' AND nspname <> 'information_schema'"); do
new "DROP SCHEMA $$S CASCADE"
done
new "CREATE SCHEMA public"
}
if [ ! -s "$$OLD/PG_VERSION" ]; then
log "no Postgres 16 database to move, nothing to do"
exit 0
fi
# The database service must be the new major. After going back to 16 the
# service runs on this very volume: touching it then would wipe the live data.
i=0
until NEWVER=$$(new "SHOW server_version_num" 2>/dev/null); do
i=$$((i + 1))
[ "$$i" -lt 60 ] || { log "the database service did not answer for a minute"; exit 1; }
sleep 1
done
if [ "$$NEWVER" -lt 180000 ]; then
log "the database service runs Postgres $$((NEWVER / 10000)), not 18: nothing to move, the old volume is left alone"
exit 0
fi
checkpoint() { pg_controldata "$$OLD" | awk -F': *' '/^Latest checkpoint location/ { print $$2 }'; }
USER_TABLES="FROM pg_tables WHERE schemaname NOT IN ('pg_catalog', 'information_schema')"
TABLES=$$(new "SELECT count(*) $$USER_TABLES")
if [ -e "$$MARK" ]; then
MOVED_ON=$$(sed -n 1p "$$MARK")
MOVED_CHECKPOINT=$$(sed -n 2p "$$MARK")
if [ -n "$$MOVED_CHECKPOINT" ] && [ "$$(checkpoint)" != "$$MOVED_CHECKPOINT" ]; then
log "the Postgres 16 database changed after it was moved on $$MOVED_ON: it was used again after going back to 16."
log "Postgres 18 still holds the state of that day, so it is not started on. To move the current 16 database,"
log "see docs/ops/postgres-major-upgrade.md, \"Updating again after going back\"."
exit 1
fi
if [ "$$TABLES" != 0 ]; then
log "the Postgres 16 database was moved on $$MOVED_ON, nothing to do"
exit 0
fi
log "the Postgres 16 database was moved on $$MOVED_ON, but the Postgres 18 database is empty now."
log "Not importing the old state again by itself. See docs/ops/postgres-major-upgrade.md, \"Starting over\"."
exit 1
fi
if [ -e "$$MOVING" ]; then
log "an earlier move did not finish; emptying the Postgres 18 database and starting it again"
empty_new
elif [ "$$TABLES" != 0 ]; then
log "the Postgres 18 database already holds data, leaving both as they are"
exit 0
fi
VERSION=$$(cat "$$OLD/PG_VERSION")
if [ "$$VERSION" != 16 ]; then
log "the old data directory is Postgres $$VERSION, this step moves Postgres 16 only."
log "See docs/ops/postgres-major-upgrade.md to move it by hand."
exit 1
fi
NEED=$$(du -sk "$$OLD" | cut -f1)
FREE=$$(df -Pk /var/lib/postgresql/next | awk 'NR == 2 { print $$4 }')
# Room for the copy, and for the dump the app takes before its first migration.
if [ "$$FREE" -le $$((NEED * 3 / 2)) ]; then
log "the move needs about $$((NEED * 3 / 2 / 1024)) MiB free for the new database and its first dump, the disk has $$((FREE / 1024)) MiB."
log "Nothing was changed. Free some space and start the stack again."
exit 1
fi
trap 'CODE=$$?; [ "$$CODE" = 0 ] || log "the move stopped (exit $$CODE). The old volume is untouched; the next start begins it again."' EXIT
date -u +%Y-%m-%dT%H:%M:%SZ > "$$MOVING"
log "moving the Postgres 16 database ($$((NEED / 1024)) MiB on disk) into Postgres 18"
su-exec postgres postgres -D "$$OLD" -c listen_addresses= -c unix_socket_directories=/tmp &
PID=$$!
i=0
until pg_isready -h /tmp -U healthlog -q; do
kill -0 "$$PID" 2>/dev/null || { log "the Postgres 16 server did not start, see the lines above"; exit 1; }
i=$$((i + 1))
[ "$$i" -lt 600 ] || { log "the Postgres 16 server did not get ready in ten minutes"; exit 1; }
sleep 1
done
pg_dump -h /tmp -U healthlog -Fc healthlog |
pg_restore -h db -U healthlog -d healthlog --no-owner --single-transaction --exit-on-error
COUNTED=0
for T in $$(old "SELECT quote_ident(schemaname) || '.' || quote_ident(tablename) $$USER_TABLES ORDER BY 1"); do
A=$$(old "SELECT count(*) FROM $$T")
B=$$(new "SELECT count(*) FROM $$T")
if [ "$$A" != "$$B" ]; then
log "table $$T has $$A rows in Postgres 16 but $$B in Postgres 18. Emptying the new database again."
log "Please report this with the lines above."
empty_new
exit 1
fi
COUNTED=$$((COUNTED + 1))
done
log "all $$COUNTED tables arrived with the same row counts"
vacuumdb -h db -U healthlog -d healthlog --analyze-in-stages --quiet
kill -INT "$$PID"
wait "$$PID" || true
# The marker keeps the old database's last checkpoint: a later start can tell
# whether 16 was used again after the move.
{ date -u +%Y-%m-%dT%H:%M:%SZ; checkpoint; } > "$$MOVING"
mv "$$MOVING" "$$MARK"
log "done. The old volume stays as it was, as a way back; see docs/ops/postgres-major-upgrade.md"
# Restores a database dump into an EMPTY database (docs/ops/disaster-recovery.md).
# Never started by `docker compose up`; run it by name:
#
# docker compose run --rm db-restore list
# docker compose run --rm db-restore latest
# docker compose run --rm db-restore healthlog-<time>-daily.dump
# docker compose run --rm db-restore latest --from-bucket
#
# It checks the dump against its manifest and the keys in .env against the
# keys the dump was written with, refuses a database that already holds
# tables, restores in one transaction and compares every table's row count
# with the manifest. Start the app only after it says "Restored".
# `--from-bucket` downloads the dump and its manifest from the off-host bucket
# (`database/`, the BACKUP_S3_* variables) and opens them with
# BACKUP_ENCRYPTION_KEY: the restore after the host and its local dumps are
# gone. Files you downloaded yourself restore from a directory you mount:
# `docker compose run --rm -v ./restore:/restore:ro db-restore
# /restore/healthlog-<time>-daily.dump.enc`.
db-restore:
image: ghcr.io/mbombeck/healthlog${HEALTHLOG_IMAGE_REF:-:latest}
profiles: ["restore"]
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
environment:
<<: *healthlog_env
volumes:
- backups:/var/lib/healthlog/backups
depends_on:
db:
condition: service_healthy
entrypoint: ["node", "scripts/db-restore.mjs"]
command: ["list"]
volumes:
postgres-data:
pgdata:
backups:
+11
View File
@@ -88,6 +88,17 @@ echo "HealthLog: Database is reachable."
# Say how many migrations this image is about to apply, with a reminder to
# back up first. Reporting only: it never stops the boot.
node scripts/pending-migrations.mjs || true
# When migrations are pending, dump the database into DB_BACKUP_DIR first, so
# the state before them can be restored (docs/ops/disaster-recovery.md). No
# directory that survives the container (none writable, or the image's own
# without the backups volume): a warning, and the boot goes on. A kept
# directory and a failed dump: the boot stops here, before anything is
# migrated.
if ! node scripts/db-dump.mjs pre-migration; then
echo "HealthLog: ERROR — not migrating without the dump. Free disk space or fix the error above and start again." >&2
echo "HealthLog: to migrate without a dump this once, set DB_BACKUP_BEFORE_MIGRATE=false." >&2
exit 1
fi
# `migrate-deploy.mjs` runs `prisma migrate deploy` and retries only a
# migration that timed out on a lock and is marked safe to re-run; any other
# failure stops the boot with Prisma's exit code (docs/ops/deploy.md).
+19 -7
View File
@@ -17,9 +17,13 @@ notes, and machine-readable specs that ship alongside the source:
encryption-key rotation, password reset and account recovery, env
checks, TLS pinning, data repair. These are what an operator reaches
for during an incident.
- [`self-hosting/`](./self-hosting/) — horizontal-scaling notes
(`HEALTHLOG_PROCESS_TYPE=web|worker|all`) and the deploy-pipeline
recipe. Routine install steps stay on `docs.healthlog.dev`. Includes
- [`self-hosting/`](./self-hosting/) — operator notes next to the code:
[`getting-started.md`](./self-hosting/getting-started.md) (install and
updates, the page the boot log points at), the TrueNAS, Unraid and
Portainer installs with their catalog templates, Tailscale, the reverse
proxy, horizontal scaling (`HEALTHLOG_PROCESS_TYPE=web|worker|all`),
notification channels, the environment module, document sources and
reproductive-health data. Includes
[`self-hosting/mcp.md`](./self-hosting/mcp.md) — enabling the
off-by-default MCP connector and pointing Claude.ai / ChatGPT /
Claude Desktop at it, and [`self-hosting/sso.md`](./self-hosting/sso.md)
@@ -44,9 +48,11 @@ notes, and machine-readable specs that ship alongside the source:
- [`migration/`](./migration/) — migration notes. Currently only
v1.3 to v1.4; later releases are covered by `CHANGELOG.md` and
<https://docs.healthlog.dev>.
- [`integrations/`](./integrations/) — per-integration reference
(Withings, Fitbit, Google Health, WHOOP, Apple Health, AI providers,
data import).
- [`integrations/`](./integrations/) — per-integration reference:
Withings, WHOOP, Fitbit, Google Health, Strava, Nightscout, Dawarich and
the Garmin route; the Apple Health, Health Connect, document and data
imports; AI providers; and Home Assistant with its blueprint. Oura and
Polar are covered on the docs site only.
- [`adr/`](./adr/) — architecture decision records.
- [`security/`](./security/) — threat-model notes.
- [`diagrams/`](./diagrams/) — architecture diagrams rendered through
@@ -59,9 +65,15 @@ notes, and machine-readable specs that ship alongside the source:
- [`contributing/ui-standards.md`](./contributing/ui-standards.md) — the
binding UI standards: card anatomy, spacing, text colour, tokens and
which primitive to use where. Read before touching any surface.
- [`contributing/benchmarks.md`](./contributing/benchmarks.md) — the
large-account benchmark (latency and statements per request) and the
statement budgets on the hot routes.
- [`contributing/testing.md`](./contributing/testing.md) — which suite a
test belongs in, the route test kit, the integration database, sharding,
property and mutation tests, and the flaky-test quarantine.
- [`ui-guidelines.md`](./ui-guidelines.md) — UI / a11y house style; where
it disagrees with the UI standards, the standards win.
If you're a self-hoster looking to spin up an instance, start at
<https://docs.healthlog.dev> or jump to the [Quick Start in the project
README](../README.md#quick-start).
README](../README.md#quick-start-docker-compose).
+11 -4
View File
@@ -66,7 +66,11 @@ before treating a warning as a regression.
`health:write`), audience-bound to `/mcp` and refused on REST writes;
- `POST /api/tokens/measurements` mints `measurements:write`, accepted by
`POST /api/measurements` and `POST /api/measurements/batch` only, on the
holder's own record only;
holder's own record only. The readings it writes carry `source: EXTERNAL`
and are linked to the token; whether the HealthLog app writes them into
Apple Health is the token's `mirrorToAppleHealth` setting (off by
default, changed with `PATCH /api/tokens/{id}`), resolved onto every
measurement row as `appleHealthMirror`;
- `POST /api/tokens/workouts` mints `workouts:write`, accepted by
`POST /api/workouts/batch` only, on the holder's own record only; the
workouts it writes carry `source: EXTERNAL`;
@@ -78,9 +82,12 @@ before treating a warning as a regression.
v1.30.17. The path still lists (`GET`) and revokes (`DELETE /api/tokens/{id}`)
every token above, whichever surface issued it.
- `sessionCookie` — HttpOnly `healthlog_session` cookie set by the
password and passkey login flows. Used by the web app and any iOS
client running inside a web view.
- `sessionCookie` — HttpOnly session cookie set by the password and
passkey login flows. Used by the web app and any iOS client running
inside a web view. It is named `__Host-healthlog_session` wherever it is
`Secure` (every HTTPS install), so no other host under the same domain
can set it; a plain-HTTP install keeps `healthlog_session`. A cookie
under the old name is still read and is re-issued under the new one.
- **`components.schemas`** — DTOs for the documented routes plus the
underlying Prisma models (e.g. `Measurement`, `Medication`,
`MedicationIntakeEvent`, `MoodEntry`, `User`, `DashboardLayout`,
+3127 -1035
View File
File diff suppressed because it is too large. Load diff
+18 -10
View File
@@ -630,9 +630,11 @@ Confirmed-rejected slugs (despite the source-tree references in the table above)
Confirmed-accepted slugs on a ChatGPT Plus subscription (per `available_in_plans` in `models.json`):
- `gpt-5.5` — HealthLog's default (first chain entry) as of 2026-06
- `gpt-5.4` / `gpt-5.4-mini` / `gpt-5.2` — accepted per plan
- `gpt-5.3-codex` — was the default (verified 2026-05-09); the backend rotated off the `-codex` line on 2026-06-02 and this slug is now only accepted intermittently, so it sits on a late chain rung
- `gpt-5.6-terra` — HealthLog's default (first chain entry) as of 2026-10-24; the backend rejected `gpt-5.5` and everything below it that day. `gpt-5.6-luna` and `gpt-5.6-sol` are its siblings on the same line
- `gpt-6-sol`, `gpt-6.1-sol`, `gpt-6-luna`, `gpt-6-astra` — the gpt-6 line, listed in `models.json` since 2026-09-22 (`gpt-6.1-sol` the catalog default since 2026-09-29). The catalog names `gpt-6-sol` as the upgrade of `gpt-5.6-terra` and `gpt-5.6-sol`, and `gpt-6-luna` as that of `gpt-5.6-luna`. Every gpt-6 slug lists `input_modalities: ["text", "image"]` and the same reasoning presets as the gpt-5.6 line (`low` to `max`, sol and astra also `ultra`). The presets omit `none`, as they do for `gpt-5.5`, which a live probe on 2026-10-06 showed accepts it; HealthLog sends `none` for "off" on the gpt-6 line too and learns a refusal per slug. A live check on 2026-10-24 found the account's own list (`GET /backend-api/codex/models?client_version=…`) offering `gpt-6.1-sol`, `gpt-6-astra`, `gpt-6-sol`, `gpt-6-luna` and the `gpt-5.6` line, all with `input_modalities: ["text", "image"]`. From a HealthLog container with its stored sign-in, `gpt-6.1-sol`, `gpt-6-sol` and `gpt-6-luna` each answered text and read a lab-report image correctly. `gpt-6.1-sol` refuses `reasoning.effort: "none"` and names `low` as its lowest effort, which the client learns and steps up to on the same slug; the other two accept `none`
- `gpt-5.5` — the default from 2026-06 until 2026-10-24, kept as the last chain rung while plans differ
Retired for ChatGPT sign-in (OpenAI's Codex models page): `gpt-5.2` and `gpt-5.3-codex` on 2026-05-26, `gpt-5.4` and `gpt-5.4-mini` on 2026-08-31, `gpt-5.5` on 2026-10-14 at 19:00 UTC. The backend answers each of them with 400 `model_not_found`, "The '<slug>' model is not supported when using Codex with a ChatGPT account.", which the client reads as a reason to try the next slug.
The `codex-rs/tui/src/model_migration.rs` references to `gpt-5-codex` etc. are _migration prompts_ shown to existing users — they are NOT the slugs sent on the wire after migration. The wire slugs come from `models.json`. Always cross-check `models.json` (`gh api repos/openai/codex/contents/codex-rs/models-manager/models.json`) before picking a default.
@@ -642,19 +644,25 @@ The ChatGPT-Codex allow-list is rotated by OpenAI without prior notice (`gpt-5-c
To eliminate that risk class, HealthLog's `CodexClient` walks an ordered chain on every fresh request series.
**Chain** (top = first try; default order is the empirically-safest most-current-first ordering as of 2026-06, after the backend rotated off the `-codex` line on 2026-06-02):
**Chain** (top = first try). `gpt-5.6-terra` leads because it is the slug verified against the backend. The gpt-6 rungs follow before terra's own siblings: the catalog names `gpt-6-sol` as terra's upgrade, and the 2026-10-24 rotation retired a whole line at once, so a rotation that takes terra most likely takes `gpt-5.6-luna` and `gpt-5.6-sol` with it. `gpt-6.1-sol` (the catalog default, also on plans `gpt-6-sol` is not offered on) and `gpt-6-luna` (the lighter one) come next. `gpt-6-astra`, the frontier tier, stays out of the default:
```ts
const DEFAULT_SLUG_FALLBACK_CHAIN = [
"gpt-5.5", // current default across paid tiers as of 2026-06-02
"gpt-5.4", // documented fallback
"gpt-5.4-mini", // documented lighter-weight fallback
"gpt-5.3-codex", // late rung — still accepted intermittently
"gpt-5.2", // legacy floor
"gpt-5.6-terra", // verified against the backend 2026-10-24
"gpt-6-sol", // terra's upgrade in the upstream catalog
"gpt-6.1-sol", // the catalog default, on every plan
"gpt-6-luna", // lighter gpt-6 sibling
"gpt-5.6-luna", // lighter sibling on terra's line
"gpt-5.6-sol", // heavier sibling on terra's line
"gpt-5.5", // previous default, kept as the last rung while plans differ
];
```
The shipped constant in `src/lib/ai/codex-client.ts` is authoritative; when the upstream rotates again, that file moves first and this listing follows.
Every default rung must be a model family the reasoning, sampling and vision gates recognise (`src/lib/ai/openai-model-family.ts`); `codex-slug-fallback.test.ts` holds the chain to that.
The shipped constant in `src/lib/ai/codex-slug-chain.ts` is authoritative; when the upstream rotates again, that file moves first and this listing follows.
A fixed chain goes stale. From 2026-10-14, when GPT-5.5 retired, every model on the chain HealthLog shipped then was retired, and every Codex request answered 503 "all configured Codex slugs were rejected" until the chain was updated or `CODEX_MODEL` was set by hand. Reading the account's live list (`GET /backend-api/codex/models`, as the Codex CLI does) would avoid this.
The chain is overridable via the env var `CODEX_MODEL_FALLBACK_CHAIN` (comma-separated). The first env-var-named slug supersedes `CODEX_MODEL`; if both are set, `CODEX_MODEL` is folded into position 0 and de-duplicated.
+119
View File
@@ -0,0 +1,119 @@
# Benchmarks
Two tools measure the server's read and write paths. Use them before and after any change to a hot path, and put both numbers in the PR.
- **`scripts/bench/run.ts`** measures latency (p50, p95, max) and SQL statements per request against a seeded account of about 1.26 million measurement rows, through the production build. It is a local tool, not a CI gate: latency depends on the machine.
- **`tests/integration/query-budget-hot-routes.test.ts`** holds a statement budget per hot route on a small fixture. Statement counts are exact on a fixed fixture, so this one is a gate: a change that adds a query per row, or a second read of something already loaded, fails it.
## The large-account benchmark
### 1. A throwaway Postgres with pg_stat_statements
Never point the seed or the benchmark at a database that holds real data. They write under a synthetic user, and the seed's `--reset` deletes that user with everything it owns.
```sh
docker run -d --name hl-bench-db --shm-size=256m \
-e POSTGRES_USER=bench -e POSTGRES_PASSWORD=bench -e POSTGRES_DB=healthlog_bench \
-p 55432:5432 postgres:18.6-alpine \
postgres -c shared_preload_libraries=pg_stat_statements -c pg_stat_statements.track=top
```
The image and the two settings are the ones `docker-compose.yml` runs in production.
### 2. Environment, migrations, seed
```sh
export DATABASE_URL="postgresql://bench:bench@127.0.0.1:55432/healthlog_bench?schema=public"
export ENCRYPTION_KEYS='{"v1":"<64 hex>"}' ENCRYPTION_ACTIVE_KEY_ID=v1
export API_TOKEN_HMAC_KEY="<64 hex>"
pnpm db:migrate:deploy
pnpm dlx tsx scripts/bench/seed-large-account.ts --reset --anchor 2026-10-22
```
Any throwaway key values work, as long as the seed, the benchmark and the server see the same ones.
The seed writes ten years for one user: Apple Health dense rows (heart rate about every ten minutes, HRV, SpO2, respiratory rate, gait, audio), Withings blood pressure, pulse and scale, a weekly manual weight, three years of Oura resting heart rate and RMSSD, 90 days of Nightscout CGM, `stats:` and per-sample cumulative rows, nightly sleep stages, about 3,000 workouts, three years of mood and of three medications with their intake events. It then folds the rollup tier the way the boot backfill does and runs `ANALYZE`. It takes about 80 seconds on the Mac mini.
The seed is deterministic for a given `--anchor`: values and timestamps are functions of the row index and that day. Without `--anchor` it anchors on today, which is what you want when the benchmark should see recent data.
### 3. A production build
```sh
pnpm build
```
### 4. Run
```sh
pnpm dlx tsx scripts/bench/run.ts --out scripts/bench/results/<label>
```
The script boots the build once with every role, so the worker creates its queues as it does in production, drops whatever that boot scheduled, and then runs the measurement against a web-only process (`HEALTHLOG_PROCESS_TYPE=web`) under the production heap ceiling (`--max-old-space-size=792`, the value Node derives in the 1.5 GB production container). Without the worker, its polling does not land in every statement count. It prints the server's idle statement rate so the remaining background is visible.
For each route it sends three warm-up requests, then twenty measured ones in each of two modes, one request at a time:
- **cold**: before every request a one-row ingest marked as a manual sync evicts the user's caches, so the request rebuilds what it serves. This is a reader's first look after a sync.
- **warm**: back to back, what a cache hit costs.
The batch ingest posts 5,000 fresh heart-rate rows as ten requests of 500, the route's per-request limit, and removes them again at the end.
Statements are the change in `sum(calls)` of `pg_stat_statements` over the database, read before and after each request, once no statement is in flight and the counter holds still. Fire-and-forget writes a request starts are therefore counted with it.
Options: `--iterations`, `--warmup`, `--user`, `--heap-mb`, `--port`, and `--base-url` to measure a server you started yourself.
The output is `<label>.json` (every number, plus the commit, Node and Postgres versions and the row count) and `<label>.md` (the table). Commit the pair when a change is meant to move a number, so the PR shows before and after.
### Reading the numbers
Run on a quiet machine; a parallel build or test run moves latency by tens of percent and leaves the statement counts alone. Compare statement counts across machines, latency only across runs on the same machine. A local number is only valid under the production heap ceiling, which the script sets.
## Boot and idle-memory profile
`scripts/bench/boot-profile.ts` profiles a production start the way production runs it: `next start` on the build, every role (web and worker), the production heap ceiling (`--max-old-space-size=792`, the heap Node derives in the 1.5 GB container), against the seeded benchmark database.
```sh
pnpm dlx tsx scripts/bench/boot-profile.ts --idle-seconds 300 --out scripts/bench/results/<label>-boot
```
It records:
- the time to the first answer from `/api/health` and to the worker's `startup` line;
- every `event_loop_stall` the process logs, with the uptime, startup phase, boot step and work in flight it names;
- every `runtime.heap.sampled` line, and every full collection from `--trace-gc` (used heap before, live heap after);
- a CPU profile of the start: the main thread's busy stretches over 50 ms without an idle sample, each with its self time per source module, resolved through the chunk source maps (`profile-analyze.ts`);
- one heap snapshot after the idle wait, taken with `--heapsnapshot-signal` (after a full collection, so it is the live heap), summarised by node type, constructor, largest strings and the largest retained subtrees with a retaining path, and the number of parsed locale catalogs.
The worker runs for the whole idle wait, and its boot passes fold and consolidate the seeded account (old dense readings into their `stats:` rows), roughly halving its row count. Reseed before the next request benchmark, or its numbers describe a different account.
The raw `.cpuprofile` and `.heapsnapshot` go to a temporary directory and are deleted after the summary unless `--keep` is given. Only run it against the benchmark database: a snapshot holds whatever the process decrypted. `--no-snapshot` and `--no-cpu-prof` skip either part. The profiler itself costs memory, so read RSS from this run as an upper bound.
### What the profile found (v1.44 runtime, October 2026)
On the seeded account, idle, with the worker running and no requests (`results/v1.45.0-before-boot.md`):
- **The boot stall is module evaluation.** The longest busy stretch of the main thread was 753 ms, 0.7 s into the start: the instrumentation hook importing the worker, whose five registrars pulled practically all of `src/lib` into one synchronous evaluation (`register-maintenance.ts` alone 280 ms inclusive), followed by the first `/api/health` request compiling its route. The event-loop monitor started after the key and time-zone checks, and a 250 ms tick measures a block by how late the next tick fires, so it reported less than the block or nothing. The beta's 718 ms and 671 ms stalls are this block on a slower machine.
- **The live heap is about 165 MB, not 400.** Full collections brought the heap back to 162 to 172 MB every 15 to 20 s while idle; the used heap between them reached 190 MB. What a heap line shows above the live heap is garbage awaiting the next full collection, plus, on a server with users, the server caches (13 caches of up to 2,000 entries with stale windows of up to an hour). The beta's 400 MB plateau is therefore not a baseline the process cannot shed; it is what the heap grows to under a large limit before a collection, plus caches.
- **What the live heap holds.** Of 172 MB: the query compiler about 50 MB (the engine object 34 MB, its WebAssembly base64 source twice at 4.4 MB, its chunk source twice at 5.6 MB); the locale catalogs about 33 MB (32 parsed catalog objects, eight locales in each of four server bundles, 14 MB, plus the eight-locale chunk source twice at 9.7 MB); module source strings over 50 KB about 44 MB in total (the catalog chunks included); compiled code 19 MB.
### What changed (v1.45.0)
- The event-loop monitor starts first in `register()`, and a stall line names the boot step, startup phase and work in flight (`src/lib/boot/boot-steps.ts`, one `boot.steps` line per boot with each step's duration).
- The worker's registrars are imported one at a time after `boss.start()`, with a turn of the event loop between them.
- The server reads a locale catalog from `messages/<locale>.json` on first use and keeps one parsed copy per process (`src/lib/i18n/server-catalogs.ts`); the seven matchers that index every locale build their index on first use.
Result on the same machine (`results/v1.45.0-boot.md`): the worker graph loads in stretches of at most 246 ms instead of one of 753 ms (the longest application stretch left is the query compiler's start, 336 ms, unchanged); the live heap after five idle minutes went from 162 to 172 MB to 142 to 153 MB; two parsed catalogs (1.6 MB) instead of 32 (14.1 MB), and no catalog chunk sources.
## Statement budgets in the integration suite
`tests/integration/query-budget-hot-routes.test.ts` seeds a small account (four months of the main types with overlapping sources, sleep, glucose, a medication, workouts and mood), and counts the statements of one request per hot route on a cache miss: `/api/auth/me`, the dashboard summary and snapshot, the pulse series over 30, 365 and 3650 days, the day view, insights health status, correlations, and a 500-row batch ingest.
The counter (`tests/integration/query-counter.ts`) wraps `pg`'s `Client.prototype.query` on the copy of `pg` the Prisma adapter loads, and settles background tasks before it closes the window.
Each budget is the measured count. When a change lowers one, lower the budget in the same change. When a change has to raise one, raise it with the reason in the PR. A failure prints every statement the request sent, grouped by text, so an added loop shows up as one statement with a count next to it.
Run it like any integration file:
```sh
pnpm test:integration tests/integration/query-budget-hot-routes.test.ts
```
+286
View File
@@ -0,0 +1,286 @@
# Testing
How the suites are organised, which one a new test belongs in, and the tools
that keep them fast and honest. Commands are in `CONTRIBUTING.md`; this page
is about choosing and writing tests.
## Which suite
| What you are testing | Where | Runs with |
| --------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------- | ----------------------- |
| Pure logic: statistics, time zones, cadence, parsers, unit conversion, formatting | `src/**/__tests__/*.test.ts` (unit) | `pnpm test` |
| Laws of that logic over random inputs | `*.property.test.ts` next to the unit tests | `pnpm test` |
| What a component renders for a given state | `src/**/__tests__/*.test.tsx` (unit) | `pnpm test` |
| How a component behaves when someone types, clicks or presses keys, and what it sends | `src/**/__tests__/*.browser.test.tsx` | `pnpm test` |
| A structural rule about the tree (a guard) | `src/__tests__/` with a stated floor | `pnpm test` |
| A route: auth, scope, validation, what it stores and what it answers | `tests/integration/routes/` with the kit | `pnpm test:integration` |
| Anything whose truth lives in Postgres: constraints, `where` semantics, transactions, locks, cascades, time zones in SQL, backups, jobs | `tests/integration/` | `pnpm test:integration` |
| A journey through the real UI | `e2e/` | `pnpm e2e` |
The rule behind the table: put a behavioural test at the level where the
risky collaborator is real. For a route that collaborator is Postgres and the
API kit (`apiHandler`, `requireAuth`, the envelope), so a route test runs
against both. A unit test that replaces Prisma with a mock and then asserts on
the calls it received checks the query text, not the behaviour; it passes when
the query is wrong and fails when it is rewritten correctly. Do not write new
ones: `src/__tests__/test-realism-ratchet.test.ts` counts route tests that mock
`@/lib/db` wholesale or stub `apiHandler`, and `as never` casts in tests, and
those numbers may only fall. Mock at the process boundary you do not own instead: provider HTTP
(`safeFetch`), APNs, SMTP, the model.
## Critical journeys
A few e2e journeys carry `@critical` in their test titles: signing in with a
password and a passkey and signing out, a reading reaching the dashboard, a
dose moving compliance, a mood entry, a lab CSV import, a questionnaire in
German and Brazilian Portuguese, a setting that persists, a backup coming
back, sharing a record, and the offline page. `pnpm e2e:critical` runs them.
They run against the real server and a seeded Postgres. A spec file that
carries the tag may not intercept anything under `/api`, directly or through
a helper it imports; `src/__tests__/e2e-critical-no-api-stub-guard.test.ts`
refuses it and keeps a floor on the number of tagged tests. Stubbing an
unreachable third-party host that is not the journey's subject is allowed,
with the reason next to the call.
A journey that writes gets an account nobody else writes. The accounts in
`e2e/setup/critical-fixture.ts` are seeded by global setup, one per journey
and per Chromium project, so a journey runs at both widths without the two
projects meeting. Reset what a previous run left with `resetCriticalAccount`
in a `beforeEach`, and read results through the app's own routes rather than
through the database.
## The route test kit
`tests/kit/` holds two modules for tests under `tests/integration/`.
`factories.ts` writes typed fixtures through the application's Prisma client
and returns the stored rows: `makeUser`, `makeAdmin`, `makeSession`,
`makeToken`, `makeMeasurement`, `makeMedication`, `makeWorkout`, `makeGrant`.
Overrides are the Prisma create input of that model, so the compiler checks
every fixture field; there is no reason for `as never` in a kit test.
Sessions and tokens are minted by the server's own `createSession` and
`issueApiToken`, so a fixture cannot drift into a shape authentication no
longer accepts.
`call-route.ts` exports `callRoute(handler, options)`, which builds a real
`NextRequest`, fills the `next/headers` stand-in from `auth`, calls the
exported handler of a `route.ts` (and with it the real `apiHandler`), and
returns `{ status, data, error, meta, body, headers }`.
```ts
import { GET } from "@/app/api/tokens/route";
import { DELETE } from "@/app/api/tokens/[id]/route";
import { callRoute } from "../../kit/call-route";
import { makeSession, makeToken, makeUser } from "../../kit/factories";
import { getPrismaClient, truncateAllTables } from "../setup";
beforeEach(() => truncateAllTables(getPrismaClient()));
it("revokes the caller's token and the token stops authenticating", async () => {
const owner = await makeUser();
const phone = await makeToken(owner);
const res = await callRoute<{ revoked: boolean }>(DELETE, {
method: "DELETE",
route: "/api/tokens/{id}",
params: { id: phone.tokenId },
auth: await makeSession(owner),
});
expect(res.status).toBe(200);
const row = await getPrismaClient().apiToken.findUniqueOrThrow({
where: { id: phone.tokenId },
});
expect(row.revoked).toBe(true);
const after = await callRoute(GET, {
method: "GET",
route: "/api/tokens",
auth: phone,
});
expect(after.status).toBe(401);
});
```
`auth` is a `SessionFixture`, a `BearerFixture`, or `anonymous`. `route` is
the path as the OpenAPI registry names it, with `{…}` segments filled from
`params`.
Every call is also a contract check. The status must be one the registry
(`src/lib/openapi/routes/`) declares for that path and method, a JSON body
must parse with the registered schema, and a key the schema does not declare
fails the call: a client generated from the contract cannot see that field, so
nobody promised to keep it. There is no generic answer the kit accepts on an
operation's behalf: the scope gate's 403 `auth.scope.insufficient` is declared
on every operation that passes the gate (`withScopeRefusal` in
`src/lib/openapi/routes/shared.ts`), so it is checked like any other status. A
test that calls an unregistered route on purpose passes
`contract: { skip: "<reason>" }`.
`tests/integration/routes/kit-contract.test.ts` proves the check can fail.
Assert what a person or a client would notice: the stored row, the response,
the next request's answer. Not the calls a mock received.
The kit runs the handler, not the edge. Security headers, the public-path
allowlist and demo mode live in `src/proxy.ts` and are tested there.
### The settings round trip
`tests/integration/settings-round-trip.test.ts` holds one table with an entry
for every `User` column the backup classifies as `SETTING`
(`USER_COLUMN_BACKUP_CLASS` in `src/lib/export/backup-plan.ts`). For each one
it sets a non-default value through the API, reads it back through the API and
through `/api/auth/me` where that publishes it, exports a backup, restores it
into a fresh account and reads it back there. A new setting column without a
table entry fails the file. A column with no write route says why in its
entry.
## Browser tests
Interaction belongs in the `browser` project: `*.browser.test.tsx` files run
in headless Chromium through Vitest's Playwright provider, as part of
`pnpm test`. Run them alone with `pnpm test --project browser`, or one file
with `pnpm exec vitest run --project browser <path>`. The Node projects never
collect these files and this project collects nothing else. CI installs the
browser it needs (`playwright install --only-shell chromium`); locally, run
that once.
Reach for it when the claim is about behaviour a markup test cannot see:
focus, keys, an open menu, a pending state, the body a form posts, the URL a
filter writes. A `renderToStaticMarkup` grep stays the tool for what a given
state renders, and bottom-sheet gestures stay in `e2e/`.
The helper is `tests/browser/`:
- `renderWithApp(ui, { locale, user })` mounts the app's own providers: a real
TanStack `QueryClient` (no retries) and `I18nProvider` with the real
catalog. The signed-in account is seeded where `useAuth` reads it, with
`authUser()` defaults under `user`. The app stylesheet and the default
theme class are loaded, so layout, visibility and colour tokens resolve.
- `stubFetch()` replaces `fetch` and nothing above it. Answer a route with
`.on(method, path, { status, data, error, meta })`, hold one with
`.deferred(...)` to assert a pending state, and read the parsed bodies from
`.calls(method, path)`. An unmatched request gets a 404 envelope and is
recorded. Do not mock `@tanstack/react-query` or `@/lib/api/api-fetch`.
- `next/navigation` is aliased to a shim over the page's real `location` and
`history`; `url.ts` sets the starting query, records pushes and replaces,
and simulates Back or an edited address.
- `pinDate(iso)` fakes `Date` only, so timers and animation frames still run.
Every test gets the real `fetch`, clock, address and an empty `localStorage`
back afterwards. Locate by role and accessible name, the way a person finds a
control; a `Label` adds a colon to its name, so match a labelled combobox with
`/^Type/` rather than `"Type"`.
## The integration database
Global setup boots one Postgres container and migrates one template database.
Each Vitest worker clones it on its first file (`CREATE DATABASE … TEMPLATE`,
see `tests/integration/worker-database.ts`), so files run in parallel without
seeing each other's rows, advisory locks or queue schema. The worker count
follows the host (half the cores, at most eight) unless
`HEALTHLOG_INTEGRATION_WORKERS` pins it; CI uses three. To reproduce a CI runner's
contention on a larger machine, cap the database container with
`HEALTHLOG_INTEGRATION_PG_CPUS=2`.
`truncateAllTables` empties every table the migrations created and puts back
the rows they seed (the mood-tag and symptom catalogues), so a test starts
from what a fresh install holds. The list is the catalogue, read from the
template, not something to maintain. `START_EMPTY_TABLES` names the one
seeded table a test starts without, and why. Schemas created at runtime
(pg-boss) are dropped when the next file starts in the worker.
`next/headers` and the boot compatibility probe are mocked for every file in
`environment-setup.ts`. A file needs its own `vi.mock("next/headers")` only
when it wants different behaviour.
## Speed and sharding
CI splits both suites by measured duration, not by file count: the sequencer
in `tests/durations/sequencer.ts` reads `tests/durations/unit.json` and
`tests/durations/integration.json` and deals files out longest first to the
shard with the least work. A file without a measurement counts as the median.
Regenerate a timing file from Vitest JSON reports when the shards drift apart:
```sh
pnpm exec vitest run --reporter=default --reporter=json --outputFile.json=unit.json
node scripts/test-durations.mjs unit unit.json
```
The nightly workflow (`.github/workflows/test-nightly.yml`) uploads a file
regenerated from CI timings, which is the better source.
The unit suite is three Node projects and the `browser` project above. The
Node ones are decided per file from its source
(`tests/unit-projects.ts`): `guards` and `unit` share a module graph within a
worker, `isolated` gives each file a fresh one. A file lands in `isolated` when
it mocks, spies, stubs env or globals, fakes timers or resets modules. If a
file needs isolation for a reason the classifier cannot see, put a
`// vitest-isolate` comment in it.
## Property tests
`fast-check` properties live in `*.property.test.ts` files and take their
parameters from `tests/property/fast-check.ts`. Pull requests run every
property with a fixed seed, so a run is reproducible on any machine. The
nightly run uses a fresh `FC_SEED` and multiplies every property's own case
count by `FC_RUNS_FACTOR`; a failure prints the seed and the shrunk
counterexample, and `FC_SEED=<seed> pnpm test <file>` replays it. A property
whose cases are expensive states a small count and passes
`propertyTimeout(baseMs)` as its test timeout, which grows by the same factor.
Write a property when the code has a law: an inverse (encrypt and decrypt, a
unit and its inverse, a wall clock and its instant), an invariant (a rate
between 0 and 100, a day that contains its own instants), or an oracle small
enough to be obviously right (a day-by-day walk of a weekly schedule). Do not
reimplement the function under test in the property.
## Mutation testing
Stryker runs on the core libraries (`stryker.config.mjs`) nightly and on
demand (`.github/workflows/mutation.yml`), not on pull requests. Each module's
score is checked against a floor in `stryker.floors.json` by
`node scripts/mutation-floors.mjs`. A floor sits just below the last measured
score; raise it when a change raises the score, never lower it to make a run
pass. The Vitest runner is patched for Vitest 5's test names
(`patches/@stryker-mutator__vitest-runner@10.0.0.patch`); without it every
test inside a `describe` block is filtered out of the mutant runs and almost
every mutant survives. Locally:
```sh
pnpm exec stryker run
node scripts/mutation-floors.mjs
```
## Flaky tests
Vitest runs without retries, and stays that way: a retry that turns red into
green hides the flake it was meant to report. e2e keeps two retries with
`failOnFlakyTests`, so a test that needed one still fails the run.
A flaky unit or integration test may be taken out of the run only through
`tests/quarantine/quarantine.json`:
```json
{
"file": "tests/integration/example.test.ts",
"test": "outer describe > the test name",
"issue": "#1234",
"reason": "fails about one run in twenty when the queue worker is slow",
"added": "2026-10-22",
"expires": "2026-11-05"
}
```
The test is reported as skipped with its issue while the entry is live. An
entry expires at most 14 days after it was added; after its expiry date the
test runs again, and `tests/quarantine/quarantine-guard.test.ts` fails until
the entry is removed. The guard also fails on an entry that names a file or a
test that does not exist. Fix the flake, or delete the test if it no longer
earns its place; do not extend the date.
Order dependence is looked for on purpose: the nightly workflow runs both
suites with `--sequence.shuffle` and prints the seed. Replay a failure with
`--sequence.shuffle --sequence.seed=<seed>`.
+6 -4
View File
@@ -32,10 +32,10 @@ The gaps this section used to list are closed: `TileHeader` carries a `size="sm"
## 2. Spacing scale
> **Scroll height is shell-owned.** Only `AuthShell` owns the scroll container's height and bottom padding (`pb-20` wrapper + `<md` bottom-nav clearance on `<main>`). Pages/sections never add viewport-height reserves (`min-h-dvh`, `min-h-[calc(100dvh-…)]`), `h-full` chains, or bottom gutters inside the scrolling `<main>`; nested reserves stack on the shell budget and scroll past the last card. One sanctioned exception: the settings-shell grid floor (budget-derived, documented; retune it when shell paddings change). Lists that need their own scrolling use `max-h-* overflow-y-auto overscroll-contain`. Guard: `e2e/settings-overscroll.spec.ts`.
> **Scroll height is shell-owned.** Only `AuthShell` owns the scroll container's height and bottom padding (`pb-20` wrapper, `pb-24` in the desktop shell, + the phone shell's bottom-nav clearance on `<main>`). The shell itself takes the window's edges (`absolute inset-0`), never `h-dvh`, and `<main>` is the one vertical scroller: the document neither scrolls nor bounces under it. Pages/sections never add viewport-height reserves (`min-h-dvh`, `min-h-[calc(100dvh-…)]`), `h-full` chains, or bottom gutters inside the scrolling `<main>`; nested reserves stack on the shell budget and scroll past the last card. One sanctioned exception: the settings-shell grid floor (budget-derived, documented; retune it when shell paddings change). Lists that need their own scrolling use `max-h-* overflow-y-auto overscroll-contain`. Guard: `e2e/settings-overscroll.spec.ts`.
- **Allowed steps:** `1 / 1.5 / 2 / 2.5 / 3 / 3.5 / 4 / 6 / 8 / 10`. `2.5` and `3.5` are allowed: the medication card's rhythm is built on them, and the in-between steps earn their keep in dense card interiors. `5` (20 px), as in `py-5` or `p-5`, is not allowed; replace it when you touch a file that still has it. `7` (28 px) is not allowed either: it was the old Settings and Admin `pl-7` body gutter, which is gone (§13).
- **Page frame:** exactly one authenticated container, owned by `AuthShell` (`layout/auth-shell.tsx:318`): `mx-auto max-w-screen-xl px-4 pt-6 pb-20 md:px-6`. Never re-implement it in a page; full-bleed pages opt out via a shell flag, not negative margins. Prose pages (about/privacy/legal): `mx-auto max-w-3xl px-4 py-8 md:px-6 md:py-12`.
- **Page frame:** exactly one authenticated container, owned by `AuthShell` (`MAIN_CONTENT_WRAPPER_CLASS` in `layout/shell-metrics.ts`): `mx-auto max-w-screen-xl px-4 pt-6 pb-20 md:px-6 shell-desktop:pb-24`. Never re-implement it in a page; full-bleed pages opt out via a shell flag, not negative margins. Prose pages (about/privacy/legal): `mx-auto max-w-3xl px-4 py-8 md:px-6 md:py-12`.
- **Page rhythm:** root of every page is `space-y-6`. Grouped card sections: `space-y-10` between groups, `space-y-4` inside a group (notifications/integrations pattern). Insights sub-pages join `space-y-6`.
- **Left reading edge:** page-level text (H1, explainer, section headings) sits at the container edge. Only surfaces that paint a visible border/background may inset their content (`px-4 md:px-6`). A block without a border must not carry horizontal padding.
- **Grids:** grids **of** cards/tiles = `gap-4`; value read-outs **inside** one card = `gap-x-3 gap-y-2`; form field grids = `gap-4`.
@@ -101,7 +101,7 @@ No `text-[9px]`. No off-scale sizes (`text-[28px]`); round to the neighbouring s
| A sheet from the bottom edge (phones) | `<SheetContent side="bottom">` or `ResponsiveSheet`, both of which render `BottomSheetContent` (`ui/bottom-sheet.tsx`): grip, swipe to close, X on the title row. `preview="58dvh"` for a sheet that opens part-way and pulls up to 90 dvh (its footer `data-sheet-footer`, its scroll body `data-sheet-body`); `draggable={false}` only for a sheet that fills the window | a hand-rolled grab handle or drag; a `fixed bottom-0` panel standing in for a sheet |
| Date / time entry | `DateField` / `TimeField` / `DateTimeField` | native `type="date | time | datetime-local"` inputs (browser-locale trap, v1.25.4) |
| Small text action inside a line (Open schedule, Select all, View all, Retry, Learn more) | `InlineAction` (`ui/inline-action.tsx`): tone `primary` / `muted` / `foreground`, size `xs` / `sm`, `asChild` for a `Link`; owns the focus ring and `pointer-coarse:min-h-11` | a hand-rolled underlined `<button>` / `<Link>` (`inline-action-guard.test.ts`) |
| Mutually exclusive choice on one track (range, zoom, cards vs table, recent vs all) | `Segmented` (`ui/segmented.tsx`): no track, the chosen segment filled in primary (the chart tabs' look; a muted track with a raised segment was tried in 1.43.8 and dropped in 1.43.10 as too heavy); `mode="radio"` (default, roving focus) or `"toggle"` (view switches, form sheets), `iconOnly` for icon segments, `size="sm"` for range rows in a chart header; 44 px per segment on a coarse pointer | a row of ghost/secondary Buttons, a fifth segmented variant (`segmented.test.tsx` holds the definition to one module) |
| Mutually exclusive choice on one track (range, zoom, cards vs table, recent vs all) | `Segmented` (`ui/segmented.tsx`): no track, the chosen segment filled in primary (the chart tabs' look; a muted track with a raised segment was tried in 1.43.8 and dropped in 1.43.10 as too heavy); `mode="radio"` (default, roving focus) or `"toggle"` (view switches, form sheets), `iconOnly` for icon segments, `size="sm"` for range rows in a chart header; 44 px per segment on a coarse pointer | a row of ghost/secondary Buttons, a fifth segmented variant (`segmented-single-implementation.test.ts` holds the definition to one module) |
| A grade beside a reading (confidence, score gain, band, lab range flag) | `ScorePill` (`ui/score-pill.tsx`): tone `success` / `info` / `warning` / `destructive` / `neutral`, `numeric` for figures; `CONFIDENCE_TONE` maps both confidence scales | a hand-rolled `rounded-full px-2 py-0.5 text-xs bg-*/15` chip (`score-pill-guard.test.ts`); `TagChip` (neutral label) and `Badge` (connection/account state) stay what they are |
| "This vital on these days vs those days" board | `MetricCrosstab` (`insights/metric-crosstab.tsx`): caller formats the numbers, the board owns row, neutral signed delta, confidence chip, dismissal | a fourth copy of the crosstab list |
| A trend / stat tile in a grid (KPI strip, derived tiles, their skeletons and building states) | `TrendTile` (`ui/trend-tile.tsx`) | the hand-spelled `bg-card border-border … p-4 md:p-6` frame |
@@ -148,7 +148,7 @@ Forbidden on any Insights card: a hand-rolled icon+title row, a card title centr
## 9. One scroll floor; sub-shells never re-declare bottom-space
Reinforces §2's "scroll height is shell-owned". Only `AuthShell` owns the scroll container's bottom padding and any viewport-height reserve: the `<main>` clears the mobile bottom-nav (`pb-[calc(4rem+safe-area)]`) and its wrapper clears the Coach FAB (`pb-20`). **No page, section, or sub-shell** adds a `min-h-[calc(100dvh-…)]` / `min-h-dvh` reserve or a `pb-*` bottom gutter inside the scrolling `<main>`; a nested reserve stacks on the shell budget and scrolls past the last card.
Reinforces §2's "scroll height is shell-owned". Only `AuthShell` owns the scroll container's bottom padding and any viewport-height reserve: the `<main>` clears the mobile bottom-nav (`pb-[calc(4rem+safe-area)]`), or the home indicator in the desktop shell, and its wrapper clears the Coach FAB (`pb-20`, `pb-24` in the desktop shell). **No page, section, or sub-shell** adds a `min-h-[calc(100dvh-…)]` / `min-h-dvh` reserve or a `pb-*` bottom gutter inside the scrolling `<main>`; a nested reserve stacks on the shell budget and scrolls past the last card.
A two-column sub-shell that needs a "don't jump while loading" floor derives it from **one shared source**: `SUB_SHELL_GRID_FLOOR` (`src/components/layout/shell-metrics.ts`); applied to the **grid** (not a column), so the `1fr` content row stretches to fill without over-shooting. It never re-declares the floor per-shell (`AdminShell` drifting to a stale 12rem column reserve is exactly how this recurred). Guard: `e2e/settings-overscroll.spec.ts` covers Settings **and** Admin and measures against each element's content-box bottom, so a redundant sub-shell `pb-*` fails instead of inflating the allowed budget.
@@ -156,6 +156,8 @@ A two-column sub-shell that needs a "don't jump while loading" floor derives it
Settings + Notifications are **account utilities, not feature destinations**. They live ONLY in the user/avatar menu (mobile top-bar `DropdownMenu`; desktop sidebar avatar menu, with Settings additionally in the sidebar footer). The mobile "More" overflow hub carries **feature destinations only** (Measurements, Mood, Cycle, Labs, Coach, …), never Settings or Notifications. The hub is `mobileMoreHubDestinations()` = `visibleNavDestinations` minus the primary slots (`/`, `/medications`, `/insights`); it does **not** append the utility tail. `NAV_UTILITY_DESTINATIONS` (`visibleUtilityDestinations()`) feeds the sidebar footer + user menus, not the mobile overflow. Never surface a utility in two menus reachable from the same screen.
The rail's destination list is the one part of the rail that gives up height: the logo band, the footer group and the account row stay pinned, and the list scrolls between them (`overscroll-contain`, no native scrollbar, fades at an edge with more). A mouse gets the height-stepped row density; a finger keeps the 44 px rows, and at an edge with more destinations the rail shows a scroll cue (`sidebar-nav-scroll-cue`) that a tap follows. Guard: `e2e/tablet-shell.spec.ts`.
## 11. Inline action row; the PageHeader pattern
A title/sentence with an adjacent action is one row, not a wrapping stack: `flex items-start justify-between gap-3`, the text `min-w-0 flex-1` (wraps internally), the action `shrink-0` (stays beside the text, never pushed to its own line). This is the canonical `PageHeader` / `TileHeader` (`right` slot) shape; reuse it (e.g. the dashboard-hero verdict + CTA) instead of a one-off `flex-wrap` row whose `gap-y-*` drops the action below on a full sentence. Keep the mobile tap-target floor (`min-h-11 sm:min-h-9`).
+1 -1
View File
@@ -81,7 +81,7 @@
<text x="600" y="360" text-anchor="middle" fill="#6272a4" font-size="11">pre-aggregated buckets</text>
<rect x="510" y="392" width="180" height="56" rx="10" fill="#44475a" stroke="#ff79c6" stroke-width="1.5" />
<text x="600" y="417" text-anchor="middle" font-weight="600">PostgreSQL 16</text>
<text x="600" y="417" text-anchor="middle" font-weight="600">PostgreSQL 18</text>
<text x="600" y="435" text-anchor="middle" fill="#bd93f9" font-size="11">encrypted at rest</text>
</g>

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

+2 -2
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 self-hosting topology</title>
<desc id="desc">Internet traffic terminates at a reverse proxy (Caddy, Traefik or Nginx) for TLS, then reaches the Next.js app on port 3000. The app shares a Postgres 16 database with the pg-boss worker process for reminders, insights, backups and the Apple Health import. GHCR ships the multi-arch image; Coolify watches the digest and pulls; off-host S3-compatible storage is optional for backups.</desc>
<desc id="desc">Internet traffic terminates at a reverse proxy (Caddy, Traefik or Nginx) for TLS, then reaches the Next.js app on port 3000. The app shares a Postgres 18 database with the pg-boss worker process for reminders, insights, backups and the Apple Health import. GHCR ships the multi-arch image; Coolify watches the digest and pulls; off-host S3-compatible storage is optional for backups.</desc>
<rect x="0" y="0" width="1200" height="620" fill="#282a36" />
@@ -47,7 +47,7 @@
<!-- Postgres (green) -->
<g>
<rect x="760" y="250" width="200" height="80" rx="10" fill="#44475a" stroke="#50fa7b" stroke-width="1.5" />
<text x="860" y="276" text-anchor="middle" fill="#f8f8f2" font-size="14" font-weight="600">PostgreSQL 16</text>
<text x="860" y="276" text-anchor="middle" fill="#f8f8f2" font-size="14" font-weight="600">PostgreSQL 18</text>
<text x="860" y="296" text-anchor="middle" fill="#bd93f9" font-size="11">26 Prisma models</text>
<text x="860" y="312" text-anchor="middle" fill="#6272a4" font-size="11">+ pg-boss queues</text>
</g>

Before

Width:  |  Height:  |  Size: 8.1 KiB

After

Width:  |  Height:  |  Size: 8.1 KiB

+1 -1
View File
@@ -10,7 +10,7 @@ copy.
| ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `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. |
| `03-self-hosting-topology.svg` | Internet → reverse proxy (TLS) → Next.js app (port 3000) ↔ pg-boss worker ↔ Postgres 18, 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. |
| `05-security-model.svg` | Three concentric layers — auth perimeter (passkey + password fallback), session perimeter (HttpOnly cookie + Postgres sessions), encrypted core (AES-256-GCM versioned keys). Side rails: rate limiter, audit log, HMAC tokens, CSP/HSTS, SSRF guard. |
+1 -1
View File
@@ -295,7 +295,7 @@ Concrete consequences:
RMSSD HRV has its own ladder, **WHOOP ≻ OURA ≻ POLAR ≻ APPLE_HEALTH**,
separate from the SDNN one.
Override per-user via Settings → Sources (`/settings/sources`).
Override per-user via Settings → Source priority (`/settings/sources`).
## Failure modes
+55 -22
View File
@@ -57,23 +57,50 @@ arrays:
}
```
Each array holds at most **10 000** entries. The endpoint is rate-limited
to **5 imports per hour per account**. Existing rows (matched on the
type + timestamp uniqueness, or on `externalId` for mood entries) are
skipped and counted under `skipped` in the response.
Each array holds at most **10 000** entries and the file at most 16 MB.
The endpoint is rate-limited to **5 imports per hour per account**.
Existing rows (matched on the type + timestamp uniqueness, or on
`externalId` for mood entries) are skipped and counted under `skipped` in
the response.
The app's own files go straight in: the JSON export (**Export → JSON**,
read inside its `data` envelope) and the portable full backup. Every field
either file writes for a reading or a mood entry comes back, apart from
the row ids, the source (every imported row is stored as `IMPORT`) and the
sync bookkeeping of the account the file came from. An empty field, `null`
in the backup and `""` in the export, reads as absent, a backup's deletion
records are skipped and counted, and a reading in a display unit (lb, °F,
mmol/L) is converted to the stored one.
A mood entry's tags and rated factors are linked by key. A full backup
also carries the account's own tags, rated factors and tag groups, and the
import brings them along: a tag the importing account already has is used
as it is, and one it lacks is created (within the account's limit of 50
custom tags and 12 groups). The account's own tag is never overwritten: when
the file names it differently, the account's name stays and the response
lists the key in `keptMoodTagKeys` (`keptMoodCategoryKeys` for a group).
Moving a file between two accounts of one server works too; the tags come
over under keys of their own. A key the account cannot resolve at all is
dropped from that entry and named in `droppedTagKeys` or
`droppedFactorKeys`, and the rest of the entry lands.
### Measurement entry
| Field | Required | Type | Notes |
| ---------------- | -------- | ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `type` | yes | enum | One of the `MeasurementType` values in the table below. |
| `value` | yes | number | Plausibility-checked per type; out-of-range values are rejected. |
| `unit` | yes | string | The canonical unit for the type (see the table). |
| `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`. See [Glucose context](#glucose-context). |
| `externalId` | no | string | A source-stable id (1–120 chars). When present, a re-import upserts on `(user, type, source, externalId)` so an edit upstream is reflected rather than skipped. |
| Field | Required | Type | Notes |
| ---------------------- | -------- | ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `type` | yes | enum | One of the `MeasurementType` values in the table below. |
| `value` | yes | number | Plausibility-checked per type; out-of-range values are rejected. |
| `unit` | yes | string | The canonical unit for the type (see the table). |
| `measuredAt` | yes | string | ISO-8601 datetime, e.g. `2026-05-01T08:00:00.000Z`. |
| `source` | no | string | Free-text origin label, not stored. Imported rows are tagged `IMPORT` server-side regardless. |
| `notes` | no | string | Optional free text. |
| `glucoseContext` | no | enum | Only for `BLOOD_GLUCOSE`. See [Glucose context](#glucose-context). |
| `valueMin` | no | number | Lower bound of a range reading, such as an hourly heart-rate statistic. Not above `valueMax`. |
| `valueMax` | no | number | Upper bound of a range reading. |
| `sleepStage` | no | enum | A sleep row's stage: `IN_BED`, `ASLEEP`, `AWAKE`, `CORE`, `DEEP` or `REM`. |
| `rhythmClassification` | no | enum | The device's verdict on an ECG, a rhythm alert or a steadiness reading. |
| `deviceType` | no | string | The writer of the reading (watch, phone), 1–32 chars. |
| `externalId` | no | string | A source-stable id (1–120 chars). When present, a re-import upserts on `(user, type, source, externalId)` so an edit upstream is reflected rather than skipped. |
### Glucose context
@@ -98,14 +125,20 @@ moment you know it.
### Mood entry
| Field | Required | Type | Notes |
| ------------ | -------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `date` | yes | string | `YYYY-MM-DD`. |
| `mood` | yes | enum | One of `SUPER_GUT`, `GUT`, `OKAY`, `SCHLECHT`, `LAUSIG`. |
| `score` | yes | integer | `1`–`5`. |
| `tags` | no | string | Comma-separated tags. |
| `loggedAt` | no | string | ISO-8601 datetime; defaults to the start of the given `date` in the default timezone. |
| `externalId` | no | string | A source-stable id (1–120 chars). When present, a re-import upserts on `(user, source, externalId)` so an edit upstream is reflected rather than skipped. |
| Field | Required | Type | Notes |
| ---------------- | -------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `date` | yes | string | `YYYY-MM-DD`. |
| `mood` | yes | enum | One of `SUPER_GUT`, `GUT`, `OKAY`, `SCHLECHT`, `LAUSIG`. |
| `score` | yes | integer | `1`–`5`. |
| `tags` | no | string | Comma-separated tags. |
| `note` | no | string | The entry's note, up to 500 chars. |
| `a1`–`a5` | no | integer | The five level-A values, `0`–`10`. Without `a1`, pleasantness is derived from `mood`. |
| `loggedAt` | no | string | ISO-8601 datetime; defaults to the start of the given `date` in the default timezone. |
| `tz` | no | string | The IANA zone `date` is anchored to. |
| `factors` | no | array | Rated factors, `[{ "key": "…", "rating": 1–5 }]`; replace the entry's rated links. |
| `structuredTags` | no | array | Ticked tags by key; replace the entry's tag links. |
| `context` | no | object | The day context (work, contacts, leisure, an event, a note), as the backup writes it; replaces the entry's context. |
| `externalId` | no | string | A source-stable id (1–120 chars). When present, a re-import upserts on `(user, source, externalId)` so an edit upstream is reflected rather than skipped. |
### `MeasurementType` values and canonical units
+5 -80
View File
@@ -1,82 +1,7 @@
# Dawarich integration
HealthLog can read daily movement context from a [Dawarich](https://dawarich.app)
instance: how far you moved, how much of it by car or by train, how many places
you visited, how long you were away from home, and whether a day was spent in
another country. It is part of the Environment module and runs only while that
module is on.
HealthLog never stores a coordinate, a track, a place name or a visit. It reads
the day summaries and keeps one row of numbers per day. The place list is read
live when you pick your home and is not saved.
## What is read
Once a night (03:20 Europe/Berlin) HealthLog fetches the last seven days, ending
yesterday. Connecting fetches the last 30 days. Each request carries your API
key as `Authorization: Bearer <key>`.
| Dawarich endpoint | What HealthLog keeps |
| ----------------------- | -------------------------------------------------------------------------------------- |
| `GET /api/v1/timeline` | total distance, distance by car (`driving`), distance by train, places visited |
| `GET /api/v1/visits` | minutes spent at your home place, to work out time away from home |
| `GET /api/v1/residency` | the country of each day, sealed at rest, and whether it differs from your home country |
| `GET /api/v1/places` | nothing; the list is shown once so you can pick your home |
| `GET /api/v1/users/me` | nothing; used to check the key |
Time away from home is the day's length minus the time a visit at your home
place covers. Without a home place, or on a day without a single visit, it is
left empty rather than shown as zero. Dawarich leaves out a mode that moved
less than half a kilometre; HealthLog then reads that mode as zero for the day.
Residency needs API access on a hosted Dawarich plan; a self-hosted instance
has it. Without it there are no travel days, and everything else still works.
Dawarich keys residency by UTC date, so on a day that crosses a border late in
the evening the country can be the next morning's.
## Connect
1. In Dawarich, open your account settings and copy the API key.
2. In HealthLog, turn on the Environment module, then open
**Settings → Integrations → Dawarich**.
3. Enter the address of the instance and the key, and connect.
4. Pick your home place from the list, and set your home country if you like.
Without one, the country you spent most days in over the last year counts.
Run Dawarich and HealthLog in the same time zone. Dawarich groups the
timeline by its own zone, HealthLog by yours; if they differ, a day can come
back without its distances.
## A Dawarich instance on your own network
A public instance works without any server setting. An instance on a private
network (a LAN address, a `.lan` name, a container on the same host) is refused
unless the operator lists its exact origin:
```env
DAWARICH_PRIVATE_ORIGINS="http://dawarich.lan:3000"
```
The grammar is the one `NIGHTSCOUT_PRIVATE_ORIGINS` uses
([Nightscout](./nightscout.md)): one `scheme://host[:port]` per entry, comma
separated, no path, no wildcard and no range. Listing a host does not open its
subdomains or another port. The unspecified address, link-local and the
cloud-metadata range can never be listed. Restart HealthLog after changing it.
The variable is on the `environment:` whitelist in `docker-compose.yml`.
## Where the values show up
- The day view has an **On the move** section with the day's figures.
- Time away from home and the distance travelled join the correlation scan as
two more factors.
- Runs of at least two travel days that no trip covers yet are offered as
trips under **Settings → Environment → Location periods**. Using one fills
in the dates; you pick the place.
- The Coach and the MCP `get_day` tool see the figures and whether it was a
travel day, never the country.
## Disconnect
Disconnecting stops the nightly fetch and keeps the stored days. **Disconnect
and delete days** removes them as well. The days are part of the backup; the
address and key are not, so a restored account connects again.
The Dawarich guide lives on the docs site:
<https://docs.healthlog.dev/guides/connect-dawarich/>. It covers what is read
and what is never stored, connecting an instance, allowing one on your own
network (`DAWARICH_PRIVATE_ORIGINS`), where the values show up, and
disconnecting.
+1 -1
View File
@@ -103,7 +103,7 @@ The Fitbit client id/secret are per-user (Settings), not env vars.
If Fitbit and another source (Withings, WHOOP, Apple Health) both supply the same
vital — resting heart rate, blood oxygen, body temperature or respiratory rate —
the source priority ladder in **Settings → Sources** decides which one wins
the source priority ladder in **Settings → Source priority** decides which one wins
per metric. FITBIT is ranked in every default metric ladder; override
per metric on that page.
+1 -1
View File
@@ -305,7 +305,7 @@ triggers the verification + CASA requirements above.)
If Google Health and another source (Withings, WHOOP, Apple Health) both supply
the same vital — resting heart rate, blood oxygen, respiratory rate — the source
priority ladder in **Settings → Sources** decides which one wins
priority ladder in **Settings → Source priority** decides which one wins
per metric.
## Disconnect
+4 -72
View File
@@ -1,74 +1,6 @@
# Strava integration
HealthLog reads your Strava activities and folds them into the same workout
history as every other source. Strava is a **workout source only** — its API is
activity-centric and exposes no sleep, recovery, body-composition or glucose
data, so connecting it adds runs, rides, swims and the rest to your workout
surface and nothing else.
The OAuth app is brought **per user**: each self-hoster registers their own
Strava API application and pastes the client id/secret into Settings. Strava
caps every newly-created app at "single-player mode" (an athlete capacity of 1)
until you request a capacity increase — perfectly fine for a personal
self-host, and the reason a single shared app cannot serve many operators.
## What you get — and what you don't
**Covered** (one `Workout` row per activity): sport type, start and end time,
moving duration, distance, elevation gain, average and maximum heart rate (when
the activity recorded it), and calories. Power, cadence, the "trainer"/"commute"
flags and the activity name are kept on the row's metadata for provenance but
are **not** surfaced as training analytics — HealthLog treats Strava as another
workout source, not a training-load platform.
**Not covered**: Strava exposes no daily metrics (steps, sleep, resting heart
rate, weight, …). Those come from your wearable's own integration or from Apple
Health / Google Health Connect.
## Duplicate activities are collapsed automatically
If you record a run on an Apple Watch **and** it also lands in Strava, the same
run arrives twice — once via Apple Health, once via Strava. HealthLog keeps one
canonical row per logical workout using your **source-priority ladder**: by
default the device-native capture (Apple Watch, WHOOP, Withings) wins over the
Strava copy, because a Strava activity is often a re-upload with lower-fidelity
or missing heart-rate data. A run you record **only** on Strava (phone GPS)
survives untouched. You can promote Strava above the wearables in
**Settings → Sources** if you prefer the Strava copy.
## 1. Register a Strava API application
1. Sign in at <https://www.strava.com/settings/api>.
2. Create an application. For **Authorization Callback Domain** enter the host
of your HealthLog instance (the domain only, no scheme or path).
3. Copy the **Client ID** and **Client Secret**.
## 2. Paste the credentials into HealthLog
1. Open **Settings → Integrations** and find the **Strava** card.
2. Paste the Client ID and Client Secret into the **API Credentials** fields and
save. (An operator can instead configure a shared app via the
`STRAVA_CLIENT_ID` / `STRAVA_CLIENT_SECRET` environment variables; per-user
credentials always take precedence.)
3. Click **Connect Strava** and approve the access request. HealthLog requests
the `activity:read_all` scope so it can read every activity, including those
you marked "only me".
## 3. First sync
The first history import runs in the background shortly after you connect;
afterwards an hourly poll pulls new activities. Strava rotates its refresh token
on every refresh, and HealthLog persists the rotated token each time, so the
connection stays healthy without you re-authorising.
## Disconnecting
Disconnecting revokes the grant at Strava and clears the stored tokens.
Activities already imported stay in your history; reconnecting resumes the sync.
## Related
- Wearable that syncs to Strava but not directly to HealthLog? See the device's
own page, or ingest via [Apple Health](./apple-health.md) /
[Google Health Connect](./google-health.md).
- **Garmin owners:** Garmin has no direct connector — see [garmin.md](./garmin.md).
The Strava guide lives on the docs site:
<https://docs.healthlog.dev/guides/connect-strava/>. It covers what Strava
adds (workouts only), how duplicate activities from a wearable are collapsed,
registering the Strava API application, the first sync and disconnecting.
+4 -4
View File
@@ -88,7 +88,7 @@ or right away after reconnecting Withings once.
needed — `user.metrics` covers weight / BP / heart-list / sleep /
SpO₂ / body comp / temperature / VO₂ max; `user.activity` is the
workout-equivalent stream for steps / energy / distance / floors.
See `WITHINGS_OAUTH_SCOPE` in `src/lib/withings/client.ts`.
See `WITHINGS_OAUTH_SCOPE` in `src/lib/withings/oauth.ts`.
4. Save the application. Withings will issue a **Client ID** and a
**Client Secret** — keep the secret out of any chat log; it grants
read access to every linked account's full health history.
@@ -145,7 +145,7 @@ callback. The portal only owns the OAuth callback URL.
## 4. Link from the Settings page
With the credentials saved in Settings, open
`/settings/integrations/withings` and click **Connect Withings**. The flow:
`/settings/integrations#withings` and click **Connect Withings**. The flow:
1. The Connect button redirects to Withings' `oauth2/authorize`.
2. The user signs in on Withings and grants the requested scopes.
@@ -181,7 +181,7 @@ import:
when Apple Health is also linked, so the Apple Health stream wins
for the cumulative axis. The Withings rows stay in the DB.
Override per-user via Settings → Sources (`/settings/sources`).
Override per-user via Settings → Source priority (`/settings/sources`).
## Troubleshooting
@@ -199,7 +199,7 @@ Override per-user via Settings → Sources (`/settings/sources`).
### The webhook stops firing
Webhook subscriptions can lapse after a token revoke or a portal-
side cleanup. Re-subscribe by opening `/settings/integrations/withings`
side cleanup. Re-subscribe by opening `/settings/integrations#withings`
and clicking **Disconnect** then **Connect Withings** again — the
callback exchange re-runs `Notify.subscribe()` with a fresh callback
URL.
+132 -470
View File
@@ -1,507 +1,169 @@
# Off-host backup & restore
# Account copies and restore
HealthLog ships with an optional daily off-host backup that ships every
user's JSON dump, encrypted with AES-256-GCM under a SEPARATE key
(`BACKUP_ENCRYPTION_KEY`), to any S3-compatible bucket — Cloudflare R2,
AWS S3, Backblaze B2, MinIO, etc.
HealthLog keeps copies of each account so that one account can be rolled back
on this server. These copies are per account. They cannot rebuild a lost
server: a restore writes into an account that already exists here under the
same id, and refuses a copy whose account is gone. To recover a server, you
need a database dump and the encryption keys. That procedure is in
[disaster-recovery.md](disaster-recovery.md).
The backup runs at **02:30 Europe/Berlin** every day from the worker
container (queue `data-backup-offhost`). Object key layout:
## What exists
```
<bucket>/YYYY-MM-DD/user-<userId>.json.enc
```
| Copy | Where | When | Kept |
| ----------------------- | ------------------------- | -------------------------------------------------------------- | ----------------------------------------------------------------------------- |
| Weekly account rollback | this database | Sundays 03:00, or **Backup now** in Admin → Backups | one per account, replaced in place each week |
| Copy before a data wipe | this database | right before "Delete all data" deletes anything | until `ACCOUNT_DELETION_GRACE_DAYS` has passed, then the purge job deletes it |
| Uploaded copy | this database | when an admin uploads a file in Admin → Backups | until deleted |
| Off-host copy | your S3-compatible bucket | nightly at 02:30 Europe/Berlin, one object per account per day | as long as the bucket's lifecycle rule keeps it |
| Personal export | the person's device | Settings → Export, "Full backup" | wherever they keep it |
## Wire format (binary)
The weekly, pre-wipe and off-host copies carry the whole record, document
files and ECG traces included, with the database's ciphertext inside them as
stored. An uploaded copy holds whatever its file held. The personal
export is portable: readable values, and documents as metadata only. It is
meant for the person to keep and read. A restore refuses it when the file
lists any document, because it carries no document content to put back.
```
magic = "HLBK" (4 bytes, ASCII)
version = 0x04 (1 byte)
keyIdLen, keyId (1 byte + 12 ASCII hex: the first 12 hex digits of SHA-256(key))
iv = 12 random bytes (AES-GCM nonce)
ciphertext = N bytes (AES-256-GCM over gzip(JSON dump), key = BACKUP_ENCRYPTION_KEY,
associated data = "healthlog/offhost-backup/v4|<object key>")
authTag = 16 bytes (AES-GCM tag, trailing)
```
Two kinds of data are not in any account copy: workout GPS routes and sample
series. Credentials, devices, share links and account grants are left out on
purpose (see "What a restore does not touch").
Version `0x04` (from v1.39.3) adds two things. The key id says which off-host
key an object needs, so a reader holding a retired key as well finds the right
one. The associated data is the object's own key in the bucket,
`YYYY-MM-DD/user-<id>.json.enc`: an object copied to another account's name or
another date does not decrypt, where before it opened as if it belonged there.
The restore script passes the key it downloaded, so nothing changes for an
operator.
The JSON import in Settings → Export & import is not a restore. It adds
measurements and mood entries, up to 16 MB and 10,000 entries per list.
Four versions exist in the wild and all four restore. `0x01` encrypted the
JSON directly and `0x02` gzipped it first; both carry the tag in FRONT of the
ciphertext, which is what made them impossible to write a piece at a time —
GCM only produces the tag once the last block is in, so a leading tag means the
whole object has to exist before its first byte can be sent. `0x03` moves the tag to the end and changes nothing else: it still
covers every ciphertext byte, and the reader still verifies it before returning
a single byte of plaintext. Objects already in your bucket stay readable, and
the restore script needs no flag to tell them apart.
## The deletion grace window
## Required env vars
`ACCOUNT_DELETION_GRACE_DAYS` (14 by default, at most 30) sets how long an
erasure waits:
| Var | Required | Notes |
| --------------------------------- | -------- | ----------------------------------------------------------------------------------- |
| `BACKUP_ENCRYPTION_KEY` | yes | 64 hex chars or 32-byte base64. **Different from `ENCRYPTION_KEY`.** |
| `BACKUP_S3_ENDPOINT` | yes | e.g. `https://<account>.r2.cloudflarestorage.com` |
| `BACKUP_S3_BUCKET` | yes | |
| `BACKUP_S3_ACCESS_KEY` | yes | |
| `BACKUP_S3_SECRET_KEY` | yes | |
| `BACKUP_S3_REGION` | no | defaults to `auto` (Cloudflare R2) |
| `BACKUP_RETENTION_DAYS` | no | not read by the app; the number for your bucket's lifecycle rule |
| `BACKUP_ENCRYPTION_PREVIOUS_KEYS` | no | retired off-host keys, comma-separated, read only (see "Rotating the off-host key") |
- **Deleting an account**, or a guardian deleting a **managed profile**, locks
the account at once. Every session, refresh token, API token, connector and
share link is revoked, sign-in is refused with the date the account is
erased, and nothing runs for the account any more: no reminders, syncs,
copies, scores or AI work. The record, its stored copies and its off-host
objects stay as they are. **Admin → Users** shows the account with its
erasure date and an **Undo deletion** button. When the window ends, the
hourly `account-deletion-purge` job erases the account and asks the bucket to
delete its off-host objects.
- **Delete all data** first stores the whole record as a copy of its own
(shown as "Before all data was deleted" in Admin → Backups), then empties the
record. An admin can restore that copy until the window ends. The off-host
objects from before the wipe are kept until then too.
## Bucket permissions
Database dumps are not purged per account: a deleted account stays in them
until they age out (`DB_BACKUP_KEEP` nights on the server, and the bucket's
lifecycle rule for `database/`). The deletion dialogs say so.
The worker needs `PutObject`, `GetObject`, `ListBucket`, `DeleteObject` and
`AbortMultipartUpload`. On Cloudflare R2 the **Object Read & Write** token
covers all of them.
`0` erases immediately, as releases before 1.46 did. The Data & Privacy page
and the deletion dialogs state the configured number.
`DeleteObject` is needed because the app deletes objects in two cases, and
only those:
## Restoring an account
- **An account is deleted, or its data is wiped.** Deleting an account,
deleting a managed profile and "Delete all data" each write a deletion
request in the same transaction as the deletion. The `offhost-backup-purge` job lists the bucket, deletes every
`YYYY-MM-DD/user-<id>.json.enc` of that account and removes the request once
nothing of the account is left. It runs right after the deletion and again
every night at 03:40. A copy the nightly upload was writing while the account
was deleted is removed by the upload itself.
- **The connection test** deletes its own `_healthcheck/<ts>.bin`.
In **Admin → Backups**, every stored copy has **Restore**. The dialog first
shows what the copy holds, then asks you to type `RESTORE`. The restore runs
in the background and replaces the account's data tables: every row the copy
holds comes back under its original id, and every row the account gained
after the copy was taken is deleted. If the current state is worth keeping,
press **Backup now** first.
The admin wipe of every account deletes nothing in the bucket. It is you
clearing your own host, the bucket is yours, and its copies are the way back
from a wipe pressed by mistake. If you are handing the host on, empty the
bucket yourself.
_Also restore instance settings_ writes the registration, MFA, locale, module,
notification and AI settings from the copy back for the whole server. Leave it
clear unless those settings themselves need to go back.
Earlier versions of this page said the worker never deletes an object. That
was the design until v1.39.3, and it meant a deleted account's copies stayed
in the bucket for as long as the lifecycle rule kept them, or forever without
one. If the bucket refuses the delete (a credential without `DeleteObject`),
the request stays, the job retries it every night, and **Admin → Backups →
Off-host** shows how many deletions are outstanding and the bucket's answer.
`AbortMultipartUpload` only reaches an upload the same worker started and is
what clears the parts of a run that failed halfway. Without it, a failed
upload leaves parts that are billed and do not show in a bucket listing.
## Bucket lifecycle (required for retention)
The app does not expire objects by age, and nothing in the app reads
`BACKUP_RETENTION_DAYS`. Expiry is the storage provider's lifecycle rule, set
to match the number you wrote there. Without one, every nightly copy of every
account is kept forever. **Admin → Backups → Off-host** reads the bucket's
lifecycle configuration and says so when there is none, or when the credential
may not read it (grant `GetLifecycleConfiguration` to let it check):
```
Filter: "" (all objects)
Action: Expire after 30 days
```
For Cloudflare R2 add this from the bucket's **Settings → Lifecycle**.
## Smoke test
After deploying, hit `POST /api/admin/backup/test` (admin-only). It
performs a 1-byte PUT + GET round-trip and returns:
```json
{
"data": {
"endpoint": "https://...r2.cloudflarestorage.com",
"bucket": "healthlog-backups",
"region": "auto",
"putLatencyMs": 142,
"getLatencyMs": 38,
"ok": true
},
"error": null
}
```
The credentials are never returned.
## Restore
Pick a key (e.g. `2026-05-08/user-clx123.json.enc`) from the bucket
and run the restore script with the same backup credentials and
encryption key the backup was written under — a freshly generated
`BACKUP_ENCRYPTION_KEY` cannot decrypt any existing object:
To restore from an **off-host object**, fetch and decrypt it from a source
checkout with the backup variables of the server that wrote it:
```bash
BACKUP_S3_ENDPOINT=https://...r2.cloudflarestorage.com \
BACKUP_S3_BUCKET=healthlog-backups \
BACKUP_S3_ACCESS_KEY=... \
BACKUP_S3_SECRET_KEY=... \
BACKUP_S3_REGION=auto \
BACKUP_ENCRYPTION_KEY=<the key the backup was written under> \
pnpm dlx tsx scripts/restore-backup.ts \
2026-05-08/user-clx123.json.enc \
/tmp/restored.json
BACKUP_S3_ENDPOINT=... BACKUP_S3_BUCKET=... \
BACKUP_S3_ACCESS_KEY=... BACKUP_S3_SECRET_KEY=... \
BACKUP_ENCRYPTION_KEY=<the key the object was written under> \
pnpm dlx tsx scripts/restore-backup.ts 2026-05-08/user-<id>.json.enc /tmp/restored.json
```
Run this command from a source checkout with the production backup variables
exported. The script imports the full application dependency graph, which the
minimal production image does not expose as an operator scripting environment.
Then upload `/tmp/restored.json` in Admin → Backups (up to 512 MB after
compression) and restore it from its row. The account `<id>` must exist on this
server.
The script downloads the object, decrypts it, and writes the JSON dump
to disk. Importing the JSON back into a HealthLog instance is left to
the operator (use `prisma db seed` or a custom script).
Every restore, preview and upload checks first that this server holds every
application key the copy's content was written under, and refuses with the key
id named if not. Nothing is changed by a refused restore.
Restoring is not streamed and does not need to be. It holds the whole document,
because the next thing anyone does with a backup is parse it as one JSON
object, and handing back plaintext the auth tag has not yet covered would trade
the authentication for memory. It runs on your machine rather than in the
container, so give it room: a record of several hundred thousand measurements
decompresses to a few hundred megabytes, and
`NODE_OPTIONS=--max-old-space-size=2048` in front of the command is enough for
a 445 000-measurement account.
### What a restore does not touch
### Restoring from the backups console
An admin can restore a stored snapshot from the browser instead of the shell.
`/admin/backups` lists one row per account and per backup type: **Backup now**
enqueues the `data-backup` pass for every account on the instance, **Download**
decrypts one snapshot and hands back the JSON, and **Restore** opens a dialog
that first shows what the file carries and then requires the word `RESTORE` to
be typed before it will run. The restore targets the account the snapshot was
taken for, never the admin running it, and it refuses a file whose declared
owner does not match the stored row. Instance-wide settings ride along only
when you tick _Also restore instance settings_ in the dialog; leave it clear to
restore the account alone.
A stored copy this instance can no longer open — the key that wrote it dropped
from `ENCRYPTION_KEYS` one rotation too early, or bytes that are not the ones
written — is refused by all three buttons with `422` and
`meta.errorCode = backup.payload.undecryptable`, before anything is read back.
### What a backup deliberately does not carry
Every credential-shaped row is left out, and this is not an oversight to fix:
API tokens, trusted devices, step-up elevations, known devices, clinician share
links, and the account grants behind shared record access. Restoring data is
rolling a record back to a known state. Restoring an authorization is different
in kind, because a grant the owner revoked on Tuesday would come back alive out
of Monday's file with nobody deciding it and neither person told.
links and account grants are not in any copy. A restore on this server leaves
them as they are, so shared access carries on. The reasoning per model is in
`src/lib/export/backup-plan.ts`.
What this means in practice depends on where you restore to.
## Off-host copies
**Onto the same instance.** Nothing changes. The restore replaces the account's
data tables and does not touch grants, tokens or devices, so shared access
carries on exactly as it was. Somebody who had read access before the restore
still has it afterwards, now looking at the restored data.
| Variable | Required | Notes |
| --------------------------------- | -------- | --------------------------------------------------------------------- |
| `BACKUP_ENCRYPTION_KEY` | yes | 64 hex chars or 32-byte base64; not the same as `ENCRYPTION_KEY` |
| `BACKUP_S3_ENDPOINT` | yes | e.g. `https://<account>.r2.cloudflarestorage.com` |
| `BACKUP_S3_BUCKET` | yes | |
| `BACKUP_S3_ACCESS_KEY` | yes | |
| `BACKUP_S3_SECRET_KEY` | yes | |
| `BACKUP_S3_REGION` | no | defaults to `auto` (Cloudflare R2) |
| `BACKUP_RETENTION_DAYS` | no | not read by the app; the number to put in the bucket's lifecycle rule |
| `BACKUP_ENCRYPTION_PREVIOUS_KEYS` | no | retired off-host keys, comma-separated, used for reading only |
**Onto a fresh instance.** None of it comes with the file. Nobody has access to
anybody's record, every API token has to be reissued, every device re-trusted,
and both people have to invite and accept again before sharing works. That is
the fail-safe direction — access lost, never access resumed — and re-consenting
is the right amount of ceremony for handing someone your health record a second
time. Plan for it rather than discovering it.
Objects are named `YYYY-MM-DD/user-<id>.json.enc`: the record as JSON,
gzipped and sealed with AES-256-GCM under `BACKUP_ENCRYPTION_KEY`, with the
object's own name as associated data and the key's id in the header.
The full per-model reasoning lives in `src/lib/export/backup-plan.ts`, where
every excluded model carries a written verdict and a structural test refuses to
let a new model land without one.
The nightly database dump goes into the same bucket, as
`database/<host of APP_URL>/healthlog-<time>-daily.dump.enc` with its manifest
beside it, sealed the same way. The folder keeps the dumps of instances that
share a bucket apart. It is the copy that rebuilds a lost server
([disaster-recovery.md](disaster-recovery.md)).
## Container memory (the nightly off-host job)
**Permissions.** The worker needs `PutObject`, `GetObject`, `ListBucket`,
`DeleteObject` and `AbortMultipartUpload` (R2's Object Read & Write token covers
them). It deletes only two things: the objects of an account that was erased or
wiped, once the grace window has ended, and its own connection-test object. A
deletion the bucket refuses is retried every night and shown in Admin →
Backups → Off-host. The admin wipe of every account deletes nothing in the
bucket.
This is the part that bites, and it bit the nightly job a release after it bit
the weekly one. The job runs inside the app process, so V8's heap limit is the
app's heap limit. Node sizes it from the container's memory cap: 1 GB gives a
524 MB old-space limit, the compose default of 1536m (since 1.40.1) gives 792 MB. A long-lived Next.js server is already holding a large share
of that before the job starts.
**Retention.** The app never expires objects by age. Set a lifecycle rule on
the bucket that covers both the account objects and `database/` (for example,
expire all objects after 30 days); Admin → Backups says when there is none.
The uploader used to build the whole backup JSON as one string, gzipped
that whole string, ran a whole-buffer cipher pass over the result and handed
the finished buffer to a single `PutObject` — four full copies of the record
alive at once. On an account of 445 000 measurements the JSON alone is 242 MB,
and the first configured run took the container down seventeen seconds in with
`FATAL ERROR: Reached heap limit`. Because the job shares the app process, one
account's size restarted the instance for everybody on it.
**Checking it works.** Admin → Backups → Off-host shows, per account, when the
last object landed: fresh, due (one night missed), stale (two missed), never,
or no record yet. A night that uploaded nothing for anybody fails the job with
the bucket's own error. Accounts pending deletion are not uploaded and are not
listed. On the 1st of each month at 04:11, a drill downloads the newest object
of every account, decrypts it and checks its shape and keys. It restores
nothing.
It streams now. The JSON is produced a page at a time, gzip and the cipher
consume it as it arrives, and the object goes up as a multipart upload that
holds two 8 MB parts. What the process holds is fixed by that pipeline's shape
rather than by the size of the record going through it: measured on the same
445 000-measurement account under `--max-old-space-size=450`, the old path
died and the new one finished holding tens of megabytes, writing a 9.1 MB
object that restores to the identical record.
**Rotating the off-host key.** Put the new key in `BACKUP_ENCRYPTION_KEY`, move
the old one to `BACKUP_ENCRYPTION_PREVIOUS_KEYS`, restart. Remove the old key
once the lifecycle rule has expired every object written under it.
Up to 1.43.4 that held for the tables that are long in rows, not for the
ones that are heavy in bytes. The stored documents and the ECG traces were
still read whole, and a vault of 190 MB cost about five times that in heap:
the bytes, their base64, the section's JSON and the preview's copies of each
row. A single account with that many documents took a 524 MB process down on
every weekly run, while 1.25 million measurements beside them fitted easily.
Both writers now read the traces a page at a time and the documents one at a
time, each document's bytes in slices of 1.5 MB. Measured on that account
under `--max-old-space-size=524`, the peak went from 930 MB (a crash) to
209 MB. The restore had the same problem in the other direction: it kept
every document of the file in memory and peaked at 1.25 GB on the same copy.
It now leaves the stored bytes out of its first read and writes the
documents one at a time from a second one, and the copy restores every
document byte for byte inside the 524 MB heap.
## Keys a copy needs
One ceiling remains, and it is structural rather than a memory bound: a
multipart upload carries 10 000 parts, so 80 GB is the largest object one
account can produce. Past it the account's backup fails with a clear refusal,
is counted in the run's `offhost_backup_oversized` meta, and the pass carries
on with everybody else.
A copy carries the database's ciphertext as stored, so it needs the
application keys it was written under, also after a rotation. Keep a retired
`ENCRYPTION_KEYS` entry until no copy you may still restore needs it. **Admin →
Encryption → Keys the backups still need** lists them, the database dumps
included: a dump needs the key that was active when it was taken, for as long
as it is kept on this server or in the bucket. Off-host objects also need
`BACKUP_ENCRYPTION_KEY` (or the previous key that wrote them).
### Reading a failed run
## Size and memory
A nightly run that could not upload for **anybody** now fails the pg-boss job
instead of reporting success. This is the case wrong credentials, a missing
bucket and an unreachable endpoint all land in, and the target's own sentence
rides out as the failure cause — `The request signature we calculated does not
match the signature you provided`, `The specified bucket does not exist`,
`connect ECONNREFUSED`. Check `offhost_backup_uploaded` against
`offhost_backup_total_users`: before this change a run where every single
upload failed still read `ok: true`, so a bucket could stay empty while the
jobs page looked healthy.
Writing, reading and restoring a copy streams: the record goes through a page
at a time, documents one at a time, and a stored copy is kept as sealed pieces
of about a megabyte. Memory does not grow with the record. Give the app
container at least 1 GB.
A run where SOME account got a copy still succeeds, with the rest counted in
`offhost_backup_failed`. Failing the whole queue over one account's object
would re-upload everybody's on every retry.
`BACKUP_MAX_STORED_MB` (default 2048) caps one account's stored copy. An account
past it fails alone, keeps its previous copy, and is counted as
`records_oversized`; the message names the setting.
### A run the process died under
Both backup jobs carry a pg-boss heartbeat of five minutes. While a run is
alive the worker refreshes it; a run whose process died (a restart, or a
container killed for memory) stops refreshing, and pg-boss fails the job with
`job heartbeat timeout` within minutes instead of leaving it `active` until its
two- or four-hour expiry. The retries the queue already allows then pick it up.
Each pass also notes when it started and when it got past every account
(`backup_pass_attempts`). An account whose last attempt started and never
finished is the one the process died under, and the next run takes it last, so
one record too large for the container cannot keep every account after it from
a copy. **Admin console → Backups** shows when the run in progress started and
names such an account under both the weekly and the off-host card. Seeing the
same account there twice means it needs more memory than the container has.
### Which account has no copy
The counts above say how many accounts were uploaded, never which. An account
whose object has failed every night for a month reads as `99 uploaded, 1
failed` each time, which looks like weather.
**Admin console → Backups → Off-host copies** answers it per account: when this
host last put that account's encrypted copy in the bucket, how big it was, and
how that compares with the nightly schedule. **Fresh** is inside one run,
**due** means one run produced nothing for that account, **stale** means two
did not, **never** means a run walked the account and no object has ever
landed for it, and **no record yet** means no run has recorded the account at
all. On a host without `BACKUP_S3_*` the card says off-host backup is not
configured and stops there.
The ledger starts empty on the release that adds it, so on a host that has
been uploading nightly for months every account reads **no record yet** until
the first run after the upgrade. That is the ledger having no history, not the
bucket being empty — which is why it is a separate word from **never**, and
why the row says so rather than claiming nothing has ever reached the bucket.
Each verdict carries six hours of slack on top of the schedule's own 24, and
that slack is load-bearing on two nights. The cron runs at 02:30 Europe/Berlin,
so on the DST fall-back Sunday two consecutive runs are 25 hours apart; and the
timestamp is when _that account's_ object landed, so on a cohort walked one
account at a time an account reached later tonight than last night drifts by
hours. Without the slack a healthy host would paint its whole cohort **due**
once a year.
The verdicts come from a ledger the worker writes when the object lands
(`offhost_backup_state`), not from a listing of the bucket. Keep the worker's
grant as documented — PutObject, GetObject, AbortMultipartUpload — and the card
still tells the truth. A row that goes stale while the run reports success is
that account's object being refused, and the reason is in the run's
`offhost_backup_failures` meta.
## The weekly in-database backup (`data-backup`)
Separate from the off-host job above, and easy to confuse with it. A second
pg-boss job writes one `WEEKLY_AUTO` copy per user into the database, the same
JSON document, gzipped and then encrypted under `ENCRYPTION_KEY` /
`ENCRYPTION_KEYS`, staying inside the instance. It is what
`/api/admin/backups/<id>/restore` reads, and an uploaded backup file is stored
the same way.
### How a copy is stored
From v1.39.2 a copy is kept as ordered pieces of about a megabyte in
`data_backup_chunks`, with the list of pieces on its `data_backups` row. The
writer produces the JSON a page at a time, gzips it as it goes and seals each
megabyte of gzip output on its own with AES-256-GCM. Reading it back (restore,
preview, download) takes the pieces one at a time. Nothing on either path holds
the whole copy, so the size of a copy has nothing to do with the app's memory.
Measured on an account of 2.6 million readings in a 1 GB container,
with the web server and the worker in one process: the backup took 87 seconds
and stored 97 MB in 96 pieces, the preview 20 seconds, the download of the
1.4 GB file 25 seconds, and the restore 264 seconds, which gave back every
reading exactly. The container peaked at 567 MB and did not restart. What the
backup, preview and download hold on top of the running app stays at a few tens
of megabytes and does not grow with the account. A 512 MB container is too
small for the app itself: its heap limit is 259 MB and a backup of any account,
even one with 130 000 readings, runs it out of heap.
Each piece carries, inside its encryption, which copy it belongs to, its
position, and whether it is the last. Before a restore deletes anything, every
piece is checked: a piece that is missing, moved, altered, taken from another
copy, or a copy that stops short of its last piece is refused with
`backup.payload.undecryptable`, and the account is left as it was.
The write is one transaction. Until the new copy is complete the previous one
stays in place and readable; a run that fails halfway leaves it untouched. A
second backup of the same account started while one is running (a manual run
during the weekly one, say) waits for the first to finish and then replaces
its copy; neither leaves a partial copy behind.
While a restore of an account's copy is queued or running, the weekly and
manual backup leave that account's copy alone and back up everyone else. The
run's `users_skipped_restoring` meta counts such accounts; the next run
replaces the copy as usual. If a copy is replaced anyway while it is being
read (a download or preview in progress when the weekly run finishes), the
reader answers `409` with `backup_changed` and "The backup was replaced by a
newer one while it was being read", not the undecryptable error, and a restore
rolls back with nothing changed. Start it again to use the new copy. A
download already sending when this happens stops partway; download again.
Copies written before v1.39.2 are a single value in `data_backups.data` and
still restore, preview and download as they did. The next weekly run replaces
the weekly copy in the new form, and a key rotation (below) converts every
remaining one, including uploaded copies. Until then those older copies are
read as one value, as before, which needs the memory they needed when they were
written.
Migration 0352 cannot be undone by going back to v1.39.1. That release reads
only the single value, so it finds a copy in pieces empty, and if it writes a
weekly copy into a row that has pieces, the row holds both forms. v1.39.2 then
refuses to read that row, because nothing says which of the two is current
("This backup holds both a single stored value and pieces ..."); the next
weekly backup replaces it. If you must go back, restore the database backup
you took before upgrading.
### The size limit
The one limit left is on storage: a copy of one account may take at most
`BACKUP_MAX_STORED_MB` megabytes (default 2048). It exists so a runaway copy
fails with a message instead of filling the database volume, which holds the
old and the new copy side by side while a backup runs. An account past it fails
for that account alone, is counted in the run's `users_failed` and
`records_oversized` meta, and keeps its previous copy; the pass carries on with
everybody else. The message names the setting. Raise it in `.env`, recreate the
app container, and make sure the database volume has room for two copies of
that size.
Before v1.39.2 the limit was a fifth of the app's heap, 105 MB in a 1 GB
container, because the copy had to pass through the app as one value. An
account with 1.75 million readings was past it, and the only way round was more
memory. That is no longer the case: `records_oversized` means the storage limit,
never memory.
The writer deliberately reads no heap gauge. A version that did shipped in
v1.38.6 and compared the whole process's live usage, garbage included, against
80 % of the limit. A Next.js server that has been up for a week sits at 400 MB
of largely collectable heap, so the weekly pass aborted every account on the
first chunk, including one whose whole stored copy is 1.2 MB, and reported
success while doing it. If you are on v1.38.6, a `data-backup` run that finishes
in seconds having backed up nothing is that defect and not your record.
### Key rotation
Rotation reaches a stored backup's envelope and nothing inside it. The
content of a disaster-recovery backup is the database's ciphertext, carried
as it is stored, and a restore writes it back verbatim: a note, a document or
a coach message inside a copy taken before a rotation still needs the key the
rotation retired. The same holds for every copy in the off-host bucket and for
every file an operator has downloaded. So the rule is: **keep a retired
`ENCRYPTION_KEYS` entry until no backup you may still restore needs it.**
**Admin → Encryption → Keys the backups still need** lists, per key id, the
stored copies that need it with the oldest date, and when the last off-host
copy needing it expires under the bucket's lifecycle rule. Copies written
before v1.39.3 did not record their keys and are listed as such.
Every restore, the restore preview, the admin upload and the monthly drill
check this before anything is changed: a file whose content needs a key this
server does not have (or an id whose key material differs) is refused with the
key id named, and the account is left as it was.
Re-encrypting the content of stored backups during rotation was considered and
not done: it would mean decrypting, parsing, re-encrypting and re-writing every
copy in full, and it still could not reach the off-host copies or downloaded
files, so the rule above would be needed regardless.
`scripts/rotate-encryption-key.ts` and the rotation in the admin console
re-seal every piece under the active key, in small batches, without reading
the backup inside. The link between a piece, its copy and its position is
inside the encryption, so it comes through rotation unchanged.
A copy still in the older single-value form (any of them, including the
`~hlgcm1.` form v1.38.6 to v1.39.1 wrote) is not re-sealed in place, which would need the
whole copy in memory several times over. Rotation converts it into pieces
under the active key instead, one copy at a time, reading the stored value a
few megabytes at a time; the copy keeps its date. The conversion of a copy is
one transaction, so a copy that fails its check is left exactly as it was and
counted as an error. Rotation up to v1.39.1 counted every `~hlgcm1.` copy
as an error; run it again after updating before retiring the old key.
### What a restore replaces
A restore replaces the account's data tables; it does not merge into them.
Every row the file carries is written back under its original id, and every row
the account gained after the snapshot was taken — readings, doses, mood
entries, documents — is deleted with the rest of the class before the file is
read back. Restoring Monday's copy on Wednesday therefore costs the account
everything it recorded on Tuesday. If the current state is worth keeping, take
a fresh snapshot from the backups console before restoring the old one.
## Rotating the off-host key
`BACKUP_ENCRYPTION_KEY` is the key new objects are written under. To rotate
it, set the new key there and move the old one into
`BACKUP_ENCRYPTION_PREVIOUS_KEYS` (comma-separated if there are several), then
restart. New objects are written under the new key; older objects stay
readable because every version-4 object names its key and older objects are
tried against each key in turn. Remove the old key once the lifecycle rule has
retired every object written under it, that is `BACKUP_RETENTION_DAYS` after
the restart.
## Monthly restore drill (automatic)
Since v1.16.4 a pg-boss job (`data-restore-drill`, cron `11 4 1 * *` —
04:11 on the 1st of each month) exercises the read path end-to-end:
fetch every backup object of the most recent date from the bucket (one per
account), decrypt each under the current `BACKUP_ENCRYPTION_KEY`, JSON-parse
it, and sanity-check the payload shape. It performs **no database restore** —
it validates the artefacts, not the import path. Each account is checked and
reported on its own: one object that fails is named, and the others are still
read.
Outcomes:
- **Success** — record counts, object age, and sizes land in the
wide-event meta (`job.restore_drill`).
- **Stale chain** — the newest object is older than 3 days: the nightly
uploader has stalled (or the lifecycle rule is too aggressive). The
drill pages via the worker error reporter (stderr + GlitchTip).
- **Failure** — empty bucket, fetch error, decryption failure (wrong or
rotated key), malformed JSON: pages the same way, once per failing object,
with the object key and how many accounts were checked. A decryption
failure right after a `BACKUP_ENCRYPTION_KEY` change means the new
key cannot read the existing objects: put the old key into
`BACKUP_ENCRYPTION_PREVIOUS_KEYS` (see "Rotating the off-host key").
- **Content under a missing application key** — the object opens, but its
content was written under an `ENCRYPTION_KEYS` entry this server no longer
has, or under an id whose key material changed. The drill decrypts the
shortest value of each section under every key id it finds, and pages naming
the key when none of them opens. A restore of that object
would write back rows nobody can open; put the key back before restoring.
- **Not configured** — deployments without the `BACKUP_S3_*` vars skip
silently (wide-event warning only).
The drill lists the bucket to find the newest object, so it needs
`ListBucket` on top of the uploader's `PutObject`, `GetObject` and
`AbortMultipartUpload`. Cloudflare R2's **Object Read & Write** token covers
all four.
Both backup passes note which account they are on. An account whose last
attempt never finished (the process died under it) is taken last on the next
run and named in Admin → Backups, so one record too large for the container
cannot keep everyone else from a copy.
+193 -39
View File
@@ -13,11 +13,13 @@ Migrations land **before** the new app container takes traffic. The
canonical command sequence on a single-instance host:
```sh
# 0. Dump the database first. A migration is not undone by going back to
# the previous image; the dump is the way back. See "Updates" in
# docs/self-hosting/getting-started.md.
docker compose exec -T db pg_dump -U healthlog healthlog \
| gzip > healthlog-before-update-$(date +%F).sql.gz
# 0. Nothing to do by hand for the way back: when the new image brings
# migrations, its entrypoint first writes
# healthlog-<time>-pre-migration.dump into DB_BACKUP_DIR (the `backups`
# volume) and keeps the last two. A migration is not undone by going back
# to the previous image; that dump is the way back
# (docs/ops/disaster-recovery.md, "Going back to before a migration").
# Copy it off the host if the host itself is what you are worried about.
# 1. Pull the new image. If you pin a version with HEALTHLOG_IMAGE_REF,
# set it to the new one first.
@@ -33,6 +35,15 @@ docker compose run --rm app sh -c 'exit 0'
docker compose up -d app
```
Step 2 takes the pre-migration dump too, in the one-off container, before it
migrates. When the backups directory is on its volume and that dump fails (a
full disk, most often), the entrypoint stops before migrating and says so;
free the space and run the step again, or set `DB_BACKUP_BEFORE_MIGRATE=false`
for one start without the dump. Without the `backups` volume the directory
is not writable, or it is the image's own and sits inside the container that
the update replaces; either way the log carries a warning, no dump is taken,
and the migrations run.
Use `docker compose run` rather than a plain `docker run` for step 2. A
plain `docker run` starts outside the stack's network, where `db` does not
resolve, and the entrypoint gives up after thirty seconds with "Database
@@ -109,6 +120,92 @@ that for you, and each boot retries). Anything else is a session you can
judge: a stuck client may be ended with `SELECT pg_terminate_backend(<pid>)`.
You never need to run `migrate resolve` by hand for this case.
### A concurrent index build that failed
A migration that builds an index `CONCURRENTLY` (0477 is one) and fails
half-way, on a constraint violation, a cancel or a killed container, leaves an
INVALID index behind and its attempt on record as failed. Once you have mended
the cause and run `prisma migrate resolve --rolled-back <migration>`, the next
boot drops that INVALID index before it migrates, logs
`dropped the invalid index …`, and the re-run builds it again. Without that
step the re-run would find the index by name and skip it.
A build that is still running looks the same, INVALID with its migration not
yet finished. The boot therefore leaves every index alone while another
instance is migrating (it checks Prisma's migration lock first) and never
drops an index Postgres reports as being built, so a second container that
starts during the first one's migration cannot cancel it.
An INVALID index whose migration is already recorded applied is not touched at
boot. The boot names it on stderr with `WARNING — index … is INVALID`; rebuild
it with `REINDEX INDEX CONCURRENTLY "<index>";` when the database is quiet.
### v1.46.0 notes
- **Postgres 18.** The compose file moves the bundled database from
Postgres 16 to 18. The first `docker compose up -d` with the new compose
file runs `db-upgrade`, which copies the database into a new
`postgres-data` volume, checks every table's row count, and only then lets
the app start; the old `pgdata` volume stays as it was, as the way back.
Pulling only the new image with the old compose file keeps 16, which this
release still runs on. Details, rollback and the steps for a database you
run yourself: [postgres-major-upgrade.md](postgres-major-upgrade.md).
- Migration `0485` drops `preview_features_json` from `users`, the same
pattern as `0430` in v1.44.0 below: do not run the previous image beside
the new schema. The sequence above stops the old container first; a
web/worker split stops both roles before the first new container starts.
- Migration `0487` drops four columns from `app_settings`
(`encryption_key_backup_confirmed_at`, `_key_id`, `_fingerprint` and
`_by_user_id`) after carrying the confirmation they held into the new
`recovery_kit_confirmations` table. The previous image reads those columns
on nearly every request that loads the instance settings and fails once
they are gone, so the same rule holds: never run it beside the new schema.
- The other migrations create tables (`0486`, `0487`), add columns
(`0480`, `0482`, `0483`, `0488`, `0489`) and build two indexes: `0477`
concurrently on `measurements`, `0488` without `CONCURRENTLY` on `users`,
which is small enough that the short lock does not matter. Three touch
rows: `0476` links API-written measurements to their token on accounts
that have exactly one measurement token, and `0478`/`0479` give PDFs whose
preview or text read failed one more attempt. On a prod-sized copy all of
them together finished in under a second.
- **Rolling back to v1.45 while accounts are pending deletion.** v1.46 keeps
a deleted account, a deleted managed profile and a data wipe's copy for a
grace window (`0488`). v1.45 knows none of that: an account pending
deletion can sign in again with its password and every background job runs
for it again, nothing ever erases it, a wipe's off-host purge held to the
end of its window runs at once, and v1.45's wipe deletes the copies taken
before earlier wipes. Before going back, list the accounts with
`SELECT id, username, deletion_purge_at FROM users WHERE deletion_requested_at IS NOT NULL;`
and either undo each deletion in the admin users list or let the purge
finish them first; after coming forward again, delete any that should
still go.
### v1.45.0 notes
A single-container install (`HEALTHLOG_PROCESS_TYPE=all`, the shipped
compose file) needs no action: the sequence above applies the one migration
and the job-queue upgrade on the first boot.
- Migration `0471` adds one nullable column to `healthkit_type_syncs`. It is
additive and rewrites no rows.
- The job queue library moves its own schema from version 45 to 46 (new
columns on its queue table, four indexes built concurrently afterwards).
Only a worker process runs that upgrade; a web-only process opens its
send-only connection without migrating and refuses a schema older than
its own (`pg-boss database requires migrations`), so it cannot queue
anything until the upgrade has run. A web/worker split therefore deploys
in this order: stop both roles, start the new **worker** and wait until its
startup log line carries `"reminder_worker": "started"`, then start the new
web containers.
The other order leaves the web containers without a queue (imports and
document reads answer `503 Background worker is not running`) until they
are restarted after the worker. An old web container left running beside
the upgraded schema loses its queue the next time it reconnects.
- The shipped compose file gives the app `stop_grace_period: 45s`, so a
redeploy lets running jobs finish instead of cutting them off after
Docker's default ten seconds. A forked or older compose file should add the
same line to the app and worker services.
### v1.44.0 notes
Migration `0430` drops the `insights_cached_text` column from `users`. A
@@ -160,10 +257,14 @@ roll back:
app version reads forward-compatible-ish shapes for nearly every
release pair shipped on the v1.4.x line.
3. If a column-drop migration ships in the rollback target, hand-roll
the column back via a fresh forward migration (`pnpm prisma migrate
dev --create-only --name vX_Y_Z_rollback_<col>`), edit the SQL,
commit, and re-deploy. Never `migrate resolve --rolled-back` against
production unless you've taken a fresh `pg_dump` first.
the column back via a fresh forward migration
(`pnpm prisma migrate dev --create-only --name vX_Y_Z_rollback_<col>`),
edit the SQL, commit, and re-deploy. Never `migrate resolve --rolled-back` against
production unless you've taken a fresh `pg_dump` first. The columns each
release drops are named in its notes above: for v1.46.0 that is
`users.preview_features_json` (`0485`) and the four
`app_settings.encryption_key_backup_confirmed_*` columns (`0487`); the
key backup confirmation then has to be given again on v1.45.
4. Rolling back from v1.40.2 or later to v1.40.1 or earlier: an account
that saved the Coach lookback "Last year" reads its whole Coach
preference blob as defaults on the older version, metric exclusions
@@ -171,13 +272,32 @@ dev --create-only --name vX_Y_Z_rollback_<col>`), edit the SQL,
pick another lookback before the rollback, or re-save their exclusions
after it.
When the data itself has to go back to before the update, restore the
pre-migration dump with the previous image; the steps are in
[`disaster-recovery.md`](disaster-recovery.md#going-back-to-before-a-migration).
## Catalog templates
A new required environment variable, or a change to the compose shape, needs a
matching pull request to the TrueNAS apps catalog in the same release cycle
(and an update to `docs/self-hosting/unraid/healthlog.xml` and
`docs/self-hosting/portainer/templates.json`). See
`docs/self-hosting/truenas.md` for what the catalog template carries.
The TrueNAS catalog bot follows new image tags on its own, so a routine
release needs nothing there. These changes do need a matching pull request to
`truenas/apps` (`ix-dev/community/healthlog/`) in the same release cycle, and
an update to `docs/self-hosting/unraid/healthlog.xml` and
`docs/self-hosting/portainer/templates.json`:
- a new required environment variable, or a new format or minimum for an
existing one (the form validates the secrets);
- a changed listening port, health check path or health semantics, or start
time beyond the template's five-minute start period;
- a required PostgreSQL major version, or an extension the stock `postgres`
image does not ship;
- any other change to the compose shape: a new container, a volume the app
must write to, a different user.
The catalog app sets `HEALTHLOG_PLATFORM=truenas`, `PORT`, `HOSTNAME`,
`DATABASE_URL`, `ENCRYPTION_KEY`, `API_TOKEN_HMAC_KEY`, `APP_URL`,
`NEXT_PUBLIC_APP_URL` and `SESSION_COOKIE_SECURE`; everything else reaches it
through the form's additional environment variables. See
`docs/self-hosting/truenas.md` for the operator side.
## Verify the served version
@@ -189,13 +309,31 @@ always confirm the served build after a deploy:
```sh
# Asserts prod + demo serve 1.9.0, prints each target's buildSha,
# exits non-zero on a mismatch or timeout after a bounded retry loop.
pnpm dlx tsx scripts/assert-deploy.ts 1.9.0
HEALTHLOG_PROD_URL=https://healthlog.example.com \
pnpm dlx tsx scripts/assert-deploy.ts 1.9.0
```
Pass `--only=prod` / `--only=demo` to check a single target, or
`HEALTHLOG_PROD_URL` is the base URL of your instance; it has no default and
is required unless you pass `--only=demo`. Pass `--only=prod` /
`--only=demo` to check a single target, or
`--attempts=N --interval=ms` to widen the retry window while a slow
pull settles.
The worker loads each job's handler module on that job's first run, so a
module the image cannot load does not stop the worker from starting. Once
it has bound its queues, the worker loads every handler a single time in
the background and reports each one that fails, normally within a minute
of start. Each failure is a `job.run.failed` event at `error` with
`reason: "handler_load_failed"` and the queue's name, and stderr also gets
`[pg-boss] [registry] preloaded N/M job handlers; failed to load: <queues>`.
If either shows up after a deploy, the image is broken for those queues,
and their jobs will fail when they run. Roll back (see Rollback above)
instead of waiting for the retries to run out:
```sh
docker compose logs --since 5m app 2>&1 | grep -E 'handler_load_failed|failed to load:'
```
## Walk the synthetic journey
The version check proves the instance serves the right build. It does not
@@ -410,29 +548,45 @@ low-friction default.
## Deploy-status webhook: replay protection
`POST /api/internal/deploy-webhook` authenticates via the static
`X-Deploy-Webhook-Secret` header. Since v1.16.4 it additionally
enforces a freshness window: a request carrying an
`X-Deploy-Webhook-Timestamp` header (unix seconds, unix milliseconds,
or ISO-8601) is rejected with `401 {"status":"stale"}` when the
timestamp lies more than **5 minutes** from server time — so a captured
request cannot be replayed later to re-page admins or forge deploy
outcomes in the audit log.
`POST /api/internal/deploy-webhook` accepts two kinds of request.
Two modes:
- **Secret header.** `X-Deploy-Webhook-Secret` carries the shared secret.
This is what Coolify's stock notification sender can do. It does not
protect against a replay: anyone who captures one request holds the
secret and can send any request they like. An `X-Deploy-Webhook-Timestamp`
beside it is still checked (a stale or unparseable one is refused with
`401 {"status":"stale"}`), but it is covered by nothing, so treat it as a
staleness hint. Versions before 1.46 described it as replay protection.
- **Signature.** `X-Deploy-Webhook-Signature: sha256=<hex>` is the
HMAC-SHA256, under `DEPLOY_WEBHOOK_SECRET`, of the
`X-Deploy-Webhook-Timestamp` value, a dot, and the exact request body. The
timestamp (unix seconds, unix milliseconds or ISO-8601) must lie within
**5 minutes** of server time. A signed request sends no secret, and a
captured one is useless once the window has passed.
- **Default (tolerant).** Requests _without_ the header pass — Coolify's
stock notification sender does not attach one. A header that is
present but stale or unparseable is always rejected.
- **Strict.** Set `DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP=true` in the app
environment to make the header mandatory, closing the replay window
completely. Use this when the sender (or a reverse proxy in front of
the app) can attach the timestamp, e.g.:
Two settings tighten this:
```sh
curl -X POST https://<host>/api/internal/deploy-webhook \
-H "X-Deploy-Webhook-Secret: $SECRET" \
-H "X-Deploy-Webhook-Timestamp: $(date +%s)" \
-H "Content-Type: application/json" \
-d '{"status":"success"}'
```
- `DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP=true` requires a fresh
`X-Deploy-Webhook-Timestamp` on a request that uses the secret header; one
without it is refused with `401 {"status":"stale"}`. This is what the
setting has always meant. It keeps an old capture out, but not someone who
saw the secret and sends a new request with a new timestamp.
- `DEPLOY_WEBHOOK_REQUIRE_SIGNATURE=true` accepts signed requests only; the
secret header alone is refused with 401. This is the one that stops a
replay. Use it when the sender, or a small relay in front of the app, can
sign:
```sh
TS=$(date +%s)
BODY='{"status":"success"}'
SIG=$(printf '%s.%s' "$TS" "$BODY" \
| openssl dgst -sha256 -hmac "$SECRET" | sed 's/^.*= //')
curl -X POST https://<host>/api/internal/deploy-webhook \
-H "X-Deploy-Webhook-Timestamp: $TS" \
-H "X-Deploy-Webhook-Signature: sha256=$SIG" \
-H "Content-Type: application/json" \
-d "$BODY"
```
Both may be set; a signed request satisfies both. An operator who set the
timestamp flag before 1.46 keeps exactly the behaviour they had.
+231
View File
@@ -0,0 +1,231 @@
# Disaster recovery
The copy that rebuilds a lost server is the database dump. This page says
what is dumped and where it goes, which keys a restore needs, how to restore
onto a new server, and how the procedure is tested.
## What is backed up, and where
| Copy | When | Where | Kept |
| ----------------------- | --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- | ------------------------------- |
| Nightly dump | 01:30 Europe/Berlin, the zone every schedule runs in (`DB_BACKUP_SCHEDULE`) | `DB_BACKUP_DIR`, the `backups` volume of the bundled compose file | the newest 7 (`DB_BACKUP_KEEP`) |
| Pre-migration dump | on start, when the new image brings migrations, before them | the same directory | the newest 2 |
| Off-host copy of a dump | right after each nightly dump, if the bucket is configured | your bucket, under `database/<host of APP_URL>/`, sealed with `BACKUP_ENCRYPTION_KEY` | your bucket's lifecycle rule |
A dump is `pg_dump --format=custom` of the whole database: every account,
sign-ins, passkeys, sharing grants and managed profiles, documents, the job
queue and the instance settings. It is two files:
- `healthlog-<UTC time>-<daily|pre-migration>.dump`, the archive;
- `healthlog-<UTC time>-<daily|pre-migration>.manifest.json` beside it: the
app version and build, the newest applied migration, the Postgres version,
the archive's size and SHA-256, the row count of every table, and the
fingerprints (never the values) of the keys the database needs.
A dump counts as good only when both files exist under their final names. The
archive is written as `.partial`, read back with `pg_restore --list`, and
renamed after its manifest; a run that fails leaves nothing that looks good.
The admin backups page shows the newest good dump, where it is, and the
newest failed run. The start log says on every boot where dumps go, or that
none can be kept.
Local dumps are not encrypted. They sit beside the database volume, in the
same trust zone: whoever can read one volume can read the other. The columns
HealthLog encrypts stay encrypted inside the dump, under `ENCRYPTION_KEYS`.
Keep a copy of the dumps off the host; the off-host bucket does that for you,
and it seals each copy with `BACKUP_ENCRYPTION_KEY` in the envelope the
per-account copies use.
A dump holds every account that existed when it was taken, including one
deleted since. Deleted accounts leave the dumps as they age out: after
`DB_BACKUP_KEEP` nights on the server, and after your lifecycle rule in the
bucket. A pre-migration dump goes once it is older than every nightly dump
kept, except the one taken before the latest update: that one is the way back
from it and stays until the next update writes a newer one. On an install
that rarely updates, delete it by hand once you no longer want the way back,
or state that exception with the retention period. Give that rule a prefix that covers `database/` too; the admin
backups page reads the bucket's rules and says when none does, and the
recovery readiness card calls it attention and tells the admins once. This is the
retention period to state where you tell people that deleting their account
deletes their data.
A dump is kept on the server only when `DB_BACKUP_DIR` survives the container
being replaced: a mounted volume or bind mount, or any writable directory when
HealthLog does not run in a container. The image creates the default
directory, so an install that runs it without the `backups` volume can write
there, but the directory sits in the container's own layer and the next update
throws it away. HealthLog reads the container's mount table to tell the two
apart and treats such a directory, or one on a tmpfs, like one it cannot
write: the nightly dump is uploaded off-host and nothing is kept on the
server, and no dump is taken before migrations; with no bucket either, no
dump is taken at all. The start log, the admin backups page and the recovery
readiness card say so.
The per-account copies (the weekly in-database copy, the nightly per-account
objects) roll one account back on the same server. They cannot rebuild a
server; see [`backup-restore.md`](backup-restore.md).
## The keys a restore needs
| Key | Without it |
| ------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
| `ENCRYPTION_KEYS` + `ENCRYPTION_ACTIVE_KEY_ID`, or `ENCRYPTION_KEY` | Nothing encrypted can be read. The restore refuses when the active key is missing or different. Keep every retired id still in the map. |
| `API_TOKEN_HMAC_KEY` | No data is lost, but every session, app token and share link stops working; people sign in again and re-create tokens. |
| `BACKUP_ENCRYPTION_KEY` (and `BACKUP_ENCRYPTION_PREVIOUS_KEYS`) | Off-host copies do not open. |
| `POSTGRES_PASSWORD` | Not needed: a new server can use a new one. |
The restore compares the keys in its environment with the fingerprints in the
manifest before it writes anything, and names each key that is missing or
different.
## Restore onto a new server
1. Install Docker and take the `docker-compose.yml` of the version that wrote
the dump or a newer one. The manifest's `appVersion` names it; to run that
exact version set `HEALTHLOG_IMAGE_REF=:v<version>` in `.env`.
2. Write `.env` with the keys from your records (see above) and a new
`POSTGRES_PASSWORD`.
3. Do not run `docker compose up` yet. The app would create an empty schema,
and the restore only writes into a database with no tables. Start only the
database:
```bash
docker compose up -d db
```
4. Restore. When the host is lost, its local dumps went with it, and the
copy is the one in your bucket: put the `BACKUP_S3_*` variables and
`BACKUP_ENCRYPTION_KEY` (with any `BACKUP_ENCRYPTION_PREVIOUS_KEYS`) in
`.env` as on the old server, then
```bash
docker compose run --rm db-restore list --from-bucket
docker compose run --rm db-restore latest --from-bucket
```
`latest --from-bucket` downloads the newest dump of this instance whose
manifest is in the bucket too, opens both with `BACKUP_ENCRYPTION_KEY`
and restores. Name a dump instead of `latest` to take an older one.
Each instance uploads under its own folder, `database/<host of APP_URL>/`,
so set `APP_URL` as on the old server. A bucket shared with another
instance holds its dumps too: `latest` refuses rather than take one of
those, and `list --from-bucket` marks them. To restore one on purpose
(after moving to a new domain, the old one), add
`--instance=<old host>`, or name the dump by its full key,
`database/<host>/healthlog-….dump.enc`.
The opened copy is a whole database, decrypted, in a scratch directory
beside the backups. The restore removes it when it ends, on Ctrl-C and
`docker compose stop` too. One left by a restore that was killed outright
is removed when the next restore starts, and by the nightly retention.
With the files in hand instead (copied off the host, or downloaded from
the bucket with your own tools: both `<name>.dump.enc` and
`<name>.manifest.json.enc`, or a `.dump` with its `.manifest.json`), put
them in a directory such as `./restore`, make them readable for the
container (`chmod a+r restore/*`) and name the dump:
```bash
docker compose run --rm -v "$PWD/restore:/restore:ro" db-restore list /restore
docker compose run --rm -v "$PWD/restore:/restore:ro" db-restore \
/restore/healthlog-20261024T233000Z-daily.dump
```
Either way it checks the archive against the manifest's SHA-256, the keys
against the manifest's fingerprints, and that the database is empty. Then
it restores in one transaction, gathers planner statistics and counts
every table against the manifest. A count that differs empties the
database again and fails. Wait for the line that starts with `Restored`.
5. Start the stack and check it:
```bash
docker compose up -d
curl -s http://localhost:3000/api/health
```
Sign in. The app's own key check runs at start and refuses to serve on a
key that does not open the data.
When the dumps are still in the server's `backups` volume (the database
volume was lost, the backups volume was not), step 4 is
`docker compose run --rm db-restore latest`.
`--ignore-key-mismatch` restores although the active encryption key differs.
Use it only when that key is lost for good: everything encrypted stays
unreadable.
## Going back to before a migration
The entrypoint dumps the database before it applies migrations. To return to
that state (the previous image, the data as it was before the update):
```bash
docker compose stop app
# Keep the current state too, in case it is wanted later.
docker compose exec -T db pg_dump -U healthlog -Fc healthlog > healthlog-current.dump
docker compose exec db dropdb -U healthlog healthlog
docker compose exec db createdb -U healthlog healthlog
docker compose run --rm db-restore list
docker compose run --rm db-restore healthlog-<time>-pre-migration.dump
# Set HEALTHLOG_IMAGE_REF in .env to the version you ran before the update.
docker compose up -d
```
The dump before a migration is taken while the new image starts. If it fails
on a directory that keeps it (a full disk, most often), the start stops before
anything is migrated and the log names the switch: `DB_BACKUP_BEFORE_MIGRATE=false`
starts without it, once.
## Knowing the copies are there
Nobody opens the backups page until the day it matters, so the server tells
the admins instead:
- The **Recovery readiness** card, on the admin overview and on Admin,
Backups, judges every copy in one verdict each: the database dump (fresh,
failed, or kept nowhere that survives the container), the weekly account
copies, the off-host copies, the last restore drill, whether the bucket is
set up, whether its lifecycle rule ever removes the dumps under
`database/`, and the recovery kit.
- The **recovery kit** under Admin, Encryption lists every key a restore
needs (the table above), by id and fingerprint, and lets an admin check a
saved copy and confirm it. It is due again after any key change.
- An hourly check sends each admin a **Backup health** notification
(`BACKUP_HEALTH`) when a signal on the card turns to needs attention or at
risk, a reminder once a day while one stays at risk, and one more when it
recovers. Each admin chooses the channels for it under Notifications.
[Getting started](../self-hosting/getting-started.md#save-the-recovery-kit)
walks through saving the kit.
## How this is tested
`scripts/compose-dr-drill.sh` plays this page against real containers: it
builds the image, seeds several accounts (the demo account with documents
and encrypted notes, a Guardian with a managed profile), lets the scheduled
job take the dump and upload it to an S3-compatible bucket, destroys every
volume of the stack (the local dumps included), and restores on a new stack
given only the bucket and the keys
(`docker compose run --rm db-restore latest --from-bucket`). A second run
restores from the dump files instead (`DRILL_SOURCE=local`). Both then count
every table against the manifest and boot the app: health, sign-in, an
encrypted note read back, a document opened, the Guardian still seeing the
profile. It runs in CI nightly and on every change to the
compose file, the image or the dump and restore code
(`.github/workflows/compose-dr-drill.yml`), and it is checked to fail on a
wrong encryption key, a wrong off-host key, a dump that lost a table's rows
and a target that is not empty.
Test your own copies too: once, and after any change to how the server is
run, restore last night's dump on a spare machine with the steps above.
## Settings
| Variable | Default | What it does |
| -------------------------- | ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
| `DB_BACKUP_ENABLED` | on | `false` stops the nightly dump. |
| `DB_BACKUP_DIR` | `/var/lib/healthlog/backups` | Where dumps are written and read. Change it only together with the volume mount. |
| `DB_BACKUP_SCHEDULE` | `30 1 * * *` | Five-field cron, Europe/Berlin time like every HealthLog schedule. |
| `DB_BACKUP_KEEP` | `7` | Nightly dumps kept (at least 1). Up to two pre-migration dumps are kept besides, none older than the oldest nightly dump except the newest. |
| `DB_BACKUP_BEFORE_MIGRATE` | on | `false` starts an update without the dump before its migrations. |
+9 -4
View File
@@ -137,10 +137,15 @@ under `v1` (the synthetic id assigned to the existing `ENCRYPTION_KEY`).
> So before step 4, also check **Admin → Encryption → Keys the backups still
> need**. It lists, per key id, the stored copies that need it with the oldest
> date, and when the last off-host copy needing it expires under the bucket's
> lifecycle rule. Keep the old key in `ENCRYPTION_KEYS` until it is no longer
> listed there (the weekly copy replaces itself within a week; delete old
> uploaded copies you no longer want, and wait out the off-host retention), or
> accept that those copies cannot be restored. Copies written before v1.39.3
> lifecycle rule. The database dumps are listed too: a dump is restored only
> with the key that was active when it was taken (`db-restore` refuses
> without it), so the old key stays listed while a nightly or pre-migration
> dump taken under it is on this server, and until the bucket's rule for
> `database/` has expired the off-host copies. Keep the old key in
> `ENCRYPTION_KEYS` until it is no longer listed there (the weekly copy
> replaces itself within a week; delete old uploaded copies you no longer
> want, and wait out the dump and off-host retention), or accept that those
> copies cannot be restored. Copies written before v1.39.3
> did not record their keys and are listed as such: treat them as needing
> every key that existed when they were written.
>
+9 -5
View File
@@ -96,8 +96,9 @@ maintenance refuses to start while one is.
Have at least twice the largest size free on the database volume.
- **Take a backup** (Settings, Data, Backups), as before any operation on the
whole table.
- **Take a backup**: a database dump, as in
[Updates](../self-hosting/getting-started.md#updates), as before any
operation on the whole table.
## Starting it
@@ -106,10 +107,12 @@ In the admin area, or with a signed-in admin session:
```sh
curl -X POST https://<your-instance>/api/admin/maintenance/measurements \
-H 'content-type: application/json' \
--cookie 'healthlog_session=<session>' \
--cookie '__Host-healthlog_session=<session>' \
-d '{"vacuum": true, "reindex": true}'
```
On a plain-HTTP install the cookie is named `healthlog_session`.
The answer is `202` with `{ "enqueued": true }`, or `enqueued: false` when a
run is already queued or running. A Bearer token is refused: the route is
cookie-only, like every admin route. Without a background worker the route
@@ -165,8 +168,9 @@ singleton key with the run you start, so the two never overlap. The admin
card keeps showing only the runs you started.
The check also measures how far the table file has grown past what its live
rows need (`maintenance_bloat_factor` on the wide event, `bloat_factor` in the
job result). Past 3 it adds a warning to the event: a vacuum makes the space
rows need (`maintenance_bloat_factor` on the wide event, as a factor such as
1.3; `bloat_percent` in the job result, as whole percent such as 130, because a
job fact is a whole number). Past 3 it adds a warning to the event: a vacuum makes the space
reusable but does not shrink the file, and only `VACUUM FULL` does (see
below).
+168
View File
@@ -0,0 +1,168 @@
# Moving the database to a newer Postgres major
From v1.46 the compose file runs Postgres 18. Installs from v1.45 or earlier
ran 16, and a major version cannot open the data directory of an older one,
so the compose file carries a one-time step that moves the database across
on the first start after the update. Nothing needs doing by hand on a stack
that runs the project's `docker-compose.yml`.
Installs that bring their own Postgres (Unraid, an existing database server)
are not touched: HealthLog runs on 16 and 18 alike, and the move there is
yours to schedule with the steps under [By hand](#by-hand).
## What happens on the first start
`db-upgrade` is a short-lived container next to `db`. It starts after the new
Postgres 18 database is up and before the app, and it exits in a second when
there is nothing to move. When the old `pgdata` volume holds a Postgres 16
database and the new one is empty, it:
1. checks that the database service runs Postgres 18, and that there is room
on the disk for a second copy and for the dump the app takes before its
first migration;
2. starts Postgres 16 inside its own container on the old volume, reachable
only from inside that container;
3. streams a dump into Postgres 18 in a single transaction;
4. compares the row count of every table between the two;
5. gathers the planner statistics, which a restore does not carry;
6. stops Postgres 16 and leaves a marker file on the old volume.
The app starts once that succeeded, applies the release's migrations and
serves as before. On a typical personal record the whole move takes seconds;
a database of a few gigabytes takes a few minutes. Watch it with
`docker compose logs -f db-upgrade`.
If any step fails, the new database stays empty, the app does not start, and
the log says what happened. A move cut short by a restart or a full disk is
run again from scratch on the next `docker compose up -d`.
The old `pgdata` volume keeps the database as it was. Postgres 16 runs on it
during the move, so it writes a checkpoint there (and recovers first after an
unclean stop), and the marker file is added; no data in it changes. That is
the way back.
## Going back
Stop the stack, put the two lines of the `db` service back the way v1.45 had
them, and start it with the v1.45 image:
```yaml
db:
image: postgres:16.15-alpine
volumes:
- pgdata:/var/lib/postgresql/data
```
```bash
docker compose down
HEALTHLOG_IMAGE_REF=:v1.45.0 docker compose up -d
```
Anything written after the move lives only in the Postgres 18 volume.
`db-upgrade` can stay in the file: it sees that the database service runs
Postgres 16 and leaves that volume alone. This also holds after a move that
stopped half-way.
## Updating again after going back
If you used Postgres 16 again after going back, the Postgres 18 volume holds
the state of the day of the move, not what you wrote since. `db-upgrade`
notices that the old database changed after the move and refuses to start on
the old copy. To move the current 16 database, remove the old copy and the
marker, then start again:
```bash
docker compose down
docker volume rm <project>_postgres-data
docker compose run --rm --entrypoint rm db-upgrade \
/var/lib/postgresql/data/HEALTHLOG_MOVED_TO_POSTGRES_18
docker compose up -d
```
`docker volume ls` shows the volume's full name. Removing it deletes what was
written on Postgres 18 before you went back; take a dump of it first if any
of that matters.
## Afterwards
Keep the old volume until the new database has run for a while, then remove
it. It is a complete copy of the database as it stood before the move, and
nothing that happens afterwards reaches it: an account deleted, a document
purged from the trash, or a key rotated after a leak is still there in the
old volume, and so is everything else for anyone who can read the host's
Docker volumes. Removing it is part of the move, not optional tidying.
`docker volume ls` shows its full name, prefixed with the compose project:
```bash
docker volume rm <project>_pgdata
```
`db-upgrade` then finds nothing to move. A later release drops the service
and the `pgdata` line.
## Starting over
When the move has run and the Postgres 18 volume is empty again (deleted by
hand, say), `db-upgrade` refuses to import the old state a second time, since
that would quietly bring back the database as it was on the day of the move.
To do exactly that on purpose, remove the marker and start again:
```bash
docker compose run --rm --entrypoint rm db-upgrade \
/var/lib/postgresql/data/HEALTHLOG_MOVED_TO_POSTGRES_18
docker compose up -d
```
## By hand
For a Postgres you run yourself, or a compose file you have changed so much
that the step does not fit, the move is a dump and a restore. Take the
encryption key's copy out of its safe place first: the dump is unreadable
without it, and this is the moment you would find out.
```bash
# 1. Stop the app so nothing writes while the dump runs (and app-worker, if
# you run the worker role in its own container).
docker compose stop app
# 2. Dump the database in Postgres' custom format.
docker compose exec -T db pg_dump -U healthlog -Fc healthlog \
> healthlog-pg16-$(date +%F).dump
# 3. Stop the old database. Its volume stays untouched.
docker compose stop db
```
4. Point the `db` service at Postgres 18 and a fresh volume. From 18 on, the
image keeps its data under `/var/lib/postgresql/18/docker` and expects the
volume mounted one level up, at `/var/lib/postgresql`; mounted at
`.../data` it refuses to start with "there appears to be PostgreSQL data
in: /var/lib/postgresql/data".
```bash
# 5. Start the new database and restore the dump into it.
docker compose up -d db
docker compose exec -T db pg_restore -U healthlog -d healthlog --no-owner \
< healthlog-pg16-<date>.dump
# 6. Gather the planner statistics. A restore brings the rows but not the
# statistics, and until autovacuum gets round to every table the planner
# guesses at their sizes, which can turn a sub-second page into one that
# takes many seconds.
docker compose exec -T db vacuumdb -U healthlog -d healthlog --analyze-in-stages
# 7. Start the app and confirm it.
docker compose up -d app
curl -s http://localhost:3000/api/version
```
The start log should say there are no pending migrations beyond the release's
own: the dump carried the migration history with it.
If you had run `CREATE EXTENSION pg_stat_statements;` for
[query statistics](query-statistics.md), the dump carries it and the compose
`command:` still loads the library, so nothing needs doing there.
`pg_upgrade --link` is faster on a very large database, but it needs both
majors' binaries in one container; for a personal health record the dump is
seconds to minutes and has nothing to set up.
+9 -5
View File
@@ -214,11 +214,15 @@ as something to log in as.
or delete the profile under a fresh second factor. Deleting the profile
deletes everything recorded for it.
- A guardian may keep that record's settings: modules, units, thresholds,
its language and timezone, and its notification preferences. Connected
services are visible as a status only. Connect, callback, sync,
credential, disconnect, resume and test are not guardian surfaces in
this release, and their deep links land on the shared-record unavailable
page rather than on a raw refusal.
its language and timezone, and its notification preferences. A guardian
can also connect and disconnect the record's Withings, WHOOP, Oura, Polar,
Strava and Google Health from its integrations page; the data lands in the
profile's record, and a profile without its own provider app uses the
guardian's. Fitbit (no new connections), Nightscout and Dawarich cannot be
connected for a profile. Sync, the connection test and provider
credentials are not guardian surfaces: once connected, the record syncs on
its own schedule, and their deep links land on the shared-record
unavailable page rather than on a raw refusal.
- **Its reminders go to its guardians.** A record with no login has no
channels of its own, so medication reminders, measurement reminders,
safety-floor alerts and low-stock alerts fan out to every active
+12 -7
View File
@@ -82,13 +82,18 @@ limit for a document).
- Imports count against the person's normal upload allowance (60 an hour) and
storage quota.
- A document that is already in HealthLog is not stored twice, and one the
person deleted in HealthLog is not brought back by picking it again. Each
import is keyed by system, instance (the archive's origin) and document id,
so the same id on two Paperless-ngx instances is two documents; a key
imported before v1.39.3 carries no instance and matches any instance. These
are the same rules the document token and `scripts/import-documents.mjs`
follow.
- A document that is already in HealthLog is not stored twice. Each import is
keyed by system, instance (the archive's origin) and document id, so the
same id on two Paperless-ngx instances is two documents; a key imported
before v1.39.3 carries no instance and matches any instance. These are the
same rules the document token and `scripts/import-documents.mjs` follow.
- A document the person deleted in HealthLog is never brought back by the
document token or the import script, however often they re-send it. The
person can bring it back themselves: from Recently deleted on the Documents
page for 30 days, or by picking it again in the picker, which says "In Recently
deleted" or "Deleted in HealthLog" on such a row. Picking one still in
Recently deleted restores that same document; picking one already removed
for good imports it again.
- A picked document follows the person's own automatic AI reading setting.
- Only the owner of a record can connect an archive or import from it. People
with shared access to a record, whatever their level, do not see the picker.
+5 -4
View File
@@ -86,10 +86,11 @@ backfill button in settings fetches both for the range it is given.
Since v1.42 the home, each dated location period and each stored day keep
their coarse location sealed with the instance's encryption key (one value
per row, `{ lat, lon, label }`, under the label
`healthlog/environment-location/v1`). The boot-time encryption backfill seals
rows written by older releases and clears their readable columns; the
readable columns are dropped in a later release once the backfill reports
zero rows everywhere. Key rotation (`scripts/rotate-encryption-key.ts`, or the
`healthlog/environment-location/v1`). The boot-time encryption backfill sealed
rows written by older releases and cleared their readable columns, and
migration `0409` (v1.43) dropped those columns; it refuses to run while a row
still holds a readable location
([migration-0409-plaintext-location.md](../ops/migration-0409-plaintext-location.md)). Key rotation (`scripts/rotate-encryption-key.ts`, or the
in-app rotation) covers all three.
What the person sees is unchanged: the settings page and
+82 -32
View File
@@ -9,8 +9,7 @@ already installed, longer if a TLS-fronted public hostname is in scope
- **Docker 24+ with Compose v2.** The bundled `docker-compose.yml`
pulls a multi-arch image from GitHub Container Registry (linux/amd64
- linux/arm64), so a Raspberry Pi 5 or an x86 VPS both work
unchanged.
and linux/arm64), so a Raspberry Pi 5 or an x86 VPS both work unchanged.
- **3 GB RAM (4 GB recommended), 10 GB free disk.** The Postgres data
volume grows with measurement history; the Apple Health import worker
can briefly hold a 1.5 GB upload in `/tmp` while it parses. The app
@@ -23,11 +22,12 @@ already installed, longer if a TLS-fronted public hostname is in scope
below the supported floor. On a busy instance, raise it.
- **Outbound HTTPS** to `ghcr.io` (image pulls) plus whichever
integration endpoints you plan to enable (Withings, OpenAI, etc.).
The opt-in environmental-context module additionally reaches
`archive-api.open-meteo.com` and `geocoding-api.open-meteo.com` for
weather and daylight; it stays off until you
enable it and set a home location, and every outbound call goes
through the SSRF-guarded egress wrapper.
The environmental-context module additionally reaches
`archive-api.open-meteo.com`, `air-quality-api.open-meteo.com` and
`geocoding-api.open-meteo.com` for weather, air quality and the city
search. It is on by default, but nothing leaves the host until someone
sets a home location, and every outbound call goes through the
SSRF-guarded egress wrapper.
Three requests happen by default without anything connected: the
HaveIBeenPwned range query when a password is set (only the first five
characters of the password's SHA-1 are sent), a daily check of the
@@ -81,7 +81,9 @@ explicitly in `.env` (see the commented example there).
### Using an existing or shared Postgres
HealthLog can live in a database on a Postgres instance you already run.
HealthLog can live in a database on a Postgres instance you already run,
version 16 or newer; the compose file ships 18, and every release is tested
on both.
It stores every time as UTC and pins each connection it opens to UTC
(`TimeZone=UTC`, sent with the libpq `options` startup parameter), so the
server's or the database's own `timezone` setting does not matter: an
@@ -105,9 +107,12 @@ ALTER DATABASE healthlog SET timezone TO 'UTC';
docker compose up -d
```
Compose starts two services:
Compose starts two services and one short-lived step:
- `db` — PostgreSQL 16 with a named volume for persistence.
- `db` — PostgreSQL 18 with a named volume for persistence.
- `db-upgrade` — moves a Postgres 16 database from v1.45 or earlier into
the new one, once, and exits; on a new install it has nothing to do. See
[Moving to a newer Postgres major](../ops/postgres-major-upgrade.md).
- `app` — HealthLog itself, listening on `localhost:3000`. The image
runs `prisma migrate deploy` on boot so the schema lands
automatically; subsequent restarts no-op when the database is
@@ -265,30 +270,53 @@ ps` — the `app` healthcheck polls `/api/health` every 30 seconds
`pull_policy: always` on the `app` service, and verify the running
build with `GET /api/version` (it returns `version` + `buildSha`).
## Back up your encryption key
## Save the recovery kit
`ENCRYPTION_KEY` (or the `ENCRYPTION_KEYS` map) is not stored in the database.
Without it, the encrypted parts of the database and every in-database backup
cannot be read. Keep a copy outside the server, then confirm it under Admin,
Encryption: the step shows the key id and a fingerprint (the first 12 hex
characters of SHA-256 over the key) and can check a pasted copy without
storing it. Admins see a dashboard reminder until the active key is confirmed.
No key HealthLog encrypts with is stored in the database. Without them the
encrypted parts of the database, a database dump and every backup copy cannot
be read. The recovery kit under Admin, Encryption lists every key a restore of
this server needs:
| Platform | Where the key is set |
| --------- | --------------------------------------------------------------- |
| Compose | `.env` next to `docker-compose.yml` |
| TrueNAS | Apps, HealthLog, Edit, Environment |
| Coolify | the application's Environment Variables |
| Unraid | the container template (Docker tab, Edit) or the Compose `.env` |
| Portainer | the stack's Environment variables |
- `ENCRYPTION_KEY` (or each id in the `ENCRYPTION_KEYS` map), including a
retired id for as long as a stored or off-host backup copy still needs it;
- `BACKUP_ENCRYPTION_KEY` and any `BACKUP_ENCRYPTION_PREVIOUS_KEYS`, when
off-host copies are set up;
- `API_TOKEN_HMAC_KEY`, without which every session, token and share link ends
(no data is lost).
Each key is shown by id and a fingerprint (the first 12 hex characters of
SHA-256 over the key), never the key itself, and **Check my copy** compares a
pasted copy without storing it. Keep a copy of each outside the server, then
confirm the kit. Admins see a dashboard reminder until it is confirmed, and the
kit is due again whenever a key it needs changes: a rotation, a new backup
key, a re-keyed install. The kit also links the
[disaster recovery guide](../ops/disaster-recovery.md).
The **Recovery readiness** card on the admin overview and the Backups page
judges every backup signal in one place: the database dump, the weekly account
copies, the off-host copies, the last restore drill, whether off-host copies
are set up, whether the bucket ever removes old database dumps, and the
recovery kit. An hourly check sends each admin a
**Backup health** notification when a signal turns to "needs attention" or "at
risk", once a day while it stays at risk, and once when it recovers. Route or
switch it off per channel under Notifications.
| Platform | Where the key is set |
| --------- | -------------------------------------------------------------------------------------------------- |
| Compose | `.env` next to `docker-compose.yml` |
| TrueNAS | Apps, HealthLog, Edit: the Encryption Key field (`ENCRYPTION_KEY` in a YAML install's environment) |
| Coolify | the application's Environment Variables |
| Unraid | the container template (Docker tab, Edit) or the Compose `.env` |
| Portainer | the stack's Environment variables |
Set `HEALTHLOG_PLATFORM` (`compose`, `truenas`, `coolify`, `unraid`,
`portainer`) to open the step on your platform's instructions.
`portainer`) to open the kit on your platform's instructions.
To compute the fingerprint of your copy:
To compute the fingerprint of your copy of a hex key, and of the token key:
```bash
echo -n "<your key>" | xxd -r -p | sha256sum | cut -c1-12
printf %s "<API_TOKEN_HMAC_KEY>" | sha256sum | cut -c1-12
```
**HealthLog refuses to serve: encryption key does not match this database.**
@@ -341,6 +369,15 @@ written under that key, so remove the setting once the key is confirmed.
Your database is the only stateful piece — back it up, and back up the
key that decrypts it.
**Database dumps the app takes itself.** Every night, and before an update
applies migrations, the app writes a `pg_dump` of the whole database with a
manifest into the `backups` volume and keeps the newest few. With the
off-host bucket configured, each nightly dump is also uploaded, encrypted.
`docker compose run --rm db-restore latest` restores one into an empty
database. What is kept where, the keys a restore needs, and restoring onto a
new server: [`docs/ops/disaster-recovery.md`](../ops/disaster-recovery.md).
Keep a copy of those dumps off this host.
**Local snapshot (the common single-box case).** Dump the `db` service
with `pg_dump`:
@@ -374,8 +411,11 @@ encryption key safely (keep the old key until zero rows remain on it) see
A new image can bring database migrations, and `docker-entrypoint.sh`
applies them on the first start of that image. Most are additive, but some
rewrite data, and going back to the previous image does not undo them. The
only way back from a migration that went wrong is the dump you took before
it. So take one before every pull:
way back from a migration that went wrong is a dump taken before it. The
entrypoint takes one itself (`healthlog-<time>-pre-migration.dump` in the
`backups` volume, see
[`docs/ops/disaster-recovery.md`](../ops/disaster-recovery.md)); a dump of
your own before every pull, kept off the host, is the second copy:
```bash
# 1. Dump the database (same command as under "Backup and restore").
@@ -393,17 +433,27 @@ curl -s http://localhost:3000/api/version
Keep the dump until the new version has run for a few days, and keep your
encryption key with it: a dump without the key cannot be read.
The database image is a separate matter: a new Postgres minor is a change of
the `image:` line. The move from 16 to 18 runs by itself on the first start
of the compose file from v1.46; a database you run yourself moves by dump
and restore. Both are in
[`docs/ops/postgres-major-upgrade.md`](../ops/postgres-major-upgrade.md).
The start log says what is about to happen before the migrations run:
```text
HealthLog: 2 database migrations pending. Back up the database before updating (pg_dump, see the Updates section of docs/self-hosting/getting-started.md); a migration is not undone by going back to the previous image.
HealthLog: 2 database migrations pending. Unless DB_BACKUP_BEFORE_MIGRATE=false, a pg_dump of the database is written to DB_BACKUP_DIR before they run (docs/ops/disaster-recovery.md); a migration is not undone by going back to the previous image.
HealthLog: taking a database dump before 2 pending migration(s) into /var/lib/healthlog/backups...
HealthLog: pre-migration dump written: healthlog-20261025T081502Z-pre-migration.dump (212.4 MB, 231 tables, 38 s).
HealthLog: Running database migrations...
```
or `HealthLog: Database schema is up to date.` when the image brings none.
The line is a notice and does not pause the start: the migrations run right
after it. So the dump belongs before step 2, not after you see the line; by
then the database is already being migrated.
The migrations run right after the dump. A dump that fails on a writable
directory stops the start before anything is migrated, and the log names the
switch that skips it once (`DB_BACKUP_BEFORE_MIGRATE=false`). Your own dump
belongs before step 2, not after you see these lines; by then the database is
already being migrated.
If an update to v1.43 stops at migration `0409_drop_plaintext_location`,
nothing was changed; the recovery is in
+4 -4
View File
@@ -19,7 +19,7 @@ on top of it, see [building skills](../api/mcp-skills.md).
> **Read this before enabling writes.** A remote, write-capable connector
> reached over OAuth is a powerful combination. Read the
> [trust model](#trust-model-read-this-before-enabling-writes) at the end
> [trust model](#trust-model--read-this-before-enabling-writes) at the end
> of this page first.
## Prerequisites
@@ -43,7 +43,7 @@ LAN-only origin works for Claude Desktop over stdio).
The switch lives with every other module: **Settings → Modules**, the
**MCP** row. Turn it on and the `/mcp` endpoint goes live for your account.
**Settings → MCP** is where you use it. The page has three parts:
**Settings → MCP connector** is where you use it. The page has three parts:
- **State** — whether the module is on, with a link to its switch in
Modules, and your endpoint URL (`<origin>/mcp`). Turning the module off
@@ -55,7 +55,7 @@ The switch lives with every other module: **Settings → Modules**, the
## 2. Mint a connector token
Still in **Settings → MCP → Tokens**:
Still in **Settings → MCP connector → Tokens**:
1. Choose the scope with the **write** toggle:
- **off** → a read-only token (`health:read`). This is the default and
@@ -106,7 +106,7 @@ refused (`403`) as a DNS-rebinding defence.
client-id-metadata-document clients with dynamic registration as a
fallback.
3. Approve the consent screen. The connection then appears under
**Settings → MCP → Connections**, where you can revoke it.
**Settings → MCP connector → Connections**, where you can revoke it.
No token to paste — OAuth mints and rotates a short-lived `health:read`
access token (60 minutes, with a refresh token) behind the scenes.
+2 -2
View File
@@ -1,7 +1,7 @@
# HealthLog on Portainer
HealthLog ships a Portainer **app template** that deploys the app and its
PostgreSQL 16 database as one stack from the project `docker-compose.yml`.
PostgreSQL 18 database as one stack from the project `docker-compose.yml`.
## Add the template
@@ -39,7 +39,7 @@ curl -s http://<host>:3000/api/version
## Notes
- Persistent state lives in one named volume (`pgdata`, the Postgres data dir);
- Persistent state lives in one named volume (`postgres-data`, the Postgres data);
the app container is stateless. Back up the database to back up everything.
- Pin a release tag (`HEALTHLOG_IMAGE_REF=:vX.Y.Z`) for deliberate upgrades, or
track `:latest`. Skim the
+2 -2
View File
@@ -5,7 +5,7 @@
"type": 3,
"title": "HealthLog",
"name": "healthlog",
"description": "Self-hosted personal health tracker (PWA + native iOS client): weight, blood pressure, pulse, blood glucose, body composition, sleep, mood, menstrual cycle, and medications on one encrypted timeline. AES-256-GCM at rest, no vendor cloud, no telemetry. Ships app + PostgreSQL 16 as one stack.",
"description": "Self-hosted personal health tracker (PWA + native iOS client): weight, blood pressure, pulse, blood glucose, body composition, sleep, mood, menstrual cycle, and medications on one encrypted timeline. AES-256-GCM at rest, no vendor cloud, no telemetry. Ships app + PostgreSQL 18 as one stack.",
"note": "Generate each secret with: openssl rand -hex 32 (a 64-char hex string). For plain-HTTP / LAN access set SESSION_COOKIE_SECURE to false, or login fails. Set NEXT_PUBLIC_APP_URL and APP_URL to how you reach the app. Full guide: https://github.com/MBombeck/HealthLog/blob/main/docs/self-hosting/getting-started.md",
"categories": ["Tools", "Other"],
"platform": "linux",
@@ -33,7 +33,7 @@
{
"name": "HEALTHLOG_PLATFORM",
"label": "Platform hint",
"description": "Opens the admin's encryption key backup step on the Portainer instructions.",
"description": "Opens the admin's recovery kit on the Portainer instructions.",
"default": "portainer"
},
{
+22 -10
View File
@@ -95,19 +95,31 @@ different things depending on which button you press.
two actions below, the row stays. A start that was folded into a later
cycle stays for as long as that cycle does, because deleting the later
cycle gives it back.
- **Settings → Delete All Data** removes the rows outright, tombstones
- **Settings → Advanced → Delete all data** removes the rows, 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.
with; the confirmation dialog lists it. Before it does, it keeps a copy
of the account on the server for the deletion grace window (14 days by
default), so a mistaken wipe can be restored; the copy is erased when
the window ends.
- **Deleting the account** locks it at once and erases it, with every
record, when the grace window ends. Until then an admin can undo it.
Two consequences follow, and they matter for the seizure threat model
`ACCOUNT_DELETION_GRACE_DAYS=0` makes both immediate again, without the
copy and without an undo.
Three 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.
have performed still holds that entry; so does a host imaged inside the
grace window. A backup taken before any of these actions still holds
whatever it captured, because a backup is a copy of a moment: the nightly
database dumps keep a deleted record until they age out (`DB_BACKUP_KEEP`
nights on the server, and your bucket's lifecycle rule for `database/`).
And after the move to Postgres 18 the old `pgdata` volume is a full copy
of the database from that day until you remove it. If your reason for
deleting is that the data must not be recoverable from the machine, set
the grace window to 0 or wait it out, remove the old volume, and let the
dumps age out or delete them
([backup-restore.md](../ops/backup-restore.md)).
## Practical hardening
+107 -13
View File
@@ -16,17 +16,19 @@ tailnet with no extra container. See
## Pre-flight — env vars the proxy interacts with
| Variable | Required for | Notes |
| --------------------- | ---------------------------------------------- | ---------------------------------------- |
| `NEXT_PUBLIC_APP_URL` | Every public-facing absolute URL the UI emits | Must match the hostname the proxy serves |
| `APP_URL` | Server-side absolute URLs from background jobs | Mirror of the above; both must match |
| `TRUST_PROXY_HOPS` | `X-Forwarded-For` chain trust | Defaults to `1` — one trusted hop |
| Variable | Required for | Notes |
| ------------------------ | ---------------------------------------------- | ---------------------------------------- |
| `NEXT_PUBLIC_APP_URL` | Every public-facing absolute URL the UI emits | Must match the hostname the proxy serves |
| `APP_URL` | Server-side absolute URLs from background jobs | Mirror of the above; both must match |
| `TRUST_PROXY_HOPS` | `X-Forwarded-For` chain trust | Defaults to `1` — one trusted hop |
| `TRUSTED_PROXY_CIDRS` | Which connections may report a client address | Unset: any private address; see below |
| `TRUST_CF_CONNECTING_IP` | Reading the visitor address Cloudflare sends | Off unless `1`; see the warning below |
Set the URL pair to the public origin (`https://your-instance.example.com`)
and restart the `app` container. The image reads both at startup; no
rebuild needed.
All three variables are on the shipped `docker-compose.yml`
All five variables are on the shipped `docker-compose.yml`
`environment:` whitelist, so a value in `.env` reaches the container
without editing the compose file. (Before v1.37.33 the whitelist was
missing `TRUST_PROXY_HOPS`; on those versions add
@@ -40,22 +42,105 @@ chain from the **right**, counting back `TRUST_PROXY_HOPS` entries.
This guards against a client rotating `X-Forwarded-For` per request
to defeat IP-based rate limits.
| Topology | `TRUST_PROXY_HOPS` |
| --------------------------------------------------------- | ------------------------------------- |
| App is internet-facing with no proxy | `0` (no forwarding header is trusted) |
| Single proxy in front (Caddy / Traefik / Nginx / Coolify) | `1` (default) |
| Cloudflare → your proxy → app | `2` |
| Cloudflare → Coolify-Tunnel → Coolify → app | `3` |
| Topology | `TRUST_PROXY_HOPS` |
| --------------------------------------------------------- | ---------------------------------- |
| App is internet-facing with no proxy | `0` (the connection's own address) |
| Single proxy in front (Caddy / Traefik / Nginx / Coolify) | `1` (default) |
| Cloudflare → your proxy → app | `2` |
| Cloudflare → Coolify-Tunnel → Coolify → app | `3` |
A misconfigured count logs a one-shot warning to stderr and collapses
every anonymous caller into one shared rate-limit bucket. Match the
value to the actual hop count.
Forwarding headers are believed only from a connection whose own address
is not a public one, which is where a proxy you run connects from:
- IPv4: loopback (`127.0.0.0/8`), `0.0.0.0/8`, the private ranges
(`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`), CGNAT
(`100.64.0.0/10`, Tailscale), link-local (`169.254.0.0/16`), the reserved
blocks `192.0.0.0/24` and `198.18.0.0/15`, and everything from
`224.0.0.0` up (multicast and reserved);
- IPv6: `::` and `::1`, link-local (`fe80::/10`), site-local (`fec0::/10`),
unique-local (`fc00::/7`, Tailscale), multicast (`ff00::/8`), and an
IPv4-mapped or embedded address from one of the IPv4 ranges above.
The reserved and multicast blocks are in the list because no host on the
internet can open a connection from them, so a peer there sits on a network
you run. A request that reaches HealthLog straight from a public
address carries headers its sender wrote, so HealthLog ignores them and
uses the connection's own address, and says so once in the log. The log
line also shows up when the published port is open to the internet beside
the proxy; bind it to the proxy's network or to `127.0.0.1` then.
That list is the default when `TRUSTED_PROXY_CIDRS` is unset, and it is
only as good as the network in front of the port. Read the next section
before relying on it.
### `TRUSTED_PROXY_CIDRS` — name the proxy
Set `TRUSTED_PROXY_CIDRS` to the addresses your reverse proxy connects
from, as comma-separated CIDRs (IPv4 or IPv6; a bare address counts as a
single host). Once it is set, only a connection from inside those ranges
may report the client's address. Every other connection is its own client,
whatever its address, private ones included, and the log says so once.
Why it matters: some setups make every caller look like a private address.
Docker Desktop, OrbStack, and Docker's `docker-proxy` (the userland proxy
behind a published port such as `3000:3000`) forward each connection from
the gateway address of the Docker network, which is private. On those
hosts, someone who can reach the published port directly, from the LAN or
from the internet if the port is open, passes the private-address check
and can again choose the address that every per-IP rate limit, the audit
log and the sign-in notices see, a new one per request if they like.
Naming your proxy closes that. The default port binding in
`docker-compose.yml` stays as it is (LAN installs rely on it); this
variable is the fix.
What to set:
| Where the proxy runs | `TRUSTED_PROXY_CIDRS` |
| ------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ |
| On the same host, proxying to `127.0.0.1:3000` (bare Nginx, Caddy) | `127.0.0.1/32,::1/128` |
| In a container on a Docker network with HealthLog (Traefik, NPM, Coolify) | that network's subnet, e.g. `172.18.0.0/16` (`docker network inspect <network>` shows it under `IPAM`) |
| On another machine that connects from a public address | that machine's address, e.g. `198.51.100.7/32` |
| No proxy, or `tailscale serve` only | leave it unset |
At boot the app logs one `[proxy-trust]` line naming the rule in force:
which connections it reads forwarding headers from and how many hops deep.
A value that is not a CIDR is logged as an error, and until it is fixed no
connection may report a client address.
**A proxy that connects from a public address.** Without
`TRUSTED_PROXY_CIDRS`, such a proxy is not believed: every request through
it resolves to the proxy's own address, so all your users share one
rate-limit bucket and one person mistyping a password can hold sign-in back
for everyone. Releases before v1.46 believed any peer, so this shows up after
the upgrade. Set `TRUSTED_PROXY_CIDRS` to the proxy's address (or put the proxy
on a private network with HealthLog) and restart.
### `TRUST_CF_CONNECTING_IP` — only when the origin takes Cloudflare only
With `TRUST_CF_CONNECTING_IP=1` HealthLog takes the visitor's address from
the `CF-Connecting-IP` header, before any of the checks above: from every
connection, whatever its address, and whatever `TRUST_PROXY_HOPS` says.
Cloudflare sets that header on every request through its edge, but any
client can send it too. So the flag is safe only when nothing but
Cloudflare can reach the origin: a Cloudflare Tunnel with no published
port, or a firewall that admits only
[Cloudflare's address ranges](https://www.cloudflare.com/ips/) to the port
HealthLog or its proxy listens on (Authenticated Origin Pulls on top is
better still). If the origin also answers on its own public address, a
caller who finds it chooses the address every per-IP rate limit, the audit
log and the sign-in locations record, and changes it on every request.
Without Cloudflare in front, leave the flag unset.
`X-Real-IP` gets the same trust as `X-Forwarded-For`. It is read only
when `TRUST_PROXY_HOPS` is exactly `1` and the request carries no
`X-Forwarded-For`, which covers a single proxy that sets only
`X-Real-IP`. With `0` neither header is believed, because without a proxy
in front both come from the caller. With `2` or more, the hop count
in front both come from the caller; the address the connection came from
is used instead. With `2` or more, the hop count
describes an `X-Forwarded-For` chain, so have your proxies send one.
If requests arrive with a forwarding header the setting does not let
@@ -242,6 +327,15 @@ The one-shot stderr warning `[getClientIp] TRUST_PROXY_HOPS=N but
X-Forwarded-For carried M entries` confirms a mismatch. Adjust the
env var down to the actual chain length and restart.
If every request logs the same client address, the proxy's own, the proxy
is not one HealthLog believes: the `[getClientIp]` warning about a
connection "from a public address" or "from outside TRUSTED_PROXY_CIDRS"
names the case. Add the proxy's address to `TRUSTED_PROXY_CIDRS`.
A `TRUST_PROXY_HOPS` value that is not a whole number resolves no client
address at all (requests keep working) and is named in the `[proxy-trust]`
boot line.
## Running over plain HTTP on a LAN (no reverse proxy)
The session cookie carries the `Secure` flag by default under
+66 -1
View File
@@ -174,7 +174,7 @@ sized for the web traffic alone leaves the worker's jobs queueing for
the same connections.
Plan total Postgres slots as **container_count × budget**.
A stock Postgres 16 container ships with `max_connections = 100`, so
A stock Postgres container ships with `max_connections = 100`, so
the safe envelope is:
| Web replicas | Worker replicas | Total containers | Pool slots used | Headroom under 100 |
@@ -250,3 +250,68 @@ The implementation lives in `src/lib/db.ts → getConnectionBudget()`
(split into `getPrismaPoolMax()` and `getPgBossPoolMax()`); the
20-slot default was chosen after measuring on a 4-CPU production
container — see the inline rationale in `src/lib/db.ts`.
## Large accounts
An account that holds tens of millions of readings, most often a
full-history Google Health backfill of minute-by-minute heart rate, runs
into the per-statement limit the app sets on every database connection:
60 seconds, from `DATABASE_STATEMENT_TIMEOUT_MS` (unset means 60000, `0`
turns the limit off).
### What it looks like
- Dashboard cards stay empty, or `/api/daily/digest` and other reads
answer 500.
- The log shows `job.run.failed` for `rollup-full-backfill` with
`consecutiveFailures` counting up, and Postgres error `57014`:
`canceling statement due to statement timeout`.
- The admin status page lists `rollup-full-backfill` under failing
background jobs, and every admin gets a push once a system job has
failed three jobs in a row (retries of one job count once; a job that runs
per account counts per account), a reminder at most once a day while it
keeps failing, and a notice when the failing job runs again. A one-off job
that failed and does not run again is announced once.
### What the app does about it
- The full rollup backfill, which runs at boot for any account whose
summary tier is incomplete (and after a release that adds a column to it),
folds the five-year window one calendar year at a time. Every statement of
it runs under its own limit of five minutes, whatever
`DATABASE_STATEMENT_TIMEOUT_MS` says, and a run that reaches its time budget
hands the remaining years to a follow-up job. The readers that fall back to
the raw readings while that tier is incomplete are fast again once it has
finished.
- Heart rate older than 90 days is folded into one mean per hour, for Apple
Health, Health Connect, Google Health and Oura alike. The DAY summaries keep
the figures taken from the raw samples, and a later backfill or full sync
does not write the folded samples back. On an account with a dense history
the first fold works through the backlog over a few nights, one day per
transaction, and leaves an account alone while its summary tier is still
being rebuilt. Postgres reuses the freed table pages for new rows; to give
the index space back to the disk, run the index rebuild of the measurement
maintenance job from Admin, System status, once the backlog is through
(`docs/ops/measurement-maintenance.md`).
### When to raise the limit
Normally never: the long background passes bring their own limits. Raise
`DATABASE_STATEMENT_TIMEOUT_MS` only when request reads still time out on a
large account after the backfill has finished (the failing-jobs card is
empty), or when something between the app and Postgres sets a lower limit.
In that case also check `SHOW statement_timeout;` as the database role
HealthLog connects with: a limit set on the role or the database, or by a
pooler, applies too. Recreate the container after changing the variable so
every connection picks it up:
```yaml
# docker-compose.yml
services:
app:
environment:
DATABASE_STATEMENT_TIMEOUT_MS: "180000" # three minutes
```
A higher limit lets one slow statement hold a pool connection longer, so
raise it in steps rather than turning it off.
+2 -1
View File
@@ -101,5 +101,6 @@ and host names before it is shown.
`docker compose logs app` prints every wide event as one JSON line;
`LOG_LEVEL=debug` adds debug events. With `LOKI_ENDPOINT` set the same events
go to Loki, and GlitchTip (Admin, General) receives unexpected server errors.
go to Loki, and GlitchTip (Admin, Integrations, once a DSN is set there)
receives unexpected server errors.
Each line carries `request_id`, so an error id finds its line there too.
+187 -60
View File
@@ -1,82 +1,167 @@
# HealthLog on TrueNAS SCALE
# HealthLog on TrueNAS
HealthLog runs on TrueNAS SCALE as a Docker app: one `app` container from
`ghcr.io/mbombeck/healthlog` and one PostgreSQL 16 database. The app container
is stateless; everything you care about lives in the database dataset and in
one value you must keep yourself: the encryption key.
HealthLog runs on TrueNAS Community Edition (formerly SCALE) as a Docker app:
one container from `ghcr.io/mbombeck/healthlog` and one PostgreSQL database.
The app container is stateless. Everything you care about lives in the
database dataset and in one value you must keep yourself: the encryption key.
## What the app needs
> **Status: pending catalog review.** A HealthLog app for the TrueNAS
> Community train has been prepared but is not listed yet. Until it is, use
> [Install via YAML](#before-the-listing-install-via-yaml) at the end of this
> page. This note goes away once the app appears under Apps, Discover Apps.
| Setting | Value |
| ------------ | ---------------------------------------------------------------------------------------- |
| Image | `ghcr.io/mbombeck/healthlog`, tag `X.Y.Z` (amd64 and arm64) |
| Port | `3000` (HTTP) |
| User | runs as uid `1001`; no extra capabilities, `no-new-privileges` is fine |
| Health check | `GET /api/health`, `200` when ready |
| Start period | at least 60 s, with more retries than the image's 5 × 30 s: migrations run at every boot |
| Database | PostgreSQL 16 (17 and later are not a drop-in), no extensions |
| Volumes | none for the app; the database gets its own dataset |
## Install from the Community train
## Environment
1. Generate two secrets. Open the TrueNAS shell (System, Shell) and run the
command twice, once for each field:
| Variable | Required | Notes |
| ----------------------- | -------- | ------------------------------------------------------------------------------------------- |
| `DATABASE_URL` | yes | `postgresql://<user>:<password>@<host>:5432/<db>` |
| `ENCRYPTION_KEY` | yes | 64 hex characters: `openssl rand -hex 32`. Keep a copy outside the NAS (see below). |
| `API_TOKEN_HMAC_KEY` | yes | 64 hex characters, generated the same way. Changing it signs everyone out. |
| `NEXT_PUBLIC_APP_URL` | yes | the address people open, for example `http://truenas.local:30180` |
| `APP_URL` | yes | the same address |
| `SESSION_COOKIE_SECURE` | no | `false` for a LAN install served over plain `http://`; leave unset behind an HTTPS proxy |
| `HEALTHLOG_PLATFORM` | no | `truenas`: the admin's key backup step opens on the TrueNAS instructions |
| `GEOLITE2_DIR` | no | a mounted directory of GeoLite2 `.mmdb` files, so sign-in locations are resolved on the NAS |
```bash
openssl rand -hex 32
```
`HEALTHLOG_PROCESS_TYPE` stays unset (`all`): one container serves the web app
and runs the background jobs.
Each run prints 64 hex characters. Put both in your password manager
before you continue; see [Save the recovery kit](#save-the-recovery-kit).
2. Open Apps, Discover Apps, search for **HealthLog** and choose Install.
3. Fill in the form:
| Field | What to enter |
| --------------------- | -------------------------------------------------------------------------------------------------------------- |
| Timezone | your timezone |
| Database Password | any strong password; only the two containers use it |
| Encryption Key | the first `openssl rand -hex 32` output |
| Token Signing Key | the second output. Changing it later signs everyone out |
| App URL | the address you open HealthLog at, with scheme and port, for example `http://192.168.1.10:30536` |
| Secure Cookies | leave off when you open HealthLog over `http://` on the LAN; turn on behind an HTTPS reverse proxy (see below) |
| WebUI Port | `30536` unless that port is taken |
| Postgres Data Storage | the default ixVolume, or a host path on a dataset you snapshot |
| Resources | keep memory at 1536 MB or more; the default of 4096 MB is fine |
4. Install. The first start applies the database migrations before the web
server listens, so give it a minute. The app shows as running once
`/api/health` answers.
5. Open the App URL. The first account you register becomes the admin.
The App URL has to match the address in the browser: passkeys, sign-in
redirects and the links in notifications are built from it. If you later put
HealthLog behind a reverse proxy with its own domain, change App URL to that
`https://` address.
Settings the form does not show go under Additional Environment Variables, for
example `UPDATE_CHECK_DISABLED=1`. The full list is in
[`.env.production.example`](../../.env.production.example) and on the
[environment variables reference](https://docs.healthlog.dev/reference/environment-variables/).
To use your own GeoLite2 databases, add the directory under Additional Storage
and point `GEOLITE2_DIR` at its mount path ([geolite2.md](geolite2.md)).
HealthLog writes a whole-database dump every night, and one before each update
that brings migrations, into `/var/lib/healthlog/backups` inside the app
container. Give that path a dataset of its own (a host path or an ixVolume,
writable by uid `1001`), under Additional Storage if the form has no field for
it. Without one the directory sits inside the container, which an update
replaces, so HealthLog keeps no dump there: the start log, the admin backups
page and the recovery readiness card say so, and with an off-host bucket the
nightly dump goes to the bucket only.
## Plain HTTP on the LAN
Most NAS installs are reached over `http://`. Set `SESSION_COOKIE_SECURE=false`
for that. If it is left at its default, the browser drops the sign-in cookie
and the login page says so before you type a password: "This server only sets
its sign-in cookie over https://".
Most NAS installs are reached over `http://`. Leave **Secure Cookies** off for
that. With it on, the browser drops the sign-in cookie on a plain-HTTP page,
and the login page says so before you type a password: "Signing in cannot
complete on this page". Turn it on only when HealthLog is served over
`https://`, and set App URL to that `https://` address.
## Back up your encryption key
## Save the recovery kit
HealthLog encrypts tokens, notes, documents and in-database backups with
`ENCRYPTION_KEY`. The key is not stored in the database. If the app is deleted
and reinstalled with a new key over the old database dataset, the data is
HealthLog encrypts tokens, notes, documents and in-database backups with the
Encryption Key. The key is not stored in the database. If the app is deleted
and installed again with a new key over the old database dataset, the data is
still there but cannot be read.
1. Copy `ENCRYPTION_KEY` from the app's settings (Apps, HealthLog, Edit,
Environment) into your password manager.
2. Sign in as the admin and open Admin, Encryption. The step shows the key id
and a fingerprint, the first 12 hex characters of SHA-256 over the key.
Compare it with your copy:
TrueNAS keeps the values you typed into the form with the app's configuration
on the apps pool. Treat that as a convenience, not as your copy: deleting the
app removes it, and a reinstall starts with empty fields. Keep the key in a
password manager or another place outside the NAS.
1. Open Apps, select HealthLog and choose Edit. The key is the Encryption Key
field under HealthLog Configuration.
2. Sign in as the admin and open Admin, Encryption. The **Recovery kit** lists
every key a restore needs (the Encryption Key, the token key and, with
off-host copies, the backup key), each with an id and a fingerprint, the
first 12 hex characters of SHA-256 over the key. Compare it with your copy:
```bash
echo -n "<your key>" | xxd -r -p | sha256sum | cut -c1-12
```
or paste the copy into "Check my copy" (it is compared and dropped, never
stored or logged).
or paste the copy into **Check my copy**. It is compared and dropped, never
stored or logged.
3. Confirm the backup. Until you do, admins see a reminder on the dashboard.
After a key rotation the step comes back for the new key.
3. Press **I have a copy of every key**. Until you do, admins see a reminder on
the dashboard. After a key rotation or a new backup key the kit is due again.
The catalog app sets `HEALTHLOG_PLATFORM=truenas`, so the kit opens on these
instructions.
## Backups and restore
A restore needs two things: a backup of the database **and** the Encryption
Key that was active when it was taken. One without the other gets you nothing
back.
- **Snapshots.** Snapshot the Postgres Data Storage dataset (the ixVolume
lives under the apps pool, a host path wherever you put it). Stop the app
first, or the snapshot holds a database that was mid-write.
- **A dump.** Open a shell on the `postgres` container (Apps, HealthLog,
the container's Shell) and run:
```bash
pg_dump -U healthlog healthlog > /var/lib/postgresql/healthlog-$(date +%F).sql
```
The file lands in the Postgres Data Storage dataset, next to the database
files, so copy it off the NAS as well.
- **The nightly dump.** With a dataset on `/var/lib/healthlog/backups` (see
above), the newest seven nightly dumps and the last two taken before a
migration stay there; [disaster-recovery.md](../ops/disaster-recovery.md)
restores one onto a new server.
- **Off-host.** HealthLog can push encrypted nightly backups to any
S3-compatible bucket; see [backup-restore.md](../ops/backup-restore.md).
To restore a dump, run the app with the Encryption Key the dump was taken
under, put the dump file in the Postgres Data Storage dataset, and from the
`postgres` container shell replace the database with it:
```bash
psql -U healthlog -d postgres \
-c "DROP DATABASE healthlog WITH (FORCE);" \
-c "CREATE DATABASE healthlog OWNER healthlog;"
psql -v ON_ERROR_STOP=1 -U healthlog healthlog < /var/lib/postgresql/healthlog-YYYY-MM-DD.sql
```
Then restart the app. Restoring over the existing database without dropping
it first does not work: the tables the background queue creates on its own
refuse a plain overwrite.
## When the key does not match the database
At every start HealthLog checks that its key opens the data already in the
database. If it does not, it refuses to serve instead of failing on every
encrypted value: pages show "HealthLog cannot open its data", API requests
answer `503` with `meta.errorCode` `encryption.key_mismatch`, `/api/health`
answers `503` with `reason: "encryption_key_mismatch"`, and no background jobs
run. TrueNAS shows the app as unhealthy rather than crashed, so the reason
stays visible.
database. A missing or malformed key stops the container at once with
`HealthLog: ERROR — encryption configuration is invalid.` in its log. A
well-formed key that does not open the stored data is different: HealthLog
refuses to serve instead of failing on every encrypted value. Pages show
"HealthLog cannot open its data", API requests answer `503` with
`meta.errorCode` `encryption.key_mismatch`, `/api/health` answers `503` with
`reason: "encryption_key_mismatch"`, and no background jobs run. TrueNAS
shows the app as unhealthy rather than crashed, so the reason stays visible in
the app's logs.
Fix it one of three ways, then restart the app:
1. Put the original `ENCRYPTION_KEY` back.
2. Point `DATABASE_URL` at the database that belongs to this key.
1. Put the original Encryption Key back (Apps, HealthLog, Edit).
2. Point the app at the database that belongs to this key, for example by
restoring that dataset.
3. Start fresh with an empty database. The old data and its backups need the
old key.
@@ -91,9 +176,8 @@ checks again at the next start.
If you are certain the configured key is the one the data was written with,
and the app still refuses because the check recorded a different key earlier,
remove that record and restart. Open a shell on the database container (Apps,
HealthLog, the database container's Shell) and run, with the key id the log
names (`v1` unless you use `ENCRYPTION_KEYS`):
remove that record and restart. Open a shell on the `postgres` container and
run, with the key id the log names (`v1` unless you use `ENCRYPTION_KEYS`):
```bash
psql -U healthlog healthlog \
@@ -108,7 +192,7 @@ id: the rotation script removes its record once no values remain under it.
On a first start there is no record to remove. If the check refuses there and
you are certain the key is right, add `ENCRYPTION_KEY_CHECK` = `warn` under
Apps, HealthLog, Edit, Environment as a last resort. The check still runs and
Additional Environment Variables as a last resort. The check still runs and
logs, `/api/health` reports `warning: "encryption_key_mismatch"` without
failing, the app serves, and no key is recorded until the check passes. This
switches off a safety check: with a wrong key every encrypted value fails and
@@ -117,6 +201,49 @@ confirmed.
## Updates
Pick the new tag, take a snapshot of the database dataset first, and update.
Migrations run on start; the first start of a new install applies all of them,
which is why the start period above is generous.
The catalog follows HealthLog releases on its own: when a new image is
published, the TrueNAS catalog picks it up and the app shows an update. Take a
snapshot or a dump first, then update.
Database migrations run at every start, before the web server listens, and
only go forward. The start log says how many are pending. Going back to an
older version with Roll Back does not undo them, and an older version may not
start against a database a newer one has migrated. The way back is the
database backup you took before the update, restored together with the older
version.
The Postgres Image field offers the Postgres major versions the catalog
supports. Changing it runs a one-way upgrade of the database files, so back up
before you touch it.
## Before the listing: install via YAML
Until the catalog app is listed, install HealthLog with Apps, Discover Apps,
the menu next to Custom App, Install via YAML, using these values:
| Setting | Value |
| ------------ | -------------------------------------------------------------------------------------- |
| Image | `ghcr.io/mbombeck/healthlog`, tag `X.Y.Z` (amd64 and arm64) |
| Port | `3000` (HTTP) |
| User | runs as uid `1001` by default; no extra capabilities, `no-new-privileges` is fine |
| Health check | `GET /api/health`, `200` when ready |
| Start period | 10 min, like the image: migrations run at every boot and wait out a held lock |
| Database | PostgreSQL 18 (16 works too), no extensions, in its own container with its own dataset |
| Volumes | `/var/lib/healthlog/backups` for the database dumps (uid `1001`); the database its own |
| Variable | Required | Notes |
| ----------------------- | -------- | ---------------------------------------------------------------------------------------- |
| `DATABASE_URL` | yes | `postgresql://<user>:<password>@<host>:5432/<db>` |
| `ENCRYPTION_KEY` | yes | 64 hex characters: `openssl rand -hex 32`. Keep a copy outside the NAS. |
| `API_TOKEN_HMAC_KEY` | yes | 64 hex characters, generated the same way. Changing it signs everyone out. |
| `NEXT_PUBLIC_APP_URL` | yes | the address people open, for example `http://truenas.local:30180` |
| `APP_URL` | yes | the same address |
| `SESSION_COOKIE_SECURE` | no | `false` for a LAN install served over plain `http://`; leave unset behind an HTTPS proxy |
| `HEALTHLOG_PLATFORM` | no | `truenas`: the admin's recovery kit opens on the TrueNAS instructions |
`HEALTHLOG_PROCESS_TYPE` stays unset (`all`): one container serves the web app
and runs the background jobs. In a YAML install the key sits in the
environment block, so the place to copy it from is `ENCRYPTION_KEY` there.
To move from a YAML install to the catalog app later, take a dump, install the
catalog app with the same key, and restore the dump as described under
[Backups and restore](#backups-and-restore).
+33 -18
View File
@@ -6,10 +6,13 @@ self-contained install (app + database as one unit) and is recommended. The
Unraid UI, but because CA templates describe a single container you install
PostgreSQL separately and wire the two together.
HealthLog stores all persistent state in PostgreSQL — the app container itself is
stateless (the offline geo-IP databases are either baked in at build time or
mounted read-only; replaceable either way, not state). A backup of
the database is a backup of everything.
HealthLog stores all persistent state in PostgreSQL. The app container keeps one
more thing, its database dumps: every night, and before each update that brings
migrations, it writes a whole-database dump to `/var/lib/healthlog/backups`
(the offline geo-IP databases are either baked in at build time or mounted
read-only; replaceable either way, not state). A backup of the database is a
backup of everything, and those dumps are that backup, so that path needs a
share of its own.
## What you need
@@ -69,10 +72,12 @@ curl -s http://tower:3000/api/version
Use this if you prefer the per-app Unraid UI. You install PostgreSQL yourself,
then add HealthLog pointed at it.
### Step 1 — install PostgreSQL 16
### Step 1 — install PostgreSQL
1. In **Apps** (Community Applications), search for a **PostgreSQL** container
(for example jj9987's `postgresql`). Install version 16.
(for example jj9987's `postgresql`). Install version 18; an existing 16
works too. Moving a 16 to 18 later is a dump and restore, see
[Moving to a newer Postgres major](../ops/postgres-major-upgrade.md#by-hand).
2. Set:
- `POSTGRES_USER` = `healthlog`
- `POSTGRES_PASSWORD` = your generated password
@@ -101,12 +106,20 @@ Fill in the fields:
| `SESSION_COOKIE_SECURE` | `false` for plain-HTTP LAN access |
| `NEXT_PUBLIC_APP_URL` | `http://tower:3000` (or your domain) |
| `APP_URL` | same as `NEXT_PUBLIC_APP_URL` |
| Database dumps | `/mnt/user/appdata/healthlog/backups` (the default) |
Replace `<password>` and `<postgres-host>` with the values from Step 1. Apply,
then open `http://tower:3000` — the first registered user becomes the admin.
Replace `<password>` and `<postgres-host>` with the values from Step 1. The
dumps folder must be writable by the app's user, uid `1001`; create it once
from the terminal with
`mkdir -p /mnt/user/appdata/healthlog/backups && chown 1001:1001 /mnt/user/appdata/healthlog/backups`.
Apply, then open `http://tower:3000` — the first registered user becomes the
admin.
The HealthLog container has no persistent volume of its own; all state is in the
PostgreSQL container you installed in Step 1. Back that one up.
All account data is in the PostgreSQL container you installed in Step 1, and
the nightly dumps of it land in the dumps folder. Without that path the dumps
would sit inside the HealthLog container, which every update replaces, so
HealthLog keeps none: the start log, the admin backups page and the recovery
readiness card say so. Back up both.
---
@@ -114,20 +127,22 @@ PostgreSQL container you installed in Step 1. Back that one up.
`SESSION_COOKIE_SECURE=false` is required for plain-HTTP LAN installs
(`http://tower:3000`). Left at its default, the session cookie carries the
`Secure` flag, the browser refuses to send it over HTTP, and login appears to
"do nothing" — the page reloads to the login screen with no error.
`Secure` flag, which the browser would drop over HTTP. The login page says so
before anything is sent: "Signing in cannot complete on this page".
Set `SESSION_COOKIE_SECURE=true` (or leave it unset) only when a TLS reverse
proxy serves HealthLog over HTTPS. In that case also point `NEXT_PUBLIC_APP_URL`
and `APP_URL` at the public `https://` URL.
## Back up your encryption key
## Save the recovery kit
Copy `ENCRYPTION_KEY` from the template into your password manager, then
confirm it under Admin, Encryption (set `HEALTHLOG_PLATFORM=unraid` so the step
opens on the Unraid instructions). A reinstall with a new key over the old
database makes the data unreadable; HealthLog then refuses to serve and says
so. See [Getting started](getting-started.md#back-up-your-encryption-key).
Copy `ENCRYPTION_KEY`, `API_TOKEN_HMAC_KEY` and, with off-host copies,
`BACKUP_ENCRYPTION_KEY` from the template into your password manager, then
confirm the recovery kit under Admin, Encryption (set
`HEALTHLOG_PLATFORM=unraid` so the kit opens on the Unraid instructions). A
reinstall with a new key over the old database makes the data unreadable;
HealthLog then refuses to serve and says so. See
[Getting started](getting-started.md#save-the-recovery-kit).
## Updates
+6 -4
View File
@@ -2,7 +2,7 @@
<!--
HealthLog — Unraid Community Applications template.
CA templates describe ONE container. HealthLog needs PostgreSQL 16, so
CA templates describe ONE container. HealthLog needs PostgreSQL (18, or 16), so
install a PostgreSQL container FIRST (CA has community PostgreSQL templates),
create a `healthlog` database + user, then point DATABASE_URL below at it.
Full walkthrough: docs/self-hosting/unraid.md.
@@ -26,7 +26,7 @@
cycle, and medications on one encrypted timeline. AES-256-GCM at rest, no
vendor cloud, no telemetry.
REQUIRES a separate PostgreSQL 16 container — install one first (CA has
REQUIRES a separate PostgreSQL 18 (or 16) container — install one first (CA has
community PostgreSQL templates), create a database and user, then set
DATABASE_URL below. Step-by-step guide:
https://github.com/MBombeck/HealthLog/blob/main/docs/self-hosting/unraid.md
@@ -46,14 +46,14 @@
<Config Name="WebUI Port" Target="3000" Default="3000" Mode="tcp" Description="HTTP port HealthLog listens on." Type="Port" Display="always" Required="true" Mask="false">3000</Config>
<Config Name="DATABASE_URL" Target="DATABASE_URL" Default="postgresql://healthlog:CHANGE-ME@POSTGRES-HOST:5432/healthlog?schema=public" Mode="" Description="Connection string for your separately-installed PostgreSQL 16. Replace CHANGE-ME with the DB password and POSTGRES-HOST with the Postgres container IP or name." Type="Variable" Display="always" Required="true" Mask="true"/>
<Config Name="DATABASE_URL" Target="DATABASE_URL" Default="postgresql://healthlog:CHANGE-ME@POSTGRES-HOST:5432/healthlog?schema=public" Mode="" Description="Connection string for your separately-installed PostgreSQL (18, or 16). Replace CHANGE-ME with the DB password and POSTGRES-HOST with the Postgres container IP or name." Type="Variable" Display="always" Required="true" Mask="true"/>
<Config Name="ENCRYPTION_KEY" Target="ENCRYPTION_KEY" Default="" Mode="" Description="AES-256-GCM key for data at rest. Generate with: openssl rand -hex 32 (64-char hex). Back this up — losing it makes encrypted data unrecoverable." Type="Variable" Display="always" Required="true" Mask="true"/>
<Config Name="API_TOKEN_HMAC_KEY" Target="API_TOKEN_HMAC_KEY" Default="" Mode="" Description="HMAC key for API token hashing. Generate with: openssl rand -hex 32 (64-char hex)." Type="Variable" Display="always" Required="true" Mask="true"/>
<Config Name="SESSION_COOKIE_SECURE" Target="SESSION_COOKIE_SECURE" Default="false" Mode="" Description="Set to false for LAN / plain-HTTP installs (http://tower:3000) or login fails. Set to true only behind an HTTPS reverse proxy." Type="Variable" Display="always" Required="true" Mask="false">false</Config>
<Config Name="HEALTHLOG_PLATFORM" Target="HEALTHLOG_PLATFORM" Default="unraid" Mode="" Description="Where this server is configured. Opens the admin's encryption key backup step on the Unraid instructions." Type="Variable" Display="advanced" Required="false" Mask="false">unraid</Config>
<Config Name="HEALTHLOG_PLATFORM" Target="HEALTHLOG_PLATFORM" Default="unraid" Mode="" Description="Where this server is configured. Opens the admin's recovery kit on the Unraid instructions." Type="Variable" Display="advanced" Required="false" Mask="false">unraid</Config>
<Config Name="ENCRYPTION_KEY_CHECK" Target="ENCRYPTION_KEY_CHECK" Default="enforce" Mode="" Description="Start-up encryption key check. enforce refuses to serve when the key does not open the stored data. warn only logs it: a last resort for a refusal you are certain is false, and it disables a safety check." Type="Variable" Display="advanced" Required="false" Mask="false">enforce</Config>
<Config Name="NEXT_PUBLIC_APP_URL" Target="NEXT_PUBLIC_APP_URL" Default="http://tower:3000" Mode="" Description="Public URL users reach HealthLog at. Use the LAN URL (e.g. http://tower:3000) or your HTTPS domain behind a reverse proxy." Type="Variable" Display="always" Required="true" Mask="false"/>
@@ -63,4 +63,6 @@
<Config Name="ENCRYPTION_KEYS" Target="ENCRYPTION_KEYS" Default="" Mode="" Description="Optional. Key-rotation map, e.g. {&quot;v1&quot;:&quot;&lt;hex&gt;&quot;,&quot;v2&quot;:&quot;&lt;hex&gt;&quot;}. Leave empty unless rotating; use ENCRYPTION_KEY instead." Type="Variable" Display="advanced" Required="false" Mask="true"/>
<Config Name="ENCRYPTION_ACTIVE_KEY_ID" Target="ENCRYPTION_ACTIVE_KEY_ID" Default="" Mode="" Description="Optional. Active key id for the ENCRYPTION_KEYS map (e.g. v2). Leave empty when using ENCRYPTION_KEY." Type="Variable" Display="advanced" Required="false" Mask="false"/>
<Config Name="Database dumps" Target="/var/lib/healthlog/backups" Default="/mnt/user/appdata/healthlog/backups" Mode="rw" Description="Where HealthLog keeps its nightly database dump and the one it takes before each update that brings migrations. Without this path the dumps would sit inside the container, which an update replaces, so none is kept. Must be writable by uid 1001 (chown 1001:1001 the folder once)." Type="Path" Display="always" Required="true" Mask="false">/mnt/user/appdata/healthlog/backups</Config>
</Container>
+1 -1
View File
@@ -311,7 +311,7 @@ Tiles at the top of the dashboard live in a **single row**. Width caps
at the chart's width directly below. **Never** wrap to a second row.
If the user enables more tiles than fit, the dashboard surfaces a banner
inviting them to deactivate or reorder via Settings → Dashboard. The
inviting them to deactivate or reorder via Settings → Arrangement. The
layout editor in Settings shows a live preview of the constraint and
flags overflow before save.
+239 -43
View File
@@ -18,7 +18,11 @@
* 5. what was deleted is back under its ORIGINAL id, and what arrived after
* the snapshot is gone — which is what "the restore replaces the
* account's data tables" in `docs/ops/backup-restore.md` means when it is
* spelled out.
* spelled out;
* 6. a second person downloads their own full backup, their account is
* recreated empty under the same id (the runbook's new host), the admin
* uploads the file and restores it, and the person signs in to the same
* rows under the same ids.
*
* Refusing controls, because a restore path that cannot say no is worse than
* none: a stored copy with one flipped ciphertext byte is refused and changes
@@ -36,19 +40,20 @@
*
* Everything the journey does, it does through the app: the readings and the
* document ride the page's own `fetch`, the archive and the snapshot ride the
* real buttons, the restore rides the typed-confirmation dialog. The only two
* real buttons, the restore rides the typed-confirmation dialog. The only
* things reaching Postgres directly are the ones a browser cannot express —
* resetting the account between repetitions, and corrupting one stored copy so
* the authenticated envelope has something to refuse. Both live in
* `setup/backup-journey-fixture.ts`.
* resetting the accounts between repetitions, corrupting one stored copy so
* the authenticated envelope has something to refuse (both in
* `setup/backup-journey-fixture.ts`), and recreating the second account under
* its own id (`setup/critical-fixture.ts`).
*
* Serial and single-project: the four tests are four acts of one journey, they
* mutate the account destructively, and the last one moves a session's record
* Serial and single-project: the five tests are five acts of one journey, they
* mutate the accounts destructively, and the last one moves a session's record
* selector. Its jar is its own for the reason `global-setup.ts` spells out.
*/
import { readFile } from "node:fs/promises";
import type { Page } from "@playwright/test";
import type { Page, Response } from "@playwright/test";
import { EXPORT_ARGON2_PARAMS } from "@/lib/export/passphrase-archive";
@@ -65,6 +70,13 @@ import {
storedMeasurements,
storeTamperedCopy,
} from "./setup/backup-journey-fixture";
import {
accountRowCounts,
clearLoginBuckets,
criticalAccount,
recreateCriticalAccount,
resetCriticalAccount,
} from "./setup/critical-fixture";
import {
actWithReproof,
completeReproofWithPassword,
@@ -79,8 +91,20 @@ test.describe.configure({ mode: "serial" });
* The pass backs up every account in the database, the restore rewrites a
* dozen tables in one transaction, and the archive runs Argon2id at the OWASP
* cost. None of that fits the suite's 30-second default on a loaded runner.
* On top of that, a test must hold the restore wait below in full.
*/
test.setTimeout(180_000);
test.setTimeout(240_000);
/**
* How long a queued restore may take to finish. The restore shares the heavy
* lane with the backup pass, and a restore that finds the lane taken goes
* back to the queue for `LANE_DEFER_SECONDS` (60 s, `src/lib/jobs/heavy-lane.ts`).
* The pass this journey starts can still be finishing when the restore is
* asked for: on a loaded machine its tail ran 15 s, the restore was put back
* by 60, and a 60-second wait ran out one second before it started. The
* budget holds one deferral and a second yield with room to run.
*/
const RESTORE_BUDGET_MS = 135_000;
/** Long enough to clear the export card's own 12-character minimum. */
const ARCHIVE_PASSPHRASE = "e2e-backup-journey-passphrase";
@@ -91,6 +115,8 @@ const RESTORED_WEIGHT_KG = 81.4;
const UNTOUCHED_PULSE_BPM = 58;
/** The reading that arrives AFTER the snapshot and must not survive it. */
const POST_SNAPSHOT_PULSE_BPM = 66;
/** The second account's reading, carried through its own backup. */
const FRESH_ACCOUNT_WEIGHT_KG = 67.3;
const MEDICATION_NAME = "E2E Backup Tablet";
@@ -291,6 +317,45 @@ async function restoreThroughDialog(page: Page, id: string) {
return answered;
}
/**
* Wait for a queued restore to finish, and fail with its code if it failed.
*
* The restore runs on the `backup-restore` queue: the request answers 202
* with the job and its status route, and the worker inside the server under
* test does the work. Nothing after this is read until the job says it is
* done, so every assertion is about the finished restore rather than a moment
* in the middle of it.
*/
async function awaitRestore(page: Page, response: Response): Promise<void> {
expect(response.status()).toBe(202);
const queued = (await response.json()) as {
data: { jobId: string; status: string; statusUrl: string };
};
expect(queued.data.status).toBe("queued");
expect(queued.data.statusUrl).toBe(
`/api/admin/backups/restores/${queued.data.jobId}`,
);
await expect
.poll(
async () => {
const res = await page.request.get(queued.data.statusUrl);
const body = (await res.json()) as {
data: {
status: string;
failure: { code: string } | null;
} | null;
};
// A failed job ends the wait with its code in the message instead of
// running the clock out on a state that will never change.
return body.data?.status === "failed"
? `failed: ${body.data.failure?.code ?? "unknown"}`
: body.data?.status;
},
{ timeout: RESTORE_BUDGET_MS, intervals: [250, 500, 1_000] },
)
.toBe("succeeded");
}
test.describe("Backup and restore, through the settings surfaces", () => {
test.use({ storageState: BACKUP_STORAGE_STATE_PATH });
@@ -319,7 +384,7 @@ test.describe("Backup and restore, through the settings surfaces", () => {
tamperedCopyId = null;
});
test("a record is written, sealed into an HLX1 archive, and snapshotted", async ({
test("a record is written, sealed into an HLX1 archive, and snapshotted @critical", async ({
page,
}) => {
await page.goto("/measurements", { waitUntil: "domcontentloaded" });
@@ -505,7 +570,7 @@ test.describe("Backup and restore, through the settings surfaces", () => {
expect(await storedMeasurements()).toEqual(before);
});
test("the snapshot puts back what was deleted and drops what came after", async ({
test("the snapshot puts back what was deleted and drops what came after @critical", async ({
page,
}) => {
const record = requireSeeded();
@@ -563,39 +628,10 @@ test.describe("Backup and restore, through the settings surfaces", () => {
expect(beforeRestore.map((m) => m.id)).toContain(laterId);
await openBackupsConsole(page);
const response = await restoreThroughDialog(page, requireSnapshot());
// The restore runs on the `backup-restore` queue: the request answers 202
// with the job and its status route, and the worker inside the server
// under test does the work. Nothing below is read until the job says it
// is done, so every assertion is about the finished restore rather than
// a moment in the middle of it.
expect(response.status()).toBe(202);
const queued = (await response.json()) as {
data: { jobId: string; status: string; statusUrl: string };
};
expect(queued.data.status).toBe("queued");
expect(queued.data.statusUrl).toBe(
`/api/admin/backups/restores/${queued.data.jobId}`,
await awaitRestore(
page,
await restoreThroughDialog(page, requireSnapshot()),
);
await expect
.poll(
async () => {
const res = await page.request.get(queued.data.statusUrl);
const body = (await res.json()) as {
data: {
status: string;
failure: { code: string } | null;
} | null;
};
// A failed job ends the wait with its code in the message instead
// of running the clock out on a state that will never change.
return body.data?.status === "failed"
? `failed: ${body.data.failure?.code ?? "unknown"}`
: body.data?.status;
},
{ timeout: 60_000, intervals: [250, 500, 1_000] },
)
.toBe("succeeded");
const afterRestore = await storedMeasurements();
@@ -650,6 +686,166 @@ test.describe("Backup and restore, through the settings surfaces", () => {
),
).toHaveCount(0);
});
test("a full backup the operator restores brings the record back into a fresh account @critical", async ({
page,
browser,
}, testInfo) => {
// The second account: a person with a record of their own, who downloads
// the file the export card offers. Its jar is its own; the act below
// gives it a session that needs a re-proof, like the admin's.
const person = criticalAccount("restore", testInfo);
await resetCriticalAccount(person, [
"DELETE FROM measurement_rollups WHERE user_id = $1",
"DELETE FROM measurements WHERE user_id = $1",
"DELETE FROM mood_entry_rollups WHERE user_id = $1",
"DELETE FROM mood_entries WHERE user_id = $1",
"DELETE FROM rate_limits WHERE key = 'export:' || $1",
]);
const context = await browser.newContext({
storageState: person.storageStatePath,
baseURL: testInfo.project.use.baseURL,
});
let backupPath = "";
let written: { weightId: string; moodId: string };
try {
const own = await context.newPage();
const endOwnSession = await useStaleSession(own, person.username);
try {
await own.goto("/measurements", { waitUntil: "domcontentloaded" });
await expect(own.locator(MEASUREMENTS_SETTLED).first()).toBeVisible({
timeout: 30_000,
});
written = await own.evaluate(async (weightKg: number) => {
const post = async (path: string, body: unknown) => {
const res = await fetch(path, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const json = (await res.json()) as { data: { id: string } | null };
if (!res.ok || !json.data) {
throw new Error(`${path} answered ${res.status}`);
}
return json.data.id;
};
const at = new Date(Date.now() - 90 * 60_000).toISOString();
return {
weightId: await post("/api/measurements", {
type: "WEIGHT",
value: weightKg,
measuredAt: at,
}),
moodId: await post("/api/mood-entries", {
mood: "OKAY",
moodLoggedAt: at,
}),
};
}, FRESH_ACCOUNT_WEIGHT_KG);
await own.goto("/settings/export", { waitUntil: "domcontentloaded" });
await expect(own.getByTestId("export-card-full-backup")).toBeVisible({
timeout: 30_000,
});
const download = own.waitForEvent("download");
await actWithReproof(own, person.password, () =>
own.getByTestId("export-action-full-backup").click(),
);
// Kept past the context: a download's own file goes with it.
backupPath = testInfo.outputPath("full-backup.json");
await (await download).saveAs(backupPath);
} finally {
await endOwnSession();
}
} finally {
await context.close();
}
// ── the account is gone, and comes back empty under the same id ──────
// What a new host is to the runbook (`docs/ops/backup-restore.md`): the
// backup names its owner by id, so the account is recreated under it
// before the upload. A browser cannot express this; it is the one act
// here that goes to Postgres.
await recreateCriticalAccount(person);
expect(await accountRowCounts(person)).toEqual({
measurements: 0,
moodEntries: 0,
});
// ── the operator uploads the file and restores it ───────────────────
await openBackupsConsole(page);
const uploaded = page.waitForResponse(
(res) =>
new URL(res.url()).pathname === "/api/admin/backups/upload" &&
res.request().method() === "POST" &&
res.status() !== 401,
);
await actWithReproof(page, E2E_BACKUP_ADMIN.password, () =>
page
.locator('input[type="file"][accept*="application/gzip"]')
.setInputFiles(backupPath),
);
const upload = await uploaded;
expect(upload.status(), (await upload.text()).slice(0, 400)).toBe(201);
const uploadId = ((await upload.json()) as { data: { id: string } }).data
.id;
await expect(snapshotRow(page, uploadId)).toHaveCount(1, {
timeout: 30_000,
});
await awaitRestore(page, await restoreThroughDialog(page, uploadId));
// ── and the person signs in to their record ──────────────────────────
const back = await browser.newContext({
baseURL: testInfo.project.use.baseURL,
});
try {
await clearLoginBuckets();
const login = await back.request.post("/api/auth/login", {
data: { email: person.username, password: person.password },
});
expect(login.status()).toBe(200);
const signedIn = await back.newPage();
// The same rows, under their own ids: a restore, not a re-entry.
const measurements = (
(await (await signedIn.request.get("/api/measurements")).json()) as {
data: { measurements: Array<{ id: string; value: number }> };
}
).data.measurements;
expect(measurements).toEqual([
expect.objectContaining({
id: written.weightId,
value: FRESH_ACCOUNT_WEIGHT_KG,
}),
]);
const moods = (
(await (await signedIn.request.get("/api/mood-entries")).json()) as {
data: { entries: Array<{ id: string; mood: string }> };
}
).data.entries;
expect(moods).toEqual([
expect.objectContaining({ id: written.moodId, mood: "OKAY" }),
]);
await signedIn.goto("/measurements", { waitUntil: "domcontentloaded" });
await expect(
signedIn
.locator(
'[data-testid="measurement-row"][data-measurement-type="WEIGHT"]' +
`[data-measurement-value="${FRESH_ACCOUNT_WEIGHT_KG}"]`,
)
.first(),
).toBeVisible({ timeout: 30_000 });
} finally {
await back.close();
}
// The admin's own record is untouched by a restore aimed at somebody else.
expect(
(await storedMeasurements()).filter((m) => m.deletedAt === null),
).toHaveLength(2);
});
});
test.describe("A read-level delegate inside the record", () => {
+30 -6
View File
@@ -524,13 +524,37 @@ test.describe("the bottom sheet", () => {
test("under reduced motion it opens without a slide", async ({ page }) => {
await page.emulateMedia({ reducedMotion: "reduce" });
await page.goto("/mood");
await expect(page.locator('[data-testid="bottom-nav-more"]')).toBeVisible();
// Read the sheet the moment it is inserted, before the browser has drawn
// a frame of it: that is when a slide that exists at all is still
// running, however short it was made. A read after it shows depends on
// frames having been drawn in between, which WebKit in the CI container
// does not promise, so a later read alone can pass or fail at random.
await page.evaluate(() => {
const w = window as unknown as { __sheetAtInsert?: unknown };
const watch = new MutationObserver(() => {
const sheet = document.querySelector(
'[data-testid="bottom-nav-more-sheet"]',
);
const overlay = document.querySelector('[data-slot="sheet-overlay"]');
if (!sheet || !overlay || w.__sheetAtInsert) return;
watch.disconnect();
w.__sheetAtInsert = [sheet, overlay].map((el) => ({
name: getComputedStyle(el).animationName,
animations: el.getAnimations().length,
}));
});
watch.observe(document.body, { childList: true, subtree: true });
});
await page.locator('[data-testid="bottom-nav-more"]').tap();
await expect(more(page)).toBeVisible();
expect(
await more(page).evaluate(
(el) =>
el.getAnimations().filter((a) => a.playState === "running").length,
),
).toBe(0);
const atInsert = await page.evaluate(
() =>
(window as unknown as { __sheetAtInsert?: unknown }).__sheetAtInsert,
);
expect(atInsert).toEqual([
{ name: "none", animations: 0 },
{ name: "none", animations: 0 },
]);
});
});
+2 -4
View File
@@ -514,10 +514,8 @@ test.describe("Coach page frame", () => {
expect(await stripBox(strip(page))).toEqual(stripOpen);
await expect(expand(page)).toBeFocused();
await expect(expand(page)).toHaveAttribute("aria-expanded", "false");
await expect(expand(page)).toHaveAttribute(
"aria-label",
"Show conversations",
);
// Named by its visible label; the action is the tooltip (WCAG 2.5.3).
await expect(expand(page)).toHaveAttribute("title", "Show conversations");
await expect(
panel(page).locator('[data-slot="coach-history-select"]'),
).toHaveCount(0);
+25 -8
View File
@@ -85,6 +85,29 @@ async function shellReady(page: Page): Promise<void> {
await expect(page.locator('[data-slot="top-bar"]')).toBeVisible();
}
/**
* Scan the open palette once it has finished opening. The dialog fades in,
* and a scan that lands mid-fade reads every colour blended with the page
* behind it: on a loaded machine the selected option's text measured 4.47:1
* against a 4.5:1 floor it clears once the fade ends. Finite animations are
* awaited, bounded, so a stuck one costs the wait and not the test.
*/
async function scanPalette(page: Page) {
await page.evaluate(async () => {
const finite = document
.getAnimations()
.filter((a) => a.effect?.getTiming().iterations !== Infinity);
await Promise.race([
Promise.allSettled(finite.map((a) => a.finished)),
new Promise((resolve) => window.setTimeout(resolve, 2_000)),
]);
});
return new AxeBuilder({ page })
.include('[data-slot="command-palette"]')
.withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"])
.analyze();
}
test.describe("command palette", () => {
test.beforeEach(async ({ context }, testInfo) => {
test.skip(
@@ -181,10 +204,7 @@ test.describe("command palette", () => {
page.locator('[data-group="records"] mark').first(),
).toHaveText("Shoulder");
const axe = await new AxeBuilder({ page })
.include('[data-slot="command-palette"]')
.withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"])
.analyze();
const axe = await scanPalette(page);
expect(axe.violations).toEqual([]);
// The arrows reach it after the local options, and Enter opens it.
@@ -226,10 +246,7 @@ test.describe("command palette", () => {
await expect(palette(page)).toBeVisible();
await expect(input(page)).toBeFocused();
const axe = await new AxeBuilder({ page })
.include('[data-slot="command-palette"]')
.withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"])
.analyze();
const axe = await scanPalette(page);
expect(axe.violations).toEqual([]);
await page.keyboard.press("Escape");
+37 -1
View File
@@ -192,7 +192,9 @@ test.describe("the day view", () => {
await expect(expand).toBeFocused();
await expect(expand).toHaveAttribute("aria-expanded", "false");
await expect(expand).toHaveAttribute("aria-controls", "day-docked-panel");
expect(await expand.getAttribute("aria-label")).toMatch(/^Show /);
// Named by its visible date (WCAG 2.5.3); the action is the tooltip.
expect(await expand.getAttribute("aria-label")).toBeNull();
expect(await expand.getAttribute("title")).toMatch(/^Show /);
expect((await rail.boundingBox())!.width).toBeLessThanOrEqual(40);
// Another page: the edge is still there and brings the same day back,
@@ -330,6 +332,40 @@ test.describe("the day view", () => {
await expect(page.locator('[data-slot="day-strip"]')).toHaveCount(0);
});
test("beside the docked day, every blood-pressure value keeps its unit on its line", async ({
page,
}, testInfo) => {
test.skip(
testInfo.project.name !== "chromium-desktop",
"docked from 1280 px",
);
// The narrowest docked width: each half of the stat card is narrowest
// here. Four cells a row left "1xx mmHg" no room, so the systolic values
// broke onto two lines while the two-digit diastolic ones fitted.
await page.setViewportSize({ width: 1280, height: 900 });
await page.goto(`/insights/blood-pressure?day=${isoDaysAgo(3)}`);
await expect(panel(page)).toHaveAttribute("data-shell", "docked");
const values = page.locator(
'[data-slot="metric-stat-series"] [data-slot="metric-stat"] > p:nth-child(2)',
);
await expect(values).toHaveCount(8);
const cells = await values.evaluateAll((els) =>
els.map((el) => {
const lineHeight = parseFloat(getComputedStyle(el).lineHeight);
return {
text: el.textContent ?? "",
lines: Math.round(el.getBoundingClientRect().height / lineHeight),
overflow: el.scrollWidth - el.clientWidth,
};
}),
);
for (const cell of cells) {
expect(cell.text, cell.text).toMatch(/mmHg$/);
expect(cell.lines, cell.text).toBe(1);
expect(cell.overflow, cell.text).toBeLessThanOrEqual(0);
}
});
test("the strip runs the full height; the search magnifier stays clear of it", async ({
page,
}, testInfo) => {
+7 -3
View File
@@ -136,7 +136,7 @@ test.describe("delegated writes", () => {
).toBeVisible();
}
test("the owner invites at a level that may add entries", async () => {
test("the owner invites at a level that may add entries @critical", async () => {
await openInviteForm();
const identifier = ownerPage.locator(
@@ -183,7 +183,9 @@ test.describe("delegated writes", () => {
).toBe(WRITE_LEVEL_VALUE);
});
test("the delegate accepts and opens the record", async ({ page }) => {
test("the delegate accepts and opens the record @critical", async ({
page,
}) => {
await page.goto("/settings/access");
await page.locator('[data-slot="grant-accept"]').first().click();
@@ -203,7 +205,9 @@ test.describe("delegated writes", () => {
await expect(banner).not.toContainText("read it, not change it");
});
test("the reading they add lands in the owner's record", async ({ page }) => {
test("the reading they add lands in the owner's record @critical", async ({
page,
}) => {
await page.goto("/measurements");
await expect(
page.locator('[data-slot="shared-record-banner"]'),
+3 -8
View File
@@ -49,7 +49,8 @@
import { readFile } from "node:fs/promises";
import type { Page, Response } from "@playwright/test";
import { PDFParse } from "pdf-parse";
import { readPdfText } from "@/lib/documents/pdfjs";
import {
E2E_REPORT_OWNER,
@@ -342,13 +343,7 @@ async function generateReport(page: Page): Promise<GeneratedReport> {
expect(response.headers()["content-type"] ?? "").toContain("application/pdf");
expect(bytes.byteLength).toBeGreaterThan(MIN_PDF_BYTES);
const parser = new PDFParse({ data: new Uint8Array(bytes) });
let text: string;
try {
text = (await parser.getText()).text;
} finally {
await parser.destroy();
}
const text = await readPdfText(new Uint8Array(bytes));
return {
status: response.status(),
+12 -8
View File
@@ -20,15 +20,19 @@ import AxeBuilder from "@axe-core/playwright";
import type { Page } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import { E2E_VAULT, VAULT_STORAGE_STATE_PATH } from "./setup/global-setup";
import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities";
import {
ensureVaultFixture,
seedNamespaceDocs,
KNIE_EPISODE_ID,
MRT_DOC_ID,
vaultFixtureIds,
} from "./setup/vault-fixture";
// The vault on an account of its own (`E2E_VAULT`): its layout is account
// state, and the month-grid tests read the default.
const { knieEpisodeId: KNIE_EPISODE_ID, mrtDocId: MRT_DOC_ID } =
vaultFixtureIds(E2E_VAULT.username);
const DEFAULT_MAX_FILE_BYTES = 26_214_400;
/** Scroll the shell's scroll container until `locator` is visible (the
@@ -145,10 +149,10 @@ test.describe("document vault", () => {
await serveAiBlock(page, aiBlockAvailable());
});
test.use({ storageState: STORAGE_STATE_PATH });
test.use({ storageState: VAULT_STORAGE_STATE_PATH });
test.beforeAll(async () => {
await ensureVaultFixture();
await ensureVaultFixture(E2E_VAULT.username);
});
// ── v1.42 — the archive as a month grid of preview tiles ──────────────
@@ -532,7 +536,7 @@ test.describe("document vault", () => {
test("bulk: 50-item kind change round-trips", async ({ page, isMobile }) => {
test.skip(isMobile, "range selection is a desktop pointer flow");
test.setTimeout(90_000);
await seedNamespaceDocs("bulkfifty", 50);
await seedNamespaceDocs("bulkfifty", 50, E2E_VAULT.username);
// Shared filing date → the namespace orders by id desc: "050" first,
// "001" last. Anchor on the first card, shift-click the last.
@@ -568,7 +572,7 @@ test.describe("document vault", () => {
isMobile,
}) => {
test.skip(isMobile, "bulk selection is a desktop pointer flow");
await seedNamespaceDocs("bulkpf", 5);
await seedNamespaceDocs("bulkpf", 5, E2E_VAULT.username);
await page.goto("/documents?q=bulkpf");
for (let i = 1; i <= 5; i++) {
@@ -603,7 +607,7 @@ test.describe("document vault", () => {
}) => {
test.skip(isMobile, "range selection is a desktop pointer flow");
test.setTimeout(120_000);
await seedNamespaceDocs("bulkchunk", 101);
await seedNamespaceDocs("bulkchunk", 101, E2E_VAULT.username);
// Newest-first by id tiebreak: "101" renders first, "001" last. Walk
// the infinite query to the end (pages of 50) so the range can span
+7 -1
View File
@@ -62,8 +62,14 @@ function decimal(value: string): RegExp {
return new RegExp(value.replace(".", "[.,]"));
}
/**
* The list, filtered to blood glucose. Unfiltered it shows the account's
* newest page of every type, and other specs keep writing readings to the
* same account, so on a database a few runs old a reading typed for
* "yesterday" falls off that page and the count reads one short.
*/
async function openList(page: Page): Promise<void> {
await page.goto("/measurements");
await page.goto("/measurements?type=BLOOD_GLUCOSE");
await expect(page.locator(LIST_SETTLED).first()).toBeVisible({
timeout: 30_000,
});
+161
View File
@@ -0,0 +1,161 @@
/**
* The app header keeps its height in every language.
*
* A translated label that is a little longer than the English one wraps, and
* a header that wraps grows: the dashboard's title row once took a second
* line in one locale while every English check stayed green. The top bar and
* the dashboard's page-header row are measured here in every shipped locale,
* on a 390 px phone and on a desktop window, against the height English gives
* them on the same viewport.
*
* The language is switched the way the app switches it, through
* `PUT /api/auth/me/locale`, which writes the account's language and the
* locale cookie; `<html lang>` is then asserted before anything is measured,
* so a switch that silently did not happen cannot pass as "same height".
*
* The locale list is the app's own (`src/lib/i18n/config.ts`), so a language
* added there is swept without touching this file.
*/
import type { Page } from "@playwright/test";
import { locales, type Locale } from "@/lib/i18n/config";
import { HEADER_LOCALES_STORAGE_STATE_PATH } from "./setup/global-setup";
import { expect, test } from "./setup/test";
const VIEWPORTS = [
{ label: "phone 390", width: 390, height: 844 },
{ label: "desktop 1280", width: 1280, height: 800 },
] as const;
type ViewportLabel = (typeof VIEWPORTS)[number]["label"];
interface HeaderGeometry {
topBar: number;
pageHeader: number;
/** For the failure message: what wrapped, not only that something did. */
title: string;
titleHeight: number;
}
/** English first: every other locale is compared with what it measured. */
const ORDER: Locale[] = ["en", ...locales.filter((l) => l !== "en")];
const reference = new Map<ViewportLabel, HeaderGeometry>();
async function readHeader(page: Page): Promise<HeaderGeometry | null> {
return page.evaluate(() => {
const topBar = document.querySelector('[data-slot="top-bar"]');
const rows = document.querySelectorAll(
'main [data-slot="page-header-row"]',
);
if (!topBar || rows.length !== 1) return null;
const row = rows[0];
const title = row.querySelector("h1");
return {
topBar: topBar.getBoundingClientRect().height,
pageHeader: row.getBoundingClientRect().height,
title: title?.textContent ?? "",
titleHeight: title?.getBoundingClientRect().height ?? 0,
};
});
}
/**
* Read the header until two reads a frame apart agree. The greeting swaps to
* the personalised line after hydration; measuring mid-swap would compare a
* frame nobody keeps.
*/
async function settledHeader(page: Page): Promise<HeaderGeometry> {
await page.evaluate(() => document.fonts.ready.then(() => undefined));
let previous: HeaderGeometry | null = null;
for (let attempt = 0; attempt < 20; attempt += 1) {
await page.evaluate(
() =>
new Promise<void>((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
),
);
const current = await readHeader(page);
if (
current &&
previous &&
current.topBar === previous.topBar &&
current.pageHeader === previous.pageHeader
) {
return current;
}
previous = current;
}
throw new Error(
`header did not settle: ${JSON.stringify(previous ?? "not found")}`,
);
}
test.describe("header height in every locale", () => {
test.use({ storageState: HEADER_LOCALES_STORAGE_STATE_PATH });
// The account's language is one value; the locales take turns on it.
// It runs in the desktop project only (see `playwright.config.ts`) and sets
// both viewports itself.
test.describe.configure({ mode: "serial" });
for (const locale of ORDER) {
test(`top bar and dashboard header keep the English height in ${locale}`, async ({
page,
}) => {
test.setTimeout(60_000);
const switched = await page.request.put("/api/auth/me/locale", {
data: { locale },
});
expect(switched.status(), await switched.text()).toBe(200);
for (const viewport of VIEWPORTS) {
await page.setViewportSize({
width: viewport.width,
height: viewport.height,
});
await page.goto("/", { waitUntil: "domcontentloaded" });
await expect(page.locator("html")).toHaveAttribute("lang", locale);
await expect(page.locator('[data-slot="top-bar"]')).toBeVisible();
await expect(
page.locator('main [data-slot="page-header-row"]'),
).toBeVisible();
await expect(
page.locator('[data-slot="dashboard-header-greeting"]'),
).toBeVisible();
await expect
.poll(() => page.evaluate(() => window.innerWidth))
.toBe(viewport.width);
const measured = await settledHeader(page);
const at = `${locale}, ${viewport.label}`;
// A band that is not there measures 0 in every locale and "agrees".
expect(
measured.topBar,
`top bar has no height (${at})`,
).toBeGreaterThan(32);
expect(
measured.pageHeader,
`page header has no height (${at})`,
).toBeGreaterThan(20);
if (locale === "en") {
reference.set(viewport.label, measured);
continue;
}
const english = reference.get(viewport.label);
expect(english, "the English reference ran first").toBeDefined();
const detail = `title "${measured.title}" ${measured.titleHeight}px tall vs "${english!.title}" ${english!.titleHeight}px`;
expect(
Math.abs(measured.topBar - english!.topBar),
`top bar is ${measured.topBar}px in ${at}, ${english!.topBar}px in English`,
).toBeLessThanOrEqual(1);
expect(
Math.abs(measured.pageHeader - english!.pageHeader),
`dashboard header is ${measured.pageHeader}px in ${at}, ${english!.pageHeader}px in English; ${detail}`,
).toBeLessThanOrEqual(1);
}
});
}
});
+231 -47
View File
@@ -1,24 +1,89 @@
import type { BrowserContext, Page } from "@playwright/test";
import { expect, test } from "./setup/test";
import {
clearLoginBuckets,
criticalAccount,
resetCriticalAccount,
type CriticalAccount,
} from "./setup/critical-fixture";
/**
* Smoke checks on the login surface — must render, must wire the
* password-manager autofill hints (autoComplete="username" /
* "current-password"), must let the user submit.
* Signing in and out, against the real server.
*
* These do NOT log in (no DB seed required); the password-flow
* roundtrip is exercised by the integration test in
* tests/integration/auth-flow.test.ts.
* Every test starts without a session and signs in through the form, so what
* it reads is what the login route, the session table and the shell did, not
* a payload typed into the test. The account is the sign-in journey's own
* (one per project, see `critical-fixture.ts`): it gets no jar from global
* setup, because the tests here end the sessions they open and a jar captured
* once would be dead for a retry.
*
* The passkey half runs through Chromium's virtual authenticator, attached
* over CDP. It is a real authenticator as far as the page and the server are
* concerned: the registration and the assertion are signed, and the server
* verifies both against the origin and RP id the suite runs under.
*/
async function openPasswordForm(page: import("@playwright/test").Page) {
test.use({ storageState: { cookies: [], origins: [] } });
const SESSION_COOKIE = "healthlog_session";
async function openPasswordForm(page: Page): Promise<void> {
await page.goto("/auth/login");
// The login page defaults to passkey mode; the password form is
// mounted only after clicking the "Login with password" / "Mit
// Passwort anmelden" outline button.
await page
.getByRole("button", {
name: /sign in with password|login with password|mit passwort/i,
})
.click();
// The login page opens in passkey mode; the password form mounts after
// this button. The button is server-rendered, so the click is retried
// until the form it mounts is there.
const usePassword = page.getByTestId("login-use-password");
const email = page.getByTestId("login-email");
await expect(async () => {
await usePassword.click({ timeout: 2_000 });
await expect(email).toBeVisible({ timeout: 2_000 });
}).toPass({ timeout: 30_000 });
}
async function signInWithPassword(
page: Page,
account: CriticalAccount,
): Promise<void> {
await openPasswordForm(page);
await page.getByTestId("login-email").fill(account.username);
await page.getByTestId("login-password").fill(account.password);
// The login route allows five attempts per IP per quarter hour and the
// whole suite is one IP; see `clearLoginBuckets`.
await clearLoginBuckets();
await page.getByTestId("login-password-submit").click();
await page.waitForURL((url) => url.pathname === "/", { timeout: 30_000 });
}
async function sessionCookie(context: BrowserContext): Promise<string> {
const cookie = (await context.cookies()).find(
(c) => c.name === SESSION_COOKIE,
);
expect(cookie, "the login answered without a session cookie").toBeTruthy();
return cookie!.value;
}
/** The username `/api/auth/me` answers for this context's session. */
async function signedInAs(page: Page): Promise<string | null> {
const res = await page.request.get("/api/auth/me");
if (res.status() !== 200) return null;
return ((await res.json()) as { data: { username: string } }).data.username;
}
async function signOutThroughTheMenu(page: Page): Promise<void> {
// The desktop sidebar and the phone top bar each carry a user menu; only
// one of them is on screen at a given width.
const trigger = page
.getByRole("button", { name: "User menu" })
.filter({ visible: true })
.first();
const signOut = page.getByRole("menuitem", { name: "Sign out" });
await expect(async () => {
await trigger.click({ timeout: 2_000 });
await expect(signOut).toBeVisible({ timeout: 2_000 });
}).toPass({ timeout: 30_000 });
await signOut.click();
await page.waitForURL((url) => url.pathname === "/auth/login");
}
test.describe("login page", () => {
@@ -27,47 +92,166 @@ test.describe("login page", () => {
}) => {
await openPasswordForm(page);
const username = page
.getByLabel(/username|benutzername|email|e-mail/i)
.first();
const username = page.getByTestId("login-email");
await expect(username).toBeVisible();
await expect(username).toHaveAttribute("autoComplete", /username|email/);
const password = page.locator('input[type="password"]').first();
const password = page.getByTestId("login-password");
await expect(password).toBeVisible();
await expect(password).toHaveAttribute("autoComplete", "current-password");
});
test("rejects an obviously-wrong credential pair", async ({ page }) => {
test("refuses a wrong password and opens no session", async ({
page,
context,
}, testInfo) => {
const account = criticalAccount("auth", testInfo);
await openPasswordForm(page);
await page
.getByLabel(/username|benutzername|email|e-mail/i)
.first()
.fill("nobody-here");
await page.locator('input[type="password"]').first().fill("not-the-pw");
// Intercept the API call so the test does not depend on a real
// backend rejecting the credentials. The login form's error
// surfacing is what we actually want to prove here.
await page.route("**/api/auth/login", (route) =>
route.fulfill({
status: 401,
contentType: "application/json",
body: JSON.stringify({ error: "Invalid credentials" }),
}),
await page.getByTestId("login-email").fill(account.username);
await page.getByTestId("login-password").fill("not-this-account-password");
await clearLoginBuckets();
const answer = page.waitForResponse(
(res) =>
res.url().endsWith("/api/auth/login") &&
res.request().method() === "POST",
);
await page.getByTestId("login-password-submit").click();
// The form has multiple buttons matching /sign in/ — both the
// passkey CTA and the password submit. Filter to the submit button
// so the click lands on the right one.
await page
.locator('button[type="submit"]')
.filter({ hasText: /login|anmelden|sign in/i })
.click();
await expect(
page.getByText(/invalid credentials|ungültig|falsch/i),
).toBeVisible({ timeout: 5_000 });
expect((await answer).status()).toBe(401);
await expect(page.getByText("Invalid credentials")).toBeVisible();
await expect(page).toHaveURL(/\/auth\/login/);
expect(
(await context.cookies()).some((c) => c.name === SESSION_COOKIE),
).toBe(false);
});
});
test.describe("signing in and out", () => {
test("a password sign-in lands on the dashboard as that account @critical", async ({
page,
}, testInfo) => {
const account = criticalAccount("auth", testInfo);
await signInWithPassword(page, account);
await expect(page.locator("main")).toBeVisible();
expect(await signedInAs(page)).toBe(account.username);
});
test("signing out ends the session on the server, not only in the browser @critical", async ({
page,
context,
playwright,
baseURL,
}, testInfo) => {
const account = criticalAccount("auth", testInfo);
await signInWithPassword(page, account);
const cookie = await sessionCookie(context);
// A second client holding the same cookie, the way a copied jar or a
// stolen cookie would.
const replay = await playwright.request.newContext({
baseURL,
extraHTTPHeaders: { cookie: `${SESSION_COOKIE}=${cookie}` },
});
try {
// Positive control: the replayed cookie is a working session before
// the sign-out, so the 401 below is about the sign-out.
expect((await replay.get("/api/auth/me")).status()).toBe(200);
await signOutThroughTheMenu(page);
// The browser dropped its cookie; that alone would pass with a server
// that kept the row. The proof is the old cookie being refused.
expect(await signedInAs(page)).toBeNull();
expect((await replay.get("/api/auth/me")).status()).toBe(401);
expect((await replay.get("/api/measurements?limit=1")).status()).toBe(
401,
);
} finally {
await replay.dispose();
}
// And the protected shell sends the browser back to the form.
await page.goto("/");
await page.waitForURL((url) => url.pathname === "/auth/login");
});
test("a passkey registered in settings signs the account back in @critical", async ({
page,
context,
}, testInfo) => {
const account = criticalAccount("auth", testInfo);
// A retry, or the last run, may have left a credential on the account;
// the list below has to count only the one this test registers.
await resetCriticalAccount(account, [
"DELETE FROM passkeys WHERE user_id = $1",
]);
const cdp = await context.newCDPSession(page);
await cdp.send("WebAuthn.enable");
const { authenticatorId } = await cdp.send(
"WebAuthn.addVirtualAuthenticator",
{
options: {
protocol: "ctap2",
transport: "internal",
hasResidentKey: true,
hasUserVerification: true,
isUserVerified: true,
automaticPresenceSimulation: true,
},
},
);
try {
await signInWithPassword(page, account);
await test.step("register a passkey under the current password", async () => {
await page.goto("/settings/security");
await page.getByRole("button", { name: "Add passkey" }).click();
const dialog = page.getByRole("alertdialog");
await expect(dialog).toBeVisible();
await dialog.locator('input[type="password"]').fill(account.password);
await dialog.getByRole("button", { name: "Verify and add" }).click();
await expect(dialog).toBeHidden();
await expect(
page.getByTestId("passkeys-list").locator("li"),
).toHaveCount(1);
const stored = await cdp.send("WebAuthn.getCredentials", {
authenticatorId,
});
expect(stored.credentials).toHaveLength(1);
expect(stored.credentials[0].isResidentCredential).toBe(true);
});
await signOutThroughTheMenu(page);
expect(await signedInAs(page)).toBeNull();
await test.step("sign in with it", async () => {
await clearLoginBuckets();
await page
.getByRole("button", { name: "Sign in with Passkey" })
.click();
await page.waitForURL((url) => url.pathname === "/", {
timeout: 30_000,
});
expect(await signedInAs(page)).toBe(account.username);
// The server recorded the use against the stored credential.
const res = await page.request.get("/api/auth/passkeys");
expect(res.status()).toBe(200);
const passkeys = (
(await res.json()) as {
data: Array<{ lastUsedAt: string | null }>;
}
).data;
expect(passkeys).toHaveLength(1);
expect(passkeys[0].lastUsedAt).not.toBeNull();
});
} finally {
await cdp
.send("WebAuthn.removeVirtualAuthenticator", { authenticatorId })
.catch(() => undefined);
}
});
});
+111 -148
View File
@@ -1,140 +1,92 @@
import type { Page } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import {
criticalAccount,
criticalStorageState,
resetCriticalAccount,
} from "./setup/critical-fixture";
import { openMenu } from "./open-menu";
/**
* Add-measurement flow — exercises the dashboard's quick-entry dropdown,
* the dialog-mounted MeasurementForm, the POST to /api/measurements, and
* the resulting query invalidation that surfaces the row on
* `/measurements`.
* Add-measurement flow, against the real server: the dashboard's quick-entry
* menu, the dialog-mounted MeasurementForm, the real POST to
* `/api/measurements`, and then every surface that has to agree about the
* reading afterwards: the dashboard tile, the weight chart (through the data
* table that lists exactly the points it drew) and the day view.
*
* Weight is the simplest type (single numeric input + datetime), so it
* keeps the spec focused on the round-trip rather than the BP three-field
* batched submit.
* Until v1.46 this spec answered its own POST and its own list read with a
* route stub, so it proved that the list renders a row the test typed in.
* Nothing here is stubbed now. The account is the measurement journey's own,
* one per project (`critical-fixture.ts`), and is emptied of readings before
* the test, so "the tile shows the weight" can only mean the weight this test
* entered.
*
* Weight is the simplest type (single numeric input + datetime), so it keeps
* the spec on the round trip rather than on the blood-pressure batch.
*/
/** Distinct from anything a fixture seeds, so a match is this reading. */
const WEIGHT_KG = "81.7";
/** Today in the browser's zone (`timezoneId` in `playwright.config.ts`). */
function todayInBerlin(): string {
return new Intl.DateTimeFormat("en-CA", {
timeZone: "Europe/Berlin",
year: "numeric",
month: "2-digit",
day: "2-digit",
}).format(new Date());
}
/** The account's weight readings, as the list route answers them. */
async function storedWeights(
page: Page,
): Promise<Array<{ value: number; unit: string }>> {
const res = await page.request.get("/api/measurements?type=WEIGHT");
expect(res.status()).toBe(200);
const { data } = (await res.json()) as {
data: { measurements: Array<{ value: number; unit: string }> };
};
return data.measurements.map(({ value, unit }) => ({ value, unit }));
}
test.describe("add measurement flow", () => {
test.use({ storageState: STORAGE_STATE_PATH });
test.use({ storageState: criticalStorageState("measurement") });
// This journey lands on the dashboard, which is the most expensive page in
// the product to render cold: the route segment suspends behind the server
// render and shows `loading.tsx` until it resolves. On a shared runner with
// the whole suite in flight that has overrun the 30 s default, and the test
// then died on the quick-add trigger rather than on anything it is about.
// The subject here is the add-then-appears round trip, not the dashboard's
// cold-start latency, so the budget is the page's and the assertions stay
// exactly as tight as they were.
// the product to render cold, and then reads three more surfaces. The
// assertions stay as tight as they are; the budget is the pages'.
test.setTimeout(90_000);
test("creating a weight reading surfaces it in the list", async ({
test.beforeEach(async ({}, testInfo) => {
// Fixture hygiene: what a previous run or a retry left on the account.
await resetCriticalAccount(criticalAccount("measurement", testInfo), [
"DELETE FROM measurement_rollups WHERE user_id = $1",
"DELETE FROM measurements WHERE user_id = $1",
]);
});
test("a weight entered on the dashboard reaches the tile, the chart and the day view @critical", async ({
page,
}) => {
// Capture the POST payload so we can assert against it independently
// of any list-fetch response Playwright might cache. Match both
// `/api/measurements` (POST creates a reading) and the list query
// `/api/measurements?type=...&limit=...&offset=...` (GET).
let postedBody: unknown = null;
await page.route("**/api/measurements*", async (route) => {
const req = route.request();
if (req.method() === "POST") {
postedBody = JSON.parse(req.postData() ?? "null");
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: { id: "m_e2e_1", success: true },
error: null,
}),
});
return;
}
// GET — return the just-posted reading so the list view shows it.
// The list expects `data.measurements: Measurement[]` plus
// `data.meta.total`; chart fetches use the same envelope.
const body = postedBody as {
type?: string;
value?: number;
measuredAt?: string;
} | null;
// The list-page `Measurement` shape requires `unit` and `source`
// — without them the row crashes the render and the e2e poll
// never sees "78.4". Mock with the same fields a real reading
// carries.
const measurements = body
? [
{
id: "m_e2e_1",
type: body.type,
value: body.value,
unit: body.type === "WEIGHT" ? "kg" : "",
source: "MANUAL",
measuredAt: body.measuredAt,
notes: null,
},
]
: [];
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: { measurements, meta: { total: measurements.length } },
error: null,
}),
});
});
// Dashboard's analytics fetch — empty summaries are fine; we only
// need the page to render so we can click "Add".
//
// v1.4.39.3 — match `/api/analytics` AND any sliced variant
// (`?slice=summaries`). The v1.4.39.2 dashboard split fires both
// requests in parallel; the previous string glob missed the slim
// variant which then fell through to the real route and slowed
// the page paint enough that the post-mutation list query never
// surfaced inside the 10 s window.
await page.route(/\/api\/analytics(\?|$)/, (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: { summaries: {}, bpInTargetPct: null, glucoseByContext: {} },
error: null,
}),
}),
);
await page.route("**/api/mood/analytics", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: { entries: [], summary: { count: 0 } },
error: null,
}),
}),
);
await page.goto("/", { waitUntil: "domcontentloaded" });
// Open the "Add" dropdown — the dashboard's quick-entry trigger sits
// at the top-right of `<main>`. Scope the locator there so we don't
// accidentally match an "Add" button on the sidebar.
const main = page.locator("main");
const addTrigger = main
// The quick-entry trigger sits at the top of `<main>`. Waiting for it,
// rather than for the route skeleton to go, is waiting for the thing the
// next line clicks.
const addTrigger = page
.locator("main")
.getByRole("button", { name: /^add$|hinzufügen|hinzufuegen/i })
.first();
// Wait for the trigger itself rather than for the route-level skeleton to
// go. The skeleton is also absent while the protected shell holds the
// whole page behind its hydration gate, so its disappearance is not a
// statement about the suspended segment having resolved; the control the
// next line clicks is. `networkidle` says nothing about either.
await expect(addTrigger).toBeVisible({ timeout: 60_000 });
await page.waitForLoadState("networkidle");
// The account holds no weight yet, so whatever the surfaces below show
// can only come from this reading.
expect(await storedWeights(page)).toEqual([]);
await openMenu(page, addTrigger);
// v1.5: the menu items now have distinct labels — the
// measurement entry says "Measurement" / "Messung" instead of "Add",
// so we can target it directly.
// The desktop rail opens a dropdown, the phone shell the capture sheet
// the bottom nav's plus opens; the measurement entry is in either.
await page
@@ -143,52 +95,63 @@ test.describe("add measurement flow", () => {
.first()
.click();
// Form should be visible inside the dialog. Switch type to WEIGHT
// (combo-box defaults to BLOOD_PRESSURE).
const dialog = page.getByRole("dialog");
await expect(dialog).toBeVisible();
// The Select trigger renders the current label — click to open and
// pick the weight option.
// The type select defaults to blood pressure.
await dialog.getByRole("combobox").first().click();
await page
.getByRole("option", { name: /weight|gewicht/i })
.first()
.click();
await dialog.locator("#value").fill(WEIGHT_KG);
// Weight is single-value mode — fill the value field with 78.4 kg.
const valueInput = dialog.locator("#value");
await valueInput.fill("78.4");
// Save
const created = page.waitForResponse(
(res) =>
new URL(res.url()).pathname === "/api/measurements" &&
res.request().method() === "POST",
);
await dialog.getByRole("button", { name: /save|speichern/i }).click();
const response = await created;
expect(response.status()).toBe(201);
await expect(dialog).toBeHidden();
// Dialog closes when the mutation succeeds
await expect(dialog).toBeHidden({ timeout: 5_000 });
// Assert the POST went through with the expected shape
expect(postedBody).toMatchObject({
type: "WEIGHT",
value: 78.4,
await test.step("the server stored exactly that reading", async () => {
expect(await storedWeights(page)).toEqual([
{ value: Number(WEIGHT_KG), unit: "kg" },
]);
});
// Navigate to the list and confirm the entry surfaces. The list
// page renders entries from /api/measurements which we've mocked
// to echo the just-posted row.
await page.goto("/measurements", { waitUntil: "domcontentloaded" });
await test.step("the dashboard tile", async () => {
await page.goto("/", { waitUntil: "domcontentloaded" });
const tile = page.locator(
'[data-slot="dashboard-tile-link"][data-tile-id="weight"]',
);
await expect(tile).toBeVisible({ timeout: 60_000 });
await expect(tile.locator('[data-slot="trend-card-value"]')).toHaveText(
WEIGHT_KG,
);
});
// The list renders a desktop table AND a mobile card grid (one is
// hidden via `md:hidden`/`hidden md:block`). At least one match
// means our row landed in the DOM regardless of viewport.
await expect
.poll(
async () =>
await page
.locator("main")
.getByText(/78[.,]4/)
.count(),
{ timeout: 10_000 },
)
.toBeGreaterThanOrEqual(1);
await test.step("the weight chart", async () => {
await page.goto("/insights/weight", { waitUntil: "domcontentloaded" });
const table = page.locator('[data-slot="chart-data-table"]').first();
await expect(table).toBeVisible({ timeout: 30_000 });
await table.locator('[data-slot="chart-data-table-toggle"]').click();
const rows = table.locator('[data-slot="chart-data-table-row"]');
await expect(rows).toHaveCount(1);
await expect(rows.first()).toContainText(WEIGHT_KG);
});
await test.step("the day view", async () => {
await page.goto(`/measurements?day=${todayInBerlin()}`, {
waitUntil: "domcontentloaded",
});
const value = page.locator(
'[data-slot="day-view"] [data-slot="day-value"][data-type="WEIGHT"]',
);
await expect(value).toBeVisible({ timeout: 30_000 });
await expect(value).toContainText(WEIGHT_KG);
});
});
});
+1 -1
View File
@@ -363,7 +363,7 @@ test.describe("medication adherence journey", () => {
});
});
test("a taken and a skipped dose move the card, the history and the dashboard tile", async ({
test("a taken and a skipped dose move the card, the history and the dashboard tile @critical", async ({
page,
}) => {
// The wizard is a seven-step dialog with per-step validation gating Next;
+534
View File
@@ -0,0 +1,534 @@
import AxeBuilder from "@axe-core/playwright";
import type { Page } from "@playwright/test";
import { expect, test } from "./setup/test";
import {
criticalAccount,
criticalStorageState,
resetCriticalAccount,
} from "./setup/critical-fixture";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
/**
* One full check-in per instrument through the shared wizard: the stem is
* on every step, the answers form one radio group in the source order, the
* number keys answer, the review lists every answer and changes one, and
* the result carries score, band, meaning and the next step. The PHQ-9 run
* answers item 9 above zero, so its result must lead with the crisis card.
*
* The total on the result is the server's; this journey adds the chosen
* option values itself and compares, which is the scoring contract seen
* from the outside (`instruments.test.ts` pins it from the inside). Each
* screen is scanned with axe on the way.
*
* Anchored on data attributes only, so the journey reads the same at every
* width and in every language.
*/
type Case = {
id: "PHQ9" | "GAD7" | "WHO5" | "SCI";
items: number;
options: number;
multiplier: number;
followUp: boolean;
};
const CASES: readonly Case[] = [
{ id: "PHQ9", items: 9, options: 4, multiplier: 1, followUp: true },
{ id: "GAD7", items: 7, options: 4, multiplier: 1, followUp: false },
{ id: "WHO5", items: 5, options: 6, multiplier: 4, followUp: false },
{ id: "SCI", items: 8, options: 5, multiplier: 1, followUp: false },
];
async function expectNoSeriousViolations(page: Page, label: string) {
// The pointer rests where the last tap landed; a hover tint under it is
// not what the screen shows at rest. Moving it away starts the tint's
// transition back, so the scan waits for every transition to settle rather
// than for a guessed number of milliseconds. Transitions only: a spinner's
// or a skeleton's endless animation would never settle.
await page.mouse.move(0, 0);
await page.waitForFunction(() =>
document
.getAnimations()
.every((a) => !(a instanceof CSSTransition) || a.playState !== "running"),
);
const results = await new AxeBuilder({ page })
.include(
'[data-slot="mental-health-check-in"], [data-slot="mental-health-result"]',
)
.withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"])
.analyze();
const blocking = results.violations
.filter((v) => v.impact === "serious" || v.impact === "critical")
.map(
(v) =>
`${v.id}: ${v.nodes.map((n) => `${n.html} ${n.failureSummary ?? ""}`).join(" | ")}`,
);
expect(blocking, `axe on ${label}`).toEqual([]);
}
test.describe("mental-health check-in, every instrument", () => {
test.use({ storageState: STORAGE_STATE_PATH });
for (const c of CASES) {
test(`${c.id}: steps, review, result`, async ({ page }) => {
test.setTimeout(90_000);
await page.goto("/mental-wellbeing");
const card = page.locator(
`[data-slot="instrument-card"][data-instrument="${c.id}"]`,
);
await card.locator('[data-slot="instrument-card-start"]').click();
const wizard = page.locator(
`[data-slot="mental-health-check-in"][data-instrument="${c.id}"]`,
);
await expect(wizard).toBeVisible();
const values: number[] = [];
for (let step = 1; step <= c.items; step++) {
const current = wizard.locator(
`[data-slot="check-in-step"][data-step="${step}"]`,
);
await expect(current).toBeVisible();
await expect(
current.locator('[data-slot="check-in-stem"]'),
).toHaveCount(1);
const group = current.locator('[role="radiogroup"]');
await expect(group.locator('[role="radio"]')).toHaveCount(c.options);
if (step === 1) await expectNoSeriousViolations(page, `${c.id} step 1`);
// PHQ-9 item 9 answered above zero: the option in the last position
// is "nearly every day" (value 3).
const position =
c.id === "PHQ9" && step === 9 ? c.options : (step % c.options) + 1;
const answered = group.locator('[role="radio"]').nth(position - 1);
values.push(Number(await answered.getAttribute("data-value")));
if (step % 2 === 0) {
// Keyboard: the number key picks the answer in that position.
await group.locator('[role="radio"]').first().focus();
await page.keyboard.press(String(position));
} else {
await group
.locator('[role="radio"]')
.nth(position - 1)
.click();
}
}
if (c.followUp) {
const followUp = wizard.locator(
'[data-slot="check-in-step"][data-functional]',
);
await expect(followUp).toBeVisible();
await followUp.locator('[role="radio"]').nth(1).click();
await wizard.locator('[data-slot="check-in-next"]').click();
}
const review = wizard.locator('[data-slot="check-in-review"]');
await expect(review).toBeVisible();
await expect(
review.locator('[data-slot="check-in-review-row"]'),
).toHaveCount(c.items + (c.followUp ? 1 : 0));
await expectNoSeriousViolations(page, `${c.id} review`);
// Change the second answer from the review; the wizard returns there.
await review.locator('[data-review-edit="2"]').click();
const second = wizard.locator(
'[data-slot="check-in-step"][data-step="2"]',
);
await expect(second).toBeVisible();
const other =
values[1] ===
Number(
await second
.locator('[role="radio"]')
.first()
.getAttribute("data-value"),
)
? 1
: 0;
await second.locator('[role="radio"]').nth(other).click();
await expect(review).toBeVisible();
values[1] = Number(
await review
.locator('[data-slot="check-in-review-row"][data-step="2"]')
.getAttribute("data-value"),
);
await wizard.locator('[data-slot="check-in-submit"]').click();
const result = page.locator(
`[data-slot="mental-health-result"][data-instrument="${c.id}"]`,
);
await expect(result).toBeVisible();
const total = values.reduce((sum, v) => sum + v, 0) * c.multiplier;
await expect(result.locator('[data-slot="result-score"]')).toContainText(
String(total),
);
await expect(result.locator('[data-slot="result-band"]')).toBeVisible();
await expect(
result.locator('[data-slot="result-meaning"]'),
).toBeVisible();
await expect(
result.locator(
'[data-slot="result-follow-up-hint"], [data-slot="result-next-step"]',
),
).toHaveCount(1);
if (c.id === "PHQ9") {
await expect(
result.locator('[data-slot="mental-health-crisis-card"]'),
).toBeVisible();
}
await expectNoSeriousViolations(page, `${c.id} result`);
});
}
});
/**
* The questionnaires in the reader's language. Where a validated translation
* exists the form shows it and nothing else; where the text is HealthLog's
* own translation it carries the note and the one-tap switch to the English
* original, and the result says it was answered in an unvalidated
* translation. Which cells are unvalidated is pinned by
* `instrument-wording.test.ts`; this journey proves the surface follows it.
*/
const UNVALIDATED: Record<string, readonly Case["id"][]> = {
de: ["SCI"],
nl: ["SCI"],
ko: ["SCI"],
"pt-BR": ["WHO5", "SCI"],
};
function caseOf(id: Case["id"]): Case {
const found = CASES.find((c) => c.id === id);
if (!found) throw new Error(`no case for ${id}`);
return found;
}
async function openInLocale(page: Page, locale: string) {
await page.context().addCookies([
{
name: "healthlog-locale",
value: locale,
url: process.env.E2E_BASE_URL ?? "http://localhost:3000",
},
]);
await page.goto("/mental-wellbeing");
}
async function answerAll(page: Page, c: Case, position: number) {
const wizard = page.locator(
`[data-slot="mental-health-check-in"][data-instrument="${c.id}"]`,
);
for (let step = 1; step <= c.items; step++) {
const current = wizard.locator(
`[data-slot="check-in-step"][data-step="${step}"]`,
);
await expect(current).toBeVisible();
await current
.locator('[role="radio"]')
.nth(position - 1)
.click();
}
if (c.followUp) {
await wizard.locator('[data-slot="check-in-next"]').click();
}
await expect(wizard.locator('[data-slot="check-in-review"]')).toBeVisible();
await wizard.locator('[data-slot="check-in-submit"]').click();
const result = page.locator(
`[data-slot="mental-health-result"][data-instrument="${c.id}"]`,
);
await expect(result).toBeVisible();
return result;
}
test.describe("mental-health check-in, in the reader's language", () => {
test.use({ storageState: STORAGE_STATE_PATH });
for (const [locale, unvalidated] of Object.entries(UNVALIDATED)) {
test(`${locale}: the note and the switch appear only on an unvalidated translation`, async ({
page,
}) => {
test.setTimeout(120_000);
await openInLocale(page, locale);
await expect(page.locator("html")).toHaveAttribute("lang", locale);
for (const c of CASES) {
await page
.locator(`[data-slot="instrument-card"][data-instrument="${c.id}"]`)
.locator('[data-slot="instrument-card-start"]')
.click();
const wizard = page.locator(
`[data-slot="mental-health-check-in"][data-instrument="${c.id}"]`,
);
await expect(
wizard.locator('[data-slot="check-in-step"][data-step="1"]'),
).toBeVisible();
const expected = unvalidated.includes(c.id) ? 1 : 0;
await expect(
wizard.locator('[data-slot="check-in-unvalidated-note"]'),
).toHaveCount(expected);
await expect(
wizard.locator('[data-slot="check-in-wording-toggle"]'),
).toHaveCount(expected);
await expect(
wizard.locator('[data-slot="check-in-question"]'),
).toHaveAttribute("lang", locale);
if (expected === 1) {
// On screen together with the question, at every width: the step
// scrolls to its top block, note and stem included.
await expect(
wizard.locator('[data-slot="check-in-unvalidated-note"]'),
).toBeInViewport();
await expectNoSeriousViolations(page, `${locale} ${c.id}`);
}
await wizard.locator('[data-slot="check-in-back-to-landing"]').click();
}
// An unvalidated translation answered as shown: the result says so.
const sci = caseOf("SCI");
await page
.locator('[data-slot="instrument-card"][data-instrument="SCI"]')
.locator('[data-slot="instrument-card-start"]')
.click();
const result = await answerAll(page, sci, 2);
await expect(
result.locator('[data-slot="result-wording-note"]'),
).toBeVisible();
await expectNoSeriousViolations(page, `${locale} SCI result`);
});
}
test("nl: the switch shows the English original, and the result records it", async ({
page,
}) => {
test.setTimeout(90_000);
await openInLocale(page, "nl");
await page
.locator('[data-slot="instrument-card"][data-instrument="SCI"]')
.locator('[data-slot="instrument-card-start"]')
.click();
const wizard = page.locator(
'[data-slot="mental-health-check-in"][data-instrument="SCI"]',
);
const question = wizard.locator('[data-slot="check-in-question"]');
const translated = await question.textContent();
// The note and its switch travel with every step, not only the first.
for (const step of [1, 2]) {
const current = wizard.locator(
`[data-slot="check-in-step"][data-step="${step}"]`,
);
await expect(
current.locator('[data-slot="check-in-unvalidated-note"]'),
).toBeInViewport();
await current.locator('[role="radio"]').first().click();
}
await wizard.locator('[data-slot="check-in-back"]').click();
await wizard.locator('[data-slot="check-in-back"]').click();
await expect(question).toHaveText(translated ?? "");
const toggle = wizard.locator('[data-slot="check-in-wording-toggle"]');
await toggle.click();
await expect(toggle).toHaveAttribute("data-showing", "original_en");
await expect(question).toHaveAttribute("lang", "en");
await expect(question).not.toHaveText(translated ?? "");
const sci = caseOf("SCI");
const result = await answerAll(page, sci, 1);
await expect(
result.locator('[data-slot="result-wording-note"]'),
).toHaveCount(0);
});
test("pt-BR: PHQ-9 item 9 above zero leads the result with the crisis card", async ({
page,
}) => {
test.setTimeout(90_000);
await openInLocale(page, "pt-BR");
await page
.locator('[data-slot="instrument-card"][data-instrument="PHQ9"]')
.locator('[data-slot="instrument-card-start"]')
.click();
const phq = caseOf("PHQ9");
// Position 2 is "Vários dias" (value 1) on every item, item 9 included.
const result = await answerAll(page, phq, 2);
await expect(
result.locator('[data-slot="mental-health-crisis-card"]'),
).toBeVisible();
await expect(
result.locator('[data-slot="result-wording-note"]'),
).toHaveCount(0);
});
});
/**
* A WHO-5 check-in, word for word, in German and in Brazilian Portuguese,
* on the wellbeing journey's own account (one per project,
* `critical-fixture.ts`) against the real server.
*
* The journeys above anchor on data attributes so they read the same in every
* language; this one reads the words. The German text is the validated WHO
* translation, which the form has to reproduce exactly; the Brazilian one is
* HealthLog's own and has to say so. Either way the stored assessment records
* the language and the kind of wording it was answered in, and the score the
* server computed is the one on screen.
*
* The strings are written out here rather than imported from
* `src/lib/mental-health/wording/`: a test that read the expected text from
* the module it checks would pass on any edit to that module.
*/
const WHO5_WORDING = {
de: {
validated: true,
progress: "Frage 1 von 5",
stem: "In den letzten zwei Wochen …",
first: "… war ich froh und guter Laune",
last: "… war mein Alltag voller Dinge, die mich interessieren",
// In the order the form prints them, best first.
anchors: [
"Die ganze Zeit",
"Meistens",
"Etwas mehr als die Hälfte der Zeit",
"Etwas weniger als die Hälfte der Zeit",
"Ab und zu",
"Zu keinem Zeitpunkt",
],
},
"pt-BR": {
validated: false,
progress: "Pergunta 1 de 5",
stem: "Nas últimas duas semanas …",
first: "Eu me senti alegre e de bom humor",
last: "Meu dia a dia foi preenchido com coisas que me interessam",
anchors: [
"O tempo todo",
"A maior parte do tempo",
"Mais da metade do tempo",
"Menos da metade do tempo",
"Algumas vezes",
"Em nenhum momento",
],
},
} as const;
/** The account's stored assessments, as the list route answers them. */
async function storedAssessments(page: Page): Promise<
Array<{
instrument: string;
locale: string;
wording: string | null;
totalScore: number;
}>
> {
const res = await page.request.get("/api/mental-health/assessments");
expect(res.status()).toBe(200);
const { data } = (await res.json()) as {
data: {
assessments: Array<{
instrument: string;
locale: string;
wording: string | null;
totalScore: number;
}>;
};
};
return data.assessments.map(
({ instrument, locale, wording, totalScore }) => ({
instrument,
locale,
wording,
totalScore,
}),
);
}
test.describe("WHO-5 check-in, word for word", () => {
test.use({ storageState: criticalStorageState("wellbeing") });
// Both locales write to the one account of the project, and each test
// empties it first; in order, so neither empties the other's mid-run.
test.describe.configure({ mode: "default" });
test.beforeEach(async ({}, testInfo) => {
// Fixture hygiene: what a previous run or a retry left on the account.
await resetCriticalAccount(criticalAccount("wellbeing", testInfo), [
"DELETE FROM mental_health_assessments WHERE user_id = $1",
]);
});
for (const [locale, words] of Object.entries(WHO5_WORDING)) {
test(`${locale}: the form reads the ${words.validated ? "validated" : "HealthLog"} translation and the result is stored in it @critical`, async ({
page,
}) => {
test.setTimeout(90_000);
await openInLocale(page, locale);
await expect(page.locator("html")).toHaveAttribute("lang", locale);
await page
.locator('[data-slot="instrument-card"][data-instrument="WHO5"]')
.locator('[data-slot="instrument-card-start"]')
.click();
const wizard = page.locator(
'[data-slot="mental-health-check-in"][data-instrument="WHO5"]',
);
const step = (n: number) =>
wizard.locator(`[data-slot="check-in-step"][data-step="${n}"]`);
await expect(step(1)).toBeVisible();
await expect(
wizard.locator('[data-slot="check-in-progress"]'),
).toContainText(words.progress);
await expect(step(1).locator('[data-slot="check-in-stem"]')).toHaveText(
words.stem,
);
await expect(
step(1).locator('[data-slot="check-in-question"]'),
).toHaveText(words.first);
await expect(step(1).locator('[role="radio"]')).toHaveText([
...words.anchors,
]);
await expect(
wizard.locator('[data-slot="check-in-unvalidated-note"]'),
).toHaveCount(words.validated ? 0 : 1);
// "Meistens" / "A maior parte do tempo" (value 4) on every item.
for (let n = 1; n <= 5; n++) {
await expect(step(n)).toBeVisible();
if (n === 5) {
await expect(
step(n).locator('[data-slot="check-in-question"]'),
).toHaveText(words.last);
}
await step(n).getByRole("radio", { name: words.anchors[1] }).click();
}
await expect(
wizard.locator('[data-slot="check-in-review"]'),
).toBeVisible();
const created = page.waitForResponse(
(res) =>
new URL(res.url()).pathname === "/api/mental-health/assessments" &&
res.request().method() === "POST",
);
await wizard.locator('[data-slot="check-in-submit"]').click();
expect((await created).status()).toBe(201);
// Five items at 4, times WHO-5's multiplier of 4.
const result = page.locator(
'[data-slot="mental-health-result"][data-instrument="WHO5"]',
);
await expect(result.locator('[data-slot="result-score"]')).toContainText(
"80",
);
await expect(
result.locator('[data-slot="result-wording-note"]'),
).toHaveCount(words.validated ? 0 : 1);
expect(await storedAssessments(page)).toEqual([
{
instrument: "WHO5",
locale,
wording: words.validated ? "validated" : "unvalidated_translation",
totalScore: 80,
},
]);
});
}
});
+1 -1
View File
@@ -5,7 +5,7 @@ import type { APIRequestContext } from "@playwright/test";
import { ADMIN_SECTION_SLUGS } from "@/components/admin/section-slugs";
import { MODULE_SETTINGS_PAGE_IDS } from "@/components/settings/module-settings-page-ids";
import { SETTINGS_SECTION_SLUGS } from "@/components/settings/section-slugs";
import { SETTINGS_SECTION_SLUGS } from "@/lib/settings/section-slugs";
import { expect, test } from "./setup/test";
import { MOBILE_ROUTES_STORAGE_STATE_PATH } from "./setup/global-setup";
+66 -32
View File
@@ -1,39 +1,53 @@
import type { Page } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import {
criticalAccount,
criticalStorageState,
resetCriticalAccount,
} from "./setup/critical-fixture";
/**
* Logging a mood through the form, against the real server: the time stays
* visible while the details are collapsed, the instant typed into it is the
* instant stored, and the entry is in the list afterwards.
*
* Until v1.46 this spec answered the POST and the list read itself, so the
* list was always empty and the POST always succeeded. The account is now the
* mood journey's own, one per project (`critical-fixture.ts`), emptied before
* the test; the empty state the form opens from is the account's real one.
*/
/** The account's mood entries, as the list route answers them. */
async function storedMoods(
page: Page,
): Promise<Array<{ mood: string; moodLoggedAt: string }>> {
const res = await page.request.get("/api/mood-entries");
expect(res.status()).toBe(200);
const { data } = (await res.json()) as {
data: { entries: Array<{ mood: string; moodLoggedAt: string }> };
};
return data.entries.map(({ mood, moodLoggedAt }) => ({ mood, moodLoggedAt }));
}
test.describe("mood timestamp capture", () => {
test.use({ storageState: STORAGE_STATE_PATH });
test.use({ storageState: criticalStorageState("mood") });
test("keeps Time visible while details are collapsed and submits the selected instant", async ({
test.beforeEach(async ({}, testInfo) => {
// Fixture hygiene: what a previous run or a retry left on the account.
await resetCriticalAccount(criticalAccount("mood", testInfo), [
"DELETE FROM mood_entry_rollups WHERE user_id = $1",
"DELETE FROM mood_entries WHERE user_id = $1",
]);
});
test("a mood logged at a chosen time is stored at that instant and listed @critical", async ({
page,
}) => {
let submitted: Record<string, unknown> | null = null;
await page.route("**/api/mood-entries*", async (route) => {
if (route.request().method() === "POST") {
submitted = route.request().postDataJSON() as Record<string, unknown>;
await route.fulfill({
status: 201,
contentType: "application/json",
body: JSON.stringify({ data: { id: "created-mood" }, error: null }),
});
return;
}
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: { entries: [], meta: { total: 0, limit: 50, offset: 0 } },
error: null,
}),
});
});
await page.goto("/mood", { waitUntil: "domcontentloaded" });
// The list is mocked empty, so its empty state carries the add action
// (the header drops its own while there is nothing to list).
// An empty list carries the add action in its empty state (the header
// drops its own while there is nothing to list).
await page.getByRole("button", { name: "Log your first mood" }).click();
await page.getByRole("radio", { name: "Amazing" }).click();
@@ -53,12 +67,32 @@ test.describe("mood timestamp capture", () => {
await timeInput.fill("09:45");
await timeInput.blur();
const created = page.waitForResponse(
(res) =>
new URL(res.url()).pathname === "/api/mood-entries" &&
res.request().method() === "POST",
);
await page.getByRole("button", { name: "Save", exact: true }).click();
expect((await created).status()).toBe(201);
await expect.poll(() => submitted).not.toBeNull();
expect(submitted).toMatchObject({
mood: "SUPER_GUT",
moodLoggedAt: "2026-01-15T08:45:00.000Z",
// 09:45 in Berlin in January is 08:45 UTC.
expect(await storedMoods(page)).toEqual([
{ mood: "SUPER_GUT", moodLoggedAt: "2026-01-15T08:45:00.000Z" },
]);
// The list shows it, at either width (the table and the phone cards each
// carry the marker; one of them is on screen).
const rows = page.locator('[data-slot="mood-rows"]').filter({
visible: true,
});
await expect(rows).toBeVisible();
await expect(rows).toContainText("Amazing");
await expect(rows).toContainText("09:45");
// And still after a reload, which reads the list from the server again
// rather than from the cache the mutation updated.
await page.reload({ waitUntil: "domcontentloaded" });
await expect(rows).toContainText("Amazing");
await expect(rows).toContainText("09:45");
});
});
+1 -1
View File
@@ -25,7 +25,7 @@ import { MOBILE_ROUTES_STORAGE_STATE_PATH } from "./setup/global-setup";
test.describe("offline, with the service worker", () => {
test.use({ storageState: MOBILE_ROUTES_STORAGE_STATE_PATH });
test("an open page says it is offline, a new one opens the offline page, and both recover", async ({
test("an open page says it is offline, a new one opens the offline page, and both recover @critical", async ({
page,
context,
}) => {
+82 -19
View File
@@ -1,29 +1,69 @@
import type { Page } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import {
criticalAccount,
criticalStorageState,
resetCriticalAccount,
} from "./setup/critical-fixture";
/**
* Lab results from a spreadsheet (#1215), at phone width, against the real
* server: the mapping is suggested from the headers, the check reports a
* refused row by line and writes nothing, the import writes the rest, and a
* second import of the same file only finds duplicates.
* Lab results from a spreadsheet (#1215), against the real server: the
* mapping is suggested from the headers, the check reports a refused row by
* line and writes nothing, the import writes the rest, the imported row is on
* the labs page, and a second import of the same file only finds duplicates.
*
* The account is the labs journey's own, one per project
* (`critical-fixture.ts`), emptied of results first. Before v1.46 this ran on
* the shared account in both projects, so the "only duplicates" verdict of
* one project could be the other project's first import.
*/
const ANALYTE = "E2E Marker CSV";
function csv(): string {
const day = new Date(Date.now() - 30 * 86_400_000).toISOString().slice(0, 10);
return [
"Analyte;Value;Unit;Date",
`E2E Marker CSV;5,4;mmol/L;${day}`,
`E2E Marker CSV;1.234;mmol/L;${day}`,
// A decimal comma, as a German lab export writes it.
`${ANALYTE};5,4;mmol/L;${day}`,
// Either 1.234 or 1234: refused as ambiguous rather than guessed.
`${ANALYTE};1.234;mmol/L;${day}`,
].join("\n");
}
/** The account's lab results, as the list route answers them. */
async function storedResults(
page: Page,
): Promise<Array<{ analyte: string; value: number | null; unit: string }>> {
const res = await page.request.get("/api/labs");
expect(res.status()).toBe(200);
const { data } = (await res.json()) as {
data: {
results: Array<{ analyte: string; value: number | null; unit: string }>;
};
};
return data.results.map(({ analyte, value, unit }) => ({
analyte,
value,
unit,
}));
}
test.describe("record CSV import", () => {
test.use({
storageState: STORAGE_STATE_PATH,
viewport: { width: 390, height: 844 },
test.use({ storageState: criticalStorageState("labs") });
test.beforeEach(async ({}, testInfo) => {
// Fixture hygiene: what a previous run or a retry left on the account.
await resetCriticalAccount(criticalAccount("labs", testInfo), [
"DELETE FROM lab_results WHERE user_id = $1",
]);
});
test("checks, imports and then only finds duplicates", async ({ page }) => {
test("checks, imports, lists the result and then only finds duplicates @critical", async ({
page,
}) => {
await page.goto("/settings/export");
await page.locator('[data-slot="record-import-open-labs"]').click();
const sheet = page.locator('[data-slot="record-import-sheet"]');
@@ -48,19 +88,42 @@ test.describe("record CSV import", () => {
await expect(
page.locator('[data-slot="record-import-error-row"]'),
).toHaveCount(1);
// The check is a dry run.
expect(await storedResults(page)).toEqual([]);
await page.locator('[data-slot="record-import-run"]').click();
await expect(
page.locator('[data-slot="record-import-done"]'),
).toBeVisible();
expect(await storedResults(page)).toEqual([
{ analyte: ANALYTE, value: 5.4, unit: "mmol/L" },
]);
// Second run of the same file.
await page.keyboard.press("Escape");
await page.locator('[data-slot="record-import-open-labs"]').click();
await page.locator('[data-slot="record-import-file"]').setInputFiles(file);
await page.locator('[data-slot="record-import-check"]').click();
await expect(
page.locator('[data-slot="record-import-run"]'),
).toBeDisabled();
await test.step("the result is on the labs page", async () => {
await page.goto("/labs");
const analyte = page
.locator('[data-slot="lab-list-analyte"]')
.filter({ hasText: ANALYTE })
.filter({ visible: true });
await expect(analyte).toHaveCount(1);
// The cell the name heads carries the latest reading under it.
await expect(analyte.locator("xpath=../..")).toContainText("5.4");
});
await test.step("a second run of the same file", async () => {
await page.goto("/settings/export");
await page.locator('[data-slot="record-import-open-labs"]').click();
await page
.locator('[data-slot="record-import-file"]')
.setInputFiles(file);
await page.locator('[data-slot="record-import-check"]').click();
await expect(
page.locator('[data-slot="record-import-checked"]'),
).toBeVisible();
await expect(
page.locator('[data-slot="record-import-run"]'),
).toBeDisabled();
expect(await storedResults(page)).toHaveLength(1);
});
});
});
+2 -2
View File
@@ -8,7 +8,7 @@ import { settleBeforeMeasure } from "./utils/settle";
* v1.25.11 settings fix): the scroll viewport must never extend far past
* the last content element. The rule the guard pins is structural — only
* the AuthShell owns the scroll height and the bottom padding
* (`pt-6 pb-20` on the wrapper, plus the `<md` bottom-nav clearance on
* (`pt-6 pb-20` on the wrapper, `pb-24` in the desktop shell, plus the `<md` bottom-nav clearance on
* `<main>`); a page/section must never add its own viewport-height
* reserve or bottom gutter, because every such nested reserve stacks on
* the shell's own budget and reopens the dark-band-below-the-last-card
@@ -45,7 +45,7 @@ const ROUTES = [
] as const;
const VIEWPORTS = [
{ name: "desktop", width: 1440, height: 900, expectedPad: 80 }, // wrapper pb-20
{ name: "desktop", width: 1440, height: 900, expectedPad: 96 }, // wrapper shell-desktop:pb-24
{ name: "phone", width: 390, height: 844, expectedPad: 144 }, // pb-20 + main pb-16
] as const;
+82
View File
@@ -0,0 +1,82 @@
import type { Page } from "@playwright/test";
import { expect, test } from "./setup/test";
import {
criticalAccount,
criticalStorageState,
resetCriticalAccount,
} from "./setup/critical-fixture";
/**
* A settings change is the server's, not the tab's.
*
* The time-format choice is changed through the account settings, then read
* back three ways that a client-side cache could not fake: after a reload, in
* a second browser context that shares the session but none of the first
* one's storage, and on `/api/auth/me`, which every client renders from.
*
* The account is the settings journey's own, one per project
* (`critical-fixture.ts`), and is put back to the default first.
*/
async function openTimeFormat(page: Page) {
await page.goto("/settings/account");
const select = page.getByTestId("settings-time-format-select");
await expect(select).toBeVisible({ timeout: 30_000 });
// The control is disabled until the account payload has landed.
await expect(select).toBeEnabled();
return select;
}
async function publishedTimeFormat(page: Page): Promise<string> {
const res = await page.request.get("/api/auth/me");
expect(res.status()).toBe(200);
return ((await res.json()) as { data: { timeFormat: string } }).data
.timeFormat;
}
test.describe("settings persistence", () => {
test.use({ storageState: criticalStorageState("settings") });
test.beforeEach(async ({}, testInfo) => {
await resetCriticalAccount(criticalAccount("settings", testInfo), [
"UPDATE users SET time_format = 'AUTO' WHERE id = $1",
]);
});
test("a time-format change survives a reload and reaches a second browser @critical", async ({
page,
browser,
}, testInfo) => {
const select = await openTimeFormat(page);
await expect(select).toHaveValue("AUTO");
const saved = page.waitForResponse(
(res) =>
new URL(res.url()).pathname === "/api/user/profile" &&
res.request().method() === "PATCH",
);
await select.selectOption("H12");
expect((await saved).status()).toBe(200);
expect(await publishedTimeFormat(page)).toBe("H12");
await page.reload();
await expect(page.getByTestId("settings-time-format-select")).toHaveValue(
"H12",
);
// Same session cookie, nothing else: no local storage, no IndexedDB
// query snapshot, no in-memory cache.
const other = await browser.newContext({
storageState: criticalAccount("settings", testInfo).storageStatePath,
baseURL: testInfo.project.use.baseURL,
});
try {
const second = await other.newPage();
await expect(await openTimeFormat(second)).toHaveValue("H12");
} finally {
await other.close();
}
});
});
+11 -1
View File
@@ -33,7 +33,17 @@ import {
type SetupScreen,
} from "./setup-flow-helpers";
const LOCALES = ["en", "de", "es", "fr", "it", "pl", "ko", "nl"] as const;
const LOCALES = [
"en",
"de",
"es",
"fr",
"it",
"pl",
"ko",
"nl",
"pt-BR",
] as const;
const VIEWPORTS = [
{ label: "desktop 1280", width: 1280, height: 800 },
+253
View File
@@ -0,0 +1,253 @@
/**
* Accounts for the `@critical` journeys: one per journey AND per project.
*
* The `@critical` journeys run against the real server with no `/api` stubs
* (`src/__tests__/e2e-critical-no-api-stub-guard.test.ts` holds that line), so
* every verdict they read is a statement about a real record. Two things would
* make those verdicts somebody else's:
*
* - a sibling spec writing into the same account. The shared fixture account
* is read and written by sixty files, so "the dashboard tile shows the
* weight I just entered" is only true of an account nobody else writes;
* - the same journey running in the other project. Every other journey that
* owns its account answers that by running in ONE project (the long list in
* `playwright.config.ts`). These journeys are the release gate and are
* meant to hold at both widths, so instead of dropping a project each
* project gets its own account: `chromium-desktop` and `chromium-mobile`
* run the same file in parallel and never meet.
*
* The accounts are seeded and signed in by `global-setup.ts`, like every
* other fixture account; this module only names them and resets what the
* journeys write. It talks to Postgres through `pg` for the reason
* `global-setup.ts` gives (Playwright's loader cannot load the generated
* Prisma client).
*/
import { resolve } from "node:path";
import type { TestInfo } from "@playwright/test";
import pg from "pg";
/** The journeys that own an account pair. */
export const CRITICAL_JOURNEYS = [
"auth",
"measurement",
"mood",
"labs",
"wellbeing",
"settings",
// The fresh account a full backup is imported into. The backup journey
// runs in the desktop project only (`playwright.config.ts` says why), so
// only the desktop account of this pair is ever written.
"restore",
] as const;
export type CriticalJourney = (typeof CRITICAL_JOURNEYS)[number];
/**
* Journeys that sign in through the form in every test and so get no jar.
* The sign-in journey ends the sessions it opens; a jar captured once in
* global setup would be dead for a retry, and a retry has to start from the
* same place as the first attempt.
*/
export const FORM_SIGN_IN_JOURNEYS: ReadonlySet<CriticalJourney> = new Set([
"auth",
]);
/** The projects the journeys run in; each gets its own account per journey. */
export const CRITICAL_PROJECTS = {
"chromium-desktop": "d",
"chromium-mobile": "m",
} as const;
type CriticalProject = keyof typeof CRITICAL_PROJECTS;
export interface CriticalAccount {
username: string;
email: string;
password: string;
storageStatePath: string;
}
function accountFor(
journey: CriticalJourney,
suffix: (typeof CRITICAL_PROJECTS)[CriticalProject],
): CriticalAccount {
const username = `e2e-critical-${journey}-${suffix}`;
return {
username,
email: `${username}@healthlog.test`,
// Fixture credential for a throwaway test database, like every other
// account in `global-setup.ts`. Long and mixed so it would also clear the
// registration strength gate.
password: `Cq7!${journey}-${suffix}-Wz4pLx`,
storageStatePath: resolve(
process.cwd(),
`e2e/setup/storageStateCritical-${journey}-${suffix}.json`,
),
};
}
/** Every critical account, for `global-setup.ts` to seed and sign in. */
export function allCriticalAccounts(): Array<
CriticalAccount & { journey: CriticalJourney }
> {
return CRITICAL_JOURNEYS.flatMap((journey) =>
Object.values(CRITICAL_PROJECTS).map((suffix) => ({
journey,
...accountFor(journey, suffix),
})),
);
}
/**
* The account a journey uses in the project the test is running in. A project
* outside the map has no account on purpose: a critical journey that grows a
* third project has to say which account it reads.
*/
export function criticalAccount(
journey: CriticalJourney,
testInfo: TestInfo,
): CriticalAccount {
const suffix = CRITICAL_PROJECTS[testInfo.project.name as CriticalProject];
if (!suffix) {
throw new Error(
`[critical-fixture] no ${journey} account for project ${testInfo.project.name}`,
);
}
return accountFor(journey, suffix);
}
/**
* A `test.use({ storageState })` value that picks the jar of the running
* project's account.
*/
export function criticalStorageState(journey: CriticalJourney) {
return async (
{}: object,
use: (path: string) => Promise<void>,
testInfo: TestInfo,
): Promise<void> => {
await use(criticalAccount(journey, testInfo).storageStatePath);
};
}
async function withPool<T>(fn: (pool: pg.Pool) => Promise<T>): Promise<T> {
const url = process.env.DATABASE_URL;
if (!url) throw new Error("[critical-fixture] DATABASE_URL is not set");
const pool = new pg.Pool({ connectionString: url });
try {
return await fn(pool);
} finally {
await pool.end();
}
}
/**
* Run statements against one critical account, with `$1` bound to its id.
* Fixture hygiene only: what a previous run or a retry left behind. Nothing a
* journey asserts is written here.
*/
export async function resetCriticalAccount(
account: CriticalAccount,
statements: readonly string[],
): Promise<void> {
await withPool(async (pool) => {
const res = await pool.query<{ id: string }>(
"SELECT id FROM users WHERE username = $1",
[account.username],
);
const id = res.rows[0]?.id;
if (!id) {
throw new Error(
`[critical-fixture] ${account.username} not seeded — global-setup must run first`,
);
}
for (const sql of statements) await pool.query(sql, [id]);
});
}
/**
* Clear the login buckets. The ceiling is five attempts per IP per quarter
* hour, the suite shares one IP, and global setup alone signs in more than
* twenty times; a journey that signs in through the form would otherwise meet
* the fixture's 429 rather than the product. Only the auth surfaces' buckets
* are touched, exactly as global setup does between its own logins.
*/
export async function clearLoginBuckets(): Promise<void> {
await withPool(async (pool) => {
await pool.query(`DELETE FROM rate_limits WHERE key LIKE 'auth:%'`);
});
}
/**
* Delete a critical account and create it again, empty, under the same id,
* username and password. The backup journey restores a person's file into
* the account the way the runbook brings one back on a new host, where the
* file names its owner by id; a browser cannot delete and recreate a row
* under a chosen id, so this is the fixture's act, not the journey's.
*/
export async function recreateCriticalAccount(
account: CriticalAccount,
): Promise<void> {
await withPool(async (pool) => {
const client = await pool.connect();
try {
await client.query("BEGIN");
const res = await client.query<{
id: string;
password_hash: string;
module_preferences_json: unknown;
}>(
`SELECT id, password_hash, module_preferences_json
FROM users WHERE username = $1`,
[account.username],
);
const row = res.rows[0];
if (!row) {
throw new Error(`[critical-fixture] ${account.username} not seeded`);
}
await client.query("DELETE FROM users WHERE id = $1", [row.id]);
await client.query(
`INSERT INTO users
(id, username, email, password_hash, role, created_at, updated_at,
onboarding_completed_at, onboarding_tour_completed,
module_preferences_json)
VALUES ($1, $2, $3, $4, 'USER', NOW(), NOW(), NOW(), true, $5::jsonb)`,
[
row.id,
account.username,
account.email,
row.password_hash,
JSON.stringify(row.module_preferences_json),
],
);
await client.query("COMMIT");
} catch (err) {
await client.query("ROLLBACK");
throw err;
} finally {
client.release();
}
});
}
/** How many measurements and mood entries a critical account holds. */
export async function accountRowCounts(
account: CriticalAccount,
): Promise<{ measurements: number; moodEntries: number }> {
return withPool(async (pool) => {
const res = await pool.query<{ measurements: string; mood: string }>(
`SELECT
(SELECT count(*) FROM measurements m
JOIN users u ON u.id = m.user_id WHERE u.username = $1)
AS measurements,
(SELECT count(*) FROM mood_entries e
JOIN users u ON u.id = e.user_id WHERE u.username = $1) AS mood`,
[account.username],
);
return {
measurements: Number(res.rows[0]?.measurements ?? 0),
moodEntries: Number(res.rows[0]?.mood ?? 0),
};
});
}
+187
View File
@@ -33,6 +33,7 @@ import {
} from "@playwright/test";
import pg from "pg";
import { allCriticalAccounts, FORM_SIGN_IN_JOURNEYS } from "./critical-fixture";
import { seedDayDoorsRecord } from "./day-doors-fixture";
import { seedMobileRoutesRecord } from "./mobile-routes-fixture";
@@ -250,6 +251,41 @@ export const TIMELINE_STORAGE_STATE_PATH = resolve(
"e2e/setup/storageStateTimeline.json",
);
/**
* The vault specs' own accounts (`documents.spec.ts`, and the documents
* journeys of `v142-qol.spec.ts`).
*
* The vault's layout (cards or a list, months stacked or side by side) is a
* per-account preference. On the shared account the layout journey turned it
* while the month-grid tests read the default, so either could fail depending
* on which ran first. Each file seeds its own vault into its own account
* (`ensureVaultFixture(username)`).
*/
export const E2E_VAULT = {
email: "e2e-vault@healthlog.test",
username: "e2e-vault",
password: "Vt4!Hq8mZr2wKx6N",
// The upload-cap test sets the instance's cap through the admin console.
role: "ADMIN",
} as const;
export const VAULT_STORAGE_STATE_PATH = resolve(
process.cwd(),
"e2e/setup/storageStateVault.json",
);
export const E2E_VAULT_LAYOUT = {
email: "e2e-vault-layout@healthlog.test",
username: "e2e-vault-layout",
password: "Vl7!Bn3qXw9pRt5M",
role: "USER",
} as const;
export const VAULT_LAYOUT_STORAGE_STATE_PATH = resolve(
process.cwd(),
"e2e/setup/storageStateVaultLayout.json",
);
/**
* The day-doors journey's own account (`day-doors.spec.ts`).
*
@@ -289,6 +325,25 @@ export const SCORE_HISTORY_STORAGE_STATE_PATH = resolve(
"e2e/setup/storageStateScoreHistory.json",
);
/**
* The header-height sweep's own account (`header-height-locales.spec.ts`).
*
* It moves the account's language through every shipped locale; the server
* honours `User.locale` wherever a request carries no locale cookie, so on a
* shared account a parallel spec could render in Korean halfway through.
*/
export const E2E_HEADER_LOCALES = {
email: "e2e-header-locales@healthlog.test",
username: "e2e-header-locales",
password: "Hl8!Tq2vNx6mWr4Z",
role: "USER",
} as const;
export const HEADER_LOCALES_STORAGE_STATE_PATH = resolve(
process.cwd(),
"e2e/setup/storageStateHeaderLocales.json",
);
export const AI_OPTIONAL_STORAGE_STATE_PATH = resolve(
process.cwd(),
"e2e/setup/storageStateAiOptional.json",
@@ -990,6 +1045,32 @@ export default async function globalSetup(config: FullConfig): Promise<void> {
],
);
// The vault specs' accounts — see `E2E_VAULT`. The vault fixture turns
// the documents module on and seeds the documents.
for (const account of [E2E_VAULT, E2E_VAULT_LAYOUT]) {
await pool.query(
`INSERT INTO users
(id, username, email, password_hash, role, created_at, updated_at,
onboarding_completed_at, onboarding_tour_completed)
VALUES ($1, $2, $3, $4, $6, $5, $5, $5, true)
ON CONFLICT (username) DO UPDATE SET
email = EXCLUDED.email,
password_hash = EXCLUDED.password_hash,
role = EXCLUDED.role,
updated_at = EXCLUDED.updated_at,
onboarding_completed_at = EXCLUDED.onboarding_completed_at,
onboarding_tour_completed = EXCLUDED.onboarding_tour_completed`,
[
cuid(),
account.username,
account.email,
await hashPassword(account.password),
now,
account.role,
],
);
}
// The day-doors journey's account. Every module the journey reaches is
// on; its record is written after the login capture.
await pool.query(
@@ -1040,6 +1121,33 @@ export default async function globalSetup(config: FullConfig): Promise<void> {
],
);
// The header-height sweep's account. Default modules and layout, so the
// dashboard header carries what a fresh account's does; the language is
// put back to English because the sweep leaves it on its last locale.
await pool.query(
`INSERT INTO users
(id, username, email, password_hash, role, created_at, updated_at,
onboarding_completed_at, onboarding_tour_completed,
module_preferences_json, locale)
VALUES ($1, $2, $3, $4, 'USER', $5, $5, $5, true, NULL, 'en')
ON CONFLICT (username) DO UPDATE SET
email = EXCLUDED.email,
password_hash = EXCLUDED.password_hash,
updated_at = EXCLUDED.updated_at,
onboarding_completed_at = EXCLUDED.onboarding_completed_at,
onboarding_tour_completed = EXCLUDED.onboarding_tour_completed,
module_preferences_json = NULL,
dashboard_widgets_json = NULL,
locale = 'en'`,
[
cuid(),
E2E_HEADER_LOCALES.username,
E2E_HEADER_LOCALES.email,
await hashPassword(E2E_HEADER_LOCALES.password),
now,
],
);
// The notification-dispatch journey's account. Seeded like the others;
// its channels, devices, ledger rows and preferences are reset by
// `e2e/setup/notification-fixture.ts` before every test, because the
@@ -1364,6 +1472,28 @@ export default async function globalSetup(config: FullConfig): Promise<void> {
// surfaces' own buckets are cleared; nothing else in the table is touched.
await pool.query(`DELETE FROM rate_limits WHERE key LIKE 'auth:%'`);
// Every other bucket the suite can fill, for the same reason. A bucket is
// keyed on the account it meters (`<surface>:<id>`, sometimes with a
// further segment) or, on an anonymous surface, on the caller's address,
// and several of them outlive a run by up to an hour: the check-in
// ceiling, the per-account write bucket, the export and import buckets.
// A second run on the same database then met the first run's counts.
// Cleared are the buckets of the fixture accounts (every one of them is
// an `@healthlog.test` address or an `e2e-` record) and those of the
// loopback addresses the suite calls from; a developer's own accounts
// and their buckets are left alone.
await pool.query(
`DELETE FROM rate_limits r
USING users u
WHERE (u.email LIKE '%@healthlog.test' OR u.username LIKE 'e2e-%')
AND position(u.id IN r.key) > 0`,
);
await pool.query(
`DELETE FROM rate_limits
WHERE key LIKE '%:127.0.0.1' OR key LIKE '%:::1'
OR key LIKE '%:::ffff:127.0.0.1' OR key LIKE '%:unknown'`,
);
// One marker weight per record. Re-seeded by delete-then-insert so a
// re-run cannot accumulate duplicates and so an edited constant takes
// effect rather than sitting beside the old value.
@@ -1539,6 +1669,49 @@ export default async function globalSetup(config: FullConfig): Promise<void> {
[cuid(), now, E2E_BACKUP_ADMIN.username, E2E_BACKUP_DELEGATE.username],
);
// ── the @critical journeys' accounts ─────────────────────────────────
//
// One per journey and per project; `critical-fixture.ts` says why. Every
// module a journey reaches is switched on (wellbeing and labs are opt-in),
// and the passkeys the sign-in journey registers go with the row's reset,
// so a re-run registers into an empty list rather than beside the last
// run's credential.
for (const account of allCriticalAccounts()) {
await pool.query(
`INSERT INTO users
(id, username, email, password_hash, role, created_at, updated_at,
onboarding_completed_at, onboarding_tour_completed,
module_preferences_json)
VALUES ($1, $2, $3, $4, 'USER', $5, $5, $5, true, $6::jsonb)
ON CONFLICT (username) DO UPDATE SET
email = EXCLUDED.email,
password_hash = EXCLUDED.password_hash,
updated_at = EXCLUDED.updated_at,
onboarding_completed_at = EXCLUDED.onboarding_completed_at,
onboarding_tour_completed = EXCLUDED.onboarding_tour_completed,
module_preferences_json = EXCLUDED.module_preferences_json,
totp_confirmed_at = NULL`,
[
cuid(),
account.username,
account.email,
await hashPassword(account.password),
now,
JSON.stringify({
mood: true,
labs: true,
mentalHealth: true,
medications: true,
}),
],
);
await pool.query(
`DELETE FROM passkeys
WHERE user_id = (SELECT id FROM users WHERE username = $1)`,
[account.username],
);
}
// Console (instead of structured logging) is intentional here —
// global-setup runs outside the app's logging context, and the
// line is useful when debugging a CI failure where the seed didn't
@@ -1646,6 +1819,10 @@ export default async function globalSetup(config: FullConfig): Promise<void> {
// The timeline journey's jar — see `E2E_TIMELINE`.
await capture(E2E_TIMELINE, TIMELINE_STORAGE_STATE_PATH);
// The vault specs' jars — see `E2E_VAULT`.
await capture(E2E_VAULT, VAULT_STORAGE_STATE_PATH);
await capture(E2E_VAULT_LAYOUT, VAULT_LAYOUT_STORAGE_STATE_PATH);
// The day-doors journey's jar, then its record — see `E2E_DAY_DOORS`.
await capture(E2E_DAY_DOORS, DAY_DOORS_STORAGE_STATE_PATH);
await seedDayDoorsRecord(baseURL, DAY_DOORS_STORAGE_STATE_PATH);
@@ -1653,6 +1830,9 @@ export default async function globalSetup(config: FullConfig): Promise<void> {
// The score-history journey's jar — see `E2E_SCORE_HISTORY`.
await capture(E2E_SCORE_HISTORY, SCORE_HISTORY_STORAGE_STATE_PATH);
// The header-height sweep's jar — see `E2E_HEADER_LOCALES`.
await capture(E2E_HEADER_LOCALES, HEADER_LOCALES_STORAGE_STATE_PATH);
await capture(E2E_SCOPE_DELEGATE, SCOPE_DELEGATE_STORAGE_STATE_PATH);
await capture(E2E_SCOPE_DELEGATE, SCOPE_A11Y_STORAGE_STATE_PATH);
await capture(E2E_SCOPE_DELEGATE, SCOPE_CAPABILITIES_STORAGE_STATE_PATH);
@@ -1708,6 +1888,13 @@ export default async function globalSetup(config: FullConfig): Promise<void> {
`UPDATE users SET totp_confirmed_at = $2 WHERE username = $1`,
[E2E_SETUP_CHILD.username, now],
);
// The @critical journeys' jars. The sign-in journey signs in through the
// form in every test, so its account gets none.
for (const account of allCriticalAccounts()) {
if (FORM_SIGN_IN_JOURNEYS.has(account.journey)) continue;
await capture(account, account.storageStatePath);
}
} finally {
await pool.end();
}
+2 -1
View File
@@ -57,7 +57,8 @@ export async function resetSetupFlow(username: string): Promise<void> {
WHERE key IN (
'onboarding-answers:' || $1,
'onboarding-restart:' || $1,
'managed-profile:create:' || $1
'managed-profile:create:' || $1,
'record-write:' || $1
)`,
[id],
);
+60 -16
View File
@@ -188,6 +188,39 @@ function jpegWithGps(marker: string): Buffer {
export const KNIE_EPISODE_ID = "e2evaultknie000000000001";
export const MRT_DOC_ID = "e2evaultmrt0000000000001";
/**
* The vault corpus's ids for one account. The shared account keeps the ids
* above; an account of its own gets the same corpus under ids of the same
* length with its own five-letter tag in place of `vault`, so two accounts'
* vaults never claim one row. Only `ensureVaultFixture` and
* `seedNamespaceDocs` take an account.
*/
const VAULT_TAGS: Record<string, string> = {
"e2e-vault": "vspec",
"e2e-vault-layout": "vqolx",
};
function vaultTag(username: string): string {
if (username === E2E_USER.username) return "vault";
const tag = VAULT_TAGS[username];
if (!tag) throw new Error(`[vault-fixture] no id tag for ${username}`);
return tag;
}
function inVault(id: string, username: string): string {
return id.replace(/^e2evault/, `e2e${vaultTag(username)}`);
}
export function vaultFixtureIds(username: string): {
knieEpisodeId: string;
mrtDocId: string;
} {
return {
knieEpisodeId: inVault(KNIE_EPISODE_ID, username),
mrtDocId: inVault(MRT_DOC_ID, username),
};
}
/** A stored PDF the AI-assist specs drive (mocked provider); title/kind are
* bland so an applied suggestion is visibly different. */
export const AI_PROBE_DOC_ID = "e2evaultaiprobe000000001";
@@ -268,10 +301,10 @@ async function withVaultSeedLock(
}
}
async function getUserId(pool: pg.Pool): Promise<string> {
async function getUserId(pool: pg.Pool, username: string): Promise<string> {
const res = await pool.query<{ id: string }>(
"SELECT id FROM users WHERE username = $1",
[E2E_USER.username],
[username],
);
const id = res.rows[0]?.id;
if (!id) {
@@ -344,7 +377,7 @@ export async function resetEncounters(): Promise<void> {
if (!url) throw new Error("[vault-fixture] DATABASE_URL is not set");
const pool = new pg.Pool({ connectionString: url });
try {
const userId = await getUserId(pool);
const userId = await getUserId(pool, E2E_USER.username);
await pool.query(
"DELETE FROM encounter_document_links WHERE user_id = $1",
[userId],
@@ -360,13 +393,15 @@ export async function resetEncounters(): Promise<void> {
* corpus. Safe to call from every spec's beforeAll — every statement
* upserts.
*/
export async function ensureVaultFixture(): Promise<void> {
export async function ensureVaultFixture(
username: string = E2E_USER.username,
): Promise<void> {
const url = process.env.DATABASE_URL;
if (!url) throw new Error("[vault-fixture] DATABASE_URL is not set");
const pool = new pg.Pool({ connectionString: url });
try {
await withVaultSeedLock(pool, async () => {
const userId = await getUserId(pool);
const userId = await getUserId(pool, username);
// Opt-in module: merge, never overwrite other module preferences.
await pool.query(
@@ -385,7 +420,7 @@ export async function ensureVaultFixture(): Promise<void> {
VALUES ($1, $2, 'Knie', 'INJURY', 'ACUTE', '2025-09-01T00:00:00Z', NOW(), NOW())
ON CONFLICT (id) DO UPDATE SET
label = EXCLUDED.label, deleted_at = NULL, updated_at = NOW()`,
[KNIE_EPISODE_ID, userId],
[inVault(KNIE_EPISODE_ID, username), userId],
);
// 60 background documents spread over 36 months (newest ~current).
@@ -393,7 +428,10 @@ export async function ensureVaultFixture(): Promise<void> {
for (let i = 0; i < 60; i++) {
const monthsBack = Math.floor((i * 36) / 60);
const anchor = new Date(Date.UTC(2026, 5 - monthsBack, 1 + (i % 25)));
const id = `e2evaultdoc${String(i).padStart(12, "0")}`;
const id = inVault(
`e2evaultdoc${String(i).padStart(12, "0")}`,
username,
);
docs.push({
id,
kind: FIXTURE_KINDS[i % FIXTURE_KINDS.length],
@@ -406,17 +444,17 @@ export async function ensureVaultFixture(): Promise<void> {
}
// The MRT report + two adjacent images, same date, all linked to Knie.
docs.push({
id: MRT_DOC_ID,
id: inVault(MRT_DOC_ID, username),
kind: "IMAGING",
title: "MRT Knie",
filename: "mrt-knie.pdf",
mimeType: "application/pdf",
documentDate: MRT_DOC_DATE,
bytes: tinyPdf(MRT_DOC_ID),
bytes: tinyPdf(inVault(MRT_DOC_ID, username)),
});
for (const n of [1, 2]) {
docs.push({
id: `e2evaultimg000000000000${n}`,
id: inVault(`e2evaultimg000000000000${n}`, username),
kind: "IMAGING",
title: `MRT Aufnahme ${n}`,
filename: `mrt-aufnahme-${n}.png`,
@@ -432,14 +470,19 @@ export async function ensureVaultFixture(): Promise<void> {
MRT_DOC_ID,
"e2evaultimg0000000000001",
"e2evaultimg0000000000002",
];
].map((id) => inVault(id, username));
for (const [i, docId] of linked.entries()) {
await pool.query(
`INSERT INTO document_condition_links
(id, document_id, episode_id, user_id, created_at)
VALUES ($1, $2, $3, $4, NOW())
ON CONFLICT (document_id, episode_id) DO NOTHING`,
[`e2evaultlink00000000000${i + 1}`, docId, KNIE_EPISODE_ID, userId],
[
inVault(`e2evaultlink00000000000${i + 1}`, username),
docId,
inVault(KNIE_EPISODE_ID, username),
userId,
],
);
}
});
@@ -462,7 +505,7 @@ export async function ensureVaultAiFixture(): Promise<void> {
const pool = new pg.Pool({ connectionString: url });
try {
await withVaultSeedLock(pool, async () => {
const userId = await getUserId(pool);
const userId = await getUserId(pool, E2E_USER.username);
await upsertDocs(pool, userId, [
{
@@ -522,12 +565,13 @@ export async function ensureVaultAiFixture(): Promise<void> {
export async function seedNamespaceDocs(
prefix: string,
count: number,
username: string = E2E_USER.username,
): Promise<void> {
const url = process.env.DATABASE_URL;
if (!url) throw new Error("[vault-fixture] DATABASE_URL is not set");
const pool = new pg.Pool({ connectionString: url });
try {
const userId = await getUserId(pool);
const userId = await getUserId(pool, username);
const docs: DocRow[] = [];
for (let i = 0; i < count; i++) {
const id = `e2e${prefix}${String(i).padStart(8, "0")}`;
@@ -569,7 +613,7 @@ export async function ensureProcedureFixture(): Promise<void> {
await seedNamespaceDocs(PROCEDURE_DOC_PREFIX, 3);
const pool = new pg.Pool({ connectionString: url });
try {
const userId = await getUserId(pool);
const userId = await getUserId(pool, E2E_USER.username);
const procedures = [
{
id: PROCEDURE_KNEE_ID,
@@ -636,7 +680,7 @@ export async function ensureShareDocFixture(): Promise<void> {
if (!url) throw new Error("[vault-fixture] DATABASE_URL is not set");
const pool = new pg.Pool({ connectionString: url });
try {
const userId = await getUserId(pool);
const userId = await getUserId(pool, E2E_USER.username);
await pool.query(
`UPDATE users
SET module_preferences_json =
+740
View File
@@ -0,0 +1,740 @@
import type { Locator, Page } from "@playwright/test";
import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities";
import { mockDay } from "./setup/day-mock";
import { expect, test } from "./setup/test";
import { MOBILE_ROUTES_STORAGE_STATE_PATH } from "./setup/global-setup";
/**
* The app shell on a tablet, under a finger, at every iPad size in both
* orientations: the rail reaches every destination, `<main>` scrolls to its
* last line, and closing a sheet or a dialog hands the scroll back.
*
* Runs in `webkit-tablet` (iPad, the engine an iPad runs; CI's WebKit job)
* and `chromium-tablet` (an Android tablet, in the Chromium shards). Both
* report `pointer: coarse`, so the rail keeps its 44 px rows and its list
* overflows in landscape, which is the case that broke.
*
* What a browser under test cannot show is the iPadOS toolbar moving the
* visible height away from `100dvh`; `shell-viewport.test.ts` holds the
* decisions that take that disagreement out, and this file measures that the
* shell they produce fills the window exactly and leaves the document
* nothing to scroll or bounce.
*
* Stable hooks only: `sidebar-nav-region`, `sidebar-nav-scroll-cue`,
* `sidebar-footer`, `main-content-wrapper`, `bottom-nav`,
* `bottom-nav-more` (test id), `command-palette-trigger`,
* `command-palette-input`, `day-panel`.
*/
/** iPad and Android tablet sizes, CSS px, with the browser's own bars where they take room. */
const VIEWPORTS = [
{ name: "iPad mini portrait", width: 744, height: 1133 },
{ name: "iPad mini landscape", width: 1133, height: 744 },
{ name: "iPad mini landscape, Safari bars", width: 1133, height: 670 },
{ name: "iPad landscape, Safari bars", width: 1000, height: 690 },
{ name: "iPad Air 11 portrait", width: 820, height: 1180 },
{ name: "iPad Air 11 landscape", width: 1180, height: 820 },
{ name: "iPad Pro 11 portrait", width: 834, height: 1194 },
{ name: "iPad Pro 11 landscape", width: 1194, height: 834 },
{ name: "iPad Pro 13 portrait", width: 1024, height: 1366 },
{ name: "iPad Pro 13 landscape", width: 1366, height: 1024 },
{ name: "Split View two thirds", width: 694, height: 1024 },
{ name: "Split View half, landscape", width: 590, height: 820 },
{ name: "Slide Over", width: 375, height: 1024 },
{ name: "Android tablet landscape", width: 1138, height: 712 },
] as const;
test.use({ storageState: MOBILE_ROUTES_STORAGE_STATE_PATH });
async function openDashboard(page: Page) {
await page.goto("/", { waitUntil: "domcontentloaded" });
await expect(
page.locator('#main-content [data-slot="main-content-wrapper"]'),
).toBeVisible();
// The module-gated destinations join the rail once `/api/auth/me` lands.
await expect(page.locator('aside nav a[href="/medications"]')).toHaveCount(1);
}
/** The shell fills the window exactly and the document has nothing to scroll. */
async function expectShellPinned(page: Page) {
const m = await page.evaluate(() => {
const shell = document.querySelector<HTMLElement>(".shell-safe-area")!;
const rect = shell.getBoundingClientRect();
const root = document.scrollingElement!;
const html = getComputedStyle(document.documentElement);
return {
top: rect.top,
bottom: rect.bottom,
left: rect.left,
right: rect.right,
innerHeight,
innerWidth,
docScroll: root.scrollHeight - root.clientHeight,
overflowY: html.overflowY,
overscroll: html.overscrollBehaviorY,
};
});
expect(m.top).toBe(0);
expect(m.left).toBe(0);
expect(Math.abs(m.bottom - m.innerHeight)).toBeLessThanOrEqual(0.5);
expect(Math.abs(m.right - m.innerWidth)).toBeLessThanOrEqual(0.5);
expect(m.docScroll).toBeLessThanOrEqual(0);
expect(m.overflowY).toBe("hidden");
// No rubber band on the document: on iOS it held the next swipe.
expect(m.overscroll).toBe("none");
}
function railNav(page: Page): Locator {
return page.locator('aside [data-slot="sidebar-nav-region"] > nav');
}
/** Scrolls `<main>` to its end, waiting out content that grows on the way. */
async function scrollMainToEnd(page: Page) {
let last = -1;
for (let i = 0; i < 8; i += 1) {
const height = await page.evaluate(() => {
const main = document.getElementById("main-content")!;
main.scrollTop = main.scrollHeight;
return main.scrollHeight;
});
if (height === last) return;
last = height;
await page.waitForTimeout(250);
}
}
/** Where the page's lowest content ends, and what covers the window's bottom edge. */
async function measureMainEnd(page: Page) {
return page.evaluate(() => {
const main = document.getElementById("main-content")!;
const wrapper = main.querySelector<HTMLElement>(
'[data-slot="main-content-wrapper"]',
)!;
let contentBottom = 0;
for (const el of wrapper.querySelectorAll<HTMLElement>("*")) {
const cs = getComputedStyle(el);
if (cs.position === "fixed" || cs.visibility === "hidden") continue;
const rect = el.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) continue;
let p = el.parentElement;
let nested = false;
while (p && p !== main) {
const ps = getComputedStyle(p);
if (
/(auto|scroll|hidden)/.test(ps.overflowY) &&
p.scrollHeight > p.clientHeight + 1
) {
nested = true;
break;
}
p = p.parentElement;
}
if (nested) continue;
contentBottom = Math.max(contentBottom, rect.bottom);
}
const visible = (el: Element | null): el is HTMLElement =>
!!el &&
getComputedStyle(el).display !== "none" &&
el.getBoundingClientRect().height > 0;
const bar = document.querySelector('[data-slot="bottom-nav"]');
const mainRect = main.getBoundingClientRect();
return {
atEnd: main.scrollTop + main.clientHeight >= main.scrollHeight - 1,
scrolls: main.scrollHeight > main.clientHeight,
contentBottom,
mainBottom: mainRect.bottom,
barTop: visible(bar) ? bar.getBoundingClientRect().top : null,
};
});
}
for (const vp of VIEWPORTS) {
test.describe(`${vp.name} (${vp.width}x${vp.height})`, () => {
test.beforeEach(async ({ page }) => {
await page.setViewportSize({ width: vp.width, height: vp.height });
});
test("every rail destination scrolls into view and takes a tap", async ({
page,
}) => {
await openDashboard(page);
await expectShellPinned(page);
const aside = page.locator("aside");
const railShown = vp.width >= 768;
if (!railShown) {
// Split View and Slide Over get the phone shell: the bar, no rail.
await expect(aside).toBeHidden();
await expect(page.locator('[data-slot="bottom-nav"]')).toBeVisible();
return;
}
await expect(aside).toBeVisible();
// Nothing in the rail is clipped by the rail: the logo band, the list
// region, the pinned footer and the account row all sit inside it, in
// that order, and the list region alone gives up height.
const parts = await page.evaluate(() => {
const asideEl = document.querySelector("aside")!;
const box = asideEl.getBoundingClientRect();
const pick = (sel: string) =>
asideEl.querySelector(sel)!.getBoundingClientRect();
const column = asideEl.querySelector('[data-slot="sidebar-column"]')!;
const user = column.lastElementChild!.getBoundingClientRect();
return {
clipped: asideEl.scrollHeight - asideEl.clientHeight,
asideTop: box.top,
asideBottom: box.bottom,
header: pick('[data-slot="sidebar-header"]'),
region: pick('[data-slot="sidebar-nav-region"]'),
footer: pick('[data-slot="sidebar-footer"]'),
user,
overscroll: getComputedStyle(
asideEl.querySelector('[data-slot="sidebar-nav-region"] > nav')!,
).overscrollBehaviorY,
};
});
expect(parts.clipped).toBeLessThanOrEqual(0);
expect(parts.header.top).toBeGreaterThanOrEqual(parts.asideTop - 0.5);
expect(parts.region.top).toBeGreaterThanOrEqual(
parts.header.bottom - 0.5,
);
expect(parts.footer.top).toBeGreaterThanOrEqual(
parts.region.bottom - 0.5,
);
expect(parts.user.top).toBeGreaterThanOrEqual(parts.footer.bottom - 0.5);
expect(parts.user.bottom).toBeLessThanOrEqual(parts.asideBottom + 0.5);
expect(parts.overscroll).toBe("contain");
await expect(
aside.locator('[data-slot="sidebar-collapse-toggle"]'),
).toBeInViewport({ ratio: 1 });
await expect(
aside.locator('[data-slot="nav-settings-link"]'),
).toBeInViewport({ ratio: 1 });
// Every destination: scrolled into the list's view, whole, on top
// (not under the cue, the footer or anything else), and actionable.
const nav = railNav(page);
const links = nav.locator("a[href]");
const count = await links.count();
expect(count).toBeGreaterThanOrEqual(10);
for (let i = 0; i < count; i += 1) {
const link = links.nth(i);
await link.scrollIntoViewIfNeeded();
await expect
.poll(() =>
link.evaluate((el) => {
const navEl = el.closest("nav")!;
const r = el.getBoundingClientRect();
const n = navEl.getBoundingClientRect();
const hit = document.elementFromPoint(
r.left + r.width / 2,
r.top + r.height / 2,
);
return (
r.top >= n.top - 0.5 &&
r.bottom <= n.bottom + 0.5 &&
!!hit &&
el.contains(hit)
);
}),
)
.toBe(true);
await link.click({ trial: true });
}
// A list taller than its region shows the touch cue at the edge that
// has more, and a tap on it is a way there.
const overflows = await nav.evaluate(
(el) => el.scrollHeight > el.clientHeight + 1,
);
const down = page.locator(
'[data-slot="sidebar-nav-scroll-cue"][data-direction="down"]',
);
const up = page.locator(
'[data-slot="sidebar-nav-scroll-cue"][data-direction="up"]',
);
await nav.evaluate((el) => (el.scrollTop = 0));
if (overflows) {
await expect(down).toBeVisible();
await expect(up).toHaveCount(0);
await down.tap();
await expect
.poll(() => nav.evaluate((el) => el.scrollTop))
.toBeGreaterThan(0);
await expect(up).toBeVisible();
await nav.evaluate((el) => (el.scrollTop = el.scrollHeight));
await expect(down).toHaveCount(0);
} else {
await expect(down).toHaveCount(0);
await expect(up).toHaveCount(0);
}
// And the last destination is a real tap away.
const lastLink = links.nth(count - 1);
const href = await lastLink.getAttribute("href");
await lastLink.scrollIntoViewIfNeeded();
await lastLink.tap();
await expect(page).toHaveURL(new RegExp(`${href}$`));
});
// The Coach button's clearance is held by `shell-viewport.test.ts`: the
// button renders only with an AI provider, which the suite's accounts
// do not have, so a check here would never run.
test("main scrolls to its last line, clear of the bottom bar", async ({
page,
}) => {
await openDashboard(page);
await scrollMainToEnd(page);
const end = await measureMainEnd(page);
expect(end.scrolls).toBe(true);
expect(end.atEnd).toBe(true);
expect(end.contentBottom).toBeLessThanOrEqual(end.mainBottom + 0.5);
if (end.barTop !== null) {
expect(end.contentBottom).toBeLessThanOrEqual(end.barTop);
}
await expectShellPinned(page);
});
});
}
/** After a modal closes, the page behind takes pointer and wheel again and scrolls. */
async function expectPageScrollsAgain(page: Page, browserName: string) {
await expect
.poll(() =>
page.evaluate(() => ({
locked: document.body.hasAttribute("data-scroll-locked"),
pointer: getComputedStyle(document.body).pointerEvents,
})),
)
.toEqual({ locked: false, pointer: "auto" });
const before = await page.evaluate(() => {
const main = document.getElementById("main-content")!;
main.scrollTop = 0;
const r = main.getBoundingClientRect();
const x = r.left + r.width / 2;
const y = r.top + r.height / 2;
const hit = document.elementFromPoint(x, y);
const wheel = new WheelEvent("wheel", {
deltaY: 120,
bubbles: true,
cancelable: true,
clientX: x,
clientY: y,
});
hit?.dispatchEvent(wheel);
return {
onMain: !!hit && main.contains(hit),
blocked: wheel.defaultPrevented,
x,
y,
};
});
// Nothing invisible lies over `<main>`, and no scroll lock swallows the
// wheel (react-remove-scroll cancels it outside its own layer).
expect(before.onMain).toBe(true);
expect(before.blocked).toBe(false);
// A real wheel where the engine has one. Mobile WebKit under Playwright
// has none (and no native touch scroll), so there the two checks above
// are the proof: nothing covers `<main>` and nothing cancels its events.
if (browserName === "webkit") return;
await page.mouse.move(before.x, before.y);
await page.mouse.wheel(0, 600);
await expect
.poll(() =>
page.evaluate(() => document.getElementById("main-content")!.scrollTop),
)
.toBeGreaterThan(0);
}
const MODAL_VIEWPORTS = [
{ name: "iPad Air 11 landscape", width: 1180, height: 820 },
{ name: "iPad Air 11 portrait", width: 820, height: 1180 },
{ name: "Split View two thirds", width: 694, height: 1024 },
] as const;
for (const vp of MODAL_VIEWPORTS) {
test.describe(`${vp.name}: scroll after a modal`, () => {
test.beforeEach(async ({ page }) => {
await page.setViewportSize({ width: vp.width, height: vp.height });
});
test("the page scrolls after the command palette dialog closes", async ({
page,
browserName,
}) => {
await openDashboard(page);
await page.locator('[data-slot="command-palette-trigger"]:visible').tap();
const input = page.locator('[data-slot="command-palette-input"]');
await expect(input).toBeVisible();
// The scroll lock makes the body `position: relative`; the shell
// behind the dialog keeps the window's box all the same.
await expectShellPinned(page);
await page.keyboard.press("Escape");
await expect(input).toBeHidden();
await expectPageScrollsAgain(page, browserName);
});
test("the page scrolls after a sheet closes", async ({
page,
browserName,
}) => {
await openDashboard(page);
if (vp.width < 768) {
// The phone shell's More sheet: the draggable bottom sheet.
await page.locator('[data-testid="bottom-nav-more"]').tap();
const sheet = page.locator('[data-testid="bottom-nav-more-sheet"]');
await expect(sheet).toBeVisible();
await expectShellPinned(page);
await sheet.locator('[data-slot="sheet-close-button"]').tap();
await expect(sheet).toBeHidden();
} else {
// The day, a sheet from the right below 1280 px.
await mockDay(page);
const day = new Date(Date.now() - 86_400_000)
.toISOString()
.slice(0, 10);
await page.goto(`/?day=${day}`, { waitUntil: "domcontentloaded" });
const panel = page.locator('[data-slot="day-panel"]');
await expect(panel).toBeVisible();
await expectShellPinned(page);
await page.keyboard.press("Escape");
await expect(panel).toBeHidden();
}
await expectPageScrollsAgain(page, browserName);
});
});
}
test("a vertical swipe that starts on a tab strip is the page's", async ({
page,
}) => {
await page.setViewportSize({ width: 1180, height: 820 });
await page.goto("/checkups", { waitUntil: "domcontentloaded" });
const strip = page.locator('#main-content [data-slot="tabs-list"]').first();
await expect(strip).toBeVisible();
// `pan-x` here refused the vertical pan, so the page did not move.
const touchAction = await strip.evaluate(
(el) => getComputedStyle(el).touchAction,
);
expect(touchAction).toBe("auto");
});
/**
* An iPad without a home button reports a bottom inset of about 20 px, which
* no browser under test does. The shell reads the inset through
* `--safe-area-bottom` (globals.css), so setting the property is the same
* inset to every element that owns the bottom edge.
*/
const HOME_INDICATOR = 20;
async function setBottomInset(page: Page, px: number) {
await page.addStyleTag({
content: `:root { --safe-area-bottom: ${px}px !important; }`,
});
}
/** What paints the window's last row of pixels, sampled across its width. */
async function bottomRowOwners(page: Page) {
return page.evaluate(() => {
const y = innerHeight - 1;
const owners: string[] = [];
// 8 px in lands on the rail at its narrowest (64 px); the rest spread
// across the content column.
for (const x of [
8,
...Array.from({ length: 11 }, (_, i) => (innerWidth * (i + 1)) / 12),
]) {
const hit = document.elementFromPoint(x, y);
const owner = !hit
? "nothing"
: hit === document.documentElement || hit === document.body
? "page"
: hit.closest("aside")
? "rail"
: hit.closest("#main-content")
? "main"
: hit.closest('[data-slot="bottom-nav"]')
? "bar"
: hit.closest('[data-slot="coach-fab"]')
? "fab"
: `other:${hit.tagName}`;
owners.push(owner);
}
return owners;
});
}
const INSET_VIEWPORTS = [
{ name: "iPad mini portrait", width: 744, height: 1133 },
{ name: "iPad Air 11 portrait", width: 820, height: 1180 },
{ name: "iPad Air 11 landscape", width: 1180, height: 820 },
{ name: "iPad Pro 13 landscape", width: 1366, height: 1024 },
{ name: "Split View two thirds", width: 694, height: 1024 },
] as const;
for (const vp of INSET_VIEWPORTS) {
test(`${vp.name}: the shell paints to the edge under a home indicator`, async ({
page,
}) => {
await page.setViewportSize({ width: vp.width, height: vp.height });
await serveAiBlock(page, aiBlockAvailable());
await openDashboard(page);
await setBottomInset(page, HOME_INDICATOR);
await expectShellPinned(page);
// The last row of pixels belongs to the rail and `<main>` (or the bar),
// never to the page behind the shell: that was the band.
const owners = await bottomRowOwners(page);
expect(owners.filter((o) => o === "page" || o === "nothing")).toEqual([]);
const railShown = vp.width >= 768;
const edge = await page.evaluate(() => {
const aside = document.querySelector("aside")!;
const column = aside.querySelector('[data-slot="sidebar-column"]');
const bar = document.querySelector<HTMLElement>(
'[data-slot="bottom-nav"]',
)!;
return {
innerHeight,
asideBottom: aside.getBoundingClientRect().bottom,
lastRailRowBottom:
column?.lastElementChild?.getBoundingClientRect().bottom ?? 0,
barBottom: bar.getBoundingClientRect().bottom,
barPad: parseFloat(getComputedStyle(bar).paddingBottom),
// What a WKWebView browser paints where it keeps the page out: the
// root's own background.
root: getComputedStyle(document.documentElement).backgroundColor,
body: getComputedStyle(document.body).backgroundColor,
};
});
if (railShown) {
expect(owners).toContain("rail");
expect(owners).toContain("main");
// The rail's background runs to the edge; its account row stops
// above the inset.
expect(edge.asideBottom).toBe(edge.innerHeight);
expect(edge.lastRailRowBottom).toBeLessThanOrEqual(
edge.innerHeight - HOME_INDICATOR,
);
expect(edge.root).toBe(edge.body);
} else {
expect(owners).toContain("bar");
expect(edge.barBottom).toBe(edge.innerHeight);
expect(edge.barPad).toBe(HOME_INDICATOR);
expect(edge.root).toBe(edge.body);
}
// At the end of the page, the last line stands clear of the inset, the
// bar and the Coach button.
await scrollMainToEnd(page);
const end = await measureMainEnd(page);
expect(end.atEnd).toBe(true);
expect(end.contentBottom).toBeLessThanOrEqual(
edge.innerHeight - HOME_INDICATOR,
);
if (end.barTop !== null) {
expect(end.contentBottom).toBeLessThanOrEqual(end.barTop);
}
const fab = page.locator('[data-slot="coach-fab"]');
await expect(fab).toBeVisible();
const fabTop = (await fab.boundingBox())!.y;
expect(end.contentBottom).toBeLessThanOrEqual(fabTop);
});
}
/**
* The Coach button over the last card of a nested layout (Insights, Settings,
* a values list): during the scroll it may sit over content, at the end it
* must not. Rendered by serving an `ai` block with the Coach available,
* since the suite has no provider.
*/
const NESTED_PAGES = [
"/insights/blood-pressure",
"/insights/values/BLOOD_PRESSURE_SYS",
"/insights",
"/settings/account",
"/settings/modules",
] as const;
for (const vp of [
{ name: "iPad Air 11 portrait", width: 820, height: 1180 },
{ name: "iPad Air 11 landscape", width: 1180, height: 820 },
] as const) {
for (const inset of [0, HOME_INDICATOR]) {
test(`${vp.name}, inset ${inset}: the Coach button clears the last line of nested layouts`, async ({
page,
}) => {
test.setTimeout(90_000);
await page.setViewportSize({ width: vp.width, height: vp.height });
await serveAiBlock(page, aiBlockAvailable());
for (const path of NESTED_PAGES) {
await page.goto(path, { waitUntil: "domcontentloaded" });
await expect(
page.locator('#main-content [data-slot="main-content-wrapper"]'),
).toBeVisible();
await page.waitForLoadState("networkidle");
if (inset) await setBottomInset(page, inset);
const fab = page.locator('[data-slot="coach-fab"]');
await expect(fab).toBeVisible();
// A section near the end can still load once the scroll reaches it
// and show its skeleton for a moment; what has to hold is the page
// as it ends up, so scroll to the end and measure again until the
// last line is in place. Content that stays under the button fails.
const coveredAtEnd = async () => {
await scrollMainToEnd(page);
return page.evaluate(() => {
const f = document
.querySelector('[data-slot="coach-fab"]')!
.getBoundingClientRect();
const main = document.getElementById("main-content")!;
const under: string[] = [];
for (const el of main.querySelectorAll<HTMLElement>("*")) {
const cs = getComputedStyle(el);
if (cs.position === "fixed" || cs.visibility === "hidden")
continue;
const paints =
(el.childElementCount === 0 && !!el.textContent?.trim()) ||
cs.borderBottomWidth !== "0px" ||
cs.backgroundColor !== "rgba(0, 0, 0, 0)";
if (!paints) continue;
const b = el.getBoundingClientRect();
if (!b.width || !b.height) continue;
if (
b.right > f.left &&
b.left < f.right &&
b.bottom > f.top &&
b.top < f.bottom
) {
under.push(el.getAttribute("data-slot") ?? el.tagName);
}
}
return under;
});
};
await expect
.poll(coveredAtEnd, {
message: `${path}: content under the Coach button`,
timeout: 15_000,
})
.toEqual([]);
}
});
}
}
/**
* A side inset. An iPad in a Safari tab laid the shell out 10 px in from the
* left edge in portrait, which no browser under test reports. The shell and
* the rail read the side insets through `--safe-area-left` / `-right`
* (globals.css), so setting them is the same inset to both.
*/
const SIDE_INSET = 10;
async function setSideInsets(page: Page, px: number) {
await page.addStyleTag({
content: `:root { --safe-area-left: ${px}px !important; --safe-area-right: ${px}px !important; }`,
});
}
const SIDE_INSET_VIEWPORTS = [
{ name: "iPad Pro 11 portrait", width: 834, height: 1194 },
{ name: "iPad Air 11 portrait", width: 820, height: 1180 },
{ name: "iPad Air 11 landscape", width: 1180, height: 820 },
{ name: "iPad Pro 13 landscape", width: 1366, height: 1024 },
] as const;
for (const vp of SIDE_INSET_VIEWPORTS) {
test(`${vp.name}: the rail runs to the left edge over a side inset`, async ({
page,
}) => {
await page.setViewportSize({ width: vp.width, height: vp.height });
await openDashboard(page);
await setSideInsets(page, SIDE_INSET);
await setBottomInset(page, HOME_INDICATOR);
await expectShellPinned(page);
await expect(page.locator("aside")).toBeVisible();
const m = await page.evaluate(() => {
const aside = document.querySelector("aside")!;
const box = (el: Element | null) => {
const r = el!.getBoundingClientRect();
return { left: r.left, right: r.right, top: r.top, bottom: r.bottom };
};
// What paints a pixel: the rail, or the page behind the shell (the
// root, the body, or one of the shell's own unpainted boxes around the
// rail and `<main>`), or some other chrome that meets the edge.
const owner = (x: number, y: number) => {
const hit = document.elementFromPoint(x, y);
if (!hit) return "nothing";
if (hit.closest("aside")) return "rail";
const unpainted =
hit === document.documentElement ||
hit === document.body ||
(hit.id !== "main-content" &&
hit.contains(document.getElementById("main-content")));
return unpainted ? `page:${hit.tagName}` : "chrome";
};
const header = aside.querySelector('[data-slot="sidebar-header"]')!;
const account = aside.querySelector(
'[data-slot="sidebar-column"]',
)!.lastElementChild!;
const avatar = account.querySelector('[data-slot="avatar"]')!;
const footer = aside.querySelector('[data-slot="sidebar-footer"]')!;
const links = [...aside.querySelectorAll("nav a[href]")].map(
(a) => a.getBoundingClientRect().left,
);
const headerBox = box(header);
const accountBox = box(account);
return {
innerWidth,
innerHeight,
aside: box(aside),
header: headerBox,
account: accountBox,
avatar: box(avatar),
logoLeft: box(header.querySelector("svg")).left,
footerLeft: box(footer).left,
firstLinkLeft: Math.min(...links),
// Down the window's first column of pixels, and its last.
leftEdge: [
(headerBox.top + headerBox.bottom) / 2,
innerHeight / 2,
(accountBox.top + accountBox.bottom) / 2,
innerHeight - 2,
].map((y) => owner(1, y)),
rightEdge: [
(headerBox.top + headerBox.bottom) / 2,
innerHeight / 2,
].map((y) => owner(innerWidth - 1, y)),
};
});
// No strip: the rail paints the inset from the logo band to the bottom
// edge, and the top bar and `<main>` (or a docked panel) meet the right
// edge.
expect(m.leftEdge).toEqual(["rail", "rail", "rail", "rail"]);
expect(m.rightEdge).toEqual(["chrome", "chrome"]);
expect(m.aside.left).toBe(0);
// The band lines run to the edge: the logo band, the account band and
// the footer group (whose scroll edge draws a line) start at x = 0.
expect(m.header.left).toBe(0);
expect(m.account.left).toBe(0);
expect(m.footerLeft).toBe(0);
// And everything in the rail stays clear of the inset.
expect(m.logoLeft).toBeGreaterThanOrEqual(SIDE_INSET);
expect(m.firstLinkLeft).toBeGreaterThanOrEqual(SIDE_INSET);
expect(m.avatar.left).toBeGreaterThanOrEqual(SIDE_INSET);
// The account band mirrors the logo band: the same height, the avatar
// centred in it, and the home indicator's inset below it.
expect(m.account.bottom - m.account.top).toBe(
m.header.bottom - m.header.top,
);
const above = m.avatar.top - m.account.top;
const below = m.account.bottom - m.avatar.bottom;
expect(Math.abs(above - below)).toBeLessThanOrEqual(1);
expect(m.account.bottom).toBe(m.innerHeight - HOME_INDICATOR);
expect(m.aside.bottom).toBe(m.innerHeight);
});
}
+14 -3
View File
@@ -332,6 +332,11 @@ test("value lines: quarterly means, gaps kept, short ones bridged, as many as th
);
expect(dots).toHaveLength(15);
await page.keyboard.press("Escape");
// The menu closes on an exit animation and only then hands focus back to
// its trigger, which scrolls the page to the toolbar. Measured before
// that, the scroll to the last row below was undone a moment later.
await expect(options).toHaveCount(0);
await expect(trigger).toBeFocused();
await expect
.poll(() => log.timelineQueries.at(-1)?.get("values")?.split(",").length)
.toBe(15);
@@ -504,6 +509,12 @@ test("a chosen range lives in the URL: reload keeps it, Back returns to it", asy
expect(await chart.boundingBox()).toEqual(before);
await expect(desktop.locator('[data-slot="timeline-range"]')).toHaveCount(0);
expect(page.url()).not.toContain("zoom=range");
// The popover slides and scales in (`slide-in-from-top-2 zoom-in-95`);
// measured mid-animation it sat up to 8 px higher than where it comes to
// rest. Read its place once the entry animation has finished.
await popover.evaluate((el) =>
Promise.all(el.getAnimations({ subtree: true }).map((a) => a.finished)),
);
const anchor = await zoom("range").boundingBox();
const box = await popover.boundingBox();
expect(box!.y).toBeGreaterThanOrEqual(anchor!.y + anchor!.height);
@@ -834,9 +845,9 @@ test("the timeline page ends where its content does, at every desktop width", as
expect(m.mainSw, where).toBeLessThanOrEqual(m.mainCw + 1);
expect(m.docSw, where).toBeLessThanOrEqual(m.docCw + 1);
expect(m.docSh, where).toBeLessThanOrEqual(m.docCh + 1);
// The shell's own buffer below the last card (pb-20 and the top
// padding), nothing more.
expect(m.mainSh, where).toBeLessThanOrEqual(m.contentBottom + 104 + 2);
// The shell's own buffer below the last card (the desktop shell's
// pb-24 and the top padding), nothing more.
expect(m.mainSh, where).toBeLessThanOrEqual(m.contentBottom + 120 + 2);
}
}
});
+1 -1
View File
@@ -255,7 +255,7 @@ test.describe("Today overview geometry", () => {
const hero = await openDashboard(page, digest);
const phone = viewport.width < 768;
// Late banners (the key-backup nudge) insert above the hero; let
// Late banners (the recovery kit nudge) insert above the hero; let
// the page settle so the capture shows one stable layout.
await page.waitForLoadState("networkidle");
mkdirSync(SHOTS, { recursive: true });
+34 -10
View File
@@ -16,22 +16,42 @@
import type { Page } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import {
E2E_VAULT_LAYOUT,
STORAGE_STATE_PATH,
VAULT_LAYOUT_STORAGE_STATE_PATH,
} from "./setup/global-setup";
import { ensureVaultFixture, seedNamespaceDocs } from "./setup/vault-fixture";
import { mockPopulatedInsights } from "./utils/mock-populated-insights";
test.use({ storageState: STORAGE_STATE_PATH });
async function resetDocumentsLayout(page: Page): Promise<void> {
async function putDocumentsLayout(
page: Page,
arrangement: "stacked" | "flow",
): Promise<void> {
const res = await page.request.put("/api/documents/inbound/layout", {
data: { version: 1, view: "cards", arrangement: "stacked" },
data: { version: 1, view: "cards", arrangement },
});
expect(res.status()).toBe(200);
}
/** The journey starts from the stacked arrangement so the flip is visible. */
const resetDocumentsLayout = (page: Page) =>
putDocumentsLayout(page, "stacked");
/** And leaves its account on the default, as a new account would read it. */
const restoreDefaultDocumentsLayout = (page: Page) =>
putDocumentsLayout(page, "flow");
// The vault's layout is account state, and this journey turns it, so these
// tests run on a vault account of their own (`E2E_VAULT_LAYOUT`); the month
// grid tests in `documents.spec.ts` read the default on theirs.
test.describe("documents", () => {
test.use({ storageState: VAULT_LAYOUT_STORAGE_STATE_PATH });
test.beforeAll(async () => {
await ensureVaultFixture();
await ensureVaultFixture(E2E_VAULT_LAYOUT.username);
});
test("the display choice behind the wrench persists across visits", async ({
@@ -56,11 +76,15 @@ test.describe("documents", () => {
settings.locator('[data-slot="module-view-list"]'),
).toHaveAttribute("aria-pressed", "true");
const flow = settings.locator('[data-slot="documents-arrangement-flow"]');
// Wait for the write to land, so the reload below reads it back.
// Wait for THIS write to land, so the reload below reads it back. The
// view switch above writes too, and its answer can arrive after this
// wait starts; matching on the arrangement keeps the two apart.
const saved = page.waitForResponse(
(r) =>
r.url().includes("/api/documents/inbound/layout") &&
r.request().method() === "PUT",
r.request().method() === "PUT" &&
(r.request().postDataJSON() as { arrangement?: string } | null)
?.arrangement === "flow",
);
await flow.click();
expect((await saved).status()).toBe(200);
@@ -87,7 +111,7 @@ test.describe("documents", () => {
).toHaveAttribute("data-arrangement", "flow");
await second.close();
} finally {
await resetDocumentsLayout(page);
await restoreDefaultDocumentsLayout(page);
}
});
@@ -96,7 +120,7 @@ test.describe("documents", () => {
isMobile,
}) => {
test.skip(isMobile, "selection by checkbox is a pointer flow");
await seedNamespaceDocs("qoldel", 2);
await seedNamespaceDocs("qoldel", 2, E2E_VAULT_LAYOUT.username);
await page.goto("/documents?q=qoldel");
const box = page.getByRole("checkbox", { name: "Select qoldel 002" });
await expect(box).toBeVisible();
@@ -140,7 +164,7 @@ test.describe("documents", () => {
isMobile,
}) => {
test.skip(isMobile, "keyboard flow");
await seedNamespaceDocs("qolkey", 1);
await seedNamespaceDocs("qolkey", 1, E2E_VAULT_LAYOUT.username);
await page.goto("/documents?q=qolkey");
const open = page.getByRole("button", { name: "Open qolkey 001" });
await expect(open).toBeVisible();
@@ -158,7 +182,7 @@ test.describe("documents", () => {
isMobile,
}) => {
test.skip(isMobile, "selection by checkbox is a pointer flow");
await seedNamespaceDocs("qolclear", 2);
await seedNamespaceDocs("qolclear", 2, E2E_VAULT_LAYOUT.username);
await page.goto("/documents?q=qolclear");
await page.getByRole("checkbox", { name: "Select qolclear 001" }).click();
@@ -0,0 +1,147 @@
/**
* Unit tests for the `healthlog/import-boundaries` rule: src/lib never
* imports components or app, and a "use client" file never makes a value
* import of a server-only module. Covers alias and relative specifiers,
* type-only imports, dynamic imports, re-exports, the allow map and its
* stale-entry report.
*/
import { describe, it } from "vitest";
import { RuleTester } from "eslint";
import nextTs from "eslint-config-next/typescript";
import rule from "../import-boundaries.js";
// The TypeScript parser the app lints with, taken from the same config so
// `import type` reaches the rule as it does in `pnpm lint`.
const tsParser = nextTs.find((c) => c.languageOptions?.parser).languageOptions
.parser;
RuleTester.describe = describe;
RuleTester.it = it;
RuleTester.itOnly = it.only;
const ruleTester = new RuleTester({
languageOptions: {
parser: tsParser,
ecmaVersion: 2023,
sourceType: "module",
parserOptions: { ecmaFeatures: { jsx: true } },
},
});
const LIB_FILE = "/repo/src/lib/medications/example.ts";
const CLIENT_FILE = "/repo/src/components/medications/example.tsx";
ruleTester.run("import-boundaries", rule, {
valid: [
// lib importing lib, a package, and itself relatively.
{ code: 'import { a } from "@/lib/x";', filename: LIB_FILE },
{ code: 'import { z } from "zod";', filename: LIB_FILE },
{ code: 'import { b } from "../y";', filename: LIB_FILE },
// components importing components and lib is the right direction.
{
code: 'import { C } from "@/components/ui/card";',
filename: CLIENT_FILE,
},
// Tests are out of scope.
{
code: 'import { C } from "@/components/ui/card";',
filename: "/repo/src/lib/medications/__tests__/x.test.ts",
},
// An allowed lib file that still imports upward.
{
code: 'import { C } from "@/components/ui/card";',
filename: LIB_FILE,
options: [{ allow: { "src/lib/medications/example.ts": "reason" } }],
},
// A server component (no directive) may import the database client.
{
code: 'import { prisma } from "@/lib/db";',
filename: "/repo/src/app/page.tsx",
},
// A client file may import types from server modules and the enums entry.
{
code: '"use client";\nimport type { MeasurementType } from "@/generated/prisma/client";',
filename: CLIENT_FILE,
},
{
code: '"use client";\nimport { type BackupRestoreJobView } from "@/lib/jobs/backup-restore";',
filename: CLIENT_FILE,
},
{
code: '"use client";\nimport { MeasurementType } from "@/generated/prisma/enums";',
filename: CLIENT_FILE,
},
// A string that only looks like the directive is not one.
{
code: 'const x = "use client";\nimport { prisma } from "@/lib/db";',
filename: CLIENT_FILE,
},
],
invalid: [
{
code: 'import { C } from "@/components/ui/card";',
filename: LIB_FILE,
errors: [{ messageId: "libUpward" }],
},
{
code: 'import type { P } from "@/app/settings/page";',
filename: LIB_FILE,
errors: [{ messageId: "libUpward" }],
},
// Relative specifiers resolve to the same layer check.
{
code: 'import { C } from "../../components/ui/card";',
filename: LIB_FILE,
errors: [{ messageId: "libUpward" }],
},
{
code: 'export { C } from "@/components/ui/card";',
filename: LIB_FILE,
errors: [{ messageId: "libUpward" }],
},
{
code: 'const m = await import("@/components/ui/card");',
filename: LIB_FILE,
errors: [{ messageId: "libUpward" }],
},
// An allowed file that stopped importing upward must leave the list.
{
code: 'import { a } from "@/lib/x";',
filename: LIB_FILE,
options: [{ allow: { "src/lib/medications/example.ts": "reason" } }],
errors: [{ messageId: "staleAllow" }],
},
// Server-only modules in a client file.
{
code: '"use client";\nimport { prisma } from "@/lib/db";',
filename: CLIENT_FILE,
errors: [{ messageId: "clientServerOnly" }],
},
{
code: '"use client";\nimport { encrypt } from "@/lib/crypto";',
filename: CLIENT_FILE,
errors: [{ messageId: "clientServerOnly" }],
},
{
code: '"use client";\nimport { MeasurementType } from "@/generated/prisma/client";',
filename: CLIENT_FILE,
errors: [{ messageId: "clientServerOnly" }],
},
{
code: '"use client";\nimport { enqueue } from "@/lib/jobs/boss-instance";',
filename: CLIENT_FILE,
errors: [{ messageId: "clientServerOnly" }],
},
{
code: '"use client";\nimport { cookies } from "next/headers";',
filename: CLIENT_FILE,
errors: [{ messageId: "clientServerOnly" }],
},
{
code: '"use client";\nimport { Pool } from "pg";',
filename: CLIENT_FILE,
errors: [{ messageId: "clientServerOnly" }],
},
],
});
@@ -0,0 +1,199 @@
/**
* @fileoverview ESLint rule — import direction between the layers of `src`.
*
* Two boundaries, both checked per import statement so the mistake shows up
* in the editor rather than in a later sweep:
*
* 1. `src/lib/**` never imports from `src/components/**` or `src/app/**`.
* The direction is `app → components → hooks → lib`. A lib module that
* reaches upward drags a component folder into server jobs and makes
* the layers impossible to split. Type-only imports count too: the
* dependency is the same, only erased at build time.
*
* 2. A `"use client"` file never makes a value import of a server-only
* module: the database client, the crypto module, the Prisma client
* entry, the job queue, `pg`, `next/headers`. Type-only imports are
* erased and stay legal, and the Prisma `enums` entry is browser-safe.
* The transitive version of this check is
* `src/__tests__/client-bundle-server-import-guard.test.ts`, which walks
* the whole client graph; this rule is the direct, local half.
*
* Existing lib → components imports are listed in the `allow` option, file
* by file, each with the reason it has not moved yet. A listed file that no
* longer imports upward is reported too, so the list can only shrink.
*
* WHAT THIS DOES NOT CATCH
*
* A dependency through a re-export in a package outside `src`, and a
* `require()` call. Neither occurs in the tree today.
*/
"use strict";
const path = require("node:path");
function toPosix(filename) {
return filename.replace(/\\/g, "/");
}
function isTestFile(filename) {
return (
/\/__tests__\//.test(filename) ||
/\.(test|spec)\.[cm]?[jt]sx?$/.test(filename) ||
/\/__mocks__\//.test(filename)
);
}
/** Path of `filename` relative to the repository's `src/`, or null. */
function srcRelative(filename) {
const idx = filename.lastIndexOf("/src/");
return idx === -1 ? null : filename.slice(idx + 1);
}
/** Where a specifier lands inside `src/`, or null for a package. */
function targetInSrc(spec, filename) {
if (spec.startsWith("@/")) return `src/${spec.slice(2)}`;
if (spec.startsWith(".")) {
const abs = toPosix(path.resolve(path.dirname(filename), spec));
return srcRelative(abs);
}
return null;
}
const SERVER_ONLY_PACKAGES = [
/^pg$/,
/^pg-boss$/,
/^@prisma\//,
/^next\/headers$/,
];
const SERVER_ONLY_SRC = [
/^src\/lib\/db(-session)?$/,
/^src\/lib\/crypto(\/|$)/,
/^src\/lib\/jobs\//,
/^src\/generated\/prisma\/(client|internal|models)(\/|$)/,
];
function isServerOnly(spec, filename) {
if (SERVER_ONLY_PACKAGES.some((re) => re.test(spec))) return true;
const target = targetInSrc(spec, filename);
if (!target) return false;
const bare = target.replace(/\.(ts|tsx|js|mjs)$/, "");
return SERVER_ONLY_SRC.some((re) => re.test(bare));
}
function isTypeOnly(node) {
if (node.importKind === "type" || node.exportKind === "type") return true;
const specifiers = node.specifiers ?? [];
return (
specifiers.length > 0 &&
specifiers.every((s) => s.importKind === "type" || s.exportKind === "type")
);
}
function hasUseClient(program) {
for (const stmt of program.body) {
if (stmt.type !== "ExpressionStatement" || !stmt.directive) break;
if (stmt.directive === "use client") return true;
}
return false;
}
/** @type {import("eslint").Rule.RuleModule} */
const importBoundariesRule = {
meta: {
type: "problem",
docs: {
description:
"Keep src/lib below components and app, and server-only modules out of client files.",
},
schema: [
{
type: "object",
properties: {
allow: {
type: "object",
additionalProperties: { type: "string", minLength: 1 },
},
},
additionalProperties: false,
},
],
messages: {
libUpward:
"src/lib must not import {{target}}. Move the shared piece into src/lib (or the importer into the component folder); an exception goes into the rule's `allow` map in eslint.config.mjs with its reason.",
staleAllow:
"This file is in the import-boundaries `allow` map but no longer imports from src/components or src/app. Remove the entry so the list keeps shrinking.",
clientServerOnly:
'A "use client" file must not import {{spec}}: it is server-only and would ship to the browser. Import a type with `import type`, or move the call behind an API route or a server component.',
},
},
create(context) {
const filename = toPosix(context.filename ?? context.getFilename?.() ?? "");
const rel = srcRelative(filename);
if (!rel || isTestFile(filename)) return {};
const allow = (context.options[0] && context.options[0].allow) || {};
const inLib = rel.startsWith("src/lib/");
const allowed = inLib && Object.hasOwn(allow, rel);
let upwardSeen = false;
let isClient = false;
function check(node, source) {
if (!source || typeof source.value !== "string") return;
const spec = source.value;
if (inLib) {
const target = targetInSrc(spec, filename);
if (
target &&
(target.startsWith("src/components/") ||
target.startsWith("src/app/"))
) {
upwardSeen = true;
if (!allowed) {
context.report({
node: source,
messageId: "libUpward",
data: { target: spec },
});
}
}
}
if (isClient && !isTypeOnly(node) && isServerOnly(spec, filename)) {
context.report({
node: source,
messageId: "clientServerOnly",
data: { spec },
});
}
}
return {
Program(program) {
isClient = hasUseClient(program);
},
ImportDeclaration(node) {
check(node, node.source);
},
ExportNamedDeclaration(node) {
if (node.source) check(node, node.source);
},
ExportAllDeclaration(node) {
check(node, node.source);
},
ImportExpression(node) {
if (node.source && node.source.type === "Literal") {
check(node, node.source);
}
},
"Program:exit"(program) {
if (allowed && !upwardSeen) {
context.report({ node: program, messageId: "staleAllow" });
}
},
};
},
};
module.exports = importBoundariesRule;
+4
View File
@@ -21,6 +21,8 @@
* toLocaleString().
* - `no-raw-card-shell` — a card surface is a <Card> or a shell
* primitive, not bg-card + border + rounded-xl.
* - `import-boundaries` — src/lib never imports components or app, and
* a "use client" file imports no server module.
*/
"use strict";
@@ -36,6 +38,7 @@ const noUtcDayKey = require("./no-utc-day-key.js");
const noPlanningTags = require("./no-planning-tags.js");
const noUnlocalisedDecimal = require("./no-unlocalised-decimal.js");
const noRawCardShell = require("./no-raw-card-shell.js");
const importBoundaries = require("./import-boundaries.js");
module.exports = {
rules: {
@@ -55,5 +58,6 @@ module.exports = {
"no-planning-tags": noPlanningTags,
"no-unlocalised-decimal": noUnlocalisedDecimal,
"no-raw-card-shell": noRawCardShell,
"import-boundaries": importBoundaries,
},
};
+27
View File
@@ -176,6 +176,27 @@ const eslintConfig = defineConfig([
],
},
],
// Layer direction: src/lib never imports src/components or src/app, and
// a "use client" file makes no value import of the database client,
// crypto, the Prisma client entry, the job queue, pg or next/headers.
// The files below still import upward; each says why it has not moved.
// A listed file that stops importing upward fails until it is removed,
// so the map only shrinks. See the rule header.
"healthlog/import-boundaries": [
"error",
{
allow: {
"src/lib/insights/metric-catalog.ts":
"reads SUB_PAGE_TABS, which is declared inside the client tab strip component together with its labels and order",
"src/lib/insights/target-adjust-context.tsx":
"a React provider that renders TargetEditSheet; it belongs under components but has importers across 21 insight pages",
"src/lib/errors/error-toast.tsx":
"a React toast renderer that renders ErrorIdLine; it belongs under components but is imported by most client surfaces",
"src/lib/module-list-prefs.ts":
"a client hook module that raises the shared outcome toast; it moves with the module list settings surface",
},
},
],
},
},
// v1.28.17 — every recharts-rendering component funnels through the
@@ -193,6 +214,9 @@ const eslintConfig = defineConfig([
"src/components/charts/chart-runtime.ts",
"src/components/admin/host-metrics-chart.tsx",
"src/components/charts/health-chart.tsx",
// HealthChart's plot layers, imported by health-chart.tsx alone, so
// they sit inside the same chart-runtime graph.
"src/components/charts/health-chart/chart-layers.tsx",
"src/components/charts/medication-compliance-chart.tsx",
"src/components/charts/mood-chart.tsx",
"src/components/charts/nutrient-daily-bar-chart.tsx",
@@ -215,6 +239,9 @@ const eslintConfig = defineConfig([
"src/components/charts/workout-elevation-chart.tsx",
"src/components/insights/coach-panel/result-chart.tsx",
"src/components/insights/derived/score-trend-chart.tsx",
// Browser tests draw a chart with Recharts itself to pin what it lays
// out; a test file is never bundled, so it mints no chunk.
"src/**/*.browser.test.tsx",
],
rules: {
"no-restricted-imports": [
+4
View File
@@ -100,6 +100,9 @@
"e2e/**/*.test.ts",
"tests/**/*.test.ts",
"tests/**/setup.ts",
// Reached only through the browser project's `next/navigation` alias in
// vitest.config.mts, which no import statement names.
"tests/browser/next-navigation.ts",
"vitest.config.ts",
"vitest.config.integration.ts",
"playwright.config.ts",
@@ -111,6 +114,7 @@
"src/**/*.{ts,tsx}!",
"scripts/**/*.{ts,mjs}!",
"tests/**/*.ts",
"tests/**/*.tsx",
"e2e/**/*.ts",
],
"ignore": [
+431 -196
View File
@@ -175,6 +175,9 @@
"failed": "Das Etikett konnte nicht gelesen werden. Versuch ein schärferes Foto."
}
}
},
"aiRuns": {
"readerBusy": "Der Dokumentleser ist gerade mit anderen Aufgaben beschäftigt. Versuch es in einer Minute noch einmal."
}
},
"doctorReport": {
@@ -435,7 +438,8 @@
"autocorrelation": "starke Abhängigkeit vom Vortag, die Bereiche können daher noch zu eng sein",
"window": "ausgewertet {from} bis {to}, das kann über den Berichtszeitraum hinausreichen",
"windowLine": "Ausgewertet {from} bis {to}. Dieser Zeitraum kann über den Berichtszeitraum hinausreichen."
}
},
"screenerUnvalidated": "{label}: {count} von {total} Ergebnissen wurden in einer nicht wissenschaftlich validierten Übersetzung beantwortet; sie dienen der Orientierung."
},
"errorBoundary": {
"title": "Da ist etwas schiefgelaufen",
@@ -628,7 +632,9 @@
"title": "Einladung ungültig",
"description": "Dieser Einladungslink ist unvollständig oder falsch kopiert. Bitte die Person, die dich eingeladen hat, um einen neuen Link.",
"toLogin": "Zur Anmeldung"
}
},
"deletionPending": "Dieses Konto wird am {date} gelöscht und ist bis dahin gesperrt. Ein Admin dieses Servers kann die Löschung vor diesem Datum rückgängig machen.",
"deletionPendingNoDate": "Dieses Konto ist zur Löschung vorgemerkt und gesperrt. Ein Admin dieses Servers kann die Löschung rückgängig machen, bis sie abgeschlossen ist."
},
"dashboard": {
"title": "Dashboard",
@@ -777,13 +783,16 @@
"bmi": "kg/m²"
}
},
"keyBackupBanner": {
"title": "Sichere deinen Verschlüsselungsschlüssel",
"body": "Ohne eine Kopie des Schlüssels lassen sich deine Daten nach einer Neuinstallation nicht wiederherstellen. Es dauert eine Minute.",
"action": "Zum Schritt"
},
"quickAddSymptom": "Symptom erfassen",
"todayCardVisible": "Obere Karte anzeigen"
"todayCardVisible": "Obere Karte anzeigen",
"recoveryKitBanner": {
"title": "Wiederherstellungs-Kit sichern",
"body": "Ohne Kopien der Schlüssel, die dieser Server nutzt, lassen sich seine Daten nach einer Neuinstallation nicht wiederherstellen. Das dauert nur ein paar Minuten.",
"action": "Kit öffnen"
},
"greetingOnly": "{greeting}.",
"welcomeNew": "Willkommen.",
"welcomeNewWithName": "Willkommen, {name}."
},
"measurements": {
"title": "Messungen",
@@ -3019,6 +3028,7 @@
"refusalInjection": "In deiner Nachricht stehen Anweisungen, die meine Vorgaben überschreiben sollen. Ich bin der HealthLog-Coach und fasse ausschließlich deine eigenen Gesundheitsdaten zusammen. Formuliere deine Frage bitte in diesem Rahmen neu.",
"errorBudget": "Du hast das heutige Coach-Limit erreicht. Das Budget setzt sich um Mitternacht in deiner Zeitzone zurück.",
"errorProvider": "Der Coach konnte gerade keinen Auswertungs-Anbieter erreichen. Bitte versuche es in einem Moment erneut.",
"errorReasoningOnly": "Das Modell hat nur nachgedacht und keine Antwort geschrieben. Versuche es noch einmal oder wähle in den KI-Einstellungen ein anderes Modell.",
"errorDemo": "In der Demo antwortet der Coach nicht.",
"errorNoProvider": "Der Coach liest deine erfassten Gesundheitsdaten und beantwortet Fragen dazu (keine medizinische Beratung). Dafür braucht HealthLog einen KI-Anbieter. Verbinde deinen eigenen Schlüssel oder ein lokales Modell.",
"errorNoProviderAction": "KI-Anbieter verbinden",
@@ -3231,11 +3241,11 @@
"last30days": "{days} Tage",
"last90days": "{days} Tage",
"lastYear": "{months} Monate",
"allTime": "Alles"
"allTime": "{months} Monate im Detail, Labor länger"
},
"detailDays": "Der Coach liest nichts, was älter als {days} Tage ist, in keiner Antwort.",
"detailMonths": "Der Coach liest nichts, was älter als {months} Monate ist, in keiner Antwort.",
"detailAll": "Alles, was du erfasst hast: {dailyMonths} Monate tagesgenau, ältere Werte als Monatswerte bis {years} Jahre zurück, Laborwerte der letzten {labMonths} Monate.",
"detailAll": "Ohne Begrenzung liest der Coach {dailyMonths} Monate tagesgenau und ältere Werte als Monatswerte bis {years} Jahre zurück. Von Laborwerten liest er den jüngsten Wert der zuletzt gemessenen Marker und, wenn du nach einem Marker fragst, dessen {labRecent} vorherige Werte; ältere Werte sind pro Jahr zusammengefasst, bis {labYears} Jahre zurück, noch frühere Jahre werden nur gezählt.",
"note": "Dein Profil, aktuelle Medikamente, eine laufende Erkrankung und anstehende Termine bleiben sichtbar; Auswertungen, die mehr Verlauf brauchen, werden gekürzt oder weggelassen. Antworten, die der Coach früher in älteren Unterhaltungen gegeben hat, können noch ältere Werte nennen."
},
"showConversations": "Unterhaltungen anzeigen",
@@ -3599,7 +3609,7 @@
"headphoneAudioBody": "Die Kopfhörerlautstärke erfasst in Dezibel, wie laut du über Ohrhörer oder Kopfhörer hörst. Die Lautstärke niedrig zu halten und Pausen einzulegen schützt dein Gehör langfristig.",
"audioEventsBody": "Laute Geräuschereignisse markieren Momente, in denen der Lärm um dich herum ein Niveau erreicht hat, das bei ausreichender Belastung das Gehör schädigen kann. Häufige Ereignisse sind ein Hinweis, eine ruhigere Umgebung oder Gehörschutz zu suchen.",
"daylightBody": "Die Zeit im Tageslicht ist, wie lange du dich täglich im Freien im Licht aufhältst. Regelmäßiges Tageslicht unterstützt einen gesunden Schlaf-Wach-Rhythmus und die Stimmung (CDC 2024), und Zeit im Freien ist mit der Augengesundheit verbunden.",
"bloodGlucoseBody": "Der Blutzucker ist die Zuckermenge in deinem Blut, die wichtigste Energiequelle des Körpers. Er steigt nach dem Essen und pendelt sich zwischen den Mahlzeiten ein; dauerhaft hohe Werte können auf Diabetes oder Prädiabetes hinweisen.",
"bloodGlucoseBody": "Der Blutzucker ist die Zuckermenge in deinem Blut, die wichtigste Energiequelle des Körpers. Er steigt nach dem Essen und pendelt sich zwischen den Mahlzeiten ein. Als allgemeine Orientierung für Erwachsene ohne Diabetes gilt ein Nüchternwert unter {prediabetesFrom} {unit} als normal, {prediabetesFrom}–{prediabetesTo} als Prädiabetes, und ab {diabetesFrom} liegt er im Diabetesbereich (ADA 2024). Ein einzelner hoher Wert ist keine Diagnose; eine Ärztin oder ein Arzt bestätigt sie mit einer Wiederholungsmessung, und wer Diabetes behandelt, arbeitet mit anderen, ärztlich festgelegten Zielwerten.",
"skinTemperatureBody": "Die Hauttemperatur ist hier die kleine nächtliche Abweichung deiner Handgelenkstemperatur von deinem eigenen Ausgangswert, keine absolute Fiebermessung. Veränderungen können Schlaf, Umgebung, Krankheit oder Zyklusphase widerspiegeln.",
"cardioFitnessBody": "VO₂max gibt an, wie viel Sauerstoff dein Körper bei maximaler Anstrengung verwerten kann. Es ist der beste Laborindex der Ausdauerfitness, den Wearables indirekt schätzen. Ein höherer oder steigender Trend ist ein starkes Zeichen für eine gute Herz-Kreislauf-Gesundheit.",
"cardioRecoveryBody": "Der Erholungspuls gibt an, um wie viele Schläge pro Minute dein Puls in der ersten Minute nach dem Ende einer anstrengenden Belastung fällt. Ein größerer Abfall bedeutet, dass sich dein Herz schneller beruhigt, ein Zeichen guter Fitness.",
@@ -5731,7 +5741,7 @@
"passphrasePlaceholder": "Passphrase (mind. 12 Zeichen)",
"passphraseHelp": "Leitet den Archivschlüssel per Argon2id ab und versiegelt ihn mit AES-256-GCM. Speichere die Datei mit der Endung .hlx.",
"noRecoveryWarning": "Es gibt keine Wiederherstellung. Geht diese Passphrase verloren, lässt sich das Archiv nicht mehr öffnen. Bewahre sie sicher auf.",
"detail": "Gleiche Form wie die wöchentliche automatische Sicherung. Einige Satzarten sind noch nicht enthalten; das Manifest der Datei nennt, was drinsteht.",
"detail": "Eine portable Kopie deiner Akte zum Aufbewahren und Lesen: Werte als einfaches JSON, Dokumente nur mit ihren Metadaten. Sie ist nicht die wöchentliche Kontokopie, mit der dieser Server ein Konto zurücksetzt, und sie kann kein Konto auf einem anderen Server neu anlegen. Einige Datenarten fehlen noch; das Manifest der Datei nennt, was enthalten ist.",
"contents": "Deine Messwerte, Medikamente, Stimmung, Zyklus, Laborwerte, Krankheitsepisoden, Allergien, Familienanamnese, Nährstoffe, Workouts, dein Gesundheitsprofil und deine eigenen Messgrößen in einer JSON-Datei."
}
},
@@ -5777,7 +5787,7 @@
"cycleDaysFailed": "{count} Zyklustage konnten nicht geschrieben werden: {reason}"
},
"json": {
"title": "JSON-Datei",
"title": "Messwerte und Stimmung (JSON)",
"description": "JSON einfügen oder eine .json-Datei mit Messwerten und Stimmungseinträgen hochladen.",
"pasteLabel": "JSON einfügen",
"fileInputLabel": "Eine JSON-Datei zum Import auswählen",
@@ -5793,8 +5803,11 @@
"readFailed": "Diese Datei konnte nicht gelesen werden.",
"resultSummary": "{measurements} Messwerte und {moods} Stimmungseinträge importiert, {skipped} übersprungen.",
"resultSuccess": "{measurements} Messwerte und {moods} Stimmungseinträge importiert",
"droppedLinks": "Ohne diese Tags importiert, weil dieses Konto keine mit dem Namen hat: {keys}",
"keptLabels": "Dieses Konto hat Tags oder Gruppen mit diesen Schlüsseln schon unter eigenem Namen, die wurden behalten: {keys}",
"resultNothing": "Nichts importiert. Alle {skipped} Einträge wurden übersprungen.",
"resultEmpty": "Diese Datei enthielt weder Messwerte noch Stimmungseinträge."
"resultEmpty": "Diese Datei enthielt weder Messwerte noch Stimmungseinträge.",
"limitsHint": "Bis {mb} MB und bis zu {rows} Einträge je Liste. Das ergänzt deine Akte um Einträge; ein vollständiges Backup lässt sich so nicht wiederherstellen."
},
"csv": {
"title": "CSV-Datei",
@@ -5940,7 +5953,7 @@
}
}
},
"subtitle": "Exportiere deine Daten oder importiere aus einer Sicherung."
"subtitle": "Exportiere deine Daten oder importiere Messwerte und Einträge aus anderen Apps und Dateien."
},
"about": {
"title": "Über diese App",
@@ -6227,14 +6240,17 @@
"password": "Passwort",
"totp": "Authenticator-Code",
"passkey": "Passkey",
"webauthn": "Sicherheitsschlüssel"
"webauthn": "Sicherheitsschlüssel",
"oidc": "Identitätsanbieter"
},
"currentPassword": "Aktuelles Passwort",
"authenticatorCode": "Authenticator-Code",
"authenticatorPlaceholder": "Sechsstelliger Authenticator-Code",
"verifyAndAdd": "Bestätigen und hinzufügen",
"factorDescription": "Bestätige eine vorhandene Anmeldemethode, bevor du einen zweiten Faktor hinzufügst.",
"verifyAndContinue": "Bestätigen und fortfahren"
"verifyAndContinue": "Bestätigen und fortfahren",
"oidcHint": "Du meldest dich bei deinem Identitätsanbieter noch einmal an und kommst direkt auf diese Seite zurück. Dann wiederholst du, was du gerade tun wolltest.",
"continueToProvider": "Weiter zur Anmeldung"
},
"passkeyName": "Name",
"noPasskeys": "Keine Passkeys registriert.",
@@ -6854,7 +6870,14 @@
"detail": "Das Token wird nur einmal angezeigt.",
"scopeNote": "Es kann ausschließlich neue Werte in deine eigene Akte eintragen. Es kann deine Daten nicht lesen, nichts ändern oder löschen und kein weiteres Token erstellen. Werte werden als von einem externen Gerät gekennzeichnet, damit du sie später erkennst, und lassen sich jederzeit korrigieren.",
"createFailed": "Das Token konnte nicht erstellt werden. Bitte versuche es erneut.",
"namePlaceholder": "Name, z. B. Home Assistant"
"namePlaceholder": "Name, z. B. Home Assistant",
"appleHealth": {
"label": "Messwerte in Apple Health schreiben",
"createHint": "Die HealthLog-App auf deinem iPhone trägt die Werte dieses Tokens dann in Apple Health ein. Du kannst das später in der Token-Liste unten ändern.",
"toggleAria": "Werte von {name} in Apple Health schreiben",
"updateFailed": "Die Apple-Health-Einstellung konnte nicht geändert werden. Bitte versuche es erneut.",
"unattributed": "Werte, die vor dieser Einstellung gesendet wurden, lassen sich keinem Token zuordnen und bleiben deshalb außerhalb von Apple Health."
}
},
"workoutsToken": {
"title": "Training-Tokens",
@@ -6911,10 +6934,12 @@
"deleteAccountCardDescription": "Löscht dein Konto inklusive Passkeys, Audit-Log und Sessions.",
"deleteAccountCta": "Konto löschen",
"deleteAccountConfirmTitle": "Konto endgültig löschen?",
"deleteAccountConfirmDescription": "Dein Konto, alle Gesundheitsdaten, Passkeys, Sessions und Audit-Einträge werden unwiderruflich gelöscht. Du wirst danach automatisch abgemeldet.",
"deleteAccountConfirmTitleGrace": "Konto löschen?",
"deleteAccountConfirmDescription": "Dein Konto, alle Gesundheitsdaten, Passkeys, Sessions und Audit-Einträge werden unwiderruflich gelöscht. Du wirst danach automatisch abgemeldet. Kopien in den Sicherungen dieses Servers verfallen nach ihrer Aufbewahrungsfrist.",
"deleteAccountSuccess": "Konto wurde gelöscht. Du wirst abgemeldet.",
"deleteAccountFailed": "Konto konnte nicht gelöscht werden",
"deleteAccountFinal": "Endgültig löschen",
"deleteAccountFinalGrace": "Konto löschen",
"saved": "Gespeichert",
"savingError": "Fehler beim Speichern",
"about": {
@@ -7321,13 +7346,16 @@
"coach": "Coach-Unterhaltungen: {days} Tage",
"audit": "Sicherheitsprotokoll: {days} Tage",
"delivery": "Benachrichtigungsprotokoll: {days} Tage",
"unavailable": "Aufbewahrungsdetails sind derzeit nicht verfügbar."
"unavailable": "Aufbewahrungsdetails sind derzeit nicht verfügbar.",
"accountDeletion": "Löschen deines Kontos, eines verwalteten Profils oder aller Daten: abgeschlossen nach {days} Tagen",
"accountDeletionImmediate": "Löschen deines Kontos, eines verwalteten Profils oder aller Daten: sofort abgeschlossen"
},
"delete": {
"title": "Löschen oder zurücksetzen",
"description": "Lösche dein Konto vollständig oder lösche deine Gesundheitsdaten und behalte das Konto.",
"backupLag": "Die Löschung entfernt deine Daten sofort. Wenn dieser Server verschlüsselte externe Sicherungen anlegt, werden die Kopien deiner Daten gleich danach aus diesem Speicher gelöscht, oder in der nächsten Nacht, falls der Speicher nicht erreichbar ist.",
"openAdvanced": "Löschen oder zurücksetzen"
"openAdvanced": "Löschen oder zurücksetzen",
"immediateNotice": "Die Löschung entfernt deine Daten sofort. Wenn dieser Server verschlüsselte externe Sicherungen anlegt, werden die Kopien deiner Daten gleich danach aus diesem Speicher gelöscht, oder in der nächsten Nacht, falls der Speicher nicht erreichbar ist. Kopien in den Sicherungen dieses Servers verfallen nach ihrer Aufbewahrungsfrist.",
"graceNotice": "Wenn du dein Konto löschst, wird es sofort gesperrt und nach {days} Tagen endgültig gelöscht, zusammen mit jeder Kopie, die dieser Server davon aufbewahrt, auch verschlüsselten Kopien außerhalb des Servers. Bis dahin kann ein Admin dieses Servers die Löschung rückgängig machen. Beim Löschen aller Daten gilt dasselbe: Eine Kopie der Akte bleibt {days} Tage erhalten, sodass ein Admin sie wiederherstellen kann. Kopien in den Sicherungen dieses Servers verfallen nach ihrer Aufbewahrungsfrist."
},
"posture": {
"title": "Datenschutz-Einstellung",
@@ -7431,6 +7459,7 @@
"lastReceived": "Zuletzt empfangen {relative}",
"lastNewValue": "Letzter neuer Wert {relative}",
"lastBackground": "Zuletzt im Hintergrund {relative}",
"lastDuplicate": "Zuletzt ohne neue Werte erneut gesendet {relative}",
"never": "Noch nicht empfangen",
"lastBackfill": "Verlauf nachgeladen {relative}",
"lastEnteredHere": "Zuletzt hier eingetragen {relative}"
@@ -7494,7 +7523,9 @@
"description": "Damit werden deine Gesundheitsdaten weitergegeben oder exportiert. Bestätige, dass du es bist, um fortzufahren.",
"descriptionChange": "Das ändert etwas, das nur du ändern können solltest. Bestätige, dass du es bist, um fortzufahren.",
"signInAgain": "Melde dich neu an und versuche es dann noch einmal. Dein Konto hat kein Passwort und keinen Passkey, den du hier bestätigen könntest.",
"failed": "Das hat nicht geklappt. Prüfe deine Eingabe und versuche es noch einmal."
"failed": "Das hat nicht geklappt. Prüfe deine Eingabe und versuche es noch einmal.",
"oidcDone": "Bestätigt. In den nächsten fünf Minuten kannst du weitermachen, ohne erneut gefragt zu werden.",
"oidcFailed": "Dein Identitätsanbieter konnte nicht bestätigen, dass du es bist. Versuche es noch einmal."
},
"passwordTitle": "Passwort",
"tokenNameLabel": "Token-Name",
@@ -7632,7 +7663,13 @@
"off": "Aus",
"change": "Unter Module ändern"
},
"timezoneUseDevice": "Zeitzone dieses Geräts übernehmen ({zone})"
"timezoneUseDevice": "Zeitzone dieses Geräts übernehmen ({zone})",
"dangerZoneConfirmDescriptionGrace": "Dieser Vorgang löscht alles in deiner Akte: Messwerte, Medikamente, Stimmungseinträge, Laborwerte, Zykluseinträge, Dokumente, KI-Gespräche und jede Integrationsverbindung, dazu alles daraus Abgeleitete und alles, was du früher gelöscht hast. Freigabe-Links, die du an eine Praxis gegeben hast, funktionieren sofort nicht mehr. Dein Konto bleibt bestehen, ebenso deine Anmeldedaten und Anzeigeeinstellungen wie Sprache, Zeitzone und Einheiten. Eine Kopie deiner Akte wird {days} Tage aufbewahrt, sodass ein Admin dieses Servers sie bis dahin wiederherstellen kann. Danach ist die Löschung endgültig.",
"deleteAccountCardDetailGrace": "Dein Konto wird sofort gesperrt und nach {days} Tagen endgültig gelöscht. Bis dahin kann ein Admin dieses Servers die Löschung rückgängig machen.",
"deleteAccountConfirmDescriptionGrace": "Du wirst abgemeldet und dein Konto wird sofort gesperrt. Nach {days} Tagen wird es endgültig gelöscht, mit allen Gesundheitsdaten, Passkeys, Sessions und Audit-Einträgen. Bis dahin kann ein Admin dieses Servers die Löschung rückgängig machen. Kopien in den Sicherungen dieses Servers verfallen nach ihrer Aufbewahrungsfrist.",
"deleteAccountScheduled": "Dein Konto ist gesperrt und wird am {date} endgültig gelöscht. Du wirst abgemeldet.",
"dangerZoneSuccessGrace": "Deine Gesundheitsakte wurde gelöscht. Ein Admin dieses Servers kann sie bis zum {date} wiederherstellen.",
"dangerZoneRestoreLimits": "Auch Zugriffe enden: was du mit anderen teilst, was andere mit dir teilen, und jede Rolle als betreuende Person. Eine Wiederherstellung bringt die Akte so zurück, wie sie jetzt ist. Was nach dem Löschen hinzukommt, wird dabei ersetzt, und Integrationsverbindungen, Freigaben, Betreuungsrollen und Freigabe-Links kommen nicht zurück; sie müssen neu eingerichtet werden."
},
"admin": {
"title": "Administration",
@@ -8068,7 +8105,24 @@
"cannotLogoutSelf": "Eigene Sitzungen können nicht beendet werden",
"emptyTitle": "Keine Nutzer in dieser Ansicht",
"emptyDescription": "Es gibt keine Nutzer, die zum aktuellen Filter passen.",
"emptyResetFilter": "Alle Nutzer anzeigen"
"emptyResetFilter": "Alle Nutzer anzeigen",
"deletionPending": "Löschung am {date}",
"deletionPendingNoDate": "Löschung vorgemerkt",
"undoDeletion": "Löschung rückgängig machen",
"undoDeletionTitle": "Löschung von {name} rückgängig machen?",
"undoDeletionBody": "{name} hat die Löschung dieses Kontos verlangt; es wird gelöscht, wenn die Frist endet. Rückgängig machen behält das Konto und alles darin so, wie es war. {name} kann sich wieder anmelden, aber API-Tokens, Freigabe-Links und vertrauenswürdige Geräte bleiben widerrufen und müssen neu eingerichtet werden.",
"undoDeletionBodyGuardian": "Eine betreuende Person hat das verwaltete Profil {name} gelöscht; es wird gelöscht, wenn die Frist endet. Rückgängig machen behält das Profil und alles darin so, wie es war, und die betreuenden Personen erreichen es wieder. Seine Freigabe-Links und Tokens bleiben widerrufen und müssen neu eingerichtet werden.",
"undoDeletionDone": "Löschung von {name} rückgängig gemacht",
"undoDeletionFailed": "Löschung konnte nicht rückgängig gemacht werden",
"undoDeletionBodyAdmin": "Ein Admin hat das Konto {name} gelöscht; es wird gelöscht, wenn die Frist endet. Rückgängig machen behält das Konto und alles darin so, wie es war. {name} kann sich wieder anmelden, aber API-Tokens, Freigabe-Links und vertrauenswürdige Geräte bleiben widerrufen und müssen neu eingerichtet werden.",
"deleteUser": "Konto löschen",
"deleteUserTitle": "Das Konto {name} löschen?",
"deleteUserBodyGrace": "{name} wird sofort überall abgemeldet und kann sich nicht mehr anmelden. Das Konto und alles darin wird am {date} gelöscht. Bis dahin kannst du die Löschung hier rückgängig machen.",
"deleteUserBodyNow": "{name} und alles im Konto wird sofort gelöscht. Dieser Server hat keine Frist eingestellt, die Löschung lässt sich also nicht rückgängig machen.",
"deleteUserConfirm": "Konto löschen",
"deleteUserScheduled": "Das Konto {name} ist gesperrt und wird am {date} gelöscht",
"deleteUserDone": "Das Konto {name} wurde gelöscht",
"deleteUserFailed": "Das Konto konnte nicht gelöscht werden"
},
"invites": {
"title": "Einladungen",
@@ -8130,8 +8184,8 @@
},
"backups": {
"title": "Backups",
"subtitle": "Wöchentliche Daten-Snapshots pro Nutzer in PostgreSQL.",
"description": "Jeder Nutzer hat einen rollierenden Snapshot pro Backup-Typ. Größen zeigen den verschlüsselten Blob. Download entschlüsselt den Snapshot für den anfragenden Admin; der Chiffretext bleibt in der Datenbank.",
"subtitle": "Kontokopien in der Datenbank dieses Servers, um ein Konto hier zurückzusetzen.",
"description": "Jedes Konto hat eine wöchentliche Kopie, die jeden Sonntag ersetzt wird, dazu hier hochgeladene Kopien und die Kopie, die vor „Alle Daten löschen“ angelegt wird. Eine Kopie setzt ein bestehendes Konto auf diesem Server zurück. Einen verlorenen Server kann sie nicht wiederherstellen: Dafür braucht es einen Datenbank-Dump und die Verschlüsselungsschlüssel. Die Größen zeigen die verschlüsselte Kopie. Beim Herunterladen wird sie für den anfragenden Admin entschlüsselt; der Geheimtext bleibt in der Datenbank.",
"runNow": "Jetzt sichern",
"runEnqueued": "Backup-Job eingeplant",
"runFailed": "Backup konnte nicht eingeplant werden",
@@ -8141,12 +8195,11 @@
"emptyDescription": "Backups laufen automatisch jeden Sonntag um 03:00 Uhr, oder erstelle jetzt einen ersten Snapshot.",
"colUser": "Nutzer",
"colType": "Typ",
"typeWeeklyAuto": "Wöchentlich (auto)",
"typeWeeklyAuto": "Wöchentliche Kontokopie",
"typeManual": "Manuell",
"colSize": "Größe",
"colCreatedAt": "Letzter Snapshot",
"colActions": "Aktionen",
"retentionLabel": "Aufbewahrung: {days} Tage",
"scheduleStaleTitle": "Die wöchentliche Sicherung läuft nicht mehr",
"scheduleStaleDescription": "Die neueste geplante Kopie ist {days} Tage alt. Ein funktionierender wöchentlicher Lauf lässt keine älter als {threshold} Tage werden.",
"scheduleFailedTitle": "Der letzte geplante Sicherungslauf ist fehlgeschlagen",
@@ -8164,7 +8217,7 @@
"downloadFailed": "Download fehlgeschlagen",
"downloadStarted": "Download gestartet",
"uploadTitle": "Backup hochladen",
"uploadDescription": "Eine Backup-Datei validieren und als manuellen Snapshot speichern. Das Wiederherstellen ist ein separater Schritt.",
"uploadDescription": "Prüft eine Kontokopie und speichert sie neben den Kopien dieses Servers. Das Konto, zu dem sie gehört, muss auf diesem Server existieren. Das Wiederherstellen ist ein eigener Schritt.",
"uploadButton": "Datei hochladen",
"uploadHelp": "HealthLog-Backup als .json oder .json.gz. Die Datei wird vor dem Hochladen komprimiert; komprimiert darf sie bis zu 512 MB groß sein.",
"uploadSuccess": "Backup hochgeladen ({count} Datensätze)",
@@ -8224,7 +8277,7 @@
"restoreSkippedAccountSetting": "Kontoeinstellung",
"restoreMissingTitle": "Wiederherstellung abgelehnt: Die Datei ist unvollständig",
"restoreMissingDescription": "Das Manifest der Datei sagt, sie enthalte den folgenden Abschnitt, und sie enthält ihn nicht. Es wurde nichts geändert: Eine Wiederherstellung ersetzt eine ganze Datenklasse, ein fehlender Abschnitt hätte sie also geleert.",
"restoreMissingRemedy": "Nimm eine Notfall-Sicherung oder exportiere das Konto erneut und lade die neue Datei hoch. Ein Abschnitt, den das Manifest ausdrücklich als weggelassen ausweist, ist etwas anderes und wird normal wiederhergestellt.",
"restoreMissingRemedy": "Nutze eine der Kontokopien dieses Servers oder eine Kopie außerhalb des Servers, oder exportiere das Konto erneut und lade die neue Datei hoch. Ein Abschnitt, den das Manifest als ausgelassen angibt, ist etwas anderes und wird normal wiederhergestellt.",
"restoreMissingDocuments": "Dokumente",
"restoreMissingWorkouts": "Workouts",
"restoreMissingMentalHealth": "Psychische Fragebögen",
@@ -8254,6 +8307,9 @@
"restoreActive": "Für dieses Konto läuft schon eine Wiederherstellung. Warte, bis sie fertig ist.",
"restoreJobQueuedTitle": "Wiederherstellung von {username} wartet auf den Start",
"restoreJobQueuedNote": "Sie beginnt, sobald der Hintergrunddienst sie übernimmt.",
"restoreJobWaitingBackupNote": "Wartet auf das Backup, das gerade auf dem Server läuft. Eine Wiederherstellung läuft nie neben einem Backup und startet etwa eine Minute nach dessen Ende.",
"restoreJobWaitingBusyNote": "Wartet, bis eine große Aufgabe auf dem Server fertig ist, etwa ein Import oder ein nächtlicher Durchlauf. Sie startet etwa eine Minute danach.",
"restoreJobQueuedSince": "In der Warteschlange seit {when}.",
"restoreJobRunningTitle": "{username} wird wiederhergestellt",
"restoreJobRunningNote": "Das Konto zeigt seine bisherigen Daten, bis die Wiederherstellung fertig ist. Du kannst die Seite verlassen; die Wiederherstellung läuft auf dem Server weiter.",
"restoreJobResumed": "Nach einem Neustart des Servers erneut gestartet.",
@@ -8274,7 +8330,7 @@
"restoreFailureOwnerMismatch": "Die Datei im Backup gehört zu einem anderen Konto als die Backup-Zeile.",
"restoreFailureOwnerMissing": "Das Konto, zu dem dieses Backup gehört, gibt es nicht mehr.",
"restoreFailureSectionMissing": "Die Datei ist unvollständig: Ihr Manifest nennt einen Bereich, den sie nicht enthält.",
"restoreFailureDocument": "Die Datei enthält Dokumente ohne deren Inhalt, wie ein portabler Export. Nimm eine Notfall-Sicherung.",
"restoreFailureDocument": "Die Datei enthält Dokumente ohne ihren Inhalt, wie ein portabler Export. Nutze stattdessen eine der Kontokopien dieses Servers oder eine Kopie außerhalb des Servers.",
"restoreFailureTimeBudget": "Dieses Backup ist zu groß, um innerhalb des Zeitlimits des Hintergrundauftrags wiederhergestellt zu werden.",
"restoreFailureTransaction": "Die Datenbank hat die Wiederherstellung mittendrin abgelehnt, deshalb wurde alles zurückgerollt. Das Server-Log nennt den Fehler.",
"restoreFailureCauseTimeout": "Die Wiederherstellung hat länger gedauert, als die Datenbank zulässt, deshalb wurde alles zurückgerollt. War der Datenbank-Host ausgelastet, starte sie erneut, wenn er ruhiger ist. Das Server-Log nennt den Schritt.",
@@ -8295,7 +8351,32 @@
"restoreSkippedMoodLabelCiphertext": "Name eines Stimmungs-Tags oder einer Gruppe mit einem Schlüssel verschlüsselt, den dieser Server nicht hat (ohne Namen behalten)",
"restoreSkippedVisitCiphertext": "Text zu Arztbesuch oder Praxis mit einem Schlüssel verschlüsselt, den dieser Server nicht hat (ohne ihn behalten)",
"restoreSkippedVaccinationCiphertext": "Impf-Notiz mit einem Schlüssel verschlüsselt, den dieser Server nicht hat (Impfung ohne sie behalten)",
"restoreSkippedEnvironmentLocationCiphertext": "Ort eines Umweltzeitraums oder -tags mit einem Schlüssel verschlüsselt, den dieser Server nicht hat (verschlüsselt behalten, diese Tage werden nicht neu abgerufen)"
"restoreSkippedEnvironmentLocationCiphertext": "Ort eines Umweltzeitraums oder -tags mit einem Schlüssel verschlüsselt, den dieser Server nicht hat (verschlüsselt behalten, diese Tage werden nicht neu abgerufen)",
"typePreWipe": "Vor dem Löschen aller Daten",
"retentionNote": "Die wöchentliche Kopie wird jeden Sonntag ersetzt. Eine Kopie, die vor „Alle Daten löschen“ angelegt wurde, wird nach {days} Tagen gelöscht. Hochgeladene Kopien bleiben, bis ihr Konto gelöscht wird.",
"retentionNoteNoGrace": "Die wöchentliche Kopie wird jeden Sonntag ersetzt. Hochgeladene Kopien bleiben, bis ihr Konto gelöscht wird.",
"database": {
"title": "Datenbank-Dump",
"lastGood": "Letzter gültiger Dump {when}, {size}, {where}.",
"whereBoth": "auf diesem Server und extern gespeichert",
"whereLocal": "auf diesem Server gespeichert",
"whereOffhost": "nur extern gespeichert",
"none": "Noch kein Datenbank-Dump. Der erste läuft heute Nacht.",
"failedTitle": "Der letzte Datenbank-Dump ist fehlgeschlagen",
"failedDescription": "{when}: {reason}",
"disabled": "Datenbank-Dumps sind abgeschaltet (DB_BACKUP_ENABLED=false). Sichere die Datenbank auf anderem Weg.",
"noDirectoryOffhost": "Dieser Server kann nicht nach {dir} schreiben. Dumps gehen deshalb nur extern, hier wird keiner aufbewahrt. Binde das Backup-Volume ein.",
"noDirectory": "Dieser Server kann nicht nach {dir} schreiben und es ist kein externer Speicher eingerichtet. Deshalb entsteht kein Datenbank-Dump. Binde das Backup-Volume ein.",
"retentionLocal": "Gelöschte Konten bleiben in den Dumps, bis diese aussortiert werden: Auf diesem Server bleiben die neuesten {nights} nächtlichen Dumps, und der Dump vor dem letzten Update bleibt bis zum nächsten Update.",
"retentionOffhost": "Externe Kopien entfernt die Lebenszyklus-Regel des Buckets nach {days} Tagen.",
"retentionOffhostMissing": "Keine Lebenszyklus-Regel im Bucket gilt für database/. Dumps dort und die gelöschten Konten darin werden deshalb nie entfernt. Lege eine Regel für dieses Präfix an.",
"notPersistentOffhost": "{dir} liegt nicht auf dauerhaftem Speicher (im Container statt auf einem eingebundenen Volume, oder im Arbeitsspeicher). Ein Dump dort wäre beim nächsten Update verloren. Dumps gehen deshalb nur extern: Hier wird keiner aufbewahrt, und vor einer Migration wird keiner erstellt. Binde das Backup-Volume ein.",
"notPersistent": "{dir} liegt nicht auf dauerhaftem Speicher (im Container statt auf einem eingebundenen Volume, oder im Arbeitsspeicher), und es ist kein externer Bucket eingerichtet. Damit wird nirgends ein Datenbank-Dump aufbewahrt. Binde das Backup-Volume ein.",
"offhostFailedTitle": "Der neueste Datenbank-Dump ist nicht im externen Bucket angekommen",
"offhostLastUpload": "Der neueste Dump im Bucket stammt vom {when}.",
"offhostNeverUploaded": "Bisher ist kein Datenbank-Dump im Bucket angekommen."
},
"restorePreWipeLimits": "Diese Kopie wurde vor dem Löschen der Daten des Kontos angelegt, und ihre Wiederherstellung macht das Löschen rückgängig, soweit eine Kopie das kann: Was das Konto seitdem hinzugefügt hat, wird ersetzt, und Integrationsverbindungen, Freigaben, Betreuungszugriffe und Freigabe-Links kommen nicht zurück."
},
"danger-zone": {
"title": "Gefahrenzone",
@@ -8361,7 +8442,10 @@
"columnsHide": "Tabelle ausblenden",
"columnsShowAll": "Alle {count} Spalten anzeigen",
"columnsShowWithRows": "Nur Spalten mit Zeilen",
"columnsNoneWithRows": "Noch keine verschlüsselte Spalte enthält Zeilen."
"columnsNoneWithRows": "Noch keine verschlüsselte Spalte enthält Zeilen.",
"backupsDumpLocalLine": "Schlüssel {key}: {count} Datenbank-Dumps auf diesem Server wurden unter ihm geschrieben; er wird gebraucht, bis die Aufbewahrung sie entfernt.",
"backupsDumpOffhostLine": "Schlüssel {key}: Der neueste Datenbank-Dump, der unter ihm geschrieben wurde, kam am {when} in den Bucket; dort gebraucht bis {until}",
"backupsDumpOffhostLineUnknown": "Schlüssel {key}: Der neueste Datenbank-Dump, der unter ihm geschrieben wurde, kam am {when} in den Bucket. Wie lange der Bucket database/ behält, ist unbekannt; behalte den Schlüssel, bis dieser Dump sicher weg ist."
},
"maintenance": {
"title": "Pflege der Messwert-Tabelle",
@@ -8491,47 +8575,6 @@
"title": "Dieser Teil der Seite konnte nicht angezeigt werden",
"description": "Der Rest der Seite funktioniert weiter; versuche es erneut oder lade neu, wenn es bleibt."
},
"keyBackup": {
"title": "Sichere deinen Verschlüsselungsschlüssel",
"description": "Ohne eine Kopie dieses Schlüssels lassen sich deine verschlüsselten Daten und Sicherungen nie wieder lesen.",
"loading": "Status der Schlüsselsicherung wird geladen…",
"loadError": "Der Status der Schlüsselsicherung konnte nicht geladen werden.",
"badgeDue": "Offen",
"badgeDone": "Gesichert",
"keyId": "Aktive Schlüssel-ID",
"fingerprint": "Fingerabdruck",
"whatIsLost": "Bewahre eine Kopie des Schlüssels außerhalb dieses Servers auf, zum Beispiel in einem Passwortmanager. Wird der Server mit einem anderen Schlüssel neu installiert, nimmt HealthLog den Betrieb erst wieder auf, wenn der ursprüngliche Schlüssel zurück ist.",
"fingerprintHint": "Der Fingerabdruck sind die ersten 12 Zeichen des SHA-256-Hashes des Schlüssels. Er erkennt den Schlüssel, ohne ihn preiszugeben, damit du ihn mit deiner Kopie vergleichen kannst.",
"checkLabel": "Meine Kopie prüfen",
"checkButton": "Prüfen",
"checkNote": "Der eingefügte Schlüssel wird mit dem aktiven Schlüssel verglichen und danach verworfen. Er wird nie gespeichert oder protokolliert.",
"checkInsecureNote": "Diese Seite läuft nicht über https://, also geht der eingefügte Schlüssel unverschlüsselt über das Netz, genau wie dein Passwort. Er wird verglichen und verworfen, nie gespeichert oder protokolliert.",
"checkMatches": "Deine Kopie passt zum aktiven Schlüssel.",
"checkNoMatch": "Deine Kopie passt nicht zum aktiven Schlüssel. Prüfe, ob du den ganzen Wert kopiert hast.",
"checkFailed": "Die Prüfung ist fehlgeschlagen.",
"confirmButton": "Ich habe diesen Schlüssel gesichert",
"confirmed": "Schlüsselsicherung bestätigt.",
"confirmFailed": "Die Bestätigung konnte nicht gespeichert werden.",
"stale": "Der Schlüssel des Servers hat sich geändert, seit diese Seite geladen wurde. Prüfe die neue Schlüssel-ID und den Fingerabdruck und bestätige dann erneut.",
"confirmedAt": "Bestätigt am {date}.",
"confirmedAtBy": "Bestätigt am {date} von {who}.",
"dueAgain": "Für Schlüssel {keyId} wurde eine Sicherung bestätigt, aber der Server nutzt jetzt einen anderen Schlüssel. Sichere den aktuellen.",
"tileLabel": "Sicherung des Verschlüsselungsschlüssels",
"tileDue": "Nicht bestätigt",
"tileDone": "Bestätigt",
"platform": {
"composeLabel": "Compose",
"composeWhere": "Der Schlüssel ist ENCRYPTION_KEY (oder ENCRYPTION_KEYS) in der .env-Datei neben docker-compose.yml.",
"truenasLabel": "TrueNAS",
"truenasWhere": "Öffne Apps, wähle HealthLog, dann Bearbeiten, und kopiere ENCRYPTION_KEY aus den Umgebungsvariablen.",
"coolifyLabel": "Coolify",
"coolifyWhere": "Öffne die Anwendung in Coolify und kopiere ENCRYPTION_KEY aus den Environment Variables.",
"unraidLabel": "Unraid",
"unraidWhere": "Bearbeite im Docker-Tab den HealthLog-Container und kopiere ENCRYPTION_KEY aus der Vorlage oder aus der .env deines Compose-Manager-Stacks.",
"portainerLabel": "Portainer",
"portainerWhere": "Öffne den HealthLog-Stack in Portainer und kopiere ENCRYPTION_KEY aus seinen Umgebungsvariablen."
}
},
"keyMismatch": {
"title": "HealthLog kann seine Daten nicht öffnen",
"body": "Der Verschlüsselungsschlüssel, mit dem dieser Server gestartet wurde, ist nicht der, mit dem seine Datenbank geschrieben wurde. Zum Schutz deiner Daten liefert HealthLog nichts aus, bis das behoben ist.",
@@ -8562,6 +8605,185 @@
"defaultAboveCeiling": "Die Standardstufe darf nicht über der höchsten Stufe liegen.",
"gapLabel": "Stunden zwischen Karten-Erneuerungen nach einem Sync (Standard)",
"gapHint": "Ganze Stunden von 1 bis 48. Leer lassen für AI_SYNC_REGEN_GAP_HOURS, sonst 6. Gerade wirksam: {hours} h."
},
"recoveryKit": {
"title": "Wiederherstellungs-Kit",
"description": "Jeder Schlüssel, den eine Wiederherstellung dieses Servers braucht, mit Kennung und Fingerabdruck.",
"intro": "Bewahre von jedem Schlüssel unten eine Kopie außerhalb dieses Servers auf, zum Beispiel in einem Passwortmanager. Ohne sie lassen sich weder ein Datenbank-Dump noch eine Sicherungskopie wieder lesen, und HealthLog startet nicht, wenn ein benötigter Schlüssel fehlt.",
"loading": "Wiederherstellungs-Kit wird geladen…",
"loadError": "Das Wiederherstellungs-Kit konnte nicht geladen werden.",
"badgeDue": "Offen",
"badgeDone": "Gesichert",
"keyId": "Kennung",
"fingerprint": "Fingerabdruck",
"noFingerprint": "Nicht auf diesem Server",
"kind": {
"application": "Anwendungsschlüssel {id}",
"backup": "Schlüssel für externe Sicherungen",
"backupPrevious": "Früherer Schlüssel für externe Sicherungen",
"token": "Token-Schlüssel"
},
"need": {
"active": "Verschlüsselt neue Daten.",
"configured": "Liegt auf diesem Server.",
"storedCopies": "Wird von {count} gespeicherten Kopien gebraucht.",
"offhostUntil": "Wird von externen Kopien bis {date} gebraucht.",
"offhostUnknown": "Wird von externen Kopien gebraucht, solange der Bucket sie aufbewahrt.",
"backupActive": "Versiegelt jede neue externe Kopie.",
"backupPrevious": "Öffnet externe Kopien, die vor dem letzten Schlüsselwechsel geschrieben wurden.",
"token": "Signiert Sitzungen, Tokens und Freigabelinks. Ohne ihn werden alle abgemeldet, Daten gehen aber nicht verloren.",
"localDumps": "Wird von {count} Datenbank-Dumps auf diesem Server gebraucht.",
"offhostDumpsUntil": "Wird von externen Datenbank-Dumps bis {date} gebraucht.",
"offhostDumpsUnknown": "Wird von externen Datenbank-Dumps gebraucht, solange der Bucket sie aufbewahrt."
},
"source": "Gesetzt in {variable}.",
"status": {
"confirmed": "Gesichert",
"new": "Noch nicht gesichert",
"notConfigured": "Nicht auf diesem Server",
"unreadable": "Kein gültiger Schlüssel"
},
"notConfiguredHint": "Eine Sicherung braucht diesen Schlüssel noch, aber dieser Server hat ihn nicht. Nur eine Kopie, die du anderswo aufbewahrst, kann diese Sicherungen lesen. Bestätige das Kit, sobald du diese Kopie hast.",
"unreadableHint": "Die Variable ist gesetzt, enthält aber keinen gültigen Schlüssel. Behebe das, bevor du dich auf die externen Kopien verlässt.",
"fingerprintHint": "Der Fingerabdruck besteht aus den ersten 12 Zeichen des SHA-256-Hashs eines Schlüssels. Er erkennt den Schlüssel, ohne ihn preiszugeben, sodass du ihn mit deiner Kopie vergleichen kannst.",
"whereTitle": "Wo die Schlüssel gesetzt sind",
"platform": {
"composeLabel": "Compose",
"composeWhere": "Die Schlüssel stehen in der .env-Datei neben docker-compose.yml: ENCRYPTION_KEY oder ENCRYPTION_KEYS, API_TOKEN_HMAC_KEY und, mit externen Kopien, BACKUP_ENCRYPTION_KEY.",
"truenasLabel": "TrueNAS",
"truenasWhere": "Öffne Apps, wähle HealthLog und dann Edit. Die Schlüssel stehen unter HealthLog Configuration; bei einer per YAML installierten App sind es ENCRYPTION_KEY, API_TOKEN_HMAC_KEY und BACKUP_ENCRYPTION_KEY in den Umgebungsvariablen.",
"coolifyLabel": "Coolify",
"coolifyWhere": "Öffne die Anwendung in Coolify und kopiere ENCRYPTION_KEY, API_TOKEN_HMAC_KEY und BACKUP_ENCRYPTION_KEY aus den Environment Variables.",
"unraidLabel": "Unraid",
"unraidWhere": "Bearbeite auf dem Docker-Tab den HealthLog-Container und kopiere ENCRYPTION_KEY, API_TOKEN_HMAC_KEY und BACKUP_ENCRYPTION_KEY aus der Vorlage oder aus der .env deines Compose-Manager-Stacks.",
"portainerLabel": "Portainer",
"portainerWhere": "Öffne den HealthLog-Stack in Portainer und kopiere ENCRYPTION_KEY, API_TOKEN_HMAC_KEY und BACKUP_ENCRYPTION_KEY aus den Umgebungsvariablen."
},
"restoreTitle": "Diesen Server wiederherstellen",
"restoreBody": "Installiere HealthLog mit denselben Schlüsseln, spiele den neuesten Datenbank-Dump in eine leere Datenbank ein und starte dann die App. Die Anleitung führt durch jeden Schritt.",
"restoreLink": "Anleitung zur Notfallwiederherstellung",
"checkLabel": "Meine Kopie prüfen",
"checkKeyLabel": "Zu prüfender Schlüssel",
"checkValueLabel": "Deine Kopie des Schlüssels",
"checkButton": "Prüfen",
"checkNote": "Der eingefügte Schlüssel wird mit dem auf diesem Server verglichen und danach verworfen. Er wird nie gespeichert oder protokolliert.",
"checkInsecureNote": "Diese Seite wird nicht über https:// ausgeliefert. Der eingefügte Schlüssel geht deshalb unverschlüsselt über das Netzwerk, genau wie dein Passwort. Er wird verglichen und verworfen, nie gespeichert oder protokolliert.",
"checkMatches": "Deine Kopie passt zu diesem Schlüssel.",
"checkNoMatch": "Deine Kopie passt nicht zu diesem Schlüssel. Prüfe, ob du den ganzen Wert kopiert hast.",
"checkFailed": "Die Prüfung ist fehlgeschlagen.",
"confirmButton": "Ich habe von jedem Schlüssel eine Kopie",
"confirmed": "Wiederherstellungs-Kit bestätigt.",
"confirmFailed": "Die Bestätigung konnte nicht gespeichert werden.",
"stale": "Das Kit hat sich geändert, seit die Seite geladen wurde. Prüfe die Schlüssel, die es jetzt aufführt, und bestätige erneut.",
"confirmedAt": "Bestätigt am {date}.",
"confirmedAtBy": "Bestätigt am {date} von {who}.",
"dueAgain": "Das Kit wurde am {date} bestätigt. Es führt jetzt auch {keys} auf, was diese Bestätigung nicht abgedeckt hat. Sichere von jedem eine Kopie und bestätige erneut."
},
"recoveryReadiness": {
"title": "Wiederherstellbarkeit",
"description": "Ob sich dieser Server nach einem Ausfall wiederherstellen ließe, beurteilt anhand jeder Sicherung, die er anlegt.",
"loading": "Sicherungen werden geprüft…",
"loadError": "Die Wiederherstellbarkeit konnte nicht geladen werden.",
"state": {
"ok": "Bereit",
"attention": "Braucht Aufmerksamkeit",
"failing": "Gefährdet"
},
"signal": {
"database_dump": "Datenbank-Dump",
"weekly_copy": "Wöchentliche Kontokopien",
"offhost_copies": "Externe Kopien",
"restore_drill": "Wiederherstellungsprobe",
"offhost_setup": "Externe Sicherung",
"recovery_kit": "Wiederherstellungs-Kit",
"dump_retention": "Dumps im Bucket"
},
"at": {
"database_dump": "Neuester Dump: {date}",
"weekly_copy": "Älteste Kontokopie: {date}",
"offhost_copies": "Älteste externe Kopie: {date}",
"restore_drill": "Letzte Probe: {date}",
"recovery_kit": "Bestätigt: {date}"
},
"dueBy": "Fällig bis {date}",
"fix": {
"database_dump": "Sicherungen öffnen",
"weekly_copy": "Sicherungen öffnen",
"offhost_copies": "Sicherungen öffnen",
"restore_drill": "Sicherungen öffnen",
"offhost_setup": "Sicherungen öffnen",
"recovery_kit": "Kit öffnen",
"dump_retention": "Sicherungen öffnen"
},
"reason": {
"database_dump": {
"not_available": "Diese Version legt keinen geplanten Datenbank-Dump an.",
"disabled": "Der geplante Datenbank-Dump ist ausgeschaltet, daher kann keine Kopie diesen Server wieder aufbauen. Zum Wiedereinschalten DB_BACKUP_ENABLED entfernen oder auf true setzen und HealthLog neu starten.",
"unreadable": "Der Zustand des Datenbank-Dumps konnte nicht gelesen werden.",
"never_succeeded": "Noch kein Datenbank-Dump ist gelungen, und der letzte Versuch ist fehlgeschlagen.",
"never_ran": "In den {hours} Stunden, seit Dumps eingeschaltet sind, wurde kein Datenbank-Dump geschrieben.",
"first_pending": "Der erste Datenbank-Dump ist noch nicht gelaufen.",
"stale": "Der neueste Datenbank-Dump ist {hours} Stunden alt.",
"last_failed": "Der letzte Datenbank-Dump ist fehlgeschlagen. Der davor ist noch aktuell.",
"fresh": "Der neueste Datenbank-Dump ist aktuell.",
"nowhere_to_write": "Das Dump-Verzeichnis {directory} ist nicht beschreibbar und es ist kein externer Bucket eingerichtet, daher kann kein Dump aufbewahrt werden.",
"not_persistent": "Das Dump-Verzeichnis {directory} liegt nicht auf dauerhaftem Speicher, und es ist kein externer Bucket eingerichtet. Jeder Dump wäre beim nächsten Update verloren. Binde das Backup-Volume ein.",
"fresh_offhost_only": "Der neueste Datenbank-Dump ist aktuell und liegt im externen Bucket. Auf diesem Server wird keiner aufbewahrt, weil {directory} nicht auf dauerhaftem Speicher liegt, und vor einer Migration wird keiner erstellt.",
"offhost_failed": "Der neueste Datenbank-Dump liegt auf diesem Server, aber das Hochladen in den externen Bucket ist fehlgeschlagen.",
"offhost_stale": "Seit {hours} Stunden ist kein Datenbank-Dump im externen Bucket angekommen, das Hochladen schlägt immer wieder fehl. Geht dieser Server verloren, gibt es keinen aktuellen Dump, um ihn wiederherzustellen.",
"offhost_never": "Noch nie ist ein Datenbank-Dump im externen Bucket angekommen, das Hochladen schlägt fehl. Geht dieser Server verloren, gibt es keinen Dump, um ihn wiederherzustellen."
},
"weekly_copy": {
"no_accounts": "Es gibt noch keine Konten zum Kopieren.",
"stale": "Die älteste wöchentliche Kopie ist {days} Tage alt. Konten im Rückstand: {accounts}.",
"never": "Ein Konto hat seit {days} Tagen keine wöchentliche Kopie. Konten im Rückstand: {accounts}.",
"first_pending": "Die erste wöchentliche Kopie entsteht am frühen Sonntagmorgen.",
"fresh": "Jedes Konto hat eine Kopie aus der letzten Woche."
},
"offhost_copies": {
"not_configured": "Es ist kein externer Bucket eingerichtet.",
"stale": "Externe Kopien liegen mehr als zwei Nächte zurück. Konten im Rückstand: {accounts}.",
"first_pending": "Der nächtliche Lauf für externe Kopien hat noch kein Konto erreicht.",
"fresh": "Jedes Konto hat eine aktuelle Kopie im externen Bucket."
},
"restore_drill": {
"not_configured": "Die Probe liest den externen Bucket, und der ist nicht eingerichtet.",
"not_run_yet": "Die monatliche Wiederherstellungsprobe ist noch nicht gelaufen.",
"failed": "Die letzte Wiederherstellungsprobe ist fehlgeschlagen: {failed} von {checked} Kopien ließen sich nicht zurücklesen.",
"failed_run": "Die letzte Wiederherstellungsprobe konnte nicht zu Ende laufen.",
"passed": "Die letzte Wiederherstellungsprobe hat {checked} Kopien zurückgelesen.",
"nothing_checked": "Die letzte Wiederherstellungsprobe hat ihr Zeitlimit erreicht, bevor sie eine einzige Kopie zurückgelesen hat, und hat damit nichts belegt."
},
"offhost_setup": {
"configured": "Kopien verlassen diesen Server jede Nacht.",
"no_admin": "Es gibt noch kein Admin-Konto.",
"not_configured": "Es ist kein externer Bucket eingerichtet, daher liegt jede Kopie nur auf diesem Server.",
"not_configured_grace": "Es ist noch kein externer Bucket eingerichtet, daher liegt jede Kopie nur auf diesem Server."
},
"recovery_kit": {
"confirmed": "Von jedem Schlüssel, den eine Wiederherstellung braucht, gibt es eine Kopie.",
"out_of_date": "Das Wiederherstellungs-Kit führt jetzt auch {keys} auf, was die letzte Bestätigung nicht abgedeckt hat. Nichts ist kaputt: Sichere eine Kopie und bestätige das Kit erneut.",
"no_admin": "Es gibt noch kein Admin-Konto.",
"not_confirmed": "Niemand hat eine Kopie der Schlüssel bestätigt, die eine Wiederherstellung braucht: {keys}.",
"not_confirmed_grace": "Sichere eine Kopie der Schlüssel, die eine Wiederherstellung braucht: {keys}."
},
"dump_retention": {
"not_configured": "Es ist kein externer Bucket eingerichtet.",
"no_offhost_dumps": "Es geht kein Datenbank-Dump in den Bucket.",
"missing": "Keine Lebenszyklus-Regel im Bucket gilt für database/. Dumps dort und die gelöschten Konten darin werden deshalb nie entfernt. Lege eine Regel für dieses Präfix an.",
"configured": "Der Bucket entfernt Dumps nach {days} Tagen.",
"unknown": "Der Bucket gibt nicht an, ob eine Regel alte Dumps entfernt. Prüfe, ob eine Lebenszyklus-Regel database/ erfasst."
}
},
"recovered": {
"database_dump": "Der Datenbank-Dump ist wieder aktuell.",
"weekly_copy": "Jedes Konto hat wieder eine aktuelle wöchentliche Kopie.",
"offhost_copies": "Jedes Konto hat wieder eine aktuelle externe Kopie.",
"restore_drill": "Die Wiederherstellungsprobe ist wieder bestanden.",
"offhost_setup": "Externe Kopien sind jetzt eingerichtet.",
"recovery_kit": "Das Wiederherstellungs-Kit ist wieder bestätigt.",
"dump_retention": "Eine Lebenszyklus-Regel im Bucket erfasst jetzt die Datenbank-Dumps."
}
}
},
"achievements": {
@@ -8875,7 +9097,10 @@
"offlineGeoUnavailableTitle": "Offline-Geodatenbanken nicht geladen",
"offlineGeoUnavailableBody": "HealthLog löst Login-Standorte über die Online-Abfrage bei {host} auf, damit verlässt jede Login-IP diesen Server. Um sie stattdessen lokal aufzulösen, lade die Datenbanken GeoLite2-City und GeoLite2-ASN mit einem kostenlosen MaxMind-Konto herunter, binde das Verzeichnis in den Container ein und setze GEOLITE2_DIR darauf. Anleitung: {docsUrl}",
"tlsPinChangedTitle": "TLS-Leaf-Zertifikat geändert: {host}",
"tlsPinChangedBody": "Das von {host} ausgelieferte TLS-Leaf hat jetzt den SPKI-Pin {servedPin}, der nicht im bekannten Pin-Satz enthalten ist. Gepinnte Clients (die native App) können sich nicht mehr verbinden, sobald der alte Pin weg ist. Extrahiere den neuen Leaf-Pin erneut, ergänze ihn in TLS_LEAF_SPKI_PINS sowie im Pin-Satz des nativen Clients und veröffentliche einen Build, bevor dieses Zertifikat am {validTo} abläuft. Siehe docs/ops/tls-cert-pin.md."
"tlsPinChangedBody": "Das von {host} ausgelieferte TLS-Leaf hat jetzt den SPKI-Pin {servedPin}, der nicht im bekannten Pin-Satz enthalten ist. Gepinnte Clients (die native App) können sich nicht mehr verbinden, sobald der alte Pin weg ist. Extrahiere den neuen Leaf-Pin erneut, ergänze ihn in TLS_LEAF_SPKI_PINS sowie im Pin-Satz des nativen Clients und veröffentliche einen Build, bevor dieses Zertifikat am {validTo} abläuft. Siehe docs/ops/tls-cert-pin.md.",
"accountErasureRefusedTitle": "Konto konnte nicht gelöscht werden: {username}",
"accountErasureRefusedBody": "Die Löschfrist von {username} ist abgelaufen, aber das Konto ist die letzte aktive betreuende Person eines verwalteten Profils und wurde deshalb nicht gelöscht. Es bleibt gesperrt. Füge dem Profil eine weitere betreuende Person hinzu, lösche das Profil oder mache die Löschung unter Administration, Nutzer rückgängig.",
"accountErasureFailedBody": "Die Löschfrist von {username} ist abgelaufen, aber das Löschen des Kontos ist fehlgeschlagen. Es bleibt gesperrt und wird jede Stunde erneut versucht, nach den anderen fälligen Konten. Den Fehler zeigt die Karte der fehlgeschlagenen Jobs unter Administration."
},
"user": {
"telegramTestBody": "HealthLog: Verbindung hergestellt. Telegram-Benachrichtigungen sind aktiv."
@@ -9027,6 +9252,21 @@
"checkupReminders": {
"title": "Vorsorge-Erinnerungen",
"description": "Schalte einzelne Vorsorge-Erinnerungen ein oder aus."
},
"eventBackupHealth": "Zustand der Sicherungen",
"eventBackupHealthDesc": "Wenn eine Sicherung ausbleibt, zurückfällt oder wieder läuft. Nur für Admins.",
"recoveryReadiness": {
"titleAttention": "Sicherungen brauchen Aufmerksamkeit",
"titleFailing": "Sicherungen sind gefährdet",
"titleReminder": "Sicherungen sind weiterhin gefährdet",
"titleRecovered": "Sicherungen laufen wieder normal"
},
"systemJobAlert": {
"titleFailing": "Hintergrundjob schlägt fehl: {queue}",
"titleReminder": "Hintergrundjob schlägt weiter fehl: {queue}",
"titleRecovered": "Hintergrundjob läuft wieder: {queue}",
"bodyFailing": "Der Hintergrundjob {queue} ist {failures}-mal in Folge fehlgeschlagen und steht auf der Admin-Statusseite. Letzter Fehler: {error}",
"bodyRecovered": "Der Hintergrundjob {queue} ist wieder erfolgreich gelaufen."
}
},
"i18n": {
@@ -10427,6 +10667,8 @@
"yellow-fever": "Gelbfieber",
"japanese-encephalitis": "Japanische Enzephalitis",
"cholera": "Cholera",
"dengue": "Dengue",
"tuberculosis": "Tuberkulose",
"td": "Td (Tetanus, Diphtherie)",
"tdap": "Tdap (Tetanus, Diphtherie, Pertussis)",
"tdap-ipv": "Tdap-IPV (Vierfachimpfung)",
@@ -10944,25 +11186,15 @@
"who5": "Wohlbefindens-Check, 5 Fragen",
"sci": "Schlaf-Check, 8 Fragen"
},
"options": {
"0": "Überhaupt nicht",
"1": "An einzelnen Tagen",
"2": "An mehr als der Hälfte der Tage",
"3": "Beinahe jeden Tag"
},
"functionalTitle": "Wenn du eine oder mehrere Beschwerden angegeben hast: Wie sehr haben diese deine Arbeit, deinen Haushalt oder den Umgang mit anderen Menschen erschwert?",
"functional": {
"0": "Überhaupt nicht erschwert",
"1": "Etwas erschwert",
"2": "Sehr erschwert",
"3": "Extrem erschwert"
},
"functionalOptionalHint": "Optional: Du kannst auch ohne Antwort auf diese Frage absenden.",
"result": {
"title": "Dein Ergebnis",
"totalLabel": "Gesamtwert",
"bandLabel": "Bereich für dieses Screening",
"takeAnother": "Weiteren Check-in machen"
"takeAnother": "Weiteren Check-in machen",
"scoreOf": "von {max}",
"firstCheckIn": "Das ist dein erstes Ergebnis mit diesem Fragebogen. Ab dem nächsten siehst du hier die Veränderung.",
"nextStep": "Du kannst den Check-in in zwei Wochen oder später wiederholen, um die Veränderung zu sehen. Wenn dich etwas beunruhigt, sprich jederzeit mit einer Fachperson."
},
"band": {
"PHQ9": {
@@ -11003,45 +11235,6 @@
"ifDanger": "Wenn du in unmittelbarer Gefahr bist, ruf jetzt {emergency} an.",
"resourcesTitle": "Menschen, die helfen können"
},
"items": {
"phq9": {
"1": "Wenig Interesse oder Freude an Ihren Tätigkeiten",
"2": "Niedergeschlagenheit, Schwermut oder Hoffnungslosigkeit",
"3": "Schwierigkeiten, ein- oder durchzuschlafen, oder vermehrter Schlaf",
"4": "Müdigkeit oder Gefühl, keine Energie zu haben",
"5": "Verminderter Appetit oder übermäßiges Bedürfnis zu essen",
"6": "Schlechte Meinung von sich selbst; Gefühl, ein Versager zu sein oder die Familie enttäuscht zu haben",
"7": "Schwierigkeiten, sich auf etwas zu konzentrieren, z. B. beim Zeitunglesen oder Fernsehen",
"8": "Waren Ihre Bewegungen oder Ihre Sprache so verlangsamt, dass es auch anderen auffallen würde? Oder waren Sie im Gegenteil „zappelig“ oder ruhelos und hatten einen stärkeren Bewegungsdrang als sonst?",
"9": "Gedanken, dass Sie lieber tot wären oder sich Leid zufügen möchten"
},
"gad7": {
"1": "Nervosität, Ängstlichkeit oder Anspannung",
"2": "Nicht in der Lage sein, Sorgen zu stoppen oder zu kontrollieren",
"3": "Übermäßige Sorgen bezüglich verschiedener Angelegenheiten",
"4": "Schwierigkeiten zu entspannen",
"5": "Rastlosigkeit, sodass Stillsitzen schwerfällt",
"6": "Schnelle Verärgerung oder Gereiztheit",
"7": "Gefühl der Angst, als würde etwas Schlimmes passieren"
},
"who5": {
"1": "… war ich froh und guter Laune",
"2": "… habe ich mich ruhig und entspannt gefühlt",
"3": "… habe ich mich energisch und aktiv gefühlt",
"4": "… habe ich mich beim Aufwachen frisch und ausgeruht gefühlt",
"5": "… war mein Alltag voller Dinge, die mich interessieren"
},
"sci": {
"1": "… how long does it take you to fall asleep?",
"2": "… if you then wake up during the night … how long are you awake for in total? (add all the wakenings up)",
"3": "… how many nights a week do you have a problem with your sleep?",
"4": "… how would you rate your sleep quality?",
"5": "… affected your mood, energy, or relationships?",
"6": "… affected your concentration, productivity, or ability to stay awake?",
"7": "… troubled you in general?",
"8": "… how long have you had a problem with your sleep?"
}
},
"crisisResource": {
"telefonSeelsorge": {
"name": "TelefonSeelsorge (rund um die Uhr, kostenlos, vertraulich)"
@@ -11069,6 +11262,24 @@
},
"crisisTextLine": {
"name": "Crisis Text Line"
},
"cvv": {
"name": "CVV, Centro de Valorização da Vida (Brasilien, rund um die Uhr, kostenlos, vertraulich)"
},
"samu": {
"name": "SAMU, medizinischer Notruf (Brasilien)"
},
"sns24": {
"name": "SNS 24, psychologische Beratung (Portugal, rund um die Uhr, Option 4 wählen)"
},
"sosVozAmiga": {
"name": "SOS Voz Amiga (Portugal, täglich 15:30 bis 0:30 Uhr)"
},
"zelfmoordpreventie113": {
"name": "113 Zelfmoordpreventie (Niederlande, rund um die Uhr, kostenlos, anonym, auch per Chat)"
},
"zelfmoordlijn1813": {
"name": "Zelfmoordlijn 1813 (Belgien, rund um die Uhr, kostenlos, anonym)"
}
},
"instrumentDescription": {
@@ -11077,7 +11288,6 @@
"who5": "Wohlbefindens-Check mit 5 Fragen. Beziehe dich auf die letzten zwei Wochen; höhere Werte bedeuten besseres Wohlbefinden.",
"sci": "Schlaf-Check mit 8 Fragen. Die meisten Fragen beziehen sich auf den letzten Monat; höhere Werte bedeuten besseren Schlaf."
},
"progress": "{current} / {total}",
"next": "Weiter",
"lastResult": "Letzter Test",
"lastScore": "Letztes Ergebnis",
@@ -11089,62 +11299,6 @@
"who5": "Ein Wohlbefindens-Wert von 50 oder darunter kann einen genaueren Blick wert sein. Wenn du magst, schaut der PHQ-9-Check-in auf dieser Seite genauer auf die Stimmung. Das ist ein behutsamer Hinweis, keine Diagnose.",
"sci": "In der Studie zu diesem Fragebogen waren Werte von 16 oder darunter mit wahrscheinlicher Insomnie verbunden. Wenn schlechter Schlaf dich weiter belastet, kann ein Gespräch mit einer Fachperson helfen. Das ist keine Diagnose."
},
"validatedInEnglishNote": "Dieser Fragebogen ist in englischer Sprache validiert; die Fragen erscheinen im englischen Original.",
"who5Options": {
"0": "Zu keinem Zeitpunkt",
"1": "Ab und zu",
"2": "Etwas weniger als die Hälfte der Zeit",
"3": "Etwas mehr als die Hälfte der Zeit",
"4": "Meistens",
"5": "Die ganze Zeit"
},
"sciOptions": {
"duration": {
"0": "≥ 61 min",
"1": "46–60 min",
"2": "31–45 min",
"3": "16–30 min",
"4": "0–15 min"
},
"nights": {
"0": "5–7",
"1": "4",
"2": "3",
"3": "2",
"4": "0–1"
},
"quality": {
"0": "Very poor",
"1": "Poor",
"2": "Average",
"3": "Good",
"4": "Very good"
},
"impact": {
"0": "Very much",
"1": "Much",
"2": "Somewhat",
"3": "A little",
"4": "Not at all"
},
"problemDuration": {
"0": "> 1 year",
"1": "7–12 months",
"2": "3–6 months",
"3": "1–2 months",
"4": "I don't have a problem / < 1 month"
}
},
"stems": {
"who5": {
"period": "In den letzten zwei Wochen …"
},
"sci": {
"night": "Thinking about a typical night in the last month …",
"impact": "Thinking about the past month, to what extent has poor sleep …",
"finally": "Finally …"
}
},
"openDetail": "Details anzeigen",
"historyLoadError": "Dein Testverlauf konnte nicht geladen werden",
"historyLoadErrorHint": "Das heißt nicht, dass keine Ergebnisse vorliegen. Die Liste konnte nicht abgerufen werden. Versuche es erneut.",
@@ -11157,6 +11311,50 @@
"worsened": "Das erreicht die Veränderung von {threshold} Punkten, die Studien als bedeutsam ansehen, in die weniger gute Richtung.",
"within_threshold": "Das ist weniger als die Veränderung von {threshold} Punkten, die Studien als bedeutsam ansehen."
}
},
"wizard": {
"progressLabel": "Frage {current} von {total}",
"followUpLabel": "Zusatzfrage (freiwillig)",
"keyboardHint": "Mit den Zahlentasten 1 bis {count} wählst du eine Antwort.",
"allAnswered": "Alle {total} Fragen beantwortet"
},
"review": {
"title": "Antworten prüfen",
"description": "Gespeichert wird erst beim Absenden, bis dahin lässt sich jede Antwort noch ändern.",
"change": "Ändern",
"changeLabel": "Antwort auf Frage {number} ändern",
"notAnswered": "Nicht beantwortet",
"open": "Antworten prüfen"
},
"bandMeaning": {
"PHQ9": {
"minimal": "Deine Antworten weisen für die letzten zwei Wochen auf keine oder kaum depressive Beschwerden hin.",
"mild": "Deine Antworten weisen für die letzten zwei Wochen auf leichte depressive Beschwerden hin.",
"moderate": "Deine Antworten weisen für die letzten zwei Wochen auf mittelgradige depressive Beschwerden hin.",
"modSevere": "Deine Antworten weisen für die letzten zwei Wochen auf mittelschwere depressive Beschwerden hin.",
"severe": "Deine Antworten weisen für die letzten zwei Wochen auf schwere depressive Beschwerden hin."
},
"GAD7": {
"minimal": "Deine Antworten weisen für die letzten zwei Wochen auf keine oder kaum Angstbeschwerden hin.",
"mild": "Deine Antworten weisen für die letzten zwei Wochen auf leichte Angstbeschwerden hin.",
"moderate": "Deine Antworten weisen für die letzten zwei Wochen auf mittelgradige Angstbeschwerden hin.",
"severe": "Deine Antworten weisen für die letzten zwei Wochen auf schwere Angstbeschwerden hin."
},
"WHO5": {
"low": "Dein Wohlbefinden lag in den letzten zwei Wochen unter dem Bereich, den der WHO-5 als gut wertet.",
"good": "Dein Wohlbefinden lag in den letzten zwei Wochen in dem Bereich, den der WHO-5 als gut wertet."
},
"SCI": {
"belowThreshold": "Dein Schlaf lag im letzten Monat in dem Bereich, in dem die Studie hinter diesem Fragebogen eine Insomnie für wahrscheinlich hielt.",
"aboveThreshold": "Dein Schlaf lag im letzten Monat außerhalb des Bereichs, den diese Studie mit Insomnie verbindet."
}
},
"wording": {
"unvalidatedNote": "Diese Übersetzung ist nicht wissenschaftlich validiert; nimm das Ergebnis als Orientierung.",
"showOriginal": "Englischen Originaltext zeigen",
"showTranslation": "Übersetzung zeigen",
"resultUnvalidated": "Beantwortet in einer Übersetzung, die nicht wissenschaftlich validiert ist; nimm das Ergebnis als Orientierung.",
"historyUnvalidated": "Beantwortet in einer nicht wissenschaftlich validierten Übersetzung"
}
},
"records": {
@@ -11684,7 +11882,8 @@
"toast": {
"duplicate": "Bereits gespeichert. Das vorhandene Dokument ist hervorgehoben.",
"deleted": "Dokument gelöscht",
"restoreFailed": "Das Dokument konnte nicht wiederhergestellt werden."
"restoreFailed": "Das Dokument konnte nicht wiederhergestellt werden.",
"deletedHint": "Gelöschte Dokumente bleiben {days} Tage unter Zuletzt gelöscht."
},
"timeline": {
"loadingMore": "Mehr wird geladen…",
@@ -11729,7 +11928,8 @@
"raster-failed": "Die PDF-Seiten konnten zum Lesen nicht gerendert werden. Gescannte Fax-Kompressionsformate sind eine bekannte Ursache.",
"provider-error": "Der KI-Anbieter konnte dieses Dokument beim letzten Versuch nicht lesen.",
"pdf-needs-anthropic": "Der eingerichtete KI-Anbieter kann keine PDF-Dateien lesen. Ein Claude-Anbieter oder lokale OCR kann es.",
"empty-transcription": "Der KI-Anbieter hat für dieses Dokument keinen Text geliefert."
"empty-transcription": "Der KI-Anbieter hat für dieses Dokument keinen Text geliefert.",
"resource-limit": "Dieses PDF ist zu groß oder zu komplex, um es hier zu lesen. Die Datei selbst ist in Ordnung und lässt sich weiterhin öffnen."
}
},
"loadError": "Dieses Dokument konnte nicht geladen werden.",
@@ -11801,7 +12001,7 @@
"dismiss": "Verwerfen",
"empty": "Aus diesem Dokument konnte nichts gelesen werden.",
"unavailableBody": "Richte einen KI-Anbieter ein, um Titel, Typ und Datum vorzuschlagen.",
"unavailableLocalOcr": "Aktiviere lokale Texterkennung in den KI-Einstellungen, um Bilder auf diesem Gerät zu lesen.",
"unavailableLocalOcr": "Schalte „Scans auf diesem Gerät lesen“ unter Einstellungen → Module → Labor ein, um Bilder auf diesem Gerät zu lesen.",
"unavailableAction": "KI-Einstellungen öffnen",
"errorGeneric": "Dokument konnte nicht gelesen werden. Bitte erneut versuchen.",
"errorOcr": "Aus diesem Bild konnte kein Text gelesen werden.",
@@ -11813,11 +12013,13 @@
"errorPdfNeedsVision": "Zum Scannen von PDFs wird ein Claude-Vision-Anbieter benötigt.",
"errorFileType": "Dieser Dateityp kann nicht gelesen werden.",
"errorNotIndexed": "Lies das Dokument zuerst aus, dann extrahiere.",
"errorLocalOcrDisabled": "Aktiviere zuerst die lokale Texterkennung in den KI-Einstellungen.",
"errorOutputLimit": "Der KI-Anbieter hat sein Ausgabelimit erreicht, bevor er einen einzigen Wert geliefert hat. Versuche ein Modell, das längere Antworten erlaubt.",
"errorLocalOcrDisabled": "Schalte zuerst „Scans auf diesem Gerät lesen“ unter Einstellungen → Module → Labor ein.",
"errorProvider": "Dafür ist kein KI-Anbieter eingerichtet.",
"errorTextImageOnly": "Lokale Texterkennung liest nur Bilder, nicht diese Datei.",
"errorConsent": "Für das Lesen von Dokumenten mit KI brauchst du zuerst deine Einwilligung.",
"errorUnavailable": "Das Lesen von Dokumenten mit KI ist hier gerade nicht verfügbar."
"errorUnavailable": "Das Lesen von Dokumenten mit KI ist hier gerade nicht verfügbar.",
"unavailableLocalOcrAction": "Labor-Einstellungen öffnen"
},
"summary": {
"summarise": "Zusammenfassen",
@@ -11873,6 +12075,7 @@
"extracting": "Liest aus…",
"extractHint": "Nutzt den bereits ausgelesenen Text dieses Dokuments. Ausgelesene Werte warten auf deine Prüfung; nichts wird automatisch gespeichert.",
"extractEmpty": "Aus diesem Dokument konnten keine Werte ausgelesen werden.",
"extractIncomplete": "Ein Teil des Dokuments konnte nicht vollständig gelesen werden, deshalb können Werte fehlen. Vergleiche die Liste mit dem Dokument.",
"failUnitRequired": "Ein Zahlenwert braucht eine Einheit, bevor er gespeichert werden kann.",
"failUnitMismatch": "Die angegebene Einheit passt nicht zur gespeicherten Einheit dieses Markers. Passe sie an und schließe die Prüfung erneut ab.",
"failGeneric": "Dieser Wert konnte nicht gespeichert werden.",
@@ -11894,7 +12097,6 @@
},
"chat": {
"title": "Über dieses Dokument chatten",
"openAria": "Coach über dieses Dokument fragen",
"openButton": "Coach fragen"
},
"readingConsent": {
@@ -11921,6 +12123,7 @@
"loadMore": "Mehr laden",
"stateImported": "In HealthLog",
"stateDeleted": "In HealthLog gelöscht",
"stateInTrash": "Unter Zuletzt gelöscht",
"selectedCount": "{count} von höchstens {max} ausgewählt",
"importAction": "{count} importieren",
"importing": "Import läuft, ein Dokument nach dem anderen …",
@@ -11934,7 +12137,9 @@
"importedLinked": "Importiert und verknüpft",
"duplicate": "Schon in HealthLog",
"duplicateLinked": "Schon in HealthLog, jetzt verknüpft",
"deleted": "In HealthLog gelöscht, bleibt gelöscht"
"deleted": "In HealthLog gelöscht, bleibt gelöscht",
"restored": "Aus Zuletzt gelöscht wiederhergestellt",
"restoredLinked": "Aus Zuletzt gelöscht wiederhergestellt und verknüpft"
},
"errors": {
"unavailable": "Dokumentarchive sind auf diesem Server nicht freigeschaltet.",
@@ -11978,8 +12183,32 @@
"deleteConfirm": {
"titleOne": "Dieses Dokument löschen?",
"titleMany": "{count} Dokumente löschen?",
"body": "Direkt danach lässt sich das noch rückgängig machen. Nach 30 Tagen wird es endgültig entfernt.",
"body": "Gelöschte Dokumente bleiben {days} Tage unter Zuletzt gelöscht, danach werden sie endgültig entfernt.",
"action": "Löschen"
},
"trash": {
"title": "Zuletzt gelöscht",
"subtitle": "Gelöschte Dokumente bleiben {days} Tage hier, danach werden sie endgültig entfernt.",
"back": "Dokumente",
"entryLabel": "Zuletzt gelöscht ({count})",
"loadError": "Zuletzt gelöscht konnte nicht geladen werden.",
"emptyTitle": "Nichts unter Zuletzt gelöscht",
"emptyDescription": "Was du löschst, wartet hier {days} Tage, bevor es endgültig entfernt wird.",
"count": "Gelöschte Dokumente ({count})",
"restoreAll": "Alle wiederherstellen",
"restore": "Wiederherstellen",
"purge": "Jetzt löschen",
"deletedOn": "Gelöscht am {date}.",
"removesIn": "Wird in {days} Tagen endgültig entfernt.",
"removesSoon": "Wird innerhalb eines Tages endgültig entfernt.",
"restored": "Dokument wiederhergestellt",
"openRestored": "Öffnen",
"restoreDuplicate": "Ein identisches Dokument ist schon gespeichert, deshalb kann diese Kopie nicht wiederhergestellt werden.",
"restorePartial": "{failed} von {total} konnten nicht wiederhergestellt werden",
"purged": "Dokument endgültig gelöscht",
"purgeFailed": "Das Dokument konnte nicht gelöscht werden.",
"purgeConfirmTitle": "Dieses Dokument endgültig löschen?",
"purgeConfirmBody": "Es wird jetzt entfernt statt am Ende der Wartezeit und lässt sich nicht wiederherstellen."
}
},
"selfDescription": {
@@ -12664,7 +12893,8 @@
"entryHint": "Wenn {name} die Akte selbst führen möchte.",
"methodSso": "{name} meldet sich beim Einlösen des Links über Single Sign-on an.",
"methodBoth": "{name} kann ein Passwort festlegen oder sich über Single Sign-on anmelden."
}
},
"deleteBodyGrace": "Alles, was für {name} erfasst wurde, verschwindet mit dem Profil: Messwerte, Medikamente, Dokumente und Notizen. Das Profil wird sofort gesperrt und nach {days} Tagen endgültig gelöscht. Bis dahin kann ein Admin dieses Servers die Löschung rückgängig machen."
},
"toast": {
"savedTo": "In der Akte von {name} gespeichert"
@@ -12719,6 +12949,7 @@
"practitionerContactRestore": "{name} hat einen Adressbucheintrag wiederhergestellt",
"illnessDayLogUpsert": "{name} hat einen Krankheitstag erfasst",
"medicationUpdate": "{name} hat ein Medikament geändert",
"medicationCourseEnd": "{name} hat eine Medikamenteneinnahme beendet",
"medicationDelete": "{name} hat ein Medikament gelöscht",
"medicationGlp1Update": "{name} hat eine GLP-1-Therapie geändert",
"medicationScheduleCreate": "{name} hat einen Einnahmeplan angelegt",
@@ -12766,6 +12997,9 @@
"rejectedDoseUpdate": "{name} wollte eine Einnahme ändern und wurde abgewiesen",
"rejectedDoseImport": "{name} wollte Einnahmen importieren und wurde abgewiesen",
"rejectedTrackedValue": "{name} wollte einen eigenen Messwert eintragen und wurde abgewiesen",
"rejectedTrackedValueTypeCreate": "{name} wollte eine eigene Messgröße anlegen und wurde abgewiesen",
"rejectedTrackedValueTypeUpdate": "{name} wollte eine eigene Messgröße ändern und wurde abgewiesen",
"rejectedMedicationLayout": "{name} wollte die Medikamentenliste neu anordnen und wurde abgewiesen",
"deniedDoseImport": "Ein von {name} gestarteter Einnahme-Import konnte nicht eingereiht werden",
"anamnesisFactCreate": "{name} hat einen Fakt zur Vorgeschichte eingetragen",
"customMetricCreate": "{name} hat eine eigene Messgröße angelegt",
@@ -13075,7 +13309,8 @@
"backgroundScan": "Wird im Hintergrund gelesen. Mit einem langsamen Modell kann das ein paar Minuten dauern.",
"waitingForWorker": "Warte auf den Hintergrunddienst. Ändert sich das nicht, läuft der Worker auf dem Server vielleicht nicht.",
"errorWorkerUnavailable": "Der Hintergrunddienst ist gerade nicht erreichbar. Versuch es gleich noch einmal oder bitte die Person, die den Server betreibt, ihn zu prüfen.",
"errorTimedOut": "Das Lesen hat länger gedauert, als deine KI-Antwortzeit erlaubt. Versuch es noch einmal oder erhöhe die Antwortzeit in den KI-Einstellungen."
"errorTimedOut": "Das Lesen hat länger gedauert, als deine KI-Antwortzeit erlaubt. Versuch es noch einmal oder erhöhe die Antwortzeit in den KI-Einstellungen.",
"errorReaderBusy": "Der Dokumentleser ist gerade mit anderen Aufgaben beschäftigt. Versuch es in einer Minute noch einmal."
},
"symptoms": {
"add": "Neues Symptom",
Loaded 100 of 3265 files, more files were not shown because too many files have changed in this diff. Show more