Commit Graph
6308 Commits
Author SHA1 Message Date
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 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 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 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 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 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 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 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 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 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 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 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 c10446f0d7 Merge branch 'release/v1.45.0' into fix/v145-review-data 2026-10-22 21:48:34 +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 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 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 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 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 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