953 Commits
Author SHA1 Message Date
Marc-André Bombeck 3186ecbe67 fix(settings): raise the Google Health tag contrast and update the integration-count assertion
The Fitbit & Pixel tag chip paired muted text on a muted background (below
WCAG AA); use foreground text. Update the mobile integrations e2e to expect
the fourth integration card (Google Health).
2026-06-05 01:33:42 +02:00
Marc-André Bombeck 554d97ec95 fix(fitbit): wire the activity page-size constant and drop dead exports
Use FITBIT_ACTIVITY_PAGE_SIZE for the sleep and exercise reads instead of a
magic 25, remove the unused FitbitDataTypeKey type, and declare the crosstab
day floors independently so they aren't a duplicate re-export of the influence
floors.
2026-06-05 01:25:39 +02:00
Marc-André Bombeck 34061236e7 refactor: simplify the capture picker without behavior change
Drop the no-op single-argument cn() wrapper on the chooser button
class (and its now-unused import), and replace the three-way nested
ternary that resolves the form-sheet title with an explicit
per-kind lookup map. The null-kind fallback keeps the prior
ternary's mood label, and the sheet stays closed in that state so
the title is never read.

(cherry picked from commit 856e620754753570e0d4ce8d6ce982778e929b75)
2026-06-05 01:17:57 +02:00
Marc-André Bombeck 6657e3da6f fix(ui): elevate the capture FAB and unify selectable-control and export-card styling
Lift the bottom-bar center capture action into a proper floating-action
button: a 56px target raised out of the bar with a card collar ring and
a stronger shadow, so it reads as the primary CTA rather than a fifth
flush tab. Desktop sidebar nav is unaffected.

Unify the selectable controls across the mood logging surface onto one
design language: the 5 mood faces, the tag tiles, the rated-factor steps
and the GLP-1 chips now share a single selected-state treatment
(border-primary, bg-primary/15, primary text, single border) and a
consistent rounding family. Match the More-hub row height to the capture
tiles so equivalent tappable rows share one min-height at the same tier.

Give the demoted doctor-report export card a primary-tinted icon to match
its peer cards — the demotion comes from position and size, not a muted
icon. Drop the doubled heading atop the mood sheet by demoting the hero
question to a field label so the sheet title is the single primary
heading. Reword the capture mood option description to describe the
action instead of echoing the hero question.

(cherry picked from commit 1296f151f1c9a7c95ff917e3d69a5c0d369ba210)
2026-06-05 01:05:47 +02:00
Marc-André Bombeck 61573301c3 fix(fitbit): single sync watermark, reject empty refresh token, correct interval time-anchor, mark provider experimental
Snapshot the incremental watermark once per sync cycle and thread the same
lower bound to every resource (metrics, activity, sleep, workout), stamping
lastSyncedAt once at the end. Previously each resource read lastSyncedAt then
stamped now() independently, so the first resource advanced the watermark and
later resources only fetched the last overlap window — silently dropping the
gap after an outage longer than the overlap. The full/backfill path is
unaffected (it passes no lower bound).

Reject an OAuth callback whose token response carries no refresh_token instead
of persisting an empty string. An empty refresh token bricks every future
token refresh; the callback now errors out cleanly so the user re-consents
rather than landing a dead connection.

Model the INTERVAL data types (steps/distance/calories/floors daily totals,
sleep and exercise sessions) on Google Health's interval.start_time /
interval.civil_start_time anchor instead of a sample_time / bare date. The
incremental filter for sleep/exercise previously targeted
sample_time.physical_time, which 400s/empties for interval types and stalls
incremental sync. Daily-summary metrics and spot readings keep their existing
date / sample anchors. Shape mismatches still fail safe (return []/null).

Mark the Google Health (Fitbit & Pixel) settings card as experimental with a
badge and a short note that data coverage is still being verified, in all six
locales.

(cherry picked from commit 3f116802149eebe9144729e2779f7f4405f248f2)
2026-06-05 01:05:47 +02:00
Marc-André Bombeck e77e8ae705 fix(mood): assert tag-link entry ownership and guard mood migrations with IF NOT EXISTS
Thread the acting user id through createTagLinks, replaceTagLinks, and
replaceRatedFactorLinks, and assert the target MoodEntry belongs to that
user before writing any link. The check runs against the same client (tx
or singleton) as the write, so it shares one snapshot. Behaviour is
unchanged for the existing POST and bulk paths, where the entry is freshly
created and owned; the guard closes a latent cross-user link hazard for
the planned PATCH mood-edit route. A mismatched or missing entry throws
MoodEntryOwnershipError before any catalog or join touch.

Add IF NOT EXISTS to every ADD COLUMN in migration 0119 so a clean replay
is idempotent, matching the 0117 convention.

(cherry picked from commit 1bf983f6c2c5e44796c99983d57c1b1d90cd7099)
2026-06-05 01:05:47 +02:00
Marc-André Bombeck cfb97593a3 feat(insights): add tag-by-metric crosstab and deprecate the moodLog webhook
Extend the mood-relations surface from mood to a health metric. For each
structured mood tag present on enough days, compare a metric's value on
tag-present vs tag-absent days: active energy on workout days (same day),
sleep length on nights tagged sleep (same day), and next-day recovery
after a tag like alcohol or food (D to D+1 lag). The comparison reuses the
existing statistics engine wholesale — the Welch two-sample t-test, the
per-group day floors, the confidence band, and the Benjamini-Hochberg FDR
control already used by the influence and discovery boards — so no new
statistics code lands. Only pairs that clear p < 0.05 and the FDR control
surface, ranked by adjusted q then absolute delta and capped at eight rows.

The result rides on GET /api/mood/insights as a new additive
`tagMetricCrosstab` field and renders as a confidence-gated, observational
card on the mood insights page, framed as an association in your own data
rather than a cause. The endpoint is server-rendered and not in the OpenAPI
contract, so no contract change is required.

Deprecate the standalone moodLog integration. Mood is now tracked fully
inside HealthLog through entries, structured tags, and rated factors, so
the external webhook and reverse-sync bridge no longer add anything. The
webhook route, the push and pull helpers, and the validation schemas carry
an @deprecated note pointing at the native path; the surface stays
functional this release and is slated for removal in a future major. The
settings card shows a deprecation hint, localised across all six locales.
2026-06-05 00:33:15 +02:00
Marc-André Bombeck 65b00e3a73 feat(dashboard): add a wrench shortcut to the dashboard settings beside the add button
Place a monochrome ghost wrench icon-button immediately to the left of
the dashboard add button, linking to the layout/tile editor at
/settings/dashboard. The button carries an aria-label and matches the
add button's 44 px mobile touch-target floor, shrinking to the icon
footprint on sm+. New customizeDashboard string lands in all six
locales.
2026-06-05 00:25:57 +02:00
Marc-André Bombeck 280bf42382 feat(nav): add the center capture action and More hub, move unit preference into the profile
Reshape the mobile bottom bar to the five-slot shape the native client
uses: Home · Meds · Log · Insights · More. The center "Log" slot is a
capture action rather than a destination — it opens a picker that routes
to the existing measurement, medication-intake and mood quick-entry
sheets (those forms already share a mount contract, so the picker reuses
them verbatim). Expand the former overflow into a real "More" hub that
holds the rest of the top-level destinations: Measurements, Mood,
Workouts, Achievements, Notifications and Settings.

The change is additive — Measurements and Mood leave the always-visible
strip but stay reachable through the More hub and the capture picker, so
nothing loses its route. On desktop the sidebar still lists every
destination.

Move the metric/imperial unit system from its standalone card into a
dropdown in the Profile form beside the timezone picker — it is a
display preference like language and timezone. Persistence is unchanged:
the dropdown PATCHes /api/auth/me/unit-preference on change and
invalidates the same query keys, so chart display transforms re-render
without a reload. Retire the now-unused card.
2026-06-05 00:08:07 +02:00
Marc-André Bombeck 488cc09ae9 feat(coach): add a full-page conversation mode with a maximize control
Extract the Coach chat surface (header actions, message thread,
composer, provenance rails, settings, mobile trays) into a shared
<CoachConversation> component so the drawer and a new full-page route
render the exact same implementation — no forked chat logic. The drawer
keeps only its <Sheet> chrome (close button + maximize control); the
page supplies a minimize-back-to-drawer control.

The maximize control in the drawer header navigates to /insights/coach
and closes the drawer (aborting any in-flight stream); the page's
minimize control routes back to /insights and reopens the drawer through
the shared launch context. Both surfaces preserve incremental streaming,
collapsible history + provenance, the single composer disclaimer, the
auto-grow composer, refusal / budget handling, and the aiMode gating —
the full page redirects to /insights when the operator flag is off or
the user has opted out.

Lift useResettableValue / nextResettableValue into their own module so
the shared surface seeds its composer from the same controlled-prefill
contract without importing the drawer shell. Register the new route in
the coach feature-flag and per-user-disable gate-site invariants, and
add the maximize / minimize labels across all six locales.
2026-06-05 00:05:20 +02:00
Marc-André Bombeck e0f60d82cc fix(medications): correct weekly-cadence compliance returning 0% despite recorded intakes
A weekly injectable (e.g. a GLP-1 dose) with real recorded intakes
reported 0% compliance on the medication card across every window
(7 / 30 / 90 days). The web reads the server-computed rate verbatim,
so the server value itself was wrong.

The intake-to-slot matcher used a fixed +/-12h pairing radius. That is
correct for a daily cadence (24h inter-slot gap) but far too tight for a
once-weekly dose: a real intake is rarely logged within 12h of the
schedule's configured time-of-day, because the user takes the shot on
whichever day and time of the dosing week suits them. Those intakes fell
outside the radius, every weekly slot read "missed", and the rate
collapsed to 0% while the matching intakes were orphaned.

Scale the pairing radius with the cadence gap. `pairDoses` now derives a
per-slot radius from half the distance to its nearer neighbouring slot
(so two adjacent slots never double-claim one intake), floored at the
12h base so daily and multi-dose-daily cadences keep their exact prior
behaviour. `buildCadenceTimeline` additionally probes the schedule's
intrinsic cadence over a padded window and passes half the minimum
inter-slot gap as a radius floor, so a window holding a single expected
slot (a weekly med over a 7-day window) widens correctly too.

A weekly cadence now pairs an intake logged anywhere in the dosing week
to that week's slot, reporting the true rate. Daily-cadence matching is
unchanged.

Adds unit coverage for the widened radius and a real-Postgres integration
test mirroring the weekly-injectable row + off-time intakes.
2026-06-04 23:34:25 +02:00
Marc-André Bombeck e9eb7dc704 feat(mood): rebuild logging as a 5-face hero with tag picker and factor ratings
Replace the score-number radio + free-text form with an icon-first
"How are you?" hero that matches the native client: a row of five mood
faces in best-on-the-left order is the primary input, and picking one
reveals a brief annotate panel (time, the category-grouped tag picker,
factor ratings, and the note field).

The tag picker now reads the catalog's `kind` / `scaleMin` / `scaleMax`
metadata. BINARY tags stay icon-above-label toggle tiles sent as
`tagKeys`; RATED factors (work / social / sleep quality / stress, and
the 1..2 Yes/No conflict factor) render a compact 1..scaleMax segmented
control and are sent as a parallel `ratedFactors: [{ key, rating }]`
array. The entries list / history stays reachable below the hero.

Add the mood-face icon map (Laugh / Smile / Meh / Frown / Angry) and the
`heroQuestion`, `factorYes`, `factorNo`, `factorRatingOption` strings
across all six locales.
2026-06-04 23:23:14 +02:00
Marc-André Bombeck 2bb810852e refactor(insights): de-duplicate and reorder the overview to match the per-metric pages
The per-metric pages are the canonical home for each metric's primary
tile, target band, and correlation card. The overview was still
rendering its own correlation row (weight × weekday, mood × pulse,
BP × compliance) — the exact cross-view duplication to kill now that
the Weight and Pulse pages own those cards via MetricCorrelationCard.

Remove the on-overview CorrelationRow (component + test) and reorder the
overview toward the iOS Insights structure: hero (coach + briefing +
prompt chips) → detailed daily briefing → dynamics/alerts (today's
signal + rhythm events) → vitals dashboard → trends → period
retrospective → warm-assessments footer control. The overview carried
no target-tile grid, so nothing to remove there.

Drop the now-dead insights.correlationRow.subtitle key across all six
locales; title and disclaimer stay (the per-metric correlation card
still uses them).
2026-06-04 23:22:37 +02:00
Marc-André Bombeck 81096b9553 feat(mood): add rated mood factors (ingestion + write contract)
Extend the structured mood taxonomy so a tag can carry a magnitude. A
rated factor is a MoodTag of kind RATED that the user scores per entry;
the score persists on MoodEntryTagLink.rating. Binary tags keep
kind BINARY and a null rating, so every existing row and read path stays
byte-identical.

Migration 0119 is additive: four marker columns on mood_tags (kind,
scale_min, scale_max, inverse), a nullable rating on mood_entry_tag_links,
and a seeded factors category with five MVP factors (work, social, sleep
quality, stress, conflict). Stress and conflict are inverse-scaled
(higher means worse); conflict is a 1..2 Yes/No factor.

Write contract: POST /api/mood-entries and the bulk route accept an
optional ratedFactors: [{ key, rating }] array parallel to the binary
tagKeys. The resolver builds the join rows field-by-field, drops unknown
and non-RATED keys silently, and rejects a rating outside the factor's
own scaleMin..scaleMax (422 on the single-entry path, per-entry skipped
on bulk). Create and list responses surface the persisted factors split
from binary keys so a client hydrates without a refetch. GET
/api/mood/tags now emits kind, scaleMin, scaleMax, and inverse so a
client can render factors versus tags.

This is the ingestion half; analytics over rated factors follow later.
Factor labels ship in all six locales.
2026-06-04 23:09:03 +02:00
Marc-André Bombeck 793602f464 fix(medications): cap intake snap tolerance to the inter-dose gap so multi-dose orals don't collapse
A daily oral with multiple timesOfDay and a wide schedule window had its
dose-slot capture zones overlap, so a second same-day dose could snap onto
an already-actioned slot and be dropped by the no-downgrade guard — the
intake never landed as its own row. A twice-daily 08:00 / 20:00 schedule
with an 08:00-22:00 window yielded a +/-7h half-span tolerance that
exceeded half the 12h inter-slot gap, so the evening write collapsed onto
the morning slot. Weekly and single-dose schedules (one slot/day) were
immune.

snapToleranceMs now caps the tolerance at half the minimum gap between
adjacent sorted timesOfDay entries (the midnight wrap counted as one of
those gaps) whenever a schedule has more than one slot, keeping the two
nearest slots' capture zones disjoint. Single-slot schedules keep the
full half-window-span behaviour, and the >=1-minute drift floor is
preserved, so weekly injectables and once-daily orals are unchanged.

Adds unit coverage for the wide-window two-dose case (distinct slots, no
collapse) plus an explicit weekly single-dose regression, and an
integration test driving both same-day writes through the DB-backed slot
resolver and canonical-slot upsert to assert two distinct live rows.
2026-06-04 23:04:40 +02:00
Marc-André Bombeck d5eb887757 feat(insights): adopt the canonical per-metric detail template and terminology
Bring the web per-metric Insights detail pages to structural parity with
the canonical metric template: a single top-to-bottom spine, every block
self-suppressing when its data is absent.

New spine (above the chart): header (title + target gear + Coach circle)
→ intro → primary tile → Letzte Messung → stat strip; then the chart,
the range controls + period delta, the per-metric correlation card, and
the Einschätzung assessment last.

- Add the primary tile (headline value + 30-day average + an In-range
  bar shown only when the metric carries a per-metric in-target pct).
  Blood pressure keeps its richer target panel as its primary tile.
- Add the Letzte Messung card (labeled heading + last-reading date),
  reading the last-seen timestamp the slim analytics slice already
  carries — no extra fetch.
- Relocate the owning correlation onto its metric page: weight × weekday
  on Weight, mood × pulse on Pulse. The card reads the correlations
  already on the analytics payload and self-gates below the surfacing
  bar; it is now the canonical home for those relationships.
- Move the headline value, 30-day average and in-target share out of the
  target reference panel so each figure appears in exactly one place;
  the panel stays the band reference (range, status, positional bar,
  consistency strip, source). Blood pressure keeps its stitched S/D
  30-day average inline.
- Order the Einschätzung last on every page.

Terminology: adopt the canonical labels — rename the mood patterns
surface to "Was auffällt", pluralise the deviation-count card to
"Signale des Tages", and align the settings section labels to
"Über diese App" and "Quellen-Priorität". New In-range /
30-day-average / Letzte Messung strings land in all six locales.

Web charts are unchanged — Recharts and the existing chart tokens
stay; the iOS monochrome chart doctrine is not applied.
2026-06-04 23:03:05 +02:00
Marc-André Bombeck 6ad0a8a5cf feat(fitbit): sync activity, sleep stages, and workouts from Google Health
Extend the Fitbit / Google Health provider with the remaining sync
resources, driven by the existing syncUserFitbit orchestrator.

Activity (daily cumulative): steps -> ACTIVITY_STEPS, distance ->
WALKING_RUNNING_DISTANCE, the active-energy portion -> ACTIVE_ENERGY_BURNED,
floors -> FLIGHTS_CLIMBED, and VO2 max -> VO2_MAX. Each daily total mints a
stats:<tag>:<YYYY-MM-DD> externalId so a re-fetched day overwrites in place
rather than duplicating, matching the Apple-Health daily-total contract. The
running totals preserve a legitimate zero (a rest day is real data); VO2 max
stays strictly positive (daily latest-wins).

Sleep: map a session's per-stage segments to per-stage SLEEP_DURATION rows
(minutes; measuredAt = the stage end), harmonised onto the shared SleepStage
enum (light -> CORE, deep -> DEEP, rem -> REM, awake/restless -> AWAKE,
in-bed -> IN_BED, classic asleep -> ASLEEP) so the night-total and hypnogram
readers consume the same shape they already read for WHOOP and Apple.

Workouts: map exercise sessions to Workout rows keyed (userId, FITBIT,
externalId), with sport-type, start/end, duration, energy, distance, and HR.
Cross-source twins stay distinct at ingest; the read-time canonical picker
collapses them via the source ladder (Fitbit already ranked below WHOOP).

All three join the orchestrator's resource list and ride the per-resource
403 soft-skip so an independently-granted Restricted bundle that the user
withheld skips only its own resource. Rollups recompute and status-insight
caches invalidate through the shared upsert. No new MeasurementType, no
migration, no new dependency.
2026-06-04 22:57:44 +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 14469a6de9 feat(mood): add hobbies and nutrition tag categories, persist bulk tagKeys
Deepen the structured mood-tag taxonomy and close a bulk-ingest data-loss
gap the iOS client has been blocked on.

Taxonomy (migration 0118, additive + idempotent, mirrors the 0101 seed):
- New `hobbies` category: movies, reading, gaming, music, outdoors.
- Nutrition-detail tags appended to the existing `health` category:
  fast_food, no_sweets, big_meal (alongside the original ate_well).
- Lucide icon names seeded per row and registered in the client icon
  map; labelKeys added for every new category and tag across all six
  locales.

Bulk tagKeys (data-loss fix):
- `POST /api/mood-entries/bulk` previously Zod-stripped `tagKeys`, so
  structured taxonomy links were silently dropped on the adopt-on-pair
  backfill while the single-entry route persisted them correctly. Add
  `tagKeys` to the bulk entry schema and write the `MoodEntryTagLink`
  rows per entry via the shared `createTagLinks` helper — unknown keys
  are dropped against the catalog, the join insert skips duplicates so a
  re-posted batch stays idempotent.

Tests cover the bulk tagKeys round-trip (links created, unknown keys
ignored, over-long arrays rejected at the schema boundary) at the unit
level and against real Postgres.
2026-06-04 22:36:58 +02:00
Marc-André Bombeck 686bc1bf30 feat(fitbit): sync Google Health metrics with backfill and poll jobs
Extend the Fitbit/Google Health provider with the read path: the
dataPoints.list client half, the metrics-bundle sync, the poll + backfill
jobs, and the connection-management routes. Builds on the existing OAuth +
credentials + token-refresh layer.

- client: paginated `fetchDataPoints` walker over `safeFetch` keyed on
  `nextPageToken`, encoding each data type kebab-case in the path and
  snake_case in the filter predicate; defensive per-type mappers (finite +
  positive guards) for weight, body fat, daily SpO2, daily HRV, daily resting
  heart rate, daily respiratory rate, intraday heart rate, sleep-temperature
  derivations, and a height profile-seed extractor. `FITBIT_FIELD_MAP` +
  `mapping.md` are the source of truth.
- mapping (no new MeasurementType): weight→WEIGHT, body-fat→BODY_FAT,
  SpO2→OXYGEN_SATURATION, HRV→HEART_RATE_VARIABILITY (SDNN slot, not
  HRV_RMSSD), resting-HR→RESTING_HEART_RATE, respiratory-rate→RESPIRATORY_RATE,
  heart-rate→PULSE, sleep-temp→WRIST_TEMPERATURE, height→User.heightCm when
  null. Steps/distance/energy/floors/VO2 + sleep + workouts stay for a later
  wave.
- sync: `syncUserFitbit` orchestrator with the reauth short-circuit, the
  per-data-class 403 soft-skip (the six Restricted bundles grant
  independently), the all-soft-skipped success guard, and incremental-vs-full
  windows; `upsertFitbitMeasurements` keyed (userId,type,FITBIT,externalId)
  with a 24 h overlap for after-the-fact daily re-rolls, recomputing rollups
  and invalidating status caches. Google refresh tokens do not rotate, so the
  stored refresh token is kept unless a new one is returned.
- jobs: hourly `fitbit-sync` poll (poll-only; Pub/Sub deferred), the
  self-converging boot backfill, and the daily OAuth-state sweep — every queue
  registered in both `allQueues` and a `boss.work` binding, staggered off the
  WHOOP/Withings crons.
- routes: status / sync / disconnect mirroring the WHOOP shape.

Tests cover the mappers, the casing invariant, `fetchDataPoints`, the
orchestrator soft-skip guard, the idempotent upsert, the backfill discovery,
and the queue registration; an integration test pins the upsert idempotency,
the DAY rollup fold, and the Fitbit-vs-Withings per-source no-collapse against
the Postgres testcontainer.
2026-06-04 22:17:55 +02:00
Marc-André Bombeck 9e6865841d feat(coach): stream replies incrementally, collapsible history and provenance, single disclaimer
Token-by-token streaming was buffering on the server. The chat route
enqueued every tokenised SSE frame synchronously inside the stream's
start() callback, so the runtime coalesced them into a single network
read and the client painted the full answer at once despite the
per-token render path already wired in use-coach. Make the emit callback
async and yield to the event loop between token frames so each one
flushes as its own chunk; the visible cadence now reads ChatGPT/Claude-
style. Refusal and error paths stay single-frame.

The conversation history was a permanent left column on lg+, eating chat
width on laptops and reading as a takeover behind the narrowed drawer
cap. It now lives behind an always-visible "Conversations" toggle on
every viewport and opens as the left-edge tray on demand; the thread
keeps the full width by default and never loses it on mobile.

The provenance block (source chips plus raw key-values) rendered fully
expanded under every answer and was often taller than the reply. Fold
the chips into the existing collapsed disclosure alongside the key-
values so the whole grounding is one click-to-expand surface, collapsed
by default, with the disclosure's aria-expanded/aria-controls intact.

The clinical-decisions disclaimer rendered both at the bottom of the
thread and in the sources-rail footer. Consolidate to a single line
directly above the composer, always visible on every viewport.

The auto-growing composer (single line growing to a capped multi-line
with internal scroll, Enter-to-send, Shift+Enter-newline) and the
aria-live streaming region are preserved. Plain-text React children
throughout; no new dependencies.
2026-06-04 22:17:55 +02:00
Marc-André BombeckandClaude Opus 4.8 7ab2cbaa56 feat(fitbit): add Google OAuth, credentials, and token refresh
Land the OAuth + credentials half of the Fitbit / Google Health provider,
cloning the WHOOP provider structure and adapting it to the Google identity
flow.

- Client OAuth half (src/lib/fitbit/client.ts): Google authorize-URL builder
  (access_type=offline + prompt=consent), authorization-code exchange, token
  refresh, and a profile fetch for the connection's external user id. Client
  credentials ride in an HTTP Basic-auth header on the token endpoint; the
  four launch Restricted read scopes are pinned in FITBIT_OAUTH_SCOPE. All
  egress goes through safeFetch.
- Non-rotating refresh tokens: getValidToken refreshes at expiry-minus-five-
  minutes, persists the new access token + expiry, and overwrites the stored
  refresh token ONLY when the response carries a fresh one — Google keeps the
  refresh token stable, unlike WHOOP.
- BYO credentials route (PUT/GET/DELETE /api/fitbit/credentials): per-user
  client id/secret stored encrypted on the User columns, built field-by-field
  under requireAuth.
- OAuth state + connect + callback routes: a random base64url nonce backed by
  a 10-minute FitbitOAuthState ledger row carries the (nonce -> userId)
  mapping; the callback validates the cookie/URL state with timingSafeEqual,
  consumes the row atomically (replay/expired/cross-user reason tags),
  exchanges the code, upserts the encrypted FitbitConnection, clears prior
  reauth state, and enqueues the self-converging history backfill.
- Response classifier mirrors WHOOP's HTTP-status buckets; Google signals a
  revoked grant with a 401, so there is no invalid_grant body special-case.

Tests cover the authorize URL, Basic-auth token exchange, the no-rotation
refresh branch (token preserved when omitted, overwritten when present), the
classifier status table, and the OAuth-state nonce mint.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 21:49:39 +02:00
Marc-André BombeckandClaude Opus 4.8 d4801d4646 refactor(export): highlight the health-record export, demote the doctor report, collapse the included-data list
The Settings export page over-emphasised the doctor-report PDF: it owned
the page hero while the broader health-record export (PDF + FHIR R4 + zip
package) sat below it as a plain card. The relative prominence is now the
other way round.

- The health-record export panel takes the page hero, with the same
  hero-gradient + glow-purple treatment the Insights hero strip uses.
- The doctor-report card drops the hero styling and moves to the bottom
  of the page as a small secondary card. It stays fully functional — the
  same dialog + /api/doctor-report flow runs from there.
- The health-record "included data" checklist is now a disclosure,
  collapsed by default, so the panel opens compact instead of as a long
  always-expanded list. It uses the inline aria-expanded/aria-controls
  pattern already used elsewhere, with no new dependency.

Pure presentation/IA change: export routes, payloads, and the exported
data are untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 21:43:02 +02:00
Marc-André Bombeck 68cad9d11c fix(medications): raise the upper-arm markers to the deltoid and the thigh markers to the upper thigh 2026-06-04 21:39:06 +02:00
Marc-André Bombeck 33ca595405 feat(medications): replace the injection-site body map with a refined illustration
Swap the hand-authored body silhouette in the injection-site picker for a
clean, gender-neutral medical line-art figure with correct proportions
(arms to mid-thigh, head-to-body ≈1:7.5, separated legs, feet). The figure
ships as a small transparent raster (~8 KB) and is painted theme-aware: its
alpha drives an SVG mask over a currentColor rect, so the strokes take the
picker's text colour and tint correctly in light and dark.

Keeping the figure inside the SVG (rather than a CSS-masked sibling) means
it shares the dots' 100×200 viewBox, so alignment stays responsive by
construction. Recalibrate SITE_COORDS so the upper-arm, abdomen-quadrant
and thigh anchors land on the new figure's anatomy; the pointer overlay,
keyboard targets, recommendation ring, last-used marker and disabled-set
logic are unchanged.
2026-06-04 21:34:10 +02:00
Marc-André Bombeck 121a68255d feat(medications): refine the injection-site body map and add a marker legend
Redraw the inline body-map silhouette to the proportions the iOS client
calibrated against: an oval head on a short neck, sloped shoulders, a
torso that tapers to the waist and flares to the hips, arms held slightly
out to mid-thigh with simple hands, and separated legs ending in feet.
The figure is composed as a torso-plus-legs outline with two separate arm
paths so there is a clean underarm gap and the arms read as arms rather
than a single blob. The eight site anchors keep their calibrated
coordinates on the 100x200 viewBox, so every tappable region and its
nearest-centre hit routing is preserved — the dots still land on real
anatomy (upper arms, the four abdomen quadrants, the thighs) and stay
selectable, theme-aware, and keyboard reachable.

Add a compact marker legend to the injection-site dialog: a dashed primary
ring marks the recommended next site and an amber ring marks the last-used
site. The last-used marker on the picker switches to the same amber tone so
the legend swatches mirror the body map exactly.
2026-06-04 21:02:22 +02:00
Marc-André Bombeck 86b2774567 feat(fitbit): add the Fitbit connection data model and FITBIT measurement source
Lay the data-model foundation for the Fitbit/Pixel integration over the
Google Health API, mirroring the WHOOP provider. No OAuth, sync, or UI yet.

- Prisma: FitbitConnection (1:1 per user, encrypted access/refresh tokens)
  and FitbitOAuthState models, two encrypted BYO-credential columns on User,
  and FITBIT appended to the MeasurementSource enum. Migration
  0117_v1120_fitbit_integration is additive only — one enum value plus two
  tables and two nullable columns, no new MeasurementType (every launch
  metric maps onto an existing type).
- Wire the two new encrypted columns and the FitbitConnection token columns
  into scripts/rotate-encryption-key.ts.
- FITBIT touchpoints: the read measurementSourceEnum (not the writable set),
  the DEFAULT_SOURCE_PRIORITY and workout-source ladders, the sources-section
  label map, the IntegrationKey union plus its display name, and a
  fitbit()/fitbitStatus() query-key pair. The source-rank SQL and read-tier
  collapse pick FITBIT up from the ladders with no code change.
- Add the FITBIT source label to all six locale bundles and regenerate the
  OpenAPI contract so the source enum carries FITBIT for the iOS decoder.
2026-06-04 21:02:10 +02:00
Marc-André Bombeck 045da70133 fix(insights): relocate range controls below the chart, right-align timestamps, tighten sleep cards
Move the time-range pills + period-over-period delta out of the slot between
the stat strip and the chart and render them as a clean row below the chart on
every metric sub-page (the HealthKit scaffold plus weight, BMI, pulse, blood
pressure, sleep). The control owns its own persisted range pref and delta read,
and the chart owns its window via its in-card range tabs, so the relocation is
a pure DOM reorder that leaves the chart range untouched.

Right-align the last-updated timestamp on the period narrative ("Zeitraum im
Überblick") and the daily-briefing footer so the "updated" line matches the
status cards, which already route through the right-aligned footer.

Tighten the sleep average and hypnogram cards — drop the default card gap and
the headline card's extra padding that left tall empty bands — and give the
hypnogram clock-aligned X-axis ticks (whole hours on a 1–3 h step picked from
the night's span) so the time axis reads 23:00 / 01:00 / 03:00 / 05:00 instead
of raw bedtime/wake stamps.

Add a compact density to the short-horizon projection card for the metric
sub-pages: it sheds the min-height floor, shortens the chart, and surfaces how
many more days of tracking are needed before a projection can appear, drawn
from the coverage envelope. The Insights overview keeps the roomier default
geometry for grid symmetry.
2026-06-04 21:01:50 +02:00
Marc-André Bombeck fda39c4159 fix(sleep): import userDayKey from the pure tz/format module to keep Prisma out of the client bundle 2026-06-04 19:52:59 +02:00
Marc-André Bombeck 874e286379 fix(insights): honest discovery footer, commensurable better-days ranking, settings tap-target + prop cleanup
- Mood discovery footer no longer pairs a mood-only numerator with the
  full behaviour-by-outcome denominator. Reword it to surface the mood
  relationships out of all day-to-day pairs the engine tested across
  every metric, so the count it reports is honest about its scope.
  Update all six locales.
- Rank the better-days board on a comparable scale: standardize the tag
  mood-point delta into a Cohen's-d effect (delta over the pooled
  with/without mood SD, clamped to [0,1]) so it sits alongside a metric
  |r| instead of the old |delta|/2 heuristic. Fall back to |delta|/2 only
  when the pooled SD is undefined. The pooled SD is ranking-only and is
  stripped before the row enters the snapshot payload; the raw delta and
  r the cards show are unchanged.
- Normalize the Withings and Whoop resume buttons to the min-h-11 scale
  token, dropping the arbitrary min-h-[44px] spelling.
- Give the API token copy button a 44px tap target on small viewports,
  scaling back to the tighter icon size from sm up.
- Drop the dead cached prop from InsightStatusCard and every caller; the
  card has carried no cached affordance since the badge was removed.
2026-06-04 19:39:37 +02:00
Marc-André Bombeck 766b395900 fix(sleep): dedup the bare ASLEEP aggregate across the stage breakdown and hypnogram
Apple Health writes an unspecified ASLEEP aggregate alongside the granular
CORE/DEEP/REM partition for the same period. The asleep total already preferred
the granular partition, but the per-night/per-session `stages` map and the
hypnogram `segments` list recorded every stage row unconditionally, so a night
ended up carrying the granular set PLUS its ~equal bare twin — a double-height
green ASLEEP block on the stacked bar and the hypnogram, and a doubled total in
the analytics stage breakdown. Route the `stages` map and the `segments` list
through the same granular-over-bare gate as the asleep total: when a session
carries a granular stage, drop the redundant bare ASLEEP (and stage-less) rows
everywhere. WHOOP never emits a bare ASLEEP, so WHOOP nights are unaffected.

Apply the gate per session, not over the merged night pool, so a granular
overnight no longer strips a same-day bare-only nap's minutes from the night
total. Render the hypnogram per-segment in the stage palette (one ReferenceArea
per segment) instead of a single flat cyan line, and show the breakdown plus
totals footer whenever any per-stage minutes exist — reserving the unavailable
state for a truly empty session — so a legacy stage-summary night still surfaces
its breakdown. Thread the user's source priority into the CSV export's sleep
dedup, and honour offset with a true pre-slice night count on the sleep list.
2026-06-04 19:39:37 +02:00
Marc-André Bombeck c0065437a7 refactor(settings): move unit-system and global injection-site exclusions to Account
The unit-system selector and the app-wide injection-site exclusions are
personal account preferences, not dashboard-layout controls. Move both
cards from the Dashboard settings section to the Account section, next to
the profile. The persistence path is unchanged — same endpoints, query
keys, mutation hooks, and labels; only the render location moves. The
Dashboard section keeps the layout/widget controls.
2026-06-04 19:13:47 +02:00
Marc-André Bombeck 6f378c8584 feat(whoop): add connection test and resume-from-park parity with Withings
Mirror the Withings integration actions on the WHOOP card. A new
POST /api/integrations/whoop/test runs a live, rate-limited probe
against the WHOOP basic-profile endpoint, refreshing the token via
getValidToken and classifying HTTP/transport failures into the same
errorCode taxonomy the Withings test route uses. A new
POST /api/integrations/whoop/resume clears a parked integration via
resumeIntegrationFromPark for the whoop provider.

The settings WhoopCard gains the shared Test-connection button, the
parked-state resume banner, and switches the full-sync icon to
RefreshCw for visual parity. Sync-now and sync-all are unchanged.
2026-06-04 19:13:47 +02:00
Marc-André Bombeck 0f46bf4b1f feat(sleep): add the last-night hypnogram to the sleep insights page
Render the night's phase progression as a stepped depth track (Awake top
→ Deep base) over clock time, reusing the Dracula stage palette from the
stacked bar (now exported, no token reshuffle). Below it: the per-stage
breakdown (time + %), total asleep, time in bed, and the mid-sleep
awakenings count. One canonical source per night — Apple and WHOOP are
never overlaid.

Wired atop the sleep insights overview, gated on the same data-bearing
branch as the rest of the page so a source-less account never fetches.
New i18n keys under insights.sleep.hypnogram in all six locales;
query-key factory entry + measurement-write invalidation.
2026-06-04 18:56:20 +02:00
Marc-André Bombeck e4a85c58af feat(sleep): add the read-only sleep-night hypnogram endpoint
GET /api/sleep/night?date=YYYY-MM-DD returns one reconstructed night for
the phase-progression view: the canonical source's stage segments (each
with an absolute start/end span), the per-stage breakdown, the asleep/
in-bed/awake totals, the mid-sleep awakenings count, and same-wake-day
naps surfaced separately. Session-clustered, wake-day-keyed, one
canonical source per night so two sources never overlay. date omitted
returns the most recent night. A view over existing per-stage rows — no
schema, no new measurement type. OpenAPI regenerated.
2026-06-04 18:56:20 +02:00
Marc-André Bombeck 4b5970fbb0 fix(sleep): reconcile the analytics stage breakdown with the tile and series
computeSleepStageBreakdown keyed each stage row by the calendar day of
its own measuredAt and never collapsed multi-source, so a night where
sleep began before midnight split across two perNight buckets and a
WHOOP + Apple Health night double-counted every stage. The stacked-bar
therefore disagreed with the dashboard tile and the chart series for the
same dates.

Route it through the canonical reconstructSleepNights: session-cluster
the stages, key by the wake day, collapse to one canonical source, and
apply the granular-over-bare de-dup. The stacked-bar now agrees with the
rest of the sleep surface.
2026-06-04 18:56:20 +02:00
Marc-André Bombeck bb4f762968 feat(sleep): export one row per night, not per stage
The CSV + JSON export emitted a wall of per-stage sleep minutes with no
night attribution. Collapse SLEEP_DURATION to one record per night by
default — the main session's TIME ASLEEP per the nap convention — with
the stage breakdown, nap count, and awakenings in the notes column so
the single uniform export reads as one night. `granularity=raw` keeps
the per-stage rows for power users. Storage is untouched.
2026-06-04 18:56:20 +02:00
Marc-André Bombeck 5c863a0bcc feat(sleep): collapse the measurements list to one row per night
SLEEP_DURATION is stored one row per stage per night, so a plain list
query leaked every stage segment as its own value. Route a sleep list
through the canonical per-night aggregation: one row per wake day
carrying the night's TIME ASLEEP (the main session's asleep total per
the nap convention), with a chevron drilling into that night's stage
segments. The drill-down renders stage labels + per-stage minutes, and
the night row captions same-day naps and mid-sleep awakenings.

API: a `?type=SLEEP_DURATION` list now returns synthetic per-night rows
(value = time asleep in minutes); `?dayKey=` returns the night's stage
segments. Additive — no existing client requested per-stage sleep rows
as a feature. Storage is untouched.

New i18n keys: measurements.sleepNapCaption + sleepAwakeningsCaption in
all six locales.
2026-06-04 18:56:20 +02:00
Marc-André Bombeck d5e67307e5 feat(sleep): add per-session reconstruction and the main-night/nap convention
Add reconstructSleepSessions — the session-level sibling to
reconstructSleepNights that keeps each sleep session separate (rather
than merging same-wake-day sessions under one key) and resolves every
stage row to its absolute span (start = measuredAt − duration), picks
one canonical source per session, and counts mid-sleep awakenings.

Add pickMainNightAndNaps to make the nap convention explicit: the main
night is the session with the most asleep minutes; every other session
on the same wake day is a nap, surfaced separately and never folded into
the headline.

These views are pure functions over existing per-stage rows — no schema
change, no new measurement type. They back the per-night list collapse,
the analytics reconciliation, and the hypnogram.
2026-06-04 18:56:20 +02:00
Marc-André Bombeck 60462b0f12 fix(dashboard): catch mood-radio and open-Select changes in the quick-entry dirty-check
The confirm-on-dismiss guard walked only `input` / `textarea`, so a user who
changed just the mood `role=radio` selector or was mid-pick in a Radix Select
(measurement type / medication) lost the change on a backdrop dismiss with no
confirm. Treat any `[role=radio][aria-checked=true]` (mood starts unselected,
so a checked radio is a deliberate choice) and any open Select trigger
(`[role=combobox][data-state=open]`) as unsaved input. Keep the date/time-picker
exclusion, and do not flag a closed Select on its default value so a pristine
sheet still dismisses without the prompt.
2026-06-04 18:38:14 +02:00
Marc-André Bombeck f3ac9942ee fix(whoop): don't stamp sync success on an all-403 grant-revoke cycle
After the graceful collection-403 soft-skip, a genuine grant revoke 403s every
data class: each resource soft-skips (returns 0, records no failure), so the
orchestrator sees no error and stamps recordSyncSuccess — leaving the dead
connection looking healthy until the token-refresh path catches the 401 up to
~1 h later. Track the soft-skip count across the resource loop via an
AsyncLocalStorage tracker scoped to the cycle; when every resource soft-skipped
and nothing imported, leave the status as-is instead of stamping success. A
partial cycle (any import, or any resource that did not soft-skip) stamps as
normal.
2026-06-04 18:38:14 +02:00
Marc-André Bombeck 8eb8717b90 fix(whoop): classify a token-endpoint invalid_grant as reauth_required
A revoked refresh token returns HTTP 400 with an OAuth body of
`error: invalid_grant` from the token endpoint. The status-only classifier
buckets every 400 as persistent, so the connection never prompts a reconnect
even though the grant is gone. Store the upstream OAuth error on WhoopApiError
and lift a 400 invalid_grant to reauth_required in classifyWhoopError; every
other 400 (a malformed request, invalid_client) stays persistent.
2026-06-04 18:38:14 +02:00
Marc-André Bombeck 4aa8b1cad7 fix(medications): keep injection-site dialog open until the PATCH settles
The injection-site capture dialog dismissed optimistically the instant
Confirm fired — before the PATCH that records the site resolved, with no
pending state. A failed request left the dose looking site-tagged when it
was not, and reused the unrelated "no sites available" string for the
error. The dialog now holds a submitting state: Confirm and Skip disable,
a spinner shows, and a backdrop tap or Esc cannot dismiss while the
request is in flight. The card handlers keep the dialog mounted until the
PATCH succeeds and surface a dedicated save-failed toast on failure.
2026-06-04 18:37:23 +02:00
Marc-André Bombeck 7986e59147 fix(mood): confirm form reset when dirty and show a note counter
Reset discarded typed input with no confirmation; a mis-tap silently
wiped a half-written entry. Reset now confirms first when the form holds
content (mood, tags or note) and resets immediately when pristine. The
note field also gained a live character counter so its 500-character cap
no longer truncates without feedback.
2026-06-04 18:37:23 +02:00
Marc-André Bombeck ccf56b8dbb fix(mood): surface a toast when a list delete fails
The mood list delete mutation had no onError handler, so a rejected
DELETE cleared no state and showed nothing — the row stayed in place
with no signal the request was refused. Add an error toast on failure,
mirroring the medication-card silent-failure fix.
2026-06-04 18:37:23 +02:00
Marc-André Bombeck 2a7195601b fix(sleep): stop double-counting bare ASLEEP against granular stages
Apple Health (and some WHOOP shapes) write BOTH an unspecified ASLEEP
aggregate row AND the granular CORE/DEEP/REM breakdown for the same
sleep period. The per-night reconstruction summed both, so a real ~8 h
night surfaced as ~16 h across every sleep surface — the chart showed
~16-18 h points and the target-range 30-day average read ~16.4 h/day.

Prefer the granular partition: when any CORE/DEEP/REM stage is present
for a night/source, count only those; fall back to the bare ASLEEP (or
a stage-less legacy/manual) row only when no granular stage exists.
IN_BED and AWAKE stay excluded; dual-source nights still collapse to one
canonical source via the user's sleep priority ladder. The same rule
applies inside the per-source asleep tiebreak so a source writing both
shapes is not over-weighted.

Align the surfaces that bypassed the shared helper:

- insights/targets: SLEEP_DURATION was averaged as raw per-stage rows
  (minutes) and fed into a target band expressed in hours, producing the
  bogus 16.4 h/day. Reconstruct per-night time-asleep, convert to hours,
  and classify + roll up consistency on the nightly hour totals.
- coach snapshot: route the sleep block through reconstructSleepNights
  so the prompt narrates the same nightly numbers (was summing every
  stage row, including IN_BED + AWAKE).
- sleep card chart: read /api/measurements/series?kind=sleep (night
  reconstruction, hours) instead of the generic measurement
  daily-average path, and label the axis in hours instead of minutes.

Add regression fixtures: a night with both a bare ASLEEP row and the
granular breakdown asserts the granular total (no double-count); a
dual-source night with both shapes per source; and a multi-night 30-day
average that lands at ~8 h, not ~16 h.
2026-06-04 18:22:55 +02:00
Marc-André Bombeck 227dd2b73f fix(settings): show a pending state on the danger-zone confirm buttons
The data-reset and account-delete confirmations fired their mutation
from an uncontrolled AlertDialog, so the action dismissed the dialog
the instant it was clicked and the only spinner lived on the now-hidden
trigger. A user on a slow link saw no feedback and could re-open and
re-submit a destructive, irreversible action.

Drive both dialogs from controlled open state and add an in-dialog
pending treatment to the confirm button — disabled, aria-busy, and a
spinner — mirroring the bulk-delete confirmation in the medication
intake history. The dialog now holds open while the request is in
flight; the data-reset closes on success, the account-delete keeps its
pending state through the post-delete redirect.
2026-06-04 18:22:02 +02:00
Marc-André Bombeck c24f42848e feat(settings): add copy button and revoke error toast to API tokens
The one-time API token rendered in a bare code block with no way to
copy it short of selecting the text by hand — the worst place for copy
friction since the value is shown exactly once. Add a copy-to-clipboard
button beside the token with a success toast, reusing the
clipboard-plus-toast pattern already used by the per-medication token
row. Surface an inline check affordance for two seconds after a copy.

Token revoke previously swallowed every failure: a non-OK response or a
network error left the user with no signal the token was still live.
Add an error toast on both paths so a failed revoke is visible.
2026-06-04 18:22:02 +02:00
Marc-André Bombeck 4e2248dfff fix(insights): drop the empty tail on the signal and narrative cards
Both overview cards carried a min-h floor sized for their tallest state and
applied it to every state, so the calm everyday read left a large empty band
at the bottom.

- Today's signal: the min-h-48 floor now applies only to the tall `fired`
  state; all-clear / watch / insufficient size to their short content.
- Period overview: the resolved card drops the min-h-40 floor and the footer's
  mt-auto that pinned the timestamp to the bottom of the floored height.

The in-flight skeletons keep their min-h footprint, so the first paint still
reserves the row and does not shift as the read resolves.
2026-06-04 18:21:14 +02:00
Marc-André Bombeck 93bdbd8e0c fix(insights): match the header target gear to the Coach icon size
The target gear painted a heavier 44 px box on mobile against the Coach
icon's flat 40 px, so the header cluster read as two mismatched icons.
Settle the gear on a flat 40 px box that equals the Coach sparkle on every
breakpoint, and restore the 44 px WCAG 2.5.5 touch target through a
transparent ::before overlay rather than a larger painted box. The shared
icon-button size stays untouched, so other icon affordances are unaffected.
2026-06-04 18:20:38 +02:00