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.
SLEEP_DURATION is stored in minutes, one row per sleep stage per night
(IN_BED / AWAKE / ASLEEP / CORE / DEEP / REM) since the HealthKit
category-sample ingest landed. The value is not cumulative, so reading
the single most-recent row surfaced one stage's minutes — never the
night. The dashboard sleep tile and the sleep series therefore showed a
fragment of the night rather than the night.
Add a shared `summarizeSleepNights` helper that groups the per-stage
rows by the wake-day calendar date in the user's timezone and sums the
asleep stages (CORE + DEEP + REM, plus a legacy bare-duration row),
excluding IN_BED and AWAKE per the AASM convention already used by the
sleep score. Wire it into:
- the dashboard summary tile, which now emits last night's time asleep
in hours with an explicit `unit: "h"` and a per-stage breakdown for a
future detail view;
- the sleep series, which now returns one point per night (time asleep
in hours) plus a top-level `unit` for every kind so clients never
infer the unit;
- the slim summaries slice, so the web dashboard tile and the insights
overview read per-night totals (kept in canonical minutes there; the
web tile converts to hours) — strictly more correct than averaging
across stage rows, and parity with the iOS value.
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.
A measurement logged by hand on the iOS app while offline is mirrored
into HealthKit. On pairing a server, adopt-on-pair uploads it as a
MANUAL row while HealthKit background sync independently re-ingests the
same sample as an APPLE_HEALTH row. The two carry different external ids
and sources, so the composite unique index keeps both — doubling the
whole hand-logged history on first pair.
Collapse the pair at ingest: an incoming MANUAL or APPLE_HEALTH row is
dropped as a duplicate when a same-reading row of the opposite source
already exists (same type and value, measuredAt within two seconds).
The match is scoped to that one cross-source pair, so same-source rows
and rows from server-owned sources keep their existing contracts, and
two genuinely distinct readings at the same minute never collapse.
stats:* cumulative rows stay on their per-day overwrite lane.
The collapsed copy returns status "duplicate" with reason
"cross_source_merge" so the client cursor advances unchanged; no
schema, migration, or OpenAPI change.
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.
WHOOP body fields are self-reported profile data; guard mapBody against
absent, NaN/Infinity, or non-positive values so a garbage reading never
seeds a WEIGHT measurement or a profile height of 0.
Extract handleCollectionFetchError so the five resource syncs share one
soft-skip-vs-rethrow path instead of five copies. Behaviour is unchanged:
a per-resource 403 returns 0 and keeps the connection connected, anything
else records a classified failure and rethrows.
A freshly-opened quick-entry sheet prefills its datetime-local field with
the current time, so the dismiss-time dirty check read every pristine
sheet as unsaved and always raised the discard confirm. Exclude
date/time picker types from the scan; typed text and notes still count.
The tile-strip skeleton and per-tile Suspense fallback reserved only 6rem,
shorter than a populated TrendCard (~8rem), so the strip grew when data
landed. Bump both to 8rem and mirror the card's overflow-hidden chrome on
the fallback to keep first-paint layout shift at zero.
An accidental overlay tap, Escape, or mobile swipe-down on a quick-entry
sheet silently dropped any typed measurement, mood, or intake. Detect
unsaved input at dismiss time and ask before closing; an explicit Cancel
or a successful save still closes straight away.
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).
The six `next/dynamic` loaders on the insights overview each rendered a
bespoke `<div className="… h-[Xrem] animate-pulse …" />` with a guessed
fixed height. Those heights pinned each placeholder taller or shorter than
the resolved block, so the page shifted as each chunk landed. Route every
loader through one shared `BlockSkeleton` built on the `Skeleton` primitive
(which already honours reduced motion) and hold the row open with a
`min-h` floor rather than a hard-coded pixel guess, removing the
resolve-time layout shift. Decorative placeholders for the cards that can
un-mount stay `aria-hidden`.
Anchor the warm-assessments control too: it floated right-aligned with no
label, reading as a context-less affordance. Pair it with a left-aligned
caption that explains the nightly-refresh model so the button has a home.
The 400 ms `.animate-insight-in` entrance keyframe is applied across five
insight surfaces and is collapsed to `animation: none` once, centrally, in
a `@media (prefers-reduced-motion: reduce)` block in globals.css. Extend
the motion-reduce coverage test to assert that central guard so a future
edit cannot silently drop it and replay the entrance motion for
motion-sensitive users.
The recommendation cards carry `transition-all md:hover:-translate-y-0.5`
for a subtle lift on pointer hover. Motion-sensitive users got the full
transform with no opt-out. Pair the hover with
`motion-reduce:transition-none motion-reduce:hover:translate-y-0` so the
card stays put when the user asks for less motion.
Lift the disclaimer and feedback-thanks lines off the off-scale 10px
size onto the standard extra-small step. Unify the user and assistant
row gap, and budget the avatar column out of the bubble's 80% cap so a
bubble plus its avatar never overflow a comfortable width on a narrow
phone. Announce the empty-thread hero as a polite live region so screen
readers read the hint when the thread first mounts.
The snapshot builder ran its independent reads sequentially on a cache
miss: feature extraction, the shared measurement query, the mood,
compliance, sleep and workout table reads, and the GLP-1, derived,
trajectory and memory helper blocks each awaited in turn. None consumes
another's result, so they now fire concurrently and resolve in batched
hops — feature extraction and the measurement read first (both gate the
per-metric blocks), then the remaining reads in one Promise.all. The
source guards are unchanged, so a disabled source still issues no query,
and the synchronous block assembly keeps its original order, so the
provenance envelope is byte-for-byte the same. Cuts several round-trips
off the cold build; the warm path is cached and unaffected.
While a reply streams the composer now swaps the send button for a
visible Stop control bound to the abort handler, so a long or off-track
reply can be interrupted instead of waited out. Closing the drawer
mid-stream also calls the cancel handler, so the SSE request no longer
keeps running in the background after the drawer is gone.
Add a localised "Stop" label across all six bundles.
The first / last jump buttons rendered at 32px, below the minimum touch
target. Extend the hit area with an invisible inset pseudo-element so the
tappable region is 44px while the visual button stays 32px; the existing
aria-labels are unchanged.
The bare centred spinner reserved a 32-unit-tall box that the resolved
card grid then displaced, jumping the layout. Render a grid of card
skeletons built on the real Card shell + shared Skeleton primitive so the
loading state matches the loaded footprint.
The intake-edit dialog already tracks a busy state and sets aria-busy, but
the Save button gave no visual feedback while the PUT was in flight, unlike
the wizard and quick-add. Render the same inline spinner during the
mutation.
A failed take/skip POST cleared the spinner without any feedback, so the
user believed the dose was logged when it was not. Show an error toast on
a non-ok response or a thrown request, and never show the success
confirmation in that case.
On a successful take/skip, attach an Undo action to the toast that
soft-deletes the just-recorded intake via the per-event delete route, so
a misclick no longer needs a trip through the intake history to correct.
The achievements namespace — badge titles, descriptions, category
labels, and dashboard card copy — rendered verbatim English outside
German. Translate the surface for the four remaining locales, keeping
the format-token strings and the Engagement category as-is.
Schedule, wizard, intake, and detail-page copy in the medication
surface rendered verbatim English for the four non-reference locales.
Translate the wizard steps, cadence options, intake history, reminder
controls, and danger-zone prose, leaving units, ATC/RxNorm codes, and
GLP-1 INN drug names in their language-appropriate form.
Large stretches of the Settings surface — account, passkeys,
notification channels, AI provider configuration, Withings, API
tokens, export, and the danger zone — rendered verbatim English in
the four non-reference locales. Translate the genuine prose and
labels, leaving brand names, units, and protocol tokens as-is.
The insights.rhythmEvents event labels and verdicts shipped verbatim
English in the four non-reference locales. Translate the 11 affected
keys so the device-health-notifications surface reads in the user's
language.
The body-measurement endpoint was fetched by a function nobody called, so WHOOP
weight, max heart rate, and height never reached the database despite being
documented in the mapping table.
Add sync-body.ts and wire it into the per-user sync loop (and, through it, the
backfill):
- weight_kilogram → a WEIGHT Measurement (source = WHOOP) on the stable
externalId whoop:body:weight with overwrite semantics. A body measurement is
a single self-reported profile value, not a time series, so a re-sync updates
the same row in place rather than accumulating one row per poll. measuredAt is
the fetch time; the source-priority picker ranks a real scale above WHOOP.
- max_heart_rate → WhoopConnection.maxHeartRate (a profile constant).
- height_meter → User.heightCm (m→cm), written ONLY when the user has no height
yet — a user-set height is never overwritten and height is never minted as a
Measurement.
Every write is field-by-field and idempotent across reruns. Document the body
mapping and the watch-only blood-pressure stance in mapping.md.
A 403 on a single WHOOP collection endpoint was classified reauth_required,
which parked the whole connection at error_reauth and short-circuited every
future sync. A lower-tier user missing one gated data class would brick the
entire integration.
Treat a per-resource collection 403 as a soft skip of that data class: log,
return 0, and continue so sibling resources still sync. Reserve connection-wide
reauth for a 401 (token rejected) and for a 403 on the token-refresh / profile
path, which run outside the per-resource catch blocks and remain unchanged.
Add isCollectionForbidden as the shared gate and cover both branches in tests.
Extend the SSRF pin inventory to also assert the requirePublicHost pins
already present on the user/operator-supplied moodLog push/sync webhooks
and the ntfy sender, so the full set of overridable-host egress points
is CI-guarded and a future edit that drops one reds here.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Prisma unique-constraint (P2002) narrowing predicate had been
hand-copied into five call sites. Fold them into a single isP2002 in
src/lib/prisma-errors.ts and import it everywhere (the two step-drain
sites keep their local isUniqueConstraintViolation name via an import
alias). Behaviour-identical; one place to chase a future P2002-semantics
change instead of five.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A single shared forgetOne mutation meant forgetOne.isPending disabled
every per-fact forget button during one delete, so the whole list read
as frozen. Track the in-flight fact id via the mutation variables so
only the row being deleted disables and shows the spinner.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The eight v1.10 additive HealthKit signals (cardio recovery, six-minute
walk, stair ascent/descent speed, breathing disturbances, wrist
temperature, falls, walking steadiness) are writable dashboard widget
ids so the native client's Home-pin PUT validates them, but the web
dashboard renders strip tiles via hardcoded per-id blocks and has no
render path for them. Left as-is, the web Settings -> Dashboard list
would offer eight toggles that do nothing on web.
Name them in a new IOS_PIN_ONLY_WIDGET_IDS set (writable-but-not-
web-rendered) and filter that set out of the rendered settings list, so
the toggles never appear on web while the ids stay accepted by the
widgets PUT enum for the native client. Add tests asserting the eight
remain writable (catalogue membership) yet are absent from the web list.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The synchronous DAY rollup hook carries no pg-boss singleton, so two
measurement writes landing on the same (user, type, day) can interleave
their delete-then-insert and the losing createMany trips the per-source
unique index. The peer rewrote the identical source-collapsed partition
— the aggregate is a pure function of the same underlying rows — so the
rollup is already correct. Catch the P2002 in the single-chunk
transactional path, annotate it, and return rather than failing the
recompute. Any other Prisma error still bubbles.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Drop the unused `now` field from the coach `ExtractOpts` type; fact
extraction never read it (conversation-summary keeps its own `now`).
- Correct the no-ladder tiebreak docstring in measurement-read: the
collapse picks the alphabetically smallest source name, matching the
live-SQL `ORDER BY … source` tiebreak, not max count.
- Replace the hand-inlined canonical-source subquery in the summaries
rollup-path narrows query with `canonicalMeasurementsFrom(rank, "90 days")`
for live/rollup parity with its siblings.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add eight widget ids (cardio recovery, six-minute walk, stair ascent/descent speed, breathing disturbances, wrist temperature, falls, walking steadiness) to the dashboard widget catalogue + default layout (hidden by default) so they can be pinned via the layout PUT.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Load the source-priority blob once and pass it into both
readBestGranularityRollups calls. Completes the B3 fast-path threading.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Load the source-priority blob once and pass it into both BP readers
instead of letting each re-query the user.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Load the source-priority blob once and pass it into the three
readRollupBuckets calls instead of letting each re-query the user.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>