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.
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.
The GLP-1 estimated active-substance curve (the modelled drug level from logged
doses) now also renders in the efficacy (Wirkung) tab and in the Insights
medication overview, not only under the injection tab. Both reuse the same
self-contained DrugLevelChart, gated on the GLP-1 treatment class, so the drug
level sits next to the efficacy readouts. Additive — no existing surface changes;
non-GLP-1 medications render nothing.
Add the per-medication "Wirkung" tab (hidden for one-shot and no-target meds)
rendering the target series with a start marker, dose-change markers, shaded
pause bands and a faint reference band, an adherence lane, a neutral
before/after-start card with honest empty states, an optional level-shift note,
and a retarget chooser. Weave a compact, read-only efficacy summary into the
Insights medication surface that reads the same DTO and links through to the
tab. All copy is descriptive; add a banned-verdict-verb guard test over the
medications.efficacy namespace across all six locales.
- Achievements: a failed load renders QueryErrorCard instead of falling
through to zeroed summary tiles and a 'none unlocked yet' empty grid.
- /coach/conversations: a load failure now shows a retry instead of a false
'no conversations yet' empty state.
- Insights overview error uses the shared QueryErrorCard.
- Coach Plans gains a header cross-link from /coach/conversations (and back),
a skeleton loading state, and the shared EmptyState primitive.
Every next/dynamic chart import used to point at its own module, so
Turbopack emitted a separate chunk group per boundary — the production
build carried EIGHT ~312 KB copies of the recharts module graph, and
/insights (the worst-LCP route) downloaded two of them (~92 KB gz of
pure duplication, one fully unused at load).
All chart loaders now resolve the single chart-runtime barrel
(src/components/charts/chart-runtime.ts), which owns the only path to
recharts: one shared chunk group, recharts exists exactly once in the
build (verified: 8 -> 1 recharts-fingerprinted chunks), cached across
routes. Charts stay recharts and stay pixel-identical; every call site
keeps its own matching skeleton and ssr:false flag.
Riders of the same rule:
- the mood-trio barrel (mood-charts.ts, the v1.16.7 precedent this
generalises) folds into the runtime and is removed
- the vitals sparkline body extracts to delta-sparkline.tsx and loads
through the runtime; the tile keeps the fixed 40 px band, so the
async gap is an identically-sized empty box
- /cycle's BBT chart (the last static recharts import on a live route)
defers through the runtime with a card-matching skeleton
H3
A measured pass over every insights sub-page, the dashboard, and the
settings sections (computed text colour, left text edge relative to the
card edge, header→body distance, per card) surfaced the drift the eye
kept catching:
- The blood-pressure pulse-pressure/MAP explainer was a bare muted
paragraph at the container edge, 16–24 px left of every neighbouring
card's text. It now rides a compact Card in the regular foreground.
- The 'Usual range' read strip was a hand-rolled bg-card/60 shell with
its own padding; rebuilt on Card + TileHeader + CardContent at the
compact density so background, radius, icon spacing and text edge
match the neighbouring tiles.
- The spine tiles carried three different header→body distances (6, 12
and 24 px) via off-scale overrides (gap-1.5, md:gap-3, md:py-5,
gap-3). Assessment, target summary, medication tiles, the mood
stand-out card and the sleep hypnogram now share the sanctioned
compact tier (gap-2 py-3 md:py-4).
- The hero score card and its skeleton kept their translucent identity
but move from a static px-4 py-4 to the p-4 md:p-6 card norm, putting
their text on the same edge as every other card.
- Two dashboard shells (recent workouts, recent unlocks) froze at p-4
on every breakpoint; now p-4 md:p-6.
- Settings: the modules hint moves into the header's description slot
and the injection-site grid joins the section-wide mt-4 rhythm — the
two gaps that deviated from the settings 16 px header→content norm.
The Card has been a gap-based flex column since v1.4.33 (gap-4 md:gap-6;
CardHeader carries no bottom padding), but 29 CardHeader pb-0/1/2/2.5 and
8 CardContent pt-6 overrides from the padding-era shadcn card survived.
Under the gap model they invert their intent: pb-* renders gap PLUS
padding (looser, not tighter), pb-0 is a no-op, and pt-6 on a header-less
CardContent stacks on the Card's own py-4/6 and makes the card top-heavy
(40-48 px top vs 16-24 px bottom). Delete them all; a genuinely tighter
header-to-body distance is expressed on the Card itself (className="gap-2"),
the only knob that controls it. An ESLint guard for these slots is a
follow-up.
grip-strength, waist, waist-to-height, and pain shipped stock hex chart
colours (#22c55e / #0ea5e9 / #8b5cf6 / #f97316) while every sibling page
passes var(--token) — the four charts ignored both themes and any token
retune. Map them to var(--success) / var(--info) / var(--chart-1) /
var(--warning) and narrow the scaffold's color prop to a `var(--…)`
template-literal type so a raw hex no longer typechecks.
The recall/forward-look block read as a second briefing paragraph
above the structured list and pulled the card down — the card now
starts straight at 'signals of the day'. The section titles read in
the regular foreground, and the card's Ask-the-Coach corner entry is
gone; the Coach keeps its own surfaces.
The per-metric reference bands, chart palettes, and status tints across
the insights pages and shared charts carried hardcoded hex that glared
on the light theme. Route them through the semantic and chart CSS
variables; dark rendering stays byte-identical, light gets the muted set.
Appearance ("Darstellung") stacked every module's view and ordering
settings on one long page. Split it into a hub that lists the modules as
navigable rows, each opening its own /settings/layout/<module> subpage
with a back link — the per-module settings move verbatim, no restyling.
The module routes now redirect to those subpages instead of an in-page
anchor, and the in-app cogs link straight to them.
The comprehensive briefing warm/cron path wrapped generation in a fixed
130 s outer abort, but the per-user response-timeout setting accepts
10-600 s and the AI settings tell users to raise it for slow local
models. Any account whose response timeout was >= ~130 s had every
comprehensive warm clipped by the warm bound, not the provider, so the
generation never landed: no cached briefing row was written and the
dashboard fell to the hard empty state.
Size the warm outer abort from the resolved effective timeout
(`aiResponseTimeoutSeconds * 1000`, falling back to the comprehensive
surface budget) plus a fixed headroom for feature extraction, prompt
assembly, and the cache write. A raised setting now extends the warm and
nightly budgets instead of being silently capped. An absolute ceiling
(the 600 s write-time maximum plus the headroom) keeps a wedged call from
pinning the worker while staying above the largest value a user can set.
Both the nightly loop and the forced on-visit warm read the per-user
setting and use the derived budget.
Make the empty state more useful: record the upstream HTTP status on a
failure marker and classify it (timeout / auth / rate-limit / provider /
format), so the "couldn't generate" state points at the right lever —
raise the response timeout for a timeout, re-check the provider for an
auth rejection — instead of one generic line. Keep-last-good behaviour is
unchanged.
Align the response-timeout field copy with the actual default budget
(~120 s, not ~180 s) across the placeholder, the OpenAPI description, and
the inline note.
Move the PHQ-9 / GAD-7 screener off `/insights/mental-wellbeing` to a
top-level `/mental-wellbeing` route so it no longer borrows the Insights
tab strip and layout shell. The page now uses the standard module-page
frame at the wider content width the other module pages use.
Replace the standing screening disclaimer block and the opt-in/private
line in the header with an info icon after the page heading whose
hover/focus tooltip carries the screening-not-a-diagnosis note. Drop the
now-orphaned `disclaimer` / `optInNote` strings across all six locales
and add the `infoTooltip` string in their place.
The questionnaire, scoring, severity result, PHQ-9 item-9 crisis-resource
handling, focus-on-submit, and 44px touch targets are unchanged. Repoint
the module nav entry and the registry comments at the new route.
Grip strength, pain (0–10 NRS), waist circumference, and waist-to-height
existed in the measurement enum, signal registry, and FHIR export but had
no web entry, no detail page, single non-sex-aware bands, and a split-brain
MCP exposure. This closes those gaps.
Web capture: the four signals join the manual measurement form, so a
web-only self-hoster can log them (the iOS client and the measurements API
already could).
Detail pages: flip detailPage on for the four signals and mount each on the
shared HealthKit metric template (chart, stat strip, Coach-read strip,
assessment card) via routed sub-pages and tab-strip pills, the same detail
spine every other metric carries.
Sex-aware bands: grip strength (EWGSOP2 men 27 / women 16 kg) and waist
(WHO men 94 / women 80 cm) now classify by the profile sex through the
age/sex norm resolver the assessment already threads; the resolver also
returns a sex-specific, age-independent band when no date of birth is set.
Fix the waist-to-height boundary so the NICE >= 0.5 rule is correct (the
last in-range value is 0.49).
MCP single source of truth: decouple the registry mcp facet from the Coach
snapshot — a signal can be MCP-readable without being on the Coach surface.
Mark the four physical signals mcp:true, derive the MCP rich-reads clinical
set from the registry flag instead of a duplicate allowlist, and relax the
invariant to coachSnapshot implies mcp (not the reverse). PHQ-9 / GAD-7 and
the environmental signals stay mcp:false and unreachable. compare_metric and
detect_changepoints now advertise clinical-signal support to match
get_metric_baseline.
The mental-wellbeing screener surface shipped built but unreachable: no nav
entry, no module key, ungated. Add a `mentalHealth` module key (opt-in,
default OFF — a depression/anxiety self-assessment is at least as sensitive
as mood) to the registry, the module-hub icon map, and the exhaustive module
maps. Give the check-in a nav home at /insights/mental-wellbeing, born-gated
on the module so it appears only once the account opts in, and redirect the
page home on a direct hit when the module is off.
Item answers stay encrypted and off the AI Coach / MCP regardless of the
toggle; this only controls whether the surface is visible and reachable.
Add the opt-in PHQ-9 / GAD-7 questionnaire surface at /insights/mental-wellbeing:
instrument picker, 0–3 item scale, server-scored result with the validated
severity band, the verbatim Pfizer/PHQ attribution, the screening-not-diagnosis
disclaimer, and a calm crisis-resource card on a positive item-9. The surface
deliberately does NOT mount the shared Coach-launch card — mental-health item
content stays out of the AI.
- i18n: full mentalHealth block plus the six new measurement-type labels and the
longevity-marker catalogue names/descriptions across all six locales
(de/en/es/fr/it/pl). Instrument item wording uses the validated German text and
the canonical English elsewhere (re-wording invalidates the score).
- OpenAPI: regenerate the contract for /api/mental-health/assessments (response
excludes raw item answers by construction).
- Exclude the new manual / instrument types from the HealthKit-counterpart guard.
The short-horizon trajectory tile read as a wall of text with no clear value
on the metric pages. Drop it from the weight page and the shared HealthKit
metric page (the opt-in forecast slot is removed entirely, not left dead), and
delete the now-orphaned tile + fan components. The trajectory engine stays for
the Coach narrative; only the metric-page surfacing is removed.
The sleep page rendered an assessment under the duration/architecture charts
and a second one under the sleep-quality scores. The quality assessment mostly
never generated — most accounts carry no wearable sleep score — so it sat at the
foot of the page as a duplicate empty card or a redundant connect-a-provider
prompt.
Drop the quality-block assessment so the duration card is the page's single
assessment; the quality block keeps its score tiles. Remove the now-unused
grounded-note fallback and its deterministic grading helper, and the orphaned
quality-assessment message keys across every locale.
The per-metric assessment card carried the Coach hand-off as a bottom-right
text button, present on only some pages. Pin it to the card header's top-right
as a single icon button (no text label) with an accessible name and a tooltip,
and wire every assessment surface to it: the bespoke slug pages
(weight, BMI, pulse, blood pressure, mood), the generic metric scaffold, the
medication-compliance card, and the biomarker detail page already on the shared
card. Each opens the Coach with a localised, auto-sent opener narrowed to that
metric's snapshot source.
The button self-gates on the Coach triple (provider mounted, operator flag,
per-user opt-out) and the card only mounts it on the populated branch, so it
never advertises a control that cannot work. The auto-sent opener now
interpolates the localised measurement name rather than a raw token.
Thread an autoSend flag through the coach-launch flow so a card's
"ask the coach about this" hand-off dispatches its seeded prompt as
the conversation's first turn instead of only seeding the composer.
The flag rides the launch context, the layout mount, the drawer, and
the shared conversation surface, where a ref-guarded mount effect fires
the send exactly once for a fresh thread.
Localize the previously hardcoded English assessment opener in the
metric status card behind insights.coach.assessmentPrompt with a
{metric} placeholder, and localize the resting-pulse coach prefill.
Both keys land in all six locales.
Surface the generation-failure signal in the daily briefing card. When a
held briefing is shown but the last refresh failed, the footer states it
plainly and offers a retry that runs the existing explicit regenerate —
no warm-on-mount. When there is no briefing to fall back on, the empty
state reads "couldn't generate" with the same retry instead of the
generic "no briefing yet". The hint is suppressed when no AI provider is
configured, where that hint already owns the surface and a retry is
futile. New copy lands in all six locales.
Remove the in-app water-intake logging feature: the WATER_INTAKE
measurement type, the per-user daily-goal field, the goal-ring card and
its endpoint, and the related query keys, OpenAPI route, and locale
strings. The feature is dropped from this release; the baseline-drift,
breathing-screening, and labs-changes insight cards are unaffected, and
the pre-existing TOTAL_BODY_WATER body-composition type stays intact.
Add three read-only awareness cards to the Insights overview, each drawn
off an existing engine and each honest about empty state — the section
un-mounts rather than surfacing a husk.
- Health status: surfaces what is drifting from the user's own normal by
folding the personal-band deviations (coincident-deviation engine) with
the dated, sustained level shifts (changepoint detector) at
GET /api/insights/health-status. Awareness only, never a diagnosis.
- Breathing screening: summarises the last 30 nights of the per-night
sleep-breathing-disturbance index plus device-flagged events at
GET /api/insights/breathing-screening. Mirrors the device's own
classification and states plainly that it is a screening signal, not a
diagnosis — discuss with a clinician.
- Lab changes: pairs the two most-recent numeric lab panels and reports
each shared analyte's signed delta and reference-band standing at
GET /api/insights/labs-changes. Neutral framing only.
Each reshaping helper is pure and unit-tested for its present/absent
states. Register the three sections in the insights-layout universe so
they merge in as default-visible, wire them into the overview and the
edit-mode title map, add the query-factory keys, document the routes in
the OpenAPI contract, and translate every new string across all six
locales.
The hero score card and the daily briefing accept tension, returnToBand, and
memory props, but nothing populated them server-side, so the lines never rendered.
Resolve all three server-authoritatively on the dashboard snapshot DTO (iOS rides
the same payload):
- tension — fires when the readiness composite's contributors disagree (a strongly
favourable one alongside a strongly unfavourable one), reusing the readiness
components the wellness ring grades and the shared disagreement detector.
Suppressed under a clinical red-flag (a coincident-deviation that no illness
explains) so the red-flag path is never reconciled into a calm verdict.
- returnToBand — runs the streak detector across the salient vitals and surfaces
at most one metric that has come back inside its own personal range after a
genuine prior out-of-band run.
- memory — maps the machine-derived coach memory block to the briefing's recall +
forward-look.
The blocks are locale-agnostic (contributor keys + a metric type); the insights
hero localises them through the existing readiness-contributor and metric-name
labels before handing the cards their resolved strings. Each value omits rather
than guesses. The snapshot cache key now carries the locale for the recall prose.
Render a compact two-line "Coach read" above each metric sub-page chart:
an own-baseline line ("Your usual range is X–Y; today's Z sits within /
above / below") and one top lagged-association line from discovered
correlations, framed descriptively. It makes a metric read seen-in-context
rather than isolated.
The strip is server-authoritative: a new GET /api/insights/coach-read
resolves the band via the median ± k·MAD baseline engine (with its 7-day
history floor — below it the strip says "still learning your range" rather
than inventing a range) and the strongest on-metric driver from the
existing FDR-controlled, effect-size-floored correlation discovery (no
lowered floor; the line is omitted when nothing clears it). The client
component only renders the resolved DTO, so web and iOS read identical
numbers. DTO shapes and the pure selection logic live in a client-safe
sibling shared by the component and the unit test.
Wired through the HealthKit metric scaffold (every generic metric page) and
the bespoke weight, blood-pressure, and pulse pages.
Compute the mean asleep minutes per night from the same canonical
per-night data the sleep-debt and chronotype cards read, exposed as a
new averagePerNight field on the sleep-rhythm DTO. Surface it as a third
peer card with identical chrome, so the three tiles sit side by side.
Calm partial state under a four-night floor; localised in six locales.
Invalidate the dashboard snapshot on measurement writes and refetch on focus so
fresh and background-synced readings surface without a hard reload; expose the
no-provider state on the briefing read with a connect-a-provider hint; align the
briefing footer caption.
An account with no AI provider configured still reads the last cached
briefing — the read path serves it without ever touching a provider — so
the days-old report rendered as current. The pregenerate cron runs fine;
the cache simply can never refill without a provider. Report provider
availability from the read GET and pair the briefing's relative age with a
discreet 'this can't refresh — connect a provider' hint linking to
Settings -> AI, on both the overview hero meta row and the briefing card
footer. The hint only shows when a briefing is actually rendered; the
empty-state already owns its own no-provider branch.
Also align the briefing card footer caption to the 11px scale the
adjacent period-in-review card uses, so the two overview footers read at
one size.
Wrap the sleep-debt headline and the chronotype section in a shared
grid on the Sleep view so they stack on narrow viewports and sit
side-by-side from the sm breakpoint up. Both already read the same
sleep-rhythm cache, so the pairing adds no extra fetch; each card keeps
its own internals.
One-shot prettier pass so the new format:check gate starts from a clean
baseline. Formatting only — no behaviour change. Generator-owned and
vendored artifacts stay untouched via .prettierignore.
Remove the single-night "Last night" hypnogram from the sleep overview: its
per-stage breakdown (time + %) duplicated the phase-distribution stacked bar
directly below it. The /api/sleep/night route stays (the dashboard reads it);
only the redundant surface is gone.
Promote the chronotype to the prominent bottom block of the view. Split it out
of the rhythm section (now the sleep-debt headline alone) into its own section:
a large band label with the natural sleep-midpoint clock beneath it and an
expandable disclosure for social jetlag + MSFsc. It reads the same
["sleep-rhythm"] cache, so it adds no round-trip, and keeps the calm
"still learning your rhythm — N of M nights" state.
Fix the sleep-quality assessment slot. It disabled the status fetch whenever the
AI narrative was absent, so the card could not read hasProvider and fell back to
its no-provider default — surfacing the "connect an AI provider" CTA even for
accounts that have a provider connected, stacked under the grounded note. The
card now stays enabled and reports the truthful state: no provider shows its
clean AI-settings CTA alone; a connected provider with no text yet shows the
grounded note; warm or preparing AI text wins. Exactly one block fills the slot.
Render the new signalsOfDay list at the top of the daily-briefing card — a
present-focused row (headline + concrete nudge) sharing the tone bar, metric
icon, and sub-page routing of the key-finding row, with matching typography
(text-sm headline, text-xs muted body) so the card reads consistently with the
rest of the insight surfaces. Add the signalsTitle string across all six
locales.
Remove the "Ask the coach" action button and the guided-questions chip strip
from the overview hero band: the Coach stays the bottom-right drawer mounted by
the insights layout shell. The hero is now greeting + subtitle + baseline meta
+ the optional Health Score panel, and no longer reads the coach flags or the
launch context. Update the coach-cascade / disable-cascade fixtures and the
hero-strip slot tests to pin the affordances absent.
Introduce src/lib/reference-ranges.ts: a structured, citation-backed
per-metric reference-range backbone (band + source body + year) with a
deterministic four-state placement helper, read by the UI surfaces. Each
band traces to the medical-citations registry, which gains the supporting
sources (ACC/AHA 2017 and ESC 2024 blood pressure, StatPearls pulse
pressure and mean arterial pressure, ESC/ESH 2018 pulse-wave velocity,
FDA 2024 and StatPearls pulse oximetry, J Gen Intern Med 2019 temperature,
RCP NEWS2 and American Lung Association respiratory rate, visceral-fat
imaging literature, AHA 2024 resting heart rate, ESC/NASPE 1996 HRV,
Wilcox 2000, ACOG 651, Phillips 2017 and CDC 2024 sleep).
Blood pressure keeps ESH 2023 as the labelled default; ACC/AHA 2017 and
ESC 2024 appear only as displayed placement context. Enrich the metric
explainers with concise cited reference bands and a general-guidance
tooltip on every metric page: a darker-skin pulse-oximeter over-read
caveat, device-proxy honesty for readiness/recovery/strain/stress, an
HRV day-to-day-noise note, and a resting-heart-rate clarification. Fill
the empty registry normal ranges, reconcile the steps green floor to
8,000, and tighten the body-temperature band below the fever line. Add
free pulse-pressure and mean-arterial-pressure context on the blood-
pressure detail page, plus cycle-physiology and sleep-hygiene copy across
all six locales. Cycle thresholds carry a verification marker pending an
ACOG source check.
Add a calm, value-free Rest Mode indicator to the dashboard score card and
the recovery insight so the web matches what iOS already shows: when an
illness/condition episode is active the score and recovery are framed, never
penalised, and the surface now says so. The new banner is neutral and
untinted, self-gates on an active episode, and reveals only the onset date —
never a decrypted note or episode label. The dashboard reads the annotation
from the snapshot (resolved server-side alongside the score); the recovery
section reads it from the analytics payload.
Give the sleep page one assessment per distinct chart group: the
duration/architecture assessment now sits with its charts, and the
sleep-quality block carries its own SLEEP_SCORE assessment, matching the
"multiple charts, multiple texts" structure the recovery page meets.
Add the steps explainer (insights.subPage.explainer.steps*) and pass
explainerMetric="steps", and demote the recovery per-block title to an h3 so
the heading outline nests cleanly under the page heading and group heading.
The recovery page rendered a grid of compact device-score tiles plus a
redundant cross-link to the recovery-score anatomy at the top. It now
follows the same structure as every other metric page (blood pressure,
pulse, the HealthKit scaffold): each present device-native signal renders
a block carrying a short explainer, the max / median / mean stat strip,
the shared chart with the identical 7 / 30 / 90 / All toggle, and a
per-chart assessment. Multiple charts now carry multiple matching texts.
Register the seven device-native recovery / strain signals (ANS charge,
day / workout strain, cardio load, whole-day average / max heart rate,
energy expenditure) in the generic metric-status registry so each chart
gets a generated assessment, gate them on the recovery module, and give
each its own chart-overlay slot. Drop the duplicate top score-link block
(this surface is reached from the overview already).
Regenerate the OpenAPI contract for the widened metric-status enum.
The sleep, blood-glucose, workouts, and recovery insights pages
fetched their series and half-rendered when the backing module was
disabled — a disabled account that navigated straight to the URL saw
the surface flash before any guard. They now resolve a shared
useModulePageGuard hook (mirroring the cycle page): an unauthenticated
visitor bounces to login, a disabled-module account bounces to
/insights, and the page renders a calm loader until the gate clears.
The backing /api routes enforce the gate server-side, so this is the
UX layer, not the security boundary.
The insights cycle ring tile and the /cycle page redirect gated on
user.cycleTrackingEnabled, which /api/auth/me resolves without the
operator availability layer — so with the Cycle module disabled
server-wide the ring still rendered (and fired its calendar read) and a
direct /cycle URL still loaded.
Read user.modules.cycle instead, which the /me map already AND-s with the
operator switch, so an operator-off instance suppresses both surfaces.
The Coach lived under /insights/coach, rendering inside the Insights
layout's tab strip. It is the product's headline surface, not a metric
sub-page, so it now lives at the top-level /coach in the standard page
chrome without the Insights tab strip. The nav model points at /coach,
every in-app link and the proactive-nudge deep link follow, and a 301
redirect keeps /insights/coach (bookmarks, cached PWA navigation, older
push payloads) resolving.
Oura's overnight body-temperature deviation — a signed delta from the personal
baseline that tracks illness and cycle onset — was stored but never shown. Add
it as a data-gated tile beneath the chart on the metabolic skin-temperature
page, its most coherent home. It mounts only when the Oura series has readings,
so a non-Oura account renders the page unchanged.
WHOOP and Polar record day strain, workout strain, ANS charge, cardio load, and
the day's whole-cycle average / max heart rate and kilojoule energy expenditure
— all stored end-to-end with no render host. The composite recovery score had
only its anatomy detail at /insights/scores/recovery; the raw signals around it
appeared nowhere.
Add /insights/recovery as the home for them, grouped into recharge, strain &
load, and the day's heart & energy. Each block is data-gated, so an account
without a strap or ring lands on a calm empty note rather than a broken surface.
When a recovery score is present the page cross-links to its anatomy view rather
than duplicating the ring. Server-authoritative throughout.
Give the page a single nav home alongside Workouts in the unified destination
model so it is discoverable on both the sidebar and the mobile More hub.
WHOOP and Oura write sleep efficiency, performance, consistency, need, the
disturbance count, and the Oura sleep score every night; until now none of them
rendered anywhere. Add a Sleep quality block after the rhythm section that
surfaces all six through the device-score tile.
Each tile is data-gated, so a non-wearable account never sees an empty card and
the whole block collapses metric-by-metric down to nothing when no source
writes them. It reads the same analytics summaries slice the page already
fetched for its duration gate, so it costs no extra round-trip.
Surface a clinical panel beneath the blood-glucose chart: time-in-range on the
consensus bands, the glucose management indicator and estimated A1C, and the
coefficient of variation with its instability flag by default, and the J-index
and the low and high blood-glucose risk indices with a per-context breakdown
behind an advanced disclosure. Below the calibration threshold it shows a calm
learning state, and it always carries the spot-reading caveat. The analytics
read, the dashboard snapshot, the coach and the doctor report all consume one
computed result over a fixed window, so the panel, the assistant and the
printed report cannot disagree.