Commit Graph
553 Commits
Author SHA1 Message Date
Marc-André BombeckandClaude Opus 4.8 d2edaeb8d1 refactor(insights): point the pulse page to the cardio-fitness surface for VO2 max
VO2 max had two divergent surfaces — the dedicated /insights/cardio-fitness
page (full chart + plain-language assessment) and a legacy compact chart-row
on the pulse page. Replace the pulse-page row with a clear cross-link to the
dedicated page so there is one VO2 max surface, and remove the now-unused
chart-row component + its test. New cardioLink copy added across all six
locales.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 14:07:56 +02:00
Marc-André BombeckandClaude Opus 4.8 4a88db6966 fix(ui): route status colours through AA-safe semantic tokens
Raw text-dracula-* utilities used as text fail WCAG AA contrast in light
mode (the Alucard light theme carries no override for the raw primitives).
Swap every text usage and its paired bg/border tint across the settings
integration/notification/sharing/api cards, the insights recommendation,
confidence, daily-briefing, health-score and coach-feedback surfaces, the
bug-report banners, the copy-confirmation icon and the chart comparison
caption to the theme-aware --success / --warning / --info / --destructive /
--dose-accent tokens, which both themes override to an AA-safe tone. Dark
mode is unchanged; decorative aria-hidden gradients and chart series fills
stay on the raw primitives.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 14:07:56 +02:00
Marc-André BombeckandClaude Opus 4.8 1f00946bfe refactor(insights): tighten the subpage spine and clarify the VO2 max chart
Pull the subpage spine to a denser space-y-3/4 rhythm to match the tighter
stat strip, and refresh the stale brush comment now that the windowed stats
are wired. Give the cardio-fitness page its Gauge stat-strip glyph so VO2
max leads with the same standard treatment as the other HealthKit metrics;
the dedicated subpage already renders the full chart. Clarify the VO2 row's
sparse-series note.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 13:49:46 +02:00
Marc-André BombeckandClaude Opus 4.8 d0d0f49385 feat(insights): add loading skeleton and error+retry to HealthKit subpages
The HealthKit metric pages consumed only `{data, isEmpty}` and ignored the
hook's `isLoading`/`error`, so they painted nothing in flight and fell
through silently on failure. Branch on `isLoading` to a stat-strip + chart
skeleton, and on `error` to a message plus a Retry that re-issues the
analytics query (mirrors the vitals-dashboard pattern). Expose `refetch`
from the analytics hook and add a `subPage.loadError` string in all six
locales.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 13:49:46 +02:00
Marc-André BombeckandClaude Opus 4.8 ad2f038963 feat(insights): tighten the stat strip and add a side-by-side multi-series mode
Drop the strip card to a denser py-3/gap-2 rhythm with 44 px stat cells and
text-base values so the numbers-first header gives away less vertical space
on every metric subpage while staying readable. Extract the per-series block
(header + four stats + brushed-window pill) into a reusable piece and add a
`series` prop that renders multiple series as columns in ONE card (two on
desktop, stacking on narrow mobile). Single-series callers keep their
contract unchanged; the brushed-window stats stay per-series.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 13:49:46 +02:00
Marc-André BombeckandClaude Opus 4.8 80c5f14748 feat(insights): reorder the mood page to the calendar-first spine
Lift the Stimmungskalender above the line chart and place the better-days
assessment directly under the Ziel card. `MoodInsightsSections` now renders by
region (`heatmap` / `assessment` / `rest`); the page threads them so the spine
reads heading → summary → calendar → chart → target → assessment →
classification + breakdowns. All three regions share the one `moodInsights`
query so the fetch is deduped. Every `data-slot` is preserved.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 13:46:42 +02:00
Marc-André BombeckandClaude Opus 4.8 56934fd658 style(insights): move mood colored text onto semantic feedback tokens
The mood confidence chips, the better-days / influence / crosstab delta
read-outs, and the mood-chart comparison caption coloured their TEXT with
raw `--dracula-*` values. Those stay bright green/cyan/red/purple in light
mode (Alucard) and fail WCAG AA on the white card. Swap the coloured-text
surfaces to the semantic feedback tokens (`--success` / `--info` /
`--destructive`) and the theme-aware `dose-accent` purple, which carry the
light-mode overrides that clear AA in both themes. Decorative chart fills,
strokes, heatmap cells, and legend swatches keep their `--dracula-*` tokens —
those are non-text and already token-based.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 13:46:37 +02:00
Marc-André BombeckandClaude Opus 4.8 3cd04533be style(insights): tighten mood chart heights and unify the overview rhythm
The mood line chart plots a 1-5 score band; the 240/280 px full-mode height
left a dead strip below the line on the mood subpage. Trim the default to
200/220 px (a per-mount --chart-height override still wins) and lower the
mood-correlation scatter height so the cards fit their content without the
empty gap.

The overview used space-y-8 between top-level blocks while the vitals wrap used
space-y-6, reading as two tiers. Unify to space-y-6 for one consistent vertical
rhythm down the page, in line with the denser-Insights direction.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 13:40:44 +02:00
Marc-André BombeckandClaude Opus 4.8 4be9767428 fix(insights): normalize the assessment-card tile-header icon size
The TileHeader nodeIcon path rendered the caller-passed icon node verbatim and
dropped the canonical h-5 w-5 text-foreground class TileHeader forwards, so a
caller passing an unsized node could drift off the contract. Forward the class
onto a sizing span that pins any child SVG to the canonical box regardless of
what the caller passed. Existing call sites that already pass a pre-sized icon
stay visually identical.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 13:40:44 +02:00
Marc-André BombeckandClaude Opus 4.8 68ab78d6d8 feat(insights): merge the two mood "what stands out" surfaces into one card
The mood page carried two separate "what stands out" tiles: the narrative
takeaway feed and the FDR-controlled discovered relations, each a full card
under the same heading. Fold them into a single card — one header, the
narrative one-liners, then the discovered relations as a labelled subsection.
Each half gates independently; the card degrades to nothing when both are empty.

Drop the muted FDR explainer paragraph: the statistical caveat rides the
subsection info-icon tooltip, and every finding now reads in the standard
foreground text colour. Remove the orphaned discovery.description string from
all six locales and add the subsection label.

Give the better-days board the assessment-card treatment (insight-assessment
hook, tighter gap/padding, the insight-in entry) so it reads as the mood
assessment, consistent with the per-metric subpages.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 13:40:44 +02:00
Marc-André Bombeck f25f7f3597 settings: full-width timezone select + match the excluded-sites card surface 2026-06-05 13:40:12 +02:00
Marc-André BombeckandClaude Opus 4.8 4dde3e1d8b feat(insights): recompute the stat strip for a brushed chart window
Wire the metric sub-pages so brushing a window in the chart recomputes the
Min/Max/Median/Mittelwert strip for the visible domain. The page lifts the
shared brushed-window state, passes `selectableDomain` + `onDomainStats` to
the chart, and threads the per-series window stats into the strip. With no
selection the strip keeps the precomputed full-range summary, so the default
is unchanged and costs no extra work.

The strip renders the windowed numbers when present and pins a "selected
range" pill to its header so the user reads the stats as their selection.
Blood pressure brushes both series at once — the single chart reports
systolic and diastolic stats and each strip reads its half.

Covers weight, pulse, blood pressure, and the ~37 HealthKit pages that ride
the shared scaffold. BMI (a derived metric with no first-class strip) and
sleep (no strip) are left as-is. New copy added in all six locales.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 13:35:54 +02:00
Marc-André BombeckandClaude Opus 4.8 0995664893 feat(charts): add opt-in brush that reports per-type windowed stats
Add an optional Recharts <Brush> to the shared HealthChart, gated behind a
new `selectableDomain` prop. When set, the chart mounts the brush beneath
the plot and reports the Min/Max/Median/Mean over the brushed window per
type through `onDomainStats`. A range-tab change or a series refetch clears
or clamps the selection so the brush never indexes past the data.

The dashboard, trends-row, VO2 row, sleep, and rationale mounts do not pass
the prop, so they render unchanged — the feature is fully isolated behind
the flag.

`computeWindowStats` is a pure helper (no React/Recharts) so the chart can
summarise the array it already holds without a refetch and the maths is
unit-tested directly. The median uses the same linear-interpolated midpoint
as `summarize()` so a window and the full-range summary read on one
algorithm. `useChartDomainStats` lifts the per-type window into shared state
with a stable callback and a shallow guard against repeat windows.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 13:35:44 +02:00
Marc-André Bombeck 41eda2d163 insights: reword the deferred chart-window stats note without a TODO marker 2026-06-05 13:09:52 +02:00
Marc-André BombeckandClaude Opus 4.8 22ba4193b2 fix(insights): dedupe coach disclaimer and add wellness-strip error affordance
Suppress the generic Insights disclaimer footer on the Coach route, which
already renders its own composer disclaimer — the layout shell now skips
the footer when the selected segment is the coach page and keeps it
everywhere else. Thread the shared derived-batch error state and refetch
into the wellness strip so a failed batch surfaces a compact error +
Retry in place (the strip is top-of-page) instead of vanishing silently;
the one Retry recovers both the wellness and vitals strips. Adds a
scores load-error string across all locales.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 12:58:39 +02:00
Marc-André BombeckandClaude Opus 4.8 036370fc88 fix(insights): normalize stat-strip and target panel header rhythm
The stat-strip and target-summary panels were bare card-styled divs with
a flat space-y gap, so their tile header sat at a different offset from
the Card-wrapped tiles (assessment, mood, medication) on the same subpage
spine. Wrap both in Card / CardHeader pb-2 / CardContent so the
header-to-body offset matches. All content and data-slots are preserved;
the panel semantics ride on role + aria-label.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 12:58:29 +02:00
Marc-André BombeckandClaude Opus 4.8 f1f6018f6e fix(insights): align vitals tiles and section headings with the shared tile header
Bring the Vitals grid in line with the harmonized card language. The
sparkline-delta tile led with a muted-uppercase caption + a muted icon;
it now leads with a foreground icon and a foreground heading matching the
canonical tile header, keeping the trailing delta. The Vitalwerte and
Mobility section headings and the provisional baseline / HRV tile headers
move onto the shared tile header (foreground icon + heading) so the
Wellness, Vitals, and Mobility sections share one heading construction.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 12:58:23 +02:00
Marc-André BombeckandClaude Opus 4.8 18e36e3226 insights(medications): drop the GLP-1 dose-listing timeline and densify the compliance tiles
Remove the chronological GLP-1 therapy-event list from the medications
insight page — it duplicated data already on the per-medication detail
page and added no read on this surface. The page spine collapses to
compliance grid -> target -> assessment.

Route each per-medication compliance card header through the canonical
TileHeader (Pill glyph + med name as the heading) and fold the dose,
category, and streak into one compact muted line. Tighten the card gap,
header padding, progress-bar height, and content spacing so the same
data (7d/30d bars, taken/skipped/missed, heatmap, per-med sentence)
reads in noticeably less vertical space, consistent with the rest of
the harmonised Insights tiles.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 12:36:16 +02:00
Marc-André BombeckandClaude Opus 4.8 424d6ee5e1 insights(mood): drop the per-card generic disclaimer, defer to the page footer
The better-days board, the tag-influence list, and the tag × metric
crosstab each repeated a generic associations-not-causes / talk-to-your-
doctor caveat. That sentence now lives once in the page-level Insights
footer, so the three mood cards no longer restate it; the genuinely
specific FDR caveat on the discovered-relations board stays. Drop the
now-unused betterDays / influence / crosstab disclaimer keys from every
locale and update the two component tests that pinned the removed copy.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 12:36:12 +02:00
Marc-André BombeckandClaude Opus 4.8 fcd8eccf6e insights(mood): order classification first, route every tile through TileHeader
Reorder the mood subpage spine so the classification tiles (in-target
share + stability band) lead, then the better-days assessment, then the
descriptive breakdown sections. The classification answers where the user
stands; the better-days board, directly beneath, answers what associates
with their good days.

Every mood card header now routes through the shared TileHeader (icon +
white heading) so the subpage speaks one card language. The in-target and
stability tiles gain proper headings (new inTargetTitle / stability.title
keys across all six locales) and drop the icon-in-a-circle headline glyph;
the narrative feed and the discovered-relations board move into headed
cards.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 12:36:12 +02:00
Marc-André BombeckandClaude Opus 4.8 85c9f3ff3f insights(mood): align mood-chart palette to Dracula tokens
The mood line chart hand-picked hex for its bands and lines: a
three-band red/orange/green ramp that skipped the yellow rung the mood
heatmap and distribution chart use, and a non-token purple primary.
Switch the bands to the same four-step red→orange→yellow→green ramp the
other two mood surfaces ride, and route the line/overlay colours through
the --dracula-* tokens (purple primary matching the health chart, pink
moving average, cyan trend) so the three mood surfaces read as one
palette and a theme change flows through a single source.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 12:36:12 +02:00
Marc-André BombeckandClaude Opus 4.8 b92728d5e8 insights: lift the wellness strip above the daily briefing
The wellness-score strip rendered inside the vitals dashboard, below the
daily briefing. Lift it out so it sits as its own full-width section
directly under the hero and above the briefing — the overview spine is
now hero, wellness, briefing, vitals, trends, retrospective, signals.

The page now owns the one shared derived batch and feeds the same query
instance to both the wellness strip and the vitals grid, so the lift
adds no second request. Each wellness tile and the section heading lead
with the canonical TileHeader (a per-metric icon plus the metric name in
the foreground colour), matching the rest of the Insights card language.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 12:33:10 +02:00
Marc-André BombeckandClaude Opus 4.8 a0883bc269 insights: render the wellness score number in the foreground colour
The centred score number painted off the band token (success/warning/
destructive), which cleared only ~1.5:1 against the dark wellness card
and read as illegible. Switch the number to the foreground colour so it
is legible on both the dark and the light card. The band semantic stays
carried by the ring arc fill, the data-band attribute, and the
aria-label, so the colour change costs no information.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 12:33:00 +02:00
Marc-André Bombeck f3f358b208 insights: consolidate the generic disclaimer to one page-level footer
Render the generic "describes your own data, not a clinical assessment or
diagnosis" line once, as a footer under every /insights route in the
layout shell, instead of repeating it across tiles and pages. Add the
insights.disclaimer.footer key in all six locales. Metric-specific caveats
stay on their own cards.
2026-06-05 12:20:59 +02:00
Marc-André Bombeck ea2ce8697e insights: lead every metric subpage with the stat strip above the chart
Render the Min/Max/Median/Mittelwert stat strip directly above the chart
on the canonical spine (intro -> stats -> chart -> target -> assessment),
the numbers-first detail-screen convention. Wire the series headings on
each subpage: blood pressure shows the full four-stat grid for both
systolic and diastolic as labelled blocks, single-series pages pass the
metric name and icon. Leave a marker for chart-brush-reactive stats as a
follow-up.
2026-06-05 12:20:54 +02:00
Marc-André Bombeck d434a6e24b insights: add canonical TileHeader and route shared tiles through it
Introduce a single TileHeader primitive (foreground icon + CardTitle,
matching the assessment-card header) and route the assessment card, the
target reference panel, and the stat strip through it so every Insights
tile leads with the same header language. The target panel now leads with
a single-word "Ziel"/"Target" heading and drops the redundant range
string (the band numbers already live on the range bar); the stat strip
labels each series via a header ("Systolisch"/"Diastolisch" for blood
pressure, the metric name for single-series metrics).
2026-06-05 12:20:48 +02:00
Marc-André Bombeck 4ace1b7706 refactor(insights): unify the metric subpage template — target tile, order, stat strip, drop the range row
Make every metric subpage identical by construction in the shared
scaffold rather than per-page bespoke.

- Target tile matches the other cards: dark `bg-card` background and the
  `rounded-xl border p-4` spacing the assessment and stat-strip cards
  use, instead of the old translucent `bg-card/20` panel.
- Drop the 7T/30T/90T/1J range row and its "+X% vs previous" delta from
  every subpage. The chart owns its own window via its in-card range
  tabs, so the chart query is unaffected. Retire the now-dead
  `MetricRangeControls`, `MetricRangeDelta`, `TimeRangePills`,
  `useAnalyticsRange`, the `useInsightsRangePref` persisted-range hook,
  and `sentimentFromMetricDirection` along with their tests.
- Fix the spine order to intro -> chart -> target card -> assessment ->
  Min/Max/Median/Mittelwert stat strip. The stat strip moves to the foot
  of the spine, below the assessment. Remove the "Letzte Messung" card
  and the headline primary tile from subpages; retire
  `MetricLastMeasurementCard` and `MetricPrimaryTile`.
- Blood pressure gains the stat strip it was missing: two stacked
  systolic / diastolic strips, since a single-series strip would drop the
  diastolic half. `MetricStatStrip` takes an optional series caption.

Update the spine guard to assert the target card precedes the
assessment and that the retired range row and last-measurement card
appear on no subpage.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 80b2a810314b4c0951c0d68c04bffc89003005d6)
2026-06-05 10:42:47 +02:00
Marc-André Bombeck 9cf2b3086a feat(insights): reorder the overview, fix briefing legibility and steps link, trim the coach prompts
Reorder the Insights overview top → bottom to Hero + Coach, Wellnesswerte,
Vitalwerte, Trends, period retrospective, then today's signals — the
wellness-score strip and vitals grid already render in that sequence inside
the vitals dashboard, so the change is the surrounding section order plus
moving the signal cards below the retrospective.

Make the steps reference value in the daily briefing tappable: it was the one
key finding with no destination, so it now routes to the generic readings view
for its measurement type like every other metric drills in.

Paint the health-score headline number through the semantic success / warning /
destructive tokens instead of the raw dracula primitives, which carry no
light-mode override and read illegibly on the Alucard light theme.

Widen the wellness-score strip so the tiles always fill the row: the fixed
five-column desktop track left an empty cell on the common four-score case and
read as a missing tile.

Drop the duplicate footer "prepare assessments" control — the tab-strip
regenerate button is the single affordance — and remove the now-dead warm hook
and its strings.

Trim the coach suggested prompts to the two that read as a clear next step
(ask the doctor, is the medication working) and remove the three speculative
data-specific openers, across all six locales.

(cherry picked from commit e819c9c12777f76b66b5b466b8d159b26f169868)
2026-06-05 10:42:47 +02:00
Marc-André Bombeck 89d51c5380 refactor(insights): tighten the mood page — consolidate subtexts, icon-ify explainers, unify palette
Lay the narrative takeaways two-up instead of a single full-width column so
short one-liners (best daypart, logging streak, weekday peak) stop stacking
into a long ladder of half-empty rows.

Fold the low-density footnotes behind a focusable ring/info explainer icon
with a tooltip: the per-pair paired-day/r/q stat under each discovered
relation, the per-card paired-day/r line under each mood-correlation scatter,
and the false-discovery footer + observational disclaimer that sat as two
full-width rows under the discovered-relations list. The trigger is a real
button carrying an aria-label so the explanation is keyboard- and
screen-reader-reachable, not hover-only.

Charts stay Recharts and visually identical — the mood distribution and
mood-by-time-of-day charts already share one Dracula band palette
(red/orange/yellow/green over the same thresholds, --dracula-fg ticks,
--dracula-comment axes, --card/--border tooltips), so the surface palette is
already consistent and left untouched.

New explainer keys land in all six locales.

(cherry picked from commit afc38491ebfae3beed6476a79439d2b87f33f823)
2026-06-05 10:42:46 +02:00
Marc-André Bombeck 72c93108d7 fix(medications): record the viewed dose by scheduled time instead of snapping to now
The medication cards show a single actionable dose — the open/overdue
window, or the server's next-due slot. The take / skip button POSTed
`{ skipped }` only, so the route defaulted `scheduledFor` to `now()` and
snapped to whichever slot was nearest. For a twice-daily 07:00 / 19:00
medication a morning tap (before the 13:00 midpoint) recorded the 07:00
dose, and a non-deliberate morning tap mis-recorded the morning dose.

Thread the displayed dose's canonical slot instant onto the POST so the
server resolves the exact dose the user is looking at, independent of the
wall-clock. The card derives the instant from its current-window status
(the window's start time today) or the next-due instant; a PRN dose with
no slot omits `scheduledFor` and keeps the legacy now-snap path. The route
already accepts `scheduledFor` and snaps it through the canonical slot
resolver, so no server change is needed — the cards now always send it.

Add a unit suite for the slot resolver and the POST body, and an
integration test proving a morning `scheduledFor` records the 07:00 slot
and an evening one records 19:00, driven by the supplied instant rather
than the clock, with marking one dose never marking the other.

(cherry picked from commit 438832cd965574da1b5dabf6567f7bffa26e36f3)
2026-06-05 09:04:09 +02:00
Marc-André Bombeck a199fac20e refactor(medications): share intake handling across cards and unify status tokens
Lift the take / skip intake flow — the optimistic spinner, the
failure toast, the soft-delete Undo, and the dependent-key
invalidation — into a shared `useMedicationIntake` hook consumed by
both the generic medication card and the GLP-1 card. The GLP-1 card
previously inlined its own copy that never gained the failure toast
or the Undo affordance, so a failed POST was swallowed silently and a
misclicked dose had no quick reversal. Both cards now behave
identically by construction; each keeps only its post-success
injection-site prompt.

Converge the medication status-colour surface onto the semantic
feedback vocabulary. The status pill reads as a success → warning →
destructive urgency ramp (dropping the lone `text-dracula-yellow`
middle tier), the streak flame moves off the raw `text-dracula-orange`
palette token onto `text-warning`, the detail status dot swaps its raw
`hsl(var(--…))` form for `bg-success` / `bg-warning`, and the import
result line drops `text-dracula-green` for `text-success`. Each
semantic token aliases the same colour the raw form resolved to, so
the hue is unchanged in dark mode and lands AA-safe on the light card.

Route the compliance percentages through the locale number formatter
with rounding so a non-integer rate never leaks raw, and give the
medications Add button the same `min-h-11 sm:min-h-9` tap-target floor
the dashboard Add button carries.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit a4415d706154b063a8783390e35eded5dbc57910)
2026-06-05 08:10:12 +02:00
Marc-André Bombeck 496147687d refactor(insights): unify the assessment term, restore the canonical spine, consolidate status cards
The per-metric AI assessment was named four ways across the bundle
(Einschätzung / Auswertung / Analyse / Bewertung in German, with parallel
drift in the other locales). Sweep every assessment-state value — the
no-provider, no-result, start / refresh / re-run, regenerate and warm
strings — onto the one noun the card title already carries, in all six
locales. Keys are unchanged; only the human strings unify.

Restore the canonical metric-detail spine on the two bespoke pages that had
drifted: the mood page rendered the assessment directly under the first
chart with the target summary and breakdown sections below it, and the
medications page rendered the therapy timeline after the assessment. Both
now end on the assessment block, matching weight / bmi / pulse /
blood-pressure and the generic scaffold. A source-level guard test asserts
the assessment card is the last child of every bespoke page's shell so the
order cannot silently regress again.

Consolidate the repeated eight-prop assessment wiring: the five
useInsightStatus-backed pages now mount a shared SlugInsightStatusCard seam
(the sibling of MetricStatusCard for the generic route) instead of
hand-wiring the card and the status?.x defaults inline. The medications page
keeps its inline card because its route carries a richer summary-shaped
envelope than the standard text-only generators. The assessment footer's
"updated" caption now uses the shared relative-time formatter the briefing,
hero, last-measurement and coach-history captions use, so adjacent cards no
longer read one relative and one absolute timestamp.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 2cd73ce5d6cdca63fea0829990fac2a7e24afebf)
2026-06-05 08:10:12 +02:00
Marc-André Bombeck 3ce0b69802 refactor(settings): unify card headers, integration verbs, test buttons, and tap targets
Holistic consistency pass over the settings and integration surfaces so the
same concept reads and behaves the same way everywhere.

- Card headers: migrate the four integration cards (Withings, WHOOP,
  Fitbit/Google Health, Mood Log) plus the profile, passkeys, avatar, about,
  api-endpoints, api-tokens, and sharing cards onto the shared
  SettingsCardHeader primitive. Extend the primitive with an inline title
  accessory slot (Fitbit tag + experimental badge) and a multi-line
  description slot (overlap / experimental / deprecation sub-notes), keeping
  the rendered result identical.
- Integration verbs: normalise Mood Log onto the canonical "Sync now" /
  "Sync all data" / "Synchronize" set used by the other integrations, across
  all six locales. Swap the full-sync icon from Download to RefreshCw and
  align its action-row icon metrics with the sibling cards.
- Test buttons: migrate Telegram and ntfy off their hand-rolled "Test
  message" buttons onto the shared TestConnectionButton, so every channel and
  integration test control shares one icon, label, latency readout, and error
  taxonomy. Retire the now-unused testMessage / testSent strings.
- ntfy auth-token placeholder no longer reuses the "Saved" status string;
  it now reads "Saved — enter new to replace" like the other secret fields.
- Save-success copy: collapse the moodLog and Telegram bespoke strings onto
  the shared settings.saved string.
- Tap targets: floor every size="sm" action-row button (sync / full-sync /
  test / disconnect / save / connect-secret copy) at min-h-11, fixed at the
  TestConnectionButton source and across the cards, so they clear the 44px
  touch minimum.
- Contrast: the onboarding source-card badge moves from muted-on-muted to
  text-foreground; web-push card body rhythm aligns on space-y-4; Mood Log's
  deprecation note drops its bespoke 11px-italic treatment for the shared
  sub-note style.

(cherry picked from commit 11931d14b7875b7e2184a3b373d7a34b842f8eae)
2026-06-05 03:45:04 +02:00
Marc-André Bombeck 53e87ba345 perf: consolidate integration status query, add mood-insights SWR, defer below-fold charts
Settings → Integrations fired five status round-trips on every mount —
the consolidated /api/integrations/status alongside the four per-provider
/api/<provider>/status queries. Extend the consolidated endpoint to carry
every field the four cards render (Withings activity scope, WHOOP/Fitbit
backfill state, moodLog webhook secret + entry count) and drive all four
cards off the single envelope. The legacy per-provider routes stay for the
iOS/test callers; the web cards no longer hit them.

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

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

(cherry picked from commit 2b627893ab6b4d7bc457fc18e6e5585b048190e1)
2026-06-05 03:25:22 +02:00
Marc-André Bombeck 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 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 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