Commit Graph
1582 Commits
Author SHA1 Message Date
Marc-André Bombeck 47e50ece39 Wait for content the page owns before scanning it
Several accessibility scans ran against a page that had not finished
arriving, so axe reported violations the app does not have. The worst of
them was the medications case, which waited for a level-one heading and
then checked that a level-one heading exists. A page that really lost its
heading would have waited out the timeout under a misleading name instead
of failing the rule, which makes it a check that cannot fail rather than a
flaky one.

The filter rail was the other bad sentinel. It renders beside the loading
silhouettes rather than after them, so it was on screen while the rows were
still grey rectangles. Under an 8x CPU throttle the rail and the loading
marker resolved in the same 40ms poll and the rows a poll later.

Every case now gates on a marker the real component carries in its
resolved, non-empty branch, and on a component other than the one the scan
asserts. Both the desktop table and the mobile card list carry the same
marker so the wait holds at any viewport. No sentinel names a heading, the
app frame, a busy marker or the filter rail any more.
2026-08-02 18:50:31 +02:00
Marc-André Bombeck 210380f46d Merge pull request #690 from MBombeck/work/sharing-client
Account sharing, the part people touch
2026-08-02 17:13:30 +02:00
Marc-André Bombeck 526041770d Merge pull request #691 from MBombeck/fix/gate-blind-spots
Let the structural guards see the directories they were skipping
2026-08-02 17:05:49 +02:00
Marc-André Bombeck 89532ee48e docs(about): state the GeoLite2 licence in full
The credits page named CC BY-SA 4.0 alone. MaxMind stopped distributing
GeoLite2 under that licence by itself: the databases ship under the
GeoLite End User License Agreement, which incorporates CC BY-SA 4.0 by
reference and controls wherever the two disagree. Name both, in that
order, and link the EULA. The attribution string was already there and
is unchanged; the e2e page check now asserts both licences rather than
the one that no longer stands alone.

Reconcile the self-hosting guide with the same wording, point it at the
current EULA URL, and say why the `/about` page must stay reachable — it
is where an operator's own attribution lives.

Correct the project licence line while on the page: it still claimed
AGPL-3.0, which the page's own footer contradicts. The trunk has been
PolyForm Noncommercial 1.0.0 since v1.15.19.
2026-08-02 16:45:51 +02:00
Marc-André Bombeck a640e84142 Exclude the worktrees the tooling was already meant to exclude
git, eslint and vitest each carry an exclusion for local development
worktrees, and all three name /.worktrees/ while the convention actually in
use is /.wt-*/. Every one of them was therefore walking eleven full copies of
the source tree.

The consequences differed by tool and none of them announced itself. pnpm lint
took ten minutes instead of one and reported warnings from whatever commits
those copies happened to sit on. vitest collected duplicate suites that drag in
a second React instance, which fails in ways that have nothing to do with the
working tree. git left them merely untracked, one git add -A away from
committing the repository eleven times over.

With the true warning count finally visible, teach no-unused-vars the
underscore convention this codebase already follows and drop the five
disable directives that convention made redundant. pnpm lint is clean.

A non-underscore unused argument still warns; that was checked rather than
assumed.
2026-08-02 16:36:25 +02:00
Marc-André Bombeck a463b1fa01 Stop recording the address a delegate accepts a grant from
A consent record has to answer who consented and when, and the grant row
already does. It does not have to answer from where, and an address kept for
as long as both accounts exist is personal data held past the point it
evidences anything. Nothing read the column, which makes it exactly the
write-only field scripts/audit-column-readers.ts exists to surface.

The acceptance stays evidenced: audit_logs records it with the caller's
address under that table's own retention. The column never reached the wire,
so the grant view and the OpenAPI contract are unchanged.

Written before the feature shipped, so migration 0295 drops an empty column.
2026-08-02 16:36:12 +02:00
Marc-André Bombeck 5199de78af feat(sharing): the shared-access panel and the record-activity view
Settings gains one section for one concept: who else can open this health
record, whose this account may open, and when they were opened. Split across
two pages it would be two half-answers.

An invitation confers nothing until the other person accepts it, and the card
says so above the field rather than after the submit — that is the part
somebody needs to know before they type a name. Refusals are named from the
stable errorCode, because "no account with that name" and "that person already
has access" are different situations with different next steps.

Ended grants stay in the list. The row is the consent record, and "who had
access, from when to when, and who ended it" is the question the panel exists
to answer; a list that dropped them could not. Revoking asks once and then does
it: reducing access must never be harder than granting it was.

`GET /api/account/activity` answers the owner's other question. One row per
person per day with a count, not one per request — a hundred identical lines
is a way of answering nothing. The window is published with the rows rather
than assumed: audit history is purged on a schedule the operator sets, so the
view prints the number the server resolved from the same setting the purge job
reads. An instance running ninety days says ninety.

Six locales ship with it.
2026-08-02 14:26:39 +02:00
Marc-André Bombeck 562123d4d1 feat(sharing): publish which records an account may open
`GET /api/auth/me` grows an `accountAccess` block: the accounts this caller
may act on, the one the browser is inside right now, and the booleans a UI
binds directly — `canSwitch`, and `canWrite` per account. Every value is
resolved server-side. A client that worked out what it may do from grant rows
would be a second program deciding one person's access to a health record, and
the two answer differently the first time an expiry or a revocation appears.

`canWrite` is false for every grant in v1 because invitations write READ only.
It is resolved through the grant state machine rather than hardcoded, so the
day a WRITE grant exists the answer changes without this file being edited.

The route becomes an actor surface. It has to be: the switcher, the banner
naming whose record is open, and the way back out all read this payload, and
under a bare `requireAuth()` the whole app shell would refuse the moment a
switch was on. Every field it returns is still the caller's own.

The active record is published as a full entry, not an id to look up. A banner
that had to join two fields could render unnamed the moment they disagreed;
here the active entry is one of the listed ones, selected by the pass that
built them. A stamp left behind by a grant that lapsed reads as not switched —
the same answer the resolver gives the next delegated request.

Not published: the other account's avatar. Those bytes are owner-scoped, so a
URL here would resolve to a refusal and paint a broken image. Clients keep the
initials fallback.
2026-08-02 14:25:55 +02:00
Marc-André Bombeck 5067020e99 refactor(sharing): drop a cast the select already proves
The invite lookup selects exactly the columns a grant party carries, so
asserting the shape afterwards said nothing the types did not already
know.
2026-08-02 13:04:25 +02:00
Marc-André Bombeck 56447a9457 feat(sharing): invite, accept, switch, revoke, renounce
The lifecycle surface under /api/account. An owner offers read access to
somebody already on the instance, the delegate accepts, either side ends
it, and a browser session points itself at a record it has been granted.

Ending a grant clears the acting-account carrier on every session of the
delegate pointing at that record, in the same transaction that stamps the
row. Split apart, a revocation would leave a browser inside a record it
can no longer read: every request refused, the banner still naming the
owner, nothing on screen able to explain it.

Revocation has no step-up and no confirmation step. Reducing access must
never be harder than granting it was.

Grant management is non-delegable by construction rather than by a check:
every route here resolves through bare requireAuth(), which refuses while
the caller is acting on another account, so no code path through them
runs as somebody else. The switch endpoint is the one actor surface, and
it is the way back out. A delegate already inside a record renounces
after switching out, one request, rather than the fail-closed default
growing an exception that later gets widened.

The state machine is not re-implemented anywhere: routes call the grant
module's predicate and its transitions, and the session carrier has one
writer.
2026-08-02 13:04:25 +02:00
Marc-André Bombeck e9029ed3c2 feat(sharing): resolve the acting account inside the auth layer
One resolver, both transports, on every request. The cookie path reads
the carrier off the session row it has already loaded; the Bearer path
reads the `X-HealthLog-Account` selector header, because stamping switch
state onto a long-lived token would make the credential itself ambient
authority. Two carriers converge on one function for the same reason
`resolveBearerToken` lives in one file for two wires.

Three declared modes. `requireRecordAuth("read" | "write")` honours a
valid switch and substitutes the owner as the data scope while keeping
the delegate as the actor. `requireActorAuth()` always serves the
caller's own rows and keeps working under a switch, which is what the
switcher and the banner need.

Bare `requireAuth()` refuses to run under a switch at all, with a stable
`sharing.not_permitted` code. It does not fall back to the caller's own
account, and that arm is the point of the whole design: a delegate who
believes they are reading the owner's record and is handed their own is
the same data mixing pointed the other way. A route nobody has reviewed
is inaccessible while switched, never wrongly accessible.

The grant is loaded from Postgres on the request that uses it. Nothing
memoises the verdict, so a revocation lands on the delegate's next
request rather than their next login.

Refusals do not enumerate. The denial takes no constructor arguments and
carries a fixed message, and neither branch looks the named account up:
an account that does not exist and one that granted nothing produce the
same empty row from the same query, so the two answers are identical in
bytes and in timing rather than by careful wording. The reason reaches
the audit row and the wide event and nothing else.

`getSession()` still answers who is calling. That is what leaves
`requireAdmin`, `requireCookieAuth` and `requireFreshMfa` structurally
out of a switch's reach; the row's carrier rides out beside the session
id as a value to be checked, never as a decision already made.

The selector header is a Bearer carrier only. Sent over the cookie
transport, or to an actor surface, it is refused rather than ignored. A
client that names an account on a route which never reads one has a bug,
and a correct response to a wrong expectation hides that bug until it
arrives as a support case about data that looks wrong.

Five test files gain the session shape their fixtures always claimed to
have; they were building an auth context without one.
2026-08-02 11:32:00 +02:00
Marc-André Bombeck 9c4235b66d fix(backup): a retired catalogue key must not cost the whole file
Three of the restore's lookups resolve a key against a seeded catalogue:
cycle symptoms, illness symptoms, mood factors. Each threw on a key it
could not resolve, inside the one transaction that wipes and rebuilds the
account. So a single renamed symptom rolled everything back and answered
500. No measurements, no doses, no notes, nothing. A year-old backup read
by a newer instance is the ordinary case for a backup, and the catalogue
drifts between those two dates by design.

The unresolvable link is dropped and named now. What it costs is one
association between a row and a symptom; the day-log, the mood entry, the
flow, the temperature and the encrypted note all come back. The count and
the exact keys ride the response, the audit row and the wide event, and
the admin surface shows them in a panel that stays put instead of a toast
that leaves. A quiet drop would be the older failure of this same seam
wearing a success message.

A dangling `cycleId` or a dose recorded against a medication the file
never carried still throws. Those name rows the file was supposed to hold,
so a missing one means the file contradicts itself. A catalogue key names
a row the instance owns, which is why it gets the other answer.

`IllnessSymptom` carries no owner column at all, so a key that will not
resolve there can only ever be drift; the file was never able to supply it.

The restore does not mint the missing catalogue row back. That would
preserve the link at the price of reference data colliding with whatever
the next migration seeds under that key, and a label nothing can translate.
2026-08-02 09:04:36 +02:00
Marc-André Bombeck b40e9bc40f Merge work/side-effecting-gets 2026-08-02 01:29:46 +02:00
Marc-André Bombeck 1e6f72b1c5 Prove the achievements read writes nothing against a real pending unlock
The assertion in the module-gate suite could not fail: that fixture's mood
badges carry no completion date, so the result it drove had no pending
unlock and "nothing was written" was true either way. Restoring the INSERT
left it green.

The case moves to its own file with the builder faked so the result always
carries a pending unlock. Undoing the fix now turns it red. The
disabled-module invariant the old case reached for lives with the write, in
the sweep's suite.
2026-08-02 01:27:42 +02:00
Marc-André Bombeck 6ac3a61829 Take the writes off three read routes
A GET must not have side effects — api-handler.ts says so next to
READ_HTTP_METHODS, where an MCP-audience token is admitted on the safe
methods precisely because they are assumed to write nothing. Three routes
broke that: the sync handshake bumped User.lastSyncedAt, the achievements
read inserted unlock rows, and the cycle calendar fired a discarded promise
at the forecast cache so a failed write left no trace anywhere.

Each write moves to the place that causes it.

The sync checkpoint is now stamped by the batch ingest endpoints for
measurements, mood and intakes. The handshake reports it. That also fixes
the watermark: the checkpoint used to advance past a window the client had
not drained, and a client trusting it would skip that window. It can only
trail a real sync now.

The unlock rows move to a nightly achievement-unlock-sweep. The badge grid
never needed them — unlocked comes from the live metrics and the completion
date is derived — so the payload is unchanged; the rows pin that date and
carry it into the backup, and the sweep reaches accounts that never open
the page.

The cycle forecast moves to a nightly cycle-prediction-refresh. The
CyclePrediction row is not a cache of a read: it is the index the reminder
cron scans, so which reminders an account received depended on whether
somebody had opened the calendar. persistPredictionCache no longer
swallows its errors either — the job reports them.
2026-08-02 01:21:03 +02:00
Marc-André Bombeck af35346687 Derive score-version fixtures from the SCORE_VERSION constant
Eight test and fixture files hardcoded scoreVersion: 2, a value the
constant has not held since v1.35.0. Now they import SCORE_VERSION and
use it, so a future bump cannot leave a fixture pinned to a stale
number.

The one genuinely historical case (record.test.ts, proving the input
fingerprint moves when the algorithm version moves) now derives both
its "previous" and "current" markers from SCORE_VERSION instead of two
unrelated literals, so the pair keeps its meaning after the next bump.
2026-08-02 00:15:29 +02:00
Marc-André Bombeck 3b37d73f31 Merge branch 'work/ecg-ingest' into release/v1.35.3 2026-08-01 19:50:52 +02:00
Marc-André Bombeck 0e301b580c Let an Apple Watch ECG arrive without a 1.5 GB export archive
POST /api/insights/ecg takes one recording per request: the stable
recording id the client already has, the on-device timestamp, the
sampling rate, the waveform as integer micro-volts, the lead, the
average heart rate, and the device's own verdict. Same gating as the
reads (auth, the insights module, the insightStatus surface), userId
from the session, APPLE_HEALTH the only source a client may claim.
sampleCount and durationSeconds are derived here, and nothing about the
waveform is ever interpreted.

The write body now lives in one place, src/lib/ecg/persist-recording.ts,
shared by the Withings sync, the export-archive importer and the new
route. Three writers with three persistence bodies is how the two ends
of one rule drift apart.

Migration 0291 adds the second unique index on
(user_id, source, recorded_at, sampling_frequency). The archive importer
identifies a strip by a hash of its content and a live client by the
device's own sample uuid, so the same physical recording arriving
through both doors carried two different ids and would have landed
twice. One device does not record two different strips at the same
instant at the same sampling rate, so the constraint is sound and the
convergence stops being a convention.

The table already holds rows on deployed instances and two writers have
been filling it since v1.19.0, so the index cannot just be created: a
pre-existing collision would abort the migration. The migration resolves
duplicates first, keeping the row with the most samples (a stored strip
beats a verdict-only row), then the one linked to its event
measurement, then the most recently written, then the lowest id so the
choice is total. The losers' event link is salvaged onto the keeper
first, so resolving a duplicate never costs the recording its place in
the event timeline.

If that resolution is wrong on some instance, the failure is visible at
deploy time and not later: db:migrate:deploy runs on boot, so the
container crash-loops with the migration named in the logs and the
release never serves traffic. A deploy that finishes in under thirty
seconds did not run it.

The GET list and detail routes and the export-archive path are
unchanged; this is a second door, not a replacement.
2026-08-01 19:19:01 +02:00
Marc-André Bombeck 368eb44439 Point the score-config contract test at the pillar list that survived
Two branches met here. One retired the fitness pillar and renamed the test's
pillar list accordingly; the other, written against the eight-pillar world,
referred to it by its old name and its old count. The merge kept the new
declaration and the old references, so nothing compiled.

The name carried the count, which is why the collision was visible at all.
There are seven now.
2026-08-01 11:59:29 +02:00
Marc-André Bombeck a830e8a9d3 Merge work/contract-touchups score-config registration 2026-08-01 11:58:17 +02:00
Marc-André Bombeck 7ddcee0445 Publish the health-score configuration route
/api/auth/me/health-score-config shipped in v1.35.0 with a settings page
in front of it and no entry in the contract at all — no path, no schemas,
nothing. A client had the surface and no way to generate against it. This
is publication only; the route does what it already did.

Both verbs are registered off what the route actually returns rather than
off a neighbour's shape. The asymmetry matters and is now written down: the
request speaks the positive selection, the pillars that should count, while
the response carries both sides resolved plus the recipe version. The column
stores the complement, which is storage and no client's business.

The response's updatedAt is documented as optional because it genuinely is
absent until a selection exists — an account that never chose has nothing to
guard a write against, and a decoder that required the field would break on
that read.

The PATCH 422 is written out rather than reusing the shared base-token
response, because this route has four of them and a client branching on
meta.errorCode needs all four: the breadth refusal with the sentence to show
the person, the validation failure, the unparseable base token, and the
non-JSON body. Naming only the token would have been a contract that omits
the refusal people actually hit.

The route test now drives the real route to produce each of those responses
and asserts the published contract names every code that comes back, so a
response the route can emit but the spec does not describe fails the suite.
That closes the base-token ledger: nine routes emit invalid_base_updated_at
and all nine now document it.
2026-08-01 11:57:31 +02:00
Marc-André Bombeck f6edff9b82 Merge work/contract-touchups 2026-08-01 11:50:33 +02:00
Marc-André Bombeck 6a69bad05e Publish the passkey list and the base-token 422
GET /api/auth/passkeys was absent from the contract entirely — no path
entry, no schema — while the client teams had been pointed at a published
schema for it since v1.32.8. It is now registered with the shape the route
actually serves: id, name, credentialDeviceType, credentialBackedUp,
createdAt, and the nullable lastUsedAt that started this. The route itself
is unchanged; only the record of it was missing. PasskeyInfo is deliberately
separate from WebauthnMfaCredentialInfo — one lists sign-in credentials, the
other second-factor security keys, and merging them would blur two surfaces
that behave differently. The route test now compares the projection the
route asks Prisma for against the published property set, so neither end can
move without the other.

`invalid_base_updated_at` existed only in route tests. It is now on all
eight published endpoints that accept a `baseUpdatedAt` body token, and the
field description says the thing implementers keep getting wrong: an
unparseable token, `null` included, is rejected with 422 rather than falling
back to the unconditional write. Omitting the key is the only way to get
that write.

/api/auth/me/health-score-config also returns this 422 but is not in the
spec at all; publishing that route is a separate piece of work.
2026-08-01 11:49:40 +02:00
Marc-André Bombeck 43c591fe19 Correct three contract claims the published spec got wrong
The `type` query parameter on GET /api/insights/derived carried the eleven
VITALS_BASELINE vitals and described itself as being for that metric only.
SAME_TIME_BASELINE takes a `type` too, and its four cumulative day metrics
were absent from the enum, so a client generated against the spec could not
send them at all. The parameter now publishes the union of both registry
sets and the description says which values belong to which metric. A guard
reads the enum back out of the assembled document and pins it to the
registry, so a type added to either engine reaches the contract with it.

The idempotency wrapper's two 409 arms sent `error` as `{ message }`. That
was the only object-shaped error the application emitted; every other route
sends the documented `{ data, error: <string> }` envelope, which is also
what the spec has always promised. The code was wrong, not the spec. Both
arms now build their response through one helper that sends the string, so
every route under withIdempotency serves the envelope its clients decode.

The hero score-ring comment still described MED_COMPLIANCE as pooled 7-day
adherence. It has been today's dose progress since v1.27.8.
2026-08-01 11:42:07 +02:00
Marc-André Bombeck f6bd8be6d5 Merge work/fhir-retire 2026-08-01 11:38:55 +02:00
Marc-André Bombeck 5767e0330b Retire allowFhirApi and resourceTypes off the share-link response
iOS green-lit removal on 2026-07-30 (their decoder treats both as
optional, confirmed at ShareLinkDTO.swift:105-106) and confirmed no
shipped build breaks. Drops RETIRED_FHIR_SCOPE_FIELDS and its spread
from the create and list responses, removes the two properties and
their required-list entries from ShareLinkCreated/ShareLinkList in
the OpenAPI source, and regenerates the spec. The request side was
already closed off by .strict() in an earlier pass; this finishes it
on the response side.

Route tests now assert the fields are absent from both responses
instead of asserting the constants they used to carry.
2026-08-01 11:38:16 +02:00
Marc-André Bombeck 4f79b9d49a Merge work/cycle-verdict 2026-08-01 11:32:07 +02:00
Marc-André Bombeck c60689affd Take the fitness pillar out of the health score
It could never score. The schema records VO2max but cannot tell a
measured test apart from a device estimate, so every fitness row reached
the scorer unproven and was refused - the pillar has never produced a
value on any account. Listing it on the score settings page as a thing
you can choose, with a line admitting it does nothing, was chrome over a
switch that changed no number.

So it leaves the catalogue: no pillar id, no domain, no scorer, no row,
no wire field. A stored selection that still names it reads back fine -
the resolver has always filtered stored ids against the catalogue and
drops what it no longer knows, so nobody's saved recipe throws or is
discarded over it.

That also fixes the page claiming unsaved changes forever. The save
stripped the fitness pillar out of the selection on its way to the
server, so the answer that came back could never match the form that
sent it, and 'All pillars' left the save button lit no matter how often
you pressed it.
2026-08-01 10:51:01 +02:00
Marc-André Bombeck c65f122017 Prove the verdict reaches a client and that nothing recomputes it
Three checks. The engine tests pin the overdue boundary from both sides:
day 42 of a stated 28-day cycle still counts, day 43 stops counting and
reports fifteen days later than usual.

The round-trip test calls the real GET handler, reads the response body as
text and parses it, so a field that both ends handle but the wire never
carries fails here. It runs the same instant twice for two accounts in
different timezones and gets two different answers, which is what the
account timezone is for. It also checks the ring's cycle day against the
phase engine the coach read uses, so the two cannot tell someone two
different days.

The guard reads the syntax tree of every file under components, hooks and
app, and fails on a grace-period constant, on an overdue verdict computed
rather than read, and on a cycle-day count compared against a sum. It
follows local bindings, so lifting the threshold into a variable above the
comparison does not hide it. A companion suite tries nine disguises against
the same detector and pins five innocent shapes it must ignore, including
an overdue medication dose.
2026-08-01 10:45:24 +02:00
Marc-André Bombeck dbecbbcbdb Publish the resolved cycle verdict instead of letting each client work it out
The cycle ring's day count, its phase arcs, and the judgement that a period
is late all used to be worked out in web client code, including the
fourteen-day grace window that decides when the count stops being an
observed fact. Anything else that wanted the same answer had to reproduce
that rule, and a second copy of it is how a ring ends up showing someone a
cycle day their record does not hold.

GET /api/cycle/calendar now carries a verdict: the state, the cycle day,
the ring's arcs, the cycle start, how many days until the next period, and
when the period is late, how late. It is resolved against the user's own
timezone day, which a browser in another zone cannot do. The web wheel,
the wellness-strip tile, the insights teaser and the temperature chart all
render it, the grace constant is gone from the components directory, and
the spec says the field is required.

The overdue caption now names the number of days instead of only saying
later than usual.
2026-08-01 10:37:18 +02:00
Marc-André Bombeck bd4a6d14d5 Let a person choose what counts toward their Health Score
A dedicated settings page lists every scorable pillar grouped by the area
of health it speaks to, so the fact that blood pressure, glycaemia and
lipids are three pillars of one area is visible rather than buried. Each
row shows the three things that were never separable before: whether the
pillar is being recorded, whether it is shown, and whether it counts
toward the score. Only the last is writable here, and the page says so.

A pillar whose tracking is switched off is not listed at all, because it
is not waiting for anything. A pillar this build cannot score yet is
listed, disabled, and says why. Wellbeing under safety signposting is
named as safety guidance and never as a mistake in the selection. The
refusal for a selection too narrow to produce a score comes from the
server and is shown in the reader's own language.

The score's foot disclosure gains one link into the page.
2026-07-31 18:49:29 +02:00
Marc-André Bombeck 0826bd80e6 Merge the persisted score record into the composition swap 2026-07-31 15:57:37 +02:00
Marc-André Bombeck 76e68f0247 Let the score's recipe decide what counts, not the module switches
Until now the set of pillars behind the Health Score was assembled from
the module switches alone, in a stack of ternaries inside the reader. A
person who turned the sleep module off lost the sleep pillar from their
number as a side effect of a setting about recording data, and there was
nowhere else to say what should count.

The reader now reads the stored recipe and narrows it to the pillars a
module is actually recording. The mapping from module to pillar moves
out of the ternaries into a named map beside the reader, where it can be
read and tested. Glycaemia stays fed by either glucose or labs, which
was the one entry a one-module-per-pillar map would have lost.

Nobody's number moves on upgrade. An account that never opened the new
surface resolves to every pillar, and every pillar narrowed by module
availability is exactly the set the ternaries built, so the inheritance
holds by construction with no backfill.

A pillar the recipe selects whose module is off produces no row at all,
rather than a "waiting for data" line. Turning a module off is a
deliberate act, and asking someone for data they chose to stop recording
would be nagging them in the voice of their own settings.
2026-07-31 15:47:12 +02:00
Marc-André Bombeck cd7c6f44c8 Teach the export test mocks about the recorded score days
Four suites hand-roll a Prisma stand-in for the backup builder, so a new
section reaches them as an undefined delegate rather than as an empty
result. Each gains the delegate, and the admin restore round trip gains an
assertion that the day comes back with its composition and per-pillar
scores, not just its headline: a row restored without those looks restored
and answers nothing.
2026-07-31 15:31:47 +02:00
Marc-André Bombeck ab8e9a164d Carry the recorded score days through wipe, backup and restore
A new user-scoped table is outside all three by default, and the two
structural guards said so the moment the model landed: the wipe plan and
the backup verdict list both named it as unclassified. It now joins the
wipe list, rides the backup payload as its own spread section, and comes
back through the restore route.

The classification guards only prove a helper exists in a declared file, so
they would pass an unreferenced restore helper. The integration round trip
goes through the real admin restore route instead, with the table emptied
first so a restore that carries nothing cannot pass by leaving what was
already there.
2026-07-31 15:13:47 +02:00
Marc-André Bombeck 2f6f3573a2 Add the route that chooses what counts toward the score
PATCH /api/auth/me/health-score-config takes the pillars a person
wants counted and stores the rest as the deselection list. A selection
that cannot produce a score is refused with a sentence naming what is
missing, using the same rule the scorer applies at read time. The
write evicts the score caches, and so does the modules route, which
changes the same composition and never did.
2026-07-31 15:12:47 +02:00
Marc-André Bombeck 860a1fa299 Record each local day's score on the path that computes it
Every surface that shows a score now goes through one entry point that
computes it and writes the day down, rather than three surfaces each
remembering to. The write is an insert that yields on conflict, so a second
computation of the same day leaves the first row alone, and a failure is
annotated rather than thrown: a score that renders must not disappear
because a bookkeeping insert did.

The fingerprint covers exactly what the stored composite and band depend on
- the algorithm version, the composition, and each counted pillar's score
with its scoring-mode identity. A pillar that was graded but not counted
stays out of both the fingerprint and the row, because it did not
contribute to the number being stored.
2026-07-31 15:05:51 +02:00
Marc-André Bombeck f8074e1b07 Merge work/hero-score-panel 2026-07-31 11:48:22 +02:00
Marc-André Bombeck 9982f87dc9 Merge work/bp-score-basis 2026-07-31 11:48:21 +02:00
Marc-André Bombeck 2d01b9846d Merge work/coach-read-locale 2026-07-31 11:48:21 +02:00
Marc-André Bombeck 06fdeb0336 Pin the panel's rules with tests
Four things a person notices if they break: a pillar never shows up twice
anywhere in the panel, a failed read never lands in the not-scored list, the
panel does not push the page sideways at 360 pixels, and the whole thing is
there with the briefing switched off. Every one of them was broken on purpose
first to check the test actually goes red.

The label column is 8rem, not 7. Rendered in all six languages, 7rem clipped
the longest label in five of them, two of those by two pixels, which reads as
a bug rather than a truncation. The reserve is 480 pixels, measured against
the built panel rather than guessed: three rows come to 440 and six to 524.

The overview fixture now carries a health score, so the phone-width guard
measures a band with the panel in it instead of an empty column.
2026-07-31 11:23:05 +02:00
Marc-André Bombeck 9d82b25c6f Type the cycle route's locale probe instead of ignoring its argument
The stand-in for the discovery engine took an argument it never read, so
the lint run flagged it. Give the mock a function type instead: the
argument stays inspectable, which is the whole point of the probe, and
nothing is declared and then dropped.
2026-07-31 11:15:38 +02:00
Marc-André Bombeck 5784d5ec72 Prove the blood-pressure basis survives the assembly, not just both ends
The grader returns the score and its basis together and the pillar builder
copies both across, and each of those ends already had its own test. Neither
sees what happens between them: the envelope hand-off, the reader's narrowing,
the pillar list, the JSON that finally goes out. A field can be produced
correctly, consumed correctly, and never reach anyone, which is exactly how a
payload field shipped earlier this month and carried nothing on the wire.

These tests read the assembled response and assert the basis that arrives, so a
future hand-picked key list on that path fails here rather than in someone's
browser.
2026-07-31 11:15:27 +02:00
Marc-André Bombeck 7233845b10 Close four ways around the locale guard
The guard read a parameter as required whenever it carried no question
mark and no default, so `Locale | undefined`, `Locale | null`, a locale
widened to `any`, and a default sitting on the destructuring pattern all
walked straight past it. Each of those lets a caller say nothing and the
sentence come back in English, which is the bug the guard exists to stop.

A counter-case pins the other direction: a plainly required locale still
reads clean, so the guard stays quiet on the shape the chain is meant to
have. The correlation read now also asserts the language it was asked in;
nothing else in that file would have noticed it going back to English.
2026-07-31 11:08:06 +02:00
Marc-André Bombeck 64a92fd392 Publish which blood-pressure axis set the pillar score
The score is the worse of the systolic and diastolic sub-scores, and
until now nothing said which one it was or how far over its ceiling the
reading sat. The grader now returns that basis together with the score,
as one value, and it is carried as one value all the way to the pillar
so an explanation can never belong to a different grading run than the
number it explains.
2026-07-31 11:04:20 +02:00
Marc-André Bombeck 63c7f63f99 Put the health score back beside the greeting
The score returns to the right-hand column of the hero band, where it sat
for eleven weeks: the number at the top, then one thin line per pillar that
actually counts toward it. The card that was pinned below the greeting is
gone, so the score has one place on the page again.

Each contributor line now takes its own colour. A pillar scoring 92 used to
be painted red just because the overall score was red.

Anything not contributing is one quiet line instead of a block: a failed
read is named once for all the pillars it took down and carries the retry,
safety guidance is named in full, and everything simply not recorded yet is
counted, with the names a tap away under the score.
2026-07-31 11:02:21 +02:00
Marc-André Bombeck 271a49a822 Pin the Coach read strip's language, and keep the default out
Three things are held down here. The route hands the reader's language to
the strip builder, for a cookie and for a stored preference. The pattern
sentence comes back in German for a German reader, asserted word for
word, through the real chain rather than the engine on its own — the
engine could always write German, it was simply never asked, so an
engine-only test would have stayed green the whole time it was broken.
And a cached strip stays with the language it was written in.

The guard reads the declarations rather than grepping them, so renaming
the argument or moving the fallback into a variable does not slip past
it. Its counterpart runs the same detector against those disguises and
insists each one is caught, because a check that cannot fail reads like
proof and is not.
2026-07-31 09:07:50 +02:00
Marc-André Bombeck 4ed6f7d1ce Write the Coach read strip's second line in the reader's language
On a German metric page the strip printed its own summary in German and
the pattern line under it in English. The pattern line is a finished
sentence the server writes, and the route never told the server who was
reading, so it wrote English.

The route now resolves the reader's language the same way the
correlations and analytics routes do and passes it down. The language is
a required argument the whole way through — it used to be optional with
an English fallback, which is why a caller could stay silent and still
get a sentence back. The cycle page's lagged rows had the same gap and
are fixed with it.

The strip's browser cache is keyed by language too, so switching
language no longer re-uses the sentence written for the previous one.
2026-07-31 08:52:23 +02:00
Marc-André Bombeck 1c5191d1e3 Merge work/nap-separation 2026-07-31 08:21:55 +02:00
Marc-André Bombeck 1f8c31d29e Keep a daytime nap out of the night in the sleep stage composition
The stage-composition chart totalled each wake day, and a nap ends on the
same wake day as that morning's night. An afternoon nap was added straight
into the night's stage buckets, so the column read longer and shallower
than the night actually was.

The composition now asks the same question the night view already asks:
which session was the night. The night's stages are the night's, and the
nap sits on its own band above them, in its own colour, with its own line
in the tooltip. Days without a nap look exactly as they did.
2026-07-31 08:13:58 +02:00