Commit Graph
224 Commits
Author SHA1 Message Date
Marc-André Bombeck 63c7f63f99 Put the health score back beside the greeting
The score returns to the right-hand column of the hero band, where it sat
for eleven weeks: the number at the top, then one thin line per pillar that
actually counts toward it. The card that was pinned below the greeting is
gone, so the score has one place on the page again.

Each contributor line now takes its own colour. A pillar scoring 92 used to
be painted red just because the overall score was red.

Anything not contributing is one quiet line instead of a block: a failed
read is named once for all the pillars it took down and carries the retry,
safety guidance is named in full, and everything simply not recorded yet is
counted, with the names a tap away under the score.
2026-07-31 11:02:21 +02:00
Marc-André Bombeck 12d3239de0 feat(nutrients): tell the account holder when a sync arrived but nothing landed
GET /api/nutrients now looks up the most recent nutrient.batch.ingest
audit row when the window is genuinely empty, and surfaces it as
lastAttempt only when that call landed nothing (inserted === 0 &&
updated === 0). A real success elsewhere is left alone, so an empty
window for an unrelated reason never gets reframed as a failure.

The lookup runs on the zero-rows path only, never on the happy path,
and stays counts-and-reason-codes only - no user-typed content, no
per-entry health values. The reason code never reaches the page as
text: each of the four closed skip reasons maps to its own human
sentence, and the two that have no useful action for the account
holder (unit_mismatch, upsert_failed) say plainly that the entries
couldn't be stored rather than inventing advice. day_invalid is the
one case worth naming a real cause for - a clock or time zone mismatch
on the sending side - without claiming it was a phone, since any
token holder can call this route.

This depends on AuditLog retention: cleanupOldAuditLogs prunes rows
past 365 days by default, so a dormant account's last failed attempt
can age out and the explanation silently reverts to the plain empty
state. Documented at the lookup for whoever next touches that job.
2026-07-30 23:30:28 +02:00
Marc-André Bombeck bed634fd67 Put the new workout blocks on the page's reading edge
SubPageShell insets its own heading to the card body edge rather than the
container edge, so a section heading or a meta line rendered flush left sat
16 px to the left of everything above it. The day-context heading, the sport
chips and the count line now take the same responsive inset.
2026-07-30 23:23:37 +02:00
Marc-André Bombeck 0784cbcbb1 Say how many workouts the list holds and let a sport narrow it
The list hook already returned the canonical total and the page threw it
away, so the surface never said how much history it was showing. Render it
as a meta line above the rows.

The route and the hook already accepted a sport, with nothing to reach it.
Add the chip row. Its vocabulary comes from the unfiltered read, which is
the same query the list itself runs when no sport is chosen, so the row
costs no extra request and choosing a sport can never empty the row it was
chosen from. A filtered view falls outside the server prefetch and pays one
client fetch; a filter that matches nothing says so instead of borrowing the
first-run empty state.
2026-07-30 23:23:37 +02:00
Marc-André Bombeck 76b407b6de Render the day around a workout instead of linking away from it
The "That day" card took the workout, dropped it, and offered three chips
to Pulse, Sleep and Mood. None of those pages accepts a date, so every chip
landed on an all-time trend surface with the day nowhere in sight.

Replace it with the day itself: the intraday pulse chart pinned to the
session's day, the night the person woke from into it, and that day's mood
entries. All three reads are addressed by the server-resolved dayKey, so
nothing here decides which day a workout belongs to.

The two outbound links that remain are undated and say what they lead to,
Sleep trends and Mood history. Sleep and the intraday chart mount only when
their module is on, so a switched-off module cannot paint as a failed read,
and a query that has not answered yet shows a skeleton rather than claiming
the night was empty.

The detail page also drops the description it borrowed from the list.
2026-07-30 23:23:36 +02:00
Marc-André Bombeck eb55c235c6 Rebuild the Insights health score as one pinned card
The score had two mounts: a compact face in the hero band and a
nine-block stack further down the overview. The stack also rode inside
the daily-briefing branch, so an instance with the briefing switched off
lost the whole breakdown.

Fold both into one progressive-disclosure card pinned between the
greeting and the customizable region. Collapsed it reads as the score
face: the ring, the band sentence, the weekly delta or the honest reason
there is none, Rest Mode, the tension and return-to-baseline lines, and
the coverage meter. Opening it adds a three-across pillar grid, the
named list of pillars that are not scored yet, the personal weight goal,
and the method footer with its cited standard.

The card is deliberately not a section in the insights layout registry:
an id a saved layout has never seen merges in as default-invisible,
which would have deleted the score for every account that has ever
customised the page.

A pillar the server gated stays visible by name with its reason instead
of being drawn as a zero, safety signposting leads that list, and a
pillar whose read failed gets an error row with a retry rather than
silence. The hero band goes back to the full-width greeting, the shape
it already had for an account without a score.
2026-07-30 23:20:02 +02:00
Marc-André Bombeck a1c5d78f72 fix(nutrients): stop the /insights/nutrients overview swallowing a failed read as absence
The page's overview query shares queryKeys.nutrientIntake(30) with
MicronutrientsCard, which already renders its own QueryErrorCard on a
failed read. But the page's empty-state check ran first and treated
undefined data (the failed-read state) the same as a genuinely empty
window, returning "no nutrient data yet" and short-circuiting before
any of the three cards - including the already-correct one - ever
mounted. A self-hoster with HealthKit permissions granted and a read
that actually failed saw the same blank invitation as someone who
never logged anything.

Add the isError branch above the empty-state check so a failed read
surfaces its own QueryErrorCard with a retry, and cover it with a test
that renders the actual page (not just the card in isolation) so a
regression here can't hide behind a passing unit test again.
2026-07-30 23:11:36 +02:00
Marc-André Bombeck 14d0eeeb46 fix(ui): stop CardHeader drawing a phantom gap without a description
grid-rows-[auto_auto] was unconditional, so a header with a title and
no CardDescription still reserved an empty second row track and drew
the gap-2 for it — 32px instead of the intended 24px between heading
and body on desktop. Make the second row conditional on a description
being present, and widen CardAction to row-span-full so it still
tracks the header's row count when an action lands in a
description-less card.

Also correct two assessment cards that had drifted onto gap-1.5
instead of the sanctioned gap-2 compact tier.
2026-07-30 15:50:14 +02:00
Marc-André Bombeck 1f87e63662 fix(insights): restore the shared card rhythm the two assessment cards lost
The spacing pass on this branch tightened `insight-status-card` to `gap-1.5`
and left `mood-insights-sections` at `gap-2`, so two sibling cards that are
meant to read as one family stopped matching. `assessment-spine-order.test.ts`
exists to hold exactly that contract and went red as soon as the formatter
stopped masking it: `format:check` failed first in the job, so the unit tests
never ran and the divergence shipped unnoticed onto the branch.

Bring the mood card to the same value rather than widen the other one back.
The direction of travel is the tighter rhythm, and both allowed steps are on
the sanctioned spacing scale.

The test's expected string moves with the contract, not around it: it still
asserts that both cards carry the SAME value, which is the whole point of it.
2026-07-30 10:49:06 +02:00
Marc-André Bombeck 90b1a4eaee fix(insights): improve narrative contrast and spacing 2026-07-29 21:28:29 +02:00
Marc-André Bombeck 9354c649fe fix(integrations): surface Google sync progress and canonical workouts 2026-07-29 15:55:37 +02:00
Marc-André Bombeck c4ec2d552f fix(01-16): normalize assessment prose presentation
- use the readable foreground at confirmed medication and Mood mounts
- tighten Health Score and medication assessment spacing
2026-07-29 14:28:47 +02:00
Marc-André Bombeck 18d0facdfe test(01-10): add red assessment and records contracts
- pin honest assessment fallback, exhaustion, foreground, and state envelopes
- require consistent save and empty-state add action grammar
2026-07-29 14:18:35 +02:00
Marc-André Bombeck c91558f18f feat(analytics): replace health score with reference composite 2026-07-28 21:10:15 +02:00
Marc-André Bombeck a2f2a30ac2 fix(quality): close backup and localization guard gaps 2026-07-28 12:03:02 +02:00
Marc-André Bombeck 2aeb925e28 merge: s15-stepsdisplay into release/v1.34.0 2026-07-27 23:41:34 +02:00
Marc-André Bombeck f5c676bbeb fix(insights): give a metric's numeric domain one home
Three defects on one card of the step sub-page, all of them a surface
answering a question that belongs to the metric.

"Your usual range is -5,595.6-13,387.6 steps", under a chart whose own
y-axis refuses to draw a negative step. Same app, same question, two
answers: the axis clamped and the sentence beneath it did not. Both now
read the same fact. `VALUE_RANGES` already declares every metric's
plausibility floor for ingest; `value-domain.ts` reads it and answers
"can this metric be negative" for the padded y-domain and for the median
+/- k.MAD band alike. The band's lower edge floors at zero for a
non-negative metric; `spread` stays the unclamped dispersion so a
deviation-in-sigma calculation is unaffected. `buildBaselineBand` now
takes the metric it is describing as a required argument, so a new caller
cannot quietly reintroduce a negative edge. Two types carried no
plausibility range at all, which let any number through ingest and read
as "unknown" on the negativity question: walking steadiness gets the same
0-100 percent band as its two gait siblings, the loud-listening event a
floor of 1 with a generous ceiling.

"13,387.6 steps" also said steps to a reader of German. Five sub-page
units are English words, not symbols - steps, flights, falls, years, and
a "count" that was a poor label in any language. They now travel as i18n
keys through a new `unitKey` on the metric-page scaffold and resolve in
all six locales. The thirteen real symbols (bpm, mmHg, %, m/s, kg/m2,
...) stay literals; translating a symbol would be the opposite mistake.

And the Min read "104.0 steps". Precision was a per-page prop defaulting
to one decimal, so every page had to remember that its metric is a count
- and the step page did not. It follows the metric now: a discrete metric
(tallies, category events, closed integer instrument scores, ordinal
levels) resolves zero decimals wherever it is rendered, and a page only
passes the prop to override that.
2026-07-27 23:01:33 +02:00
Marc-André Bombeck b0862c0902 feat(score): say which yardstick graded the weight row
The weight pillar has been graded against something for two years and
never said what. The score card's weight row now carries one line: the
user's own target band when they set one, or a plain statement that the
row scored the trend with no target at all. The band rides the analytics
DTO in canonical kg and the page formats it into the reader's display
unit, so an imperial account reads pounds.

The weight sub-page shades the same yardstick: its chart bands follow an
explicit target when one exists and fall back to the height-derived band
only when it does not.
2026-07-27 23:00:26 +02:00
Marc-André Bombeck 3f355fbde4 feat(charts): put the drawn data points under the chart as a table
A chart shows the shape and hides the numbers. An appointment needs the
numbers, and so does anyone moving readings into a spreadsheet.

The table reads the same expression the chart hands Recharts, after the range
slice, the week/month bucketing and the display scale, and it formats through
the chart's own value and date formatters. Nothing is re-derived, so a cell and
the tooltip cannot print two numbers for one point.

Collapsed by default, with the point count in the trigger so the size reads
while it is closed; the region scrolls in its own box rather than growing the
page, and the table primitive already scrolls a wide row set sideways. The
caption names the grain, because a weekly mean labelled as a daily reading
would be a lie, and the grain now has one source shared with the bucketing.
Derived overlays get no column: a moving average is drawn from the points, not
measured. An empty window renders no table at all, since the chart's own empty
state already says there is nothing there.

Opt-in per mount. The metric sub-pages take it; the dashboard tiles do not,
because a table under every tile buries the dashboard it summarises.

Refs #636
2026-07-26 19:23:52 +02:00
Marc-André Bombeck a402eaaf15 fix(coach): tell a history outside the window apart from a metric never recorded
A Coach retrieval tool that read the window and found nothing returned
`reason: "no_data"` — the same code for two situations that have nothing in
common: the record holds nothing for that metric, and the record holds a
substantial history for it, all of it older than the window that was searched.
Nothing downstream could tell them apart, so the Coach said the honest-sounding
sentence for the wrong case, and then, having been told there was nothing there,
changed the subject. Someone with a year of imported glucose readings was told
the readings did not exist, on the same account whose glucose page was showing
them.

The window cap stays. It exists because a multi-year history does not fit a
prompt budget, and widening it would only move the same failure. What was
missing is the ability to say a different true sentence.

- `tools/availability.ts` — one grouped aggregate per miss, no rows
  materialised, resolving four states: `no_data` (probe confirmed empty),
  `outside_window` (rows exist, older than the searched window; carries the
  count, the date range in the user's own timezone, and a bounded per-series
  mean/min/max), `unavailable_in_scope` (rows exist inside the window and the
  domain still produced none — a module is off), and `no_data_unconfirmed` (the
  probe itself failed; absence was never established, so it is not asserted).
- Every empty read in the executor routes through it. A bare `no_data` can no
  longer be returned without the record having been checked.
- The DATA INVENTORY carries the same distinction, so a domain the window
  cannot reach is advertised with what it holds instead of as absent — the model
  no longer skips the fetch, and a vague follow-up keeps the subject.
- Both instructions that told the model to report absence and move on now
  branch on the reason code, and forbid answering about one metric by reporting
  another.
- The out-of-window aggregate joins the authoritative set for the prose
  verifier, so the sentence the fix exists to make sayable is not elided.
- A cross-cutting narration block that throws is now counted rather than
  vanishing into a `null` that looks like honest absence.
- The medication-compliance page tells a failed read apart from an account with
  no medications, instead of inviting someone to re-enter what it already holds.

Refs #648
2026-07-26 18:34:42 +02:00
Marc-André Bombeck 84d78d2926 fix(units): round a converted value where the conversion happens
Every entry in the display-transform registry declares the decimals its
unit is read at. That number was carried to the render layer and read by
exactly one screen out of all the ones that show a converted value, so
everywhere else the raw product of a kilogram and 2.20462262185 reached
the surface. Pounds came out with a dozen digits it never needed.

Move the rounding into `applyDisplayTransform` and
`applyDisplayTransformDelta`, so a surface gets a correctly-scaled number
whether or not it remembers to ask. Patching the weight cell would have
left the same trap behind inches, Fahrenheit, miles per hour and miles.

Two boundaries stay exempt and are documented as such. A transform that
does not rescale is the exact identity and is never rounded, so a stored
78.45 kg keeps rendering as 78.45 on the metric branch and every
untransformed metric is byte-identical. `invertDisplayTransform` — the
entry boundary — keeps full precision, because a value rounded on the way
out and inverted on the way back would drift the stored canonical number
a little further on every save; the entry surfaces quantise to their own
two-decimal canonical dialect instead.

`applyDisplayTransformUnrounded` is the one documented escape hatch, for
arithmetic whose result is not rendered. The target adapter needs it: its
guardrails round inward, and rounding the 30 kg floor to the nearest
tenth of a pound first gives an edge that inverts to 29.98 kg and the
server rejects it.

The weight page and the HealthKit scaffold were folding the factor into
their stat strips by hand. Both now convert through the helper, so the
raw multiplier is only ever handed to the chart, which owns its own
series and formatting.

Formatting is unchanged. Precision is fixed at the conversion and the
surfaces keep the formatter they already used, so switching preference
changes the unit and nothing else about how the number looks.

Refs #627
2026-07-26 00:46:03 +02:00
Marc-André Bombeck d8a3c08e12 fix(i18n): stop one key template from whitelisting a whole namespace
The reverse-coverage guard asserts that every key in the bundle is reachable
from a call site. It had stopped doing that for most of the bundle. A template
whose only static anchor was a namespace root registered that root as a subtree
base, so a single per-provider OAuth key marked every key under `settings.*`
reachable, and the same shape did it for `measurementReminders.*`. A cache-key
string that happens to start with a namespace word had quietly done it for
`insights.*` too. The guard stayed green throughout, which is the part worth
noticing: a pair guard degrades into a one-end guard without any signal.

Extract the matcher into a helper with its own unit tests, and change what it
accepts. A template carrying both a literal prefix and a literal suffix now
records the pair and requires both ends to match, so the OAuth template covers
the provider keys and nothing else. A template with no suffix whose anchor is a
bare namespace no longer whitelists anything — it fails the guard by name, with
the two honest remedies in the message. The same door is used by the template,
assignment and concatenation arms, so none of them can reopen it. Template
bodies are read by scanning forward from a known opening backtick rather than
by pairing backticks across the tree, which was drifting and dropping whole
files out of the scan.

Three call sites build their keys whole instead of interpolating onto a
namespace root: the two measurement-reminder due helpers, the mental-health
option labels, and the OAuth error copy, which now names its per-provider base.
Two audit-log action names that merely look like keys carry a written
exemption; the API error catalog is a runtime lookup and is allowlisted with
its reason.

With the matcher honest, 149 keys had no call site left. They belong to an
explainer popover, a hypnogram, a passkey table and an insights hero that were
all rebuilt or removed, and they are deleted from all six locales. The
explainer guard stops pinning its title leaves, which no surface has rendered
since the popover went.
2026-07-25 16:23:58 +02:00
Marc-André Bombeck e1ca523e21 fix(insights): route every weight-class and temperature surface through the unit transform
Wire the display transform into the chart scaffold, the chart itself (with an
affine offset for the temperature conversion), the dashboard trend tiles and
weight chart, the vitals grid, the coach read strip, and the device-score
tile. Each surface resolves its unit and scale from the user's preference at a
single choke point and stops hardcoding the canonical unit, so an imperial user
sees a consistent value, axis, band, tooltip, and range hint while a metric
user's render stays byte-identical. Retire the local celsius display shim now
that the transform owns the symbol.
2026-07-25 00:10:53 +02:00
Marc-André Bombeck fb50335459 fix(insights): stop the pulse page from silently swapping to resting heart rate
Closes #584. `/insights/pulse` computed hasRestingHr from
RESTING_HEART_RATE presence and used it to swap the ENTIRE primary
chart and the Coach read strip to that series whenever any resting
row existed, while the title, stat strip, capture action, and
"show all values" link kept saying PULSE. A user could see a
resting-only chart under a page that claimed to be pulse throughout.

The chart now always includes PULSE as its first series; when resting
data exists, RESTING_HEART_RATE renders as a second, clearly-labelled
series (the same multi-series pattern the blood-pressure page uses for
systolic/diastolic) instead of replacing it. The Coach read strip
stays pinned to PULSE. The page also drops the resting-calibrated
target band it used to paint behind the chart — that band is
calibrated to steady-state resting readings and would misreport an
expected workout spike in raw PULSE data as "out of target"; the
personal target belongs on the dedicated resting-pulse page against
the clean resting series.
2026-07-23 09:38:03 +02:00
Marc-André Bombeck 62eb431d66 release: v1.32.0 — hardening and scalability (#580)
* release: prepare v1.32.0

* test: stabilize water capture re-entry
2026-07-22 04:14:15 +02:00
Marc-André Bombeck 430cbb2ceb Merge remote-tracking branch 'origin/work/v131-slice3-activity' into release/v1.31.0
# Conflicts:
#	src/lib/jobs/reminder/register-status.ts
2026-07-19 15:42:11 +02:00
Marc-André Bombeck 6e1945e8a0 docs(workouts): correct the seam comments now that the card can mount 2026-07-19 14:59:24 +02:00
Marc-André Bombeck 096c8c799b feat(coach): give every read-only assessment surface a way out
Status cards and derived-score sheets read one way: they state a verdict and
end there. The metric pages already carried the hand-off; the score sheets
passed neither an opener nor a scope, so they painted no action at all.

- Score sheets (sleep / readiness / recovery / stress / strain) gain the same
  auto-sent opener the metric pages use, each scoped to the inputs that drive
  the composite rather than to a block it has no equivalent of.
- Workout detail's coach launch carries the workout itself. The chat request
  gains an optional workoutId; on the FIRST turn the route narrows by
  { id, userId } and pins ONE bounded evidence section onto the snapshot.
  Later turns reuse the pinned snapshot, so per-turn work stays constant in
  conversation length.
- /coach?workout=<id> covers the full-page and maximize paths.

The workout block deliberately does not take the fenced transport the stored-
document path takes: a workout row is the user's own sensor record, and the
projection admits no free text. That is enforced, not assumed — sport_type is
a TEXT column, so it is re-asserted against the closed sport union, metadata
is excluded apart from its numeric zone durations read through a Zod slice,
and a recursive guard throws on any non-numeric leaf.

Own-history sport averages move to src/lib/workouts/sport-context.ts so the
narrative and the detail page cannot quote different figures.
2026-07-19 13:55:37 +02:00
Marc-André Bombeck 59ffa02b0c Show one sleep-rhythm error, not three
The rhythm, average-sleep and chronotype cards on /insights/sleep share
a single ["sleep-rhythm"] read, and each rendered its own copy of the
same failure sentence — so one failed read painted the identical notice
three times, none of them offering a way to try again.

The page owns the shared state now: one notice with a retry in place of
the card row. The cards keep their loading and empty handling and drop
the duplicated error branch.
2026-07-19 06:49:28 +02:00
Marc-André Bombeck c87ce161ba Never render a failed read as an absence of data
"No data yet" is a claim about the user's record. Making it when the
read failed states something false about their health history.

The mental-wellbeing surface was the one that mattered: the assessment
history read dropped isError entirely, so a failure left both
instrument cards showing "not taken yet" and the detail sheet showing
an empty trend. Someone with months of screening results was told, on
a mental-health surface, that the results did not exist. It now tells
loading, failed and genuinely empty apart, with a retry on the failure.

Also swept:
- the workouts insights page read isEmpty but not isError, so a failed
  list read fell through every branch to null and painted a titled
  shell with nothing in it;
- the micronutrients card (not in the original list) rendered its "no
  nutrient data" empty state on a failed read, while its sibling
  hydration card already used the shared error card;
- the medication detail page and the custom-metric values list showed
  an honest error with no way out. Both keep retry: false — right for
  a deleted row, wrong for a transient failure — and gain a
  user-initiated retry instead, which cannot storm;
- /achievements rendered a blank page on a direct hit with the module
  disabled. It now bounces to /insights like every other gated page.

The achievements query error branch, the custom-metric error branch and
the chronotype notice were already correct and are left alone.
2026-07-19 06:09:49 +02:00
Marc-André Bombeck 5b464b26de chore(release): v1.30.14 — server-prefetch the workouts list; correct the aggregated heart-rate bucket docs
Move the workouts list read into a shared cached module so the RSC
wrapper and GET /api/workouts share one deduplicated projection pass,
and dehydrate it under the client cell's exact query key so the page
paints its rows instead of a loading skeleton.

Correct the aggregated heart-rate externalId documentation: the example
and the min/max field notes still described the original hourly bucket,
while the accepted contract has been 10-minute buckets since v1.30.7.
2026-07-18 22:42:23 +02:00
Marc-André Bombeck e0387c5ceb perf(prefetch): server-prefetch above-the-fold data for /insights, /medications, /coach
Extend the dashboard's RSC server-prefetch pattern to three more route roots
so their above-the-fold content paints populated on first load instead of
flashing a shell that fills in on mount.

Each route gains a thin RSC wrapper that runs the same cached read its API
route uses, dehydrates the result under the exact client query key, and hands
it to the client leaf through a HydrationBoundary. Every wrapper fails soft to
today's client-fetch path on any error and honours the DASHBOARD_SSR_PREFETCH
kill-switch the e2e server sets.

- /insights: prefetch the dashboard snapshot — the same locale-keyed cell the
  dashboard warms, resolved once server-side so the key matches by
  construction. The AI briefing and the heavy analytics aggregations stay
  client-lazy (the briefing must never trigger generation; a cold analytics
  read would trade FCP for TTFB).
- /medications: prefetch the medications list through the shared
  caches.medications cell (extracted to src/lib/medications/list-read.ts). The
  list cell keeps refetchOnMount: "always" — a cross-device take/skip produces
  no client mutation event, so the mount refetch is a correctness gate; the
  prefetch removes the initial skeleton flash without dropping that guarantee.
- /coach: prefetch the nudge status (extracted to
  src/lib/ai/coach/nudge-status.ts) so the auto-open-most-recent decision is
  ready at hydrate, collapsing the one sequential waterfall. Only runs when the
  Coach surface is reachable; the streaming conversation is never warmed.

Add per-route tests pinning server-key === client-key, JSON wire-shape
round-trip, and the fail-soft / module-gate paths. Repoint the source-reading
insights, take-all-due, and Coach gate-cascade tests at the client leaves.
2026-07-18 20:36:19 +02:00
Marc-André Bombeck e61fe42bd1 feat(workouts): enrich the per-workout detail view (#67)
The detail surface at /insights/workouts/[id] gains a real heart-rate
curve, effort zones, an aspect-true GPS route, and per-kilometre splits
— all read from data already stored, no new integration, no migration.

- HR curve: one server path with strict provenance — stored
  WorkoutSamples series first, a raw-PULSE window reconstruction second
  (quality-gated so sparse data hides rather than lies), nothing third.
  Adaptive bucketing keeps on-screen density constant across a 20-minute
  HIIT and a 6-hour ride; a min/max envelope surfaces intervals.
- Effort zones: five %HRmax bands via the Tanaka HRmax, with a device's
  own reported time-in-zone winning when present. Descriptive only.
- GPS route: self-rendered inline SVG with an equirectangular
  cos(midLat) longitude correction (fixes the ~40% horizontal stretch),
  aspect-clamped viewBox, RDP downsampling, start/end markers, and an
  optional elevation profile. Nothing leaves the origin — no map tiles.
- Per-km splits and a GPX export, both derived client/server-side from
  the geometry already on the payload.
- The endpoint extends additively (hrSeries, zones, splits, sportContext,
  a reserved insight seam) behind an opt-in compact=1 param that drops
  the raw sample/timestamp blobs for the web client; without the param
  the response is unchanged.
- Every data-less section now hides instead of rendering an empty card;
  the growing detail component is split into a workout-detail/ directory.
- List rows flag which sessions open into a rich detail.
2026-07-18 16:05:17 +02:00
Marc-André Bombeck 4ca34d4b00 fix(insights): point the ECG cross-link at the routed sub-page
Repoint EcgCrossLink from the overview `#ecg` fragment to `/insights/ecg`.
The fragment is absent until the overview section's own fetch resolves, so
the deep link no-oped on navigation (UX-flows finding F1-1); the routed
sub-page always mounts.
2026-07-18 09:25:29 +02:00
Marc-André Bombeck adf80da989 feat(insights): give ECG a routed home and index-surface presence
ECG recordings had no pill in the tab strip, no entry in the sub-page
manager, and no routed page — the only inbound pointer was a cross-link
card on the spot-pulse page. Hiding the overview ECG teaser left no route
back to a user's recordings.

- Add a routed `/insights/ecg` sub-page that reuses the existing ECG
  section (heading suppressed so the page shell owns the title); the
  overview teaser stays.
- Add an ECG entry to the Heart group of the tab strip, gated on a
  dedicated `hasEcgRecordings` signal since ECG carries no measurement
  type. The Heart group surfaces for an ECG-only device too. No ECG pill
  appears without recordings.
- Mount the ECG cross-link on the resting-pulse and HRV pages, matching
  its placement on the pulse page.
- List ECG in the sub-page manager under Heart, its eye toggling the ECG
  overview section so hiding it is reversible.
- Point the metric catalog's ECG row at the new page.
- Add `navEcg`, the sub-page description, and the empty-state copy across
  all locales.
2026-07-18 09:24:28 +02:00
Marc-André Bombeck a977df24a0 fix(ui): catalog loading/error truth, info-popover dedupe, touch sweep
Metric catalog page painted every metric as "Not tracked yet" (with
Connect CTAs) while its four availability probes were still in flight,
and stayed there forever on a failed batch — a load failure indistinguishable
from an honest absence. Gate rows on the probes' aggregate loading/error
state: row skeletons while loading, a QueryErrorCard with retry on failure.

Repoint ComplianceInfoTip onto InfoPopover instead of carrying a verbatim
structural copy (adds icon/align/data-slot override props to the shared
primitive); migrate the cycle log-sheet's FieldInfo off a hover Tooltip
onto the same click-opened InfoPopover, since a hover trigger never opens
reliably on the sheet's thumb-first touch surface.

Lift ViewToggle/SegmentedToggle off their stale 40px tap floor onto the
44px/36px floor the sibling intake and history-rail controls already
carry, and correct the docstrings to match. Restore the dropped
safe-area-inset-top term in /coach/conversations' height calc so it
matches /coach on notched devices. Bring the coach history rail's
off-scale spacing and sub-floor timestamp text onto the standard scale.
2026-07-18 06:47:32 +02:00
Marc-André Bombeck 8dd04e0170 fix(ux): close medium UX-audit findings across flows, IA, and onboarding
Coach deep-links now carry launch context in the URL instead of dropping it
at the route boundary: /coach reads ?scope= (validated against the closed
CoachScopeSource enum) and ?ask= to seed the drawer's own prefill/scope
props, the Today check-in's "Adjust" action seeds ?ask= with the plan's own
words instead of opening a blank chat, and the workout detail page scopes
its coach button to workouts.

IA: give the document vault a "View N lab values" link on LAB_RESULT-shaped
documents (the auto-stage/OCR-commit linkage existed server-side with no UI
surface); disambiguate the two "Recovery" pages (RECOVERY_SCORE anatomy vs
the composite wearable page) with a distinct label plus a two-way
cross-link; move the opt-in nutrients sync card from Source priority to
Integrations, where every other sync surface lives; rename the user-menu
theme switcher off "Appearance" (collides with the Settings hub) and the
Environment settings section to "Location & weather"; cross-link mood,
mental-wellbeing check-ins, and mood insights, which were three
unconnected islands.

Onboarding: the getting-started checklist's data-source/notifications rows
now gate their fetch on the same checklistRelevant condition the other rows
use, instead of only-while-onboarding — they used to be stuck undone
forever for anyone who finished the wizard with under 5 measurements; the
tour wrap-up no longer promises a replay affordance that was removed;
the wizard's Withings card shows a connected badge once the OAuth
round-trip lands instead of leaving the button looking untouched in the
tab it was opened from; the done screen and Modules hub get a quiet
cross-link; the sleep empty-state CTA reads "Connect a source" instead of
being pinned to one vendor.

All copy changes land across every locale; de.json reviewed for register.
2026-07-18 05:06:24 +02:00
Marc-André Bombeck b00450aec8 fix(pwa): fix pull-to-refresh's at-top check and extend coverage
The authenticated shell's actual scroll container is <main
id="main-content"> (AuthShell sets it overflow-y-auto inside a
height-locked layout); document.scrollingElement never moves there, so
the gesture's "only arm at the top of the page" check was permanently
true regardless of how far a user had scrolled a long list. Point it
at the real scroll container instead, with the old document-root check
kept as a fallback for the few body-scrolled surfaces outside the
shell.

Extend pull-to-refresh from 4 pages (labs/checkups/mood/measurements)
to the dashboard, /insights, /medications, and the documents vault —
the sharpest PWA-resume-staleness surfaces, per the audit: a
background-refreshed dose or a cross-device write otherwise sits
stale until a full remount or the staleTime window lapses.
2026-07-18 03:52:32 +02:00
Marc-André Bombeck 07f161bf25 fix(ui): workout header stacking, chart-shell inset parity, silent retarget failure
WorkoutDetailHeader held three columns at every width, wrapping the
long-format date to 4-5 lines beside a text-2xl duration at 360px;
stack below sm with duration moving under the title block. Same pass
drops a stray text-[11px] muted hint below the size floor.

The intraday pulse chart and the VO2/cardio-fitness link card carried
a static p-4 shell with no md:p-6 step, and the intraday loading state
was a raw pulse div instead of the Skeleton primitive under-reserving
the chart height — both now match the card inset contract used
elsewhere. Same one-line fix on the insights hero shell (sm-stepped
inset instead of the standard md-stepped one) and the glucose advanced
panel's 3-up grid at a 320px fold.

The medication efficacy tab's retarget control failed saves silently —
no toast, no inline alert, an unhandled rejection escaping past the
disabled-button reset. Surface a toast on failure. Also swaps the
level-shift date off a raw toISOString slice onto the locale
formatter, and the primary-target tag off bare muted text onto the
Badge component the rest of the app uses for this kind of marker.

The caffeine card collapsed its 280px skeleton to nothing on every
nutrients-page visit for an account with no logged caffeine; a 60s
staleTime (already the convention on the sibling insights queries)
keeps the flash to once per session. The efficacy chart's dynamic-
import fallback was the full card-chrome ChartSkeleton nested inside
its own Card, snapping ~100px when the real bare chart mounted — swap
for a plain Skeleton sized to the actual chart.
2026-07-18 03:52:32 +02:00
Marc-André Bombeck 5532a42ff6 fix(ui): mobile stacked intake history, icon-collapse bulk bar, ResponsiveSheet parity, tap-target floor
Intake history stayed a 4-5 column table with two full datetime columns
on mobile, forcing sideways scroll of a primary history surface; add
the stacked card-list split below md (the measurement-list pattern) and
lift the row kebab off a hard 32px box onto the size-11/size-9 floor.

The documents bulk-action bar wrapped five labelled buttons into a
2-3 row floating slab at 360px; collapse to icon-only (aria-label
carries the name) below sm, matching the document-detail-sheet
footer's established pattern.

Two coach/medication modals were still on raw Dialog while every
sibling had moved to ResponsiveSheet: the schedule-era editor and the
document attachment picker. Both now share the bottom-sheet-on-mobile
primitive; the era editor's submit button associates to its form via
the native `form` attribute since the footer slot sits outside the
form's DOM subtree (the log-intake-dialog precedent).

Tap-target floor: side-effect entry chips and the coach attachment-pill
remove control were below the 44px floor; the chips get a min-h-11
flex-center, the pill remove gets an invisible hit-slop instead of
growing the pill's visual footprint. Two insights empty-state CTAs
were missing the existing `ctaSize="lg"` treatment already used by
their sibling empty states.
2026-07-18 03:52:32 +02:00
Marc-André Bombeck e7802701ba refactor(insights, settings): unify the Heart group and add settings sidebar groups
The insights tab strip's "Cardiovascular" popover held only three rare
Withings markers while pulse, resting heart rate, HRV, and cardio
recovery sat scattered across a flat pill and the unrelated Vitals
group. Cardiovascular is now Heart and gathers all six; bmi and
workouts — flat only by historical accident — collapse into their
correct Body / Activity groups, and breathing disturbances moves out
of Vitals into the Sleep manager bucket instead of vanishing whenever
the sleep module is off. Blood pressure, weight, sleep, mood, and
medications stay pinned as flat top-level pills — now a documented
`PINNED_SUB_PAGE_SLUGS` set instead of an unexplained "predates
v1.4.32" carve-out. Active energy moves from Vitals to Activity to
match its own tab-strip section comment. All changes are grouping /
presentation data only — no slug, route, or redirect changed.

The Settings sidebar's 19 entries rendered as one flat list with no
visual structure. They now cluster under seven group headers (Account,
Your data, Display, Health profile, AI, Connectivity, System),
reordered to match; the desktop sidebar gets a non-interactive text
header per group, the mobile chip strip a quiet divider. Presentation
only — no route changed.

Reference: 2026-07-17 UX/IA audit, H2 + M1 + M2 + M3 + M6.
2026-07-18 01:23:37 +02:00
Marc-André Bombeck bf1c052797 feat(insights): add a metric catalog for discoverability
Metrics with zero data stay hidden from the dashboard and Insights nav
by design — that keeps the daily views uncluttered but leaves no
reachable surface telling a sparse-device-kit account which
capabilities exist at all. Add /insights/catalog: every metric domain
grouped by category, each row showing whether the account has data
for it and what would supply it, with a connect CTA into
settings/integrations when absent and device-sourceable.

Reuses the existing tab-strip gating (hasMetricData over the shared
analytics/comprehensive/workouts/nutrients reads) and the per-metric
empty-state copy already shown on each sub-page, so names, sources,
and presence can't drift from what's shown elsewhere. Adds one quiet,
always-visible pill at the tail of the Insights tab strip as the entry
point — no new loud nav destination.
2026-07-17 23:50:06 +02:00
Marc-André Bombeck 16d142762a feat(insights): /insights/nutrients — hydration, caffeine, vitamins
New routed sub-page, gated on the opt-in nutrients module with an
in-context enable CTA (the module stays opt-in — the 2026-07-17
consent memo's reasoning holds; this just makes the toggle
discoverable instead of buried in Settings):

- Hydration card (hero): today's total, a muted EFSA reference-intake
  line (sex-resolved, omitted when the profile has no sex on file),
  a 30-day bar chart with a dashed reference line, and quick-add
  chips (+200/+300/+500 mL + custom + edit-today's-total) in a
  ResponsiveSheet, POSTing to /api/nutrients/water.
- Caffeine card: same anatomy, rendered only when the window has
  data; the EFSA safe-level reads as a ceiling, never "exceeded"
  colouring.
- Micronutrients card: rows only for the 24 vitamin/mineral codes
  with data in the window (catalog order), a muted "M of 24 tracked"
  footer, and one honest sentence that sparse logging isn't a
  deficiency. Zero rows collapse to a single EmptyState.

No ring, no attainment colour anywhere — the bar chart plus its
dashed reference line IS the context (UI-STANDARDS: values stay
foreground).

The bar chart is a new NutrientDailyBarChart, registered in the
shared chart-runtime barrel behind its own next/dynamic boundary so
recharts stays off the route's first-load JS.

Tab-strip wiring: nutrients joins the flat pill set (event/store
driven like medications/workouts — a lightweight 1-day probe read
drives the pill's data-availability gate, module gate on top).

~20 new i18n keys across en/de/es/fr/it/pl.
2026-07-17 16:08:39 +02:00
Marc-André Bombeck af4cad46fe fix(insights): load the intraday pulse chart through the shared chart runtime
The pulse page imported the intraday area chart directly, so its
`from "recharts"` import minted a second recharts fingerprint chunk and
pushed total client JS past its budget. Route it through the shared
chart-runtime barrel via the same single next/dynamic boundary every other
chart uses, so recharts stays one shared chunk.
2026-07-17 09:46:09 +02:00
Marc-André Bombeck 8ac38926c4 merge: assemble capstone signal slices (S10 ECG narrative, S11 intraday pulse, S12 milestones) 2026-07-17 08:38:44 +02:00
Marc-André Bombeck d82862b384 feat(insights): weave ECG into the briefing narrative and daily rail
Fold the ECG silo into the insights narrative without crossing the
non-diagnostic line — the app reports the recording device's own verdict
and the existence/count of recordings, never an interpretation of the trace.

- Feature snapshot: a bounded, device-verdict-only ECG descriptor (recording
  count, device-verdict distribution, latest recorded-days-ago + average HR)
  flows into the nightly briefing prompt. The waveform never enters the
  payload; the grounding gate admits the pre-computed counts so a
  device-attributed sentence stays grounded. A prompt rule (EN + DE)
  constrains any mention to the device's attribution and forbids reading
  morphology, measuring intervals, or emitting a verdict of the app's own.
- Pulse cross-link: a device-attributed pointer from the resting-HR / pulse
  page into the ECG viewer, un-mounting when no recordings exist.
- Daily rail: an ecg_new_recording item surfaces once when a recording landed
  within the last day — calm, info-level, deep-linking the viewer. Gated on
  the insights module; the DTO carries no waveform.
- i18n across all six locales; non-diagnostic disclaimer copy per language.
2026-07-17 08:19:17 +02:00
Marc-André Bombeck a33527da57 feat(insights): intraday pulse shape and elevated-at-rest tension window
Add an on-demand intraday layer to the pulse insight: 10-minute mean
heart rate for the viewed day, folded from raw samples via the read-swap
pattern (never persisted as 10-minute rollups for all history). Draw the
personal resting baseline and softly shade any sustained
elevated-at-rest stretch.

The tension signal is not raw heart rate — it is heart rate held above
the personal resting baseline while movement is low (the elevated-at-rest
window), so a workout or a walk never reads as tension. Detection is
deliberately conservative: baseline maturity, per-bucket sample density,
a mandatory step-based low-movement gate, and a minimum sustained
duration must all hold before anything is reported. Silence over
speculation.

Surface the same signal as a calm, non-diagnostic tension_window item in
the daily digest — at most once per day, honest-absent otherwise, gated
on the insights module and yielding the rail to time-sensitive actions.
Cautious copy across all six locales; framed as "possible tension", never
a clinical stress verdict.
2026-07-17 08:16:32 +02:00
Marc-André Bombeck 49ed0f41df feat(insights): view ECG recordings
ECG recordings synced from a compatible watch were stored encrypted but
shown nowhere. They now have a surface in Insights: a list of recordings,
each opening the full single-lead waveform on a familiar ECG grid, with the
date, duration, average heart rate, and the result the device recorded.

The read routes narrow ownership in the query and decrypt the waveform
through the existing fail-closed codec; the overview is min/max-decimated so
the R-wave peaks survive the reduction, with a raw path for a calibrated
view. The result shown is the recording device's own classification,
attributed to the device — HealthLog does not read or interpret the trace
and produces no verdict of its own. A permanent note states this, and a
non-normal result carries a line to discuss the recording with a clinician.
2026-07-16 09:55:35 +02:00
Marc-André Bombeck 9bbd8a89dc chore(release): v1.28.35 — timestamps honor the profile timezone
Client-rendered times fell back to the hardcoded display zone
(Europe/Berlin) because neither useFormatters() nor the legacy format.ts
helpers ever received users.timezone. A third localStorage mirror
(timezone-mirror.ts, following the time-format/date-format pattern)
carries the validated profile zone from /api/auth/me into both read
paths; the fallback chain is strictly profile → Europe/Berlin — never
the browser zone, so screen, PDF, and export cannot disagree.
makeFormatters validates its zone before Intl (a poison value falls back
instead of throwing inside every render).

Same pass, per the two-document audit: formatUpdatedLabel boundary and
clock now resolve one zone in every state (the undefined-tz browser hole
is closed); @db.Date calendar fields render UTC-pinned so profiles west
of UTC cannot shift a day; dose-history day headings and GLP-1 weekday
labels derive from the display zone; chart day/month labels are
encoding-derived and UTC-pinned (bucket math untouched, byte-identical
for Berlin profiles); the clinician share view renders in the patient's
timezone instead of the container's; Playwright pins Europe/Berlin.
Server day-key semantics, streak anchoring, insight cache keys, and
verbatim wall-clock strings are untouched by construction and pinned by
the new Auckland/New_York/Manila + DST test matrix.
2026-07-14 20:19:23 +02:00
Marc-André Bombeck 13464b512e chore(release): v1.28.28 — OpenAI-compatible gateways work; a re-keyed night can no longer vanish
The Local provider speaks the standard JSON wire: response_format by default,
one self-healing fallback per endpoint when a server rejects it (dialect
cached per base URL) — LiteLLM, OpenRouter, vLLM, LM Studio and plain Ollama
all work from the same form, and the form + provider docs now say so. A
gateway that shims a Claude-family model behind a synthesized tool call is
parsed from the tool-call arguments instead of yielding silently empty
insights.

The insight token budget is configurable (INSIGHTS_MAX_TOKENS, default 2500,
clamped 500-8000) and unified across all six call sites; a reply cut off
mid-JSON returns a distinct truncation error instead of a generic parse
failure. A briefing withheld by the number-grounding check now says so on the
card instead of pretending nothing was generated. The provider test button
answers 4xx with a bad_request category instead of claiming the endpoint was
unreachable.

Sleep repair follow-up: measurements carries a second full unique index — the
natural key (type, measured_at, source, sleep_stage) — so a key-format
migration could tombstone the old rows while their re-keyed replacements
collided with that index and were silently dropped (live incident: a
full-history re-sync erased a user's sleep). Planned creates now run a
natural-key rescue probe: a match, live or tombstoned, is updated in place —
fresh value, the new externalId, deletedAt cleared. A rescue-probe failure
holds the watermark so nothing is lost past the overlap.
2026-07-11 10:36:02 +02:00