Commit Graph
4535 Commits
Author SHA1 Message Date
Marc-André Bombeck 2aeb925e28 merge: s15-stepsdisplay into release/v1.34.0 2026-07-27 23:41:34 +02:00
Marc-André Bombeck 66017a8b06 merge: s10-google into release/v1.34.0 2026-07-27 23:41:34 +02:00
Marc-André Bombeck b14943ea1b merge: s8-baselines into release/v1.34.0 2026-07-27 23:41:34 +02:00
Marc-André Bombeck fbdadae0ce merge: s5-export into release/v1.34.0 2026-07-27 23:41:33 +02:00
Marc-André Bombeck b402be221d merge: s4-yardstick into release/v1.34.0 2026-07-27 23:41:33 +02:00
Marc-André Bombeck 602ca36ee2 merge: s3-written into release/v1.34.0 2026-07-27 23:41:33 +02:00
Marc-André Bombeck 924babe2ea merge: s1-jobspine into release/v1.34.0 2026-07-27 23:41:33 +02:00
Marc-André Bombeck b56f5721a2 test(measurements): pin that the day's shape is written before the delete
The drain's existing transaction mock had no profile delegate, so the fold
could have thrown inside the per-day error boundary and every assertion would
still have passed. Cover the merge directly: the upsert lands before the
deleteMany, the stored curve matches where the samples actually fell, and a
serialised tick that finds the rows already drained writes nothing.
2026-07-27 23:39:37 +02:00
Marc-André Bombeck 6bd8d387ae feat(insights): surface the same-time baseline on Today and in the grid
The rail gains a same_time_baseline kind and a server builder, which is the
growth path that type documents for itself. It speaks only when today is
actually outside the typical band: a card that reports doing exactly what you
usually do teaches people to stop reading the rail. Dismissing it is keyed by
day and metric, not by the hour, so it stays dismissed instead of coming back
every sixty minutes.

The Insights grid gains a section of four tiles, one per cumulative metric,
each honestly quiet until the history accrues.

The two-trajectory chart it needs did not exist, so DeltaSparkline gains a
second mode rather than a second chart family: the comparison curve rides the
same container, the same fixed height and the same shared y-domain as a dashed
muted line behind the live one, which is the only way the two can be read
against each other.

Six locales. The wipe and backup plans learn about the new table: the profile
cannot be rebuilt once the samples are gone, so it is carried, not derived.
2026-07-27 23:32:22 +02:00
Marc-André Bombeck 1f47db5955 docs(jobs): name the binding contract the registrars actually use 2026-07-27 23:29:20 +02:00
Marc-André Bombeck e69e0b3941 fix(admin): count every record class in the upload summary
The total shown after a backup upload skipped the nutrient day totals, so a file
that carried and restored them reported fewer records than it held. Same defect
as the payload gaps this branch closes, on the reporting side. Counts the
nutrient days, the health profile and the custom metrics with their readings.
2026-07-27 23:27:18 +02:00
Marc-André Bombeck a7fcd6d249 fix(targets): show the weight target the user set, not the WHO band
The targets reference panel graded weight against the WHO BMI band even
for an account that had entered its own target, so the panel under the
weight chart contradicted the chart above it. An override now wins and
the card's source reads Custom, the same posture the glucose builder has
always taken with a user-pinned band. The BMI classification stays
WHO-derived: naming which category a reading falls in is a reference
fact, not a goal.
2026-07-27 23:27:14 +02:00
Marc-André Bombeck ae294573ef test(jobs): follow the queue guards onto the new binding
Thirty-three suites read a registrar as source text and matched
`boss.work` next to a queue constant. They now match `createAndWork`,
and the seven schedule tuples that grew a `cronIsTheRetry` third element
assert that element rather than accepting any arity — a guard that
accepts more than it did before is a guard that notices less.

Two negative assertions were tightened while they were open: the
"has no cron" checks on the arrival and workout-insight queues ended at
a literal `]`, so a cron added WITH send options would have slipped past.

The behavioural repairs carry the real change. Handlers that resolved to
undefined now resolve to their outcome, so the assertions name the counts
instead of asserting nothing. The reconcile tick's test said it swallows
a failure instead of throwing; it reports a failed outcome now, and the
test says that, with the comment pointing at the retryLimit that carries
the old anti-retry-loop concern.
2026-07-27 23:23:53 +02:00
Marc-André Bombeck faa89ef54b test(backup): guard that a backed-up model travels both ways
The classification guard proved every model has an opinion and would have
passed unchanged while a model classified as carried was read into the payload
and never written back — which is what happened to the nutrient day totals, the
custom mood tags and the custom cycle symptoms, three releases running.

Split BACKED_UP into the models with both ends and the models without, each of
the latter carrying what an account loses meanwhile, so the debt is a register
rather than a silence. The check reads declared writer and restore files rather
than grepping for a route, because the route delegates: a review that grepped
only the route concluded the cycle data was never restored and had to be
retracted. Declaring the file list caught its own first omission — measurements
are read through the pager, not directly.

Adds a round trip that builds a real payload, parses it through the wire schema
and drives the real restore, asserting on the rows written rather than on the
payload. Dropping the cycle section spread makes it fail the way production
did: the restore throws on a symptom key nothing wrote back.
2026-07-27 23:19:58 +02:00
Marc-André Bombeck be348f8820 test(score): pin the weight yardstick, the mood gate and rollup/live parity
Covers what the required parameters now make impossible to forget: the
scorer takes the band whole and never widens it, a missing target scores
the bare trend, the resolved yardstick rides the weight component, the
shared builder refuses the reference band that sits under an override,
the snapshot gates its mood pillar on the module map, an explicit target
reaches both the score and the dashboard bands, and weight/BMI record
direction follows the user's own goal.

F1 adds the golden the score has never had: the rollup read branch and
the live read branch scored against one series encoded both ways. On the
canonical one-weigh-in-a-day cadence they agree exactly. On an uneven
cadence they part company, and the test pins that the whole difference is
the per-day mean collapse - the rollup branch equals the live branch fed
those same daily means - with a companion assertion proving the fixture
is not a no-op.
2026-07-27 23:15:38 +02:00
Marc-André Bombeck f0a804a812 test(jobs): pin the outcome spine and the binding rule
runJob's claim is behavioural, not typed: an ok:false outcome has to
reach pg-boss as a failed job, reported once, with the handler's own
facts and the thrown error's stack rather than runJob's frame. That is
what the retention cleanups now rely on, so it is pinned.

The rule's tests carry its blind spots in the valid list, where a reader
will trip over them: an aliased receiver is not detected, and neither is
a handler that returns ok:true after swallowing.
2026-07-27 23:11:38 +02:00
Marc-André Bombeck 232217ce68 test(insights): pin the three step-page display rules
One guard per defect, each verified by breaking it on purpose first.

A non-negative metric's baseline `low` never goes below zero — swept over
every type in the catalogue with a series whose spread exceeds its
median, plus the reported step shape rendering an edge of exactly 0. The
axis assertion sits in the same file, because the point of the fix is
that the two read one fact.

No insights page passes an English word as a unit — the page sources are
scanned for `unit=` / `yAxisUnit=` literals and held against the set of
language-neutral symbols, and every `unitKey` is resolved in all six
locales. That second half matters: a key passed as a prop is invisible to
the `t()` call-site coverage sweep, so nothing else would notice a
missing translation.

An integer-valued metric never renders a fractional part — the step page
is rendered in German with no precision prop at all and must produce no
comma-decimal, on the Min/Max card and on the band sentence below it.

Two types were missing from the plausibility map, so the first guard also
asserts the map covers the enum.
2026-07-27 23:11:37 +02:00
Marc-André Bombeck 0aa5cec17d feat(jobs): bind every queue through createAndWork and ban the bare call
All 104 bindings across the five registrars now go through createAndWork,
whose handler type is (jobs) => Promise<JobOutcome>. The forty-two inline
handlers return what they did. A handler that falls off the end no longer
compiles, and the healthlog/job-handler-outcome rule refuses a bare
boss.work( anywhere under src/lib/jobs except the one module that owns
the wrapper. The rule's header states its own limit: it catches the
omission, not a handler that claims ok:true after a swallow.

Retry policy, reviewed queue by queue because the rethrow changes what
runs, not only what is visible:

  - Twenty-one cron passes carry cronIsTheRetry (retryLimit 0). They are
    the retention DELETEs, the OAuth-state prunes, the hourly maintenance
    sweeps and the nightly per-sample drain — every one of them a
    statement that will fail identically a second later. pg-boss defaults
    to two retries with no delay, so converting them without this would
    have run the same doomed statement three times a night. The next tick
    is the retry and it arrives with the whole gap as backoff.
  - The policy rides on the schedule, not on createQueue: create_queue()
    ends in ON CONFLICT DO NOTHING, so a queue-level default would be
    inert on every already-running instance.
  - Everything else keeps its retries deliberately. A TLS probe, an
    off-host upload, a restore drill and a provider poll all fail
    transiently, and the reminder ticks only fail at job level when the
    pass itself could not run.
2026-07-27 23:08:50 +02:00
Marc-André Bombeck 6911f115bc fix(integrations,labs): let the write decide how a sync result reads
Six integration cards and the lab-OCR dialog keyed their tone off `res.ok`.
A sync that imported nothing because every reading was refused rendered a
green tick and a count, and a re-scan in which every confirmed row turned out
to be a duplicate raised a green toast saying zero readings were saved.

Each surface now renders the outcome the route resolved: a tick only when
everything landed, a warning when some of it did not, an error when none of
it did, and a neutral note when there was nothing new. The lab-OCR toast
picks its variant the same way.

Replaces the directory-scoped import guard with one that covers the whole
component tree and pins per-file occurrence counts, so a listed file cannot
quietly add a second tick. Its docstring states what it cannot catch.
2026-07-27 23:06:44 +02:00
Marc-André Bombeck c6593b292f feat(backup): carry the health profile and custom metrics through export and restore
Both were classified as backed up when the backup plan was written and carried
by nothing. The custom metric is live data loss: a series the account defined
itself is the one kind no integration can re-sync, so a restore rebuilt the
account without the metric and every one of its readings, and reported success.

Builder and restore live in one file beside each other, the way the cycle pair
does — a grep of the restore route alone does not find either, because the route
delegates to both.

Readings nest inside the metric that owns them rather than referencing it by
name, so the restore has no parent to look up and no lookup to miss. A duplicate
metric name and an undecodable ciphertext field both throw and name the value.
Intake events whose medication did not restore now throw with the unresolved
reference instead of being filtered away into a dose history that comes back
short while the restore reports success.
2026-07-27 23:04:44 +02:00
Marc-André Bombeck b2955beffd feat(insights): add the same-time baseline for cumulative metrics
Compare today's running total at the last completed local hour against this
person's own typical total at that same hour, over a trailing four weeks of
stored intraday profiles. Same estimator as the vitals baseline — median for
the centre, median plus or minus k times MAD for the band — so a reader who
understands one understands the other.

It refuses to speak below fourteen usable days and says so through the
existing insufficient arm rather than a band built on three days. It ships no
projected day total: extrapolating a partial day is a guess, and this exists
to replace guesses.

Steps first; active energy, distance and flights ride the same engine.
2026-07-27 23:04:01 +02:00
Marc-André Bombeck f5c676bbeb fix(insights): give a metric's numeric domain one home
Three defects on one card of the step sub-page, all of them a surface
answering a question that belongs to the metric.

"Your usual range is -5,595.6-13,387.6 steps", under a chart whose own
y-axis refuses to draw a negative step. Same app, same question, two
answers: the axis clamped and the sentence beneath it did not. Both now
read the same fact. `VALUE_RANGES` already declares every metric's
plausibility floor for ingest; `value-domain.ts` reads it and answers
"can this metric be negative" for the padded y-domain and for the median
+/- k.MAD band alike. The band's lower edge floors at zero for a
non-negative metric; `spread` stays the unclamped dispersion so a
deviation-in-sigma calculation is unaffected. `buildBaselineBand` now
takes the metric it is describing as a required argument, so a new caller
cannot quietly reintroduce a negative edge. Two types carried no
plausibility range at all, which let any number through ingest and read
as "unknown" on the negativity question: walking steadiness gets the same
0-100 percent band as its two gait siblings, the loud-listening event a
floor of 1 with a generous ceiling.

"13,387.6 steps" also said steps to a reader of German. Five sub-page
units are English words, not symbols - steps, flights, falls, years, and
a "count" that was a poor label in any language. They now travel as i18n
keys through a new `unitKey` on the metric-page scaffold and resolve in
all six locales. The thirteen real symbols (bpm, mmHg, %, m/s, kg/m2,
...) stay literals; translating a symbol would be the opposite mistake.

And the Min read "104.0 steps". Precision was a per-page prop defaulting
to one decimal, so every page had to remember that its metric is a count
- and the step page did not. It follows the metric now: a discrete metric
(tallies, category events, closed integer instrument scores, ordinal
levels) resolves zero decimals wherever it is rendered, and a page only
passes the prop to override that.
2026-07-27 23:01:33 +02:00
Marc-André Bombeck b0862c0902 feat(score): say which yardstick graded the weight row
The weight pillar has been graded against something for two years and
never said what. The score card's weight row now carries one line: the
user's own target band when they set one, or a plain statement that the
row scored the trend with no target at all. The band rides the analytics
DTO in canonical kg and the page formats it into the reader's display
unit, so an imperial account reads pounds.

The weight sub-page shades the same yardstick: its chart bands follow an
explicit target when one exists and fall back to the height-derived band
only when it does not.
2026-07-27 23:00:26 +02:00
Marc-André Bombeck 22d7e8c30c feat(measurements): keep the intraday shape of a cumulative day
A cumulative metric has no meaningful latest reading — a partial day's step
count is a fragment, not a value — so the only honest comparison is against
the same person's own typical total at the same local hour. That needs an
hourly substrate and there was none: the nightly cumulative drain collapses
per-sample Apple Health rows into one daily total after the grace window and
hard-deletes the originals in the same transaction. Today's curve was
derivable; every older day's was already gone.

Fold the rows into twenty-four running totals and write them down first, in
the same transaction and before the delete. The merge is additive so a day
drained twice keeps both halves, and the rows are re-read under the advisory
lock the drain already takes so an overlapping tick folds nothing.

One table, one nightly range delete to bound it.
2026-07-27 23:00:19 +02:00
Marc-André Bombeck 67aa3338da feat(jobs): return an outcome from every named queue handler
Sixty-two handler functions across twenty-four modules now resolve to a
JobOutcome instead of undefined. The counts they already wrote to their
wide event ride out in `did`, so a run that deleted nothing and a run
that could not run are no longer the same shape.

Three rules decided each conversion:

  - a catch that swallowed becomes jobFailed, keeping its warning line;
  - a catch that already rethrew keeps its throw, because the throw
    already fails the job and rewrapping it buries the stack;
  - a fan-out pass is judged on the pass. One account's revoked grant
    belongs on that account's integration ledger, not in a queue-wide
    failure that retries the whole cohort, so per-leg failures ride out
    as a users_failed count — visible for the first time.

The retention cleanups are the behaviour change worth naming: every one
of them used to warn and return, so a purge that had not run for weeks
looked like a purge with nothing to do. They fail now. Their registrar
sets retryLimit accordingly in the commit that converts the bindings.

JobFailure carries the thrown error's stack; without it every one of the
101 queues would report the same runJob frame.
2026-07-27 22:56:59 +02:00
Marc-André Bombeck ffe97d9ad9 fix(backup): carry the account's own cycle symptoms into the payload
The section builder returned them, the wire schema declared them, and the
restore has read them since v1.33.1 — including the throw that replaced the
silent drop. The payload assembly copied its keys out one by one and was never
extended, so every backup written carried an empty list. An account with a
custom symptom did not get the silent drop that release removed; it got a
restore that failed outright on a key nothing had written back.

Spread the cycle and records sections instead of hand-picking their keys, so a
field added to either section interface reaches the wire by construction. Declare
labelEncrypted on the cycle section, which the builder already emitted.
2026-07-27 22:55:05 +02:00
Marc-André Bombeck b1b76ab68a fix(score): grade weight against the user's own target, never a fabricated one
The Health Score graded the weight pillar against 22 x height squared,
expanded by 2 kg either side: a target nobody set, nothing disclosed,
and the only producer of the pillar's band. For anyone with a height on
file it pre-empted both the honest trend-only path the module header
promised and the null-redistribution behind it, while the target the
user had actually entered sat unread in User.thresholdsJson.

computeHealthScore now takes the band whole plus the yardstick it came
from. Both callers resolve it through one shared builder next to
buildHealthScoreBpInputs, which hands over a band only when
getEffectiveRange reports a real user override; every other account
scores the bare trend. defaultWeightTargetFromHeight is gone.

Same pass, same defect class elsewhere: the dashboard bands, the
weight-drift verdict rung and the weight/BMI record direction all
ignored an explicit target too, and the snapshot never resolved the
mood module, so a mood-off account got a mood pillar in the hero ring
and none in the Insights card. moodEnabled and the weight yardstick are
required parameters now, so a caller that forgets one fails to compile
instead of quietly scoring against a default.
2026-07-27 22:54:48 +02:00
Marc-André Bombeck 530f78dcf5 fix(sync): answer with what a run wrote, not that it completed
Six provider syncs returned a bare count, which narrowed their failures away:
a Nightscout window in which every SGV row was refused by a constraint
returned the same 0 as a quiet night, and an Oura or Polar run whose
collections all failed returned 0 too. The routes then answered 200 with a
count and the settings card drew a green tick over it.

Every sync now reports what it imported AND whether any part of it did not
land, and the route resolves the outcome server-side so the web card and the
native client cannot disagree. Google Health keeps its 502 for a run that
failed and wrote nothing, but a run that failed after writing some resources
is now a partial rather than a discarded result. The labs OCR commit carries
the same resolved outcome.
2026-07-27 22:53:43 +02:00
Marc-André Bombeck 39aaef27b1 test(workouts): pin sport provenance across the provider ingests
Behavioural cases first: the Google mapper keeps the raw `exerciseType`
when the sport falls back to "other", reports a missing one as null
rather than an empty string, and the ingest writes it under
`metadata.googleExerciseType` on both the insert and the update arm — the
update is what a full re-sync goes through, so a row written by an older
build repairs in place. The key is omitted rather than nulled when a
session carries no type, so a re-sync cannot wipe what an earlier one
recorded.

Then the structural guard, because the behavioural ones only cover the
provider that was reported. It freezes the set of sport-label mappers and
the set of files creating Workout rows, and asserts every mapping ingest
carries its raw label into metadata. Fitbit has the same gap and is
excluded on purpose — it ships on its own, small and isolated, per the
standing rule for that provider. The exception is an assertion, not an
omission: when Fitbit gains the write, the guard fails and forces the
entry to move. An exception nobody has to retire is how this gap survived
in the first place.
2026-07-27 22:45:54 +02:00
Marc-André Bombeck 52288b7f77 refactor(outcome): lift the write-outcome classifier out of the import panel
The rule that a write which wrote nothing must not read as a success was
scoped to the import panel. Provider syncs need the same rule, so the
classifier moves to src/lib/outcome/written-outcome.ts and the tick plus the
success colour move to one presentation component the whole tree renders
through. Adds the sync shape: a run reports what it imported and whether any
part of it failed, and the outcome is resolved from both.
2026-07-27 22:43:27 +02:00
Marc-André Bombeck 119af56f6f feat(jobs): derive job completion from a returned outcome
A pg-boss handler signalled success by falling off the end of a function.
Every binding in this tree resolved to undefined and nothing read the
value, so a handler that caught an error, logged a warning and returned
was indistinguishable from one that finished its work: pg-boss marked
both completed and the failed-job readers saw nothing.

JobOutcome makes the completion a value the handler has to construct.
runJob turns ok:false into a reported, rethrown failure so the queue's
retry policy applies. createAndWork is the one binding path; its handler
type is (jobs) => Promise<JobOutcome>, so a handler that returns nothing
no longer compiles.

The type cannot make a handler honest — jobDone() after a swallow still
compiles. It converts omission into commission, which is the smaller and
far more reviewable class.
2026-07-27 22:41:02 +02:00
Marc-André Bombeck 3db4c33f49 fix(google-health): keep the raw exercise type on the workout row
An unmapped Google `exerciseType` resolves to the literal "other", and
until now that was the end of the label: nothing else carried it, so a
workout Google typed as a sport the map does not know became
indistinguishable from a genuine `OTHER_WORKOUT`. Carry the trimmed
upstream value through `GoogleHealthMappedWorkout.sportTypeRaw` and write
it to `Workout.metadata.googleExerciseType`, the shape Polar, Strava and
WHOOP already use. Insert and update share one payload, so a full sync
repairs rows written by an older build.

The comment above the type map claimed an unmapped type "still persists"
because the column is free-text. It does not, and it did not when the
comment was written. Prose that asserts what the code does not do is the
same defect as the missing write, so correct it here rather than later.
2026-07-27 22:40:09 +02:00
Marc-André Bombeck 60c30e1e3a docs(changelog): describe the dashboard-layout fix v1.33.1 shipped
The v1.33.1 credit block named a reporter for the dashboard layout while
the body of the notes described nothing of the kind. The fix was in the
release; only its description was missing, which leaves a credit pointing
at nothing and a reader unable to tell what changed on their own instance.
2026-07-27 19:13:34 +02:00
Marc-André Bombeck e21fc24950 Merge pull request #660 from MBombeck/release/v1.33.1
v1.33.1 — what the app knew and did not say
v1.33.1
2026-07-27 18:01:38 +02:00
Marc-André Bombeck 4d1d712b93 chore(release): v1.33.1 — what the app knew and did not say
Eight workstreams, two migrations, and a pass by an independent reader that
found four things this release was claiming about itself that were not true.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Delete the copy, replace the ternaries, and name the pair once as
`BinaryReferenceSex` so a signature that needs it stops re-listing the two
literals. No behaviour changes: every site already resolved OTHER and "no
answer" to the same neutral or absent value, and the existing suites pass
untouched.
2026-07-27 14:47:54 +02:00