Commit Graph
2906 Commits
Author SHA1 Message Date
Marc-André Bombeck faa3ef867b feat(insights): correlate intake, alcohol days, training, rings, State of Mind and room climate
The discovery matrix gains the families people log but could not test:
water, caffeine and up to four varying micronutrients from the nutrient
log, alcohol days from the mood tag, workout minutes (one per session) and
the day's training impulse, exercise, stand and mindful minutes, State of
Mind as an outcome, and the overnight room climate against that night's
sleep only. Every channel runs through the same seasonal adjustment,
effective-n p-value, Benjamini-Hochberg control and effect-size floor, is
read only while its module is on, and drops out below the paired-day floor.

State of Mind, mindful minutes and alcohol days are statistics for the
person: the Coach, the period narrative and the assessment cards drop every
pair that touches them, after the shared scan so the remaining pairs keep
the statistics the correlations page shows. A guard walks the import
closure of the analysis modules and refuses any path to a provider.
2026-10-21 21:39:15 +02:00
Marc-André Bombeck e1b70537cc Merge branch 'feat/v144-settings' into release/v1.44.0
# Conflicts:
#	src/components/settings/privacy-section.tsx
2026-10-21 20:51:39 +02:00
Marc-André Bombeck 21cfd46988 Merge branch 'feat/v144-integrations' into release/v1.44.0 2026-10-21 20:51:29 +02:00
Marc-André Bombeck cc8ea3dfbb feat(measurements): accept indoor CO2, temperature and humidity
Room climate from a home automation system gets three types of its own:
INDOOR_CO2 (ppm), INDOOR_TEMPERATURE (celsius, shown in Fahrenheit for
imperial readers) and INDOOR_HUMIDITY (percent). They arrive through the
existing ingest token (measurements:write, source EXTERNAL), sit in the
environment cluster beside daylight, and the daily rollup carries their
mean, low and high like any spot reading. They never mix with the body
temperatures or with the outdoor environment channels.

The Home Assistant page documents the three types, a rest_command that
sends a room as one request, a single-room automation, and an
importable blueprint (docs/integrations/blueprints) that skips sensors
reporting unknown or non-numeric states, converts Fahrenheit, and
stamps readings to the minute so a retried push is refused rather than
written twice.
2026-10-21 20:26:15 +02:00
Marc-André Bombeck b045703493 feat(apple-health): ingest the activity rings, mindfulness, State of Mind and AFib burden
Six Apple Health signals leave the deferred list and ride
POST /api/measurements/batch and the export.zip import end to end:
EXERCISE_MINUTES, STAND_MINUTES and STAND_HOURS (the exercise and stand
rings), MINDFUL_MINUTES, STATE_OF_MIND_VALENCE (iOS 18 State of Mind,
-1..1) and AFIB_BURDEN (AFib History, percent of the week). The ring
types and mindful minutes are cumulative, so the daily rollup is their
sum; AFib burden is scaled strictly from Apple's fraction and a value
above 1 is refused rather than guessed at. The archive import takes
mindful minutes from the session's dates and skips idle stand hours.
Every registry carries them: units, ranges, labels in all locales,
categories, chart tokens, report catalogue (mindful minutes and the
valence fenced as sensitive), signal registry with FHIR codings, and
MCP exposure for the three ring types only.

Workout GPS tracks are now an opt-in, off by default
(PATCH /api/auth/me/workout-routes, a switch on Data & Privacy). While
it is off, the workout batch route and the export import keep every
workout and drop its track with the same per-entry status. With it on,
the import reads the referenced GPX files from the archive one bounded
member at a time, thins each to the point cap and stores it sealed.
The climb a track carries is written to Workout.elevationM at write
time, so the workout insight no longer opens the track, and the AI
egress guard now freezes the set of files that read route geometry.
2026-10-21 20:14:20 +02:00
Marc-André Bombeck ae601de16f Merge branch 'feat/v144-oura-depth' into feat/v144-integrations 2026-10-21 19:56:43 +02:00
Marc-André Bombeck 74ce17b7de feat(integrations): let a guardian connect a managed profile's providers
A guardian acting on a managed profile can now connect and disconnect its
Withings, WHOOP, Oura, Polar, Strava and Google Health from the profile's
integration cards, and each card says whose record it connects for. The
connect starts with a fenced fetch that mints a state naming the profile;
the provider's callback lands tokens and data there only after checking
that the browser belongs to a guardian with a live MANAGE grant on it.
Without a provider app of its own, the profile borrows the guardian's,
with an audit row. Withings and Google Health callbacks read the session
directly, since a provider redirect cannot carry the record-session header.
2026-10-21 19:49:07 +02:00
Marc-André Bombeck 131b48cc49 fix(i18n): one German word per thing across Settings
Anbieter instead of Provider, Standard instead of Default, Gesundheitsscore
for every spelling of the score, and Insights where the area was called
Auswertungen. The Coach's rate-limit notice no longer shows English in the
German interface. A guard reads the Settings namespaces of the German
catalog and fails on the retired spellings.
2026-10-21 19:48:29 +02:00
Marc-André Bombeck ec240bc07a test(settings): follow the moved pages in e2e and cover the phone navigation
Point the header, overscroll and timezone specs at the pages that hold
those cards now, add the module pages and Preview features to the
one-piece sweep, and add a 390 px journey through the grouped section
sheet. Unit coverage for the Preview features card.
2026-10-21 19:44:07 +02:00
Marc-André Bombeck 71dbef1742 feat(settings): nine groups, module pages and one switch per state
Settings now reads as nine groups: Account, Tracking, Arrangement,
Notifications, Health profile, AI, Connections, Data and System. On a
phone one button names the current section and opens the grouped list,
replacing the strip of every section.

What moved, every old address still working:
- Sessions, trusted devices and security activity sit under Security;
  the privacy page anchors forward there.
- Notifications holds every reminder kind: the Coach nudges and the
  checkup reminder switches joined, and a card names the reminders set
  where their subject lives (dose times, cycle reminders).
- Arrangement holds order and view only. Mood tags, the labs biomarker
  catalog and on-device text recognition, and the injection sites live on
  module pages under /settings/modules/<id>, linked from each Modules row
  and from the module's own wrench. /settings/layout/mood redirects.
- The timezone saves on change in the Display card, with a one-tap offer
  of the device zone.
- Screening hints sit beside the life circumstances they come from.
- System gains Preview features with the Topics switch.

Duplicates: the Coach and MCP pages show their state and link to the one
switch in Modules (turning MCP off keeps tokens), the Coach overlay shows
the provider and model with a link to Settings > AI instead of a second
set of selects, and the privacy page drops its copy of the export links.

A guard walks every historical /settings address through the redirects
and every forwarded anchor to a page that renders.
2026-10-21 19:43:02 +02:00
Marc-André Bombeck 3ebef19ac3 fix(settings): one save model and one confirm per card
The Telegram, ntfy, webhook and email cards saved their enable switch on
tap and the rest of the form on Save; the switch is now part of the form
and the one Save sends it. The display selects confirm a change with a
toast. Destructive confirmations in Settings go through ConfirmButton or
ConfirmDialog with the destructive style. Two structural guards hold both
rules: a card may not mix instant and explicit saves, and no settings file
reaches for a raw AlertDialog without a written reason.
2026-10-21 19:42:53 +02:00
Marc-André Bombeck d44af06467 docs: publish the UI standards in docs/contributing
The binding UI rulebook (card anatomy, spacing scale, text colour,
tokens, primitive mapping, enforcement) lived outside the repository,
so a contributor could follow a code comment to a file they could not
open. It now lives at docs/contributing/ui-standards.md, every comment
that cited it points there, and the docs index and ui-guidelines.md
say which document wins when they disagree.
2026-10-21 19:37:22 +02:00
Marc-André Bombeck 07f9615dba feat(ops): name 1536 MB as the supported memory floor
The getting-started requirements now say a host needs 3 GB of RAM, 4 GB
recommended, and drop the advice to lower APP_MEMORY_LIMIT to 1g on a
2 GB host. The compose and .env example comments say the same.

At boot the app reads its cgroup memory limit (v2, then v1) and logs one
line when it is below MEMORY_FLOOR_MB. The support report adds the
container limit and a note below the floor, and Admin > System status
shows "below the supported floor" next to the existing low-heap flag.
The app keeps running either way.
2026-10-21 19:36:06 +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 9941db1549 refactor(insights): drop the readable cached-text column
Every write has gone to insights_cached_text_encrypted since 0411, and
the boot-time free-text backfill sealed the rows written before it.
Migration 0430 drops insights_cached_text and the backfill index that
served it. A host that skipped the intervening releases loses only a
cache of model output; the next warm writes a fresh, sealed payload.

The cipher module now reads and writes the sealed column only, the
backfill job loses its insights step, and consent withdrawal, the wipe
plan, the backup plan and the session projection follow the schema.
Backups never carried the column, and restore ignores unknown account
settings; a round-trip test now proves an old backup naming the field
still restores cleanly. The access guard pins exact counts for where
the sealed name may appear. Fixtures, the demo seed and the demo
migration script seed the sealed column instead of plaintext.
2026-10-21 19:24:21 +02:00
Marc-André Bombeck a3f5f998cf fix(report): end the visit offer on the record's day from the server
The preparation window now carries to, the booked visit's own day on
the record's calendar, and the report offer uses it. Cutting the end in
the reader's zone was wrong for a guardian viewing a managed profile in
another zone.
2026-10-21 16:50:14 +02:00
Marc-André Bombeck 88200a9273 fix(report): cut the visit offer's end day on the record's calendar
The offer took its start from the server's preparation window, cut on
the record's calendar, and its end from the appointment's day in the
browser's zone. In a browser in another zone a late-evening or
early-morning visit moved the end a day against the start. Both ends now
use the record's zone.
2026-10-21 15:07:05 +02:00
Marc-André Bombeck 03cacc996c fix(profile): one display-name bound, checked after trimming
The setup flow capped a display name at 50 characters while the profile
took 80, and the profile checked its minimum before trimming, so a name
of only spaces was stored as an empty string. Every surface now reads
one bound (80), the profile trims before validating, and only spaces
means no name on both paths: the profile clears it to null, the setup
flow leaves it unset.
2026-10-21 15:06:57 +02:00
Marc-André Bombeck a264dee030 fix(settings): keep the end-access button while the grant list loads
The button for a grant that sign-out-everywhere left standing rendered
only once the grant list (which carries the audit window its
confirmation names) had loaded, so while it loaded, and for good when
the read failed, the row explained the access but offered no way to end
it. The button now shows disabled until the list arrives, and a failed
read draws the standard error row with a retry.
2026-10-21 15:06: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 bf3830203f fix(hrv): keep RMSSD on the display surfaces for an account with no SDNN
Fitbit and Google Health HRV is filed as RMSSD since migration 0420, so
an account whose only HRV comes from them lost it wherever a display
surface read SDNN alone. The dashboard HRV tile keeps its RMSSD fallback
with Recovery off while the account has no SDNN, and its toggle stays
offered for either estimator. A briefing HRV signal counts as measured
today when either series has a reading. The doctor report already files
RMSSD as its own ungated leaf; a guard now holds that. Readiness, HRV
balance and stress stay on SDNN; a test pins that an RMSSD-only account
renormalises over the other components.
2026-10-21 14:32:56 +02:00
Marc-André Bombeck 8d16799479 Merge branch 'fix/v14311-visits' into release/v1.43.11 2026-10-21 14:13:52 +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 eaaff23858 Merge branch 'fix/v14311-integ' into release/v1.43.11 2026-10-21 14:13:37 +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 2914832ed8 fix(visits): keep the appointment rail action's wire tokens for the shipped app
The shipped iOS app routes the rail by intent and resolves the label
from its own catalogue; a new `visit.view` intent would leave its tap on
an appointment inert. The upcoming-visit action keeps `checkup.view`
and `daily.action.viewCheckups` on the wire and only its `href` moves
to the visit. The web card names what that link opens: "View
appointment", or "View appointments" for the visits list.
2026-10-21 12:38:08 +02:00
Marc-André Bombeck 27a59c29fd fix(records): let an emergency select go back to not recorded
The blood type, organ donor and advance directive selects had no empty
option, and the save left out an empty select, so a value once chosen
could be changed but never removed. Each select now offers "Not
recorded", and a select the person changed rides the save, as null when
it was cleared; an untouched one stays out of the request.
2026-10-21 12:36:15 +02:00
Marc-André Bombeck 1e606834d8 fix(settings): let the display name change after the setup flow
The setup flow asks for the name the greeting and the reports use, and
the profile route has always accepted it, but no settings surface offered
the field and /api/auth/me did not publish it. A name chosen on the
first day stayed for good.

The account payload now carries `displayName`, the profile card shows the
field beside the username and saves it with the rest of the profile, and
an emptied field clears the name.
2026-10-21 12:36:15 +02:00
Marc-André Bombeck 6037ebd3d6 fix(settings): let a saved AI API key be removed
Every personal provider form showed a stored key and offered no way to
take it back, since saving with an empty key field keeps the stored one.
The OpenAI, Anthropic, local model and gateway forms now carry a
confirmed "Remove API key" while a key is stored. It sends the existing
PATCH /api/user/ai-provider with that one key as null, which clears the
column and leaves the provider choice, model and the other keys alone.
2026-10-21 12:36:14 +02:00
Marc-André Bombeck 41cf584a65 fix(settings): say what the AI provider picker does
The select on the AI provider card read "Primary provider" under a card
that said it held the provider HealthLog asks first. It only picks which
provider's key and model the form below edits, and saves nothing; the
order of the fallback chain decides who answers first. A person who
picked a provider there expected it to answer first and it did not.

The picker and the card description now say what they do and point at
the chain for the order.
2026-10-21 12:36: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 b6e3b47bec fix(settings): never leave a card saving after a failed request
Six settings cards raised their own saving flag, awaited a raw fetch and
lowered the flag on the next line. A request that never reached the
server rejected instead, the line never ran, and the card kept its
control disabled until a reload: the Telegram form, the mood reminder,
the low-stock threshold, the lock-screen detail switch, the Coach nudges
and the profile form.

A network failure now falls through to the card's own error line, and the
flag is lowered in a finally. A guard holds every hand-rolled pending
flag under settings to that shape.
2026-10-21 12:36:14 +02:00
Marc-André Bombeck 6a9543b4f8 fix(settings): confirm before ending a grant from the sessions card
After sign-out-everywhere the sessions card lists the access grants it
left standing, each with an end button that revoked on the tap. It is the
same act the access card confirms, permanent for the other account, so it
now asks the same question with the same disclosure, audit window
included, and the row names the account the way the access card does.
2026-10-21 12:36:14 +02:00
Marc-André Bombeck 99fec79177 fix(visits): start the visit preparation and its report at the matching visit
Both surfaces read the start of their window from `preparation` and say
why it starts there: "Since your last visit with <practice> on <date>",
"Since your last <specialty> visit on <date>", "Since your last visit
of the same kind (<kind>) on <date>", or "The last 90 days" with the
reason that no earlier visit matched. The visits tab no longer picks
the last visit of any kind from the past list.

The report panel's appointment offer now sets both dates, the start
from the server and the end on the appointment's day, instead of the
end alone beside whatever start the panel held. Both stay editable.
2026-10-21 12:32:26 +02:00
Marc-André Bombeck 4c50f48b26 fix(labs): name a failed re-check as an error and close the last budget gaps
A re-check or reservation that failed itself stopped the scan as
"unavailable", and the review screen said AI had been turned off. It now
stops as stoppedBy error with its own message in every locale, and the
failure is reported as one: an error signal and the worker error
reporter. A scan that failed that way before any row answers 500
aiRuns.failed.

The overspend booked before a further read is now net of the cached
share, as the settlement books it. A reservation that names no known
owner stops the scan instead of taking the user plan's larger cap.

A page's text layer is cut at the last whole line; only a page whose
first line alone runs past the cap keeps part of it, cut at a space or
hard, rather than collapsing to the bare cut mark, and an endless line is
no longer read past the cap. The unused per-call text limit is gone. The
run result's description names every outcome a stopped scan can have.
2026-10-21 12:28:32 +02:00
Marc-André Bombeck 0dcafeeb70 fix(visits): open an upcoming appointment where it lives
Visits are the second tab of `/checkups`, and the bare path opens the
first one, the preventive-care list, which never shows an appointment.
Every surface that listed an upcoming appointment linked it there: the
dashboard's next-appointment line, the Today rail item, the Today
overview fact and the appointment's push notification. A tap on the
appointment landed on the list it is not on.

Each now opens the visit itself (`/checkups?visit=<id>`), or the visits
tab (`/checkups?view=visits`) for a line naming several appointments.
The checkups page reads `view` to pick a tab. The rail item stops
borrowing the check-up item's action: it carries its own label
("View appointment") and intent `visit.view`. The appointment push names
its visit through the producer's `metadata.url` override; a check-up's
push keeps the checkups list.

The same mis-route sat on three links that lead to visits without
being upcoming: a condition's visit rows, the address book's back link
and the onboarding and timeline prompts to add a visit.

`visit-link-guard.test.ts` keeps the visit paths in the builder and
counts every bare `/checkups` per file, each with the reason it names
the checkups list.
2026-10-21 12:22:36 +02:00
Marc-André Bombeck 78bacb0d86 refactor(visits): build every link to a visit in one place
Seven surfaces spelled `/checkups?visit=<id>` by hand, some encoding
the id and some not. They now share `visitPath()` from
`src/lib/encounters/visit-links.ts`, the place the next fix extends.
No behaviour change: a cuid encodes to itself.
2026-10-21 12:22:22 +02:00
Marc-André Bombeck 2e653b7aa0 fix(settings): name the act on the token revoke confirmation
The confirm button in the API-token and MCP-token revoke dialogs read
"Revoked", the status word the admin overview shows for a token that is
already gone, and the phone layout's trigger carried the dialog title,
question mark included. Both now say "Revoke token".
2026-10-21 12:02:20 +02:00
Marc-André Bombeck 9a8b95cfd0 fix(ui): let ConfirmButton's dialog wear the destructive style
ConfirmButton took a `destructive` prop and dropped it on the way to its
dialog, so every delete, revoke and remove built on it confirmed with a
neutral primary button while ConfirmDialog confirmed the same kind of act
in red. The prop is forwarded now, and the call sites whose act takes
something away pass it. A guard holds every confirmation whose slot names
a destroying act to the destructive style; resets stay neutral.
2026-10-21 12:02:13 +02:00
Marc-André Bombeck dc704b5e63 fix(notifications): show the mood reminder's real default in the matrix
The /notifications matrix read the constant default table, where the
mood reminder is off, while delivery derives it from the reminder card.
An account receiving the reminder saw every one of its channel switches
off.

The no-row default now lives in one resolver that delivery and the
preferences read both use. GET /api/notifications/preferences publishes
it as `eventDefaults`, the matrix renders a switch without a stored row
from that map, and the reminder card refreshes the matrix when it flips.
2026-10-21 12:01:53 +02:00
Marc-André Bombeck 79d905f089 fix(labs): reserve and re-check each page of a scan, and read digits from the text layer
The route reserved one read's budget and checked the wire once, and a
20-page report then ran up to 40 calls for many minutes on that single
admission. Every read after the first now reserves its own share of the
day's budget and asks the wire again (aiEgressRefusal) before it is sent;
when either refuses, the scan stops there, keeps the pages already read
and names the rest with stoppedBy budget or unavailable. The run settles
against everything it reserved, with the provider's reported usage net of
its cached share, and a scan that fails after several calls is charged
for them instead of refunded in full.

A rendered PDF page now goes out with its own text layer, read by pdfjs
beside the image, so a digitally produced report's digits, decimal
separators and units come from its characters rather than from a 1600 px
picture. Splitting the PDF into single-page documents would need a PDF
writer the project does not ship; the text layer needs none and helps
every provider. A scanned page has no text layer and reads as before.

Codex's cut reply keeps the text it streamed (on a non-enumerable
property, so no log or event serialises it), and its closed rows are
salvaged like every other provider's. The merge folds a reading only into
one an earlier page listed, never two rows of the same page, and the
printed range is part of the key. OCR'd text the deadline cannot split
small enough for one read is refused up front as labs.ocr.tooLarge.
2026-10-21 11:10:02 +02:00
Marc-André Bombeck 6376710254 fix(labs): say on the review screen what a scan could not read
The review screen now names a read that stopped at the provider's output
limit (the pages it happened on, or the report when it was one read),
pages the provider never answered for, and a report with more readings
than one scan carries, beside the existing page note. A scan that
produced no reading because every read hit the output limit says so
instead of asking for a clearer photo.

A scanned name or result longer than 120 characters, or a unit longer
than 40, now marks its field on the row instead of failing the whole
save with a 422; the commit's bounds are shared with the screen.
2026-10-21 10:43:04 +02:00
Marc-André Bombeck 766b413871 fix(ui): keep a disabled chosen segment readable and the range rows at 44 px
A faded primary fill reads about 2:1 in light mode, so a disabled chosen
segment swaps to the token pair Button uses instead of the opacity. The
small range rows keep 44 px under a mouse as well, as the chart tabs had,
and the changelog names the fill by what it is.
2026-10-21 09:36:05 +02:00
Marc-André Bombeck f9796858e6 fix(ui): give the segmented control back the chart tabs' look
The shared control introduced a muted track with the chosen segment
raised on the background colour. In a chart header it read heavier than
the card around it, larger type and wider segments on a grey bar, and it
dropped the primary fill the range tabs had carried.

Segmented now draws the chart tabs' former look everywhere: no track, the
chosen segment filled in the primary colour, the others plain until
hovered. A small size keeps the range rows in chart headers at the former
type and padding; the touch floor of 44 px on a coarse pointer stays.
2026-10-21 09:32:58 +02:00
Marc-André Bombeck 6224409678 fix(oura): request the stress scope and exchange a repeated callback once
Resilience sits behind Oura's `stress` scope, not `heart_health`, so a
connection that granted every scope HealthLog asked for still answered 401
on `daily_resilience`. `src/lib/oura/scope.ts` now maps every collection the
sync reads to the scope the live API enforces and is the one source for the
authorize request, the per-collection permission detection, the reconnect
hint and the settings card. The request asks for `stress` as well.

Oura names a missing scope in its 401 body ("Token is not authorized access
stress scope."), while a dead token reads differently. A collection refusal
now carries that scope: it spends no token refresh, never counts toward the
reconnect threshold and marks only the collections it names. A 403 (a lapsed
membership) is a persistent failure rather than a reconnect. The callback
also records what the token response says was granted, so an unticked
permission shows on the card before the first sync.

A browser can deliver an OAuth callback twice with the same code. Both
requests exchanged it, and Oura answers a reused code by revoking the token
the first exchange issued. Every connect callback (Oura, Polar, Strava,
WHOOP, Withings, Fitbit, Google Health) now claims its state in
`oauth_callback_claims` before the exchange: one request exchanges, the
duplicate waits for it and redirects to the same page without touching any
token. The ledger flows claim inside the transaction that consumes their
state row. A failed exchange settles with its error page, and a new connect
mints a new state.

Oura token refreshes are serialised per user with an advisory lock, so
overlapping syncs no longer spend the same single-use refresh token, and the
write stays conditional so a reconnect's new grant is never overwritten by a
refresh of the old one.
2026-10-21 00:05:22 +02:00
Marc-André Bombeck f573969428 fix(ui): give unchosen segments the tab trigger's readable dark tone
The muted foreground on the muted track reads 3.6:1 in the dark theme,
below WCAG AA. Tabs already solved this with the tab-inactive token;
Segmented now uses the same treatment, so both controls look alike.
2026-10-20 17:37:17 +02:00
Marc-André Bombeck c8649faddf test(ui): sharpen the page-title and empty-state guards
The H1 guard matched the raw text, so seven files sat on its allowlist
only for mentioning an H1 in a comment; it strips comments first now and
the entries are gone. The empty-state ratchet counted against a ceiling,
which let a new copy in wherever an old one left, and missed the no...Yet
keys: it freezes the remaining sites by file and key, recognises no...Yet
and nothing... keys, and the two it missed (the insight status card and
the screener card) render EmptyState.
2026-10-20 16:45:11 +02:00
Marc-André Bombeck 7ed953a029 fix(ui): route the hand-built segment rows through Segmented
The narrative period, the lab result type, both admin filters, the chart
range tabs, the mood chart's own copy of them, the sleep-stage window and
the re-authentication method each spelled a segmented control by hand,
with their own sizes and fills. They render Segmented now; the range tabs
keep their long names as a hover title, and the admin filters gain an
accessible group name in every language. A test refuses a new button row
whose aria-pressed compares a state to each button's value, and names
the rows that remain with why they are not one choice on one track.
2026-10-20 16:43:59 +02:00
Marc-André Bombeck 160da978e6 fix(insights): keep grid tiles on one baseline when a title wraps
Removing truncate from the grid tiles let a long title take a second
line and push its value and sparkline below the neighbours'. TileHeader
takes reserveLines={2}, a fixed two-line row with a clamp and the full
title in title, as the dashboard trend strip already does. The vitals
tiles, their building state and skeleton, the device score tiles and the
resilience tile use it, and a test holds those files to it.
2026-10-20 16:38:29 +02:00
Marc-André Bombeck 024620a7f1 fix(ui): give a wrapped card title room between its lines
CardTitle wraps but kept leading-none, so the two lines of a broken
compound touched. It is leading-tight now; a caller's own leading still
wins.
2026-10-20 16:38:29 +02:00