1370 Commits
Author SHA1 Message Date
Marc-André Bombeck 1c4578d2f2 Merge pull request #256 from MBombeck/release/v1.11.5
Release v1.11.5 — mood relations, sleep depth, polish
v1.11.5
2026-06-04 19:59:03 +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 9b8a7c98a0 chore(release): v1.11.5 — mood relations insights, sleep depth view, clear-the-decks polish 2026-06-04 19:43:27 +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 74aa41519f test(sleep): integration coverage for the list collapse and analytics reconcile
Lock the SLEEP_DURATION list collapse end-to-end (one row per night,
dayKey drill-down to stage segments) and the analytics breakdown
reconciliation (a midnight-spanning dual-source night stays one bucket,
canonical source only, no double-count).
2026-06-04 18:56:20 +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 31301cc889 i18n: translate the admin namespace for es/fr/it/pl
Translate the remaining admin.* real-sentence labels and prose that
still matched English across es/fr/it/pl, plus a handful of stragglers
outside admin (doctor-report hypertension grades in fr, the mind-and-body
workout label in pl). Brand names, units, single technical tokens, URLs,
and placeholder strings stay verbatim, mirroring the de reference.
2026-06-04 18:34:15 +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 e78e3df1b9 i18n: translate reminder, Telegram help, and bug-report copy
Translate the medication-reminder phase notifications, the Telegram help
command listing, and the bug-reports-disabled notice into es/fr/it/pl, and
render the dashboard tile-column header in Italian. Placeholders and the
inline HTML markup are preserved verbatim.
2026-06-04 18:22:09 +02:00
Marc-André Bombeck a2bfa644e2 i18n: translate HealthKit metric labels and the generic load-error string
Fill the remaining English-fallback values in es/fr/it/pl for the Apple
Health / WHOOP measurement-type labels (irregular-rhythm, high/low heart
rate, walking steadiness, breathing disturbance, recovery/stress/strain
scores) and the shared common.loadFailed message.
2026-06-04 18:22:09 +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 af040e7480 Merge pull request #255 from MBombeck/release/v1.11.4
release/v1.11.4 — sleep totals, trend captions, and a round of fixes
v1.11.4
2026-06-04 17:46:08 +02:00
Marc-André Bombeck 8a26548123 test(analytics): expect the per-night sleep total in the stage-aggregation integration test
The SLEEP_DURATION summary now reports one per-night asleep total (CORE +
DEEP + REM) instead of a raw per-stage row count, so the stage-aggregation
test expects one datapoint of 400 minutes rather than four rows. The
per-stage breakdown assertions are unchanged.
2026-06-04 17:39:39 +02:00
Marc-André Bombeck cb4fd14a8a chore(release): v1.11.4 — sleep totals, trend captions, and a round of fixes 2026-06-04 17:27:12 +02:00
Marc-André Bombeck 49a029e023 fix(sleep): group per-night totals by session, not per-stage calendar day
Per-stage SLEEP_DURATION rows carry the stage segment's END instant, so a
night where the user falls asleep before local midnight spread its stages
across two calendar days. Keying each stage by its own day split one
overnight sleep into two partial nights, and the headline last-night tile
showed only the post-midnight slice.

Reconstruct nights by clustering stage segments into sessions: a new session
starts only when the next segment's start (end minus its minute duration) is
more than three hours after the running session end. A single long stage
block no longer splits a night, a daytime nap stays separable from the
following overnight block, and the gap is measured on the absolute instant so
the clustering is DST-immune. Each session is keyed by the local wake day of
its latest segment end, matching the convention that a sleep session belongs
to the morning you wake up.

Collapse a multi-source night to one canonical source before summing, using
the user's sleep source-priority ladder (default WHOOP > Apple Health >
Withings), so a dual-source account no longer double-counts the total. Sources
are never blended within a night.

Bound the series sleep read to one year even on the 'Alle' range, matching the
slim slice's sleep window, so a multi-year dual-source account does not walk a
six-figure stage-row set into JS.

Add fixtures straddling local midnight, crossing a DST spring-forward, keeping
a nap separable, and collapsing a dual-source night.
2026-06-04 17:25:19 +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 ebeb16daa3 fix(sleep): emit the per-night total, not a single stage, on the sleep tile and series
SLEEP_DURATION is stored in minutes, one row per sleep stage per night
(IN_BED / AWAKE / ASLEEP / CORE / DEEP / REM) since the HealthKit
category-sample ingest landed. The value is not cumulative, so reading
the single most-recent row surfaced one stage's minutes — never the
night. The dashboard sleep tile and the sleep series therefore showed a
fragment of the night rather than the night.

Add a shared `summarizeSleepNights` helper that groups the per-stage
rows by the wake-day calendar date in the user's timezone and sums the
asleep stages (CORE + DEEP + REM, plus a legacy bare-duration row),
excluding IN_BED and AWAKE per the AASM convention already used by the
sleep score. Wire it into:

- the dashboard summary tile, which now emits last night's time asleep
  in hours with an explicit `unit: "h"` and a per-stage breakdown for a
  future detail view;
- the sleep series, which now returns one point per night (time asleep
  in hours) plus a top-level `unit` for every kind so clients never
  infer the unit;
- the slim summaries slice, so the web dashboard tile and the insights
  overview read per-night totals (kept in canonical minutes there; the
  web tile converts to hours) — strictly more correct than averaging
  across stage rows, and parity with the iOS value.
2026-06-04 16:44:53 +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 accd7c8514 fix(measurements): merge MANUAL and Apple Health rows for the same reading
A measurement logged by hand on the iOS app while offline is mirrored
into HealthKit. On pairing a server, adopt-on-pair uploads it as a
MANUAL row while HealthKit background sync independently re-ingests the
same sample as an APPLE_HEALTH row. The two carry different external ids
and sources, so the composite unique index keeps both — doubling the
whole hand-logged history on first pair.

Collapse the pair at ingest: an incoming MANUAL or APPLE_HEALTH row is
dropped as a duplicate when a same-reading row of the opposite source
already exists (same type and value, measuredAt within two seconds).
The match is scoped to that one cross-source pair, so same-source rows
and rows from server-owned sources keep their existing contracts, and
two genuinely distinct readings at the same minute never collapse.
stats:* cumulative rows stay on their per-day overwrite lane.

The collapsed copy returns status "duplicate" with reason
"cross_source_merge" so the client cursor advances unchanged; no
schema, migration, or OpenAPI change.
2026-06-04 16:44:39 +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 979bfe4c01 Merge pull request #254 from MBombeck/release/v1.11.3
release/v1.11.3 — WHOOP body data, quality-of-life polish, fuller translations
v1.11.3
2026-06-04 16:06:19 +02:00
Marc-André Bombeck 3bc6124cf1 chore(release): v1.11.3 — WHOOP body data, quality-of-life polish, fuller translations 2026-06-04 15:58:51 +02:00
Marc-André Bombeck 41d0628a5f fix(i18n): use the formal register for the French feedback-error string 2026-06-04 15:57:14 +02:00