v1.4.22 B4 moved the disclaimer ("Coach replies are generated.
Clinical decisions belong with your doctor.") into the sources-rail
footer. The rail only paints at `xl+` viewports; on `<xl` (every
laptop and every mobile) the disclaimer was reachable only by
opening the chevron tray. That dropped the medical-disclaimer reach
from "every Coach session" to "the subset of users who tap a
chevron" — material regression for clinical-adjacent UI.
Pin a small persistent line at the bottom of the message thread so
the disclaimer paints regardless of viewport. The rail footer stays
for desktop users; the redundancy is intentional.
Closes Design-H3, Design-M4.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The BD-Zielbereich tile is the only tile that ships an `avgAllTime`
sub-value (W2 A1+A2). Combined with the comparison-delta callout
above it, the tile rendered four stacked rows below the headline on
mobile: the comparison delta, then 7d/30d/all-time on a single
flex row that wrapped to three lines at 280 px (Galaxy Fold). The
BP tile read 2× as dense as every other trend card.
At `<sm`: hide the all-time span on the 7d/30d row, hide the
standalone comparison-delta callout, and render a single combined
secondary row beneath: "All-time 11% · Δ −0.4 last month". Full
layout returns at `>=sm` for users with the screen real-estate.
Closes Design-H2.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Multiple accessibility + visual defects on `<InsightsSectionNav>`:
- No `aria-current` on the active tab — screen readers had no signal
the active section had changed.
- No `:focus-visible` ring — `transition-colors` only animated the
background, leaving keyboard focus invisible against `bg-primary/10`.
- Unconditional `scroll-smooth` ignored `prefers-reduced-motion`.
- `bg-background/80` let the hero gradient bleed through.
- `-mx-4` carried the bar past the safe horizontal-scroll boundary
on 280 px Galaxy Fold; combined with the inner `overflow-x-auto`
it produced a ghost scrollbar.
- 112 px `scroll-mt-28` was ~2.5× the actual sticky-strip height
(~40-44 px), pushing target headings well below the viewport top.
- IntersectionObserver picked the last entry in the batch instead of
the highest intersectionRatio, so the active pill jumped during
fast scrolls.
Add `aria-current="location"`, focus-visible ring, motion-reduce
gate around `scrollIntoView`, swap `bg-background/80` for `/95
backdrop-blur`, drop the negative margin (the parent handles
horizontal padding), hide the inner scrollbar, ratio-sort the
observer entries, and tune `scroll-mt-28` → `scroll-mt-16` on all
seven sections.
Add `insights.navAriaLabel` (EN: "Skip to section", DE: "Zu
Abschnitt springen").
Closes Design-H1, Design-H3, Code-MED-4, Design-LOW-4.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Coach chat route had three identical `new ReadableStream({
start(controller) { try { ... } finally { controller.close(); } } })`
blocks (refusal / provider-error / happy path), differing only in
which frames they enqueued. Extract the boilerplate into
`src/lib/sse/create-stream.ts` so the v1.5 iOS streaming endpoints
inherit the same try/finally close semantics without copy-paste.
The helper accepts sync or async emit callbacks; the controller is
always closed in `finally` so a thrown emit never hangs the response.
Three unit tests pin the sync, async, and throw paths.
Closes Sr-H2.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The `setOnboardingPendingCookie` helper was called from six different
auth routes (login, passkey-verify, register, password, /me,
/onboarding/complete) by convention. A future auth surface (SSO,
magic link, social login) added without remembering the helper would
silently re-introduce the dashboard flash that v1.4.22 C4 was
designed to fix.
Make `onboardingPending` a required parameter on `createSession`
(not optional) so issuing a session without anchoring the cookie is
type-impossible. The five primary auth surfaces now thread the
user's `onboardingCompletedAt == null` value through; the
`/api/auth/me` mirror remains as a fall-back for legacy sessions
that predate the cookie. The `/api/onboarding/complete` clear stays
where it is — it is a value flip after a DB write, not a session
issuance.
Three new integration tests pin: createSession sets the cookie when
pending, clears it when completed, and destroySession clears it
alongside the session cookie.
Closes Sr-H1.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The tile computed `bp30 - bpAll` regardless of which baseline the
user picked under Settings → Dashboard, while rendering a caption
keyed on `comparisonBaseline` ("vs. last month" / "vs. last year").
The math therefore lied to the user. Every other trend tile uses
`tileCompareDelta()` against period-aligned `avg30LastMonth` /
`avg30LastYear` baselines.
Add `priorMonth` (now-60d…now-30d) and `priorYear` (now-395d…
now-365d) windows to `computeBpInTargetWindows()`. Ship the pcts on
the analytics envelope as `bpInTargetPctPriorMonth` /
`bpInTargetPctPriorYear`. Route the dashboard's `bpCompareDelta` to
subtract the prior window matching the user's baseline; fall back
to null for `none`.
Three new bp-in-target unit tests (priorMonth, priorYear, null
windows) and two insights-polish guards (envelope contains the
fields, page no longer subtracts `bpAll`).
Closes Code-H2.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When the model emits only a `---KEYVALUES---` block with no leading
prose (or a malformed block missing the closing marker), the
post-strip prose is empty. The previous fallback chained back to
`rawReply`, surfacing raw sentinel markers to the user. Replace with
a structured `coach.provider.empty` error frame so the UI renders
the localised provider-error copy instead of leaking the markers.
Add a regression integration test pinning that the streamed body
never contains `---KEYVALUES---` for a sentinel-only reply, and that
no assistant message is persisted in this branch.
Closes Code-H1.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Maintainer-voice sweep across `src/`. ~190 references to the
maintainer by first name in code comments and JSDoc neutralised to
"the maintainer", "the live tenant", "users", or "the user" depending
on context. Three sed passes covering ~50 distinct patterns
(`Marc reported`, `Marc wants`, `Marc kept`, `Marc rolled back`, etc.).
Test fixtures (`displayName: "Marc B."`, `username: "marc"`,
`<HeroStrip userName="Marc">`) kept as opaque test data per the brief.
CHANGELOG normalisation. The v1.4.14 + v1.4.15 entries paired a
German bullet with its English translation by historical design — the
in-app copy is English-default and the user docs are English, so the
mixed-language style was a holdover from the early-marathon notes.
A one-off node script extracted the `_..._` English paragraph from
each bilingual bullet and rewrote the entry as that text. ~44 bullets
reduced; one Docs bullet that lost a leading word in the regex
fixed by hand.
CLAUDE.md → CONTRIBUTING-AI.md rename so the filename is no longer
AI-vendor-specific. `CONTRIBUTING.md` reference updated. `AGENTS.md`
stays in place for multi-agent compatibility (the conventions doc is
shared with Codex / Cursor / etc.).
Wave-4 state wrap. STATE.md now records C1–C5 + D + D2 as complete;
v1421-backlog.md grew a "Closed in v1.4.22" header listing the 20
items the marathon landed; `.planning/phase-W4-report.md` documents
the per-commit decisions, test deltas, and the 23 items deferred to
v1.4.23 with reasoning.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Cluster of the v1.4.21 carry-over items that each landed in ≤5 LOC.
- F-32 — drop the duplicate help paragraph beneath the dashboard
settings header; the page-level description already covers it
- F-33 / F-34 — replace `&` with "and"/"und" in the measurements and
mood subtitles
- F-35 — unify the test-notification button label
(notificationTestSend) with the user-facing notificationStatus.sendTest
- F-44 — drop the `LOKI_ENDPOINT` env-var name from the user-facing
monitoring help text; reads as "Loki integration" instead
- F-45 — admin/users table column reads "Name" instead of repeating
the page title "Users"
- F-47 — /notifications event-matrix first column reads "Event" instead
of repeating the page title "Notifications" — new notifications.eventColumn key
- F-50 — soften every DE error-toast "Bitte erneut versuchen" to
"Bitte versuche es erneut", matching the friendlier tone we already
use in the Coach error path
- F-58 — admin/reminders section header swaps Clock for Bell so the
card icon matches the admin-shell nav tile
- F-62 — DE dashboard `greetingSalutation` reads "Hallo, {name}"
instead of the English "Hi, {name}"
- D-CR-M-03 — formatTokenName regex broadens to non-Z ISO offsets so a
`+02:00` timestamp suffix renders as the locale-aware chunk like a
`Z` suffix already does
- D-DSGN-M-04 — moodLog "Copy" button uses common.copy instead of
stripping the `!` off common.copied
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The post-hydration redirect for users with `onboardingCompletedAt ===
null` caused a brief dashboard flash before the client-side effect
could fire. The redirect now lives in `src/proxy.ts` so it lands on the
first server response.
The proxy runs in the Edge runtime and can't reach Prisma, so the auth
routes (login / passkey-verify / register / password / /api/auth/me)
mirror the DB `onboardingCompletedAt` column into a non-httpOnly
`hl_onboarding` cookie. The cookie is a UX hint, not a security
signal — a user editing it locally only skips the dashboard flash;
they still can't bypass any server check. `/api/onboarding/complete`
clears the cookie so the next navigation drops the redirect
immediately, and `destroySession` + expired-session purge wipe it
alongside the session cookie.
The e2e onboarding-flicker spec can return to testing the actual
incomplete-onboarding case: the proxy reads the cookie (absent for
the fully-onboarded storage-state user), not the mocked `/api/auth/me`
response, so the test now sets `onboardingCompletedAt: null` honestly
instead of pinning it to a non-null value as a workaround.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The /admin/api-tokens page has been painting a residual horizontal
scrollbar for four releases despite column-hide, mobile card-list, and
the admin section-strip `no-scrollbar` fixes. A fresh production probe
confirmed the actual culprit: the "Last used" and "Created" `<td>`s
carry `whitespace-nowrap`, and `formatDateTime` renders strings like
"05.05.2026, 21:46" (~110px) that exceed the 12% colgroup allotment
(~84px on a 700px content area). `whitespace-nowrap` wins over
`table-fixed`'s width contract, so the table's intrinsic width exceeds
100% and the wrapping `overflow-x-auto` paints the scrollbar.
Dropping the two `whitespace-nowrap` classes lets the date+time wrap
to two lines on narrow viewports. The card grows by one row but no
scrollbar — and at >1024px desktop the unwrapped string already fits
in the 12% allotment so the visual is unchanged for the common case.
Picked option 1 of the three W1a probe found (drop nowrap, switch to
formatDateShort, or hide "Created" at md): smallest two-line diff, no
i18n surface impact, no information loss.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Zielwerte page read as a static reference cheat-sheet — every card
surfaced a current value + range bar but no recent trajectory. Each
target now grows a dependency-free 30-day sparkline beneath the range
bar plus a localised "Δ −2.3 kg vs. last month" caption so the page
reads as a living health log rather than a guideline lookup.
The API computes both fields from a single 60-day query (one round-trip,
not two): `points30d` is the daily-mean series for the last 30 days,
`deltaVsLastMonth` is the signed difference between the current and
prior 30-day averages. Both are null when either window has fewer than
3 readings so cold-start accounts don't see a misleading flat trace
or a "± 0" comparison that's really "no comparison".
BMI piggybacks on the weight series (divided by height²) so the BMI
card's sparkline matches its range bar's y-axis. Derived metrics
(BP-in-target rate, mood stability, medication compliance,
blood-glucose-by-context) keep their previous chrome — the rich
status surface they already render is the right answer there.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three contained UX fixes that line up with the new prose-first
conversation feel:
- User bubble avatar pulls the user's Gravatar at the same 32-px
dimensions as the Coach avatar; falls back to initials when no
Gravatar is configured.
- Disclaimer ("Coach replies are generated. Clinical decisions belong
with your doctor.") moves out of the composer and into the sources
rail footer, replacing the now-redundant "Coach reads only your
connected data" caption.
- Settings cog removed from the drawer header. The placeholder
tooltip read as a dead button and a real settings surface lands in
a later patch — better to drop the icon cleanly than wire it to
nothing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Render the v1.4.22 keyValues array as a click-to-expand "Worauf
bezieht sich das?" / "What I'm looking at" disclosure under each
assistant message. Closed by default; rows show label, value, unit,
and window with Dracula muted-foreground accents and a chevron that
rotates on open. Source chips stay above the disclosure so users see
both abstraction layers, and the whole block hides when keyValues is
empty.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add a text-marker sentinel parser (`---KEYVALUES---` … `---END---`)
that strips the Coach's evidence block out of the prose before it
streams to the client, validates each line against a Zod schema, and
attaches the parsed entries to `provenance.keyValues`. The persisted
envelope round-trips so the disclosure renders on conversation
reload. Hard-capped at 8 lines and 1 KB to guard against
prompt-injection padding; malformed blocks log a wide-event and pass
the prose through unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace the v1.4.20 "conversational summariser" persona with a partner
who sits alongside the user — warm, curious, conservative, with seven
numbered ground rules that prefer invitation over refusal when data is
missing and reserve specific numbers for a structured evidence block
at the end of each reply. The day-level timeline section from v1.4.21
stays verbatim so weekday questions still resolve cleanly. Bumps the
shared PROMPT_VERSION from 4.20.2 to 4.22.0 and broadens existing
schema tests off the 4.20.x pin onto the 4.x train.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two related leak fixes traced to the same root: every other AI-prose
surface ran user-visible text through `stripChartTokens()`, but the
recommendation card's always-visible text was rendered directly. The
model embeds `metric:WEIGHT` and friends in the rec text expecting an
inline mini-chart, but the chart slot lives in the EXPANDED rationale
block — so the token had nowhere to be consumed and leaked verbatim
to the DOM.
- `<RecommendationCard>` now wraps `norm.text` in
`stripChartTokens()`, matching the contract every other
rec / finding / summary surface already uses.
- DE Health-Score component labels surfaced English nouns ("Mood",
"Medis", "BD") instead of full German. Rename to "Blutdruck",
"Stimmung", "Einnahmetreue", "Gewicht" so the four sub-bars read
natively German. The "Mut" the maintainer reported on prod was
voice-to-text rendering "Mood".
- i18n-integrity test pins the four component labels so a copy-paste
regression can't reintroduce the leak.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two related polishing changes for the Insights surface:
a) Correlation row title rename. "Muster" / "Patterns" reads as a
loose grouping; the row actually shows pre-defined statistical
relationships between two metrics. Rename to "Zusammenhänge" /
"Relationships" so the title carries the same conceptual weight as
the surrounding "Trends" / "Daily Briefing" headers without
borrowing the "Trends" word from the row above it. Picked
"Zusammenhänge" over "Trends" because the Trends row already uses
that word and a correlation is conceptually a relationship between
two series rather than a single-series trend.
b) Section tabs lift above the hero strip. `<InsightsSectionNav>` was
mounted below the advisor card so the user had to scroll past the
hero before discovering the metric-tab nav. Lift it to the top of
the page so the tabs are the first thing visible — the hero +
daily briefing render directly below, and clicking a tab still
scrolls to the matching section. Sticky scroll-anchored behaviour
preserved (incremental over a full SPA-tab rewrite) so today's
reactive surface keeps working.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three layout problems landed together because they share the same
root: the `/insights` grids assumed every row would be full, so when a
section dropped a card the layout left a half-row of empty space.
- Row-fill rule: every grid on `/insights` now picks `xl:grid-cols-2`
only when 2+ cards actually render. With 1 card the grid collapses
to a single full-width column. The BP-medication grid, the weight-
correlation grid, and the medications-per-day grid all carried the
half-empty-row bug; all three are normalised.
- Trends row equal heights: each `<TrendsRow>` card is now a flex
column with `min-h-[300px]` so a multi-line AI annotation under one
chart doesn't push that card taller than its neighbours. The
visual rhythm holds even when the prose varies in length.
- Hide insufficient-data correlation tiles: `<CorrelationRow>` drops
any hypothesis with `status !== "ok"` instead of painting a
placeholder. When zero hypotheses qualify, the entire row hides
itself (no header, no disclaimer). 1 ok card spans 100 % width, 2
or 3 ok cards split 50/50 on `>=md`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Up to v1.4.21 the comparison-baseline toggle lived in two places: the
canonical Settings → Dashboard picker (where it's persisted) and an
on-surface duplicate above the Daily Briefing on `/insights`. Per the
"no split" rule the duplicate is removed: the toggle is a global
preference, so it belongs in Settings only.
Every chart on `/insights` still consumes the resolved
`comparisonBaseline` from the same dashboard-layout query the picker
writes to, so flipping the Settings toggle keeps propagating to every
overlay on next refetch — only the duplicate UI affordance is gone.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Up to v1.4.21 the BD-Zielbereich tile was the only one in the strip
that didn't ship the standard tile feature set. Other tiles paint a
trend arrow next to the headline, a 7-day-trend chip on the `7d:`
sub-row, and a comparison-overlay caption when the dashboard's global
compare toggle is active. The BP-in-target tile shipped none of these
because we only had a single % series instead of a `DataSummary`.
Synthesise the slope from the difference between the 7-day and 30-day
in-target shares: a recent week above the recent month means the
metric is improving (up-good ⇒ green arrow); below means slipping.
The 7-day-trend chip uses the same delta. The comparison overlay uses
`bpInTargetPctAllTime` as the long-arc baseline so the caption reads
correctly when comparison is on.
Extend `<TrendCard>` with an optional `avgAllTime` sub-value so the
all-time aggregate stays visible alongside `7d:` and `30d:` after the
v1.4.22 A1 headline re-anchor. Other tiles leave the field undefined
so the third sub-row never renders for them.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Up to v1.4.19 the BD-Zielbereich tile pinned 50/50/50 because the
route routed the headline through `windows.last30Days?.pct` — making
the headline a literal copy of the `30T` sub-value. v1.4.19 A1
re-anchored the headline to all-time so the three windows could
diverge. That fixed the algorithmic pin but introduced an emotional
problem: the headline became the slowest-moving aggregate possible,
punishing users who had recently dragged their compliance up. A user
seeing 7d 50 % / 30d 50 % alongside a stubborn 11 % all-time read it
as "your work doesn't count yet."
Re-anchor the headline to `windows.last30Days?.pct` and surface
all-time as a third sub-value (`bpInTargetPctAllTime`) so power users
still see the long-arc number without it dominating. The integration
test pins the new contract: with three legitimately divergent windows
the route's `bpInTargetPct` equals `last30Days.pct`, and the new
all-time field carries the historical figure.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
New `.planning/phase-v1421-hotfix-report.md` summarises the six
fixes (Daily Briefing regenerate · Coach day-level snapshot · Coach
prompt update · streaming-bubble dedup · drawer header · sources rail
scope picker), the test-count delta (2026 → 2036 unit), the
token-budget impact of the day-level snapshot (~190 → ~3000 tokens
per full-scope turn), and a recommendation to ship as a v1.4.20.1
hotfix.
STATE.md grows a Phase F section + status block tracking the six
commits on develop. Includes prettier polish across the touched
coach surfaces (sources-rail, drawer, message-thread, snapshot,
chat route).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previously the Sources rail listed BP / Weight / Pulse / Mood /
Compliance as a static legend. Marc asked for a real "what I can
see" surface — per-source checkboxes (include/exclude) plus a
window selector (last 7d / 30d / 90d / all-time) that flow through
the Coach request body.
Wire the controls all the way down:
- <SourcesRail> grows scope + onScopeChange props. Each row
becomes a 36px tall flex row with a real checkbox; the row
fades to 60% opacity when its source is excluded.
- A Select trigger above the list drives the window. Both control
surfaces share the existing insights.coach.window.* i18n keys;
add a new windowLabel key so the dropdown has an EN+DE label.
- <CoachDrawer> owns the scope state (resets on each mount per
the v1.4.20.1 plan — no conversation-level persistence yet) and
forwards the picked scope through useSendCoachMessage to the
chat request body. The payload only ships when the user has
narrowed away from the all-source last30days default so the
server can tell "no opinion" from "intentionally narrow".
- buildCoachSnapshot already accepts the optional CoachScope from
the previous commit; it now flows end-to-end.
No new dependencies — checkboxes are plain inputs styled with
Tailwind + Dracula tokens.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Radix Sheet paints its default close-X at absolute top-4 right-4. The
v1.4.20 drawer header parked the settings cog inside the right-edge
button cluster (next to the New chat button), so the close-X overlay
visually swallowed the cog — the icon still rendered but read as
unclickable.
Move the cog to the left side, immediately after the gradient
avatar, so the right edge is reserved for Radix's close-X alone. Add
pr-12 / sm:pr-14 padding to the header so the New chat button can
never slide under the close-X on narrower viewports either.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
After an SSE `done` frame the streaming hook keeps `streaming.content`
populated to support the in-flight render path, then fires a TanStack
invalidate that pulls the persisted assistant message into
`conversation.messages`. Until the next `send` reset the message
thread therefore rendered the assistant reply twice — once from the
streaming bubble and once from the persisted message — until the
drawer was closed and reopened (which is when Marc noticed the bug
went away).
Suppress the streaming bubble at render time as soon as the persisted
twin lands. The match keys on `streaming.messageId`, which the SSE
`done` frame fills in once the row is on disk; mid-stream the field
is null so the in-flight bubble stays visible.
Add two regression tests: one that asserts dedup-on-id works and one
that pins the still-streaming render path so the fix doesn't trade a
duplicate-bubble bug for a no-bubble bug.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous Coach prompt only said "ground every number in the
SNAPSHOT" without telling the model that the snapshot now carries a
timeline.recent block of day-level rows with weekday labels.
Without that pointer the model kept defaulting to aggregate-only
phrasing on day-specific questions.
Add a DAY-LEVEL READINGS section to both EN and DE prompts:
- explain the timeline.recent / timeline.weekly shape
- tell the model to answer day-specific or weekday-specific
questions out of timeline.recent and to cite the actual reading
with date + weekday
- when no row exists for the day named, say so plainly and offer
the closest available day rather than the aggregate
- older weekday questions fall back to timeline.weekly
- the scope.sources field is the conversation's source-allowlist;
a question about a non-listed metric should be acknowledged as
out-of-scope rather than answered from the aggregate
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Coach snapshot used to ship only aggregated statistics (mean,
slope, SD, range, count) per metric. A turn that asked
"why was BP higher on Monday?" had no day-level rows to ground in
and the model fell back to apologetic aggregate-only phrasing
("from the snapshot I cannot isolate the specific Monday").
Extend buildCoachSnapshot to fold in:
- timeline.recent: one row per UTC day for the last 14 days, each
tagged with its weekday so the model can answer day or weekday
questions directly. BP rows pair systolic + diastolic; days with
only one side measured are dropped to avoid fabricating a
complement.
- timeline.weekly: ISO-week buckets covering days 15..N where N is
the configured window. Keeps the prompt budget tight on 30/90-day
windows while still letting the Coach cite older weeks.
- per-day medication adherence rows from MedicationIntakeEvent so
the Coach can answer "did I miss my dose Tuesday?" without
inventing the schedule.
Also add the optional `scope` field to coachChatRequestSchema (sources
+ window) so the upcoming sources-rail picker can narrow the snapshot
without breaking older clients — missing fields fall through to
all-source last30days.
A typical 30-day, 5-source snapshot lands around 2.4-2.8 KB JSON
(≈700-900 prompt tokens) — comfortably inside the 25k/day Coach token
budget.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The /api/insights/generate route was still calling the legacy
getInsightsSystemPrompt that returns the v1.4.5 {changed, stable,
drivers, …} shape and never asks the model for a dailyBriefing block.
GROUND RULE 8 (emit dailyBriefing when the snapshot has signal) lives
on getStrictInsightsSystemPrompt in src/lib/ai/prompts/insight-
generator.ts; the route never adopted it. Re-runs from the hero strip
therefore stored a payload with no briefing, the use-insights-advisor
lift saw null, and the briefing card painted its empty state forever.
Switch the route to getStrictInsightsSystemPrompt(locale). Existing
strict-shape tolerance is unchanged: insightResultSchema.safeParse
falls through to the raw parsed body on shape mismatch, and
aiInsightResponseSchema's passthrough() keeps any custom keys for
forward compat.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Both EN + DE have heroGreetingNight pointing to the same string as
heroGreetingEvening (the night bucket maps to "Good evening" so 03:00
doesn't read as Morgen). Drop the duplicate key and fall through to
heroGreetingEvening in the resolver — the resolver decided the
collapse, the JSON catalog carried dead weight.
Fixes Design-M2.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- weekly-report eyebrow chip drops uppercase + tracking-wide for the
sentence-case rule the rest of the app follows; print stylesheet
now neutralises bg + colour so monochrome output reads clean
(Design-M4, Design-L3).
- correlation-card "Try a 7-day experiment" CTA flips from default to
outline while disabled. Disabled-primary read as "the system is
broken right now"; outline reads as "this is on the roadmap"
(Design-M7).
- coach-drawer settings cog aria-label switches to the coming-soon
tooltip copy so SR users hear the same "arrives in v1.4.21" message
sighted users see (Design-M9).
- sources-rail static "fresh" dot becomes aria-hidden — v1.4.20 has
no real freshness state behind it; SR users no longer hear "Fresh"
five times in a row (Design-L4).
- summariseTitle slices on Unicode code points rather than UTF-16
code units so a user message ending mid-emoji ("🩺") no longer
produces a half-character on disk (Code-LOW-04).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three live byte-identical formatRelativeTime copies (hero-strip,
daily-briefing, coach history-rail) lifted to src/lib/i18n/relative-
time.ts. Both correlation-card and trend-annotation duplicated the
exact same low/moderate/high Dracula classnames; extracted to
src/components/insights/confidence-badge.ts (per-component i18n
labels stay local — the copy differs).
Drop the dead "null" placeholder branch in useCoachConversation's
queryKey: the ternary picked a different key when id was null, but
enabled: id !== null kept the query disabled so the placeholder never
fired. Key directly on id.
Hoist STORYBOARD_COLOR_BY_CATEGORY in /insights/page.tsx to module
scope so the map isn't recreated on every render.
Fixes Simplify-S-02, Simplify-S-03, Simplify-S-04, Simplify-S-05,
Code-LOW-02.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Phase D security review flagged a "Marc's data (572 paired
readings, …)" comment in the analytics route. It predates v1.4.20
but the file received new commits in this release, so neutralise the
example to a generic "busy account" wording that conveys the same
shape without leaking a maintainer name.
Fixes Security-LOW-4.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The hero band's glow-purple box-shadow bled through the sticky
section nav (bg-background/80 + backdrop-blur) so the nav painted
with a faint purple cast on scroll. Add `isolate` to the hero
container so the shadow stays z-trapped inside its own stacking
context.
Fixes Design-HIGH-4.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The drawer's lg:max-w-[1080px] cap left only ~200-286px of underlying
/insights visible on common laptop viewports (1280-1366px), turning
the "keep dashboard context behind it" sheet into a near-takeover.
Drop the lg cap to min(960px,75vw) with the inline sources rail
hidden below xl — the rail still surfaces on lg via the existing
mobile chevron tab. xl+ restores the full three-column layout. The
history rail stays visible inline on lg+ (it's the more frequently
referenced of the two).
Update the SSR test contract to match: history trigger hides on lg+,
sources trigger hides on xl+.
Fixes Design-HIGH-1.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The streaming assistant bubble updated in place as the SSE tokens
landed but had no aria-live region — sighted users saw live text
while screen-reader users heard silence. Wrap the in-flight bubble
in role=log + aria-live=polite + aria-relevant=additions text so the
assistant prose announces progressively (matches the daily-briefing
pattern that already uses aria-live for its loading state).
Fixes Design-HIGH-3.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Chips painted at ~28px tall (px-3 py-1.5 text-xs). On a 375px viewport
the chip strip became a fingertip lottery. Bump to min-h-9 with
px-3.5 py-2 text-[13px] so chips clear the 36px mobile touch baseline
without the type-shrink that triggered the regression.
Fixes Design-HIGH-2.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase B4 shipped /insights/report/[week] but the hero action-row
button stayed disabled with a "Coming soon" tooltip. Wire it into the
current ISO week so a click opens this week's report. Older parents
that don't pass weeklyReportHref still get the disabled placeholder.
Fixes Design pushback (item 6).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Every POST /api/insights/chat called extractFeatures(userId, false),
which ran prisma.measurement.findMany({ where: { userId } }) with no
date filter — fetching every measurement the user had ever recorded.
For power users with multi-year Withings imports that's tens of
thousands of rows on every Coach turn.
Add an optional sinceDays bound to extractFeatures (default unchanged
= full history; analytics dashboard + insights/generate keep their
existing behaviour) and have the Coach snapshot pass sinceDays: 90.
The Coach already only narrates the last7 / last30 / last90 windows,
so the 90-day floor preserves every metric the snapshot surfaces.
Fixes Senior-HIGH-3.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Drop the withIdempotency wrap on POST /api/insights/chat. The wrapper
caches response bodies via cloned.text() then replays through
NextResponse.json(JSON.parse(...)) — turning the SSE wire format into
a null body on retry. The PWA never sends Idempotency-Key so the bug
is hidden today; the iOS client would walk straight into it. Dedup
still holds via the conversationId existence check + 20-turn cap.
- Read the SSE response body even on !response.ok when the content type
is text/event-stream so the structured error frame the route emits
surfaces its errorCode to the bubble. Without this the stream was
read only on 2xx and the rich error channel was dead code.
- Stabilise the useSendCoachMessage opts reference via useRef so an
inline-literal { onDone } from the drawer no longer re-memoises send
on every render.
- Drop the unused snapshot parameter from streamProviderError (the
ReturnType<typeof Object.assign> alias widened to any). Switch the
error response to status 200 so the streaming client reads the SSE
body and parses the error frame instead of branching on HTTP status.
Fixes Code-HIGH-1, Code-HIGH-2, Code-HIGH-3, Code-HIGH-4, Security-M-1,
Senior-HIGH-1, Simplify-S-01.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The hero strip's right side, empty by design since B1, now paints the
<HealthScoreCard> when `analytics?.healthScore` is non-null. Layout
flips from `flex-col` to `flex-row` on `lg+` so the panel sits beside
the title block on desktop and stacks below on mobile.
The `onAskCoach` prop signature picks up an optional `prefill`
argument so the same handler powers both surfaces — the action-row
button passes nothing (drawer opens blank), the score panel passes
"Why is my health score X out of 100?". One drawer instance, one
state pair, one consistent UX.
Tests: 5 new hero-strip cases pin the score panel slot, the lg-row
layout switch, and the conditional ask-coach button mounting.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pure presentational <HealthScoreCard> for the right-side hero
panel. Surfaces the composite 0..100 score with a band-coloured
number, a band-tinted progress bar, four sub-component rows
(BP / Weight / Mood / Compliance) with their individual scores, a
"vs last week" delta line with arrow + chip, the "Indicative — not
a clinical assessment" disclaimer, and an "Ask the Coach" button
that pre-fills the drawer with "Why is my health score X out of
100?".
Layout: 220 px wide on desktop, full-width on mobile so it stacks
below the hero title block on `<lg`. Dracula band tokens
(`--dracula-green/orange/red`) carry WCAG AA contrast against
`bg-card/65`.
Tests: 17 SSR cases covering each band, delta-arrow shapes, the
null-component em-dash fallback, the German locale, the score-aware
prefill argument format, the progressbar role + ARIA values, and
the score-clamp at 0..100 % bar width.
i18n keys land under `insights.healthScore.*` in en.json + de.json.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The route now builds the four-component Health Score input from the
already-fetched BP figure plus a fresh weight + mood + medication
intake fetch (last 30 days, plus a 7-day-shifted snapshot for the
delta). The response gains a `healthScore: { score, band,
components, delta } | null` field; null when no component is
computable so the UI hero panel can hide cleanly.
The wide-event annotation carries `healthScore.score` + `band` +
`delta` so admin-app-logs can attribute coverage without DB queries.
The previous-week BP rate reuses the all-time figure (it's a slow-
moving aggregate; rewinding it cheaply isn't possible without a
full historical re-pair). Delta therefore primarily reflects week-
over-week movement in weight / mood / compliance — the components
that actually move on a weekly cadence.
Integration coverage seeds three synthetic-data shapes against the
postgres testcontainer:
- strong-positive → green band (>= 75)
- empty user → null healthScore
- compliance-only → bp/weight/mood null, compliance weight 1.0
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Composite 0-100 score with three bands (green >= 75, yellow 50..74,
red < 50). Server-deterministic so the same inputs always produce the
same output.
Sub-components:
- bpInTargetRate (30 %) — reuses the existing all-time figure
- weightTrendAlignment (20 %) — slope vs target band over 30 days
- moodStability (20 %) — `100 - CV*100`, requires >= 5 entries
- complianceRate (30 %) — mean of medication compliance30
Null components scale the remaining weights proportionally so the
score never lies about what was actually measured. Pure helpers
(linearRegressionSlope, coefficientOfVariation, weightTrendAlignment,
moodStability, complianceRate) live alongside the composite.
Tests cover strong/mixed/poor scenarios, every null-redistribution
shape (bp null, bp+mood null, all-but-compliance null, all null) and
determinism (same input -> same output).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The history + sources rails were desktop-only since B2b. B4 surfaces
them on `<lg` via two chevron-button triggers anchored to the inside
edges of the message thread. Tapping a trigger opens a side <Sheet>
that hosts the same HistoryRail / SourcesRail instance the desktop
layout uses, so the user can browse conversations or check what data
the Coach is reading without losing message-thread context.
Refactors the body grid into a separate <CoachDrawerBody> presentational
component so the SSR test harness can pin the trigger slots + their
lg:hidden Tailwind variant without rendering the outer Radix Sheet
portal (which is client-only).
Selecting a conversation from the mobile history tray closes the tray
automatically so the user lands back on the message thread.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add an additive annotations[] prop to <HealthChart> that paints a
vertical Recharts <ReferenceLine> per AI-narrated event on the
underlying timeline. The line + tooltip + comparison overlay remain
untouched — annotations sit between the cartesian grid and the data
lines so they read as orientation, never as data.
The /insights page wires advisor.payload.insights.storyboardAnnotations
into the 90-day BP chart, mapping the four canonical categories to
Dracula colours (medication=pink, event=cyan, milestone=green,
warning=orange).
Pure helper resolveAnnotationPositions() handles the date->pointIndex
mapping with a 7-day snap window (so annotations don't stack on
monthly buckets) and a 24-char truncation for the small-viewport
label. The unit suite pins exact-day pinning, near-day snapping,
out-of-window dropping, and label truncation.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add a slim banner-card on <HeroStrip> that surfaces a fresh weeklyReport
between the title block and the action row. Three actions:
- Read -> Next.js Link to /insights/report/[week]
- Share -> navigator.share() with clipboard fallback + sonner toast
- Export PDF -> deep-link to the report URL with ?print=1 so the report
page auto-fires window.print() after first paint
The banner only renders when the parent passes weeklyReportReady; the
insights page derives that prop from advisor.payload.insights.
weeklyReport so the banner appears the moment a fresh PROMPT_VERSION
4.20.2 generation lands.
EN + DE translations under insights.heroBanner.* (Read / Share / Export
PDF + share success/failure toasts).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add a newsletter-style report route at /insights/report/[week] that
parses the YYYY-Www slug, reads the cached weeklyReport block off the
AI advisor payload, and renders Summary / Going-well / Worth-watching /
Tips / Data-quality sections in a print-friendly layout.
Print export uses window.print() with Tailwind print: variants on the
toolbar (hidden) and article wrapper (full-width margins). The route
honours ?print=1 to deep-link the export PDF flow from the upcoming
hero banner card.
Includes:
- src/lib/insights/week-iso.ts — parseWeekISO / toWeekISO / weekISOToRange
- WeeklyReportView wrapper + WeeklyReportPresentation pure layer
- Empty-state with Generate CTA when the cached report's weekISO
doesn't match the route param
- EN + DE i18n keys under insights.report.* and insights.heroBanner.*
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Extend aiInsightResponseSchema with weeklyReport (ISO week + summary +
goingWell/worthWatching/tips bullets + optional dataQualityNotes) and
storyboardAnnotations (date + label + category + detail; max 20).
Both blocks are nullable + optional so legacy 4.20.1 caches round-trip.
Bump PROMPT_VERSION 4.20.1 -> 4.20.2 and add GROUND RULE 10 (weekly
report sections match layout, conservative phrasing, no causal claims)
+ GROUND RULE 11 (storyboard annotations are factual user-logged events
with neutral prose) to both EN and DE prompts.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>