Commit Graph
1206 Commits
Author SHA1 Message Date
Marc-André Bombeck 2af0751b69 test: type the new fixtures instead of casting them to never
The fixtures added with these fixes switched the type checker off with
`as never`, which the realism ratchet refuses to see grow. They now carry
their types, three webhook mocks needed no cast at all, and the lowered
counts, with the targets route's one fewer version stamp, are written
back.
2026-11-12 13:03:51 +01:00
Marc-André Bombeck 2d1009136a chore: drop the em dashes this release added to prose and test titles
The notification categories' contract description, which lands in the
published API document, and a handful of new code comments and test
titles used em dashes; the house style keeps them out of new prose.
2026-11-12 12:54:20 +01:00
Marc-André Bombeck cad4c89831 test: mark the visit, dose and address-book deletes restorable
The three deletes gained an Undo toast that calls the restore route,
but the destructive-control registry still listed them as tombstoned
with no restore, with comments saying they would become restorable the
day a surface offered the undo. The correlation card test's header
still described the disabled "Coming soon" action it no longer tests.
2026-11-12 12:53:36 +01:00
Marc-André Bombeck df47980454 fix(telegram): pick the running course and judge a reminder by its slot
The "taken <name>" keyword looked up the first medication of that name
and only then asked whether its course had ended, so an old ended
course beside the current one of the same name answered "not found".
The lookup now reads every match and takes the first that runs.

A reminder button judged the course at the moment of the tap, so the
last evening dose of a course, confirmed after midnight, was refused
although the web still takes it. When the message names its slot, the
course is now judged at that slot. The handler file grows by six lines;
its size baseline is raised to match.
2026-11-11 22:59:53 +01:00
Marc-André Bombeck f76e5cdafd fix(insights): let a read grant read the record's targets
The weight band and the BMI chart read `/api/insights/targets` inside a
shared record, and the route asked for a manage grant, so a read or
write delegate got a 403 and saw no band and no BMI. The targets are
the bands the dashboard snapshot already paints on a whole-record read
grant, computed with no provider, so the read now asks for the same
level. A section-scoped grant is still refused.
2026-11-11 22:54:34 +01:00
Marc-André Bombeck ce01d455be fix(targets): no adult weight and BMI bands on a child's record
Now that the BMI chart draws inside a managed record, a child's normal
BMI sat in the red under-17 band, and the weight page painted the adult
WHO band (about 26 to 35 kg for 118 cm), so a healthy 21 kg read as
underweight. The release's own weight-band test pinned that band.

No band here knows a child's age yet. For a record under 18, or a
managed record with no date of birth, the targets read names no WHO
weight range, no BMI range and no category; the record's own weight
target still shows, and the values and the chart stay. The BMI chart
and the weight band draw only what the server names. The weight-band
test now uses an adult height and gains the child case.

The targets builder and the vitals builder grow by two lines each for
the flag; their size baselines are raised to match.
2026-11-11 22:53:31 +01:00
Marc-André Bombeck 4be724fefb fix(ai): keep the picked gateway serving behind an empty chain
A person on the OpenAI-compatible gateway lost every AI surface once
their chain was saved at all: the fallback to the picked gateway asked
whether the chain was the default object itself, so a chain reset to
the default, reordered, or written by the gateway form's reasoning
setting (which appended a disabled entry to hold the value) no longer
counted, and the chain resolved empty. Rows written that way before
this release broke on deploy.

The fallback now reads the chain's content: the picked gateway serves
when nothing else resolves, unless the chain holds the gateway switched
off. A disabled gateway entry that carries a reasoning effort is the
form's holder, not a switch; the chain editor drops the effort of a
gateway it switches off, so a switch-off stays one. Saving the gateway
form with the gateway picked writes its entry switched on. Resolution,
the presence probe, the account payload and the iOS availability flag
all read the same rule.
2026-11-11 22:45:28 +01:00
Marc-André Bombeck 82125815d5 test: settle the parity snapshot and the size ratchet
An unreadable provider key is now reported once: the empty-chain fallback no longer opens the same key a second time. Two files shrank, so the size baseline steps down.
2026-11-11 22:17:07 +01:00
Marc-André Bombeck aacf06b7b3 fix(coach): say when the attachment picker list is cut
The Coach's "Choose from documents" picker reads the newest fifty
documents and dropped the route's nextCursor, so with a longer vault an
older report seemed not to exist unless one already knew to search for
it. A cut list now ends with the same line the document picker shows:
how many are shown and that the search reaches the rest. The document
row moves into its own component, which takes AttachmentPicker under
the function-size cap.
2026-11-11 21:44:42 +01:00
Marc-André Bombeck e68041a3af fix(forms): keep one Idempotency-Key for the remaining create forms
A screening, an illness episode, a life event, a symptom, a custom
metric or an address-book entry created on a weak signal could be
stored twice: the POST committed, the answer missed the timeout, and
the second tap went out under a new key. Their routes already honour
the key; the forms now post through useIdempotentPost like the other
create forms, so the retry replays the first answer.

The screening's save moves into a small hook beside MentalWellbeing,
which sat at the function-size cap and now drops below it; the custom
metric form's create-or-update call moves into one as well.
2026-11-11 21:39:19 +01:00
Marc-André Bombeck 0b7afd8460 fix(errors): show the catalogue sentence, not the server's text
About forty refusal messages still printed the error string the server
sent: English in every locale, and for a dropped connection the
browser's own TypeError text. Mood tags, targets and thresholds, family
history, medication side effects, phases, API tokens and dose imports,
the Coach model picker, the AI provider, fallback chain, timeout and
Codex cards, the notification channel cards, the avatar, passkeys,
security keys, the authenticator card, the re-proof dialog and the
admin pages now resolve the refusal's code through localizedApiError
and fall back to the surface's own sentence.

Two refusals carried something a person needs and had no code: a
private AI base URL (the AI_PRIVATE_ORIGINS hint) and removing the last
passkey. Both now send a code with a sentence of their own. A throttled
re-proof says so in the reader's language. Raw fetch sites throw the
same ApiError as the verb helpers through the exported throwApiError,
which replaces the admin's getApiErrorMessage; the authenticator card's
copy of describeStepUp is gone, and describeStepUp no longer passes a
non-step-up refusal's prose through (its test pinned that).

The admin password reset keeps the server's verdict on a 422: the
server words the strength and breach verdict in the account's language.
2026-11-11 21:29:33 +01:00
Marc-André Bombeck 9227844f42 Merge branch 'fix/v1473-ai' into release/v1.47.3
# Conflicts:
#	src/__tests__/baselines/source-function-lines.json
#	src/__tests__/baselines/version-stamp-comments.json
2026-11-11 21:07:27 +01:00
Marc-André Bombeck aa6e617da0 fix(insights): grade blood pressure and pulse against the own target
Someone whose doctor agreed a target of 140/90 entered it on /targets;
the chart and the dashboard tile followed it, but the BP and pulse status
texts, the general status, the comprehensive overview, the insight-card
alert and `extractFeatures` (and through it the comprehensive text, the
daily briefing and the Coach) still graded every reading against the age
default. The text under the chart could contradict the chart.

All six places now resolve the band the way the charts do since #1261:
`resolveEffectiveBpTargets` for blood pressure, the PULSE override for
resting pulse, the default only where no target is set. The resolved band
rides in each snapshot's targets block, which is part of the input hash,
so the next generation follows a changed target. The Health Score keeps
its clinical targets on purpose.
2026-11-11 21:00:10 +01:00
Marc-André Bombeck 926c427907 fix(coach): stop computing the plan review verdict nobody sees
The nightly plan review read a before/after window of the plan's metric
and stored an English verdict in `outcomeEncrypted` for every plan. No
screen and no route returned it; only the Coach snapshot read it, behind
the `COACH_EXPERIMENT_VERDICT` flag that is off by default. The worker now
only moves an active plan whose review fired to `reviewed`, which is what
takes it out of the standing group. The flag, its snapshot block, the
three eval cases for that block and the compose and env entries go with
it. The Coach prompt keeps its experiment rules and now ties the
read-back to the plan's progress line instead of the removed block.
Existing stored outcomes stay in backups untouched.

The plans page and the Coach reminders card printed the metric as a raw
key in capitals (WEIGHT, BLOOD_PRESSURE) in every language. Both now show
the measurement's name from the catalogue, through the plan metric
resolver moved to `plan-metric.ts`; a key HealthLog does not read shows
as written. The unused `coach.plans.proposalTitle` string goes.
2026-11-11 20:51:18 +01:00
Marc-André Bombeck b793be3f84 fix(onboarding): shorten the AI pitch on the last setup screen
Every new account finished setup on a full AI panel: a gradient badge, a
sample briefing, three ways to connect a provider, a one-tap offer for the
operator's key and a disclaimer, the most prominent block on the screen.
It is now one muted line with a link to Settings, AI, shown under the same
rule as the Insights hint (could be set up here, nothing configured yet,
not opted out). That hint and the consent request on Insights and
Settings, AI stay the places that invite.

The sample briefing card, the `onboarding.ai.*` strings in ten locales and
the end-to-end spec for the removed offer go with it. The consent-grant
guard's floor drops to the two callers left.
2026-11-11 20:38:37 +01:00
Marc-André Bombeck 1d5236a326 test(ai): settle the ratchets after the empty-chain change
The resolver tests queue their fixture rows through one helper instead of
an `as never` per row, the no-provider status case also proves the
single-provider resolver is never asked, and the size and comment
baselines take the lower numbers the change left behind.
2026-11-11 20:37:48 +01:00
Marc-André Bombeck f519df4433 Merge branch 'fix/v1473-record' into release/v1.47.3 2026-11-11 20:18:59 +01:00
Marc-André Bombeck 6a02cda210 Merge branch 'fix/v1473-writes' into release/v1.47.3 2026-11-11 20:18:58 +01:00
Marc-André Bombeck da671f74aa refactor(day): share one window for date-only columns
The day view, the day index and the visit preparation each built the
UTC calendar window for a date-only column by hand (documents had a
fourth copy). `dateOnlyWindow()` beside the other date-only helpers
replaces them. The report name select becomes one spread, the share
page builds its calendar formatter outside the view, and the PDF
section reads it off the context, so the functions these fixes touched
stay within their size budget; `DocumentRecordLinks` drops under it.
2026-11-11 20:12:44 +01:00
Marc-André Bombeck 53f4ecc2c5 fix(documents): say when the document picker list is cut
The picker for linking documents to an illness and for choosing
documents for a share link reads the newest fifty and dropped the
route's nextCursor, so with a longer vault an older report seemed not
to exist unless one already knew to search for it. A cut list now ends
with a line that says how many are shown and that the search reaches
the rest. Paging was left out: the coach attachment picker shares the
picker's cache key with a single-page read.
2026-11-11 20:08:24 +01:00
Marc-André Bombeck d204b4344b fix(records): offer Undo after deleting a vaccination, visit or entry
Deleting a vaccination, a doctor visit or an address-book entry closed
the sheet with no way back, although all three are soft deletes with a
documented restore route. The vaccination hook had a restore mutation
nobody called; visits and the address book had none.

The two hooks gain a restore mutation, and each delete now confirms
with an Undo toast, the same pattern the illness episode uses. The Undo
runs after the sheet has closed, so it awaits the restore itself and
the hook's own onSuccess refreshes the lists, instead of relying on
callbacks a closed sheet never receives. Restoring an address-book
entry brings the entry back, not the links the delete cleared on the
visits that named it. The three new success toasts are pinned in the
success-affordance guard.
2026-11-11 20:06:02 +01:00
Marc-André Bombeck 5449933c4f chore: remove three components nothing renders
personal-record-badge.tsx, intake-history-editable.tsx and
intake-history-preview.tsx had no importer outside their own tests and
the preview, so they rendered nowhere while four guard tests kept them
listed as live surfaces. They go with their tests, their guard entries
and the strings only they used.

GET /api/personal-records stays: the native app reads it. The
resolved-field guard now records its benchmark band as a native-only
field instead of naming the deleted badge as its reader.
2026-11-11 20:03:00 +01:00
Marc-André Bombeck f761500247 fix(forms): send one Idempotency-Key per opened create form
A mood, symptom, lab value, custom metric value, unscheduled dose,
allergy, vaccination, visit or measurement saved on a weak signal could
be stored twice: the POST committed, the answer missed the 15 s
timeout, the form said it could not save, and the second tap was a new
call with a new key, so the server ran it again.

useIdempotentPost keeps one key until a save lands and renews it for the
next entry, so the retry replays the first answer. The create forms and
the three create hooks post through it; the comment on the per-call key
in api-fetch now says what that key does and does not cover.

LabForm and MoodForm each gain the one line that mounts the key, so
their function-size baselines go up by one. The intake toast guard now
recognises a dose posted through the hook.
2026-11-11 20:02:40 +01:00
Marc-André Bombeck 02856e7f37 fix(targets): accept a child's weight target below 30 kg
A weight target was refused below 30 kg on every save path (the own
targets editor and a managed record's settings parse the same schema),
while a weight reading is accepted from 1 kg. A guardian setting 20 to
23 kg for a child got only "please check your entries". The target
floor is now the measurement floor.

The weight bands on the dashboard and the weight page started their low
red zone at a hard-coded 30 kg, so a target below it lost that zone; the
edge now follows the same floor. Bands are clipped to the chart's own
domain, so an adult's chart looks the same. `buildWeightBandsFromHeight`
had no caller left and goes; the band builder's 30/250 defaults, which
every caller overrode, go with it. The unit-adapter test keeps its
inward-rounding check at the new floor (1 kg is 2.2046 lb, offered as
2.3).
2026-11-11 20:00:39 +01:00
Marc-André Bombeck 4fa5e6381e fix(insights): take weight and BMI bands from the record on screen
Inside a shared or managed record the weight page shaded the band for
the viewer's own height, and the record's own target never arrived
because `/api/user/thresholds` refuses under a switch: a parent saw
their own healthy range behind a child's weights. The BMI page decided
from the viewer's height whether there was anything to show, and the
target gear opened an editor that could neither load nor save there.

The weight page now reads the record's target and height from
`/api/insights/targets`, the payload the reference panel under the
chart already uses, and paints the same wings as before. The BMI page
reads the record's height there inside somebody else's record. The
gear stays hidden in another person's record, as the BMI chart
already draws nothing there rather than a wrong figure.
2026-11-11 19:56:49 +01:00
Marc-André Bombeck 71fe874ef2 fix(medications): silence a switched-off module and an ended course
With the Medications module switched off, dose reminders, missed-dose
rows and low-stock alerts kept coming, a tap on one landed on
"Medications is switched off", and the Telegram buttons still booked
intakes. The reminder tick now drops the medications of an owner with the
module off, one module read per owner and tick, so neither a reminder nor
a missed-dose placeholder is written while it is off. The low-stock pass
skips such a user, as the mood reminder does for its module, and the bot
offers and books nothing.

A medication whose course had ended still counted as live: the low-stock
pass kept warning about its stock, and the bot's /add list offered it and
booked a dose after the end, taking a unit off the stock. The low-stock
pass and every bot path now skip a course that has ended on the person's
clock. A course that has not started yet keeps its reorder warning and
stays in the bot. A paused medication is already active: false, so the
existing filter keeps it out.

The bot's six copies of the medication lookup go through one helper.
2026-11-11 19:55:09 +01:00
Marc-André Bombeck 8057bb20bc fix(notifications): drop the four switches nothing sends
The notification matrix listed "Unusual measurement", "Low adherence",
"Withings sync failed" and "Personal record" with a switch per channel,
three of them on by default, yet nothing dispatches any of them: a reading
far outside its range or a week of missed doses produced no message.

eventTypesForRole now leaves them out for every role, next to the
admin-only filter, and the preferences read no longer serves rows stored
for them. Their labels in all locales and their tap targets are deleted.
The types stay in EVENT_TYPES, so the preference write and the stored rows
keep parsing; no migration.

The label and tap-target tests walked EVENT_TYPES and so pinned the four
in place; they now walk the event types that are sent.
2026-11-11 19:53:01 +01:00
Marc-André Bombeck 971d5d32cc fix(documents): refresh what confirmed facts and a take-over create
Confirming a document's facts creates medications, conditions and lab
values, and the profile take-over creates a condition or an allergy.
Both evicted only the roots of those lists, so the dashboard and the
Today digest showed the old medication set until the 120 s poll, the day
and timeline kept the old state for five minutes, and adherence and
nutrient reads were not evicted at all.

Both now spread the bundles of what they create, and the condition paths
pair them with the inactive refetch of the daily reads.
2026-11-11 19:44:13 +01:00
Marc-André Bombeck 5cf4a7d213 fix(doctor-report): name a managed record by its display name
The report cover, the PDF author field, the FHIR Patient and the share
page printed `fullName ?? username`, and the collector never read the
display name. A managed record has no way to get a full name, so its
generated `managed-<hex>` login landed on every clinical document made
for it; an own account printed the login instead of its display name.

The collector now resolves the name once (`patientName()`: full name,
display name, login, never a managed record's generated handle) and the
five readers print that one field. `patient.username` and
`patient.fullName` leave the report payload; nothing else read them.
2026-11-11 19:42:17 +01:00
Marc-André Bombeck 2e73b0ffec fix(query-keys): refresh the day view and timeline after every write
Symptoms, workouts, lab results, allergies, documents and questionnaires
evicted only their own lists. An open day, its calendar dots and the
timeline kept the pre-write answer for up to five minutes, so "Capture
for this day" saved a symptom or a workout and the day showed the old
list, and a deleted row stayed on it.

Workouts, labs, allergies and documents get a bundle beside the seven
that already carry the day and timeline roots; the six lab lists become
one (the OCR dialog's own copy of the commit hook's list goes with it).
Symptoms and questionnaires add the two roots to their list. Symptoms
stay off the illness bundle on purpose: nothing on the dashboard or the
Today digest reads them. Custom metrics need nothing: neither the day
nor the timeline reads them. invalidateKeys now drops a key listed twice,
since spread bundles share the two roots.
2026-11-11 19:41:14 +01:00
Marc-André Bombeck a486c3c8a3 Merge remote-tracking branch 'origin/release/v1.47.2' into takeover/1370
# Conflicts:
#	CHANGELOG.md
#	src/lib/module-list-prefs.ts
2026-11-11 00:04:51 +01:00
Marc-André Bombeck ea3fcce80c fix(checkups): keep a missed rolling reminder due until it is measured
A rolling reminder ("every N days") is anchored on its last reading, but
the reminder tick rolled any cycle of a week or less on after a delivered
reminder, re-anchoring it on the send. A missed every-two-days blood
pressure reading therefore moved two days ahead each evening, read "in 2
days" on the card, and never showed as today or overdue.

A rolling reminder now stays due after its reminder, whatever its
interval, until a reading lands or it is done, skipped or snoozed. While
it stays open the repeat follows its interval, capped at a week, still in
its notify hour and under the per-day claim. Calendar rules that recur
within a week (a twice-daily course) keep rolling on; appointments are
unchanged.

holdsOpenAfterReminder stays the one definition for the tick and the
edit route. The daily digest asks the narrower question underneath it,
hasLongCycle, so a short rhythm still waits for its time of day on the
start page and is not pinned under the rail cap.

Refs #1369
2026-11-10 22:14:02 +01:00
Konrad Garbal b4ac67ee7c feat(labs): add biomarker filtering and sorting 2026-11-10 20:46:57 +01:00
Marc-André Bombeck 69ba8bec91 fix(measurements): read the GKI's glucose from the ketone side
The correlated EXISTS over a time range had no equality to drive it from the ketones, so a person with years of sensor glucose walked every glucose row in the window. Each ketone reading now reads its glucose through a LATERAL index range. A full tie between two glucose readings resolves by id, the day tile names the unit, and the empty text says a ketone reading above zero.
2026-11-10 19:53:47 +01:00
Marc-André Bombeck 62f4578874 Merge branch 'feat/v1472-ketones' into feat/cycle-feed-forecast-style
# Conflicts:
#	CHANGELOG.md
2026-11-10 19:31:31 +01:00
Marc-André Bombeck f75337e6f2 feat(measurements): record blood ketones and show the glucose-ketone index (#1367)
Ketones had to live in a custom metric, with the glucose-ketone index typed
in by hand beside it, where it drifted from the readings it came from.

- Type: BLOOD_KETONES (beta-hydroxybutyrate, mmol/L, migration 0496),
  plausible from 0 to 10: the blood ketone meters read 0.0 to 8.0 and print
  HI above it, and a venous value off a lab sheet fits under 10. Zero is a
  real reading. No band, no direction, no personal record, off the Coach
  snapshot and the correlations; readable over MCP and owned by the glucose
  module like glucose.
- Entry: choosing blood ketones in the measurement form also takes the
  glucose from the same fingerstick, in the account's glucose unit. Both
  rows go through the array arm of POST /api/measurements with one
  timestamp, so they land in one transaction or not at all.
- Index: glucose (mg/dL / 18.0182, the tree's one factor) over ketones,
  computed at read time and never stored. Each ketone reading above zero
  pairs with the glucose reading at the same instant, else the nearest one
  within 15 minutes either side; a tie goes to the same source, then to the
  earlier reading. Nothing pairs across a wider gap.
- Surfaces: GET /api/measurements/series takes kind=ketones and
  kind=glucoseKetoneIndex (each point names the paired readings), and the
  day carries glucoseKetoneIndex when both reading types are visible. The
  new Ketones page charts the readings with the index beneath; the day view
  shows the index beside the value tiles; the doctor report, its PDF and the
  clinician share print it in the glucose group when both leaves are chosen,
  since beside the ketones it gives the glucose back.
- FHIR: ketone readings export under LOINC 104816-4 (Beta hydroxybutyrate
  [Moles/volume] in Blood). The index is left out: it has no LOINC term,
  and both readings it is built from are in the bundle.
- The measurement form's action row moved into its own component to keep
  the form inside its size budget.

OpenAPI regenerated; all ten locales; the import unit table lists the type.
2026-11-10 19:28:57 +01:00
Marc-André Bombeck 928ade5109 feat(labs): let a person order the lab categories (#1349)
The labs list layout gains categoryOrder, a bounded allow-list of
category keys (200 entries, 64 characters each, deduplicated, malformed
values dropped on read). GET /api/labs/overview reads the caller's own
layout and answers categories already in that order, so every client
renders them as given. Listed categories come first; keys the data does
not have are ignored; a category the order does not name follows in
the default order (catalog panels, then the person's own by label, then
other). Other left at the end of the order stays last, behind any
category added later; moved anywhere else, it stays there.

Settings > Arrangement > Labs shows a category order card while
categories are on. It lists the overview's categories under their
localised labels, sends the whole order after every move, moves the
cached overview at once and refetches it. The card says how the default
order works. The OpenAPI document carries the new field.
2026-11-10 18:57:48 +01:00
Marc-André Bombeck 546b9b2569 feat(cycle): choose how the calendar feed draws a forecast (#1350)
A forecast period was one all-day event over every day it might fall on,
so a period of a few days showed up as a week-long estimate next to the
logged ones. Each feed now carries a forecast style:

- EXACT: the calculated period only, from the most likely start for the
  predicted number of bleeding days.
- MARGIN: that event, plus separate "possible" events for the days it may
  start earlier and the days it may still run on, each only where the
  band reaches that side. Discreet titles use an open dot for them.
- BAND: the single range, unchanged.

Migration 0495 adds the column with BAND as its default, so every live
feed keeps the events it serves. A feed turned on from now on starts on
MARGIN, set by the mint route. PATCH changes it, rotate copies it, the
status and the audit diff carry it, and the settings card offers it with
a one-line explanation of the chosen style. The horizon and its stop rule
are the same in every style, and the period event keeps its UID across
styles so a calendar updates it in place.
2026-11-09 10:59:18 +01:00
Marc-André Bombeck 3b3ef5c0a1 fix(cycle): keep the calendar feed to the owner and a managed profile's guardian
A calendar feed is a standing credential whose contents end up in a
third party's calendar cloud. Putting a record's cycle dates there is
the decision of the person they are about, or of the guardian of
somebody who cannot make it, and no delegate of an adult's record,
MANAGE included.

The subscription routes resolve through requireGuardianAuth instead of
requireRecordAuth("manage", "cycle"): the owner on their own record, or
a MANAGE grant on a record carrying the managed-profile marker. Every
verb follows, the revoke too. The feed re-reads the marker on each fetch
as well as the guardian's grant, so a row minted for somebody else on an
adult's record is a 404 whatever grant stands behind it. The settings
card is offered only where the routes answer.

The routes move from the delegable and MANAGE literals to the guardian
list, and the MANAGE handler inventory is back to 93.
2026-11-06 18:03:37 +01:00
Marc-André Bombeck d9a31524a2 feat(cycle): serve the cycle forecast as a subscribable calendar
GET /api/cycle/feed/<hlf_token>[.ics] answers any calendar app with the
record's forecast as RFC 5545: all-day events for the next few periods
over their widening windows, optionally past periods, and for a
conception goal that asks for it the next fertile window. Dates and a
title only; discreet titles and the calendar name "HealthLog" by default.
The body is built live on every fetch in the record's own zone and
language, never the request's. ETag is the content hash and a matching
If-None-Match answers 304; DTSTAMP moves only when the content does.

The feed is anonymous, so every gate is read per fetch: the auth-surface
limit before any lookup, the token's shape, the HMAC lookup of a live
feed, a per-token bucket, then the record and carrier not pending
deletion, the operator's external-access switch, cycle tracking for the
record, and for a feed a delegate minted, a live MANAGE grant covering
cycle. Every miss is the same 404.

/api/cycle/feed-subscription (status, turn on, options, turn off) and
its rotate are cookie-only through requireRecordAuth("manage", "cycle"),
with a fresh proof on turning on and rotating and a per-actor mint
bucket. The URL is returned once; the status carries the prefix, the
options, the fetch statistics as a calendar-app family and the
projection state.

The hlf_ family is registered with the path and token redactors, the
secret-shape refusal, the proxy's public paths and edge headers, the
URL-credential, sharing, recent-proof, step-up and module-gate guards,
the error-code catalogue, the activity feed and the destructive-control
registry. The cycle settings tab gains the subscription card and the
predictions panel lists the later cycles.
2026-11-06 18:03:37 +01:00
Marc-André Bombeck 2480a0138d feat(cycle): add the calendar-feed credential table
A record's cycle calendar subscription is a URL credential: one row per
feed, only the HMAC of the raw hlf_ token stored, and at most one live
feed per record through a partial unique index on user_id where
revoked_at is null. Both foreign keys cascade, so deleting the record
or the carrier who minted the feed ends it.

The row is a credential everywhere else too. It stays out of the
backup (a restore would bring back a revoked link), Delete All Data
takes it on both sides, a credential remediation revokes the feeds on
the record and the ones the account minted elsewhere, and a
managed-profile handover claim revokes any feed a guardian minted.

backupVerdict moves beside the plan into its own module so the plan
stays the lists and their reasons.
2026-11-06 18:03:27 +01:00
Marc-André Bombeck c0470df14c test(labs): tie the category fields to their readers
A per-envelope guard walks the category leaves of LabOverviewResponse,
customPanels on ListBiomarkersResponse and adoptedPanel on
BiomarkerMergeResult out of the Zod schemas and ties each to the web
reader that renders it, so a new leaf without a reader fails. The
resolved-field guard gains a rule that bans the category ingredients
(catalogKeyForName, resolveMarkerCategory, summariseCategory,
categoriseOverview, ALIAS_PANEL, buildLocalisedLabelIndex) from client
code: the category, its order and its status are read, never rebuilt.

Refs #1349
2026-11-06 17:57:28 +01:00
Marc-André Bombeck 65f1a3db91 feat(labs): keep a marker's panel when it is merged into one without
A merge into a target without a panel now takes over the first panel
a source carries, in the order the sources were given, so the merged
marker keeps its category. The result reports it as adoptedPanel, on
a dry run as well, and the merge preview says so before the person
confirms. A target with its own panel keeps it. The ledger does not
record panels; a merge has no undo.

The integration suite also proves the clear path end to end: an
explicit null on PUT clears panel, context and both bounds.

Refs #1349
2026-11-06 17:53:56 +01:00
Marc-André Bombeck 910f28dcfe feat(labs): suggest panels in the marker form and let an edit clear fields
The panel field offers the catalog panels in the page language and the
person's own panels, which GET /api/biomarkers now lists as
customPanels (one spelling per folded form). The field stays free
text; a catalog panel typed in any language lands in the same category.

An edit used to omit an emptied panel, context or bound, so the stored
value survived the save. The form now sends null for a field that held
a value and was emptied. A new marker still omits empty fields, and a
field that was empty when the edit began stays out of the body, so a
context the list could not decrypt is never wiped by an unrelated save.

Refs #1349
2026-11-06 17:51:34 +01:00
Marc-André Bombeck 92638ec089 feat(labs): show the labs list under server-resolved categories
GET /api/labs/overview now answers categories and grouped next to the
markers. A marker's category is its panel: a catalog panel in any
shipped language folds to one key, any other panel becomes the
person's own category under an opaque hash key, and a marker without
a panel falls back to the catalog panel of its name. ALIAS_PANEL
places the ten analytes that have names but no seed. Each category
carries its label in the caller's language, its members, counts per
range status and one combined status (above wins over below; no
alarm tone). grouped is true only when grouping adds something.

The list renders this as given: with groupBy set to category it shows
a disclosure header per category in both views, the person's sort
applies inside each category, and groupsOpen decides which start open.
A category opened or closed by hand stays so in this browser until the
choice changes. Settings > Arrangement > Labs gets both choices. The
overview query key carries the locale, since the labels are localised.

Refs #1349
2026-11-06 17:49:38 +01:00
Marc-André Bombeck ef61c7d5d1 fix(labs): keep the printed range on a marker minted from a text result
A report that prints a text result such as "SEE NOTE" with a numeric
range beside it minted the marker without bounds: the document commit,
the scan commit, the free-text lab write and the CSV import each passed
null bounds for a qualitative reading, and the CSV row reader and the
scan review payload dropped the range before that. The marker's first
number then had nothing to be judged against.

Every mint path now passes the printed window. A range that is text
itself yields no bounds, an existing marker keeps its own, a transposed
window is not minted, and the reading itself still records no window.
The scan review shows the range on a text row that mints a new marker.

Refs #1313
2026-11-05 10:59:52 +01:00
Marc-André Bombeck e9886ee554 fix(security): let the app frame its own blob previews
A blob: document inherits the policy of the page that minted it, and
WebKit applies the inherited frame-ancestors to the blob frame's own
parent. With 'none', Safari on macOS and iOS refused the PDF preview
iframe and showed an empty box; Chromium does not apply it there and
rendered the preview. 'self' keeps every foreign origin out.

Refs #1311
2026-11-05 00:52:02 +01:00
Marc-André Bombeck 341061de5c feat(measurements): store resting energy as its own metric
HealthKit's basalEnergyBurned sat in the deferred set, so the batch route
refused it per entry and acceptedTypes left it out. It now maps to a new
BASAL_ENERGY_BURNED type in kcal (migration 0493), beside active energy
and never added to it.

It is cumulative like active energy: summed per day on the read path and
in the rollups, drained into one daily row, imported from export.zip,
listed and charted with its own label in every locale, part of the report
selection and the backup, and readable over MCP. It carries no personal
record direction and feeds no score, insight or Coach snapshot.

Health Connect is left unmapped on purpose: its basal metabolic rate is a
rate at one instant, not a day's energy. No other integration reports a
resting figure of its own.

Refs #1340
2026-11-04 23:45:55 +01:00
Marc-André Bombeck a35b77c147 feat(labs): serve one marker's lab line under the labs module
GET /api/biomarkers/{id}/series answers the line labSeries on
/api/timeline draws for that marker, gated on the labs module instead of
the opt-in timeline. The window rules move into resolveTimelineWindow and
the shared query rules into withWindowRules, so both routes cut the same
window and read the same loader; all starts at the marker's first reading.

Refs #1342
2026-11-04 21:24:12 +01:00
Marc-André Bombeck d3401b5b89 refactor(records): move the life circumstances field primitives into their own file
The draft state for the typed fields pushed the form past its size
baseline. The primitives own only their input state and read nothing from
the form, so they move out and the baselines tighten.
2026-11-04 17:59:54 +01:00