Commit Graph
5881 Commits
Author SHA1 Message Date
Marc-André Bombeck 6f514bfc7d Describe the API spec as generated and complete the MCP catalogue
- api/README: the YAML is emitted from the Zod registry by
  pnpm openapi:generate with a CI drift check; document the real
  servers, tags, and coverage (client-facing REST, most admin routes
  and the OAuth/webhook surfaces excluded) and replace the hand-edit
  workflow with the generator workflow
- mcp-capabilities: add the registered get_intraday_pulse,
  get_ecg_recordings and get_nutrients tools and the
  nutrient/{code} resource template to the tables
2026-08-27 12:28:05 +02:00
Marc-André Bombeck b2ee7f11d1 True up the integration references
- ai-providers: /api/ai/test returns provider type, model, token usage
  and a sample with a reasonCode on failure; drop drifted line numbers
- apple-health: source-priority overrides moved to /settings/sources;
  cite the constant instead of line numbers
- data-import: four import paths (medication dose history added); ECG
  and metric-typed clinical records are imported; measurement
  externalId documented; CSV re-uploads dedupe on the natural key;
  mood loggedAt defaults to the start of the given date; the type
  table is a curated subset with /api/meta/capabilities authoritative
- fitbit: the source-priority ladder ships, FITBIT is ranked in every
  default ladder
- google-health: Sources is its own settings section, not nested under
  Integrations
- withings: disconnect unsubscribes the webhook and deletes stored
  tokens without revoking the Withings-side grant; the connection test
  is self-serve, not admin-only; the safety-net cron is hourly;
  refresh stale references
2026-08-27 12:26:08 +02:00
Marc-André Bombeck 123fcc4ec3 Refresh the docs index and reference pages
- docs/README.md: drop the doctor-report.md and audit/ entries (neither
  exists), broaden the ops/ description, list the real subdirectories,
  and stop promising release-by-release migration notes
- migration/v1.3-to-v1.4: note which v1.4 routes have since moved or
  gone; rotation runs via pnpm dlx tsx
- ui-guidelines: six locales with guard tests, settings sections point
  at the registry instead of an eight-item list, /settings redirect is
  a 308
- apple-store-connect-checklist: drop the unsourced 40% rejection-rate
  figure
- codex-protocol-spec: the slug chain rotated on 2026-06-02; default is
  gpt-5.5, the shipped constant is authoritative
2026-08-27 12:23:37 +02:00
Marc-André Bombeck fa7e1ee739 Bring the self-hosting guides in line with the shipped code
- geolite2: quote the real commented compose mount (./geolite2 to
  /opt/geolite2, the GEOLITE2_DIR default, so no env var is needed for
  the default path)
- getting-started: healthcheck probes /api/health, not /api/version;
  the environment module reaches archive-api and geocoding-api
  .open-meteo.com, relevant for egress allowlists
- mcp: the module is opt-in and off by default, but no longer the only
  such module
- reverse-proxy: the Coach stream is SSE over plain HTTP, not
  WebSockets; note that TRUST_PROXY_HOPS is not on the bundled compose
  whitelist and must be added by hand
- scaling: /api/health healthcheck; worker liveness is in-memory and
  invisible across a web/worker split; pool sizing now goes through
  getConnectionBudget with DB_CONNECTION_LIMIT as the compose knob and
  a 2-connection pg-boss share
- sso: the native SSO handoff shipped, OIDC_ONLY works with app builds
  that carry it
- unraid: geo databases are baked in only when the build had a licence
  key, or mounted; replaceable either way
2026-08-27 12:21:27 +02:00
Marc-André Bombeck c986efab30 Correct operator runbooks against the current code
- attempt-number-semantics: name the real bucket column
  (consecutiveFailuresByKind) and drop a drifted line citation
- backup-restore: show the smoke-test response inside the standard
  { data, error } envelope
- deploy: the manual migration step now uses the bundled Prisma CLI
  via node (the image strips pnpm); WHOOP vars are already on the
  shipped compose whitelist
- encryption-key-rotation: point at the encrypted-column registry
  instead of a five-model list several releases behind
- env-check: the CI gate shipped (env-check.yml); the partial-APNs
  example now shows the synthetic all-or-none row and the non-zero exit
- password-reset: the admin API reset route exists; only the
  self-service route does not
- tls-cert-pin: stop asserting client-repo pin-set details that cannot
  be audited from this repository
- drop v141-followup-issues.md: a v1.4.1 punch list whose items are
  fixed, removed, or superseded; keeping it under docs/ops invites
  re-fixing shipped work
2026-08-27 12:18:34 +02:00
Marc-André Bombeck 7f407797f4 Merge pull request #844 from MBombeck/release/v1.37.31
release: v1.37.31
v1.37.31
2026-08-27 11:45:07 +02:00
Marc-André Bombeck a0bbf0bf46 chore(release): v1.37.31 — the retry ledger gets an operator's window 2026-08-27 11:27:07 +02:00
Marc-André Bombeck 6c4682acfe Give the operator a provider-health readout
The per-user retry ledger has recorded every AI delivery outcome for a
long time, but no surface ever read it for the operator: a central
provider could fail on every call for weeks and the admin console said
nothing. The AI settings section now carries a health card, backed by a
new admin-only endpoint that folds the ledger into one row per provider
type: users tracked, users currently failing, the worst uninterrupted
failure run, and the last success and failure instants. Operator-managed
types sort first because their failure takes the shared fallback down
for everyone whose chain relies on it. Counts and timestamps only; the
response never names a user.
2026-08-27 11:26:40 +02:00
Marc-André Bombeck 1d7bd82ca8 Merge pull request #843 from MBombeck/release/v1.37.30
release: v1.37.30
v1.37.30
2026-08-27 09:15:44 +02:00
Marc-André Bombeck 7aecae863d Teach the pin inventory the conditional openai-client shape
The SSRF pin inventory asserted a literal unconditional pin in the
openai-client; the previous commit made that pin conditional for the
two person-typed base-URL tags. The guard now asserts the exact
ternary — allowlist helper for gateway/admin-key, literal true as the
floor — so neither the floor nor the condition can drift silently.
2026-08-27 08:56:53 +02:00
Marc-André Bombeck 96d6f64ee6 chore(release): v1.37.30 — the operator provider may cross into its own network 2026-08-27 08:49:49 +02:00
Marc-André Bombeck fb82954077 Let the operator provider reach an allowlisted private host
The global admin provider's base URL is operator-typed in the admin
settings, but the client pinned requirePublicHost unconditionally for
its tag — so an operator pointing it at a private OpenAI-compatible
proxy (an OAuth sidecar on the instance's own Docker network) got a
safeFetch refusal on every call, indistinguishable from an outage. The
admin-key tag now consults the same ALLOW_LOCAL_AI_PRIVATE_HOSTS
policy as the user-configured gateway: a public URL never consults the
allowlist, a private host without an entry is refused exactly as
before, and both ends of the grant are the operator's own. The codex
tag stays hard-pinned; its base URL is a repository constant. Every
non-operator admin-key construction pins the public api.openai.com
constant, so the personal-key posture is unchanged.

The test asserting the old blanket pin is split into the three cases
that actually differ: allowlisted private host passes, unlisted
private host still refuses, codex refuses even a blanket allowlist.
2026-08-27 08:48:00 +02:00
Marc-André Bombeck d5d9793586 Write down the client-development contract
A second native client is starting, and the rules that keep the first
one aligned with the server lived in issue threads and habit. They are
now docs/CONTRIBUTING-CLIENTS.md: where the contract truth lives, the
response envelope, auth from a client's point of view, sync semantics,
the server-authoritative-values rule, and how cross-repo coordination
works in practice.

CONTRIBUTING-AI.md was a snapshot of an old conventions document and
had drifted well away from the codebase it describes (it still spoke
of 26 Prisma models and in-memory rate limits). It now points at the
living sources instead of carrying an aging copy.
2026-08-26 21:08:14 +02:00
Marc-André Bombeck 15fe030784 Record the openssl patch under v1.37.29 2026-08-26 20:27:52 +02:00
Marc-André Bombeck d489d29cab Merge pull request #842 from MBombeck/fix/openssl-cve-2026-14456
Patch OS packages in the runtime image at build time
v1.37.29
2026-08-26 19:56:35 +02:00
Marc-André Bombeck 4ce0e6c5ce Patch OS packages in the runtime image at build time
Trivy flags CVE-2026-14456 (openssl denial of service via unbounded
memory growth in the QUIC server) in the runner stage: the pinned
node:22-alpine digest ships libssl3/libcrypto3 3.5.7-r0 and the fix is
published as 3.5.8-r0 in the Alpine repo. An apk upgrade in the runner
stage picks up published OS security fixes without giving up the digest
pin, closing the window between a CVE landing in the secdb and the next
base-image rebuild.
2026-08-26 19:39:51 +02:00
Marc-André Bombeck 898a5a95f3 Merge pull request #840 from MBombeck/release/v1.37.29
release: v1.37.29
2026-08-26 19:18:37 +02:00
Marc-André Bombeck e7395398ca Anchor the a11y weight fixture on now
The a11y matrix feeds the weight sub-page chart ten rows with absolute
July dates. The range tabs are calendar-day windows anchored on now, so
those rows aged out of the default window, the page rendered its empty
state, and the .recharts-wrapper painted gate timed out on every shard
and theme. Relative dates keep the fixture inside the window on any
date, same as the charts-mobile spec already does.
2026-08-26 18:49:59 +02:00
Marc-André Bombeck 0e6fcaa2fc Smooth a parenthetical in the release notes 2026-08-26 18:01:08 +02:00
Marc-André Bombeck 14b8b5ac27 chore(release): v1.37.29 — the range tabs say days because they are days 2026-08-26 18:00:12 +02:00
Marc-André Bombeck 240c15ff15 Label the chart range tabs as the day windows they are
The range selector said points — 7 pts, Last 7 data points — while the
fetch has always treated the value as a calendar-day window. Someone
who weighs in twice a week picked 7 pts and saw two, and read that as
a broken chart; the labels were the broken part. The tabs now say 7d /
30d / 90d / All across all six locales.

Behaviour is unified on the same reading everywhere. The measurement
chart's visible slice becomes a real day-window filter — the old
slice(-N) was a no-op on the self-fetched path but silently meant
last N readings on the dashboard's preloaded batch slice, so the same
tab meant two different things depending on the mount path. The mood
chart sliced the last N logged days out of the whole history and now
windows by days like its siblings. The header chip reads the same
grain decision the chart and the data table use instead of re-deriving
it in a second memo.

Component fixtures move to now-anchored dates: the windows are anchored
on now, so absolute-dated fixtures age out of the window and would have
exercised only the empty states. The span-dependent cases (weekly chip,
bucketed-caption table) mount the All range, which is now the only
range that can span past 90 days.
2026-08-26 17:55:56 +02:00
Marc-André Bombeck 0823a4e222 Refine the tiered rollup read by the actual data span
The tier for a chart series keyed off the requested window width. The
All range always requests ~3650 days, so every account got MONTH
buckets regardless of how much history exists — a record spanning four
months came back as four monthly means, and the chart captioned them
from the real span as a weekly average. The reader now re-reads at the
tier the returned rows' span calls for; a coverage miss on the finer
tier keeps the coarser rows.

The ladder comment claimed to mirror the client's pickBucket
thresholds and misquoted them. The two ladders are deliberately not
identical; a test now pins the direction that keeps the caption honest
(server never coarser than the client captions) for every span up to
ten years.
2026-08-26 17:55:43 +02:00
Marc-André Bombeck 3ce5411d19 Merge pull request #838 from MBombeck/release/v1.37.28
v1.37.28 — the parts of a shared record nobody could see
v1.37.28
2026-08-24 03:27:56 +02:00
Marc-André Bombeck 7a4e09bb4c chore(release): v1.37.28 — the parts of a shared record nobody could see 2026-08-24 02:58:23 +02:00
Marc-André Bombeck 5808f8f91a Merge branch 'fix/share-viewer-leaves' into release/v1.37.28 2026-08-24 02:57:12 +02:00
Marc-André Bombeck e417376d29 Merge branch 'feat/derived-window-days' into release/v1.37.28 2026-08-24 02:57:12 +02:00
Marc-André Bombeck d7d8e437ee feat(insights): let a caller choose the derived-metric window
`GET /api/insights/derived` accepts `windowDays`, bounded to 1..90, and
threads it to the dispatcher. Omitting it changes nothing: every engine
keeps its own default, so a caller that asks for nothing reads exactly
what it read before.

Ninety is where the tier this route promises runs out. The rollup router
opens its WEEK floor at 91 days, and the baseline engines cannot compose
a spread from WEEK buckets, so a 91-day request falls through to a raw
`measurement.findMany` with no row cap — for a densely sampled type that
is a full history scan, and the analytics-read budget allows 120 of them
a minute per account. The trailing `series` is capped at 30 points
regardless, so past the ceiling a wider window would only broaden the
mean behind the value. Out of range is a 422 rather than a clamp.

An unhonoured window is visible rather than silent. `provenance.windowDays`
is the window the engine actually used and `coverage.historyDays` the days
that actually backed it, so a three-week record answering a thirty-day
request reports both numbers instead of implying coverage it does not
have. `HEALTH_SCORE` composes fixed per-pillar windows and reports the
widest; a new suite freezes that it is the only metric that does, so an
arm added later cannot quietly drop the parameter.

Recovery keeps its canonical resolution under the wider window. A worn
band stamps the wake morning and the computed proxy the night before, and
only `resolveCanonicalRecovery` pairs them; a test seeds forty such
nights, asks for sixty days, and holds the answer to forty nights with a
flat trend — a bypass leaves eighty rows and a false climb.

Contract: model `WellnessScoreValue` field for field and publish it. The
envelope's `value` has to stay an open record because one route serves
eighteen differently-shaped metrics, so nothing can `$ref` the shape and
it went out undocumented — `series`, `daysInWindow` and `asOf` were on
the wire with no way to find them short of inspecting a live response. It
is registered in the forced-components slot instead, held against the
runtime interface by a test, and the fields it does not cover are named
in the schema comment so the remaining gap is known rather than invisible.
2026-08-24 02:00:55 +02:00
Marc-André Bombeck 96bcf843fe test(share): render the clinician view in all six locales
Most labels on this page are resolved from a runtime value, not written
as a literal: an illness type, a lifecycle, a visit kind, a vaccine slug,
a blood-type constant, a cycle phase, a GLP-1 side-effect tag. The
call-site guard cannot see any of them, and the enum-derived guard can
only cover a space whose members it can enumerate from a source. A key
that does not exist therefore renders as its own dot notation, in front
of a doctor, with every other guard green — which is exactly how
`encounters.kind.ROUTINE` reached three surfaces.

One fixture with every section populated, six renders, one assertion:
nothing in the reader-visible text may look like a dotted key path.

Break-proof: put the interpolated visit-kind key back and all six go red
naming it.
2026-08-24 01:34:32 +02:00
Marc-André Bombeck a7452c7a6c feat(share): name both reference windows on a diverging lab row
A reading judged against the window its own report printed, beside a
saved band that states different limits, is two answers about one
number. The PDF names both — the range in the column, the saved band in
a footnote under the table — and says in as many words that showing one
of them is a partial answer. The page carried only the first.

Both fit on the value line here, so there is no footnote to place.
Break-proof: force the divergence branch off and the case goes red.
2026-08-24 01:31:05 +02:00
Marc-André Bombeck b59a78155b docs(api): publish the share link's PDF and FHIR downloads
Both routes were exempt from the contract as `shareLink` — "authenticated
by the URL credential, for a recipient with no account". That reason
holds for the page a person opens. It does not hold for two routes whose
whole purpose is to hand a practice a file it files into another system,
which is exactly the audience a contract has. `/c/{token}/d/{id}` was
published all along, one route away in the same tree.

Each entry states the gate it shares with the page, the frozen selection
it can never exceed, the flat 404 that covers every miss class including
a documents-only link, and the 20/h per-link bucket. The FHIR entry says
plainly that it is a download and not a REST face, and names the two
resource families the bundle deliberately omits.

The create-request description claimed "there is no FHIR or other
machine-readable face behind a share token", which stopped being true
when these routes landed. It now describes what a link actually serves
and records that the insurance leaf is refused outright.

The coverage guard gains the other half of its staleness check. It caught
an exemption for a route that had disappeared, and never the reverse — an
exemption left behind after the route was published, which is how these
two would have stayed on the list while the contract already carried
them. Break-proof: re-add either entry and it goes red naming it.
2026-08-24 01:10:24 +02:00
Marc-André Bombeck ac6fd187d6 test(share): pin every catalogue leaf to a clinician-view renderer
Eleven leaves stayed invisible for eleven releases because nothing
connected the two lists. The picker-to-aggregator guard was satisfied and
kept being satisfied; the page was not on either end of it.

`DISPOSITIONS` is `Record<StructuredLeafId, …>`, so an eighteenth
structured leaf fails `pnpm typecheck` until someone writes down whether
it renders or why it cannot. Per leaf the suite then renders the real
view over a real fixture and asserts the marker appears; re-renders with
that leaf alone removed from the frozen selection, payload untouched, and
asserts it is gone; and for a refusal, reads the list that enforces it
rather than trusting the note.

Once over the whole set: a carried leaf with nothing behind it says so, a
leaf whose module is off says something else, and a leaf the link never
carried says nothing at all.

The `share-downloads` row is asserted here too. Its test id had zero
references anywhere in the tree, so the two buttons a practice clicks
could have lost a link, pointed at the wrong path, or vanished from a
documents-only share unnoticed.

Break-proofs, each confirmed red then restored: drop a section element
(that leaf's render case); gate `LeafSection` on data instead of scope
(thirteen withholding cases); collapse the switched-off arm (the
distinction case); empty `SHARE_LINK_FORBIDDEN_LEAVES` (the refusal
case); add an eighteenth leaf (typecheck).
2026-08-24 01:07:14 +02:00
Marc-André Bombeck 7a9aa141a6 feat(share): render the record sections the clinician view withheld
Someone ticks "Lab values" in the selection, sends the link, and the
doctor opens a page that says nothing about lab values — while the PDF
download from the same link prints them. Eleven of the seventeen
structured leaves were like that: the control existed, the data reached
the page object, and no component read it. The person had every reason
to believe they had shared something they had not.

Rendered, in catalogue-group order, which is also the PDF's:

  - emergency information, framed and first, as it is page one there;
  - personal details (name, date of birth, gender, height);
  - lab values, with the reference window each reading was judged
    against, printed as the source report printed it;
  - GLP-1 therapy and the logged-dose ledger;
  - conditions and illnesses, visits, immunizations;
  - family history, mood, the menstrual cycle.

What each shows follows the PDF, which has already settled what belongs
in a section and in what order. The layout does not: a six-column table
becomes a labelled block, a four-column one becomes a row with its
detail composed onto the value side.

Absence is stated, never implied. A leaf the link does not carry renders
nothing at all — the recipient was never promised it. A leaf on the link
with nothing behind it renders its heading and says so, and one whose
domain is switched off on the owner's account says that instead. This
extends what the health-profile section has always done per fact to the
section level, and the glucose, medication and allergy cards adopt it
too; the measurement groups deliberately stay silent, because a group
card stands for up to seventeen leaves and twelve empty ones is noise.

Every card now asks the frozen selection directly through `LeafScope`
rather than inferring consent from the presence of data. The aggregator
already applies the selection, but a section that reads only "is there
data" trusts a gate it cannot see.

Three rows on the emergency card are composed from other leaves — severe
allergies, the drug list, chronic conditions. Each appears only when its
own leaf is on the link: printing "Not recorded" for a leaf the owner
withheld states an absence in the record where the truth is an absence
in the share, and on that card it is the dangerous direction to get
wrong.

The glucose card's heading was "Lab values", which was the wrong name
for it even before the actual lab results arrived; it is the glucose
group's own label now.

`INSURANCE` gets no renderer and must not get one: the create route
refuses the leaf outright, so no link can carry it.
2026-08-24 01:02:50 +02:00
Marc-André Bombeck fbe2081506 feat(share): carry the owner's module verdict beside the payload
A leaf on a share link can be absent from the payload for two reasons
that look identical downstream: the owner shared it and recorded
nothing, or they shared it and the domain is switched off on their
account. The aggregator ANDs the selection and the module map and
returns the same null either way, so the recipient could not tell an
empty section from one that never had a chance to carry anything.

`loadShareViewData` now resolves the whole module map — it needed the
`doctorReport` key from it anyway — and returns `unavailableLeaves`: the
leaves the link DOES carry whose owning module is off. Only selected
leaves appear, because a withheld leaf's module state is not the
recipient's business in any direction.

The map is deliberately NOT handed to the aggregator. The third argument
is the frozen selection and there is no fourth, which is what keeps this
surface from growing an options object that widens what it asks for; the
resolver memoises its reads per request, so the aggregator resolving its
own map again costs no round-trip.

The module-gate suite moves off `isModuleEnabled` with it, and gains
three cases for the new verdict, including the one that proves an
unshared leaf is never named.
2026-08-24 01:02:33 +02:00
Marc-André Bombeck 29ab274775 fix(i18n): name a visit kind instead of printing its enum
Three surfaces built the label key by interpolation — the doctor-report
PDF's visit table, the daily digest's upcoming-visit line and the
clinician share view. `EncounterKind` is `ROUTINE` and the bundle leaf is
`routine`, so all three rendered `encounters.kind.ROUTINE` verbatim: into
a document a practice files, onto a lock screen, and onto the page.

Route them through `encounterKindLabelKey`, a literal switch beside the
existing `encounterKindLabel`, so the mapping is written down once.

The enum-derived i18n guard could not have caught this: it only knows
key spaces of the form `prefix + member`, and this one is not. It gains a
second arm for spaces whose mapping is a function, with `encounters.kind`
as its first entry. Break-proof: point one arm of the resolver back at
the uppercase key and six locale cases go red.
2026-08-24 01:02:20 +02:00
Marc-André Bombeck d290422923 Merge pull request #837 from MBombeck/release/v1.37.27
v1.37.27 — the checks that were green because they were not checking
v1.37.27
2026-08-23 21:17:51 +02:00
Marc-André Bombeck 7f708a4bcb docs(changelog): record the shell's eager settings-schema import 2026-08-23 21:00:00 +02:00
Marc-André Bombeck 92b8040796 perf(shell): import the settings-destination predicate from its leaf
The authenticated shell wraps every route and read one destination
predicate off the `@/lib/record-settings` barrel. That barrel also
re-exports the managed settings-patch schemas, so Zod, the module
registry and three validation modules rode into the eager client
bundle of every authenticated page for a function that inspects a URL
slug. The shell imports `record-settings/classification` now; the
barrel loses the re-export nobody else used.

Measured over all 585 client-rendered routes: none grew, 100 fell, by
4.7 KB gz at the median and 8.1 KB at the most. /insights had been
sitting on its 445 KB ceiling for three releases and went over it on
this branch's CI run; it measures 437 now, /measurements 435, / 450,
/insights/mood 450.

The whole-build sum moved the other way, 3451 to 3476, because the
aggregate counts each emitted chunk once: a graph that leaves the
chunk group every route shares gets counted again wherever it lands,
here inside the deferred settings chunks that use it. Bytes on disk
rose, bytes a reader downloads only fell. The aggregate cap moves to
3500 with the same 24 KB headroom the four earlier re-measurements
kept, and the reasoning sits next to the number.
2026-08-23 20:56:13 +02:00
Marc-André Bombeck 5803d16e7d chore(release): v1.37.27 — the checks that were green because they were not checking 2026-08-23 19:44:01 +02:00
Marc-André Bombeck 1d733ef192 Let a German verdict reach the classifier the merge gave it
The two halves of the ECG fix were built apart. One taught the parser
Apple's full English verdict set; the other taught it the metadata keys and
verdict wording a German watch writes. Each was correct alone, and each
left "Sinusrhythmus" unresolved for a different reason.

Together they meet: the alias table translates the wording onto Apple's
own English, and the classifier decides what it means. One place still
owns the meaning, which is what stops a per-language verdict table from
drifting the first time Apple adds a case.

The test that expected null was pinning the gap between the branches
rather than a decision about German. It now expects NOT_DETECTED, and a
French verdict takes over the null case, because four of the six
languages genuinely have no observed key map yet.
2026-08-23 19:42:45 +02:00
Marc-André Bombeck f4695457b1 Merge branch 'fix/ecg-csv-real-exports' into release/v1.37.27
# Conflicts:
#	src/lib/apple-health/ecg-csv.ts
2026-08-23 19:39:36 +02:00
Marc-André Bombeck b154ec52cb Merge branch 'fix/language-matchers-rest' into release/v1.37.27 2026-08-23 19:38:45 +02:00
Marc-André Bombeck 31991924f0 Merge branch 'chore/knip-dead-exports' into release/v1.37.27 2026-08-23 19:38:45 +02:00
Marc-André Bombeck b0d7705491 Merge branch 'fix/data-envelope' into release/v1.37.27 2026-08-23 19:38:44 +02:00
Marc-André Bombeck dbee6b7429 Merge branch 'fix/discovery-matrix' into release/v1.37.27 2026-08-23 19:38:44 +02:00
Marc-André Bombeck 8bf3c53d7d fix(apple-health): read an ECG export whose metadata keys are translated
The CSV is written in the device's language, and the keys are translated
along with the values: a German watch writes `Aufzeichnungsdatum` and
`Klassifizierung`, not `Recorded Date` and `Classification`. Indexing on
the English keys found none of them, so the parser never located the
waveform and rejected the recording outright — a lost import, not a
missing field.

Two alias maps close it, both taken from real exports rather than from a
dictionary: nine metadata keys and two verdicts, consistent across every
German file observed. Adding a language is a data addition to those maps
and nothing else.

The verdict map is an indirection onto the English wording rather than a
second verdict-to-enum table, so there stays exactly one place that
decides what a verdict means.

The other four shipped languages are absent on purpose. A wrong key
silently mis-files a value and a wrong clinical verdict is worse than
none, so an export this parser cannot place is refused — which an operator
can see — rather than read into the wrong column, which they cannot. Two
gaps are written down at the map: no observed file carries an
average-heart-rate row in any language, and the paired layout's column
header has only ever been seen in English.
2026-08-23 19:17:02 +02:00
Marc-André Bombeck 29bad0a2b8 fix(apple-health): read the sampling rate as real exports spell it
The rate matcher demanded "Hz". Observed exports spell the unit out and
lower-case it — "511,422 hertz" — and the German ones separate it from the
number with a no-break space. The fixtures had only ever used "512 Hz", so
the mismatch never showed.

Rate and heart rate now split into a magnitude and a unit word, with the
magnitude read through the same decimal helper the waveform uses. "512 Hz",
"512 hertz" and "511,562 Hertz" all resolve; a missing unit, the wrong
unit, a grouped number and a rate past the bound are all still refused.

The unit word is matched loosely and the magnitude strictly. That is the
right way round: the unit is presentation and varies by region, while the
number is the measurement.
2026-08-23 19:17:02 +02:00
Marc-André Bombeck a85c6681bb fix(apple-health): read an ECG waveform written with decimal commas
A watch set to a comma-decimal region writes its samples as "-180,596".
The single-column reader rejected any row containing a comma outright, so
every one of those files failed on its first sample row.

The two waveform layouts disagree about what a comma means, which is why
this cannot be decided per line. The parser now names the section it is in
— header, single-column, paired — and both crossings are commented at the
point they happen: the paired layout announces itself with a `Lead,Voltage`
column header, while the single-column one begins at the first blank line
AFTER Lead and Unit have been declared, because real exports also carry
blank lines mid-header.

Scalars in both waveforms now read either dialect through one helper, so
"-180.596" and "-180,596" give the same microvolts and neither is
privileged. A number carrying thousands grouping is refused rather than
guessed at, since resolving it needs the file's region and the file never
states it. The one genuinely ambiguous shape is a separator followed by
exactly three digits; it is read as a decimal, because observed waveforms
put two- and three-digit fractions in the same file and grouping is always
three, and because a machine writing one bare value per row has no reason
to group at all.
2026-08-23 19:17:02 +02:00
Marc-André Bombeck 19699ce8ce fix(apple-health): accept the valueless header row an ECG export opens with
Every real export begins with a bare `Name` when the name field is blank —
Apple omits the trailing comma rather than writing an empty value. The
parser required a comma on every header row, so it rejected the file on
line 1, before it had read anything.

A valueless header row is now skipped. It contributes no metadata entry
rather than an empty-string one, because `Lead` and `Unit` presence is what
switches the parser into the single-column waveform mode and a key with no
value must not be able to trip that switch.

The relaxation is confined to the header. Inside the single-column
waveform a comma-less row is a sample and is read as one; inside the
paired waveform every row is lead and voltage, so a missing comma there is
still malformed.
2026-08-23 19:17:02 +02:00
Marc-André Bombeck 03293bc2eb Record what the dead-code gate was blind to before knip 6.28.0
ignoreExportsUsedInFile did not only exempt exports used inside their own
module; until 6.28.0 it also marked every re-export as referenced in its
own file, so a barrel line nothing imported was never reported. The
upgrade took this repo from 0 findings to 206 on unchanged code. Write it
down next to the setting, because a green gate on any earlier knip proved
nothing about re-exports.

Also correct the production-pass file count, which had drifted to 21.
2026-08-23 19:11:12 +02:00
Marc-André Bombeck 949677008f Drop the type imports left behind by the extract-prompt re-export
CadenceKind and WeekdayToken were imported only to be re-exported. The
re-export went in the previous commit but the import survived, because
the doc comment above it still named both types at the time.
2026-08-23 19:11:12 +02:00