Commit Graph
1818 Commits
Author SHA1 Message Date
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
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 c14cc9ab47 fix(cycle): turning the feed off wins against a rotation in either order
DELETE revoked the one feed it had read, so a rotation committing while it waited left the successor live behind a 200. It now revokes every live feed of the record in two passes. A rotation that loses to a turn-off answers 404 cycle.feed.none instead of 409; PATCH documents its 409. The in-repo feed guide describes the three forecast styles.
2026-11-10 19:53:47 +01:00
Marc-André Bombeck c62abad965 fix(sync): give every drained domain a fresh watermark two minutes back
A domain with no new rows kept its last row's stamp, so once that was older than the tombstone horizon every pull answered cursorExpired and the client resynced from scratch on every pull. A domain whose page runs dry now takes serverNow minus the overlap on every page where its watermark has not passed that line, and on the last page every row domain does, raising a quiet domain and holding back one that delivered rows in the stretch. A domain drained early in a long walk is covered as well. The overlap grows from 60 to 120 seconds, above the 60-second timeout of the longest transactions that write served rows.
2026-11-10 19:49:30 +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 6fb0d184f1 fix(sync): hold a drained cursor a minute back so late commits arrive
A page's watermark is its last row's stamp, and a long import stamps its rows before it commits them: a quick write committing in between carried the watermark past them, and the keyset never returned them. A caught-up page now hands out watermarks at most a minute before serverNow in every row domain, as start=now does, so the next pull reads that minute again. The mirror domain keeps its watermark.
2026-11-10 19:08:36 +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 84dace4f89 chore(release): v1.47.1 2026-11-07 01:44:29 +01:00
Marc-André Bombeck 46a1a5d320 fix(cycle): answer a raced feed rotation with 409 instead of 500
Two rotations at once both revoked the same live feed, and the second
successor then hit the one-live-feed index with an unhandled unique
violation. The losing rotation now gets the mint path's 409
cycle.feed.exists, and the winner's link stands.
2026-11-07 01:33:19 +01:00
Marc-André Bombeck 01884e91fa fix(sync): place a start=now cursor a minute behind the present
updatedAt is stamped when a write is built, not when it commits, so a
transaction in flight when the cursor was handed out could commit a row
stamped before it, and a watermark at serverNow skipped that row for
good. The watermarks now sit one minute back; client upserts are
idempotent, so the overlap is harmless. The token's issue time stays
serverNow.
2026-11-07 01:33:19 +01:00
Marc-André Bombeck 74e691cecd feat(timeline): draw person-defined symptoms as value lines
A custom symptom could feed pattern discovery and the Coach but could not
be looked at over time. The timeline now offers every symptom the record
has logged, hidden ones included, beside the measurements and lab markers,
and draws a chosen one as a value line.

`values` takes `symptom:<definitionId>`, checked for its shape and capped
like a lab marker; an id the record does not hold draws nothing. A day's
value is its highest intensity on the record's local day; a point is the
mean of the bucket's day values over the days logged, with the number of
those days, and a day without an entry is not a zero. The line sits on a
fixed 0 to 10 scale. Symptoms are read under the illness lane's gate, the
module and sharing domain the symptom routes use. An `all` window reaches
back to a chosen symptom's first entry.

The response carries `symptomSeries` and `availableSymptoms`; the OpenAPI
contract is regenerated. The page words a point's count as days and the
value out of ten. The date-key schema moves to its own module so the
symptom schemas can share it.
2026-11-07 00:38:53 +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 e8ef590af7 docs: describe the cycle calendar subscription
A how-to for subscribing from Google, Apple and Outlook and what the
feed shows; a section on the reproductive-health page saying that once
it is on, the dates also sit with the calendar provider and turning it
off does not reach those copies; a reverse-proxy note with Caddy and
Nginx examples for keeping path credentials out of access logs; and
API_TOKEN_HMAC_KEY rotation named as ending every feed link.
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 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 e2170115c7 feat(settings): carry category choices in the labs list layout
The list layout gains two choices that only the labs list reads:
groupBy (none or category, default none) and groupsOpen (expanded,
flagged or collapsed, default expanded). The resolver keeps a valid
stored value and drops anything else to the default, the PUT merges
both field by field, and the hook exposes a setter for each. A
browser value from before the server column never held them, so the
legacy parse fills them from the defaults.
2026-11-06 17:44:38 +01:00
Marc-André Bombeck 7b4c67b372 feat(workouts): attach a route to a stored workout that has none
The workout batch kept `route` only for entries it inserted. An entry
matching a stored workout reported `duplicate` and its track was dropped,
so turning on `storeWorkoutRoutes` only helped workouts recorded from then
on.

A matching entry that carries a route now attaches it when the stored
workout has no track and the account keeps tracks, the same way the
heart-rate series enrichment works: the planner probes for a stored
track, the write seals it like an insert does, and the entry reports
`enriched` (once, also when it attaches series and track together). The
climb derived from the track fills an empty `elevationM`, as it would on
insert. A workout that already has a track keeps it, and with routes off
nothing is written.

Track rows now go through `createManyAndReturn` on the unique
`workoutId`, so a track that lands between the probe and the write wins
and this entry settles as a duplicate instead of failing or adding a
second row.
2026-11-05 16:04:08 +01:00
Marc-André Bombeck da3d29424d fix(visits): cap the window before a visit at the longest report
The window before a booked visit started at the last matching visit
however far back it lay, while the health-report export covers 730 days
and fell back to its 90-day default for anything longer. The card then
offered a report the export did not build. The window now stops at the
export's limit, read from one shared constant, and preparation.capped says
when it did; the web says "The last two years" for it.

The export clamps an over-long explicit range to the last 730 days before
its end instead of falling back to 90 days, so a custom range or a linked
window cannot shrink silently either, and its day count forgives the hour
a clock change adds, so two years of local days count as 730.
2026-11-05 14:12:09 +01:00
Marc-André Bombeck f39598f2e7 feat(coach): name a read past the lookback limit on the trail
A read whose readings all lie past the lookback limit was reported on its
step as outside_window, and its fetch entry carried no reason, so a client
could not tell an empty window from data the limit keeps out. The step now
carries reason outside_reach and the fetch entry mirrors it. The method
line and the web trail keep the window sentence; the answer names the
limit as before.
2026-11-05 14:12:09 +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 27a1cd6c2f feat(integrations): list imported cycle days in the details
The details of the Garmin and Oura cards listed measurement types and
workouts only, so cycle days a wearable imported appeared nowhere, and a
bug report could not say whether any had arrived. The status envelope now
carries a `CYCLE_DAYS` entry per cycle-importing source with the newest
day and the count, and the freshness list renders it. It counts as a
sparse type, so a month between periods never reads as a dead pipe.

Refs #1333
2026-11-04 22:03:18 +01:00
Marc-André Bombeck afdb8163ce fix(onboarding): offer Garmin as a source
The setup question about where readings come from listed every other
connection on Settings and not Garmin, so the flow could neither record it
nor end on connecting it. Garmin is now a Q4 source and a browser
connectable one: the key is pasted on its card, so nothing can be missing
up front.

A guard now pins that every connection the health read covers is either an
onboarding source or named with the reason it is not.

Refs #1333
2026-11-04 22:03:08 +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 a79fc4168a feat(sync): let a client start the delta feed at the present
GET /api/sync/changes?start=now answers an empty page whose cursor sits at
serverNow in every domain, in the same opaque format the feed hands out.
A client that only needs what changes from now on, such as the Apple
Health write-back, pages forward from it instead of draining the whole
history first. start and cursor together are refused with 422.

Refs #1339
2026-11-04 21:24:12 +01:00
Marc-André Bombeck 59434cd463 feat(healthkit): list the accepted sample types on the integration read
GET /api/integrations/healthkit carries acceptedTypes, the quantity and
category identifiers the batch route maps today. The list is derived from
APPLE_HEALTH_TYPE_MAP alone, so a deferred type is never in it and the
client no longer needs a release to follow a mapping change.

Refs #1340
2026-11-04 21:18:30 +01:00
Marc-André Bombeck 66533497de fix(auth): carry purgeAt on the native OIDC deletion-pending redirect
The web leg of the OIDC callback already names the purge date when the
account is pending deletion; the native leg redirected with the bare
reason. It now adds the same purgeAt parameter to the custom-scheme
redirect, and only the date.

Refs #1341
2026-11-04 21:18:30 +01:00
Marc-André Bombeck fb143a2d47 docs(release): screenshots for v1.47.0 2026-11-04 14:21:49 +01:00
Marc-André Bombeck bc4b9f4350 chore(release): v1.47.0 2026-11-04 14:13:49 +01:00
Marc-André Bombeck e547a77683 docs: describe the image as the official Node 22 Debian slim image
The unreleased changelog entry said the image moved to Wolfi. It now
names the official Node image, why (Raspberry Pi 4 rendering, Node
security releases arriving with the image), that the image carries no
package manager, and that psql is no longer in the app image. The README
and the getting-started prerequisites say the same.
2026-11-04 13:14:04 +01:00
Marc-André Bombeck 2348073eeb docs(garmin): describe the service choice, key validity and skipped data
The Garmin page says the key goes only to the service picked, how to
correct a key's validity from the card, that a refused key is retried
daily, that weigh-ins reach back past the first watch, that removed
nights disappear, and where data Garmin does not provide is listed. The
changelog entry names the service choice.
2026-11-04 10:56:52 +01:00
Marc-André Bombeck 83c06051fe fix(garmin): read weigh-ins in both shapes, keep refused resources out of the way
Weigh-ins: Garmin's document has no samplePk and gives kilograms, the live
service sends a samplePk and grams. A weigh-in now takes its id from
samplePk or else its own GMT instant, reads each mass by magnitude (above
1000 is grams), and is written whole or not at all: a missing weight, a
missing instant or one implausible value drops every row of it, counted
on the wide event by reason.

Refused resources: a lasting 4xx on one resource (a cycle summary for an
account that tracks no cycle) is noted once on the progress column,
counts as done for the backfill, is skipped by the hourly sync and is
listed in the card's details. It no longer holds the backfill open or
turns the connection red. The cycle leg never parks the connection,
whatever it is refused with.

Backfill: weigh-ins walk back to the start of Garmin Connect on their
own, so history before the first watch day and a scale-only account
arrive. Progress and the completion stamp are written only while the
connection the slice started on is still the stored one.

Key: connect sends the key to the one service the person picks (Garmin
Connect or Garmin Connect China), never to both. A parked key is asked
about again once a day with a single token exchange and reconnected when
Garmin accepts it. The key's end is read from the token's own iat and
exp, an estimate the key outlived is cleared after an accepted exchange,
and the validity can be corrected from the card's menu (PATCH
/api/garmin/key-expiry, with the day the key was created) without
pasting it again.

Sync: an incremental run reaches back to the last successful sync, up to
a year, so an outage longer than the 30-day window leaves no gap; a night
Garmin no longer reports (deleted, or turned into a nap) is retired.

Card: connect errors are translated from meta.errorCode in every locale.

Types: BODY_BATTERY, STRESS_LEVEL, TRAINING_READINESS and
ACUTE_TRAINING_LOAD get source ladders of their own (the device or the
bridge first, then the hand-entered sources in their usual order), join
the ranked types and the provenance picker, and the recovery-empty
description names them.
2026-11-04 10:56:52 +01:00
Marc-André Bombeck 73f16e5389 docs(garmin): document the Personal API integration
The Garmin page now explains how to create a key, what syncs and where
it lands, how key expiry is handled, and what the Personal API does not
carry. The Home Assistant guide shows how to push Training Readiness and
the acute training load, the Apple Health and Google Health pages point
at the direct integration, and the changelog records the release notes.
2026-11-04 10:14:17 +01:00
Marc-André Bombeck e2ba86b12f feat(garmin): sync Garmin Connect through the Personal API
Garmin Connect now offers a Personal API: a person creates a key for
their own account and any client can buy short-lived tokens with it.
HealthLog takes that key in a new Garmin card, checks it with Garmin
before storing it sealed on the user row, and polls every hour.

- Transport: key exchange with a per-process token cache keyed on the
  key, one re-exchange on a refused token and then reauth_required, the
  429 Retry-After carried to the caller, the China host tried when the
  global one does not know the key, answers read with a byte cap, all
  egress through safeFetch to the two fixed hosts.
- Sync: daily summaries, sleep summaries, HRV, weigh-ins and the
  activity search, chunked to each resource's documented range, read one
  after another and stopped at the first rate limit; today's menstrual
  cycle summary adds logged period days the person has not logged.
- Backfill: a resumable walk through the shared admission lane that
  learns where the history starts from the daily summaries and the
  activities, waits out Garmin's rate limit and stamps its marker.
- Types: GARMIN source; BODY_BATTERY and STRESS_LEVEL for Garmin's own
  day scores, kept apart from the computed STRESS_SCORE; TRAINING_READINESS
  and ACUTE_TRAINING_LOAD for figures a bridge pushes through the ingest
  token. All four chart on the recovery page and sit on the ladders,
  units, ranges, labels and report groups like the device-native scores.
- Key expiry: learned from a token capped short of a day, else estimated
  from the validity picked at connect; the card warns a week ahead and
  asks for a new key once Garmin refuses the old one.
- Ledger, health, freshness, watchdog, backup classes, wipe plan,
  encrypted-column registry, rotation script, OpenAPI and all ten
  locales carry the new integration. The old "no Garmin connector" note
  is gone.
2026-11-04 10:14:10 +01:00
Marc-André Bombeck 22f83aed6e Merge branch 'feat/v147-bp-time-of-day' into release/v1.47.0 2026-11-04 00:13:45 +01:00
Marc-André Bombeck b93241aa87 feat(insights): blood pressure by time of day
Home monitoring reads morning and evening readings as separate sets, so
the blood pressure page now shows the morning, midday and evening means
side by side over the chart's range, each with its count and pulse, and
the morning-minus-evening difference. A control under them narrows the
chart to one part of the day; the choice lives in `?partOfDay=` so it
survives a reload and a shared link, and the whole day keeps a bare URL.

The split is resolved on the server in the record's own time zone and
published as one response, `GET /api/measurements/blood-pressure/time-of-day`,
so every client renders the same numbers. Morning is 04:00 to 11:59,
midday 12:00 to 17:59, evening 18:00 to 03:59: the guidelines define the
sets by waking and bedtime rather than by the clock, so the windows are
wide, and a reading after midnight is a late evening one. Pairs follow
the blood-pressure series' rule (same source within five minutes); the
pulse is the reading's own. A part shows a mean from three readings on.

`GET /api/measurements` takes `partOfDay` on the blood-pressure and pulse
series read: each local day folds only that part's readings, a day runs
from 04:00, and long ranges fold weekly instead of reading rollups that
cannot tell the hour.

Refs #1331
2026-11-04 00:12:49 +01:00
Marc-André Bombeck b2fb295d43 Merge pull request #1322 from l1137l/feat/ru-locale 2026-11-03 23:50:35 +01:00
Marc-André Bombeck 3e2e2ad56b Merge branch 'pr-1322' into review/pr-1322 2026-11-03 23:37:22 +01:00
Marc-André Bombeck a80fcf5276 docs: name the per-database role and describe the owner-owned queue schema 2026-11-03 23:31:38 +01:00
Marc-André Bombeck 7cb67c2e75 Merge branch 'fix/v147-nightly' into release/v1.47.0 2026-11-03 22:30:50 +01:00
Marc-André Bombeck b1cb387062 Merge branch 'fix/v147-pdf-unrenderable' into release/v1.47.0
# Conflicts:
#	.env.production.example
2026-11-03 22:10:14 +01:00
Marc-André Bombeck df6d20b696 fix(documents): read a PDF the server cannot render by its text layer
On a host whose CPU cannot run the page renderer (x64 without AVX2, arm64
musl without LSE/RCpc), every AI read of a PDF for a provider that only
takes images failed with "Scanning PDFs needs a Claude vision provider",
which says nothing about the cause. Most medical PDFs are produced
digitally and carry every word in their text layer, which pdf.js reads
without rendering.

`prepareVisionInput` now answers a text read (`mode: "text"`) when the
renderer is unavailable on this host, the provider cannot take the PDF
whole and the PDF has a usable text layer. Summary, suggest and the
background summary send that text through their existing text mode;
"Read with AI" and the index backfill write it as the local text index it
is, with no provider call; the automatic index leaves it to its local path.
`planVisionReads` does the same for the extraction and the lab scan: text
reads by page group, with anchored provenance, and the pages without a text
layer reported as `extractionIncomplete` on a document and as `unreadPages`
on a lab scan. A supplement label PDF is read from its text layer too.

A scan on such a host now answers `documents.inbound.pdfRendererUnavailable`
and `labs.ocr.pdfRendererUnavailable` (422): this server's processor cannot
run the page renderer, so a scanned PDF can only be read by a provider that
takes PDF files directly, or as photos. The `pdfNeedsAnthropic` codes are no
longer emitted, since with a working renderer every provider reads a PDF;
the recorded index outcome keeps its `pdf-needs-anthropic` value with
corrected copy.

The extraction path no longer folds a render failure of one document into
the capability error: like `prepareVisionInput` since #776 it reports
"The PDF pages couldn't be rendered for scanning." (`extractFailed`), and
the lab scan answers `labs.ocr.extractFailed`.

Hosts that render, and providers that take PDFs whole, are unchanged.

Refs #1311
2026-11-03 22:07:22 +01:00
Marc-André Bombeck a503c8855d Merge branch 'feat/v147-wolfi' into release/v1.47.0 2026-11-03 21:56:23 +01:00
Marc-André Bombeck 5d36afb487 build: run the image on a Wolfi runner
The dependency and build stages stay on node:22-trixie-slim, where pnpm
picks the glibc native packages. The runner moves to wolfi-base, pinned
by its index digest: glibc, about fifty packages, and every one of them
known to the image scan, Node and the PostgreSQL client included.

nodejs-22, tzdata and postgresql-18-client come from apk, so the pgclient
stage and its pin guard go. The builder writes its Node module ABI and
glibc version out and the runner refuses to build on another ABI or an
older glibc. Wolfi's npm is major 12 and runs no install scripts unless
package.json allows them, so the Prisma CLI install names the three
packages that have one, and the build checks that the schema engine file
is there before asking the CLI about it. npm is installed for that step
and removed in the same layer; the check that nothing is left asks a
fresh shell, whose command cache cannot remember the deleted binary.
2026-11-03 21:55:30 +01:00
Marc-André Bombeck 713806fe9b test(parity): keep each route walk in order under a shuffled run
A parity file is one account walked through a route's cases, each
request reading what the earlier ones stored, and the recorded files
pin that sequence. The nightly shuffle reordered the steps and every
snapshot after the first moved. Keep each walk in order with
shuffle: false, wrapping the files whose top-level blocks build on each
other, and note the rule for order-bound files in the testing guide.
2026-11-03 21:35:56 +01:00
Marc-André Bombeck 899a054e64 Merge branch 'feat/v147-mcp-reading-id' into release/v1.47.0 2026-11-03 21:33:50 +01:00
Marc-André Bombeck 66709222b2 docs(api): describe the lab reading id next to the analyte id 2026-11-03 21:33:18 +01:00