Commit Graph
1963 Commits
Author SHA1 Message Date
Marc-André Bombeck 2bab06e323 feat(medications): resolve each dose slot's own window duration 2026-07-28 21:26:53 +02:00
Marc-André Bombeck 7b8fb98e48 feat(analytics): replace health score with reference composite 2026-07-28 21:12:05 +02:00
Marc-André Bombeck 3aff2c16db fix(insights): scope generation guard to prompt fields, clear cache on restore 2026-07-28 21:11:43 +02:00
Marc-André Bombeck c91558f18f feat(analytics): replace health score with reference composite 2026-07-28 21:10:15 +02:00
Marc-André Bombeck 87544af2cd Fix medication reminder slot attribution 2026-07-28 21:06:07 +02:00
Marc-André Bombeck 666a85d311 feat(insights): persist correlation patterns 2026-07-28 14:01:11 +02:00
Marc-André Bombeck 8ece6f39cc fix(status): expose background failures 2026-07-28 14:00:44 +02:00
Mathew Sims 08e535a0d9 fix(reminders): resolve each dose slot's own window duration, not the whole schedule's span
The 15-minute reminder tick computed windowDuration ONCE per schedule
as windowEnd - windowStart, then applied that SAME span to every
timeOfDay slot in the schedule. For a single-dose schedule that span
genuinely is the one slot's window, but for a multi-dose schedule
(e.g. windowStart=08:00/windowEnd=18:00 with timesOfDay=[08:00,18:00])
it silently gave the 08:00 slot a 600-minute grace before even
entering ORANGE phase — ten hours nobody configured — while an
identically-intended single-dose schedule with a narrow legacy span
(08:00-09:00) correctly escalated on schedule but then had only a
30-minute radius for a logged-but-late dose to suppress its reminder.
Same misapplied field, opposite-looking symptoms: one medication
silently never reminded, the other kept escalating despite being
logged as taken.

resolveSlotWindowDurationMinutes resolves each slot's duration
independently, checked in priority order: an explicit per-dose
doseWindows entry for that exact timeOfDay (the actual per-dose rule
set in the dose-window editor) > the schedule's reminderGraceMinutes
(documented for exactly this purpose but never actually read by the
reminder tick until now — the write path already reads it via
graceToleranceMs) > the same default an unconfigured "point" dose
gets everywhere else in the app, bounded by the gap to the nearest
sibling slot so two doses can never share a window. A single-dose
schedule with neither override keeps the exact legacy windowEnd -
windowStart behaviour, unchanged, for backward compatibility with
schedules that predate the per-dose doseWindows system.

The core property this restores: a dose's resolved window never
depends on how many sibling doses share its schedule. Two
identically-configured doses now resolve identically regardless of
whether one of them happens to have a second dose elsewhere in the
day.

Full suite (18k+ tests) passes with no regressions; 13 new tests
cover both real-world cases that surfaced this (a single 08:00-09:00
schedule and a twice-daily 08:00/18:00 one) plus the sibling-
independence property directly.
2026-07-28 12:55:34 +01:00
Marc-André Bombeck a689329e79 feat(dashboard): configure Today highlights 2026-07-28 13:22:08 +02:00
Marc-André Bombeck a2f2a30ac2 fix(quality): close backup and localization guard gaps 2026-07-28 12:03:02 +02:00
Marc-André Bombeck 81825ce23b merge: forward-port v1.33.2 into release/v1.34.0 2026-07-28 11:43:44 +02:00
Marc-André Bombeck ddbce306be fix(backup): drop the manifest key the records spread already carries
The assembly picked `manifest` out of the records section by hand, one line
under the comment explaining that both sections are spread precisely so nobody
has to pick their keys. It resolved to the same value the spread had already
placed, at the same position, so the emitted file is byte-identical either way.

Left in, it is the pattern that lost the custom cycle symptoms sitting next to
the note about how that happened.
2026-07-28 00:09:10 +02:00
Marc-André Bombeck dddd2164b1 test(withings): teach the fallback mock the shape its function now returns
The sync function was widened to answer with what it wrote rather than a bare
count. This mock still resolved to a number, so the handler destructured
`imported` off it, got undefined, and reported `measurements_imported: NaN`.

Nothing in the type system could see it: `vi.fn().mockResolvedValue(0)` carries
no relation to the real signature, so a widened contract drifts past every mock
in the tree unnoticed. This was the only sibling; the class is worth a sweep of
its own.
2026-07-28 00:07:53 +02:00
Marc-André Bombeck 51dbfefbcb fix(backup): carry the intraday day shape through export and restore
`IntradayCumulativeProfile` shipped classified as backed up and carried by
nothing. The drain folds the per-sample rows into twenty-four running totals
and deletes the originals in the same transaction, so past the grace window
the table is the only copy of a day's curve: a restore rebuilt the account
without it, reported success, and left the same-time baseline reading "still
learning" for a fortnight.

Builder and restore sit together in one file, the way the profile and cycle
pairs do, because the restore route delegates and a grep of the route alone
lies. The section spreads into the payload rather than being copied key by
key. Two curves for the same metric and day, or a curve that is not a whole
day, throw naming the day instead of writing a row every reader drops.

The pair is proven end to end rather than at each end: the round-trip suite
builds a real payload from a populated account, serialises it, parses it back
through the real parser, drives the real restore, and asserts on the rows
written. Remove the read and it goes red.
2026-07-28 00:07:10 +02:00
Marc-André Bombeck 9d1f21408f fix(insights): floor the same-time band from the metric, not by hand
Two parallel lines of work reached the same conclusion independently: a
baseline band over a quantity that cannot go negative must not report a lower
edge below zero. One of them answered it locally with a `Math.max(0, …)` at the
call site; the other gave every metric's numeric domain a single home and made
`buildBaselineBand` take the metric so the floor comes from the declaration.

Keep the second and drop the first. The required parameter is what surfaced
this at all: the new call site could not compile without naming its metric,
which is the whole reason it was made required rather than defaulted.
2026-07-27 23:45:00 +02:00
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 e5b958cd86 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 23:40:44 +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 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 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 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 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 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