Commit Graph
108 Commits
Author SHA1 Message Date
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 13e232c836 test(garmin): pin the key exchange, the mapping and a sync against a fake Garmin
Unit tests cover the token cache, the single re-exchange on a refused
token, the key's end read from a capped token, the 429 wait, the range
chunking, and every resource's mapping over made-up records shaped from
Garmin's document. The card test pins the shared anatomy, the menu and
the expiry sentences. The integration test runs the sync, the backfill,
connect and disconnect against a fake Garmin served over HTTP and the
real schema: rows per resource under GARMIN, no second row on a re-sync,
a refused key parked after one retry, a 429 that stops the run, a
history walked to its start across slices, and a purge on disconnect
that keeps the imported history.
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
l1137l 48bfbad4e6 fix(i18n): address the ru review — units, Cyrillic regexes, locale registry
Dose bank
- DRUG_UNIT learns мг / мкг / мл / ЕД / МЕ, so "2,4 мг" anchors a dose again.
- The bank matches verb-first Russian word order too ("принимайте 2 таблетки",
  "пропустите дозу метформина", "начните с 5 мг"), and the Cyrillic drug names
  people actually see on the box; the deferral words ("спросите врача",
  "как назначено") stand those shapes down.

Cyrillic-safe regexes
- \w and \b are ASCII in JavaScript: every stem in the ru bank and in
  population-norms/ru.ts is [а-яё]*, and the word edges are lookarounds
  (?<![а-яёa-z]), so «высокий риск» and «нормальный диапазон» match again.
- coach-prose-grounding: Russian month names in the genitive, a space as the
  thousands separator in parseLocaleNumber and numFrag, and Cyrillic-safe
  edges around the written-date pattern.

Registered everywhere a locale has to be
- targetLanguageName() returns "Russian", so the coach, the period narrative
  and the document descriptions stop saying "write in English".
- i18n-key-tool.mjs, split-description.mjs, LOINC name normalisation (it
  stripped Cyrillic, so no Russian lab name resolved), HOST_TLDS (ru/by/kz/ua),
  no-key-fallbacks (32 tables), country-label, the AUDIT-C standard drink.
- docs/api/openapi.yaml regenerated.

Questionnaires
- The PHQ-9 / GAD-7 source comments no longer claim no validated Russian
  version exists (Zolotareva 2023; Pfizer's official screeners).
- WHO-5 items are gender-neutral: «чувствовал(а)».
- "body fat" and "fat mass" both read «Жировая масса»; body fat is now
  «Процент жировой массы», which is what the two metrics measure.

Tests
- ru in the 13 locale tables, plus mood, onboarding, instruments, clinician
  view, status summary, describe screen, citation coverage and LOINC.
- i18n-no-dot-separator: the ru bundle is exempt from the dash rule (a spaced
  dash is Russian punctuation, not a fragment join); the middle dot still
  fails there.
- Two 800-line baselines raised by the 12 and 5 lines this change adds.

pnpm typecheck, pnpm openapi:check and the full suite (36 316 tests) pass.
2026-11-02 20:22:03 +00:00
Marc-André Bombeck 72d90282d5 fix(settings): open the docs pages themselves, not their old addresses
The integration runbooks, the Garmin note, the guardian note on a managed
record's connections, the backups admin link and the README's self-hosting
link all pointed at retired docs paths that only resolve through redirects
(or not at all, on a host without them). They now go to the final guide
pages, and the settings tests assert the new addresses.
2026-10-25 12:12:15 +01:00
Marc-André Bombeck d8e4cf6000 fix(url): write page state into the query string through one serialised helper
Next 16.4 adopts a native history write a render later. A second write
inside that gap was undone when the router adopted the first, and a
write computed from useSearchParams started from the URL before the
last one. Clearing the vault's filters could therefore leave
?encounter= behind: the debounced search wrote the filters it had
rendered back over the clear.

@/lib/url-state is now the one way a page writes its own query: each
write starts from the live address bar, at most one waits for the
router at a time, and later updates queue on top of it and go together
once the router has it (or after a short grace). useUrlSearch reads the
same live value and reports each adoption. The filter hook (documents,
measurements, mood), the open document, the day layer, the timeline's
zoom and range, the medication tabs, the AI provider choice, the
Coach's ?c= and the one-shot parameter strips all go through it; the
filter changes merge into the live filters. A guard refuses raw
history writes and router writes built from the rendered params
outside the helper.

The timeline spec now waits for the values menu to hand focus back
before scrolling to the last row; it measured mid-animation and the
focus return scrolled the page back.
2026-10-23 02:04:21 +02:00
Marc-André Bombeck 764794496c feat(i18n): register Brazilian Portuguese as a locale
Add pt-BR to the locale list and everything that hangs off it: the
client loader, Intl and date-fns mapping, the reply-language footers of
the Coach and insights prompts (English body, pt-BR footer, the same
path Korean and Dutch take), the citation-coverage normative words, the
no-key fallback copy, the AUDIT-C standard-drink line, country labels,
period search, CSV yes/no cells and the Telegram bare-word aliases.

The locale enums in the extract, managed-profile and record-settings
schemas now derive from the locale list instead of repeating it, and
the five notification and login-alert routes share one isLocale guard
instead of hand-rolled chains, so a later locale is one edit there.
A language tag is matched exactly and case-insensitively before its
primary subtag, so pt-BR and pt-PT browsers land on the Portuguese
bundle rather than English, and AUDIT-C no longer cuts a region tag to
two letters.

PHQ-9, GAD-7 and WHO-5 are not marked validated for pt-BR: the wording
has not been read against the published Brazilian forms, so readers get
the English items plus the existing note.
2026-10-22 14:50:00 +02:00
Marc-André Bombeck 6a2247e422 fix(healthkit): count a type as arrived only when the batch brought something new
A batch that carried a type with duplicates only moved its
lastReceivedAt, lastTrigger and lastBackgroundAt, so a type whose live
delivery had stalled kept reading as freshly arrived for as long as a
replay, a retry or an older client re-sent its old samples.

The arrival ledger now moves those stamps only for a type the batch
brought at least one new or changed row for. An unchanged stats:
re-post and an hour posted again with its stored value count as
duplicates here; the wire keeps answering updated for them. A
duplicates-only type stamps the new last_seen_duplicate_at column on
its existing row and nothing else. The intake bulk route's dose entry
follows the same rule through the shared recorder. lastSyncedAt and
healthKitLastSyncedAt are unchanged.

GET /api/integrations/healthkit serves the stamp additively as
metricFreshness[].lastSeenDuplicateAt, and the Apple Health card names a
re-send that came after the last arrival.

Refs #1259
2026-10-22 10:34:45 +02:00
Marc-André Bombeck e0eaed2251 fix(integrations): one provider account per record, borrowed apps return
A guardian finishing a connect for a managed profile could attach their
own provider account to the profile. Every OAuth callback (Withings,
WHOOP, Fitbit, Google Health, Strava, Polar, Oura) now refuses a
provider account another record already holds with reason
already_linked, shown as its own message on the integrations page.
WHOOP's owner_conflict tag becomes already_linked as well.

Oura keeps the account id of a grant (personal_info, migration 0441) so
the check has something to compare; existing connections fill it at
their next connect.

A disconnect leaves Withings webhook subscriptions and the Strava app
authorisation in place while another record holds the same account,
and the Withings webhook routes an account held twice to the record
that linked it first instead of whichever row a lookup returned.

The provider app copied from a guardian onto a managed profile is now
marked (migration 0442) and removed again on disconnect or when that
guardian no longer holds a live MANAGE grant: revoke, renounce,
removal, handover, account deletion and data wipe. Each removal is
audited as integration.client_app.removed.
2026-10-21 21:18:33 +02:00
Marc-André Bombeck 7585994697 feat(oura): import workouts and intraday heart rate
Workouts from /v2/usercollection/workout land as Workout rows keyed on
the Oura id, with the activity mapped onto the canonical sport set and
the raw label, intensity and recording source kept in metadata. Heart
rate from /v2/usercollection/heartrate lands as PULSE, streamed page by
page and written in bounded batches, first write wins per instant. The
hourly window for heart rate is hours, not days: six by default,
reaching back to the newest reading after a gap, at most 72.

Both collections sit behind their own scopes (workout, heartrate). A
grant from before they were requested answers 401 naming the scope; that
is recorded as a scope note on the card, never as a failure or a
reconnect. Collection refusals carry Retry-After on a 429.
2026-10-21 19:35:47 +02:00
Marc-André Bombeck b53434daa0 fix(fitbit): show the retired card only to an account that had Fitbit
After the Web API turndown every account still saw a Fitbit card that
can no longer connect. The status entry now publishes
retirement.offered: false once the API is gone for an account with no
Fitbit connection, no Fitbit app of its own and no Fitbit data, and the
connections panel leaves the card out. Accounts that had Fitbit keep it
with the retirement notice.
2026-10-21 15:06:40 +02:00
Marc-André Bombeck eabfeaef08 Merge branch 'fix/v14311-settings' into release/v1.43.11
# Conflicts:
#	messages/de.json
#	messages/en.json
#	messages/es.json
#	messages/fr.json
#	messages/it.json
#	messages/ko.json
#	messages/nl.json
#	messages/pl.json
2026-10-21 14:13:51 +02:00
Marc-André Bombeck ba1271275f feat(fitbit): stop at the Web API turndown and point to Google Health
Google turns the legacy Fitbit Web API off on 30 October 2026 (support
ended on 30 September). From that day the hourly poll, the backfill,
the sleep repair and the token refresh make no call, the connect, sync
and test routes refuse with `provider_retired`, and the connection
reads as not connected to the health read and the watchdog, so the
shutdown pages nobody and asks nobody to reconnect. The connection row
and everything it imported stay.

The status envelope publishes `retirement` on the Fitbit entry (the
day, whether it has come, the successor), and the Fitbit card shows
when the connection stops, how to move to Google Health (same devices,
one more consent, tokens cannot be carried over) and that the data
stays; sync and connect grey out after the day. Both integrations can
run side by side meanwhile: every type Fitbit writes is laddered with
Fitbit just above Google Health, so a day both deliver counts once.

The test setups pin the turndown off, so the Fitbit suites do not turn
red on the day; the turndown's own tests flip it.
2026-10-21 12:46:14 +02:00
Marc-André Bombeck 2bb3783b14 fix(settings): report the outcome of quiet settings actions
Adding or removing a passkey or a security key only redrew the list.
Disconnecting an integration and revoking a share link ran from a dialog
that had already closed, so a refused request left everything as it was
without a word. The injection-site exclusions, the local OCR switch and
the preventive-care reminder switches saved, or did not, in silence.

Each now reports through the outcome toast and, where the card has no
inline error line, the shared error toast with its error id.
2026-10-21 12:36:14 +02:00
Marc-André Bombeck 3bae93256d chore(settings): retire the unused IntegrationStatusPill
Three status pills paint a connection state: HealthBadge on the shared
integration card, the notification channel badge, and
IntegrationStatusPill. The first two describe different state models
and stay. The third had no production caller since the integration
cards moved to HealthBadge; it goes with its test and the nine strings
only it used. formatRelative, which two cards still import, moves to its
own module.
2026-10-20 13:53:51 +02:00
Marc-André Bombeck ab3f21c58c refactor(ui): one Segmented control instead of four
The timeline's Segmented, the document sources' SegmentedChoice, the
medication detail's SegmentedToggle and the icon-only ViewToggle were
four implementations of the same control in two looks, two at 36 px on
a phone. ui/segmented.tsx replaces all four, plus the hand-rolled range
rows above the lab and custom metric charts, the mood dimension window
and the inventory quantity mode: a muted track with the chosen segment
raised, radio semantics with roving focus by default, pressed-button
semantics for view switches and form sheets, icon-only segments, and
44 px per segment on a coarse pointer. A test holds the definition to
that one module.
2026-10-20 13:15:29 +02:00
Marc-André Bombeck c849619bee fix(settings): one action row on the Dawarich card
The connected card showed the home country field and a second row of
buttons (Choose home, Save) above the card's own row of details, menu
and primary action. The home editor now opens from the card menu, or
from a prompt while the home place or country is missing, and carries
its own Cancel and Save only while it is open.
2026-10-20 13:15:29 +02:00
Marc-André Bombeck 60be63bcd1 feat(ui): InlineAction for small text actions, with a 44 px touch floor
Thirty-three hand-rolled underlined text links and buttons (Open
schedule, Select all, View all, Retry, Learn more, the device-code copy
action, ...) carried three different focus rings, none on half of them,
and a 16 px target on a phone. They now render through one InlineAction
primitive: tone primary / muted / foreground, size xs / sm, a focus ring,
and min-h-11 on a coarse pointer so the desktop layout does not move. A
structural test refuses a new hand-rolled copy.
2026-10-20 13:03:15 +02:00
Marc-André Bombeck 923249dbf6 fix(ui): draw every failed read through a read-failure primitive
Around thirty surfaces drew a failed read their own way: a bare
destructive paragraph with no retry, a muted line saying the data was
still being learned, a skeleton that never ended, an error card nested
in a tile, or nothing at all. None of them told an ended session from a
refusal. They now use QueryErrorRow or QueryErrorCard with the query's
error and a retry, inset where a card already frames them, and keep
their card and title where the page would otherwise jump. Hydration and
caffeine now fail the same way.

The recovery page no longer points to Integrations while Oura's first
resilience level is simply on its way.

A guard reads every read hook's isError branch and refuses hand-rolled
markup there, and refuses a framed error row inside a card.
2026-10-20 10:10:49 +02:00
Marc-André Bombeck 6e54f33901 refactor(i18n): ask the catalogue for a sentence in one helper
`t()` answers a missing key with the key, so "does this code have a
sentence" was a comparison written inline at every site that wanted a
code's sentence or a fallback, twenty-odd times in several spellings.
translateOrNull, translateCodeOrNull and translateCode in
src/lib/i18n/translate-code.ts now hold that probe, every site uses
them, and a guard refuses the inline comparison anywhere else.
2026-10-20 09:53:31 +02:00
Marc-André Bombeck fea1d1e0b5 chore: strip planning tags from comments and enable the rule
Output of `node scripts/strip-planning-tags.mjs --write`: 3,157 comment
lines in 1,308 files lose the work-item identifier at their head. Release
versions and issue references stay; nothing outside comment text
changes, which the script checks per file against a comment-free
printout. healthlog/no-planning-tags is enabled at error level with this
commit, so the tree it lands on is clean.
2026-10-20 09:08:40 +02:00
Marc-André Bombeck 9b93247ae0 test: prime only the EN catalog in the unit setup
The setup file runs once per test file and imported all eight message
catalogs, several hundred kilobytes each, which made setup the largest
share of a component-suite run. It now primes EN, the fallback floor of
the t() chain. A test that renders another locale primes it at module
scope through primeLocales(), which loads the catalog via the production
loader in a file-level beforeAll.
2026-10-20 08:38:08 +02:00
Marc-André Bombeck 45c027aee2 Merge branch 'fix/v1437-review' into release/v1.43.7
# Conflicts:
#	src/app/api/analytics/route.ts
2026-10-20 06:20:42 +02:00
Marc-André Bombeck ea933b91cc Merge branch 'fix/v1437-jobs' into release/v1.43.7 2026-10-20 05:58:02 +02:00
Marc-André Bombeck 0497685db0 fix(apple-health): count readings entered here before calling a type quiet (#1247)
Weight, blood pressure and blood glucose are written back to the phone
while their entry is bidirectional. A reading entered here goes to Apple
Health and never arrives back, so a person who keeps those readings here
saw the type go quiet after two weeks. The Apple Health status read now
adds `lastEnteredHereAt` to each written-back type, the newest reading
from any source other than Apple Health, and `stale` counts it; the card
shows it as "Last entered here". The value is resolved on the server so a
client renders it as sent.
2026-10-20 03:36:31 +02:00
Marc-André Bombeck 37b65fa9c8 refactor(settings): take the backfill-ended type from its source
The integration status shape hand-copied the published entry with a looser
type field; it now imports the type the status routes publish.
2026-10-20 03:34:47 +02:00
Marc-André Bombeck 7be3714ac2 Merge pull request #1246 from robdegraaf/feat/i18n-nl 2026-10-20 00:14:49 +02:00
Marc-André Bombeck f6b09f07cd feat(integrations): say where an imported Google Health history ends
The two status routes publish backfillEnded: each data type whose
backfill stopped short of the start of its history, with the instant it
reaches back to and whether the horizon or a walk that stopped moving
ended it. The integration card lists them in place of an import that
would otherwise read as still loading, in all seven locales.

Refs #1220
2026-10-19 17:25:57 +02:00
Rob de Graaf c6f61fad33 feat(i18n): add Dutch as an eighth end-to-end locale
Adds messages/nl.json (AI-initial, informal je/jij register, full key
parity with en.json) and registers nl everywhere the locale list lives:
config, loaders, Intl/date-fns mapping, zod enums, OpenAPI spec, scripts,
the managed-profile picker and the notification locale guards.

Dutch follows the Korean pattern for the AI surfaces (English body plus a
Dutch reply-language footer, no safety-contract matrix) and gets its own
banks in the outbound safety screen (dose, risk, causal, continuation),
the prose-grounding and citation-coverage tables, the reference-range
prose and the no-key fallback copy, each with Dutch test fixtures.

PHQ-9 / GAD-7 / WHO-5 are not marked validated for nl yet: the Dutch
wording has not been read against the published forms, so Dutch readers
get the English items plus the existing note until it has.
2026-10-19 11:06:55 +02:00
Marc-André Bombeck 69e2f18788 fix(ui): tidy the new record and settings surfaces at phone width
- Composition amounts show only the decimals they carry, so 100 µg no
  longer reads as 100.000 µg; the remove button stays on the amount row.
- AUDIT-C remove controls are icon-only, so they no longer spill out of
  the card or cover the earlier answer.
- Life-circumstance labels keep their colon when they wrap, checkbox
  groups sit in two columns and the weekly-minutes sentence ends.
- Link chips get a 44 px hit area for their remove control.
- The AI usage level reports a failed save with a sentence and the
  error id instead of a bare "Error".
- The Dawarich home-country and admin refresh-gap labels sit above
  their fields.
2026-10-16 08:03:29 +02:00
Marc-André Bombeck b838ed4403 feat(dawarich): fetch daily movement values from a Dawarich instance
Connect, status, places, settings, test, sync and disconnect replace their
stubs. A nightly job (03:20) and the connect backfill read the timeline,
visits and residency endpoints through safeFetch and store one row of daily
values per local day: distances, places visited, minutes away from the home
place and the sealed country with its travel-day verdict. A private instance
needs its exact origin in DAWARICH_PRIVATE_ORIGINS.

The connection joins the shared ledger and the connection health model,
watched for errors only. The day carries a movement block, the model
projection drops the travel country, the two movement channels join the
correlation matrix under the environment module, travel days become trip
suggestions, and the days travel in the environment backup section.
2026-10-16 01:20:53 +02:00
Marc-André Bombeck 3fc37c9183 Merge branch 'v143/ios' into release/v1.43.0 2026-10-16 00:28:40 +02:00
Marc-André Bombeck 6146082824 Merge branch 'v143/supplements' into release/v1.43.0
# Conflicts:
#	docs/api/openapi.yaml
#	messages/de.json
#	messages/en.json
#	messages/es.json
#	messages/fr.json
#	messages/it.json
#	messages/ko.json
#	messages/pl.json
#	src/__tests__/sharing-surface-guard.test.ts
#	src/lib/ai/capabilities/types.ts
#	src/lib/export/backup-plan.ts
2026-10-16 00:28:29 +02:00
Marc-André Bombeck a0f6b89e92 feat(measurements): take hourly sums of summed types into the intraday profile and a backfill trigger that moves no live stamp; fix(backup): restore qualitative lab markers 2026-10-16 00:18:35 +02:00
Marc-André Bombeck f7386742b4 feat(web): show the error id on server failures and add the support info and report surfaces 2026-10-15 22:31:51 +02:00
Marc-André Bombeck 17478b08aa test(healthkit): type the card fixture's arrival fields 2026-10-15 15:38:42 +02:00
Marc-André Bombeck 09072c9f62 test(healthkit): pin dose arrivals, the background date and migration 0401 over enum rows 2026-10-15 15:36:20 +02:00
Marc-André Bombeck 03b24461ad feat(healthkit): record Apple Health dose arrivals and the last background arrival per type
Migration 0401 turns healthkit_type_syncs.type into text so the ledger can
hold MEDICATION_DOSE, and adds last_background_at (seeded from rows whose
latest arrival was a background one). POST /api/medications/intake/bulk
takes syncTrigger and records APPLE_HEALTH dose arrivals; the Apple Health
status read serves a MEDICATION_DOSE freshness entry and lastBackgroundAt
on every entry with arrival facts.
2026-10-15 15:33:30 +02:00
Marc-André Bombeck eedd91ec64 test(e2e): drive the integration cards through every health state
One mocked page carries a provider in each state, checked at desktop width
and at 390 px by data attributes, with the menu driven from the keyboard,
the copied diagnostics compared with the shown text, and an axe pass in both
themes. The solid destructive badge replaces the red-on-red wash, which
fell below AA.
2026-10-15 15:21:49 +02:00
Marc-André Bombeck 1a5b09a73c feat(settings): one integration card for every connection
Every connection in Settings renders the same card from the server's health
field: a badge with one word, one sentence per reason, the newest data as a
meta line, a collapsed details block whose text is exactly what the copy
button copies, and an action row with the rare actions in a menu and at most
one primary button. The per-provider banners, the raw provider error line,
the token expiry, the failure counter and the button rows are gone; every
capability they carried is reachable from the sentence, the one button, the
menu or the details. The managed-profile view renders the same card,
read-only. A structural guard keeps the cards off the retired fields.
2026-10-15 15:06:03 +02:00
Marc-André Bombeck b17a250894 feat(record-settings): publish connection health for a managed record's integrations
The guardian's read of a managed record's connections now carries the same
server-resolved health the owner's own envelope does, loaded for the record
and never for the guardian. The legacy state stays on the wire for one
transition release.
2026-10-15 15:06:02 +02:00
Marc-André Bombeck 4793edb961 test(integrations): pin the Withings plan handling and the Oura scope gap; document the plan change 2026-10-14 19:08:22 +02:00
Marc-André Bombeck 044f9008ba fix(oura): ask for the spo2 and heart_health scopes, explain a missing scope calmly
Oura's current authorization server enforces scopes per collection.
daily_spo2 needs spo2; vO2_max, daily_cardiovascular_age and
daily_resilience need heart_health. The authorize request now asks for
both.

A grant issued before keeps answering 401 on those four collections
while every other one succeeds. Such a 401 next to a success is now
recorded as a scope gap rather than a failure: it no longer counts
toward the reconnect threshold, it no longer forces a token refresh once
known, and the Oura card asks for one reconnect instead. The Withings
card names the readings the account's API plan withholds.
2026-10-14 19:01:02 +02:00
Marc-André Bombeck 9a86a9427b fix(oura): name the issuer refusal on the integrations page 2026-10-11 13:59:05 +02:00
Marc-André Bombeck 86a3856ae9 fix(i18n): drop spaced dashes as separators from every visible string
A dash between two parts of a sentence ("{what} — tomorrow", "Could not
record {name} — try again", "End {name} — …") stands in for a comma, a
colon or a full stop the sentence should say. Every such string in the
seven bundles now says it in plain words. A range keeps its unspaced en
dash ("70–100 mmHg", "{start}–{end}"); that is how a range is written,
not a pause, and the spaced range forms became unspaced ones.

Labels the components put together themselves follow the same rule:
aria-labels read "Move up: Ramipril", a medication and its dose read
"Ramipril 5 mg", page titles use "|", Coach prefills are two sentences,
and the API's user-facing error messages lost their dashes too.

The bundle guard now refuses " — " and " – " beside " · ", with an empty
allowlist, and a sibling guard walks src/components and src/app for a
string or template literal that joins its parts with a spaced dash.
2026-10-09 16:05:32 +02:00
Marc-André Bombeck d029e4e1dc fix(notifications): send content and text so Discord and Slack webhooks deliver
The generic webhook body carried title, message, eventType and priority
only. Discord refuses a body without content (400, code 50006 "Cannot
send an empty message") and a Slack incoming webhook one without text,
so the test button and every reminder failed against the targets the
settings card names.

The generic body now appends content (cut to Discord's 2000 characters)
and text, both the masked title and message on two lines, plus
allowed_mentions with an empty parse list so Discord resolves no
mentions. The four original fields keep their values and order for
Home Assistant and n8n rules. The Gotify body is unchanged. The card
names what works and shows a hint for the generic format.
2026-10-09 13:43:28 +02:00
Marc-André Bombeck 1fe4a7af95 fix: settle the new edit and HRV surfaces after a look at them
The HRV page's second chart reported its visible window into the same
stat-strip slot as the first, so the two replaced each other on every
render until React stopped it and the chart failed to load. Only the
primary chart reports the window now; the strip describes that measure.

The workout edit sheet no longer shows the "leave empty if you just
finished" hint, which is about logging and not about a stored start. On
the Apple Health card a type's name reads as the row's content above its
arrival line, with room between rows.
2026-10-08 09:52:51 +02:00
Marc-André Bombeck ba99551faa feat(apple-health): show when each type last arrived and how
The Apple Health card listed each type's newest sample. A type the phone
only hands over on "Sync all" can carry a reading from this morning and
never have arrived in the background, so the sample time could not answer
whether a type flows on its own.

The batch route's per-type ledger now records, in one upsert per batch,
when each Apple Health type last arrived, the trigger that carried it
(including the new `manual`), and when a batch last brought a new or
updated sample of it. Duplicate-only re-sends move the arrival but keep the
last new value; scoped credentials and MANUAL rows write nothing, the same
rule as the sync checkpoint. A ledger failure leaves a warn line and never
fails the ingest.

`GET /api/integrations/healthkit` lays `lastReceivedAt`, `lastTrigger` and
`lastNewSampleAt` over each `metricFreshness` entry (null for a type no live
sync has carried, absent on the workouts entry and when the ledger read
failed), and the card shows them as a second line per type.

The per-type newest-sample read walks live types through
`listLiveMeasurementTypes` and probes each type's newest row instead of
grouping every live row of the source.
2026-10-08 09:22:49 +02:00
Marc-André Bombeck e63be67b5a feat: lay down the shared v1.42 contract
Land every file the v1.42 work shares before any of it is built, so the
parts built side by side never edit the same file.

Schema and migrations 0379-0386: the HEALTH_CONNECT measurement source,
ImportJob.kind, the air-quality columns and the per-user switch on the
environment tables, sealed environment locations with nullable plaintext,
managed-profile handover links, the per-type HealthKit arrival ledger, lab
unit-conversion provenance, and user-defined vaccines. Every new column is
annotated for the column-reader sweep.

HEALTH_CONNECT reaches every exhaustive list: the source labels, the
read enum (not the write allowlist), the source-priority ladder cap, now
the number of sources rather than a fixed eight, and the write-path smoke
test. RMSSD gets its own priority class and a 300 ms ceiling. The batch
syncTrigger gains `manual`, with its label on the Apple Health card.

Queues for the Health Connect import, the compaction-tombstone purge and
the measurement-table maintenance are registered with stub handlers;
host-metric sampling moves to every five minutes. Environment and work_mem
variables reach the compose whitelist, the production example and the
manifest. The proxy admits the handover link and redirects it at the edge,
and the Health Connect upload bypasses the proxy body limit.

The new routes answer 501 behind their final authentication and are
published in the contract; backup, wipe and key rotation classify the new
models and columns; the seven message bundles carry the base copy. Guards
that list release-pending keys and columns fail once an entry is wired, so
those lists empty themselves before the tag.

Also re-anchor a login-throttle fixture to the wall clock, where its
expiry is compared, and re-base the aggregate bundle cap on a measured
12 KB of new locale strings.
2026-10-08 07:56:59 +02:00
Marc-André Bombeck b4577f5c31 test: give every scanning guard a floor on what it found
A guard that compares a sweep against an allowlist, or against "none of
these may exist", passes when the sweep comes back empty. walkSourceFiles
already refuses a short walk; this adds the same refusal for what a guard
does with the walk.

- requireFloor(label, found, floor) throws below the floor and refuses a
  floor of zero.
- scanSourceMatches(root, pattern, { fileFloor, matchFloor }) walks, matches
  and floors both the files read and the hits found.

Floors added, pinned at the current counts:

- coach_usage owner columns: the clauses it actually examined (6).
- measurement reference index: the foreign keys it extracted (2).
- job outcome fact inventory: job files (130, floor 120) and fact keys
  (399, floor 350).
- tone harness: assessment prompt modules (11).
- coach locale default: turn pipeline modules (15, floor 12).
- locale integrity: message bundles (7).
- settings card header coverage and settings anatomy: the walked cards.
- coach metric scope inventory: insight route sources.
- side-effect drift guard: both enums populated, since two empty keysets
  are equal.

The pill-mapper guard walked with globSync, which skips dot-prefixed
directories; it now walks through walkSourceFiles like the rest.
2026-10-01 10:52:48 +02:00