Release v1.4.21

9955288 chore(release): v1.4.21
52dd9ff docs(planning): record v1.4.20.1 hotfix wave
08fd411 feat(coach): per-source + window scope picker on sources rail
ddb2914 fix(coach): drawer header — close-X / cog collision
f07e35b fix(coach): clear streaming bubble after persisted twin lands
2143377 feat(coach): system prompt instructs model to use day-level data
ed61b17 feat(coach): snapshot includes day-level readings with weekday labels
7921ffc fix(insights): wire generate route to strict prompt so dailyBriefing emits

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:36:22 +02:00
commit 8e198e1b6b
18 files changed
+1317 -116

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`.
+63
View File
@@ -1,5 +1,68 @@
# Changelog
## [1.4.21] — 2026-05-10
### Fixed
- **Daily Briefing regenerate produced an empty card.** The
`/api/insights/generate` route was still calling the legacy
`getInsightsSystemPrompt`, which returns the v1.4.5 schema and
never asks the model for a `dailyBriefing` block. Re-runs from the
hero strip therefore stored a payload with no briefing and the
card painted its empty state forever. The route now uses
`getStrictInsightsSystemPrompt(locale)` so the v1.4.20 ground
rules apply to manual regeneration too. Cached legacy blobs still
parse — every new schema field is optional with `passthrough()`.
- **Duplicate streaming bubble after the assistant reply landed.**
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`. The thread therefore rendered the
reply twice until the next `send` reset cleared it. The render
path now suppresses the in-flight bubble as soon as the persisted
twin lands, keyed on `streaming.messageId`.
- **Settings cog overlapped by the drawer's close-X.** Radix Sheet
paints its default close-X at `top-4 right-4`, which visually
swallowed the cog in the right-edge button cluster. The cog moves
to the left header zone (next to the gradient avatar) and a
`pr-12 / sm:pr-14` padding rule keeps the New-chat button out of
the close-X area on narrower viewports.
- **Suggested-prompt chips below the 36px touch target.** Chips
rendered at 28px which fell short of the WCAG-AA target-size
guideline. Padding bumped to land on a 36px hit area.
### Changed
- **Coach context now carries day-level readings instead of
aggregates only.** `buildCoachSnapshot` folds 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 the rest of the analysis window). Systolic and
diastolic pair into a single BP row per day; half-measured days
drop so the model never fabricates a complement. Per-day
medication-adherence rows draw from `MedicationIntakeEvent`. The
EN and DE Coach system prompts gain a DAY-LEVEL READINGS section
instructing the model to answer day-specific or weekday-specific
questions out of `timeline.recent` with date + weekday citations,
acknowledge missing days plainly, and fall back to
`timeline.weekly` for older windows. Token cost per Coach turn
rises from ≈190 tokens (aggregates only) to ≈3000 tokens for a
full-scope snapshot. The new scope picker is the relief valve.
### Added
- **Per-source + per-window scope picker on the Coach sources rail.**
The sources rail grew real per-source checkboxes (BP / Weight /
Pulse / Mood / Compliance — 36px touch target, 60% opacity when
excluded) and a window selector (last 7 days / 30 days / 90 days /
all-time). The drawer owns the scope state and forwards picked
scope through `useSendCoachMessage` to the chat request body.
Toggles reset to the all-source last-30-days default each drawer
open. The scope payload only ships when the user has narrowed
away from the default — server can tell "no opinion" from
"intentionally narrow". A single-source last-7-days narrowed turn
lands around ≈600–700 tokens.
## [1.4.20] — 2026-05-10
### Added
+2 -1
View File
@@ -883,7 +883,8 @@
"last30days": "letzte 30 Tage",
"last90days": "letzte 90 Tage",
"allTime": "gesamter Zeitraum"
}
},
"windowLabel": "Zeitraum"
},
"viewAll": "Alle ansehen",
"previewEmpty": "Noch keine Empfehlungen.",
+2 -1
View File
@@ -883,7 +883,8 @@
"last30days": "last 30 days",
"last90days": "last 90 days",
"allTime": "all time"
}
},
"windowLabel": "Window"
},
"viewAll": "View all",
"previewEmpty": "No recommendations yet.",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "1.4.20",
"version": "1.4.21",
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
"license": "AGPL-3.0-only",
"homepage": "https://healthlog.dev",
+2 -2
View File
@@ -143,7 +143,7 @@ async function handleChatRequest(request: NextRequest): Promise<Response> {
{ status: 422 },
);
}
const { conversationId, message, locale: bodyLocale } = parsed.data;
const { conversationId, message, locale: bodyLocale, scope } = parsed.data;
await enforceBudget(userId);
@@ -201,7 +201,7 @@ async function handleChatRequest(request: NextRequest): Promise<Response> {
// Build the prompt: system + (optional) snapshot + recent history +
// the new user message.
const snapshot = await buildCoachSnapshot(userId);
const snapshot = await buildCoachSnapshot(userId, scope);
const systemPrompt = getCoachSystemPrompt(locale);
const window = buildHistoryWindow([
...priorTurns,
+13 -2
View File
@@ -3,10 +3,10 @@ import { auditLog } from "@/lib/auth/audit";
import { apiSuccess, apiError, getClientIp } from "@/lib/api-response";
import { extractFeatures } from "@/lib/insights/features";
import {
getInsightsSystemPrompt,
buildUserPrompt,
type ComparisonSnapshot,
} from "@/lib/insights/prompt";
import { getStrictInsightsSystemPrompt } from "@/lib/ai/prompts/insight-generator";
import { summarize, type DataPoint } from "@/lib/analytics/trends";
import {
resolveDashboardLayout,
@@ -281,7 +281,18 @@ export const POST = apiHandler(async (request: NextRequest) => {
userId,
providers: chain,
params: {
systemPrompt: getInsightsSystemPrompt(locale),
// The strict prompt (PROMPT_VERSION 4.20.x) carries GROUND RULE 8
// — emit a top-level `dailyBriefing` block when the snapshot has
// analysable signal — plus the trendAnnotations / weeklyReport /
// storyboardAnnotations rules. The legacy `getInsightsSystemPrompt`
// returned the v1.4.5 `{changed, stable, drivers, …}` shape and
// never asked the model for dailyBriefing, so the hero strip's
// "Re-run analysis" button kept producing a payload with no
// briefing block (Issue 1, v1.4.20 post-deploy). The route already
// tolerates the strict shape via `insightResultSchema.safeParse`'s
// soft fallback to `parsed` and `passthrough()` on the strict
// schema, so switching the prompt does not break legacy callers.
systemPrompt: getStrictInsightsSystemPrompt(locale),
userPrompt,
temperature: 0.3,
maxTokens: 1500,
@@ -144,6 +144,72 @@ describe("<MessageThread>", () => {
expect(html).toContain("could not reach an AI provider");
});
it("hides the streaming bubble once its persisted twin lands by id", () => {
// After the SSE `done` frame the hook keeps `streaming.content`
// populated AND fires a TanStack invalidate. When the invalidated
// refetch resolves, the persisted message lands inside
// `conversation.messages` while `streaming.messageId` matches
// exactly. Without de-dup the user sees the assistant reply twice.
const conv: CoachConversationDetailDTO = {
...baseConversation,
messages: [
...baseConversation.messages,
{
id: "m3-streaming",
role: "assistant",
content: "Looking at your data fresh persisted",
createdAt: "2026-05-10T09:01:30.000Z",
metricSource: null,
providerType: "admin-openai",
promptVersion: "4.20.0",
},
],
};
const html = render(
<MessageThread
conversation={conv}
streaming={{
content: "Looking at your data ",
metricSource: null,
inProgress: false,
messageId: "m3-streaming",
errorCode: null,
}}
/>,
);
// 1 persisted user + 2 persisted assistant + 0 streaming → 2.
const assistantBubbles = (
html.match(/data-slot="coach-bubble-assistant"/g) ?? []
).length;
expect(assistantBubbles).toBe(2);
// The persisted text shows; the older streaming sketch does not
// appear as a separate bubble.
expect(html).toContain("fresh persisted");
});
it("still renders the streaming bubble while inProgress and id is null", () => {
// Mid-stream — `messageId` is null, persisted twin can't exist
// yet. Confirm the streaming branch survives the v1.4.20.1
// de-dup logic.
const html = render(
<MessageThread
conversation={baseConversation}
streaming={{
content: "Looking at your data ",
metricSource: null,
inProgress: true,
messageId: null,
errorCode: null,
}}
/>,
);
const assistantBubbles = (
html.match(/data-slot="coach-bubble-assistant"/g) ?? []
).length;
expect(assistantBubbles).toBe(2);
});
it("uses German empty hint when locale is 'de'", () => {
const html = render(<MessageThread conversation={null} />, "de");
expect(html).toContain(
@@ -50,4 +50,25 @@ describe("<SourcesRail>", () => {
"The Coach reads only the data you&#x27;ve connected.",
);
});
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;
expect(checkboxes).toBe(5);
expect(html).toContain('data-slot="coach-sources-window-trigger"');
});
it("paints checkboxes as checked when scope.sources includes the row", () => {
const html = render(
<SourcesRail
scope={{ sources: ["bp", "weight"], window: "last7days" }}
onScopeChange={() => undefined}
/>,
);
// The bp + weight rows should be marked active; others inactive.
expect(html).toMatch(/data-source="bp"[^>]*data-active="true"/);
expect(html).toMatch(/data-source="weight"[^>]*data-active="true"/);
expect(html).toMatch(/data-source="pulse"[^>]*data-active="false"/);
});
});
@@ -24,8 +24,13 @@ import { CoachDrawerBody } from "./coach-drawer-body";
import { CoachInput } from "./coach-input";
import { HistoryRail } from "./history-rail";
import { MessageThread } from "./message-thread";
import { SourcesRail } from "./sources-rail";
import { DEFAULT_COACH_SCOPE, SourcesRail } from "./sources-rail";
import { useCoachConversation, useSendCoachMessage } from "./use-coach";
import type {
CoachScope,
CoachScopeSource,
CoachScopeWindow,
} from "@/lib/ai/coach/types";
/**
* v1.4.20 phase B2b — AI Coach drawer (right-side `<Sheet>` overlay).
@@ -89,6 +94,17 @@ export function CoachDrawer({
// open/prefill transition. That sidesteps `setState`-in-`useEffect`
// entirely (banned by `react-hooks/set-state-in-effect`).
const [inputValue, setInputValue] = useState<string>(() => prefill ?? "");
// v1.4.20.1 — scope picker state (per-source checkboxes + window
// selector). Resets to the all-source last30days default each time
// the drawer mounts; no conversation-level persistence in this
// hotfix per the v1.4.20.1 plan.
const [scope, setScope] = useState<{
sources: CoachScopeSource[];
window: CoachScopeWindow;
}>(() => ({
sources: [...DEFAULT_COACH_SCOPE.sources],
window: DEFAULT_COACH_SCOPE.window,
}));
const handleOpenChange = useCallback(
(next: boolean) => {
@@ -115,9 +131,23 @@ export function CoachDrawer({
const trimmed = value.trim();
if (!trimmed || send.isStreaming) return;
setInputValue("");
// Pass the scope only when the user has narrowed it from the
// defaults — keeps the wire payload minimal and lets the route
// tell "no opinion" apart from "intentionally narrow".
const isDefault =
scope.window === DEFAULT_COACH_SCOPE.window &&
scope.sources.length === DEFAULT_COACH_SCOPE.sources.length &&
DEFAULT_COACH_SCOPE.sources.every((s) => scope.sources.includes(s));
const scopePayload: CoachScope | undefined = isDefault
? undefined
: {
sources: scope.sources,
window: scope.window,
};
await send.send({
conversationId: currentConversationId ?? undefined,
message: trimmed,
scope: scopePayload,
});
}
@@ -148,10 +178,18 @@ export function CoachDrawer({
"flex h-[100dvh] flex-col gap-0",
)}
>
{/* Header (full width). Title + new-chat button + settings. */}
{/* Header (full width). Title + new-chat button. Settings cog
sits next to the avatar on the LEFT — the previous layout
painted it next to the new-chat button on the right, where
Radix Sheet's default close-X (absolute top-4 right-4)
overlapped the cog and made it visually un-clickable. The
new layout keeps the cog inside the visible header column
and reserves the right edge for the close-X alone.
v1.4.20.1: pr-12 ensures the new-chat button never slides
under the close-X on narrower viewports either. */}
<SheetHeader
data-slot="coach-drawer-header"
className="border-border/70 flex-row items-center gap-3 border-b p-3 sm:p-4"
className="border-border/70 flex-row items-center gap-3 border-b p-3 pr-12 sm:p-4 sm:pr-14"
>
<div
aria-hidden="true"
@@ -159,6 +197,29 @@ export function CoachDrawer({
>
<Sparkles className="text-background size-4" />
</div>
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
disabled
// Match the visible disabled state — sighted users
// see a coming-soon tooltip; SR users hear the same
// copy instead of a working "Coach settings" label.
aria-label={t("insights.coach.settingsTooltip")}
data-slot="coach-drawer-settings"
className="size-8 shrink-0"
>
<Settings2 className="size-4" aria-hidden="true" />
</Button>
</TooltipTrigger>
<TooltipContent>
{t("insights.coach.settingsTooltip")}
</TooltipContent>
</Tooltip>
</TooltipProvider>
<div className="min-w-0 flex-1">
<SheetTitle className="truncate text-sm font-semibold">
{drawerTitle}
@@ -173,36 +234,13 @@ export function CoachDrawer({
size="sm"
onClick={handleNewChat}
data-slot="coach-drawer-new-chat"
className="gap-1.5"
className="shrink-0 gap-1.5"
>
<Plus className="size-3.5" aria-hidden="true" />
<span className="hidden sm:inline">
{t("insights.coach.newChat")}
</span>
</Button>
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
disabled
// Match the visible disabled state — sighted users
// see a coming-soon tooltip; SR users hear the same
// copy instead of a working "Coach settings" label.
aria-label={t("insights.coach.settingsTooltip")}
data-slot="coach-drawer-settings"
className="size-8"
>
<Settings2 className="size-4" aria-hidden="true" />
</Button>
</TooltipTrigger>
<TooltipContent>
{t("insights.coach.settingsTooltip")}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</SheetHeader>
{/* Body — three columns on lg+, single column on smaller.
@@ -218,7 +256,11 @@ export function CoachDrawer({
/>
)
}
sourcesRail={sourcesRail ?? <SourcesRail />}
sourcesRail={
sourcesRail ?? (
<SourcesRail scope={scope} onScopeChange={setScope} />
)
}
thread={
<MessageThread
conversation={conversation ?? null}
@@ -283,7 +325,9 @@ export function CoachDrawer({
</SheetTitle>
</SheetHeader>
<div className="h-full min-h-0 overflow-y-auto">
{sourcesRail ?? <SourcesRail />}
{sourcesRail ?? (
<SourcesRail scope={scope} onScopeChange={setScope} />
)}
</div>
</SheetContent>
</Sheet>
@@ -52,8 +52,22 @@ export function MessageThread({
const wasPinnedRef = useRef(true);
const messages: CoachMessageDTO[] = conversation?.messages ?? [];
// v1.4.20.1 — once the SSE stream emits `done`, the route's
// invalidate-then-refetch pulls the persisted assistant message into
// `conversation.messages`. The streaming bubble was still rendering
// because the hook keeps `streaming.content` populated to support
// the in-flight render path; the result was two assistant bubbles
// side by side until the next `send` reset the streaming state.
// Suppress the streaming bubble at render time as soon as the
// persisted twin lands — comparing on `messageId` keeps the
// transition seamless while never accidentally hiding an in-flight
// bubble (during streaming `messageId` is null).
const streamingPersisted =
streaming?.messageId != null &&
messages.some((m) => m.id === streaming.messageId);
const streamingActive =
!!streaming?.inProgress || !!streaming?.content || !!streaming?.errorCode;
!streamingPersisted &&
(!!streaming?.inProgress || !!streaming?.content || !!streaming?.errorCode);
// Track scroll position so we don't yank the viewport when the user
// is browsing earlier turns.
@@ -10,30 +10,86 @@ import {
Smile,
} from "lucide-react";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
import type {
CoachScope,
CoachScopeSource,
CoachScopeWindow,
} from "@/lib/ai/coach/types";
/**
* v1.4.20 phase B2b — "What I can see" rail.
*
* Right-column companion in the Coach drawer. Lists the data sources
* the assistant draws on so the user can build trust in the
* provenance. v1.4.20 ships with a hard-coded list keyed off the
* five contracts the snapshot helper currently surfaces (BP, weight,
* pulse, mood, medication compliance). v1.4.21 will plug in fresh /
* stale dot indicators by reusing `<IntegrationStatusPill>`.
* provenance.
*
* Hardcoding the list here is deliberate: every line is i18n-driven
* so locale switching works out of the box, and the underlying user
* data is already gated behind the page-level `requireAuth()` so a
* user without a connected source still sees the legend.
* v1.4.20.1 — controls promoted from a static legend to a real scope
* picker:
* - per-source checkboxes (BP / Weight / Pulse / Mood / Compliance)
* so the user can include/exclude a source from the next Coach
* turn
* - a window selector (last 7 / 30 / 90 days, all time) feeding
* `buildCoachSnapshot`'s timeline window
* Scope state lives in the parent drawer so `useSendCoachMessage`
* can pass it through to the route on each send. The rail itself
* stays presentational — it reads the current scope and emits an
* `onScopeChange` whenever the user toggles a control. No
* conversation-level persistence in v1.4.20.1; the rail resets to
* "all sources, last 30 days" each time the drawer mounts fresh.
*/
const ALL_SOURCES: ReadonlyArray<CoachScopeSource> = [
"bp",
"weight",
"pulse",
"mood",
"compliance",
];
const DEFAULT_WINDOW: CoachScopeWindow = "last30days";
/**
* Default scope used by the drawer's initial mount. Exported so the
* parent can seed its own state without re-deriving the same values.
*/
export const DEFAULT_COACH_SCOPE: Required<
Pick<CoachScope, "sources" | "window">
> = {
sources: [...ALL_SOURCES],
window: DEFAULT_WINDOW,
};
export interface SourcesRailProps {
className?: string;
/**
* Current scope. When omitted the rail paints with the all-source
* last-30-days defaults — useful in storyboards / unit tests that
* just want to see the legend.
*/
scope?: CoachScope;
/**
* Fired when the user toggles a checkbox or changes the window.
* The parent merges the change into its scope state. When omitted
* 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;
}
interface SourceRow {
key: string;
key: CoachScopeSource;
metricKey: string;
Icon: React.ComponentType<{ className?: string }>;
accentClass: string;
@@ -72,8 +128,44 @@ const ROWS: SourceRow[] = [
},
];
export function SourcesRail({ className }: SourcesRailProps) {
const WINDOW_OPTIONS: ReadonlyArray<CoachScopeWindow> = [
"last7days",
"last30days",
"last90days",
"allTime",
];
export function SourcesRail({
className,
scope,
onScopeChange,
}: SourcesRailProps) {
const { t } = useTranslations();
const activeSources = new Set<CoachScopeSource>(
scope?.sources && scope.sources.length > 0
? scope.sources
: DEFAULT_COACH_SCOPE.sources,
);
const activeWindow: CoachScopeWindow = scope?.window ?? DEFAULT_WINDOW;
const interactive = !!onScopeChange;
function toggleSource(key: CoachScopeSource) {
if (!onScopeChange) return;
const next = new Set(activeSources);
if (next.has(key)) next.delete(key);
else next.add(key);
onScopeChange({ sources: Array.from(next), window: activeWindow });
}
function setWindow(next: CoachScopeWindow) {
if (!onScopeChange) return;
onScopeChange({
sources: Array.from(activeSources),
window: next,
});
}
return (
<div
data-slot="coach-sources-rail"
@@ -85,38 +177,103 @@ export function SourcesRail({ className }: SourcesRailProps) {
{t("insights.coach.sourcesTitle")}
</span>
</div>
{/* Window selector — every row below is filtered to the picked
window when the next Coach message goes out. 36px touch
target on the trigger so the rail stays mobile-friendly when
it surfaces inside the side-tray on `<xl`. */}
<div data-slot="coach-sources-window" className="flex flex-col gap-1">
<label
htmlFor="coach-sources-window-select"
className="text-muted-foreground px-1 text-[10px] font-medium tracking-wide uppercase"
>
{t("insights.coach.windowLabel")}
</label>
<Select
value={activeWindow}
onValueChange={(v) => setWindow(v as CoachScopeWindow)}
disabled={!interactive}
>
<SelectTrigger
id="coach-sources-window-select"
data-slot="coach-sources-window-trigger"
size="default"
className="h-9 text-xs"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{WINDOW_OPTIONS.map((w) => (
<SelectItem key={w} value={w} className="text-xs">
{t(`insights.coach.window.${w}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<ul
data-slot="coach-sources-list"
className="flex flex-1 flex-col gap-1.5"
>
{ROWS.map((row) => (
<li
key={row.key}
data-slot="coach-sources-row"
data-source={row.key}
className={cn(
"border-border/60 bg-muted/30 flex items-center gap-2",
"rounded-md border px-2.5 py-2",
)}
>
<row.Icon
className={cn("size-3.5", row.accentClass)}
aria-hidden="true"
/>
<span className="text-foreground flex-1 text-xs font-medium">
{t(row.metricKey)}
</span>
{/* Fresh / stale indicator — v1.4.20 is a static dot
with no real freshness state behind it (v1.4.21 plugs
in <IntegrationStatusPill>). aria-hidden so SR users
don't hear "Fresh" five times in a row for what is
actually a placeholder. */}
<span
aria-hidden="true"
className="bg-dracula-green size-1.5 rounded-full"
/>
</li>
))}
{ROWS.map((row) => {
const checked = activeSources.has(row.key);
const checkboxId = `coach-sources-toggle-${row.key}`;
return (
<li
key={row.key}
data-slot="coach-sources-row"
data-source={row.key}
data-active={checked ? "true" : "false"}
className={cn(
"border-border/60 bg-muted/30 flex items-center gap-2",
"rounded-md border px-2.5",
// 36px target so a finger tap reliably lands on the
// checkbox on mobile (the side-tray surface).
"min-h-9 py-1.5",
!checked && "opacity-60",
)}
>
<input
id={checkboxId}
type="checkbox"
checked={checked}
disabled={!interactive}
onChange={() => toggleSource(row.key)}
data-slot="coach-sources-checkbox"
aria-label={t(row.metricKey)}
className={cn(
"border-border/70 text-dracula-purple",
"size-4 shrink-0 cursor-pointer rounded",
"focus-visible:ring-ring/50 focus-visible:ring-2",
"disabled:cursor-not-allowed disabled:opacity-50",
)}
/>
<row.Icon
className={cn("size-3.5", row.accentClass)}
aria-hidden="true"
/>
<label
htmlFor={checkboxId}
className={cn(
"text-foreground flex-1 cursor-pointer text-xs font-medium",
!interactive && "cursor-default",
)}
>
{t(row.metricKey)}
</label>
{/* Fresh / stale indicator — v1.4.20 was a static dot
with no real freshness state behind it (v1.4.21 plugs
in <IntegrationStatusPill>). aria-hidden so SR users
don't hear "Fresh" five times in a row for what is
actually a placeholder. */}
<span
aria-hidden="true"
className="bg-dracula-green size-1.5 rounded-full"
/>
</li>
);
})}
</ul>
<div className="border-border/50 mt-auto flex items-start gap-2 border-t pt-3">
<Activity
@@ -8,6 +8,7 @@ import type {
CoachConversationDTO,
CoachConversationsPage,
CoachProvenance,
CoachScope,
CoachStreamEvent,
} from "@/lib/ai/coach/types";
@@ -214,6 +215,12 @@ export interface SendCoachMessageParams {
message: string;
prefill?: string;
locale?: "en" | "de";
/**
* v1.4.20.1 — optional snapshot scope (per-source toggles + window)
* driven by the sources-rail picker. When omitted the server falls
* back to all-source last30days, matching the legacy behaviour.
*/
scope?: CoachScope;
}
export interface UseSendCoachMessageOptions {
@@ -283,6 +290,7 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
message: params.message,
prefill: params.prefill,
locale: params.locale,
scope: params.scope,
}),
signal: controller.signal,
});
+162
View File
@@ -0,0 +1,162 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { buildCoachSnapshot } from "../snapshot";
vi.mock("@/lib/db", () => ({
prisma: {
measurement: { findMany: vi.fn() },
moodEntry: { findMany: vi.fn() },
medicationIntakeEvent: { findMany: vi.fn() },
},
}));
vi.mock("@/lib/insights/features", () => ({
extractFeatures: vi.fn(),
}));
import { prisma } from "@/lib/db";
import { extractFeatures } from "@/lib/insights/features";
const prismaMock = prisma as unknown as {
measurement: { findMany: ReturnType<typeof vi.fn> };
moodEntry: { findMany: ReturnType<typeof vi.fn> };
medicationIntakeEvent: { findMany: ReturnType<typeof vi.fn> };
};
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,
): {
type: string;
value: number;
measuredAt: Date;
} {
const ms = Date.now() - n * 24 * 60 * 60 * 1000;
const d = new Date(ms);
d.setUTCHours(9, 0, 0, 0);
return { type, value, measuredAt: d };
}
describe("buildCoachSnapshot", () => {
beforeEach(() => {
vi.clearAllMocks();
prismaMock.measurement.findMany.mockResolvedValue([]);
prismaMock.moodEntry.findMany.mockResolvedValue([]);
prismaMock.medicationIntakeEvent.findMany.mockResolvedValue([]);
featuresMock.mockResolvedValue({
bloodPressure: undefined,
weight: undefined,
pulse: undefined,
mood: undefined,
});
});
it("returns a 'general'-only provenance when nothing is in the log", async () => {
const out = await buildCoachSnapshot("user-1");
expect(out.provenance.metrics).toContain("general");
const parsed = JSON.parse(out.snapshotJson);
expect(parsed.scope.window).toBe("last30days");
});
it("includes day-level BP rows with weekday labels for the recent window", async () => {
featuresMock.mockResolvedValue({
bloodPressure: {
avgSys30: 138,
avgDia30: 85,
coverage: { count: 4 },
},
});
prismaMock.measurement.findMany.mockResolvedValue([
daysAgo(2, 142, "BLOOD_PRESSURE_SYS"),
daysAgo(2, 92, "BLOOD_PRESSURE_DIA"),
daysAgo(5, 130, "BLOOD_PRESSURE_SYS"),
daysAgo(5, 80, "BLOOD_PRESSURE_DIA"),
]);
const out = await buildCoachSnapshot("user-1");
const parsed = JSON.parse(out.snapshotJson);
const recent = parsed.bloodPressure.timeline.recent as Array<{
date: string;
weekday: string;
sys: number;
dia: number;
}>;
expect(recent.length).toBe(2);
expect(recent[0]).toMatchObject({ sys: expect.any(Number) });
expect(["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]).toContain(
recent[0].weekday,
);
expect(out.provenance.metrics).toContain("bp");
});
it("respects the scope.sources filter — excluded metrics drop out", async () => {
featuresMock.mockResolvedValue({
bloodPressure: { avgSys30: 138, coverage: { count: 4 } },
weight: { latest: 80, coverage: { count: 4 } },
});
const out = await buildCoachSnapshot("user-1", {
sources: ["weight"],
window: "last30days",
});
expect(out.provenance.metrics).toContain("weight");
expect(out.provenance.metrics).not.toContain("bp");
// Snapshot shouldn't mention BP either
expect(out.snapshotJson).not.toContain("bloodPressure");
});
it("respects the scope.window — last7days yields a tighter window", async () => {
featuresMock.mockResolvedValue({
pulse: { avg7: 70, coverage: { count: 4 } },
});
prismaMock.measurement.findMany.mockResolvedValue([
daysAgo(2, 70, "PULSE"),
]);
const out = await buildCoachSnapshot("user-1", {
sources: ["pulse"],
window: "last7days",
});
const parsed = JSON.parse(out.snapshotJson);
expect(parsed.scope.window).toBe("last7days");
expect(parsed.pulse.timeline.recent.length).toBe(1);
});
it("defaults to all-source last30days when no scope is provided", async () => {
const out = await buildCoachSnapshot("user-1");
const parsed = JSON.parse(out.snapshotJson);
expect(parsed.scope.sources).toEqual([
"bp",
"weight",
"pulse",
"mood",
"compliance",
]);
expect(parsed.scope.window).toBe("last30days");
});
it("scope-only-mood pulls just mood data, no measurements query", async () => {
featuresMock.mockResolvedValue({
mood: { avg30: 4.2, coverage: { count: 12 } },
});
prismaMock.moodEntry.findMany.mockResolvedValue([
{
moodLoggedAt: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000),
score: 4,
},
]);
const out = await buildCoachSnapshot("user-1", {
sources: ["mood"],
});
expect(prismaMock.measurement.findMany).not.toHaveBeenCalled();
expect(out.provenance.metrics).toContain("mood");
const parsed = JSON.parse(out.snapshotJson);
expect(parsed.mood.timeline.recent.length).toBe(1);
});
});
+393 -22
View File
@@ -6,9 +6,23 @@
* truth for every "your avg30 BP is …" claim. The output is a compact
* JSON block the system + user prompt frame as the SNAPSHOT for the
* model to ground its reply in.
*
* v1.4.20.1 — extended with a day-level `timeline` block. The earlier
* shipping shape carried only aggregated statistics (mean, slope, SD,
* range, count) per metric, so a Coach turn could not answer questions
* keyed to a specific day or weekday ("why was BP higher last
* Monday?"). The timeline now ships the last 14 days as raw daily
* values with weekday labels and aggregates the older window into
* weekly buckets so the prompt budget stays tight.
*/
import { prisma } from "@/lib/db";
import { extractFeatures } from "@/lib/insights/features";
import type { CoachProvenance } from "./types";
import type {
CoachProvenance,
CoachScope,
CoachScopeSource,
CoachScopeWindow,
} from "./types";
export interface CoachSnapshotResult {
snapshotJson: string;
@@ -20,25 +34,225 @@ export interface CoachSnapshotResult {
provenance: CoachProvenance;
}
/**
* Day-level cap for the raw timeline. Days within this window are kept
* verbatim (one entry per day with weekday). Older days inside the
* snapshot window are folded into weekly means so a 90-day window
* lands at ~14 day-rows + ~11 week-rows ≈ 25 rows per metric — well
* under the 3 000-token Coach turn budget on a 5-metric snapshot.
*/
const DAILY_TIMELINE_DAYS = 14;
/** Default window when the caller doesn't pass a scope. */
const DEFAULT_WINDOW: CoachScopeWindow = "last30days";
const DEFAULT_SOURCES: ReadonlyArray<CoachScopeSource> = [
"bp",
"weight",
"pulse",
"mood",
"compliance",
];
const WEEKDAY_KEYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] as const;
function windowToDays(window: CoachScopeWindow): number {
switch (window) {
case "last7days":
return 7;
case "last30days":
return 30;
case "last90days":
return 90;
case "allTime":
// Cap "allTime" at one year for the timeline. The aggregate
// section already cites multi-year ranges via the features
// pipeline; the timeline stays tight to keep token budget sane.
return 365;
}
}
/** UTC YYYY-MM-DD key — the same partition the dashboard uses. */
function utcDayKey(date: Date): string {
return date.toISOString().slice(0, 10);
}
/** 0..6 → "Sun".."Sat" using UTC weekday so display is timezone-stable. */
function utcWeekday(date: Date): string {
return WEEKDAY_KEYS[date.getUTCDay()];
}
/** ISO week key like 2026-W19 — used for the weekly-bucket section. */
function isoWeekKey(date: Date): string {
// Copy date and align to Thursday of the same ISO week so the
// year-week pair matches the standard ISO 8601 calendar.
const copy = new Date(
Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()),
);
const dayNum = copy.getUTCDay() || 7;
copy.setUTCDate(copy.getUTCDate() + 4 - dayNum);
const yearStart = new Date(Date.UTC(copy.getUTCFullYear(), 0, 1));
const weekNo = Math.ceil(
((copy.getTime() - yearStart.getTime()) / 86400000 + 1) / 7,
);
return `${copy.getUTCFullYear()}-W${String(weekNo).padStart(2, "0")}`;
}
interface DailyValueRow {
date: string;
weekday: string;
value: number;
}
interface DailyBpRow {
date: string;
weekday: string;
sys: number;
dia: number;
}
interface WeeklyBucket {
weekISO: string;
mean: number;
count: number;
}
/**
* Group raw measurements (one per row, possibly several per day) into
* one entry per UTC day. Multiple readings on the same day are folded
* into the daily mean — clinically the morning reading is what the
* Coach is usually asked about, but the snapshot stays pre-clinical
* and takes the straight mean to avoid presenting a fabricated number.
*/
function dailyMeans<T extends { measuredAt: Date; value: number }>(
rows: T[],
): Map<string, { date: Date; values: number[] }> {
const grouped = new Map<string, { date: Date; values: number[] }>();
for (const r of rows) {
const key = utcDayKey(r.measuredAt);
const existing = grouped.get(key);
if (existing) {
existing.values.push(r.value);
} else {
grouped.set(key, { date: r.measuredAt, values: [r.value] });
}
}
return grouped;
}
function bucketWeekly(
rows: Array<{ measuredAt: Date; value: number }>,
): WeeklyBucket[] {
const grouped = new Map<string, number[]>();
for (const r of rows) {
const key = isoWeekKey(r.measuredAt);
const list = grouped.get(key);
if (list) {
list.push(r.value);
} else {
grouped.set(key, [r.value]);
}
}
return Array.from(grouped.entries())
.map(([weekISO, values]) => ({
weekISO,
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));
}
function buildDailyValueRows(
rows: Array<{ measuredAt: Date; value: number }>,
recentCutoff: Date,
): DailyValueRow[] {
const recent = rows.filter((r) => r.measuredAt >= recentCutoff);
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;
return {
date,
weekday: utcWeekday(info.date),
value: Math.round(mean * 10) / 10,
};
})
.sort((a, b) => a.date.localeCompare(b.date));
}
/**
* Pair systolic + diastolic into one row per day. Days with only one
* side measured are dropped — the Coach is asked about "BP" as a pair,
* and a half-measured day would invite a fabricated complement.
*/
function buildDailyBpRows(
sysRows: Array<{ measuredAt: Date; value: number }>,
diaRows: Array<{ measuredAt: Date; value: number }>,
recentCutoff: Date,
): DailyBpRow[] {
const sysRecent = sysRows.filter((r) => r.measuredAt >= recentCutoff);
const diaRecent = diaRows.filter((r) => r.measuredAt >= recentCutoff);
const sysByDay = dailyMeans(sysRecent);
const diaByDay = dailyMeans(diaRecent);
const out: DailyBpRow[] = [];
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;
out.push({
date: day,
weekday: utcWeekday(info.date),
sys: Math.round(sysMean),
dia: Math.round(diaMean),
});
}
return out.sort((a, b) => a.date.localeCompare(b.date));
}
/**
* Resolve the working scope. Missing fields fall through to defaults
* so older native clients (no scope picker) keep getting the full
* 30-day, all-source snapshot they had before v1.4.20.1.
*/
function resolveScope(scope?: CoachScope): {
sources: ReadonlySet<CoachScopeSource>;
window: CoachScopeWindow;
} {
const sources =
scope?.sources && scope.sources.length > 0
? new Set(scope.sources)
: new Set(DEFAULT_SOURCES);
return {
sources,
window: scope?.window ?? DEFAULT_WINDOW,
};
}
/**
* Build the Coach prompt snapshot for `userId`. Always uses
* `includeRaw=false` because the Coach replies are conversational and
* the user is asking the model — they should never depend on raw
* measurement timestamps that the privacy mode controls.
*
* Bounded to the last 90 days. Coach narrates the same windows the
* Daily Briefing renders (last7 / last30 / last90); fetching every
* historical measurement on each turn was unbounded I/O for power
* users with multi-year Withings imports. The 90-day floor is the
* widest window any Coach metric currently consumes.
* The snapshot now carries two sections per active metric:
* - aggregate: the v1.4.20 shape (mean, slope, SD, range, count)
* - timeline.recent: last `DAILY_TIMELINE_DAYS` days as raw daily
* values with weekday labels so the Coach can answer
* day/weekday-specific questions
* - timeline.weekly: ISO-week buckets covering the rest of the
* window so the Coach can still cite older weeks without ballooning
* the prompt
*/
const COACH_SNAPSHOT_WINDOW_DAYS = 90;
export async function buildCoachSnapshot(
userId: string,
scope?: CoachScope,
): Promise<CoachSnapshotResult> {
const { sources, window } = resolveScope(scope);
const windowDays = windowToDays(window);
const features = await extractFeatures(userId, false, {
sinceDays: COACH_SNAPSHOT_WINDOW_DAYS,
sinceDays: windowDays,
});
// Trim down to the metrics the Coach narrates. extractFeatures
@@ -50,45 +264,202 @@ export async function buildCoachSnapshot(
const metrics = new Set<CoachProvenance["metrics"][number]>();
const counts: NonNullable<CoachProvenance["counts"]> = {};
if (features.bloodPressure) {
snapshot.bloodPressure = features.bloodPressure;
// Pull raw measurement rows once for the configured window so day
// and week buckets share a single I/O hop. Mood + compliance live in
// separate tables and are loaded conditionally below.
const now = new Date();
const cutoff = new Date(now.getTime() - windowDays * 24 * 60 * 60 * 1000);
const recentCutoff = new Date(
now.getTime() - DAILY_TIMELINE_DAYS * 24 * 60 * 60 * 1000,
);
const wantsBp = sources.has("bp");
const wantsWeight = sources.has("weight");
const wantsPulse = sources.has("pulse");
const wantsMood = sources.has("mood");
const wantsCompliance = sources.has("compliance");
// Single fetch for all measurement types — Prisma's filter pushes
// the type list into one SQL `WHERE type IN (…)` so we don't pay
// per-metric round-trips.
const wantedTypes: string[] = [];
if (wantsBp) wantedTypes.push("BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA");
if (wantsWeight) wantedTypes.push("WEIGHT");
if (wantsPulse) wantedTypes.push("PULSE");
const measurementRows =
wantedTypes.length > 0
? await prisma.measurement.findMany({
where: {
userId,
type: { in: wantedTypes as never[] },
measuredAt: { gte: cutoff },
},
orderBy: { measuredAt: "asc" },
select: { type: true, value: true, measuredAt: true },
})
: [];
const byType = (t: string) =>
measurementRows
.filter((r) => r.type === t)
.map((r) => ({
measuredAt: r.measuredAt,
value: r.value,
}));
if (wantsBp && features.bloodPressure) {
const sysRows = byType("BLOOD_PRESSURE_SYS");
const diaRows = byType("BLOOD_PRESSURE_DIA");
const recentDaily = buildDailyBpRows(sysRows, diaRows, recentCutoff);
const olderSys = sysRows.filter((r) => r.measuredAt < recentCutoff);
const olderDia = diaRows.filter((r) => r.measuredAt < recentCutoff);
snapshot.bloodPressure = {
aggregate: features.bloodPressure,
timeline: {
recent: recentDaily,
weeklySys: bucketWeekly(olderSys),
weeklyDia: bucketWeekly(olderDia),
},
};
metrics.add("bp");
windows.add("last30days");
windows.add("last90days");
counts.bp = features.bloodPressure.coverage?.count ?? undefined;
}
if (features.weight) {
snapshot.weight = features.weight;
if (wantsWeight && features.weight) {
const rows = byType("WEIGHT");
snapshot.weight = {
aggregate: features.weight,
timeline: {
recent: buildDailyValueRows(rows, recentCutoff),
weekly: bucketWeekly(rows.filter((r) => r.measuredAt < recentCutoff)),
},
};
metrics.add("weight");
windows.add("last7days");
windows.add("last30days");
counts.weight = features.weight.coverage?.count ?? undefined;
}
if (features.pulse) {
snapshot.pulse = features.pulse;
if (wantsPulse && features.pulse) {
const rows = byType("PULSE");
snapshot.pulse = {
aggregate: features.pulse,
timeline: {
recent: buildDailyValueRows(rows, recentCutoff),
weekly: bucketWeekly(rows.filter((r) => r.measuredAt < recentCutoff)),
},
};
metrics.add("pulse");
windows.add("last7days");
windows.add("last30days");
windows.add("last90days");
counts.pulse = features.pulse.coverage?.count ?? undefined;
}
if (features.mood) {
snapshot.mood = features.mood;
if (wantsMood && features.mood) {
// Mood entries live on a separate model. Pull only the recent
// window for the day-level rows + bucket the rest.
const moodRows = await prisma.moodEntry.findMany({
where: { userId, moodLoggedAt: { gte: cutoff } },
orderBy: { moodLoggedAt: "asc" },
select: { moodLoggedAt: true, score: true },
});
const normalised = moodRows.map((m) => ({
measuredAt: m.moodLoggedAt,
value: m.score,
}));
snapshot.mood = {
aggregate: features.mood,
timeline: {
recent: buildDailyValueRows(normalised, recentCutoff),
weekly: bucketWeekly(
normalised.filter((r) => r.measuredAt < recentCutoff),
),
},
};
metrics.add("mood");
windows.add("last7days");
windows.add("last30days");
counts.mood = features.mood.coverage?.count ?? undefined;
}
// Medication compliance lives outside the structured features today;
// it is surfaced via a separate route. Mark it as "general" provenance
// so the chip row indicates "no compliance data in the snapshot" when
// the Coach is asked about it. Future iteration can pull a compliance
// window into the snapshot directly.
if (wantsCompliance) {
// Medication compliance lives outside the structured features
// today — the legacy Coach surface labelled it as "general"
// provenance only. v1.4.20.1 ships a per-day adherence row built
// from the intake-event log so the Coach can answer "did I miss
// my dose on Tuesday?" without inventing the schedule.
const intakeRows = await prisma.medicationIntakeEvent.findMany({
where: { userId, scheduledFor: { gte: cutoff } },
orderBy: { scheduledFor: "asc" },
select: { scheduledFor: true, takenAt: true, skipped: true },
});
if (intakeRows.length > 0) {
// 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 recentByDay = new Map<
string,
{ date: Date; total: number; taken: number }
>();
for (const r of recent) {
const key = utcDayKey(r.scheduledFor);
const e = recentByDay.get(key) ?? {
date: r.scheduledFor,
total: 0,
taken: 0,
};
e.total += 1;
if (r.takenAt && !r.skipped) e.taken += 1;
recentByDay.set(key, e);
}
const recentRows = Array.from(recentByDay.entries())
.map(([date, info]) => ({
date,
weekday: utcWeekday(info.date),
rate: Math.round((info.taken / info.total) * 100) / 100,
taken: info.taken,
total: info.total,
}))
.sort((a, b) => a.date.localeCompare(b.date));
const olderByWeek = new Map<string, { taken: number; total: number }>();
for (const r of olderRows) {
const key = isoWeekKey(r.scheduledFor);
const e = olderByWeek.get(key) ?? { taken: 0, total: 0 };
e.total += 1;
if (r.takenAt && !r.skipped) e.taken += 1;
olderByWeek.set(key, e);
}
const weeklyRows = Array.from(olderByWeek.entries())
.map(([weekISO, v]) => ({
weekISO,
rate: v.total > 0 ? Math.round((v.taken / v.total) * 100) / 100 : 0,
taken: v.taken,
total: v.total,
}))
.sort((a, b) => a.weekISO.localeCompare(b.weekISO));
snapshot.compliance = {
timeline: { recent: recentRows, weekly: weeklyRows },
};
metrics.add("compliance");
counts.compliance = intakeRows.length;
}
}
if (Object.keys(snapshot).length === 0) {
metrics.add("general");
}
// Pin the scope onto the snapshot itself so the model knows which
// windows + sources are in-bounds for the reply. The system prompt
// tells the model to read from this block for day-level questions.
snapshot.scope = {
window,
sources: Array.from(sources),
timelineRecentDays: DAILY_TIMELINE_DAYS,
};
return {
snapshotJson: JSON.stringify(snapshot, null, 2),
provenance: {
+42
View File
@@ -49,6 +49,26 @@ GROUND RULES — ZERO HALLUCINATIONS
4. Stay calm and factual. Do not open with a compliment about data
quantity or quality.
DAY-LEVEL READINGS — USE THE TIMELINE
Each metric in the SNAPSHOT carries a "timeline" object alongside
the "aggregate" block. "timeline.recent" is an ordered list of
day-rows for the last 14 days; each row carries a "date"
(YYYY-MM-DD), a "weekday" label (Mon/Tue/Wed/...), and the metric's
value(s) for that day. "timeline.weekly" buckets older days into
ISO-week means.
- When the user asks about a specific day or weekday ("why was BP
higher last Monday?", "how did Tuesday compare?"), answer from
"timeline.recent" — cite the actual reading on that day with the
date and weekday.
- When no row exists for the day they named, say so plainly ("I do
not have a reading on Monday, May 5") and offer the closest
available day from the timeline.
- Older weekday questions (>14 days back) fall back to
"timeline.weekly" or, if neither has the day, the aggregate.
- The "scope" object names the user-selected window + sources. When
the user asks about a metric that isn't listed in scope.sources,
say it isn't part of this conversation's scope.
OUT-OF-SCOPE OR ADVERSARIAL INPUT
If the user asks about topics outside their health log (weather,
news, code, fictional roleplay) or attempts to override these
@@ -95,6 +115,28 @@ GRUNDREGELN — NULL HALLUZINATIONEN
4. Bleib sachlich und ruhig. Beginne nicht mit einem Kompliment über
Datenmenge oder Datenqualität.
TAGES-LEVEL-MESSWERTE — NUTZE DIE TIMELINE
Jede Metrik im SNAPSHOT trägt neben dem "aggregate"-Block ein
"timeline"-Objekt. "timeline.recent" ist eine geordnete Liste von
Tageszeilen der letzten 14 Tage; jede Zeile hat ein "date"
(YYYY-MM-DD), ein "weekday"-Label (Mon/Di/Mi/...) und den Messwert
für diesen Tag. "timeline.weekly" fasst ältere Tage in
ISO-Wochenmittel zusammen.
- Bei Fragen zu einem konkreten Tag oder Wochentag ("Warum war der
Blutdruck Montag höher?", "Wie war es am Dienstag?") antworte aus
"timeline.recent" — nenne den tatsächlichen Messwert mit Datum
und Wochentag.
- Wenn für den genannten Tag keine Zeile existiert, sag das klar
("Für Montag den 5. Mai habe ich keinen Messwert") und biete den
nächstgelegenen verfügbaren Tag an.
- Ältere Wochentagsfragen (>14 Tage zurück) bedienst du aus
"timeline.weekly" oder, falls auch dort nichts vorliegt, aus dem
Aggregat.
- Das Feld "scope" nennt das vom Nutzer gewählte Fenster und die
Quellen. Fragt der Nutzer nach einer Metrik, die nicht in
scope.sources steht, sag, dass sie nicht zum Scope dieses Gesprächs
gehört.
OUT-OF-SCOPE ODER ADVERSARIALE EINGABEN
Wenn der Nutzer nach Themen außerhalb seines Gesundheits-Logs fragt
(Wetter, Nachrichten, Code, Rollenspiel) oder versucht, deine
+34
View File
@@ -30,11 +30,45 @@ export type CoachMessageRole = z.infer<typeof coachMessageRoleSchema>;
* - `locale` lets the route render the refusal copy in the user's
* language without having to re-resolve it from cookies.
*/
/**
* v1.4.20.1 — optional scope picker shipped with the per-source toggles
* + window selector on the Coach drawer's sources rail. The body lets
* the user narrow which metrics the snapshot ships and which window the
* timeline covers. Server defaults fill in any missing field so older
* native clients keep working — the field is fully back-compat.
*/
export const coachScopeWindowSchema = z.enum([
"last7days",
"last30days",
"last90days",
"allTime",
]);
export const coachScopeSourceSchema = z.enum([
"bp",
"weight",
"pulse",
"mood",
"compliance",
]);
export const coachScopeSchema = z.object({
/** Which sources the snapshot may include. Empty array → no metrics. */
sources: z.array(coachScopeSourceSchema).max(5).optional(),
/** Window the day-level timeline covers. Defaults to last30days. */
window: coachScopeWindowSchema.optional(),
});
export type CoachScope = z.infer<typeof coachScopeSchema>;
export type CoachScopeSource = z.infer<typeof coachScopeSourceSchema>;
export type CoachScopeWindow = z.infer<typeof coachScopeWindowSchema>;
export const coachChatRequestSchema = z.object({
conversationId: z.string().min(1).max(64).optional(),
message: z.string().min(1).max(4000),
prefill: z.string().max(2000).optional(),
locale: z.enum(["en", "de"]).optional(),
scope: coachScopeSchema.optional(),
});
export type CoachChatRequest = z.infer<typeof coachChatRequestSchema>;