Commit Graph
515 Commits
Author SHA1 Message Date
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 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 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 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 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 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 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 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 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 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 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
Marc-André Bombeck 252921b563 fix(insights): tidy the assessment card and gate its show-more toggle
Drop the top-right "cached" label from the assessment card — it surfaced
an implementation detail and read as if the insight were second-hand. The
warm cache still backs the text; the card just no longer announces it.

Mount the "show more" toggle only when the clamped prose actually
overflows three lines, measured against the live layout via a
ResizeObserver instead of a character-count guess. Short assessments that
fit no longer grow a toggle that reveals nothing, and text that wraps past
three lines on a narrow column now gets one.

Move the toggle copy off the inline locale-prefix fallback onto proper
`insights.assessmentShowMore` / `insights.assessmentShowLess` keys across
all six locales, and right-align the "last updated" line.
2026-06-04 18:20:38 +02:00
Marc-André Bombeck 4e6599d85f feat(mood-insights): promote mood to a discovery outcome and surface it
Two changes that turn the descriptive relations surfaces into a
statistically-gated discovery view on the mood page.

Mood as an outcome: the FDR-controlled all-pairs correlation discovery
engine treated mood only as a behaviour (a lag source). Promote it to an
outcome channel too, so the scan also surfaces "behaviour today → next-day
mood" relations (e.g. more daylight today → better mood tomorrow) alongside
the existing "mood today → next-day outcome" direction. The self-pair is
skipped and Benjamini-Hochberg already controls the larger pair family this
opens up. The discovery route now feeds the shared mood daily-mean series
into both roles.

Surface the discovery slice: the engine was computed but never rendered.
The mood Insights page now shows the mood-involving pairs that survive the
false-discovery correction, each phrased observationally with its paired-day
count, r, and BH q-value, plus the honest "X of N tested" footer. The
surface self-fetches the correlation route and degrades to nothing when the
operator has disabled it. Observational only, with the standing disclaimer.
2026-06-04 18:09:07 +02:00
Marc-André Bombeck f1edbd17d1 feat(mood-insights): add tag influence and the "better days" relations board
Two new observational surfaces on the mood Insights page, both computed in
the single mood-aggregates engine so the charts and the coach snapshot stay
in sync.

Influence on mood (per tag): for each frequent flat and structured tag,
compare the average daily mood on days the tag is present against the
counterfactual baseline of days it is absent, with the delta and a
confidence band. The band comes from a Welch two-sample t-test reusing the
exact Student-t p-value already behind the correlation cards, not a naive
approximation. Both groups must clear a five-day floor; rows rank by
absolute delta and cap per axis. Sparse tags, single-day tags,
all-same-value days, and zero-delta tags are dropped cleanly — never NaN.

Better days board: one ranked list folding the tag deltas and the
mood x health-metric correlations (sleep, steps, pulse, weight, blood
pressure) into a single effect-size-ranked, confidence-gated surface — the
headline answer to "what goes with my good days?". Near-zero and
under-sampled correlations are excluded; the list is capped and observational
only, with the standing "association, not cause" caption.

Both surfaces ride the mood-status snapshot too so the assessment prose
never contradicts the on-screen relations. New copy in all six locales.
2026-06-04 18:09:07 +02:00
Marc-André Bombeck 528ce93c71 feat(insights): move target-range editing to a header gear
The target reference card carried an inline "Adjust target range" link,
which read as a control buried in a read surface. Lift the action to a
gear button in the category-page header, to the left of the Coach launch
icon, so the two sit as a consistent icon cluster beside the heading.

A small page-scoped provider bridges the two: the reference card
registers its editable target (one per page, except blood glucose which
fans out to several contexts and registers each) and the header gear
opens the per-metric editor sheet the provider owns. Single-target pages
open that target directly; glucose opens the primary context, since
target editing lives only on these pages — there is no separate targets
management surface. The card itself is now a pure read surface.

The gear carries the action's existing label as its accessible name,
clears the 44 px touch-target floor on mobile, and exposes a stable
`data-slot` for end-to-end coverage.
2026-06-04 17:47:17 +02:00
Marc-André Bombeck 9396bfea46 fix(insights): align the target reference card to the shared card surface
The per-metric target reference panel rendered on a borderless
`bg-muted/30` inset, which read as a nested detail block rather than a
top-level card. Side-by-side with the assessment, trajectory, trend and
recommendation cards on the same category page it looked off-tone. Match
the compact card surface those siblings use (`bg-card/20` tint, a
`border-border/40` hairline, the shared `rounded-lg`) so the panel reads
as one family.
2026-06-04 17:47:17 +02:00
Marc-André Bombeck 4666359470 feat(insights): compute a deterministic trend caption when the AI annotation is absent
Each Trends-row card carries a one-line caption below its mini-chart.
The first choice is the AI advisor's per-metric sentence, but on a cold
briefing — a web-only account, a pre-briefing cached payload, or the
advisor still generating — that sentence is null and every card fell
back to a static "Awaiting more data" hint, even with a full 30-day
series painted right above it.

Add a deterministic, rule-based descriptor derived from the same series
the mini-chart plots, and apply a three-tier caption precedence:

  1. advisor annotation present → the AI sentence (unchanged).
  2. else, a computable series   → the deterministic descriptor:
     direction (rising / falling / stable) plus the first-vs-last
     magnitude over the window, e.g. "Rising over 30 days (+8 mmHg)",
     "Falling over 30 days (−1.4 kg)", "Stable over the last 30 days".
     Mood reads on a categorical 1–5 scale, so it is phrased in plain
     terms ("Mood trended slightly higher over 30 days") with no raw
     point delta.
  3. else (genuinely < 2 points)  → the real "Awaiting more data" hint.

The descriptor is observational and neutral by construction: direction
and magnitude only, with no causal, diagnostic, or medical claim and no
value judgement — a rising weight and a rising step count read the same
way here. A per-metric stability floor (the larger of an absolute and a
relative bound) keeps day-to-day noise from reading as a trend.

The descriptor logic lives in a pure, unit-tested helper. The caption
reads the same series the chart shows: mood reuses the existing
mood-analytics cache slot, and numeric metrics take a small dedicated
30-day daily-aggregate read keyed under a new query-key factory entry
that joins the measurement-write invalidation set. New descriptor copy
ships in all six locales. The pending shimmer while the advisor is in
flight is unchanged.
2026-06-04 17:05:35 +02:00
Marc-André Bombeck a2dff85fb8 fix(insights): contain the mood trend card so its axis labels stop overflowing
The Trends row renders three mini-chart cards (blood pressure, weight,
mood). The mood card alone overflowed: its categorical y-axis ("Super
gut/Gut/Okay/...") and date x-axis paint inside a Card shell that runs
taller than the flat HealthChart minis beside it, so the x-axis date
labels bled past the fixed chart slot and overlapped the caption below.

Bound the overflow on the slot itself, without touching the chart
components:

- `overflow-hidden` clips anything the inner chart paints past the
  180px envelope, so a stray axis label can never escape into the
  caption row.
- `[--chart-height:120px]` drives both mini charts' internal band down
  to a shared 120px (both honour `h-[var(--chart-height,140px)]`). The
  mood card's full envelope — band + header chrome + axis labels — now
  fits inside 180px, so all three tiles share one chart-band baseline.

Recharts and the chart tokens are untouched; the fix lives entirely on
the row's slot container.
2026-06-04 17:05:29 +02:00
Marc-André Bombeck 32ce6dfa18 fix(charts): render sparse metric data instead of withholding it
The metric chart withheld every reading behind a "more days needed"
card until three distinct daily points existed, so a user with one or
two days of real data (e.g. respiratory rate) saw a hint instead of
their measurements. Render the available points for any non-empty
window — a single marker for one day, a line for two — and add a
subtle inline caption that more days fill out the trend. Only a
genuinely empty window keeps the no-data card.

The trend, moving-average, and comparison overlays already self-gate
at two points, so relaxing the render gate cannot paint a misleading
line from too little data. Drop the now-unused raw-count plumbing the
old three-point copy split relied on.
2026-06-04 16:50:34 +02:00
Marc-André Bombeck 6e241c16a4 fix(dashboard): stop the recent-achievements card flashing in then retracting
On a cold dashboard load the recent-achievements card briefly showed content
(or its empty state) and then vanished. Two causes:

- The layout-toggle gate read `DEFAULT_DASHBOARD_LAYOUT` while the real layout
  was still loading; achievements (and recent workouts) default to visible, so
  the card painted during the load window and was retracted once a layout that
  hid it arrived. Gate both layout-toggle-only cards on the resolved layout.
- The card itself rendered its empty / content branch before its query settled,
  so the "no achievements yet" state flashed ahead of the real unlocks. Render a
  skeleton while the query is pending and commit to a content shape only once it
  resolves.
2026-06-04 16:44:46 +02:00
Marc-André Bombeck 5e60eaf9c0 fix(insights): serve the daily briefing read-only so the overview never blocks on the LLM
The hero strip, daily-briefing card and trends row all read the advisor
payload through `useInsightsAdvisorQuery`, which POSTed `/api/insights/generate`
without `force`. That POST generates inline on a cache miss, pinning the
page-load path on the full provider chain (up to the client's 8 s abort) before
the briefing paints — even though a read-only lift of `User.insightsCachedText`
costs nothing.

Add a read-only GET to the route that serves the cached payload immediately and,
when the cache is stale or missing and a provider is configured, enqueues an
out-of-band warm on the existing `insight-pregenerate` queue. The read mount now
uses the GET; user-initiated regeneration keeps the POST force path. Mirrors the
stale-while-revalidate posture of the per-metric status and period-narrative
reads — no provider call ever sits on a navigation request.
2026-06-04 16:44:46 +02:00
Marc-André Bombeck b54b95dbc7 fix(insights): bound the mood time-of-day chart height
The "mood by time of day" card spans the full overview width, unlike its
distribution/weekday siblings which sit in a two-column grid. Sizing the
chart wrapper with aspect-[3/2] derived the height off the full card width,
so on a wide viewport the chart ballooned to roughly 800px tall and
overflowed its card. Swap the aspect ratio for a fixed bounded height so the
chart fits the card at the same size as the width-constrained sibling charts
regardless of viewport. Chart internals and tokens are unchanged.
2026-06-04 16:44:39 +02:00
Marc-André Bombeck 42945efa04 fix(insights): stop clipping long wellness-score ring labels
The wellness-score ring tiles passed the metric title into the ScoreRing
as an in-SVG centred <tspan>. A long localised title (Schlaf-Score,
Bereitschaft) was wider than the small ring's clear centre, and because
the text is SVG-centred inside the recharts overflow:hidden svg the leading
glyph was clipped (the missing leading "S" on Schlaf-Score). Render the
title as an HTML caption below the ring instead, where it wraps within the
tile and never truncates; the ring keeps just the number centred. The
in-SVG label slot stays for the anatomy detail view, which only ever passes
the short "/100" string.
2026-06-04 16:44:39 +02:00
Marc-André Bombeck 65b988c686 fix(settings): right-align danger-zone action buttons beside their labels
The data-reset and account-delete cards under Settings → Advanced
stacked their destructive button below the description. Move the
button to a right-aligned slot on the same row as the title and
description, matching the stack-on-mobile / right-align-on-desktop
contract the onboarding-tour and about cards already use
(flex-col gap-3 sm:flex-row sm:justify-between, button w-full sm:w-auto).
The AlertDialog confirmation, i18n keys, and destructive styling are
unchanged.
2026-06-04 16:44:39 +02:00
Marc-André Bombeck ad3bbd5507 fix(settings): show a friendly message when an export download fails
The export cards rendered the raw "Download failed (500)" error string to
the user. Map the failure to a localized message across all six locales.
2026-06-04 15:37:36 +02:00
Marc-André Bombeck eddc06d135 style(settings): align the mobile passkey card padding with sibling surfaces
The mobile passkey list rows used a tighter p-3 while every other card
surface in the account section uses the standard padding step. Bump to p-4
so nested card surfaces read consistently; radius stays rounded-lg.
2026-06-04 15:37:36 +02:00
Marc-André Bombeck 51959a4737 fix(settings): give the API-token revoke button an accessible name and a larger target
The revoke icon button announced only "button" to screen readers and sat
at a 32px target. Add a localized aria-label and bump it to 36px.
2026-06-04 15:37:36 +02:00
Marc-André Bombeck 16b7306243 fix(insights): surface an error toast when recommendation feedback fails
The thumb-up / thumb-down POST used to fail silently — on error the
buttons just reappeared with no signal that the submission had not gone
through. Add a lightweight localized error toast on failure while keeping
the success path quiet (the inline confirmation row is signal enough
there).
2026-06-04 15:36:20 +02:00
Marc-André Bombeck e63fae564b test(insights): pin the reduced-motion guard for .animate-insight-in
The 400 ms `.animate-insight-in` entrance keyframe is applied across five
insight surfaces and is collapsed to `animation: none` once, centrally, in
a `@media (prefers-reduced-motion: reduce)` block in globals.css. Extend
the motion-reduce coverage test to assert that central guard so a future
edit cannot silently drop it and replay the entrance motion for
motion-sensitive users.
2026-06-04 15:36:20 +02:00
Marc-André Bombeck 60b86f1ec9 fix(insights): suppress recommendation-card hover lift under reduced motion
The recommendation cards carry `transition-all md:hover:-translate-y-0.5`
for a subtle lift on pointer hover. Motion-sensitive users got the full
transform with no opt-out. Pair the hover with
`motion-reduce:transition-none motion-reduce:hover:translate-y-0` so the
card stays put when the user asks for less motion.
2026-06-04 15:36:20 +02:00
Marc-André Bombeck fb4037ed96 fix(coach): tidy the message thread type scale, bubble width and empty state
Lift the disclaimer and feedback-thanks lines off the off-scale 10px
size onto the standard extra-small step. Unify the user and assistant
row gap, and budget the avatar column out of the bubble's 80% cap so a
bubble plus its avatar never overflow a comfortable width on a narrow
phone. Announce the empty-thread hero as a polite live region so screen
readers read the hint when the thread first mounts.
2026-06-04 15:36:16 +02:00
Marc-André Bombeck 2e3bfbb971 feat(coach): add a stop control and abort the stream on drawer close
While a reply streams the composer now swaps the send button for a
visible Stop control bound to the abort handler, so a long or off-track
reply can be interrupted instead of waited out. Closing the drawer
mid-stream also calls the cancel handler, so the SSE request no longer
keeps running in the background after the drawer is gone.

Add a localised "Stop" label across all six bundles.
2026-06-04 15:36:16 +02:00