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).
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)
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)
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)
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.
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.
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.
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.
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).
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.
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.
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.
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.
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.
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>
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.
Redraw the inline body-map silhouette to the proportions the iOS client
calibrated against: an oval head on a short neck, sloped shoulders, a
torso that tapers to the waist and flares to the hips, arms held slightly
out to mid-thigh with simple hands, and separated legs ending in feet.
The figure is composed as a torso-plus-legs outline with two separate arm
paths so there is a clean underarm gap and the arms read as arms rather
than a single blob. The eight site anchors keep their calibrated
coordinates on the 100x200 viewBox, so every tappable region and its
nearest-centre hit routing is preserved — the dots still land on real
anatomy (upper arms, the four abdomen quadrants, the thighs) and stay
selectable, theme-aware, and keyboard reachable.
Add a compact marker legend to the injection-site dialog: a dashed primary
ring marks the recommended next site and an amber ring marks the last-used
site. The last-used marker on the picker switches to the same amber tone so
the legend swatches mirror the body map exactly.
Lay the data-model foundation for the Fitbit/Pixel integration over the
Google Health API, mirroring the WHOOP provider. No OAuth, sync, or UI yet.
- Prisma: FitbitConnection (1:1 per user, encrypted access/refresh tokens)
and FitbitOAuthState models, two encrypted BYO-credential columns on User,
and FITBIT appended to the MeasurementSource enum. Migration
0117_v1120_fitbit_integration is additive only — one enum value plus two
tables and two nullable columns, no new MeasurementType (every launch
metric maps onto an existing type).
- Wire the two new encrypted columns and the FitbitConnection token columns
into scripts/rotate-encryption-key.ts.
- FITBIT touchpoints: the read measurementSourceEnum (not the writable set),
the DEFAULT_SOURCE_PRIORITY and workout-source ladders, the sources-section
label map, the IntegrationKey union plus its display name, and a
fitbit()/fitbitStatus() query-key pair. The source-rank SQL and read-tier
collapse pick FITBIT up from the ladders with no code change.
- Add the FITBIT source label to all six locale bundles and regenerate the
OpenAPI contract so the source enum carries FITBIT for the iOS decoder.
Move the time-range pills + period-over-period delta out of the slot between
the stat strip and the chart and render them as a clean row below the chart on
every metric sub-page (the HealthKit scaffold plus weight, BMI, pulse, blood
pressure, sleep). The control owns its own persisted range pref and delta read,
and the chart owns its window via its in-card range tabs, so the relocation is
a pure DOM reorder that leaves the chart range untouched.
Right-align the last-updated timestamp on the period narrative ("Zeitraum im
Überblick") and the daily-briefing footer so the "updated" line matches the
status cards, which already route through the right-aligned footer.
Tighten the sleep average and hypnogram cards — drop the default card gap and
the headline card's extra padding that left tall empty bands — and give the
hypnogram clock-aligned X-axis ticks (whole hours on a 1–3 h step picked from
the night's span) so the time axis reads 23:00 / 01:00 / 03:00 / 05:00 instead
of raw bedtime/wake stamps.
Add a compact density to the short-horizon projection card for the metric
sub-pages: it sheds the min-height floor, shortens the chart, and surfaces how
many more days of tracking are needed before a projection can appear, drawn
from the coverage envelope. The Insights overview keeps the roomier default
geometry for grid symmetry.
- Mood discovery footer no longer pairs a mood-only numerator with the
full behaviour-by-outcome denominator. Reword it to surface the mood
relationships out of all day-to-day pairs the engine tested across
every metric, so the count it reports is honest about its scope.
Update all six locales.
- Rank the better-days board on a comparable scale: standardize the tag
mood-point delta into a Cohen's-d effect (delta over the pooled
with/without mood SD, clamped to [0,1]) so it sits alongside a metric
|r| instead of the old |delta|/2 heuristic. Fall back to |delta|/2 only
when the pooled SD is undefined. The pooled SD is ranking-only and is
stripped before the row enters the snapshot payload; the raw delta and
r the cards show are unchanged.
- Normalize the Withings and Whoop resume buttons to the min-h-11 scale
token, dropping the arbitrary min-h-[44px] spelling.
- Give the API token copy button a 44px tap target on small viewports,
scaling back to the tighter icon size from sm up.
- Drop the dead cached prop from InsightStatusCard and every caller; the
card has carried no cached affordance since the badge was removed.
Apple Health writes an unspecified ASLEEP aggregate alongside the granular
CORE/DEEP/REM partition for the same period. The asleep total already preferred
the granular partition, but the per-night/per-session `stages` map and the
hypnogram `segments` list recorded every stage row unconditionally, so a night
ended up carrying the granular set PLUS its ~equal bare twin — a double-height
green ASLEEP block on the stacked bar and the hypnogram, and a doubled total in
the analytics stage breakdown. Route the `stages` map and the `segments` list
through the same granular-over-bare gate as the asleep total: when a session
carries a granular stage, drop the redundant bare ASLEEP (and stage-less) rows
everywhere. WHOOP never emits a bare ASLEEP, so WHOOP nights are unaffected.
Apply the gate per session, not over the merged night pool, so a granular
overnight no longer strips a same-day bare-only nap's minutes from the night
total. Render the hypnogram per-segment in the stage palette (one ReferenceArea
per segment) instead of a single flat cyan line, and show the breakdown plus
totals footer whenever any per-stage minutes exist — reserving the unavailable
state for a truly empty session — so a legacy stage-summary night still surfaces
its breakdown. Thread the user's source priority into the CSV export's sleep
dedup, and honour offset with a true pre-slice night count on the sleep list.
The unit-system selector and the app-wide injection-site exclusions are
personal account preferences, not dashboard-layout controls. Move both
cards from the Dashboard settings section to the Account section, next to
the profile. The persistence path is unchanged — same endpoints, query
keys, mutation hooks, and labels; only the render location moves. The
Dashboard section keeps the layout/widget controls.
Mirror the Withings integration actions on the WHOOP card. A new
POST /api/integrations/whoop/test runs a live, rate-limited probe
against the WHOOP basic-profile endpoint, refreshing the token via
getValidToken and classifying HTTP/transport failures into the same
errorCode taxonomy the Withings test route uses. A new
POST /api/integrations/whoop/resume clears a parked integration via
resumeIntegrationFromPark for the whoop provider.
The settings WhoopCard gains the shared Test-connection button, the
parked-state resume banner, and switches the full-sync icon to
RefreshCw for visual parity. Sync-now and sync-all are unchanged.
Render the night's phase progression as a stepped depth track (Awake top
→ Deep base) over clock time, reusing the Dracula stage palette from the
stacked bar (now exported, no token reshuffle). Below it: the per-stage
breakdown (time + %), total asleep, time in bed, and the mid-sleep
awakenings count. One canonical source per night — Apple and WHOOP are
never overlaid.
Wired atop the sleep insights overview, gated on the same data-bearing
branch as the rest of the page so a source-less account never fetches.
New i18n keys under insights.sleep.hypnogram in all six locales;
query-key factory entry + measurement-write invalidation.
SLEEP_DURATION is stored one row per stage per night, so a plain list
query leaked every stage segment as its own value. Route a sleep list
through the canonical per-night aggregation: one row per wake day
carrying the night's TIME ASLEEP (the main session's asleep total per
the nap convention), with a chevron drilling into that night's stage
segments. The drill-down renders stage labels + per-stage minutes, and
the night row captions same-day naps and mid-sleep awakenings.
API: a `?type=SLEEP_DURATION` list now returns synthetic per-night rows
(value = time asleep in minutes); `?dayKey=` returns the night's stage
segments. Additive — no existing client requested per-stage sleep rows
as a feature. Storage is untouched.
New i18n keys: measurements.sleepNapCaption + sleepAwakeningsCaption in
all six locales.
The injection-site capture dialog dismissed optimistically the instant
Confirm fired — before the PATCH that records the site resolved, with no
pending state. A failed request left the dose looking site-tagged when it
was not, and reused the unrelated "no sites available" string for the
error. The dialog now holds a submitting state: Confirm and Skip disable,
a spinner shows, and a backdrop tap or Esc cannot dismiss while the
request is in flight. The card handlers keep the dialog mounted until the
PATCH succeeds and surface a dedicated save-failed toast on failure.
Reset discarded typed input with no confirmation; a mis-tap silently
wiped a half-written entry. Reset now confirms first when the form holds
content (mood, tags or note) and resets immediately when pristine. The
note field also gained a live character counter so its 500-character cap
no longer truncates without feedback.
The mood list delete mutation had no onError handler, so a rejected
DELETE cleared no state and showed nothing — the row stayed in place
with no signal the request was refused. Add an error toast on failure,
mirroring the medication-card silent-failure fix.
Apple Health (and some WHOOP shapes) write BOTH an unspecified ASLEEP
aggregate row AND the granular CORE/DEEP/REM breakdown for the same
sleep period. The per-night reconstruction summed both, so a real ~8 h
night surfaced as ~16 h across every sleep surface — the chart showed
~16-18 h points and the target-range 30-day average read ~16.4 h/day.
Prefer the granular partition: when any CORE/DEEP/REM stage is present
for a night/source, count only those; fall back to the bare ASLEEP (or
a stage-less legacy/manual) row only when no granular stage exists.
IN_BED and AWAKE stay excluded; dual-source nights still collapse to one
canonical source via the user's sleep priority ladder. The same rule
applies inside the per-source asleep tiebreak so a source writing both
shapes is not over-weighted.
Align the surfaces that bypassed the shared helper:
- insights/targets: SLEEP_DURATION was averaged as raw per-stage rows
(minutes) and fed into a target band expressed in hours, producing the
bogus 16.4 h/day. Reconstruct per-night time-asleep, convert to hours,
and classify + roll up consistency on the nightly hour totals.
- coach snapshot: route the sleep block through reconstructSleepNights
so the prompt narrates the same nightly numbers (was summing every
stage row, including IN_BED + AWAKE).
- sleep card chart: read /api/measurements/series?kind=sleep (night
reconstruction, hours) instead of the generic measurement
daily-average path, and label the axis in hours instead of minutes.
Add regression fixtures: a night with both a bare ASLEEP row and the
granular breakdown asserts the granular total (no double-count); a
dual-source night with both shapes per source; and a multi-night 30-day
average that lands at ~8 h, not ~16 h.
The data-reset and account-delete confirmations fired their mutation
from an uncontrolled AlertDialog, so the action dismissed the dialog
the instant it was clicked and the only spinner lived on the now-hidden
trigger. A user on a slow link saw no feedback and could re-open and
re-submit a destructive, irreversible action.
Drive both dialogs from controlled open state and add an in-dialog
pending treatment to the confirm button — disabled, aria-busy, and a
spinner — mirroring the bulk-delete confirmation in the medication
intake history. The dialog now holds open while the request is in
flight; the data-reset closes on success, the account-delete keeps its
pending state through the post-delete redirect.
The one-time API token rendered in a bare code block with no way to
copy it short of selecting the text by hand — the worst place for copy
friction since the value is shown exactly once. Add a copy-to-clipboard
button beside the token with a success toast, reusing the
clipboard-plus-toast pattern already used by the per-medication token
row. Surface an inline check affordance for two seconds after a copy.
Token revoke previously swallowed every failure: a non-OK response or a
network error left the user with no signal the token was still live.
Add an error toast on both paths so a failed revoke is visible.
Both overview cards carried a min-h floor sized for their tallest state and
applied it to every state, so the calm everyday read left a large empty band
at the bottom.
- Today's signal: the min-h-48 floor now applies only to the tall `fired`
state; all-clear / watch / insufficient size to their short content.
- Period overview: the resolved card drops the min-h-40 floor and the footer's
mt-auto that pinned the timestamp to the bottom of the floored height.
The in-flight skeletons keep their min-h footprint, so the first paint still
reserves the row and does not shift as the read resolves.
The target gear painted a heavier 44 px box on mobile against the Coach
icon's flat 40 px, so the header cluster read as two mismatched icons.
Settle the gear on a flat 40 px box that equals the Coach sparkle on every
breakpoint, and restore the 44 px WCAG 2.5.5 touch target through a
transparent ::before overlay rather than a larger painted box. The shared
icon-button size stays untouched, so other icon affordances are unaffected.
Drop the top-right "cached" label from the assessment card — it surfaced
an implementation detail and read as if the insight were second-hand. The
warm cache still backs the text; the card just no longer announces it.
Mount the "show more" toggle only when the clamped prose actually
overflows three lines, measured against the live layout via a
ResizeObserver instead of a character-count guess. Short assessments that
fit no longer grow a toggle that reveals nothing, and text that wraps past
three lines on a narrow column now gets one.
Move the toggle copy off the inline locale-prefix fallback onto proper
`insights.assessmentShowMore` / `insights.assessmentShowLess` keys across
all six locales, and right-align the "last updated" line.
Two changes that turn the descriptive relations surfaces into a
statistically-gated discovery view on the mood page.
Mood as an outcome: the FDR-controlled all-pairs correlation discovery
engine treated mood only as a behaviour (a lag source). Promote it to an
outcome channel too, so the scan also surfaces "behaviour today → next-day
mood" relations (e.g. more daylight today → better mood tomorrow) alongside
the existing "mood today → next-day outcome" direction. The self-pair is
skipped and Benjamini-Hochberg already controls the larger pair family this
opens up. The discovery route now feeds the shared mood daily-mean series
into both roles.
Surface the discovery slice: the engine was computed but never rendered.
The mood Insights page now shows the mood-involving pairs that survive the
false-discovery correction, each phrased observationally with its paired-day
count, r, and BH q-value, plus the honest "X of N tested" footer. The
surface self-fetches the correlation route and degrades to nothing when the
operator has disabled it. Observational only, with the standing disclaimer.
Two new observational surfaces on the mood Insights page, both computed in
the single mood-aggregates engine so the charts and the coach snapshot stay
in sync.
Influence on mood (per tag): for each frequent flat and structured tag,
compare the average daily mood on days the tag is present against the
counterfactual baseline of days it is absent, with the delta and a
confidence band. The band comes from a Welch two-sample t-test reusing the
exact Student-t p-value already behind the correlation cards, not a naive
approximation. Both groups must clear a five-day floor; rows rank by
absolute delta and cap per axis. Sparse tags, single-day tags,
all-same-value days, and zero-delta tags are dropped cleanly — never NaN.
Better days board: one ranked list folding the tag deltas and the
mood x health-metric correlations (sleep, steps, pulse, weight, blood
pressure) into a single effect-size-ranked, confidence-gated surface — the
headline answer to "what goes with my good days?". Near-zero and
under-sampled correlations are excluded; the list is capped and observational
only, with the standing "association, not cause" caption.
Both surfaces ride the mood-status snapshot too so the assessment prose
never contradicts the on-screen relations. New copy in all six locales.
The target reference card carried an inline "Adjust target range" link,
which read as a control buried in a read surface. Lift the action to a
gear button in the category-page header, to the left of the Coach launch
icon, so the two sit as a consistent icon cluster beside the heading.
A small page-scoped provider bridges the two: the reference card
registers its editable target (one per page, except blood glucose which
fans out to several contexts and registers each) and the header gear
opens the per-metric editor sheet the provider owns. Single-target pages
open that target directly; glucose opens the primary context, since
target editing lives only on these pages — there is no separate targets
management surface. The card itself is now a pure read surface.
The gear carries the action's existing label as its accessible name,
clears the 44 px touch-target floor on mobile, and exposes a stable
`data-slot` for end-to-end coverage.
The per-metric target reference panel rendered on a borderless
`bg-muted/30` inset, which read as a nested detail block rather than a
top-level card. Side-by-side with the assessment, trajectory, trend and
recommendation cards on the same category page it looked off-tone. Match
the compact card surface those siblings use (`bg-card/20` tint, a
`border-border/40` hairline, the shared `rounded-lg`) so the panel reads
as one family.
Each Trends-row card carries a one-line caption below its mini-chart.
The first choice is the AI advisor's per-metric sentence, but on a cold
briefing — a web-only account, a pre-briefing cached payload, or the
advisor still generating — that sentence is null and every card fell
back to a static "Awaiting more data" hint, even with a full 30-day
series painted right above it.
Add a deterministic, rule-based descriptor derived from the same series
the mini-chart plots, and apply a three-tier caption precedence:
1. advisor annotation present → the AI sentence (unchanged).
2. else, a computable series → the deterministic descriptor:
direction (rising / falling / stable) plus the first-vs-last
magnitude over the window, e.g. "Rising over 30 days (+8 mmHg)",
"Falling over 30 days (−1.4 kg)", "Stable over the last 30 days".
Mood reads on a categorical 1–5 scale, so it is phrased in plain
terms ("Mood trended slightly higher over 30 days") with no raw
point delta.
3. else (genuinely < 2 points) → the real "Awaiting more data" hint.
The descriptor is observational and neutral by construction: direction
and magnitude only, with no causal, diagnostic, or medical claim and no
value judgement — a rising weight and a rising step count read the same
way here. A per-metric stability floor (the larger of an absolute and a
relative bound) keeps day-to-day noise from reading as a trend.
The descriptor logic lives in a pure, unit-tested helper. The caption
reads the same series the chart shows: mood reuses the existing
mood-analytics cache slot, and numeric metrics take a small dedicated
30-day daily-aggregate read keyed under a new query-key factory entry
that joins the measurement-write invalidation set. New descriptor copy
ships in all six locales. The pending shimmer while the advisor is in
flight is unchanged.
The Trends row renders three mini-chart cards (blood pressure, weight,
mood). The mood card alone overflowed: its categorical y-axis ("Super
gut/Gut/Okay/...") and date x-axis paint inside a Card shell that runs
taller than the flat HealthChart minis beside it, so the x-axis date
labels bled past the fixed chart slot and overlapped the caption below.
Bound the overflow on the slot itself, without touching the chart
components:
- `overflow-hidden` clips anything the inner chart paints past the
180px envelope, so a stray axis label can never escape into the
caption row.
- `[--chart-height:120px]` drives both mini charts' internal band down
to a shared 120px (both honour `h-[var(--chart-height,140px)]`). The
mood card's full envelope — band + header chrome + axis labels — now
fits inside 180px, so all three tiles share one chart-band baseline.
Recharts and the chart tokens are untouched; the fix lives entirely on
the row's slot container.
The metric chart withheld every reading behind a "more days needed"
card until three distinct daily points existed, so a user with one or
two days of real data (e.g. respiratory rate) saw a hint instead of
their measurements. Render the available points for any non-empty
window — a single marker for one day, a line for two — and add a
subtle inline caption that more days fill out the trend. Only a
genuinely empty window keeps the no-data card.
The trend, moving-average, and comparison overlays already self-gate
at two points, so relaxing the render gate cannot paint a misleading
line from too little data. Drop the now-unused raw-count plumbing the
old three-point copy split relied on.
On a cold dashboard load the recent-achievements card briefly showed content
(or its empty state) and then vanished. Two causes:
- The layout-toggle gate read `DEFAULT_DASHBOARD_LAYOUT` while the real layout
was still loading; achievements (and recent workouts) default to visible, so
the card painted during the load window and was retracted once a layout that
hid it arrived. Gate both layout-toggle-only cards on the resolved layout.
- The card itself rendered its empty / content branch before its query settled,
so the "no achievements yet" state flashed ahead of the real unlocks. Render a
skeleton while the query is pending and commit to a content shape only once it
resolves.
The hero strip, daily-briefing card and trends row all read the advisor
payload through `useInsightsAdvisorQuery`, which POSTed `/api/insights/generate`
without `force`. That POST generates inline on a cache miss, pinning the
page-load path on the full provider chain (up to the client's 8 s abort) before
the briefing paints — even though a read-only lift of `User.insightsCachedText`
costs nothing.
Add a read-only GET to the route that serves the cached payload immediately and,
when the cache is stale or missing and a provider is configured, enqueues an
out-of-band warm on the existing `insight-pregenerate` queue. The read mount now
uses the GET; user-initiated regeneration keeps the POST force path. Mirrors the
stale-while-revalidate posture of the per-metric status and period-narrative
reads — no provider call ever sits on a navigation request.
The "mood by time of day" card spans the full overview width, unlike its
distribution/weekday siblings which sit in a two-column grid. Sizing the
chart wrapper with aspect-[3/2] derived the height off the full card width,
so on a wide viewport the chart ballooned to roughly 800px tall and
overflowed its card. Swap the aspect ratio for a fixed bounded height so the
chart fits the card at the same size as the width-constrained sibling charts
regardless of viewport. Chart internals and tokens are unchanged.
The wellness-score ring tiles passed the metric title into the ScoreRing
as an in-SVG centred <tspan>. A long localised title (Schlaf-Score,
Bereitschaft) was wider than the small ring's clear centre, and because
the text is SVG-centred inside the recharts overflow:hidden svg the leading
glyph was clipped (the missing leading "S" on Schlaf-Score). Render the
title as an HTML caption below the ring instead, where it wraps within the
tile and never truncates; the ring keeps just the number centred. The
in-SVG label slot stays for the anatomy detail view, which only ever passes
the short "/100" string.
The data-reset and account-delete cards under Settings → Advanced
stacked their destructive button below the description. Move the
button to a right-aligned slot on the same row as the title and
description, matching the stack-on-mobile / right-align-on-desktop
contract the onboarding-tour and about cards already use
(flex-col gap-3 sm:flex-row sm:justify-between, button w-full sm:w-auto).
The AlertDialog confirmation, i18n keys, and destructive styling are
unchanged.
The mobile passkey list rows used a tighter p-3 while every other card
surface in the account section uses the standard padding step. Bump to p-4
so nested card surfaces read consistently; radius stays rounded-lg.
The thumb-up / thumb-down POST used to fail silently — on error the
buttons just reappeared with no signal that the submission had not gone
through. Add a lightweight localized error toast on failure while keeping
the success path quiet (the inline confirmation row is signal enough
there).