Commit Graph
18 Commits
Author SHA1 Message Date
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 b6e34d1264 Close five honesty gaps on the integration surface
Each of these made the server report something that was not so, and each now
has a test that fails without the fix.

The Withings credential DELETE wrote no audit row and left the ledger at
whatever state it last held. Fitbit, Google Health and WHOOP all park theirs,
and three of them say in their own comments that they do it for parity with a
list that never included Withings. A client reading `state` after the teardown
was told `connected` about a connection that no longer existed.

Oura's and Strava's credential DELETEs audited only when an access token
happened to be present, so removing a stored pair that had never completed
OAuth left no trail at all. Polar's twin has always done both unconditionally;
they match it now. The old Oura test asserted the skip, which is why the gap
survived a rewrite — that expectation is inverted here, with the reason next to
it.

The Withings and WHOOP manual syncs had no rate limiter, while `fullSync` walks
full history against a per-user provider budget. The Fitbit route's own comment
records that it was the outlier until it got one; these two were never brought
along. Both now carry the same 5/60s baseline and 1/hour full-sync buckets as
their siblings.

Every sync route parsed its body inside a catch commented "no body provided",
which was true for one of the three ways to reach it. An absent body still
means an incremental run — that is the documented shape and the web cards send
it. A body that is present and unparseable is now 400, and one that fails the
schema is 422: `{"fullSync": "true"}` used to be read as false and answered 200,
so a client that asked for full history and mistyped the value was told the run
it never requested had succeeded. Unknown keys are still ignored. The four
copies collapse into one helper.

The consolidated integration status swallowed a failed freshness query into an
empty array, which is exactly what a record with nothing in it also produces —
so the signal built to say "this metric has gone quiet" answered "nothing has
gone quiet" when it had failed to look. `metricFreshnessDegraded` separates the
two, as one flag rather than one per entry, because it is a single query for all
eight providers.

The contract follows the behaviour in the same commit.
2026-08-22 13:13:18 +02:00
Marc-André Bombeck 4e3b029c38 test(integrations): pin the escalation ladder, leg-scoped clearing, and what the card says
Mutation-checked against the production paths they cover: the escalation
verdict, the leg guard in `recordSyncSuccess`, the streak anchor the verdict
dates itself by, the status envelope threading it, and the pill's relative
timestamp.

Also give the four legacy per-provider status routes the verdict the
consolidated envelope already carries. They answered `connected: true` plus a
`state` string and nothing else, so a client reading one of them saw a live
connection over a pipe that had delivered nothing for weeks.
2026-07-26 14:19:13 +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 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 de47bfa5ba feat(integrations): resolve sync health on the server
The ledger records what the last attempt did. It has no way to say what is
no longer being attempted: a row frozen at `error_transient` for a month
reads exactly like one retrying every hour, because nothing anywhere read
the age of `lastAttemptAt`.

`sync-verdict.ts` derives the missing reading as a pure read-side
projection: eight verdicts down one ladder, no writer, no cron, nothing that
can desynchronise from the row it reads. `stalled` is the new one, and it
deliberately outranks `failing` — an integration nobody is calling is not
failing, it is not running.

Every entry on `/api/integrations/status` carries the verdict, computed
inline from data the route already holds. That placement is the point: a
card-level verdict misses a card-less provider by construction, which is how
a dead sync stayed invisible. Nightscout joins the envelope so its card can
stop fetching its own status.

The freshness module gains the workout leg (Strava writes no measurements at
all) and a source-parameterised core so the Apple Health route can reuse it.
Its per-metric `stale` flag fires only while the provider itself reads
healthy: that combination is the dead-pipe signature, and when the whole
connection has stopped every metric ages together anyway.

MCP reports the verdict beside `connected`, which only ever meant "not
explicitly disconnected".
2026-07-25 09:41:20 +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 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 4c158f6abc feat(integrations): per-metric-type last-value freshness signal
Freshness was tracked only per-integration, so a provider returning a
healthy 200 with an empty body for ONE metric type recorded no failure and
the whole integration stamped green — a metric dead since launch was
indistinguishable from a healthy-idle one. Compute the last-value
timestamp per (source, type) straight from the Measurement table (no
migration) and surface it per integration on the status endpoint, so the
Settings card can flag a silently-dead metric the green pill hides. The
card UI badge + per-cadence staleness classification are the deferred
remainder.
2026-07-08 23:55:35 +02:00
Marc-André Bombeck 0d3067837e fix(google-health): surface the connection status and add the resume and test routes
Wire the settings card to the live connection so it reflects reality
after a connect.

- Emit a google-health entry on the consolidated integration-status
  envelope (configured, connected, last sync, backfill, and the
  needs-reconnect flag). Without it the card read an undefined status and
  showed a permanently disconnected connection even after a successful
  OAuth grant, hiding the sync, disconnect, and reconnect controls.
- Add the resume and test-connection routes the card calls, mirroring the
  classic Fitbit ones.
- Use the shared refresh icon on the reconnect action.
- Drop the experimental-scope opt-in from the runbook and describe ECG
  and irregular-rhythm as a planned addition rather than a current toggle.
2026-07-03 14:41:53 +02:00
Marc-André Bombeck 1f4ba5939d style: apply prettier across the tree
One-shot prettier pass so the new format:check gate starts from a clean
baseline. Formatting only — no behaviour change. Generator-owned and
vendored artifacts stay untouched via .prettierignore.
2026-06-19 22:20:31 +02:00
Marc-André Bombeck 9430e89889 feat(integrations): unify OAuth returns and the status envelope
The WHOOP and Fitbit OAuth callbacks redirect back with
`?whoop=connected|error` / `?fitbit=connected|error`, but the settings page
only read that param for Withings, Polar and Oura — a user returning from a
WHOOP or Fitbit round-trip landed on a silently unchanged page with no toast.
Extend the generic return handler to all four OAuth providers, add the
matching connected/failed/error i18n keys across every locale, and invalidate
both the per-card key and the consolidated envelope on a successful return.

Polar and Oura already write the shared IntegrationKey ledger but each fired
its own `/api/<provider>/status` round-trip from the card, so the page issued
one consolidated request plus three per-card ones. Fold Polar and Oura into
`/api/integrations/status` and let the cards read a passed view-model instead
of fetching, retiring the extra round-trips. The per-provider status routes
stay for the iOS and test callers.
2026-06-15 01:34:30 +02:00
Marc-André Bombeck 53e87ba345 perf: consolidate integration status query, add mood-insights SWR, defer below-fold charts
Settings → Integrations fired five status round-trips on every mount —
the consolidated /api/integrations/status alongside the four per-provider
/api/<provider>/status queries. Extend the consolidated endpoint to carry
every field the four cards render (Withings activity scope, WHOOP/Fitbit
backfill state, moodLog webhook secret + entry count) and drive all four
cards off the single envelope. The legacy per-provider routes stay for the
iOS/test callers; the web cards no longer hit them.

Mood-insights cache was a plain 60 s TTL that was hard-evicted on every
mood write, so an active logger re-paid the multi-second cold compute on
every entry. Add stale-while-revalidate to ServerCache (wrapSwr / cachedSwr
+ a markStaleByPrefix that collapses the fresh TTL without dropping the
value), give the moodInsights bucket a 10-minute stale window, route the
read through cachedSwr, and switch the mood-write invalidation from a hard
evict to a mark-stale so the prior aggregate serves immediately while a
single background recompute warms a fresh one.

Defer the eager Recharts on the mood-insights surface. The trajectory
forecast card pulled Recharts into the initial chunk of every
trajectory-eligible metric page though the main chart is already deferred;
split its fan band into trajectory-fan.tsx behind next/dynamic. The three
below-fold mood mini-charts (distribution / weekday / time-of-day) were
static imports under an already-dynamic hero line chart; defer them with
next/dynamic. Each loader paints a skeleton sized to the chart's own band
so the chunk arrives without a layout shift; the charts stay Recharts and
visually identical.

(cherry picked from commit 2b627893ab6b4d7bc457fc18e6e5585b048190e1)
2026-06-05 03:25:22 +02:00
Marc-André Bombeck f44dccb828 feat(fitbit): add the Google Health settings card with connection test and resume
Give the Google Health (Fitbit & Pixel) integration the same settings-card
parity WHOOP and Withings already have. It is a BYO-key provider — the user
pastes their own Google OAuth client id/secret, then connects — so the card
mirrors the WHOOP flow rather than the server-brokered Withings one.

- New POST /api/integrations/fitbit/test does a live, rate-limited
  (5/60s per user) probe against the Google Health profile endpoint via
  the refreshed token from getValidToken, returning { ok, latencyMs,
  lastSyncedAt } and the same error-code union WHOOP uses. The Bearer
  token and upstream host are scrubbed before any annotation.
- New POST /api/integrations/fitbit/resume clears a parked integration
  via resumeIntegrationFromPark, rate-limited to match the test route.
- FitbitCard renders the credentials form, OAuth connect, sync now /
  sync all data / test connection / disconnect action row, the parked
  resume banner, and a backfill-in-progress line. Status reads from the
  existing /api/fitbit/status; the pill/error/parked state comes off the
  cross-integration envelope, which now carries a fitbit entry.
- Card strings added under settings.fitbit* across all six locales.
2026-06-04 22:37:58 +02:00
Marc-André Bombeck 008160dc72 feat(whoop): settings surface — status, disconnect, credentials, sync routes + connect card
Fill the WHOOP route set beside the OAuth connect/callback: status reports
connection + token expiry + backfill progress, disconnect revokes the stored
tokens and parks the integration, credentials saves/clears the per-user BYO
client id/secret encrypted at rest, sync triggers a manual incremental or full
pull. Add a WHOOP card to Settings -> Integrations mirroring the Withings card
(BYO-keys form, connect/disconnect, status pill, last-sync, backfill notice)
and surface whoop rows in the combined integrations-status envelope so the pill
reflects real failure state. WHOOP lives under the existing integrations
section -- no new slug.
2026-06-04 02:34:33 +02:00
Marc-André Bombeck 66a7f8b6fa chore(release): v1.4.47 — audit-backlog closure + drag-to-reorder + Coach disable + OAuth nonce ledger
Eight changes: W0 idempotency test pin, W1 legacy column drop, W2 Textarea primitive, W3 Coach disable toggle, W4 dnd-kit drag-to-reorder, W5 onboarding 24h gate, W6 Withings OAuth nonce ledger, W7+W8 legacy_form_total drop + Coach onLine pre-check. 6-axis QA reconciled. Migrations 0076/0077/0078 required pre-image. Tests 5093 → ~5170.
2026-05-22 09:18:52 +02:00
Marc-André BombeckandClaude Opus 4.7 4db72a8c2e feat(integrations): connection-state + sync-error UI in Settings → Integrations
v1.4.15 Phase B2, criterion 1. Until this release the Settings →
Integrations card showed "Connected / Last sync 23 min ago" and
nothing else. A user whose Withings refresh token had revoked saw a
green badge; a user whose moodLog instance was down saw the same
green badge with a slightly older timestamp. The signal was useless.

This commit adds:

  - New `IntegrationStatus` table (`integration_statuses` migration
    `0029`). One row per (user, integration) capturing `state`
    (`connected | error_transient | error_reauth | disconnected`),
    `lastSuccessAt`, `lastAttemptAt`, encrypted `lastError`,
    `consecutiveFailures` counter, `alertedAt` window-guard.
  - `src/lib/integrations/status.ts` — the only writers
    (`recordSyncSuccess`, `recordSyncFailure`, `markReauthRequired`,
    `markReconnected`, `markDisconnected`) plus a read-only
    `getIntegrationStatus()` for the API. The error column is
    AES-256-GCM encrypted at rest because Withings/moodLog 401s can
    echo URL/apiKey fragments — we can't ship that into a future
    backup tarball.
  - `GET /api/integrations/status` — single roundtrip returns both
    Withings and moodLog snapshots plus the global threshold (so the
    UI's "{n}/{threshold}" string is server-sourced and rotates with
    the env var).
  - Settings → Integrations cards both gain an
    `IntegrationStatusBanner` row above the credentials form: state
    badge ("Connected" / "Reconnect required" / "Error" /
    "Disconnected"), `{n}/{threshold} consecutive failures` chip,
    last-success / last-attempt times, and a destructive-toned
    last-error line. The banner is suppressed entirely when the
    integration is fresh-connected with no history (no false alarms).

Tests: +20 unit (status helper threshold + alert-window state machine,
banner rendering across all four states) + 8 integration
(`tests/integration/integration-status.test.ts`) confirming
encrypted-at-rest, audit-log row shape, alert idempotency window,
and CASCADE-delete on User. 116 files / 883 unit pass, 11 integration
files / 39 pass.

Encryption uses the existing `src/lib/crypto.ts` AES-256-GCM helper —
no new key material. The `consecutive_failures` counter and
`alerted_at` window are wired to the dispatcher in the next commit
(`feat(integrations): admin Telegram alert on persistent sync
failure`).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 21:04:04 +02:00