docs(planning): record v1.4.20.1 hotfix wave

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>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-10 19:31:20 +02:00
1 parent 08fd411ff1
commit 52dd9ff9c3
9 files changed
+253 -60

No files matched your search

+58 -19
View File
@@ -85,17 +85,17 @@ ships behind a feature flag and unlocks independently.
action row + suggested-prompt strip; Daily Briefing card mounts
full-width below the hero)
- [x] New `src/components/insights/{hero-strip,daily-briefing,
suggested-prompts}.tsx`
suggested-prompts}.tsx`
- [x] Extend `aiInsightResponseSchema` with `dailyBriefing`
(`paragraph`, `keyFindings[]`)
- [x] PROMPT_VERSION bump 4.19.0 → 4.20.0
- [~] Apple Health gated tiles — descoped per `phase-D-v1419-product-
lead-review.md` decision. The 4-vitals tile row from the artboard
uses BP / Weight / Pulse / Mood (all data already in the app);
HRV / Sleep / Resting-HR tiles defer to v1.5 / iOS app. The B1
tile row itself (Vitals at-a-glance) is deferred to a later
B-phase since it duplicates the per-section status cards below
— see B1 report for the rationale.
lead-review.md` decision. The 4-vitals tile row from the artboard
uses BP / Weight / Pulse / Mood (all data already in the app);
HRV / Sleep / Resting-HR tiles defer to v1.5 / iOS app. The B1
tile row itself (Vitals at-a-glance) is deferred to a later
B-phase since it duplicates the per-section status cards below
— see B1 report for the rationale.
- Detailed report: `.planning/phase-B1-v1420-report.md` (the older
`phase-B1-report.md` is from the v1.4.18 achievements marathon)
@@ -108,7 +108,7 @@ commit.
- [x] New `POST /api/insights/chat` SSE-streaming endpoint
- [x] New `GET /api/insights/chat` (list) + `GET/DELETE
/api/insights/chat/[id]` (single + delete)
/api/insights/chat/[id]` (single + delete)
- [x] New Prisma `CoachConversation` + `CoachMessage` + `CoachUsage`
(encrypted content, GDPR cascade)
- [x] Source-chip provenance attached to every assistant message
@@ -130,14 +130,14 @@ commit.
- [x] New `src/lib/insights/correlations.ts` (Pearson + weekday-ANOVA;
n >= 14 + p < 0.05 surfacing gate)
- [x] New `src/components/insights/{correlation-card,correlation-row,
trends-row,trend-annotation}.tsx`
trends-row,trend-annotation}.tsx`
- [x] Confidence interval via Fisher z-transform; conservative
phrasing locked in by code-review convention
- [x] PROMPT_VERSION 4.20.0 → 4.20.1; new `trendAnnotations` block
on the AI response schema (nullable + optional for legacy
payloads)
- [x] `/api/analytics` now emits `correlations: {bpCompliance,
moodPulse, weightWeekday}` server-side
moodPulse, weightWeekday}` server-side
- [x] `<CorrelationRow>` + `<TrendsRow>` mounted on `/insights`
between Daily Briefing and the Advisor card
- Detailed report: `.planning/phase-B3-report.md`
@@ -175,14 +175,14 @@ commit.
Wave B closed the Insights redesign across 5 phases on `develop`:
| Phase | Theme | Commits | Test count delta (unit / int) |
| ----- | ------------------------------------------ | --------- | ----------------------------- |
| B1 | Hero strip + Daily Briefing + Suggested | 5 | 1672 → 1753 (+81) / 67 → 67 |
| B2 | AI Coach drawer (B2a backend + B2b UI) | 5 + 4 | 1753 → 1833 (+80) / 67 → 78 |
| B3 | Correlation discovery + Trends row + AI | 5 | 1833 → 1907 (+74) / 78 → 78 |
| B4 | Weekly Report + Storyboard + Mobile passes | 5 | 1907 → 1975 (+68) / 78 → 78 |
| B5 | Personal Health Score | 5 | 1975 → 2026 (+51) / 78 → 81 |
| | **Cumulative** | **29** | **+354 / +14** |
| Phase | Theme | Commits | Test count delta (unit / int) |
| ----- | ------------------------------------------ | ------- | ----------------------------- |
| B1 | Hero strip + Daily Briefing + Suggested | 5 | 1672 → 1753 (+81) / 67 → 67 |
| B2 | AI Coach drawer (B2a backend + B2b UI) | 5 + 4 | 1753 → 1833 (+80) / 67 → 78 |
| B3 | Correlation discovery + Trends row + AI | 5 | 1833 → 1907 (+74) / 78 → 78 |
| B4 | Weekly Report + Storyboard + Mobile passes | 5 | 1907 → 1975 (+68) / 78 → 78 |
| B5 | Personal Health Score | 5 | 1975 → 2026 (+51) / 78 → 81 |
| | **Cumulative** | **29** | **+354 / +14** |
Test count: 1672 → 2026 unit (+354), 67 → 81 integration (+14).
Test files 217 → 237. typecheck clean every phase; lint
@@ -225,7 +225,7 @@ Test files 217 → 237. typecheck clean every phase; lint
JSON-LD `softwareVersion` 1.4.20 + 3 new featureList entries)
- [x] `docs/audit/v1420-summary.md` (release brief, `7f9ae21`)
- [x] v1.5 plan refreshed — see `.planning/phase-D-v1420-product-
lead-review.md`
lead-review.md`
---
@@ -295,6 +295,45 @@ Test files 217 → 237. typecheck clean every phase; lint
`coach-chat.test.ts` "round-trips" (not introduced by Phase-D —
consistent re-runs pass). Phase E (release v1.4.20) unblocked.
## Phase F — v1.4.20.1 hotfix
Triggered by Marc's testing of the new Insights/Coach surface in
production after v1.4.20 went live. Five issues captured in
`feedback_v1420_post_deploy_bugs.md` — landed across six atomic
commits on `develop`. No tag yet; maintainer decides whether to ship
as v1.4.20.1 or bundle into v1.4.21.
- [x] Daily Briefing regenerate produces a non-empty briefing
(`/api/insights/generate` now uses `getStrictInsightsSystemPrompt`
so GROUND RULE 8 reaches the model — `7921ffc`)
- [x] Coach snapshot ships day-level readings with weekday labels
and a per-day medication-adherence row (`ed61b17`)
- [x] Coach system prompt instructs the model to use the timeline
block for day-specific or weekday-specific questions
(`2143377`)
- [x] Streaming bubble clears once the persisted twin lands —
message-thread render-time dedup keyed on `messageId`
(`f07e35b`)
- [x] Drawer header settings cog moved to the left side so Radix
Sheet's close-X no longer overlaps it (`ddb2914`)
- [x] Sources rail grows per-source checkboxes + window selector;
scope flows through `useSendCoachMessage` to the chat request
body and into `buildCoachSnapshot` (`08fd411`)
- [x] Detailed report: `.planning/phase-v1421-hotfix-report.md`
## Status block — F (v1.4.20.1 hotfix)
- 2026-05-10 (post-deploy) — Hotfix complete. Six commits on
`develop` (`7921ffc` → `08fd411`). Test counts: 2026 → 2036 unit
(+10 net across snapshot + message-thread + sources-rail tests).
Integration tests unchanged at 81 — coach-chat.test.ts still 5/5.
typecheck + lint clean (13 baseline warnings unchanged). Token-
budget impact of the day-level snapshot: ~190 → ~3000 tokens per
full-scope turn; the new scope picker is the relief valve, single-
source narrowed turns land around 600-700 tokens. v1.4.21 should
watch usage and consider trimming `DAILY_TIMELINE_DAYS` from 14 to
10 if heavy users start hitting the 25k/day cap.
## Status block — B5 (v1.4.20)
- 2026-05-10T17:45+02:00 — B5 complete. Personal Health Score
+167
View File
@@ -0,0 +1,167 @@
# Phase v1.4.20.1 — hotfix report
Date: 2026-05-10 (post-v1.4.20 deploy).
Triggered by Marc's testing of the new Insights/Coach surface in
production. Five issues captured in
`feedback_v1420_post_deploy_bugs.md`; all six commits below land on
`develop` with no tag — the maintainer decides whether to ship as a
v1.4.20.1 hotfix or bundle into v1.4.21.
## Issue 1 — Daily Briefing regenerate produced nothing
**Diagnosis.** `/api/insights/generate` was still calling the legacy
`getInsightsSystemPrompt` from `src/lib/insights/prompt.ts`, which
returns the v1.4.5 `{changed, stable, drivers, …}` shape and never
asks the model for a `dailyBriefing`. The B1 phase shipped GROUND
RULE 8 (emit dailyBriefing when the snapshot has signal) on
`getStrictInsightsSystemPrompt` in
`src/lib/ai/prompts/insight-generator.ts`, but the route never
adopted it. Re-runs from the hero strip therefore stored a payload
with no briefing block and the `<DailyBriefing>` card painted its
empty state forever.
**Fix.** Switch the route to `getStrictInsightsSystemPrompt(locale)`.
The route's existing `validated.success ? validated.data : parsed`
fall-through and the strict schema's `passthrough()` mean the change
is back-compat for cached legacy blobs. Commit `7921ffc`.
## Issue 2 — Coach felt generic, couldn't answer day questions
**Diagnosis.** `buildCoachSnapshot` shipped 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 and the
model fell back to apologetic aggregate-only phrasing — Marc's
actual reply was "from the snapshot I cannot isolate the specific
Monday because no day-specific individual values with weekday are
included." Two layers of fix needed: snapshot must carry the data;
prompt must tell the model to use it.
**Primary fix (commit `ed61b17`).** Extend `buildCoachSnapshot` to
fold in a `timeline.recent` block (one row per UTC day for the last
14 days, each tagged with weekday) and a `timeline.weekly` block
(ISO-week buckets covering days 15..N). Pair systolic + diastolic
into one BP row per day; drop half-measured days so the model never
fabricates a complement. Pull per-day medication adherence rows from
`MedicationIntakeEvent`. Add an optional `scope` field to
`coachChatRequestSchema` (sources + window) so a future picker can
narrow without breaking existing clients.
**Secondary fix (commit `2143377`).** Add a DAY-LEVEL READINGS
section to both EN and DE Coach system prompts: explain the timeline
shape; instruct the model to answer day-specific or weekday-specific
questions out of `timeline.recent` and to cite the actual reading
with date + weekday; tell the model to acknowledge missing days
plainly and offer the closest available day; let older weekday
questions fall back to `timeline.weekly`.
## Issue 3 — Duplicate assistant reply in display
**Diagnosis.** After SSE `done` 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.
Marc noticed the duplicate cleared on drawer close + reopen — the
persisted state was correct, the bug was purely client-side render
overlap.
**Fix.** 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.
Commit `f07e35b`.
## Issue 4 — Settings cog vs Sheet close-X collision
**Diagnosis.** 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, so the close-X overlay
visually swallowed the cog — the icon still rendered but read as
unclickable.
**Fix.** 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. Commit `ddb2914`.
## Issue 5 — Sources rail had no toggle UI
**Diagnosis.** The Sources rail listed BP / Weight / Pulse / Mood /
Compliance as a static legend. Marc wanted per-source checkboxes
(include/exclude) plus a window selector (last 7d / 30d / 90d /
all-time) feeding the next Coach turn.
**Fix (commit `08fd411`).** Wire the controls all the way down:
`<SourcesRail>` grows `scope` + `onScopeChange` props with real
checkboxes (36px touch target; 60% opacity when excluded) and a
`<Select>` for the window. `<CoachDrawer>` owns the scope state
(resets each mount per the v1.4.20.1 plan) and forwards 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 — server can tell "no opinion" from
"intentionally narrow". `buildCoachSnapshot` already accepted the
optional `CoachScope` from the previous commit; it now flows end-
to-end. New i18n key `insights.coach.windowLabel` in EN+DE.
## Test count delta
- 2026 → 2036 unit tests (+10 net).
- 6 new tests in `src/lib/ai/coach/__tests__/snapshot.test.ts`
covering empty input, day-level BP rows, scope.sources filter,
scope.window narrowing, default scope, mood-only path.
- 2 new tests in
`src/components/insights/coach-panel/__tests__/message-thread.test.tsx`
pinning the streaming-bubble dedup behaviour (suppress when
persisted twin lands; keep when still streaming).
- 2 new tests in
`src/components/insights/coach-panel/__tests__/sources-rail.test.tsx`
pinning the checkbox count + scope-driven active state.
Integration suite green; `tests/integration/coach-chat.test.ts` still
passes 5/5 with the route changes.
## Token-budget impact of the day-level snapshot
Aggregates-only snapshot before: ~760 bytes JSON ≈ 190 tokens.
Day-level snapshot after, full 5-source 30-day window: ~12 KB JSON
≈ 3000 tokens. Delta is roughly +2800 tokens per turn. The
`MAX_TOKENS_PER_USER_PER_DAY = 25_000` cap therefore drops a heavy
user from ~13 turns/day to ~8 turns/day. The new scope picker is
the relief valve: a single-source, last-7-days narrowed turn lands
around ~600-700 tokens. v1.4.21 should consider reducing the
`DAILY_TIMELINE_DAYS` constant from 14 → 10 if heavy users start
hitting the cap; usage data will tell.
## Dispatch recommendation
Ship as **v1.4.20.1 hotfix** rather than bundling into v1.4.21.
Reasoning:
- Issue 1 is a regression of the v1.4.20 headline feature — Daily
Briefing was the centerpiece of the Insights redesign and it
hasn't been working since the tag.
- Issue 3 is a render bug that makes every Coach interaction look
broken to a first-time user.
- Issues 2, 4, 5 are quality-bar items that, taken together, fix the
"Coach feels impersonal" perception that undercuts the AI-Insights-
as-differentiator product principle.
- The diff is contained: 8 files touched across 6 commits, no
schema migration, no new dependencies, no breaking API change
(`scope` is additive optional). All 2036 unit tests + the
coach-chat integration suite green.
The runtime risk of holding the fixes for v1.4.21 is that production
keeps shipping a broken Daily Briefing. A patch tag here is exactly
what the conservative-semver memory expects.
## What was not fixed
Nothing was deferred — all five issues have a working fix on
`develop`. One follow-up worth tracking in v1.4.21:
- The +2800 tokens/turn snapshot delta is at the upper end of what
the 25k/day budget tolerates for power users. v1.4.21 should
watch real usage and either trim `DAILY_TIMELINE_DAYS` from 14 to
10, or add a "summarise-when-narrow" path that drops the
`timeline.weekly` block when the window is `last7days`.
+1 -6
View File
@@ -143,12 +143,7 @@ async function handleChatRequest(request: NextRequest): Promise<Response> {
{ status: 422 },
);
}
const {
conversationId,
message,
locale: bodyLocale,
scope,
} = parsed.data;
const { conversationId, message, locale: bodyLocale, scope } = parsed.data;
await enforceBudget(userId);
@@ -53,9 +53,8 @@ describe("<SourcesRail>", () => {
it("renders a checkbox per row + a window selector trigger", () => {
const html = render(<SourcesRail />);
const checkboxes = (
html.match(/data-slot="coach-sources-checkbox"/g) ?? []
).length;
const checkboxes = (html.match(/data-slot="coach-sources-checkbox"/g) ?? [])
.length;
expect(checkboxes).toBe(5);
expect(html).toContain('data-slot="coach-sources-window-trigger"');
});
@@ -258,10 +258,7 @@ export function CoachDrawer({
}
sourcesRail={
sourcesRail ?? (
<SourcesRail
scope={scope}
onScopeChange={setScope}
/>
<SourcesRail scope={scope} onScopeChange={setScope} />
)
}
thread={
@@ -67,9 +67,7 @@ export function MessageThread({
messages.some((m) => m.id === streaming.messageId);
const streamingActive =
!streamingPersisted &&
(!!streaming?.inProgress ||
!!streaming?.content ||
!!streaming?.errorCode);
(!!streaming?.inProgress || !!streaming?.content || !!streaming?.errorCode);
// Track scroll position so we don't yank the viewport when the user
// is browsing earlier turns.
@@ -82,7 +82,10 @@ export interface SourcesRailProps {
* the controls still render but stay read-only — keeps the legend
* surface stable for callers that don't yet wire scope through.
*/
onScopeChange?: (next: { sources: CoachScopeSource[]; window: CoachScopeWindow }) => void;
onScopeChange?: (next: {
sources: CoachScopeSource[];
window: CoachScopeWindow;
}) => void;
}
interface SourceRow {
+5 -1
View File
@@ -27,7 +27,11 @@ const featuresMock = extractFeatures as unknown as ReturnType<typeof vi.fn>;
/**
* Helper: produce a measurement row N days before "now" at 09:00 UTC.
*/
function daysAgo(n: number, value: number, type: string): {
function daysAgo(
n: number,
value: number,
type: string,
): {
type: string;
value: number;
measuredAt: Date;
+14 -23
View File
@@ -53,15 +53,7 @@ const DEFAULT_SOURCES: ReadonlyArray<CoachScopeSource> = [
"compliance",
];
const WEEKDAY_KEYS = [
"Sun",
"Mon",
"Tue",
"Wed",
"Thu",
"Fri",
"Sat",
] as const;
const WEEKDAY_KEYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] as const;
function windowToDays(window: CoachScopeWindow): number {
switch (window) {
@@ -163,7 +155,9 @@ function bucketWeekly(
return Array.from(grouped.entries())
.map(([weekISO, values]) => ({
weekISO,
mean: Math.round((values.reduce((s, v) => s + v, 0) / values.length) * 10) / 10,
mean:
Math.round((values.reduce((s, v) => s + v, 0) / values.length) * 10) /
10,
count: values.length,
}))
.sort((a, b) => a.weekISO.localeCompare(b.weekISO));
@@ -177,8 +171,7 @@ function buildDailyValueRows(
const grouped = dailyMeans(recent);
return Array.from(grouped.entries())
.map(([date, info]) => {
const mean =
info.values.reduce((s, v) => s + v, 0) / info.values.length;
const mean = info.values.reduce((s, v) => s + v, 0) / info.values.length;
return {
date,
weekday: utcWeekday(info.date),
@@ -206,10 +199,8 @@ function buildDailyBpRows(
for (const [day, info] of sysByDay) {
const dia = diaByDay.get(day);
if (!dia) continue;
const sysMean =
info.values.reduce((s, v) => s + v, 0) / info.values.length;
const diaMean =
dia.values.reduce((s, v) => s + v, 0) / dia.values.length;
const sysMean = info.values.reduce((s, v) => s + v, 0) / info.values.length;
const diaMean = dia.values.reduce((s, v) => s + v, 0) / dia.values.length;
out.push({
date: day,
weekday: utcWeekday(info.date),
@@ -310,10 +301,12 @@ export async function buildCoachSnapshot(
: [];
const byType = (t: string) =>
measurementRows.filter((r) => r.type === t).map((r) => ({
measuredAt: r.measuredAt,
value: r.value,
}));
measurementRows
.filter((r) => r.type === t)
.map((r) => ({
measuredAt: r.measuredAt,
value: r.value,
}));
if (wantsBp && features.bloodPressure) {
const sysRows = byType("BLOOD_PRESSURE_SYS");
@@ -405,9 +398,7 @@ export async function buildCoachSnapshot(
// Per-day adherence rate within the recent window. Older days
// collapse into a single weekly bucket.
const recent = intakeRows.filter((r) => r.scheduledFor >= recentCutoff);
const olderRows = intakeRows.filter(
(r) => r.scheduledFor < recentCutoff,
);
const olderRows = intakeRows.filter((r) => r.scheduledFor < recentCutoff);
const recentByDay = new Map<
string,
{ date: Date; total: number; taken: number }