Commit Graph
72 Commits
Author SHA1 Message Date
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
Marc-André Bombeck 6534c3f9e8 fix(documents): picker and settings card follow-ups
The settings card stays visible, read-only, while a connection is
stored but the operator list is gone or Documents is off, so the token
can still be removed; a new address asks for the token again. The
picker clears a selection when the search or a filter changes, says
when the 25-document limit is reached, steps back the rows a finished
run did not touch and offers Pick more, shows an empty state, and
announces each row's result to screen readers. Both system switches
are radio groups with one tab stop and arrow-key movement. Strings in
all seven languages.
2026-09-26 14:32:46 +02:00
Marc-André Bombeck 9e38ee3a99 fix(documents): report picker and card results through the shared outcome line, and register the disconnect as a destructive control 2026-09-26 13:32:52 +02:00
Marc-André Bombeck d109d67889 feat(documents): pick documents from Paperless-ngx or Papra in the vault and wherever documents are linked
A settings card under Integrations connects either system and tests it
before saving. An import button beside Upload in Documents, on a
condition's documents card, and beside Add in the visit and vaccination
document pickers opens a sheet that searches by name with tag and date
filters, shows what HealthLog already holds, and imports the ticked
documents one at a time with a result per row. The buttons appear only
for a person in their own record with a connection; strings in all
seven languages.
2026-09-26 13:26:18 +02:00
Marc-André Bombeck 48bfc1f987 refactor(navigation): name the full-document loads the client router cannot serve
Nine places assigned window.location.href directly: OAuth connects and
sign-in hand-offs that land on API routes answering with a redirect, and
the reload after account deletion. The newer Next lint rule reads each as
an in-app page change. They now go through one helper that says why a
document load is the right tool there.
2026-09-24 10:37:21 +02:00
Marc-André Bombeck 0bdbf009f6 fix(integrations): a provider with no history is "unknown"
The ledger's synthetic "no row" snapshot returned `state: "connected"`, so
the field's default meant the opposite of its name: every account that had
never connected a provider published `connected` on the status envelope.
Settings survived it by never reading `state`; the setup flow's connect step
did read it, and told brand-new accounts their wearable was connected.

The snapshot now reads `unknown`. `syncHealth.verdict` is unchanged and stays
the only liveness truth; the managed-record projection maps `unknown` onto
`disconnected` the way it already maps a missing connection.
2026-09-11 09:08:38 +02:00
Marc-André Bombeck 05d3736d75 Resolve OAuth callback URLs on the server for integration cards
The v1.38.0 notice that warns when the configured OAuth callback origin
differs from the origin the browser is on never rendered in the published
image, and the copyable callback address in the redirect guide showed a
bare path. `integrationCallbackUrl()` read `process.env.NEXT_PUBLIC_APP_URL`
inside a "use client" module. Next.js inlines NEXT_PUBLIC_* into client
bundles at build time, and the GHCR image is built without that variable
(only the version, build-sha and built-at args reach the build), so the
base compiled to an empty string, `new URL("/api/<provider>/callback")`
threw, and the notice returned null on every card for every self-hoster.

The value now comes from the server at request time. A new map
(`src/lib/integrations/callback-urls.ts`) calls each provider's own
redirect-URI function (the four private `getRedirectUri()` helpers are
exported under provider-specific names; Polar, Oura and Strava already
exported theirs), so the page shows exactly what the handshake registers,
explicit `*_REDIRECT_URI` override included. The settings route resolves
the map and passes it as a prop through `IntegrationsSection` and
`ConnectionsPanel` to every card. Fitbit and Google Health validate their
URI and throw on a bad configuration; that surfaces as `null`, the notice
skips the comparison and the guide falls back to the fixed path.

Tests: a map test proving the explicit override wins and the derived form
is used otherwise, a component test rendering the notice from a passed
value (mismatch renders, match does not), and a structural guard that
fails when any "use client" file under src/components/settings/integrations
reads NEXT_PUBLIC_APP_URL.
2026-09-01 21:00:12 +02:00
Timon_bed 3453bfe101 fix: warn about mismatched OAuth callback URLs 2026-08-30 15:00:24 +02:00
aucun6352 f1b0420479 feat(i18n): add Korean as a seventh end-to-end locale
Register `ko` in the locale list, language names, and labels, and ship
messages/ko.json so the whole UI — dashboard, settings, share views,
clinician PDF, notifications, and AI prompts — renders in Korean.

Extend the locale-parity, English-leak, plural-completeness, and copy
guard tests to cover the new locale, and update README/CONTRIBUTING to
list Korean among the supported languages.
2026-08-26 16:42:45 +09:00
Marc-André Bombeck a3561b4600 Tell a first-run what Apple Health is actually doing
Refs #778. Two honesty gaps from the first-run report: the web onboarding
presented Apple Health like a web connector although the connection only
completes in the iPhone app, and the initial backfill gave no sign of
progress, so a working first sync looked broken.

The onboarding source step now says plainly that Apple Health is set up in
the iPhone app after onboarding, without dead-ending the flow. The
integration card gains a server-authoritative progress summary — rows
accepted, oldest reading reached, and an honest flowing/waiting line
derived from the last accepted batch. Nothing the server does not know is
shown: no percentage, no ETA, no invented throttle state; a quiet stretch
reads as waiting for the phone, not as an error.
2026-08-14 09:46:24 +02:00
Marc-André Bombeck 487eb6970c Polish the copy the audit flagged across all six locales
- Singular variants for the bulk-delete toast and confirm dialog
  (measurements + mood); the call sites branch on a count of one.
- Consent confirm buttons shortened so they no longer overflow the
  small button on phones.
- Workouts empty-state CTA is source-neutral instead of assuming an
  iPhone with Apple Health.
- "Pills" is no longer a navigation term in a medication app: DE says
  Navigations-Chips, EN chips, PL chipy; ES/FR/IT subtitles align with
  their own established terms.
- Health-score insufficient hint drops the translationese and fixes the
  pronoun.
- Side-effect tag helper uses the established Stimmungs-Tags term.
- Success toasts that are single fragments lose the terminal period
  (37 keys, all locales).
- Integration pill parked state short enough to survive truncation.
- The iPhone-managed reminder badge carries a short label; the full
  sentence moved to a helper line below instead of clipping mid-word.
2026-08-14 05:02:49 +02:00
Marc-André Bombeck b6332a96d9 refactor(ui): route hand-rolled card headers through the shared header primitives
Move the last hand-rolled tile and section headers onto TileHeader and
SectionHeading so every card speaks one header language: the vorsorge
dashboard tile, the three illness cards, the metric-correlation section,
and the custom-metric list group. The mood-chart title drops to the
primitive semibold weight and its bucket label becomes a TagChip.

Align the in-card sub-headings of the integration cards to the one
sanctioned section-label tier (muted uppercase eyebrow) instead of a
second text-sm font-semibold dialect.

Keep the onboarding and mental-health wizard hero H1s at their
deliberate semibold weight, now marked inline as an intentional
exception so a future heading sweep leaves them alone.
2026-08-09 11:03:43 +02:00
Marc-André Bombeck ba0cd63cf5 refactor(i18n): a description is one sentence, in all six languages
Measured before the sweep: 317 description and hint strings under the
`settings` and `admin` namespaces, median 83 characters, 113 of them two
sentences or more. The rule the design standards now carry is one sentence,
guideline 120 characters, and this is the sweep that makes it true.

Almost nothing was retranslated. A second sentence is already written in
every language — it is just in the wrong slot. `scripts/split-description.mjs`
cuts a description at its first sentence boundary in all six bundles at once
and either drops the tail or files it under its own key; the twenty-one keys
that moved now render in the card body as foreground `text-sm`, which is
where multi-sentence prose belongs and where a reader can actually read it
(the slot is muted `text-xs`). Eight tails were dropped outright, because
"Off by default" under a switch, "Topics and frequency are adjustable" above
the topic switches, and "Only admins can create invitations" on an
admin-gated route are not information.

Twenty-eight keys are gone: the privacy page's intro, which was a table of
contents for the four cards under it; two About descriptions that listed
verbatim the labels and the buttons beneath them; a card description that
repeated its own title; the API-token sentence that said what the paragraph
below it said with the actual instruction; and the eight integration
secondary sentences. Six descriptions were rewritten shorter in all six
languages where trimming was honest rather than lossy.

On the strings that changed: 4391 characters of English down to 2290, and 32
multi-sentence descriptions down to zero.

The rule is now a gate. `card-description-length.test.ts` reads the keys that
actually reach a card header's description slot — statically, from the JSX —
and checks them in EN and DE, because a rule that only holds in English
produces a slot that wraps to two lines in half the locales. Its exception
list is empty and carries a reason requirement plus a test that removes a
stale entry. Verified by putting a second sentence back: it fails.
2026-08-07 20:53:05 +02:00
Marc-André Bombeck b2e1aa0cde refactor(settings): one header, one action row, one integration card
The sweep that follows from the primitives. Nothing here is new behaviour;
every change removes a second way of saying something the surface already
said one way somewhere else.

Headers. The two collapsible security cards stop rebuilding
`SettingsCardHeader` by hand to carry a chevron — the chevron rides inside
the title button, and the description moves out of the collapsed region, so
the first paint is no longer a bare title. The import card shell, the export
and import section headings, and the four free-standing group headers stop
inventing their own type scale: a heading above a GROUP of cards is
`SectionHeading` (`text-base`), a card title is `SettingsCardHeader`
(`text-lg`), and the step between them is what says which is which. The
targets card gets a title instead of a naked icon.

Actions. Every card-level row is `SettingsCardActions`. The primary action
comes out of the header's status slot in six cards (password, web push,
pillar order, coach memory, and the two danger-zone triggers) and out of the
middle of the card in three more; the reset that hung in the dashboard-layout
header joins save as a secondary in the same row. Result and status lines
move ABOVE the row — sources' cross-link, the invite form's two status lines
— because nothing follows a card's actions. Revoking a share link is
`variant="destructive"` rather than an `outline` hand-tinted red in two
different ways. The import row had four variants for four buttons; it has
two. Export downloads are the card's primary, not an outline link.

Integrations. One description sentence with the setup-guide link inline: the
second paragraph was byte-identical across six of eight cards, which is the
tell that it said nothing about the provider it sat under. The `<hr>` under
the header — which existed only in this folder — is gone. So is the hint that
pointed at a guide 40 px above it, and the second format for "not
configured". Connect is one button everywhere: primary, `sm`, in the action
row, instead of primary-and-small in three cards and outline-and-large in
four.

Plus the standards leftovers the audit listed: two `pl-5` list gutters, one
`scroll-mt-20` anchor, a purple header icon, `text-[10px]` badges, four
hand-rolled callout shells that `SettingsInfoTile` already paints, and the
two inline buttons that never had the mobile tap floor at all.
2026-08-07 19:39:11 +02:00
Marc-André Bombeck 5445bfc1f1 refactor(ui): give Settings and Admin one card rhythm and one page header
The two consoles used the same primitives and still read as two systems,
because everything without a primitive had drifted: the header-to-body
step existed in three competing techniques at once (`mt-4` on the first
body child, `space-y-4` on the card, `mb-4` on the header), the body
started either at the card edge or 28 px in, and both shells hand-rolled
the page title instead of calling `PageHeader`.

Move the decisions into the primitives so a call site cannot re-open them:

- `SettingsCard` is gap-based like `Card` (`flex flex-col gap-4`). The
  header-to-body step is the container's, not a margin somebody remembers.
  A dense card still overrides the gap on the card itself.
- The body starts at the card edge. `pl-7` is gone — off the spacing scale,
  and it mis-aligned action rows against their own forms wherever two
  neighbouring cards disagreed about it.
- The card header's description slot loses its `space-y-1`. That was the
  licence call sites used to stack explainer paragraphs into a muted
  `text-xs` slot; the slot holds one sentence.
- New `SettingsCardActions`: one action row, trailing edge, last thing in
  the card. `align="start"` spells the one exception, a card whose only
  actions are links out.
- New `StatTile` replaces four hand-rolled label/value boxes in Admin.
- `SectionHeading` moves from `insights/` to `ui/`. It is the heading above
  a group of cards, which is what Settings needs; Settings had been using a
  card header with no card, which reads as a card that failed to paint.

Both shells now render their heading through `PageHeader`, which gained
`headingAs="div"` for the second copy each shell paints (mobile above the
nav strip, desktop inside the grid) so only one is a real `<h1>`. The
page explainer is muted everywhere: the primitive said foreground, the
standards and every hand-rolled copy said muted, so pages disagreed with
each other. Medications, Vorsorge, the labs / custom-metric / illness
detail pages and the clinician view drop their hand-rolled title blocks
too, including the mid-word truncation on the medications subtitle.

Tests pin the parts that can be checked exactly: the card's gap, the
description slot's missing rhythm, the action row's two alignments, the
`<h1>`-free shells, and a sweep asserting `pl-7` stays gone.
2026-08-07 19:05:15 +02:00
Marc-André Bombeck 9354c649fe fix(integrations): surface Google sync progress and canonical workouts 2026-07-29 15:55:37 +02:00
Marc-André Bombeck 67eeefd584 fix(i18n): translate final security and import states 2026-07-29 15:07:35 +02:00
Marc-André Bombeck ba6d06be3b feat(01-21): expose Google sync terminal status
- return redacted per-resource outcomes from manual sync
- poll owner-scoped progress and invalidate committed workout views
2026-07-29 15:01:13 +02:00
Marc-André Bombeck 6f84629482 test(01-13): add red Google sync terminal contracts
- cover authenticated status polling and bounded resource outcomes
- prove committed workouts invalidate server and client cache surfaces
2026-07-29 14:22:49 +02:00
Marc-André Bombeck 27a3e08436 fix(01-07): make private Nightscout access operator-owned
- remove the user-facing private-host bypass
- render a redacted operator-action state for denied legacy connections
2026-07-29 13:57:48 +02:00
Marc-André Bombeck 5569b8259b test(01-04): pin Nightscout operator-action UI
- Remove the user-facing private-host bypass contract
- Require redacted operator approval guidance for legacy failures
- Preserve usable public and approved-private connected states
2026-07-29 13:39:50 +02:00
Marc-André Bombeck 8ece6f39cc fix(status): expose background failures 2026-07-28 14:00:44 +02:00
Marc-André Bombeck 6911f115bc fix(integrations,labs): let the write decide how a sync result reads
Six integration cards and the lab-OCR dialog keyed their tone off `res.ok`.
A sync that imported nothing because every reading was refused rendered a
green tick and a count, and a re-scan in which every confirmed row turned out
to be a duplicate raised a green toast saying zero readings were saved.

Each surface now renders the outcome the route resolved: a tick only when
everything landed, a warning when some of it did not, an error when none of
it did, and a neutral note when there was nothing new. The lab-OCR toast
picks its variant the same way.

Replaces the directory-scoped import guard with one that covers the whole
component tree and pins per-file occurrence counts, so a listed file cannot
quietly add a second tick. Its docstring states what it cannot catch.
2026-07-27 23:06:44 +02:00
Marc-André Bombeck d222aa2354 test(measurements): pin the null-trigger stamp on a batch that declares none 2026-07-27 14:35:34 +02:00
Marc-André Bombeck 4e95df9e8d feat(integrations): tell the Apple Health card how the last sync arrived
The batch route has accepted a `syncTrigger` since v1.32.8 and put it on the
wide event, where no human reads it. Half of what was promised got built: the
value is written and appears in three places in the codebase, none of which is
a screen anyone looks at.

Persist it on the account, alongside the instant of the most recent batch that
arrived without the app being opened, and publish both from
`GET /api/integrations/healthkit`. The card now names how the last sync arrived
and when data last came in on its own. `healthKitLastSyncedAt` alone reads
identically whether the phone delivers by itself or only while someone has the
app open, which is why "is background delivery working" could not be settled
from either side.

A batch that declares no trigger writes null rather than leaving the previous
one standing — the field describes this sync, and an older client's silence is
not evidence about it. A foreground batch leaves the background timestamp
alone; overwriting it would erase the only answer the card has.

Also here: WHOOP, Fitbit and Google Health join the workout-freshness table.
All three write workouts, and without an entry their cards could report every
measurement type fresh while the workout leg had been silent for weeks. And the
status pill drops a generic `aria-label` that replaced its own accessible name,
so a screen reader announced the same words for a healthy connection as for a
broken one.
2026-07-27 14:35:33 +02:00
Marc-André Bombeck 06935256b0 feat(integrations): let the ledger hold every failing leg at once
`failing_leg` was one slot, so two legs of the same provider failing in
overlapping windows overwrote each other. The row remembered whichever failed
last, and that leg recovering then cleared an error the other one was still
causing — the ledger forgot a live failure and painted the card green over it.

The slot becomes a set. A failure adds its leg, a success removes its own, and
the error state, the per-kind buckets and the streak anchor clear only once the
set is empty. The array is ordered oldest failure first, so the last element is
the leg that wrote the message `last_error` holds; when that leg recovers while
others are still failing, the message is cleared rather than left describing a
leg that is now healthy.

An unattributed failure adds nothing and removes nothing. A single-leg provider
therefore behaves exactly as before, and a multi-leg provider no longer loses
what its other legs already reported.

The column never reached a client — it is absent from the status serialiser and
from the OpenAPI contract — so it is replaced rather than shimmed.
2026-07-27 14:35:33 +02:00
Marc-André Bombeck 527dd6c1fa fix(integrations): escalate a long transient failure streak, and scope a success to its own leg
A sync failure the classifier bucketed as `transient` had no exit. The park
test read `kind === "persistent"` and the persistent streak anchor was only
ever stamped by a persistent failure, so a streak could run for a thousand
attempts with the persistent counter at zero, the state frozen at
`error_transient`, and no ladder it could climb. `transient` is a hypothesis
about retryability, and the classifier defaults every timeout, 429, 5xx and
unrecognised response to it precisely because the wire cannot tell a reboot
from a permanent break. That default is right for one attempt and wrong for a
thousand: an unbroken streak retried hourly for a full day has refuted it by
experiment. Record it as `persistent` from there, which feeds the park ladder
that already exists — 48 h of unbroken failure end to end before an
integration stops attempting.

The counters could not accumulate anyway. `IntegrationStatus` is keyed
(user, integration) while Withings runs four legs on four crons and WHOOP four
resources on four more, and a success cleared the error, every bucket, the
streak anchor and the state whatever leg had recorded them. The ECG tick at
:41 wiped what the sleep tick recorded at :15 — including a reauth park — so
the strike ladder was reset several times an hour and the card painted a green
"connected · 12 minutes ago" over a data class that had stopped arriving. A
success now clears only what it is entitled to clear.

Two columns carry it: `failing_since_at` dates the streak, `failing_leg` names
its owner. The migration backfills the anchor for rows already in an error
state so a connection broken for weeks escalates on its next attempt rather
than restarting its clock.
2026-07-26 14:19:13 +02:00
Marc-André Bombeck d8a3c08e12 fix(i18n): stop one key template from whitelisting a whole namespace
The reverse-coverage guard asserts that every key in the bundle is reachable
from a call site. It had stopped doing that for most of the bundle. A template
whose only static anchor was a namespace root registered that root as a subtree
base, so a single per-provider OAuth key marked every key under `settings.*`
reachable, and the same shape did it for `measurementReminders.*`. A cache-key
string that happens to start with a namespace word had quietly done it for
`insights.*` too. The guard stayed green throughout, which is the part worth
noticing: a pair guard degrades into a one-end guard without any signal.

Extract the matcher into a helper with its own unit tests, and change what it
accepts. A template carrying both a literal prefix and a literal suffix now
records the pair and requires both ends to match, so the OAuth template covers
the provider keys and nothing else. A template with no suffix whose anchor is a
bare namespace no longer whitelists anything — it fails the guard by name, with
the two honest remedies in the message. The same door is used by the template,
assignment and concatenation arms, so none of them can reopen it. Template
bodies are read by scanning forward from a known opening backtick rather than
by pairing backticks across the tree, which was drifting and dropping whole
files out of the scan.

Three call sites build their keys whole instead of interpolating onto a
namespace root: the two measurement-reminder due helpers, the mental-health
option labels, and the OAuth error copy, which now names its per-provider base.
Two audit-log action names that merely look like keys carry a written
exemption; the API error catalog is a runtime lookup and is allowlisted with
its reason.

With the matcher honest, 149 keys had no call site left. They belong to an
explainer popover, a hypnogram, a passkey table and an insights hero that were
all rebuilt or removed, and they are deleted from all six locales. The
explainer guard stops pinning its title leaves, which no surface has rendered
since the popover went.
2026-07-25 16:23:58 +02:00
Marc-André Bombeck 933dfcfdfb refactor(ui): mint the text-2xs token and the neutral TagChip
Three integration cards carried the same hand-copied chip literal in the
card-header accessory slot, each free to drift on the next touch; the size
itself was an arbitrary value repeated at three slightly different spellings
across the tree.

- Mint `--text-2xs` (0.6875rem with a matched line-height) as the one
  sanctioned step below `text-xs`.
- Mint `TagChip` and adopt it on the Fitbit, Google Health, and Nightscout
  cards. It stays distinct from `Badge`, which carries status meaning.
- Sweep the arbitrary micro sizes: the stepper label and the two dose-ledger
  outline badges move onto the token; the muted target caption and the muted
  ledger meta row move up to the `text-xs` muted floor.

No visible change beyond the two muted rows gaining a fraction of a pixel.
2026-07-25 14:00:25 +02:00
Marc-André Bombeck d0b45e36b3 feat(integrations): remove the moodLog integration
Mood has been a first-class module here for a long time, with entries,
structured tags, rated factors and notes. The bridge to the external moodLog
service had nothing left to add, and its Settings card was withdrawn in
a56e6fe61 on the promise of a dedicated removal. This delivers it.

Removed: the hourly `moodlog-sync` queue, cron and handler binding; the
inbound webhook and its shared-secret handling; the reverse push and its four
call sites; the pull-sync module; the per-user credential routes and the
instance-wide toggle; the entry on the `/api/integrations/status` envelope,
the `IntegrationKey` union, the cadence map and the display-name table; the
boot-time secret rotation pass; 22 message keys per locale.

Migration 0271 drops the five `users` credential columns and
`app_settings.mood_log_global`, clears the ledger row for the provider, and
deletes the retired pg-boss schedule so no cron keeps enqueuing work no
handler drains.

Mood data is untouched. `mood_entries` is not named anywhere in the migration
and cannot be reached from it: the dropped columns carry no index, constraint
or foreign key, and `mood_entries.user_id` references `users(id)`, not any of
them. Rows the bridge imported keep `source = 'MOODLOG'`, and every read path
- list filter, source badge, exports, backups - still resolves that value.

`src/lib/validations/moodlog.ts` held the native mood-entry schemas alongside
the bridge's own; it is now `validations/mood.ts` and carries only the former.
2026-07-25 11:36:18 +02:00
Marc-André Bombeck 10593a11a8 test(integrations): cover the verdict ladder and the coverage guarantee
The load-bearing one is the panel-coverage test: render the panel with an
envelope carrying every integration key and require each configured one to
surface as a card or as a fallback row. Drop the fallback row and it goes red,
which is the whole point — the previous shape of this bug was a provider on
the wire that no screen rendered, and no card-shaped test could have caught it.

Alongside it: the ladder table with the month-silent fixture that resolves to
stalled, the 24-hour and 7-day boundaries asserted against literals rather than
the constants they check, the per-metric stale flag firing only under a healthy
provider, and a structural guard that the verdict-to-pill mapping is defined in
exactly one module.

The card suites migrate with the semantics: a transient failure now expects the
warning pill everywhere, and the Apple Health suite trades its bespoke badge
states for pill assertions. The e2e mocks gain the verdict shape, the
nightscout entry, and an assertion that the card-less row is on the page.
2026-07-25 09:41:53 +02:00
Marc-André Bombeck ce74fd52a2 feat(settings): render the sync verdict and per-metric freshness
Three `pillStateFor` implementations disagreed with each other, so the same
ledger state painted red on one card and amber on the next, on one page. All
three are gone; the client projects the server verdict once.

Red now means "your action fixes this" and nothing else. A transient upstream
failure carries the warning tone: telling a user to reconnect against a 503
sends them clicking at something they cannot repair, and the detail belongs in
the inline error line anyway. Three new pill states carry the rest — no recent
data, sync stopped, waiting for first data.

Per-metric freshness has been computed on every status call since v1.27.30 and
rendered nowhere. Every connected card now opens a disclosure listing each data
type the connection has delivered and how long ago, with a quiet one flagged.
Collapsed by default so a 25-type card keeps its shape, but the collapsed
summary already names the count, or the signal hides behind a click. A type a
provider never delivered simply is not listed.

The panel iterates the envelope and renders a plain status row for every
configured entry no card claims. That is the structural half: a connection can
no longer be running, failing and absent from the page at once.

Apple Health drops its bespoke badge for the shared pill, gains the divider and
body inset every sibling carries, and says something when a week has passed
with nothing arriving. Nightscout reads the envelope instead of its own status
round-trip. The OAuth card uses the shared error primitive, all nine cards
carry the divider testid, and the four original connectors gain the "view your
data" link the newer ones already had.
2026-07-25 09:41:37 +02:00
Marc-André Bombeck 0bcbb88db1 feat(settings): put a sync-now button on the four cards that lacked one
A route with no caller is an API detail, not a feature. The Oura, Polar,
Strava and Nightscout cards get the same action WHOOP already had, in the
same row as Test connection, rendered only once connected: post the sync,
show the count or the failure, invalidate the reads that depend on
measurements. No full-history dialog, since these providers have no such
arm. Keys added across the six locales.
2026-07-25 04:43:12 +02:00
Marc-André Bombeck 62eb431d66 release: v1.32.0 — hardening and scalability (#580)
* release: prepare v1.32.0

* test: stabilize water capture re-entry
2026-07-22 04:14:15 +02:00
Marc-André Bombeck fb73c97125 fix(onboarding): stop the Withings connect dead-end and disclose BYO setup
The onboarding "Connect Withings" tap opened /api/withings/connect in a new
tab, which returned a raw JSON 400 for every brand-new account (Withings
credentials are per-user BYO with no env fallback, and the wizard never
collects them). WHOOP, Fitbit, Polar, Oura, and Strava shared the identical
raw-JSON-on-no-creds pattern on their own /connect routes, while every other
failure branch already redirected with a toast reason. All six now redirect
to /settings/integrations?<provider>=error&reason=nocreds instead, and the
wizard's Withings card checks the integrations-status envelope first: unless
credentials are already configured, the CTA reads "Set up in Settings" and
links straight to the credentials form rather than launching a doomed OAuth
handshake.

Also surfaces the BYO-developer-app requirement, which was invisible until a
user was deep inside a Settings card. The source-picker step now says
up front that device sources need a free developer app on the user's own
account; each of the six provider cards gets a compact 3-step guide with the
exact callback URL to register, shown until the user has saved their own
credentials.
2026-07-17 23:12:12 +02:00
Marc-André Bombeck 75eb0637e6 feat(strava): add per-user BYO-OAuth workout source + Garmin indirect-path docs
Add Strava as a per-user BYO-OAuth workout source, mirroring the shipped
WHOOP/Oura/Polar pattern. Strava caps every new app at single-player mode, so
each self-hoster registers their own app and pastes the client id/secret into
Settings (falling back to a shared env app). Strava is a workout source only —
it exposes no daily metrics and no training-depth analytics are derived.

- Schema (migration 0233): per-user encrypted client id/secret + access/refresh
  tokens, plaintext athlete id, incremental cursor, and backfill marker on
  `User`; `STRAVA` added to `MeasurementSource`.
- Token model follows Oura's reactive-refresh-on-401 with a compare-and-set on
  the stored refresh ciphertext, because Strava rotates its refresh token on
  every refresh.
- Client over safeFetch: authorization-code + refresh, activity list + budget-
  aware per-activity detail (for calories/HR), honouring the 200/15min cap; each
  activity maps to one `Workout` row. Activity name/description stay in metadata
  as data, never surfaced or interpolated.
- Dedup reuses the existing source-agnostic two-layer picker: `STRAVA` slots
  into the workout ladder below the device-native wearables and above
  MANUAL/IMPORT, so an Apple-Watch + Strava twin collapses to one canonical row.
- Poll-cohort hourly sync (:17) + self-converging boot backfill, wired into the
  integration-sync registrar. Webhook deferred.
- OAuth routes (connect/callback/credentials/disconnect/status/test) with signed
  state, cross-account re-auth rejection, and a surgical `www.strava.com`
  connect-src gated to the Strava surfaces.
- Settings card + consolidated status envelope entry; env + compose whitelist;
  i18n across all six locales.
- Garmin: no direct connector is possible (business-partner-only). Add
  docs/integrations/garmin.md documenting the Apple Health / Google Health
  Connect path and Garmin's withheld proprietary scores, cross-linked from both
  platform docs, plus a non-connector info note in the Connections panel.

Regenerate docs/api/openapi.yaml for the new `STRAVA` MeasurementSource value.
2026-07-09 21:25:25 +02:00
Marc-André Bombeck e5ca125dfb fix(onboarding,integrations,cycle): deep-link source chips + cycle settings wrench
- Onboarding 'more sources' chips anchor to their provider card
  (/settings/integrations#oura etc.); connection cards gain scroll anchors.
  Apple Health (no web card) stays on the page root.
- Cycle module gains a header settings wrench for parity with labs /
  medications / illness; the tab strip is now controlled so the wrench jumps
  to the settings tab and ?tab= deep-links keep working.
2026-07-08 23:59:10 +02:00