From b6ce54e2c6af1026322312f49c85b4a6428dc49e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 5 Oct 2026 21:05:13 +0200 Subject: [PATCH 01/19] fix(coach): drop the search field from the conversations panel The panel lists the history grouped by recency with load-more, rename and delete; title search stays on the full /coach/conversations page. --- e2e/coach-layout.spec.ts | 7 ++-- .../__tests__/history-rail.test.tsx | 8 ++--- .../insights/coach-panel/coach-drawer.tsx | 2 +- .../coach-panel/conversations-panel.tsx | 5 ++- .../insights/coach-panel/history-rail.tsx | 36 ++++--------------- 5 files changed, 18 insertions(+), 40 deletions(-) diff --git a/e2e/coach-layout.spec.ts b/e2e/coach-layout.spec.ts index a4b432ab8..d13cf9965 100644 --- a/e2e/coach-layout.spec.ts +++ b/e2e/coach-layout.spec.ts @@ -472,7 +472,7 @@ test.describe("Coach page frame", () => { expect(Math.abs(closed.composer - open.composer)).toBeLessThanOrEqual(1); // Closed, nothing inside the panel is reachable. await expect( - panel(page).locator('[data-slot="coach-history-search"]'), + panel(page).locator('[data-slot="coach-history-select"]').first(), ).not.toBeInViewport(); await expectNoSidewaysScroll(page); await shot(page, testInfo, `coach-frame-${width}-closed`); @@ -484,7 +484,10 @@ test.describe("Coach page frame", () => { // Open again, then Escape from inside closes it, focus to the toggle. await toggle(page).click(); await expect(panel(page)).toHaveAttribute("data-state", "open"); - await panel(page).locator('[data-slot="coach-history-search"]').focus(); + await panel(page) + .locator('[data-slot="coach-history-select"]') + .first() + .focus(); await page.keyboard.press("Escape"); await expect(panel(page)).toHaveAttribute("data-state", "closed"); await expect(toggle(page)).toBeFocused(); diff --git a/src/components/insights/coach-panel/__tests__/history-rail.test.tsx b/src/components/insights/coach-panel/__tests__/history-rail.test.tsx index 1dea34a3d..121e67ccd 100644 --- a/src/components/insights/coach-panel/__tests__/history-rail.test.tsx +++ b/src/components/insights/coach-panel/__tests__/history-rail.test.tsx @@ -141,15 +141,15 @@ describe("", () => { expect(html).toContain("Your conversations will appear here."); }); - it("mounts the search input with the localised placeholder", () => { + it("carries no search field (search lives on the full conversations page)", () => { const client = makeClientWithConversations(samplePage); const html = render( {}} />, client, ); - expect(html).toMatch( - /data-slot="coach-history-search"[^>]*placeholder="Search conversations…"/, - ); + expect(html).toContain('data-slot="coach-history-list"'); + expect(html).not.toContain('type="search"'); + expect(html).not.toContain("Search conversations"); }); it("uses German strings when locale is 'de'", () => { diff --git a/src/components/insights/coach-panel/coach-drawer.tsx b/src/components/insights/coach-panel/coach-drawer.tsx index a79708cb2..c80697cb3 100644 --- a/src/components/insights/coach-panel/coach-drawer.tsx +++ b/src/components/insights/coach-panel/coach-drawer.tsx @@ -176,7 +176,7 @@ export function CoachDrawer({ // v1.21.4 (Coach-UI B) — the drawer's "Conversations" affordance now hands // off to the dedicated conversation-history page (`/coach/conversations`), - // which renders the search + recency-grouped list as a sibling of the + // which renders the recency-grouped list as a sibling of the // Coach page. The former `?view=conversations` in-page slide-in drawer is // gone; selecting a row there routes back to `/coach?c=`. const handleOpenConversations = useCallback(() => { diff --git a/src/components/insights/coach-panel/conversations-panel.tsx b/src/components/insights/coach-panel/conversations-panel.tsx index eb756f365..381d2202b 100644 --- a/src/components/insights/coach-panel/conversations-panel.tsx +++ b/src/components/insights/coach-panel/conversations-panel.tsx @@ -366,9 +366,8 @@ export function ConversationsPanel({ data-slot="coach-conversations-panel" className="w-[22rem] max-w-[90vw] gap-0 p-0 sm:max-w-[22rem]" onOpenAutoFocus={(event) => { - // Land on the sheet itself, not its first control: on a phone - // focusing the search field would raise the keyboard over the - // list the person opened the sheet to read. + // Land on the sheet itself, not its first control, so opening + // the sheet does not paint a focus ring on a header button. event.preventDefault(); (event.currentTarget as HTMLElement | null)?.focus(); }} diff --git a/src/components/insights/coach-panel/history-rail.tsx b/src/components/insights/coach-panel/history-rail.tsx index b19e423fe..44502d152 100644 --- a/src/components/insights/coach-panel/history-rail.tsx +++ b/src/components/insights/coach-panel/history-rail.tsx @@ -8,7 +8,6 @@ import { Paperclip, Pencil, RotateCw, - Search, Trash2, } from "lucide-react"; import { toast } from "sonner"; @@ -20,10 +19,8 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; -import { Input } from "@/components/ui/input"; import { cn } from "@/lib/utils"; import { useTranslations } from "@/lib/i18n/context"; -import { useDebouncedValue } from "@/hooks/use-debounced-value"; import { useLoadMoreSentinel } from "@/hooks/use-load-more-sentinel"; import type { CoachConversationDTO } from "@/lib/ai/coach/types"; import { groupConversationsByRecency } from "@/lib/insights/coach-conversation-groups"; @@ -36,14 +33,14 @@ import { } from "./use-coach"; /** - * The Coach's conversation list: search, the full history grouped by recency + * The Coach's conversation list: the full history grouped by recency * (Today / Yesterday / This week / Earlier), and one row per conversation with * a "⋯" menu for Rename and Delete. * * It fills the Coach page's conversations panel and the drawer's history * tray. The history is cursor-paginated (`useInfiniteQuery`, loaded as the - * list scrolls near its end) and searched server-side on the title, debounced - * 200 ms. + * list scrolls near its end). Title search lives on the standalone + * `/coach/conversations` page, not here. * * Rename edits the title in place (`ConversationRename`, Enter saves, Escape * cancels without closing the panel around it) and returns focus to the row's @@ -88,8 +85,6 @@ export function HistoryRail({ onUndoDeleteActive, }: HistoryRailProps) { const { t } = useTranslations(); - const [filter, setFilter] = useState(""); - const debouncedFilter = useDebouncedValue(filter, 200); const { conversations, isLoading, @@ -98,19 +93,17 @@ export function HistoryRail({ hasNextPage, isFetchingNextPage, fetchNextPage, - } = useCoachConversationHistory({ search: debouncedFilter }); + } = useCoachConversationHistory(); const ownDeletion = useDeleteCoachConversationWithUndo(); const { pendingDeleteIds, requestDelete, undoDelete } = deletion ?? ownDeletion; - // `pendingDeleteIds` hides rows inside the undo window; the server-side - // search already resolved everything else. + // `pendingDeleteIds` hides rows inside the undo window. const visible = useMemo( () => conversations.filter((c) => !pendingDeleteIds.has(c.id)), [conversations, pendingDeleteIds], ); const groups = useMemo(() => groupConversationsByRecency(visible), [visible]); - const isSearching = debouncedFilter.trim().length > 0; // A `useState`-backed callback ref so the sentinel hook re-runs once the // scroll container exists (null on the very first render). @@ -153,21 +146,6 @@ export function HistoryRail({ {t("insights.coach.historyTitle")} )} -
-
- {isSearching - ? t("insights.coach.historySearchEmpty") - : t("insights.coach.historyEmpty")} + {t("insights.coach.historyEmpty")}

) : ( groups.map((group) => ( From bed47387decaf765f600c0d3d6a234f0d9e13167 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 5 Oct 2026 21:08:48 +0200 Subject: [PATCH 02/19] fix(ui): show text-field focus as a neutral border, not a purple ring Every text-entry control (Input, Textarea, Select and NativeSelect triggers, the date and time fields, the calendar's dropdowns, the Coach composer and the hand-built note fields) now marks focus by turning its border the new neutral input-focus tone, about 5:1 on the dark background and 4:1 on the light one, with no ring. A text field matches :focus-visible on every mouse click, so the ring was on for everyone. An invalid field keeps its red border and draws its destructive ring only while focused. Buttons, links, checkboxes and switches keep their rings. text-field-focus-guard.test.ts fails when a text-field class string brings a focus ring or a primary/ring-coloured focus border back. --- src/__tests__/text-field-focus-guard.test.ts | 121 ++++++++++++++++++ src/app/globals.css | 6 + src/components/cycle/cycle-settings.tsx | 2 +- .../documents/document-detail-sheet.tsx | 2 +- .../insights/coach-panel/coach-input.tsx | 2 +- .../records/about-me-note-manager.tsx | 2 +- .../records/allergy-free-text-note.tsx | 2 +- src/components/records/conditions-manager.tsx | 2 +- .../ui/__tests__/date-field.test.tsx | 12 +- .../ui/__tests__/time-field.test.tsx | 8 +- src/components/ui/calendar.tsx | 2 +- src/components/ui/date-field.tsx | 6 +- src/components/ui/input.tsx | 2 +- src/components/ui/native-select.tsx | 2 +- src/components/ui/select.tsx | 2 +- src/components/ui/textarea.tsx | 2 +- src/components/ui/time-field.tsx | 9 +- 17 files changed, 156 insertions(+), 28 deletions(-) create mode 100644 src/__tests__/text-field-focus-guard.test.ts diff --git a/src/__tests__/text-field-focus-guard.test.ts b/src/__tests__/text-field-focus-guard.test.ts new file mode 100644 index 000000000..061bf0eba --- /dev/null +++ b/src/__tests__/text-field-focus-guard.test.ts @@ -0,0 +1,121 @@ +/** + * Structural guard on the focus treatment of text-entry fields. + * + * A text field shows focus by its border alone: `border-input` turns the + * neutral `input-focus` tone (a foreground-based grey, at least 3:1 on the + * background in both themes). No colour ring, no purple. The ring was the + * loudest thing on a form, and for a text field it is on for every mouse + * click too, because `:focus-visible` always matches a field that takes + * typed input. Buttons, links, checkboxes and switches keep their rings; + * they are not in scope here. + * + * The one ring a field still draws is the destructive one on an invalid + * field that has focus (`aria-invalid:focus-visible:ring-[3px]`): its border + * is already red, so the ring is what tells focus apart there. + * + * Two checks: + * + * 1. Every text-entry primitive (and the Coach composer) uses the token. + * 2. Every class string in `src/` that styles a text field (it names + * `border-input`, the focus token, or a `placeholder:` colour) carries no focus ring and no + * primary/ring-coloured focus border. The sweep must find a floor of + * such strings, so a matcher that stops matching fails instead of + * passing on nothing. + */ +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { join, relative } from "node:path"; + +import { describe, expect, it } from "vitest"; + +const ROOT = join(__dirname, "..", ".."); +const SRC = join(ROOT, "src"); + +/** Text-entry primitives and the hand-built text fields that own a focus style. */ +const TEXT_FIELD_FILES = [ + "src/components/ui/input.tsx", + "src/components/ui/textarea.tsx", + "src/components/ui/select.tsx", + "src/components/ui/native-select.tsx", + "src/components/ui/date-field.tsx", + "src/components/ui/time-field.tsx", + "src/components/ui/calendar.tsx", + "src/components/insights/coach-panel/coach-input.tsx", + "src/components/cycle/cycle-settings.tsx", + "src/components/records/about-me-note-manager.tsx", + "src/components/records/conditions-manager.tsx", + "src/components/records/allergy-free-text-note.tsx", +]; + +const FOCUS_VARIANT = /(^|:)(focus|focus-visible|focus-within|has-focus)(:|$)/; +const BANNED_UTILITY = + /^(ring(-\[[^\]]+\]|-\d+)?$|ring-(ring|primary|offset)|border-(ring|primary)|outline-(ring|primary))/; + +/** Focus tokens in a class string that put a ring or a colour border on focus. */ +function bannedFocusTokens(classString: string): string[] { + return classString.split(/\s+/).filter((token) => { + if (!token.includes(":")) return false; + const parts = token.split(":"); + const utility = parts.pop()!; + const variants = parts.join(":"); + if (!FOCUS_VARIANT.test(variants)) return false; + // The invalid field's destructive focus ring is the documented exception. + if (/(^|:)aria-invalid(:|$)/.test(variants)) return false; + return BANNED_UTILITY.test(utility); + }); +} + +function walk(dir: string, out: string[] = []): string[] { + for (const name of readdirSync(dir)) { + const path = join(dir, name); + if (name === "generated" || name === "__tests__") continue; + if (statSync(path).isDirectory()) walk(path, out); + else if (/\.(ts|tsx)$/.test(name) && !/\.test\.tsx?$/.test(name)) + out.push(path); + } + return out; +} + +const STRING_LITERAL = /"([^"\n]*)"|`([^`]*)`|'([^'\n]*)'/g; +const TEXT_FIELD_MARKER = + /(^|\s)(border-input|placeholder:text-)|border-input-focus/; + +describe("text-field focus", () => { + it("the detector flags a ring and a purple border, and spares the exceptions", () => { + expect( + bannedFocusTokens( + "focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-within:border-primary/50 focus-within:ring-2 has-focus:border-ring focus-visible:ring-offset-2", + ), + ).toHaveLength(6); + expect( + bannedFocusTokens( + "focus-visible:border-input-focus aria-invalid:focus-visible:ring-[3px] focus-visible:outline-none aria-invalid:ring-destructive/20", + ), + ).toEqual([]); + }); + + it.each(TEXT_FIELD_FILES)("%s uses the neutral focus border", (file) => { + const source = readFileSync(join(ROOT, file), "utf8"); + expect(source).toContain("border-input-focus"); + expect(source).not.toMatch(/ring-primary/); + }); + + it("no class string that styles a text field draws a focus ring", () => { + const offenders: string[] = []; + let fieldStrings = 0; + for (const file of walk(SRC)) { + const source = readFileSync(file, "utf8"); + for (const match of source.matchAll(STRING_LITERAL)) { + const literal = match[1] ?? match[2] ?? match[3] ?? ""; + if (!TEXT_FIELD_MARKER.test(literal)) continue; + fieldStrings++; + const bad = bannedFocusTokens(literal); + if (bad.length > 0) + offenders.push(`${relative(ROOT, file)}: ${bad.join(" ")}`); + } + } + // The primitives alone account for more than this; fewer means the + // matcher went blind, not that the fields went away. + expect(fieldStrings).toBeGreaterThanOrEqual(10); + expect(offenders).toEqual([]); + }); +}); diff --git a/src/app/globals.css b/src/app/globals.css index b00af340d..5946209ec 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -56,6 +56,7 @@ --color-chart-1: var(--chart-1); --color-ring: var(--ring); --color-input: var(--input); + --color-input-focus: var(--input-focus); --color-border: var(--border); --color-destructive: var(--destructive); --color-accent-foreground: var(--accent-foreground); @@ -241,6 +242,9 @@ --destructive: #ff5555; --border: #44475a; --input: #44475a; + /* Text-field focus border: a neutral, foreground-based tone, no colour + * ring. ~5.2:1 on --background, ~6:1 on --card. */ + --input-focus: #9a9b9d; --ring: #bd93f9; --chart-1: #bd93f9; --chart-2: #50fa7b; @@ -369,6 +373,8 @@ --success-foreground: #ffffff; --border: oklch(0.885 0.008 300); --input: oklch(0.87 0.009 300); + /* Text-field focus border, neutral: ~4.2:1 on --background. */ + --input-focus: oklch(0.56 0.012 295); --ring: oklch(0.6 0.13 292); --chart-1: #644ac9; --chart-2: #14720a; diff --git a/src/components/cycle/cycle-settings.tsx b/src/components/cycle/cycle-settings.tsx index ce0aec3dd..30d0dc20e 100644 --- a/src/components/cycle/cycle-settings.tsx +++ b/src/components/cycle/cycle-settings.tsx @@ -371,7 +371,7 @@ function PriorField({ max={max} value={value} onChange={(e) => onChange(e.target.value)} - className="border-input bg-background focus-visible:ring-ring/50 w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:outline-none" + className="border-input bg-background focus-visible:border-input-focus w-full rounded-md border px-3 py-2 text-sm focus-visible:outline-none" />
); diff --git a/src/components/documents/document-detail-sheet.tsx b/src/components/documents/document-detail-sheet.tsx index da20cedda..396e1db28 100644 --- a/src/components/documents/document-detail-sheet.tsx +++ b/src/components/documents/document-detail-sheet.tsx @@ -989,7 +989,7 @@ export function DocumentDetailSheet({ setTitleDraft(doc.title ?? ""); setEditingTitle(true); }} - className="border-input hover:bg-muted/50 focus-visible:ring-ring/50 flex min-h-10 w-full items-center justify-between gap-2 rounded-md border px-3 py-2 text-left text-sm focus-visible:ring-[3px] focus-visible:outline-none" + className="border-input hover:bg-muted/50 focus-visible:border-input-focus flex min-h-10 w-full items-center justify-between gap-2 rounded-md border px-3 py-2 text-left text-sm focus-visible:outline-none" > ", () => { expect(html).toContain('aria-label="Open date picker"'); }); - it("uses the Input focus vocabulary — a ring that hugs the box, no offset", () => { - // Drift guard: the field must share the app's focus ring (soft 3 px, no - // offset) so the highlight never paints wider than the div or jumps on - // focus. The old hard `ring-offset-2` ring is banned here. + it("uses the Input focus vocabulary — a neutral border, no ring", () => { + // Drift guard: the field shares the app's text-field focus (the border + // turns the neutral input-focus tone, no ring), so the highlight never + // paints wider than the div or jumps on focus. const html = render(); - expect(html).toContain("focus-within:ring-ring/50"); - expect(html).toContain("focus-within:ring-[3px]"); + expect(html).toContain("focus-within:border-input-focus"); + expect(html).not.toMatch(/focus-within:ring-/); expect(html).not.toContain("ring-offset-2"); expect(html).not.toContain("ring-offset-background"); }); diff --git a/src/components/ui/__tests__/time-field.test.tsx b/src/components/ui/__tests__/time-field.test.tsx index ed155c726..f5f406687 100644 --- a/src/components/ui/__tests__/time-field.test.tsx +++ b/src/components/ui/__tests__/time-field.test.tsx @@ -132,12 +132,12 @@ describe(" SSR", () => { expect(html).toContain("min-h-11"); expect(html).toContain("sm:h-10"); }); - it("uses the Input focus vocabulary — a ring that hugs the box, no offset", () => { - // Drift guard shared with : soft 3 px ring, no offset, so the + it("uses the Input focus vocabulary — a neutral border, no ring", () => { + // Drift guard shared with : neutral border, no ring, so the // highlight never overflows the div or jumps on focus. const html = render(, "de"); - expect(html).toContain("focus-within:ring-ring/50"); - expect(html).toContain("focus-within:ring-[3px]"); + expect(html).toContain("focus-within:border-input-focus"); + expect(html).not.toMatch(/focus-within:ring-/); expect(html).not.toContain("ring-offset-2"); expect(html).not.toContain("ring-offset-background"); }); diff --git a/src/components/ui/calendar.tsx b/src/components/ui/calendar.tsx index b0721b904..f5fb159d5 100644 --- a/src/components/ui/calendar.tsx +++ b/src/components/ui/calendar.tsx @@ -74,7 +74,7 @@ function Calendar({ defaultClassNames.dropdowns, ), dropdown_root: cn( - "relative rounded-md border border-input shadow-xs has-focus:border-ring has-focus:ring-[3px] has-focus:ring-ring/50", + "relative rounded-md border border-input shadow-xs has-focus:border-input-focus", defaultClassNames.dropdown_root, ), dropdown: cn( diff --git a/src/components/ui/date-field.tsx b/src/components/ui/date-field.tsx index 42e3ab896..199cec69b 100644 --- a/src/components/ui/date-field.tsx +++ b/src/components/ui/date-field.tsx @@ -56,15 +56,15 @@ const FIELD_HEIGHT_CLASSES = "min-h-11 h-11 sm:min-h-10 sm:h-10"; // ~230 px no matter what width the parent hands it, and the surplus spills out // of whatever dialog or sheet it sits in. See the matching note on the overlay // input below; both ends are needed, one alone still floors the box. -// The focus vocabulary matches `` exactly: a soft translucent 3 px ring -// that hugs the border box with no offset. The earlier `ring-2 ring-offset-2` +// The focus vocabulary matches `` exactly: the border turns the neutral +// `input-focus` tone, with no ring. The earlier `ring-2 ring-offset-2` // painted a hard 4 px ring OUTSIDE the box — wider than the field's div and // visibly mismatched next to the app's other inputs, and it shifted the // highlight box on focus. `text-base md:text-sm` mirrors `` too: a // sub-16 px input triggers iOS Safari's focus auto-zoom, which reads as a // layout jump; 16 px below `md:` suppresses it. const FIELD_BASE_CLASSES = - "border-input bg-background text-foreground placeholder:text-muted-foreground relative flex w-full min-w-0 items-center rounded-md border ps-3 pe-2 text-base md:text-sm shadow-xs transition-[color,box-shadow] focus-within:border-ring focus-within:ring-ring/50 focus-within:ring-[3px] focus-within:outline-none"; + "border-input bg-background text-foreground placeholder:text-muted-foreground relative flex w-full min-w-0 items-center rounded-md border ps-3 pe-2 text-base md:text-sm shadow-xs transition-[color,box-shadow] focus-within:border-input-focus focus-within:outline-none"; export interface DateFieldProps { id?: string; diff --git a/src/components/ui/input.tsx b/src/components/ui/input.tsx index a00531658..ed2a3bcf0 100644 --- a/src/components/ui/input.tsx +++ b/src/components/ui/input.tsx @@ -77,7 +77,7 @@ function Input({ // v1.4.34 IW-G — floor at 44 px on mobile to clear WCAG 2.5.5; // shrink to 40 px on sm+ where pointer precision is higher. "file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-11 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 sm:h-10 md:text-sm", - "focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]", + "focus-visible:border-input-focus aria-invalid:focus-visible:ring-[3px]", "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", className, )} diff --git a/src/components/ui/native-select.tsx b/src/components/ui/native-select.tsx index 5cfac9a66..b344ba353 100644 --- a/src/components/ui/native-select.tsx +++ b/src/components/ui/native-select.tsx @@ -55,7 +55,7 @@ import { cn } from "@/lib/utils"; // chevron uses. const NATIVE_SELECT_CLASS = - "border-input bg-background text-foreground ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-11 sm:h-10 w-full appearance-none rounded-md border ps-3 pe-9 py-1 text-sm shadow-xs transition-[color,box-shadow] focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none bg-[image:var(--native-select-chevron)] bg-[length:1rem] bg-[position:right_0.75rem_center] bg-no-repeat"; + "border-input bg-background text-foreground placeholder:text-muted-foreground focus-visible:border-input-focus flex h-11 sm:h-10 w-full appearance-none rounded-md border ps-3 pe-9 py-1 text-sm shadow-xs transition-[color,box-shadow] focus-visible:outline-none bg-[image:var(--native-select-chevron)] bg-[length:1rem] bg-[position:right_0.75rem_center] bg-no-repeat"; export type NativeSelectProps = React.SelectHTMLAttributes; diff --git a/src/components/ui/select.tsx b/src/components/ui/select.tsx index 45cb3da2b..3d3230c6d 100644 --- a/src/components/ui/select.tsx +++ b/src/components/ui/select.tsx @@ -49,7 +49,7 @@ function SelectTrigger({ // value are inset equally; `gap-2` between them keeps the chevron // from crowding a long value, and `shrink-0` guarantees it never // clips. - "border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-11 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 data-[size=default]:sm:h-10 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + "border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-input-focus aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:focus-visible:ring-[3px] data-[size=default]:h-11 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 data-[size=default]:sm:h-10 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", className, )} {...props} diff --git a/src/components/ui/textarea.tsx b/src/components/ui/textarea.tsx index 5db2e5d71..43273a45e 100644 --- a/src/components/ui/textarea.tsx +++ b/src/components/ui/textarea.tsx @@ -66,7 +66,7 @@ function Textarea({ // iOS zoom defence + WCAG 2.5.5 tap-target floor; see the // primitive's docblock for the full reasoning. "border-input bg-background text-foreground placeholder:text-muted-foreground dark:bg-input/30 min-h-11 w-full rounded-md border px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 sm:min-h-9 sm:text-sm", - "focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]", + "focus-visible:border-input-focus aria-invalid:focus-visible:ring-[3px]", "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", className, )} diff --git a/src/components/ui/time-field.tsx b/src/components/ui/time-field.tsx index 39300187b..60a6c530a 100644 --- a/src/components/ui/time-field.tsx +++ b/src/components/ui/time-field.tsx @@ -42,12 +42,13 @@ const FIELD_HEIGHT_CLASSES = "min-h-11 h-11 sm:min-h-10 sm:h-10"; // automatic minimum size would otherwise be the 20-character intrinsic width of // the text input inside, which floors the whole field at ~230 px and overflows // the dialog that hosts it. See the note in `date-field.tsx`. -// Focus vocabulary and font-size parity with `` / ``: a soft -// 3 px ring hugging the box (not the old hard `ring-2 ring-offset-2`, which -// painted 4 px outside the div and jumped on focus) and `text-base md:text-sm` +// Focus vocabulary and font-size parity with `` / ``: the +// border turns the neutral `input-focus` tone with no ring (not the old hard +// `ring-2 ring-offset-2`, which painted 4 px outside the div and jumped on +// focus) and `text-base md:text-sm` // to keep iOS Safari from auto-zooming a sub-16 px input. const FIELD_BASE_CLASSES = - "border-input bg-background text-foreground placeholder:text-muted-foreground relative flex w-full min-w-0 items-center rounded-md border ps-3 pe-2 text-base md:text-sm shadow-xs transition-[color,box-shadow] focus-within:border-ring focus-within:ring-ring/50 focus-within:ring-[3px] focus-within:outline-none"; + "border-input bg-background text-foreground placeholder:text-muted-foreground relative flex w-full min-w-0 items-center rounded-md border ps-3 pe-2 text-base md:text-sm shadow-xs transition-[color,box-shadow] focus-within:border-input-focus focus-within:outline-none"; const TIME_RE = /^([01]\d|2[0-3]):([0-5]\d)$/; From aa222f5b02b32404ace597fe8b8dc82f59e58a3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 5 Oct 2026 21:22:57 +0200 Subject: [PATCH 03/19] fix(daily): speak about today only from today's readings A statement that says "today", or compares today against a baseline, now only rests on a reading taken on the current calendar day in the reader's zone. Yesterday morning's pulse read at nine in the evening was narrated as today's: the signals of the day accepted any reading from the last two weeks, and the Today overview, the coincident deviation flag and the health status counted yesterday as current. - computeSignalsOfDay drops a metric with no reading today, so the briefing prompt and its content hash never see a stale value as the day's; the prompt says so in both languages. - The digest serves no cached briefing written on an earlier day and drops a reading-backed signal whose metric was not measured today, so the web hero, the iOS topSignal and the morning push agree. - The Today overview's vitals line and deterministic lead, the coincident flag and the health status deviations use the new isFromToday rule (calendar day, not elapsed hours). Signal deltas now carry each metric's display precision: the prompt input is rounded (whole bpm and mmHg, one decimal of kg), and the delta string under the hero lead is re-read at that precision in the reader's locale ("+34 bpm", not "+33,72 bpm"). --- src/lib/ai/prompts/insight-generator.ts | 8 + .../daily/__tests__/briefing-today.test.ts | 133 +++++++++++++++ .../daily/__tests__/today-overview.test.ts | 46 +++++- src/lib/daily/briefing-today.ts | 151 ++++++++++++++++++ src/lib/daily/load-digest.ts | 34 ++-- src/lib/daily/today-overview.ts | 11 +- .../insights/__tests__/health-status.test.ts | 6 + .../__tests__/signals-of-day-pulse.test.ts | 22 +-- .../__tests__/signals-of-day-today.test.ts | 78 +++++++++ .../__tests__/coincident-deviation.test.ts | 17 ++ .../insights/derived/coincident-deviation.ts | 8 +- src/lib/insights/features-units.ts | 28 +++- src/lib/insights/features.ts | 9 +- src/lib/insights/health-status.ts | 14 +- src/lib/insights/measurement-freshness.ts | 21 +++ src/lib/insights/signals-of-day.ts | 97 ++++++++--- src/lib/measurements/vital-precision.ts | 37 +++++ 17 files changed, 645 insertions(+), 75 deletions(-) create mode 100644 src/lib/daily/__tests__/briefing-today.test.ts create mode 100644 src/lib/daily/briefing-today.ts create mode 100644 src/lib/insights/__tests__/signals-of-day-today.test.ts create mode 100644 src/lib/measurements/vital-precision.ts diff --git a/src/lib/ai/prompts/insight-generator.ts b/src/lib/ai/prompts/insight-generator.ts index b4f36a865..a17c28c14 100644 --- a/src/lib/ai/prompts/insight-generator.ts +++ b/src/lib/ai/prompts/insight-generator.ts @@ -293,6 +293,10 @@ gib die obige Verweigerung zurück.`, briefing is stronger with one or two sharp signals than three weak ones. When "signalsOfDay" is absent or flat, omit the field (or set null) — do NOT manufacture a signal from a quiet metric. + Every "signalsOfDay" entry comes from a reading taken TODAY in the + reader's own timezone. A metric missing from the block was not + measured today: never call its last reading "today's", and never + build a signal or a "today" sentence from it. - keyFindings: 0-5 short rows — the longer-horizon trend list below the signals. Each row has tone ("good" | "watch" | "info"), a headline (≤ 60 chars), a one-sentence detail, an @@ -504,6 +508,10 @@ gib die obige Verweigerung zurück.`, zwei scharfe Signale sind stärker als drei schwache. Fehlt "signalsOfDay" oder ist flach, lass das Feld weg (oder setze null) — erfinde KEIN Signal aus einer ruhigen Metrik. + Jeder "signalsOfDay"-Eintrag stammt aus einer Messung von HEUTE in + der Zeitzone der Person. Eine Metrik, die im Block fehlt, wurde + heute nicht gemessen: nenne ihren letzten Wert nie "heute" und baue + daraus weder ein Signal noch einen Satz über heute. - keyFindings: 0-5 kurze Zeilen — die Trend-Liste mit längerem Horizont unter den Signalen. Jede Zeile hat tone ("good" | "watch" | "info"), eine headline (≤ 60 Zeichen), ein detail im Einzelsatz, diff --git a/src/lib/daily/__tests__/briefing-today.test.ts b/src/lib/daily/__tests__/briefing-today.test.ts new file mode 100644 index 000000000..7a9ff8f00 --- /dev/null +++ b/src/lib/daily/__tests__/briefing-today.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, it } from "vitest"; + +import type { DailyBriefing } from "@/lib/ai/schema"; +import { + briefingForToday, + formatSignalDelta, +} from "@/lib/daily/briefing-today"; + +describe("formatSignalDelta", () => { + it("reads a pulse difference in whole beats (de)", () => { + expect( + formatSignalDelta("+33,72 bpm vs. dein 30-Tage-Mittel", "pulse", "de"), + ).toBe("+34 bpm vs. dein 30-Tage-Mittel"); + }); + + it("reads a pulse difference in whole beats (en)", () => { + expect( + formatSignalDelta("+33.72 bpm vs your 30-day average", "pulse", "en"), + ).toBe("+34 bpm vs your 30-day average"); + }); + + it("reads weight at one decimal in both languages", () => { + expect( + formatSignalDelta("−0,37 kg seit letzter Woche", "weight", "de"), + ).toBe("−0,4 kg seit letzter Woche"); + expect(formatSignalDelta("-0.37 kg since last week", "weight", "en")).toBe( + "-0.4 kg since last week", + ); + }); + + it("leaves a figure already at its precision, and a grouped thousand, alone", () => { + expect( + formatSignalDelta("+6 mmHg vs your 30-day average", "bp", "en"), + ).toBe("+6 mmHg vs your 30-day average"); + expect(formatSignalDelta("+1,2 kg", "weight", "de")).toBe("+1,2 kg"); + // "1.234" is a thousand in German, never a decimal to round. + expect(formatSignalDelta("1.234 bpm", "pulse", "de")).toBe("1.234 bpm"); + }); + + it("drops the sign of a difference that rounds to nothing", () => { + expect(formatSignalDelta("+0.3 bpm vs your normal", "pulse", "en")).toBe( + "0 bpm vs your normal", + ); + }); + + it("keeps a metric that is not a single reading verbatim", () => { + expect(formatSignalDelta("−42,5 Min.", "sleep", "de")).toBe("−42,5 Min."); + }); +}); + +const BRIEFING: DailyBriefing = { + paragraph: "Dein Puls ist heute deutlich erhöht.", + signalsOfDay: [ + { + sourceMetric: "pulse", + tone: "watch", + headline: "Puls ist heute deutlich erhöht", + nudge: "Gönn dir heute etwas Ruhe.", + delta: "+33,72 bpm vs. dein 30-Tage-Mittel", + }, + { + sourceMetric: "sleep", + tone: "info", + headline: "Kürzere Nacht", + nudge: "Heute etwas früher ins Bett.", + delta: null, + }, + ], + keyFindings: [], +}; + +/** 2026-10-05 21:00 in Berlin, the time the defect was seen. */ +const BERLIN = { + timezone: "Europe/Berlin", + todayLocalDate: "2026-10-05", + language: "de" as const, +}; + +describe("briefingForToday", () => { + it("drops a pulse signal whose last reading was yesterday at 08:50", () => { + const out = briefingForToday(BRIEFING, { + ...BERLIN, + updatedAt: "2026-10-05T04:00:00Z", + lastSeenAt: (type) => (type === "PULSE" ? "2026-10-04T06:50:00Z" : null), + }); + expect(out?.signalsOfDay?.map((s) => s.sourceMetric)).toEqual(["sleep"]); + }); + + it("keeps a pulse signal measured today, with its delta in whole beats", () => { + const out = briefingForToday(BRIEFING, { + ...BERLIN, + updatedAt: "2026-10-05T04:00:00Z", + lastSeenAt: (type) => (type === "PULSE" ? "2026-10-05T06:50:00Z" : null), + }); + expect(out?.signalsOfDay?.[0]?.delta).toBe( + "+34 bpm vs. dein 30-Tage-Mittel", + ); + }); + + it("serves no briefing written on an earlier day, just after midnight", () => { + // Written 23:30 on the 4th, read 00:10 on the 5th (Berlin). + expect( + briefingForToday(BRIEFING, { + ...BERLIN, + updatedAt: "2026-10-04T21:30:00Z", + lastSeenAt: () => "2026-10-04T21:00:00Z", + }), + ).toBeNull(); + }); + + it("decides the day in the reader's zone", () => { + // 05:00 UTC on the 5th is the evening of the 4th in Los Angeles. + const ctx = { + updatedAt: "2026-10-05T05:00:00Z", + lastSeenAt: () => "2026-10-05T05:00:00Z", + language: "en" as const, + }; + expect( + briefingForToday(BRIEFING, { + ...ctx, + timezone: "America/Los_Angeles", + todayLocalDate: "2026-10-05", + }), + ).toBeNull(); + expect( + briefingForToday(BRIEFING, { + ...ctx, + timezone: "UTC", + todayLocalDate: "2026-10-05", + })?.signalsOfDay?.length, + ).toBe(2); + }); +}); diff --git a/src/lib/daily/__tests__/today-overview.test.ts b/src/lib/daily/__tests__/today-overview.test.ts index 6e25dcc1a..060451c31 100644 --- a/src/lib/daily/__tests__/today-overview.test.ts +++ b/src/lib/daily/__tests__/today-overview.test.ts @@ -343,7 +343,51 @@ describe("deterministic lead — strongest signal", () => { expect(o.today).toEqual([]); }); - it("ignores a vital that is not from today or yesterday", () => { + it("says nothing about today from yesterday's reading", () => { + // A pulse from yesterday morning, read at nine in the evening: neither + // the lead nor the vitals line may call it today's. + const o = buildTodayOverview( + input({ + vitals: [ + vital({ + type: "PULSE", + daysAgo: 1, + direction: "above", + value: 98, + high: 72, + valueLabel: "98 bpm", + rangeLabel: "58 to 72 bpm", + }), + ], + }), + t, + ); + expect(o.lead).toBeNull(); + expect(o.today.some((f) => f.kind === "vitals")).toBe(false); + }); + + it("speaks about a vital measured today", () => { + const o = buildTodayOverview( + input({ + vitals: [ + vital({ + type: "PULSE", + daysAgo: 0, + direction: "above", + value: 98, + high: 72, + valueLabel: "98 bpm", + rangeLabel: "58 to 72 bpm", + }), + ], + }), + t, + ); + expect(o.lead?.source).toBe("signal"); + expect(o.lead?.text).toContain("98 bpm"); + }); + + it("ignores a vital from days ago", () => { const o = buildTodayOverview( input({ vitals: [vital({ daysAgo: 4, direction: "above", value: 70 })], diff --git a/src/lib/daily/briefing-today.ts b/src/lib/daily/briefing-today.ts new file mode 100644 index 000000000..bce8c7be8 --- /dev/null +++ b/src/lib/daily/briefing-today.ts @@ -0,0 +1,151 @@ +/** + * The cached briefing, reduced to what is still true about today. + * + * The briefing is model text written at one moment and served for up to a + * day, and the content-hash gate may keep the same text across several. The + * hero reads it as the day's lead, its top signal as "pulse is up today", and + * the morning push repeats both. Two things made that wrong in practice: + * + * - A briefing written on an earlier calendar day speaks about that day. + * Served after midnight it narrates yesterday's readings as today's. + * - A signal whose metric has no reading today is not a signal of the day, + * whatever the text says. The prompt input is filtered the same way + * (`computeSignalsOfDay`), so this only catches text cached before that, + * or a cache that outlived the reading it was written from. + * + * Both are decided here, on the server, so every client (the web hero, the + * iOS app reading `topSignal`, the push line) gets the same answer. The + * delta string is model text as well: its figures are re-read at the + * metric's display precision, so a pulse difference reads "+34 bpm", never + * "+33.72 bpm". + */ +import type { DailyBriefing, DailyBriefingSignal } from "@/lib/ai/schema"; +import type { Locale } from "@/lib/i18n/config"; +import { resolveIntlLocale } from "@/lib/format-locale"; +import { vitalDisplayDecimals } from "@/lib/measurements/vital-precision"; +import { userDayKey } from "@/lib/tz/format"; + +/** + * The measurement type a briefing signal is a reading of. Metrics absent + * here (sleep, steps, mood, compliance, derived scores) are not a single + * dated reading and keep their signal; the prompt input already decides + * those. + */ +export const SIGNAL_READING_TYPE: Partial< + Record +> = { + bp: "BLOOD_PRESSURE_SYS", + weight: "WEIGHT", + pulse: "PULSE", + resting_hr: "RESTING_HEART_RATE", + hrv: "HEART_RATE_VARIABILITY", + body_temp: "BODY_TEMPERATURE", +}; + +/** The locale's decimal separator ("," for de, "." for en). */ +function decimalSeparator(locale: Locale): string { + return ( + new Intl.NumberFormat(resolveIntlLocale(locale)) + .formatToParts(1.5) + .find((part) => part.type === "decimal")?.value ?? "." + ); +} + +function escapeRegExp(text: string): string { + return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +/** + * Re-read the figures in a signal's delta string at the metric's display + * precision. + * + * Only a number written with the locale's own decimal separator is touched, + * and only when it carries more fraction digits than the metric is shown + * with: "1.234" in German is a thousand, not a decimal, and is left alone. + * A figure that rounds to zero loses its sign ("+0.3 bpm" reads "0 bpm"). + * A metric with no reading type keeps its delta verbatim. + */ +export function formatSignalDelta( + delta: string, + sourceMetric: DailyBriefingSignal["sourceMetric"], + locale: Locale, +): string { + const type = SIGNAL_READING_TYPE[sourceMetric]; + if (!type) return delta; + // Every reading type above reads at the same precision in both unit + // systems (whole bpm / mmHg / ms, one decimal of kg / lb / °C / °F). + const decimals = vitalDisplayDecimals(type, 1); + const sep = decimalSeparator(locale); + const number = new Intl.NumberFormat(resolveIntlLocale(locale), { + minimumFractionDigits: decimals, + maximumFractionDigits: decimals, + useGrouping: false, + }); + const pattern = new RegExp( + `([+\\-−±]?)(\\d+)${escapeRegExp(sep)}(\\d+)(?![\\d])`, + "g", + ); + return delta.replace(pattern, (match, sign: string, int: string, frac) => { + if ((frac as string).length <= decimals) return match; + const value = Number(`${int}.${frac as string}`); + const rounded = Math.round(value * 10 ** decimals) / 10 ** decimals; + return `${rounded === 0 ? "" : sign}${number.format(rounded)}`; + }); +} + +export interface BriefingForTodayContext { + /** When the cached briefing was written (ISO), or null when unknown. */ + updatedAt: string | null; + /** The newest reading of a measurement type (ISO), or null when none. */ + lastSeenAt: (type: string) => string | null; + /** The reader's zone and today's day key in it. */ + timezone: string; + todayLocalDate: string; + /** The reader's language, already resolved; deltas are re-read in it. */ + language: Locale; +} + +/** + * The briefing as the Today surfaces may use it: null when it was written on + * an earlier calendar day, otherwise with every reading-backed signal whose + * metric was not measured today removed and every remaining delta re-read at + * its metric's precision. + */ +export function briefingForToday( + briefing: DailyBriefing | null, + ctx: BriefingForTodayContext, +): DailyBriefing | null { + if (!briefing) return null; + if ( + ctx.updatedAt !== null && + userDayKey(new Date(ctx.updatedAt), ctx.timezone) !== ctx.todayLocalDate + ) { + return null; + } + if (!briefing.signalsOfDay) return briefing; + const measuredToday = (type: string) => { + const at = ctx.lastSeenAt(type); + return ( + at !== null && + userDayKey(new Date(at), ctx.timezone) === ctx.todayLocalDate + ); + }; + const signalsOfDay = briefing.signalsOfDay + .filter((signal) => { + const type = SIGNAL_READING_TYPE[signal.sourceMetric]; + return type === undefined || measuredToday(type); + }) + .map((signal) => + signal.delta + ? { + ...signal, + delta: formatSignalDelta( + signal.delta, + signal.sourceMetric, + ctx.language, + ), + } + : signal, + ); + return { ...briefing, signalsOfDay }; +} diff --git a/src/lib/daily/load-digest.ts b/src/lib/daily/load-digest.ts index db39186f9..a786cbbf1 100644 --- a/src/lib/daily/load-digest.ts +++ b/src/lib/daily/load-digest.ts @@ -82,6 +82,8 @@ import { resolveRestMode } from "@/lib/illness/rest-mode"; import { readTodayCycle, type TodayCycleRead } from "@/lib/cycle/today-verdict"; import { addDays, dayDiff } from "@/lib/cycle/day-math"; import { makeFormatters, resolveIntlLocale } from "@/lib/format-locale"; +import { vitalDisplayDecimals } from "@/lib/measurements/vital-precision"; +import { briefingForToday } from "@/lib/daily/briefing-today"; import type { DateFormatPreference, TimeFormatPreference, @@ -328,24 +330,6 @@ async function gatherScoreDays( } } -/** - * Display decimals per vital. The transform's own `decimals` covers the - * converted units (lb, °F, mmol/L); the rest are whole numbers on every - * device and in every chart that shows them. - */ -function vitalDecimals(type: string, transformDecimals: number): number { - switch (type) { - case "BODY_TEMPERATURE": - case "SKIN_TEMPERATURE": - case "WEIGHT": - return 1; - case "BLOOD_GLUCOSE": - return transformDecimals; - default: - return 0; - } -} - /** Render one vital's value and range in the reader's units and locale. */ function toTodayVital( vital: ExtrasVital, @@ -354,7 +338,7 @@ function toTodayVital( t: (key: string, params?: Record) => string, ): TodayVital { const transform = getReadingTransform(vital.type, units); - const decimals = vitalDecimals(vital.type, transform.decimals); + const decimals = vitalDisplayDecimals(vital.type, transform.decimals); const number = new Intl.NumberFormat(resolveIntlLocale(locale), { maximumFractionDigits: decimals, minimumFractionDigits: decimals, @@ -950,7 +934,17 @@ export async function loadDailyDigest( snapshot.layout.enabledHeroItemKinds ?? PRIORITY_ITEM_KINDS, score, - briefing: snapshot.briefing, + // Only what is still true about today: a briefing written on an + // earlier day is not served as today's read, and a signal whose + // metric has no reading today is dropped (`briefing-today.ts`). + briefing: briefingForToday(snapshot.briefing, { + updatedAt: snapshot.briefingUpdatedAt, + lastSeenAt: (type) => + snapshot.tiles.lastSeenByType[type]?.lastSeenAt ?? null, + timezone: user.timezone, + todayLocalDate, + language: resolvedLocale, + }), medsToday: snapshot.medsToday, sleepLastSeenDaysAgo, morningRefreshedToday, diff --git a/src/lib/daily/today-overview.ts b/src/lib/daily/today-overview.ts index 726b14983..105eec8ef 100644 --- a/src/lib/daily/today-overview.ts +++ b/src/lib/daily/today-overview.ts @@ -24,7 +24,7 @@ import type { MedsTodayBlock } from "@/lib/dashboard/meds-today"; import type { Locale } from "@/lib/i18n/config"; import { pluralKey } from "@/lib/i18n/plural"; import { formatDurationMinutes } from "@/lib/i18n/duration"; -import { isCurrentForTodayClaim } from "@/lib/insights/measurement-freshness"; +import { isFromToday } from "@/lib/insights/measurement-freshness"; import type { ModuleKey } from "@/lib/modules/registry"; import type { PriorityItem } from "@/lib/daily/priority-item"; @@ -486,11 +486,16 @@ function buildSignalLine( return keptDelta ? { headline: null, delta: keptDelta } : null; } -/** Vitals that may speak about today: module on, reading current. */ +/** + * Vitals that may speak about today: module on, and a reading taken today in + * the reader's zone. Yesterday's reading is not today's, however recent: at + * nine in the evening a pulse from the previous morning would be narrated as + * the day's, on a day with no reading at all. + */ function currentVitals(input: TodayOverviewInput): TodayVital[] { return input.vitals.filter( (v) => - isCurrentForTodayClaim(v.daysAgo) && + isFromToday(v.daysAgo) && (v.moduleKey === null || moduleOn(input.modules, v.moduleKey)), ); } diff --git a/src/lib/insights/__tests__/health-status.test.ts b/src/lib/insights/__tests__/health-status.test.ts index b5ad57d9c..0bf8b49c8 100644 --- a/src/lib/insights/__tests__/health-status.test.ts +++ b/src/lib/insights/__tests__/health-status.test.ts @@ -57,6 +57,12 @@ describe("summariseHealthStatus", () => { expect(s.shifts[0]).toMatchObject({ metric: "WEIGHT", direction: "up" }); }); + it("does not present yesterday's reading as now", () => { + const s = summariseHealthStatus([vital(true, "above", 1)], []); + expect(s.present).toBe(false); + expect(s.deviations).toHaveLength(0); + }); + it("drops a vital flagged outside but with an 'in' direction", () => { const s = summariseHealthStatus([vital(true, "in")], []); expect(s.present).toBe(false); diff --git a/src/lib/insights/__tests__/signals-of-day-pulse.test.ts b/src/lib/insights/__tests__/signals-of-day-pulse.test.ts index 7aea4e5d1..a4417ef3b 100644 --- a/src/lib/insights/__tests__/signals-of-day-pulse.test.ts +++ b/src/lib/insights/__tests__/signals-of-day-pulse.test.ts @@ -7,16 +7,20 @@ const DAY = 86_400_000; /** * A day of pulse is the mean of its hours' means and a window the mean of its * days (`day-mean.ts`). Two days: a workout hour of twelve readings at 150 and - * three resting hours at 60 (day value 82.5), then a day with one reading at - * 60. The 7- and 30-day means are 71.25, not the 127.5 of the readings. + * three resting hours at 60 (day value 82.5), then today with one reading at + * 60. The 7- and 30-day means are 71.25, not the 127.5 of the readings; the + * pulse signal states them at its display precision, whole beats. */ + +/** 21:00 UTC: late enough that this morning's reading is today's. */ +const NOW = Date.UTC(2026, 9, 5, 21, 0); function rows(now: number) { const today = new Date(now); const midnight = (daysAgo: number) => Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate()) - daysAgo * DAY; const out: Array<{ value: number; measuredAt: Date }> = []; - const workout = midnight(3); + const workout = midnight(1); for (let i = 0; i < 12; i += 1) { out.push({ value: 150, @@ -26,13 +30,13 @@ function rows(now: number) { for (const h of [12, 14, 16]) { out.push({ value: 60, measuredAt: new Date(workout + h * 3_600_000) }); } - out.push({ value: 60, measuredAt: new Date(midnight(2) + 8 * 3_600_000) }); + out.push({ value: 60, measuredAt: new Date(midnight(0) + 8 * 3_600_000) }); return out; } describe("the pulse signal of the day", () => { it("weighs each day once in its 7- and 30-day means", () => { - const now = Date.now(); + const now = NOW; const pulse = rows(now); const [signal] = computeSignalsOfDay( (type) => (type === "PULSE" ? pulse : []), @@ -40,13 +44,13 @@ describe("the pulse signal of the day", () => { "UTC", ); expect(signal.metric).toBe("pulse"); - expect(signal.avg7).toBe(71.25); - expect(signal.avg30).toBe(71.25); - expect(signal.deltaVs7).toBe(-11.25); + expect(signal.avg7).toBe(71); + expect(signal.avg30).toBe(71); + expect(signal.deltaVs7).toBe(-11); }); it("leaves every other metric on the plain mean of its readings", () => { - const now = Date.now(); + const now = NOW; const weight = rows(now); const [signal] = computeSignalsOfDay( (type) => (type === "WEIGHT" ? weight : []), diff --git a/src/lib/insights/__tests__/signals-of-day-today.test.ts b/src/lib/insights/__tests__/signals-of-day-today.test.ts new file mode 100644 index 000000000..d03b066a8 --- /dev/null +++ b/src/lib/insights/__tests__/signals-of-day-today.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "vitest"; + +import { computeSignalsOfDay } from "../signals-of-day"; + +const HOUR = 3_600_000; +const DAY = 24 * HOUR; + +/** + * Thirty days of pulse around 64 bpm, one reading a day at 08:50 local, the + * newest one at `newest`. Enough history for a signal; the only question is + * whether the newest reading is today's. + */ +function pulseUpTo(newest: Date) { + const out: Array<{ value: number; measuredAt: Date }> = []; + for (let d = 20; d >= 1; d -= 1) { + out.push({ + value: 62 + (d % 5), + measuredAt: new Date(newest.getTime() - d * DAY), + }); + } + out.push({ value: 98, measuredAt: newest }); + return out; +} + +function signals(newest: Date, now: Date, tz: string) { + const pulse = pulseUpTo(newest); + return computeSignalsOfDay( + (type) => (type === "PULSE" ? pulse : []), + now.getTime(), + tz, + ); +} + +describe("signals of the day are about today", () => { + it("drops a pulse last measured yesterday morning, read at 21:00", () => { + // 2026-10-04 08:50 and 2026-10-05 21:00 in Berlin (UTC+2). + const newest = new Date("2026-10-04T06:50:00Z"); + const now = new Date("2026-10-05T19:00:00Z"); + expect(signals(newest, now, "Europe/Berlin")).toEqual([]); + }); + + it("keeps a pulse measured this morning", () => { + const newest = new Date("2026-10-05T06:50:00Z"); + const now = new Date("2026-10-05T19:00:00Z"); + const [signal] = signals(newest, now, "Europe/Berlin"); + expect(signal?.metric).toBe("pulse"); + expect(signal?.latestDaysAgo).toBe(0); + // Whole beats, as pulse is shown everywhere else. + expect(Number.isInteger(signal?.deltaVs30)).toBe(true); + expect(Number.isInteger(signal?.avg30)).toBe(true); + }); + + it("treats 23:50 as yesterday at 00:10, and 00:05 as today", () => { + const now = new Date("2026-10-05T22:10:00Z"); // 00:10 on the 6th in Berlin + expect( + signals(new Date("2026-10-05T21:50:00Z"), now, "Europe/Berlin"), + ).toEqual([]); + expect( + signals(new Date("2026-10-05T22:05:00Z"), now, "Europe/Berlin")[0] + ?.metric, + ).toBe("pulse"); + }); + + it("reads today in the reader's zone, not in UTC", () => { + // 05:00 UTC is 22:00 the previous evening in Los Angeles; 18:00 UTC the + // same calendar day in UTC, but the next one in Los Angeles. + const newest = new Date("2026-10-05T05:00:00Z"); + const now = new Date("2026-10-05T18:00:00Z"); + expect(signals(newest, now, "UTC")[0]?.metric).toBe("pulse"); + expect(signals(newest, now, "America/Los_Angeles")).toEqual([]); + }); + + it("is quiet when there is nothing today, even on a sizeable deviation", () => { + const newest = new Date(Date.UTC(2026, 9, 3, 8)); + const now = new Date(newest.getTime() + 2 * DAY); + expect(signals(newest, now, "UTC")).toEqual([]); + }); +}); diff --git a/src/lib/insights/derived/__tests__/coincident-deviation.test.ts b/src/lib/insights/derived/__tests__/coincident-deviation.test.ts index 6b08af942..76bdf0548 100644 --- a/src/lib/insights/derived/__tests__/coincident-deviation.test.ts +++ b/src/lib/insights/derived/__tests__/coincident-deviation.test.ts @@ -145,6 +145,23 @@ describe("computeCoincidentDeviation", () => { } }); + it("does not call yesterday's outliers today's, read the next evening", async () => { + // The outliers were taken 2026-06-02 07:00 UTC; read on the 3rd at + // 21:00 in Berlin there is no reading today, so nothing contributes. + findMany.mockImplementation(firingRows); + const result = await computeCoincidentDeviation("u1", PROFILE, { + now: new Date("2026-06-03T19:00:00Z"), + tz: "Europe/Berlin", + }); + expect(result.status).toBe("ok"); + if (result.status === "ok") { + expect(result.value.contributing).toEqual([]); + expect(result.value.fired).toBe(false); + // The vitals keep their standing and their age for the anatomy view. + expect(result.value.vitals.every((v) => v.daysAgo === 1)).toBe(true); + } + }); + it("does not fire when vitals stay inside their bands", async () => { const flat = (base: number): Array<{ value: number; measuredAt: Date }> => Array.from({ length: 10 }, (_, i) => ({ diff --git a/src/lib/insights/derived/coincident-deviation.ts b/src/lib/insights/derived/coincident-deviation.ts index 235a514fa..a9dbe2459 100644 --- a/src/lib/insights/derived/coincident-deviation.ts +++ b/src/lib/insights/derived/coincident-deviation.ts @@ -52,7 +52,7 @@ import { } from "@/lib/measurements/value-domain"; import { dayKeyAgeInDays, - isCurrentForTodayClaim, + isFromToday, } from "@/lib/insights/measurement-freshness"; import { DEFAULT_TIMEZONE, userDayKey } from "@/lib/tz/format"; @@ -422,14 +422,14 @@ export async function computeCoincidentDeviation( } // Everything this flag says is about TODAY — the card is literally called - // "signals of the day" and every line it renders is present tense. A vital - // whose freshest reading is older than that cannot contribute to it: the + // "signals of the day" and its lines say "today". A vital whose freshest + // reading is from an earlier calendar day cannot contribute to it: the // reading describes the day it was taken on, and saying otherwise tells the // reader something about a day on which nothing was measured. Stale vitals // stay in `vitals` with their age, so the anatomy view still lists them and // nothing silently disappears. const contributing = vitals.filter( - (v) => v.outside && isCurrentForTodayClaim(v.daysAgo), + (v) => v.outside && isFromToday(v.daysAgo), ); const fired = contributing.length >= COINCIDENT_FIRE_THRESHOLD; diff --git a/src/lib/insights/features-units.ts b/src/lib/insights/features-units.ts index 874142a82..1e06fb3ee 100644 --- a/src/lib/insights/features-units.ts +++ b/src/lib/insights/features-units.ts @@ -16,6 +16,10 @@ * factor alone. A metric reader on mg/dL gets every number back unchanged, * with a unit label added. */ +import { + roundToDisplay, + vitalDisplayDecimals, +} from "@/lib/measurements/vital-precision"; import { applyDisplayTransform, applyDisplayTransformDelta, @@ -61,19 +65,29 @@ export function signalInReaderUnits( const type = SIGNAL_TYPE[signal.metric]; if (!type) return signal; const t = getReadingTransform(type, units); + // A signal is a statement the model repeats to the reader, so its figures + // carry the precision the reading is shown at. The metric branch of a + // transform is the identity and does not round, which handed the model + // "+0.37 kg" to restate. + const decimals = vitalDisplayDecimals(type, t.decimals); + const round = (value: Num): Num => + typeof value === "number" ? roundToDisplay(value, decimals) : value; return { ...signal, unit: t.displayUnit, - latest: applyDisplayTransform(signal.latest, t), - avg7: abs(signal.avg7, t), - avg30: abs(signal.avg30, t), - deltaVs7: delta(signal.deltaVs7, t), - deltaVs30: delta(signal.deltaVs30, t), - spread30: delta(signal.spread30, t), + latest: roundToDisplay(applyDisplayTransform(signal.latest, t), decimals), + avg7: round(abs(signal.avg7, t)), + avg30: round(abs(signal.avg30, t)), + deltaVs7: round(delta(signal.deltaVs7, t)), + deltaVs30: round(delta(signal.deltaVs30, t)), + spread30: round(delta(signal.spread30, t)), recentAnomaly: signal.recentAnomaly ? { ...signal.recentAnomaly, - value: applyDisplayTransform(signal.recentAnomaly.value, t), + value: roundToDisplay( + applyDisplayTransform(signal.recentAnomaly.value, t), + decimals, + ), } : null, }; diff --git a/src/lib/insights/features.ts b/src/lib/insights/features.ts index 08e790466..091714530 100644 --- a/src/lib/insights/features.ts +++ b/src/lib/insights/features.ts @@ -1901,7 +1901,14 @@ export async function extractFeatures( // in-memory measurement set (no extra DB round-trip). Lands inside the // compacted features payload, so it feeds both the briefing prompt AND the // content-hash gate — a fresh daily signal forces the briefing to refresh. - const signalsOfDay = computeSignalsOfDay(byType, now, pulseTz); + // Each signal is a statement about today, which is a calendar day in the + // reader's own zone, so the zone is resolved for every account here, not + // only for the ones with pulse. + const signalsOfDay = computeSignalsOfDay( + byType, + now, + await resolveUserTimezone(userId), + ); if (signalsOfDay.length > 0) { features.signalsOfDay = signalsOfDay; } diff --git a/src/lib/insights/health-status.ts b/src/lib/insights/health-status.ts index 358059b6e..6cf8e718d 100644 --- a/src/lib/insights/health-status.ts +++ b/src/lib/insights/health-status.ts @@ -17,7 +17,7 @@ import type { MeasurementType } from "@/generated/prisma/client"; import type { VitalDeviation } from "@/lib/insights/derived/coincident-deviation"; import type { ChangepointSignal } from "@/lib/insights/derived/changepoint"; -import { isCurrentForTodayClaim } from "@/lib/insights/measurement-freshness"; +import { isFromToday } from "@/lib/insights/measurement-freshness"; /** One vital sitting outside its personal band today. */ export interface HealthStatusDeviation { @@ -66,14 +66,12 @@ export function summariseHealthStatus( ): HealthStatusSummary { const deviations: HealthStatusDeviation[] = vitals // Present tense, so present readings only. "Your pulse is above your usual - // range" describes now; a reading from last week describes last week, and - // the card carried no date to say which. A vital whose freshest reading is - // outside the claim window is left out rather than dressed as current — + // range ... now 95" describes today; a reading from yesterday describes + // yesterday, and the card carries no date to say which. A vital whose + // freshest reading is not from today is left out rather than dressed as + // current — // the changepoint shifts below are dated by construction and unaffected. - .filter( - (v) => - v.outside && v.direction !== "in" && isCurrentForTodayClaim(v.daysAgo), - ) + .filter((v) => v.outside && v.direction !== "in" && isFromToday(v.daysAgo)) .map((v) => ({ type: v.type, value: v.value, diff --git a/src/lib/insights/measurement-freshness.ts b/src/lib/insights/measurement-freshness.ts index 2fcf162fa..7063d74e9 100644 --- a/src/lib/insights/measurement-freshness.ts +++ b/src/lib/insights/measurement-freshness.ts @@ -42,6 +42,27 @@ export function isCurrentForTodayClaim( ); } +/** + * Whether a reading was taken today, on the reader's own calendar. + * + * Stricter than {@link isCurrentForTodayClaim}, and the rule for every + * sentence that says "today" in so many words or compares today against a + * baseline: the Today overview on the dashboard, the signals of the day, + * the "vitals outside their range today" card. A reading from yesterday + * morning, read at nine in the evening, is not today's pulse, and saying so + * describes a day on which nothing was measured. A metric with no reading + * today is left out of those statements rather than worded with its age: + * the quieter of the two answers, and the one that never asks for a + * measurement nobody planned to take. + * + * `daysAgo` is the calendar distance in the reader's zone (as + * {@link dayKeyAgeInDays} computes it), never elapsed hours: 23:40 and + * 00:10 are different days, 00:10 and 23:50 the same one. + */ +export function isFromToday(daysAgo: number | null | undefined): boolean { + return daysAgo === 0; +} + /** * Whole days between two `YYYY-MM-DD` local-day keys (`then` before `today`). * diff --git a/src/lib/insights/signals-of-day.ts b/src/lib/insights/signals-of-day.ts index 6f6c620e0..a55db8b60 100644 --- a/src/lib/insights/signals-of-day.ts +++ b/src/lib/insights/signals-of-day.ts @@ -10,6 +10,12 @@ */ import { trendSlope } from "@/lib/analytics/trends"; import { avgInWindow, stdDev, toDataPoints } from "@/lib/insights/features"; +import { dayKeyAgeInDays } from "@/lib/insights/measurement-freshness"; +import { + roundToDisplay, + vitalDisplayDecimals, +} from "@/lib/measurements/vital-precision"; +import { userDayKey } from "@/lib/tz/format"; /** * v1.18.7 — one present-focused signal feeding the daily briefing. Every @@ -26,7 +32,10 @@ export interface SignalOfDay { unit?: string; /** Freshest reading (the "now" value the briefing leads with). */ latest: number; - /** Days since the freshest reading. */ + /** + * Calendar days between the freshest reading and today in the reader's + * zone. Always 0: a metric with no reading today yields no signal. + */ latestDaysAgo: number; /** Trailing-7d mean. */ avg7: number | null; @@ -88,26 +97,51 @@ function r2(v: number): number { return Math.round(v * 100) / 100; } +/** How one signal is read: its zone, and the precision its figures carry. */ +interface SignalRead { + /** The reader's zone; "today" is the calendar day in it. */ + tz: string; + /** For pulse, the zone its day means are read in (`day-mean.ts`). */ + day?: { type: string; tz: string }; + /** + * Display precision of a unit-insensitive metric (bpm, mmHg), applied + * here. Weight and glucose stay canonical and are rounded where they are + * converted into the reader's unit (`signalInReaderUnits`). + */ + decimals?: number; +} + /** * Build one signal from a single metric's measurement records. Returns null - * when there is no fresh reading or fewer than three points in 30 days — a - * sparse metric cannot carry an honest "today vs your normal" read. + * when the metric has no reading today, or fewer than three points in 30 + * days — a sparse metric cannot carry an honest "today vs your normal" read. + * + * "Today" is the calendar day in the reader's zone. Every signal is a + * present-tense statement about the day ("pulse is up today"), so a reading + * from yesterday morning read at nine in the evening is not one: it would + * narrate a day on which nothing was measured. A metric that was not + * measured today simply has no signal; the briefing's trend sections still + * carry its history, dated. */ function buildSignal( metric: SignalOfDay["metric"], label: string, records: Array<{ value: number; measuredAt: Date }>, now: number, - unit?: string, - day?: { type: string; tz: string }, + unit: string | undefined, + read: SignalRead, ): SignalOfDay | null { if (records.length === 0) return null; const newest = records[records.length - 1]; - const latestDaysAgo = Math.round( - (now - newest.measuredAt.getTime()) / (24 * 60 * 60 * 1000), + const latestDaysAgo = dayKeyAgeInDays( + userDayKey(newest.measuredAt, read.tz), + userDayKey(new Date(now), read.tz), ); - // A reading older than two weeks is not a "signal of the day". - if (latestDaysAgo > 14) return null; + if (latestDaysAgo !== 0) return null; + const { day } = read; + const shown = (v: number) => + read.decimals === undefined ? r2(v) : roundToDisplay(v, read.decimals); + const shownOrNull = (v: number | null) => (v === null ? null : shown(v)); const win30 = records.filter( (rec) => rec.measuredAt.getTime() >= now - 30 * 24 * 60 * 60 * 1000, @@ -119,8 +153,8 @@ function buildSignal( const avg7 = avgInWindow(records, now, 7, 0, day); const avg30 = avgInWindow(records, now, 30, 0, day); const spread30 = stdDev(win30.map((rec) => rec.value)); - const deltaVs7 = avg7 !== null ? r2(newest.value - avg7) : null; - const deltaVs30 = avg30 !== null ? r2(newest.value - avg30) : null; + const deltaVs7 = avg7 !== null ? shown(newest.value - avg7) : null; + const deltaVs30 = avg30 !== null ? shown(newest.value - avg30) : null; const outsideNormalSwing = avg30 !== null && spread30 !== null && spread30 > 0 ? Math.abs(newest.value - avg30) > spread30 @@ -152,7 +186,7 @@ function buildSignal( bestAbs = abs; recentAnomaly = { kind: (sd > 0 ? "peak" : "trough") as "peak" | "trough", - value: r2(rec.value), + value: shown(rec.value), anomalyDaysAgo: Math.round( (now - rec.measuredAt.getTime()) / (24 * 60 * 60 * 1000), ), @@ -166,13 +200,13 @@ function buildSignal( metric, label, ...(unit ? { unit } : {}), - latest: r2(newest.value), + latest: shown(newest.value), latestDaysAgo, - avg7, - avg30, + avg7: shownOrNull(avg7), + avg30: shownOrNull(avg30), deltaVs7, deltaVs30, - spread30, + spread30: shownOrNull(spread30), outsideNormalSwing, emergingTrend, recentAnomaly, @@ -190,13 +224,17 @@ function buildSignal( export function computeSignalsOfDay( byType: (type: string) => Array<{ value: number; measuredAt: Date }>, now: number, - /** The zone pulse days are read in (`day-mean.ts`). */ - pulseTz: string = "UTC", + /** The reader's zone: today's calendar day, and pulse's day means. */ + tz: string = "UTC", ): SignalOfDay[] { const candidates: SignalOfDay[] = []; const push = (s: SignalOfDay | null) => { if (s) candidates.push(s); }; + const whole = (type: string) => ({ + tz, + decimals: vitalDisplayDecimals(type, 0), + }); // Systolic carries the BP signal (the headline number clinicians read first). push( @@ -206,10 +244,22 @@ export function computeSignalsOfDay( byType("BLOOD_PRESSURE_SYS"), now, "mmHg", + whole("BLOOD_PRESSURE_SYS"), ), ); // Absent data simply produces no signal. - push(buildSignal("glucose", "blood glucose", byType("BLOOD_GLUCOSE"), now)); + push( + buildSignal( + "glucose", + "blood glucose", + byType("BLOOD_GLUCOSE"), + now, + undefined, + { + tz, + }, + ), + ); push( buildSignal( "resting_hr", @@ -217,18 +267,21 @@ export function computeSignalsOfDay( byType("RESTING_HEART_RATE"), now, "bpm", + whole("RESTING_HEART_RATE"), ), ); push( buildSignal("pulse", "pulse", byType("PULSE"), now, "bpm", { - type: "PULSE", - tz: pulseTz, + ...whole("PULSE"), + day: { type: "PULSE", tz }, }), ); // Weight and glucose are canonical here and carry no unit: the reader's // unit is attached where the signal is converted for the prompt // (`featuresInReaderUnits`), so no canonical symbol can leak into one. - push(buildSignal("weight", "weight", byType("WEIGHT"), now)); + push( + buildSignal("weight", "weight", byType("WEIGHT"), now, undefined, { tz }), + ); // Sleep is stored one row per stage per night, so a raw "latest" point // would mis-sum; the sleep aggregates carry that signal already. Steps // ingest as many intraday `stats:`-prefixed samples, so the newest raw row diff --git a/src/lib/measurements/vital-precision.ts b/src/lib/measurements/vital-precision.ts new file mode 100644 index 000000000..d46dfacf1 --- /dev/null +++ b/src/lib/measurements/vital-precision.ts @@ -0,0 +1,37 @@ +/** + * Decimal places a vital reading is shown at, in the unit it is shown in. + * + * The transform's own `decimals` covers the converted units (lb, °F, + * mmol/L); body temperature and weight read at one decimal; the rest + * (pulse, blood pressure, HRV, SpO2, respiratory rate, the cumulative + * same-hours metrics) are whole numbers on every device and in every chart + * that shows them. + * + * One answer for every server-side text that prints a vital or a difference + * of one: the Today overview's value and range labels, the signals of the + * day the briefing prompt is given, and the delta under the hero lead. A + * difference is read at the precision of the readings it was taken from, so + * "+34 bpm", never "+33.72 bpm". + */ +export function vitalDisplayDecimals( + type: string, + transformDecimals: number, +): number { + switch (type) { + case "BODY_TEMPERATURE": + case "SKIN_TEMPERATURE": + case "WEIGHT": + return 1; + case "BLOOD_GLUCOSE": + return transformDecimals; + default: + return 0; + } +} + +/** Round to `decimals` fraction digits. Non-finite input passes through. */ +export function roundToDisplay(value: number, decimals: number): number { + if (!Number.isFinite(value)) return value; + const scale = 10 ** decimals; + return Math.round(value * scale) / scale; +} From ac48e0d0ed70801c0810f1c4def7289590db012b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 5 Oct 2026 21:29:04 +0200 Subject: [PATCH 04/19] fix(coach): give a series read its numbers when the shared build cut its block MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Inside a chat turn every tool read lands on the turn's shared full-source snapshot when its window matches. On an account with many synced series that build passes the prompt budget, and the budget pass collapses blocks to a bare unit and then to an `{ omitted }` marker. get_metric_series sliced the marker out and answered `present: true` with it, so the model was told the readings exist and was given no figure. The same held for sleep, glucose, compliance and workouts. - The snapshot result names the blocks the budget pass cut. A tool whose block was cut in the shared build reads it again from its own source at the same window. - A read scoped to one source condenses its own blocks last and never swaps them for the marker: it keeps a summary (first, last, min, max, mean, change) taken before anything is cut, the newest daily values, weekly means folded into monthly means, and names each cut. - get_metric_series never answers present with a block holding no figure; that read reports retrieval_failed instead. - A chat series read carries the readings it counted, so the step shows them, and on a long or condensed window a note on what it covers. The tool description and a new tool-mode rule tell the model to call get_metric_table itself for longer history instead of asking. - The screen-reader comma between step parts is unselectable, so a copied row no longer reads "Weight · ,all time". MCP output is unchanged: the source-snapshot equality suite stays green. --- .../coach-panel/__tests__/turn-steps.test.tsx | 29 +- .../insights/coach-panel/turn-steps.tsx | 5 +- .../__tests__/series-long-window.test.ts | 304 ++++++++++++++++++ src/lib/ai/coach/series-condense.ts | 263 +++++++++++++++ src/lib/ai/coach/snapshot.ts | 54 +++- src/lib/ai/coach/tools/definitions.ts | 2 +- src/lib/ai/coach/tools/executor.ts | 166 ++++++++-- src/lib/ai/coach/tools/system-addendum.ts | 6 +- src/lib/ai/coach/turn/steps.ts | 17 +- .../coach-series-long-window.test.ts | 249 ++++++++++++++ 10 files changed, 1049 insertions(+), 46 deletions(-) create mode 100644 src/lib/ai/coach/__tests__/series-long-window.test.ts create mode 100644 src/lib/ai/coach/series-condense.ts create mode 100644 tests/integration/coach-series-long-window.test.ts diff --git a/src/components/insights/coach-panel/__tests__/turn-steps.test.tsx b/src/components/insights/coach-panel/__tests__/turn-steps.test.tsx index 163a86e83..d29209a83 100644 --- a/src/components/insights/coach-panel/__tests__/turn-steps.test.tsx +++ b/src/components/insights/coach-panel/__tests__/turn-steps.test.tsx @@ -243,7 +243,10 @@ describe("", () => { expect(html).toContain('data-status="done"'); expect(html).toContain('data-status="empty"'); expect(html).toContain('data-status="running"'); - let text = html.replace(/, <\/span>/g, ""); + let text = html.replace( + /, <\/span>/g, + "", + ); // Strip tags until nothing changes, so no fragment can survive one pass. for (let prev = ""; prev !== text;) { prev = text; @@ -349,3 +352,27 @@ describe("legacyAreaLabels", () => { ).toContain("1 Bereich angesehen"); }); }); + +describe("a step row copied out of the list", () => { + it("reads 'domain · window' without the screen reader's comma", () => { + const html = render( + , + "de", + ); + // What a selection copies: every text node except the unselectable ones. + const copied = html + .replace( + /]*class="[^"]*\bselect-none\b[^"]*"[^>]*>[^<]*<\/span>/g, + "", + ) + .replace(/<[^>]+>/g, "") + .replace(/\s+/g, " ") + .trim(); + expect(copied).toBe("Gewicht · letzte 90 Tage"); + // The comma is still there for a screen reader. + expect(html).toMatch(/class="sr-only select-none">, <\/span>/); + }); +}); diff --git a/src/components/insights/coach-panel/turn-steps.tsx b/src/components/insights/coach-panel/turn-steps.tsx index 16f354640..9e62b0d01 100644 --- a/src/components/insights/coach-panel/turn-steps.tsx +++ b/src/components/insights/coach-panel/turn-steps.tsx @@ -314,7 +314,10 @@ export function CoachTurnStepList({ {meta.map((part, i) => ( - , + {/* The comma is for a screen reader only. `select-none` + keeps it out of a copied row, which otherwise read + "Weight · ,all time". */} + , {part} ))} diff --git a/src/lib/ai/coach/__tests__/series-long-window.test.ts b/src/lib/ai/coach/__tests__/series-long-window.test.ts new file mode 100644 index 000000000..bb40f918b --- /dev/null +++ b/src/lib/ai/coach/__tests__/series-long-window.test.ts @@ -0,0 +1,304 @@ +/** + * A series read never answers "present" without numbers. + * + * Two layers: the condensing a requested block goes through when its own + * read passes the prompt budget (`series-condense.ts`), and the executor, + * which re-reads a block the turn's shared full-source build cut for its + * prompt, refuses to hand on a block with no figure in it, and tells the + * model where the rest of a long history is. + */ +import { DEFAULT_UNIT_PREFERENCES } from "@/lib/measurements/display-transform"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { CoachSnapshotResult } from "@/lib/ai/coach/snapshot"; +import { + KEEP_DAILY_ROWS, + KEEP_MONTHLY_ROWS, + condenseSeriesBlock, + isoWeekMonth, + summarisePoints, + weeklyToMonthly, +} from "@/lib/ai/coach/series-condense"; + +const buildCoachSnapshot = + vi.fn<(userId: string, scope?: unknown) => Promise>(); +vi.mock("@/lib/ai/coach/snapshot", async (importOriginal) => ({ + ...(await importOriginal()), + buildCoachSnapshot: (userId: string, scope?: unknown) => + buildCoachSnapshot(userId, scope), +})); +vi.mock("@/lib/db", () => ({ + prisma: { + measurement: { + groupBy: () => Promise.resolve([]), + findMany: () => Promise.resolve([]), + }, + }, +})); + +import { executeCoachTool } from "@/lib/ai/coach/tools/executor"; +import { buildToolModeAddendum } from "@/lib/ai/coach/tools/system-addendum"; +import { UNBOUNDED_REACH } from "@/lib/ai/coach/history-reach"; + +function weeks(count: number, from = 2023, value = (i: number) => 20 + i / 10) { + return Array.from({ length: count }, (_, i) => { + const year = from + Math.floor(i / 52); + const week = (i % 52) + 1; + return { + weekISO: `${year}-W${String(week).padStart(2, "0")}`, + mean: Math.round(value(i) * 10) / 10, + count: 7, + }; + }); +} + +function recent(count: number, value = (i: number) => 30 + i) { + return Array.from({ length: count }, (_, i) => ({ + date: `2026-09-${String(i + 1).padStart(2, "0")}`, + weekday: "Mon", + value: value(i), + })); +} + +describe("series-condense", () => { + it("places an ISO week in the month of its Thursday", () => { + expect(isoWeekMonth("2026-W01")).toBe("2026-01"); + expect(isoWeekMonth("2025-W01")).toBe("2025-01"); // Thu 2 Jan 2025 + expect(isoWeekMonth("2026-W53")).toBe("2026-12"); + expect(isoWeekMonth("2026-W05")).toBe("2026-01"); // Thu 29 Jan + expect(isoWeekMonth("nope")).toBeNull(); + }); + + it("folds weekly means into count-weighted monthly means", () => { + expect( + weeklyToMonthly([ + { weekISO: "2026-W01", mean: 10, count: 1 }, + { weekISO: "2026-W02", mean: 20, count: 3 }, + { weekISO: "2026-W06", mean: 5, count: 2 }, + ]), + ).toEqual([ + { month: "2026-01", mean: 17.5, count: 4 }, + { month: "2026-02", mean: 5, count: 2 }, + ]); + }); + + it("summarises points in time order", () => { + expect( + summarisePoints([ + { at: "2026-09-02", value: 18 }, + { at: "2025-W10", value: 21 }, + { at: "2026-09-01", value: 17 }, + ]), + ).toEqual({ + from: "2025-W10", + to: "2026-09-02", + first: 21, + last: 18, + min: 17, + max: 21, + mean: 18.67, + change: -3, + points: 3, + }); + expect(summarisePoints([])).toBeNull(); + }); + + it("keeps numbers through every step and names what it cut", () => { + const block = { + unit: "kg", + timeline: { recent: recent(14), weekly: weeks(150) }, + }; + expect(condenseSeriesBlock(block, 1)).toBe(true); + expect(block.timeline.recent).toHaveLength(KEEP_DAILY_ROWS); + expect(block.timeline.recent.at(-1)?.value).toBe(43); + expect(condenseSeriesBlock(block, 2)).toBe(true); + expect(block.timeline).not.toHaveProperty("weekly"); + const monthly = (block.timeline as { monthly?: unknown[] }).monthly; + expect(monthly!.length).toBeGreaterThan(30); + expect(condenseSeriesBlock(block, 3)).toBe(true); + expect((block.timeline as { monthly?: unknown[] }).monthly).toHaveLength( + KEEP_MONTHLY_ROWS, + ); + // The summary was taken before anything was cut: every week and day. + const summary = (block as { summary?: Record }).summary; + expect(summary).toMatchObject({ + from: "2023-W01", + to: "2026-09-14", + first: 20, + last: 43, + points: 164, + }); + expect((block as { condensed?: string[] }).condensed).toEqual([ + `daily values: newest ${KEEP_DAILY_ROWS} days kept`, + "weekly means folded into monthly means", + `monthly means: newest ${KEEP_MONTHLY_ROWS} months kept`, + ]); + }); + + it("summarises blood pressure per series", () => { + const block = { + timeline: { + recent: [{ date: "2026-09-01", weekday: "Tue", sys: 121, dia: 79 }], + weeklySys: [{ weekISO: "2026-W30", mean: 130, count: 4 }], + weeklyDia: [{ weekISO: "2026-W30", mean: 85, count: 4 }], + }, + }; + condenseSeriesBlock(block, 2); + expect(block).toMatchObject({ + summary: { + sys: { first: 130, last: 121, change: -9 }, + dia: { first: 85, last: 79, change: -6 }, + }, + timeline: { + monthlySys: [{ month: "2026-07", mean: 130, count: 4 }], + monthlyDia: [{ month: "2026-07", mean: 85, count: 4 }], + }, + }); + }); + + it("leaves a block without a timeline alone", () => { + const block = { aggregate: { mean: 1 } }; + expect(condenseSeriesBlock(block, 1)).toBe(false); + expect(block).toEqual({ aggregate: { mean: 1 } }); + }); +}); + +function snapshot( + sections: Record, + extra: Partial = {}, +): CoachSnapshotResult { + return { + snapshotJson: JSON.stringify(sections), + sections, + provenance: { windows: [], metrics: [] }, + referenceGrounding: null, + units: DEFAULT_UNIT_PREFERENCES, + ...extra, + }; +} + +const SHARED = { sources: ["weight", "fat_mass", "steps"], window: "allTime" }; + +function series(args: Record) { + return executeCoachTool({ + userId: "u1", + name: "get_metric_series", + rawArguments: JSON.stringify(args), + fallbackWindow: "allTime", + sharedScope: SHARED as never, + reach: UNBOUNDED_REACH, + }); +} + +const FAT_MASS = { + unit: "kg", + timeline: { recent: recent(14, () => 18.2), weekly: weeks(50) }, +}; + +describe("get_metric_series inside a chat turn", () => { + beforeEach(() => { + buildCoachSnapshot.mockReset(); + }); + + it("re-reads a block the shared build cut, from its own source", async () => { + buildCoachSnapshot.mockImplementation(async (_u, scope) => + scope === SHARED + ? snapshot( + { fatMass: { omitted: "trimmed for prompt budget" } }, + { degradedBlocks: ["fatMass", "steps"] }, + ) + : snapshot( + { fatMass: FAT_MASS }, + { + provenance: { + windows: [], + metrics: ["fat_mass"], + counts: { fat_mass: 1460 }, + }, + }, + ), + ); + const result = await series({ metric: "fat_mass", window: "allTime" }); + expect(buildCoachSnapshot).toHaveBeenCalledTimes(2); + expect(buildCoachSnapshot.mock.calls[1][1]).toEqual({ + sources: ["fat_mass"], + window: "allTime", + }); + expect(result.present).toBe(true); + const data = result.data as Record; + expect(data.section).toEqual(FAT_MASS); + expect(data.readings).toBe(1460); + expect(data.coverage).toContain("call get_metric_table yourself"); + }); + + it("keeps the shared build when the block was not cut there", async () => { + buildCoachSnapshot.mockResolvedValue( + snapshot({ fatMass: FAT_MASS }, { degradedBlocks: ["steps"] }), + ); + const result = await series({ metric: "fat_mass" }); + expect(buildCoachSnapshot).toHaveBeenCalledTimes(1); + expect(result.present).toBe(true); + }); + + it("never answers present with a block that holds no figure", async () => { + for (const empty of [ + { unit: "kg" }, + { omitted: "trimmed for prompt budget" }, + ]) { + buildCoachSnapshot.mockReset(); + buildCoachSnapshot.mockResolvedValue(snapshot({ fatMass: empty })); + const result = await series({ metric: "fat_mass", window: "last30days" }); + expect(result).toEqual({ present: false, reason: "retrieval_failed" }); + } + }); + + it("states no coverage note on a short window", async () => { + buildCoachSnapshot.mockResolvedValue(snapshot({ fatMass: FAT_MASS })); + const result = await series({ metric: "fat_mass", window: "last30days" }); + expect(result.data).not.toHaveProperty("coverage"); + }); +}); + +describe("the tool-mode rules", () => { + it.each(["en", "de"] as const)( + "tell the model to fetch the table itself (%s)", + (locale) => { + const text = buildToolModeAddendum(locale); + expect(text).toMatch(/^7\. .*get_metric_table/m); + expect(text).toContain("summary"); + }, + ); +}); + +describe("the prompt-budget pass on a single-source read", () => { + it("condenses the requested block last and never swaps it for a marker", async () => { + const { degradeToBudget } = await vi.importActual< + typeof import("@/lib/ai/coach/snapshot") + >("@/lib/ai/coach/snapshot"); + const big = () => ({ + unit: "kg", + timeline: { recent: recent(14), weekly: weeks(520, 2016) }, + }); + const snap: Record = { + fatMass: big(), + steps: big(), + walkingSpeed: big(), + }; + const clusters = new Map([ + ["fatMass", "body"], + ["steps", "activity"], + ["walkingSpeed", "mobility"], + ] as const); + const degraded = degradeToBudget( + snap, + new Map(clusters), + new Set(["fatMass"]), + ); + expect(snap.steps).toEqual({ omitted: "trimmed for prompt budget" }); + expect(snap.walkingSpeed).toEqual({ omitted: "trimmed for prompt budget" }); + const fat = snap.fatMass as Record; + expect(fat).not.toHaveProperty("omitted"); + expect(fat.summary).toMatchObject({ points: 534, last: 43 }); + expect(degraded.map((d) => d.key).at(-1)).toBe("fatMass"); + }); +}); diff --git a/src/lib/ai/coach/series-condense.ts b/src/lib/ai/coach/series-condense.ts new file mode 100644 index 000000000..8dcc2d714 --- /dev/null +++ b/src/lib/ai/coach/series-condense.ts @@ -0,0 +1,263 @@ +/** + * Condensing a requested series block to the prompt budget without emptying + * it. + * + * The snapshot's budget pass sheds detail from a block in three steps: the + * daily rows, then the weekly means, then the whole block for an + * `{ omitted }` marker. For a block the read did not ask for that is right; + * for the one it did ask for it leaves the model a block that says the + * readings exist and holds no figure, and the model then says exactly that. + * + * A requested block is condensed instead. Before anything is cut it gains a + * `summary` (first, last, min, max, mean and change over every point it held) + * and each step after that keeps numbers: + * + * 1. the daily rows keep their newest `KEEP_DAILY_ROWS`; + * 2. the weekly means fold into monthly means (count-weighted); + * 3. the monthly means keep their newest `KEEP_MONTHLY_ROWS`, and the + * coarse monthly band and anomaly envelope go (the yearly band stays). + * + * Every step it took is named in `condensed`, so the block says what it no + * longer carries. Pure over its input: no I/O, no clock. + */ +import { dateOnlyKey } from "@/lib/tz/date-only"; + +/** Daily rows a condensed block keeps, newest first in time order. */ +export const KEEP_DAILY_ROWS = 7; +/** Monthly means a fully condensed block keeps. */ +export const KEEP_MONTHLY_ROWS = 6; + +export interface SeriesSummary { + /** Earliest point's period: a day (`YYYY-MM-DD`) or an ISO week. */ + from: string; + /** Newest point's period. */ + to: string; + first: number; + last: number; + min: number; + max: number; + /** Mean of the points (daily and weekly means alike), not of readings. */ + mean: number; + /** `last - first`. */ + change: number; + /** How many points the figures above were taken over. */ + points: number; +} + +interface WeeklyBucket { + weekISO: string; + mean: number; + count: number; +} + +export interface MonthlyBucket { + month: string; + mean: number; + count: number; +} + +type Rec = Record; + +function isRecord(value: unknown): value is Rec { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function round(value: number): number { + return Math.round(value * 100) / 100; +} + +function isWeekly(value: unknown): value is WeeklyBucket[] { + return ( + Array.isArray(value) && + value.every( + (b) => + isRecord(b) && + typeof b.weekISO === "string" && + typeof b.mean === "number" && + typeof b.count === "number", + ) + ); +} + +/** ISO week `YYYY-Www` → the Monday that starts it, as `YYYY-MM-DD`. */ +function isoWeekMonday(weekISO: string): string | null { + const match = /^(\d{4})-W(\d{2})$/.exec(weekISO); + if (!match) return null; + const jan4 = new Date(Date.UTC(Number(match[1]), 0, 4)); + const monday = new Date(jan4); + monday.setUTCDate( + jan4.getUTCDate() - + (jan4.getUTCDay() || 7) + + 1 + + (Number(match[2]) - 1) * 7, + ); + return dateOnlyKey(monday); +} + +/** + * A sortable key for a point's period. A week and a day do not compare as + * strings ("2026-W30" sorts after "2026-09-01"), so a week sorts by its + * Monday. + */ +function timeKey(at: string): string { + return isoWeekMonday(at) ?? at; +} + +/** Summary over points (any order), or null when there are none. */ +export function summarisePoints( + points: ReadonlyArray<{ at: string; value: number }>, +): SeriesSummary | null { + const finite = points.filter((p) => Number.isFinite(p.value)); + if (finite.length === 0) return null; + const sorted = [...finite].sort((a, b) => + timeKey(a.at).localeCompare(timeKey(b.at)), + ); + const values = sorted.map((p) => p.value); + const first = values[0]; + const last = values[values.length - 1]; + return { + from: sorted[0].at, + to: sorted[sorted.length - 1].at, + first, + last, + min: Math.min(...values), + max: Math.max(...values), + mean: round(values.reduce((s, v) => s + v, 0) / values.length), + change: round(last - first), + points: values.length, + }; +} + +/** + * The calendar month (`YYYY-MM`) an ISO week belongs to, by its Thursday — + * the same day ISO 8601 uses to place a week in its year. + */ +export function isoWeekMonth(weekISO: string): string | null { + const match = /^(\d{4})-W(\d{2})$/.exec(weekISO); + if (!match) return null; + const year = Number(match[1]); + const week = Number(match[2]); + const jan4 = new Date(Date.UTC(year, 0, 4)); + const jan4Day = jan4.getUTCDay() || 7; + const thursday = new Date(jan4); + thursday.setUTCDate(jan4.getUTCDate() - jan4Day + 4 + (week - 1) * 7); + return dateOnlyKey(thursday).slice(0, 7); +} + +/** Weekly means folded into count-weighted monthly means, in time order. */ +export function weeklyToMonthly( + weekly: ReadonlyArray, +): MonthlyBucket[] { + const months = new Map(); + for (const bucket of weekly) { + const month = isoWeekMonth(bucket.weekISO); + if (!month || bucket.count <= 0) continue; + const acc = months.get(month) ?? { sum: 0, count: 0 }; + acc.sum += bucket.mean * bucket.count; + acc.count += bucket.count; + months.set(month, acc); + } + return Array.from(months.entries()) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([month, { sum, count }]) => ({ + month, + mean: Math.round((sum / count) * 10) / 10, + count, + })); +} + +/** + * The value series a timeline carries: `value` over `weekly` for a single + * series, `sys`/`dia` over `weeklySys`/`weeklyDia` for blood pressure. + */ +const SERIES: ReadonlyArray<{ + name: string | null; + field: string; + weekly: string; + monthly: string; +}> = [ + { name: null, field: "value", weekly: "weekly", monthly: "monthly" }, + { name: "sys", field: "sys", weekly: "weeklySys", monthly: "monthlySys" }, + { name: "dia", field: "dia", weekly: "weeklyDia", monthly: "monthlyDia" }, +]; + +function blockSummary(timeline: Rec): unknown { + const recent = Array.isArray(timeline.recent) ? timeline.recent : []; + const out: Rec = {}; + for (const s of SERIES) { + const points: Array<{ at: string; value: number }> = []; + const weekly = timeline[s.weekly]; + if (isWeekly(weekly)) { + for (const b of weekly) points.push({ at: b.weekISO, value: b.mean }); + } + for (const row of recent) { + if (isRecord(row) && typeof row.date === "string") { + const value = row[s.field]; + if (typeof value === "number") points.push({ at: row.date, value }); + } + } + const summary = summarisePoints(points); + if (summary) { + if (s.name === null) return summary; + out[s.name] = summary; + } + } + return Object.keys(out).length > 0 ? out : undefined; +} + +function note(block: Rec, text: string): void { + const list = Array.isArray(block.condensed) ? block.condensed : []; + if (!list.includes(text)) list.push(text); + block.condensed = list; +} + +/** + * Take one condensing step on a block that carries a `timeline`. Returns + * whether anything changed; a block without a timeline is left alone and + * reports false. + */ +export function condenseSeriesBlock(block: unknown, step: 1 | 2 | 3): boolean { + if (!isRecord(block) || !isRecord(block.timeline)) return false; + const timeline = block.timeline; + if (block.summary === undefined) { + const summary = blockSummary(timeline); + if (summary !== undefined) block.summary = summary; + } + let changed = false; + if (step === 1) { + if ( + Array.isArray(timeline.recent) && + timeline.recent.length > KEEP_DAILY_ROWS + ) { + timeline.recent = timeline.recent.slice(-KEEP_DAILY_ROWS); + note(block, `daily values: newest ${KEEP_DAILY_ROWS} days kept`); + changed = true; + } + } else if (step === 2) { + for (const s of SERIES) { + const weekly = timeline[s.weekly]; + if (!isWeekly(weekly)) continue; + timeline[s.monthly] = weeklyToMonthly(weekly); + delete timeline[s.weekly]; + note(block, "weekly means folded into monthly means"); + changed = true; + } + } else { + for (const s of SERIES) { + const monthly = timeline[s.monthly]; + if (Array.isArray(monthly) && monthly.length > KEEP_MONTHLY_ROWS) { + timeline[s.monthly] = monthly.slice(-KEEP_MONTHLY_ROWS); + note(block, `monthly means: newest ${KEEP_MONTHLY_ROWS} months kept`); + changed = true; + } + } + const coarse = timeline.coarse; + if (isRecord(coarse) && ("monthly" in coarse || "anomalies" in coarse)) { + delete coarse.monthly; + delete coarse.anomalies; + note(block, "coarse monthly band and anomalies left out"); + changed = true; + } + } + return changed; +} diff --git a/src/lib/ai/coach/snapshot.ts b/src/lib/ai/coach/snapshot.ts index 10ee28d0b..4c7bae0dd 100644 --- a/src/lib/ai/coach/snapshot.ts +++ b/src/lib/ai/coach/snapshot.ts @@ -33,6 +33,7 @@ import { resolveSnapshotPrelude, } from "./snapshot-prelude"; import { annotateSnapshotFreshness } from "./snapshot-freshness"; +import { condenseSeriesBlock } from "./series-condense"; import { buildGlp1SnapshotBlock } from "./glp1-snapshot"; import { buildDerivedSnapshotBlock } from "./derived-snapshot"; import { buildCorrelationsSnapshotBlock } from "./correlations-snapshot"; @@ -136,6 +137,12 @@ export interface CoachSnapshotResult { * units, so a reply never mixes the two. */ units: UnitPreferences; + /** + * The blocks the prompt-budget pass cut or condensed, absent when it cut + * nothing. A tool reading one block out of a shared build checks this: a + * block cut for somebody else's prompt is no answer to its own read. + */ + degradedBlocks?: ReadonlyArray; } /** @@ -325,9 +332,13 @@ async function buildCoachSnapshotImpl( // Snapshot top-level keys that carry an `aggregate` companion — the // degrader can drop `timeline.recent` from these and still leave the // aggregate for the Coach to reason from. + // The source each block was built for, so a single-source read can tell + // which blocks it asked for (see `degradeToBudget`). + const blockSources = new Map(); const registerBlock = (key: string, source: CoachScopeSource) => { const cluster = sourceCluster(source); if (cluster) blockClusters.set(key, cluster); + blockSources.set(key, source); }; // v1.7.0 — record which clusters resolved active for this build so @@ -1046,7 +1057,26 @@ async function buildCoachSnapshotImpl( // `MAX_SNAPSHOT_CHARS`. The `scope` block is exempt — the model // needs it to know what is in-bounds. The helper emits its own // `coach.snapshot.truncated` annotation when it sheds anything. - degradeToBudget(compactSnapshot, blockClusters); + // + // A read scoped to one source (every retrieval tool, the MCP metric reads, + // a metric page) asked for that source's blocks: those are condensed, never + // emptied, and only after everything else has been shed. + const requestedSource = + Array.isArray(scope?.sources) && scope.sources.length === 1 + ? scope.sources[0] + : null; + const requestedBlocks = new Set( + requestedSource === null + ? [] + : Array.from(blockSources.entries()) + .filter(([, source]) => source === requestedSource) + .map(([key]) => key), + ); + const degraded = degradeToBudget( + compactSnapshot, + blockClusters, + requestedBlocks, + ); // v1.18.6 (W7) — build the citation-aware reference-grounding block from the // representative scalars collected above. Deterministic + pure; the route @@ -1088,6 +1118,9 @@ async function buildCoachSnapshotImpl( }, referenceGrounding, units, + ...(degraded.length > 0 + ? { degradedBlocks: Array.from(new Set(degraded.map((d) => d.key))) } + : {}), }; } @@ -1105,9 +1138,10 @@ async function buildCoachSnapshotImpl( * Returns the list of `{ key, cluster, pass }` it degraded — empty when * the snapshot already fit. */ -function degradeToBudget( +export function degradeToBudget( snapshot: Record, blockClusters: Map, + requested: ReadonlySet = new Set(), ): Array<{ key: string; cluster: CoachDataCluster; pass: number }> { const degraded: Array<{ key: string; @@ -1128,6 +1162,10 @@ function degradeToBudget( const orderedKeys = Array.from(blockClusters.entries()).sort((a, b) => { const pa = priorityIndex.get(a[1]) ?? -1; const pb = priorityIndex.get(b[1]) ?? -1; + // A block the read asked for goes last, whatever its cluster. + const ra = requested.has(a[0]) ? 1 : 0; + const rb = requested.has(b[0]) ? 1 : 0; + if (ra !== rb) return ra - rb; // Higher priority index = lower signal = degrade first. return pb - pa; }); @@ -1258,10 +1296,22 @@ function degradeToBudget( // until it is genuinely the last lever left. for (const [key, cluster] of orderedKeys) { if (size() <= MAX_SNAPSHOT_CHARS) break; + // A requested series is condensed step by step and keeps its numbers + // (`series-condense.ts`); it is never swapped for the `omitted` marker. + if (requested.has(key) && asRecord(asRecord(snapshot[key])?.timeline)) { + for (const step of [1, 2, 3] as const) { + if (size() <= MAX_SNAPSHOT_CHARS) break; + if (condenseSeriesBlock(snapshot[key], step)) { + degraded.push({ key, cluster, pass: step }); + } + } + continue; + } if (dropRecent(key)) degraded.push({ key, cluster, pass: 1 }); if (size() <= MAX_SNAPSHOT_CHARS) break; if (dropWeekly(key)) degraded.push({ key, cluster, pass: 2 }); if (size() <= MAX_SNAPSHOT_CHARS) break; + if (requested.has(key)) continue; if (dropBlock(key)) degraded.push({ key, cluster, pass: 3 }); } diff --git a/src/lib/ai/coach/tools/definitions.ts b/src/lib/ai/coach/tools/definitions.ts index 69017aa35..6631e0b3a 100644 --- a/src/lib/ai/coach/tools/definitions.ts +++ b/src/lib/ai/coach/tools/definitions.ts @@ -215,7 +215,7 @@ export const COACH_TOOL_DEFS: AiToolDef[] = [ { name: "get_metric_series", description: - "Fetch the user's own time series for ONE metric: blood pressure (bp), weight, pulse, or any synced series (hrv, resting_hr, steps, sleep duration, body composition, gait, audio exposure, vo2_max, …). Returns an aggregate plus a recent-daily and weekly timeline. Call once per metric you need; call several in parallel for a multi-metric question. Returns { present: false } with a reason when nothing came back: no_data (never recorded), outside_window (recorded, but older than the window searched — the result carries the count, date range and aggregate, and the window to re-call with), unavailable_in_scope, or no_data_unconfirmed.", + "Fetch the user's own time series for ONE metric: blood pressure (bp), weight, pulse, or any synced series (hrv, resting_hr, steps, sleep duration, body composition, gait, audio exposure, vo2_max, …). Returns an aggregate plus a recent-daily and weekly timeline reaching back at most 12 months (older history only as the coarse monthly and yearly means); for a longer history or a table, call get_metric_table yourself. Call once per metric you need; call several in parallel for a multi-metric question. Returns { present: false } with a reason when nothing came back: no_data (never recorded), outside_window (recorded, but older than the window searched — the result carries the count, date range and aggregate, and the window to re-call with), unavailable_in_scope, or no_data_unconfirmed.", parameters: { type: "object", additionalProperties: false, diff --git a/src/lib/ai/coach/tools/executor.ts b/src/lib/ai/coach/tools/executor.ts index b1f4f94e3..fde8110dc 100644 --- a/src/lib/ai/coach/tools/executor.ts +++ b/src/lib/ai/coach/tools/executor.ts @@ -46,7 +46,10 @@ import { annotate } from "@/lib/logging/context"; import type { Locale } from "@/lib/i18n/config"; import { resolveUserTimezone } from "@/lib/tz/resolver"; import { resolveModuleMap } from "@/lib/modules/gate"; -import { buildCoachSnapshot } from "@/lib/ai/coach/snapshot"; +import { + buildCoachSnapshot, + type CoachSnapshotResult, +} from "@/lib/ai/coach/snapshot"; import { buildCoachSourceSnapshot } from "@/lib/ai/coach/source-snapshot"; import { admitCoachSources, coachExclusions } from "@/lib/ai/coach/scope-gate"; import type { CoachPrefs } from "@/lib/validations/coach-prefs"; @@ -252,6 +255,44 @@ function scopeFor( }; } +/** + * The snapshot a tool slices its block(s) out of. + * + * Reads under `scopeFor` first, which lands on the turn's shared full-source + * build when the windows match. That build is cut to the prompt budget for a + * prompt this read never sends: on an account with many synced series the + * budget pass collapses whole blocks to an `{ omitted }` marker, and slicing + * the marker out answered `present: true` with no figure in it. So when a + * block this read needs was cut there, it is read again from its own sources + * at the same window, where it is the block that was asked for. + */ +async function readToolSnapshot( + userId: string, + sources: CoachScope["sources"], + sectionKeys: ReadonlyArray, + window: CoachScopeWindow | undefined, + fallbackWindow: CoachScopeWindow | undefined, + sharedScope: CoachScope | undefined, + reach: CoachHistoryReach, +) { + const scope = scopeFor(sources, window, fallbackWindow, sharedScope, reach); + const snapshot = await buildCoachSnapshot(userId, scope, { reach }); + if (scope !== sharedScope) return snapshot; + const cut = sectionKeys.filter((key) => + snapshot.degradedBlocks?.includes(key), + ); + if (cut.length === 0) return snapshot; + annotate({ + action: { name: "coach.tool.shared_block_cut" }, + meta: { blocks: cut, window: scope.window ?? DEFAULT_WINDOW }, + }); + return buildCoachSnapshot( + userId, + { sources, window: scope.window }, + { reach }, + ); +} + /** * Execute one tool call. `rawArguments` is the model's raw JSON-string * arguments (parsed + validated here, never trusted blindly). Returns a @@ -507,10 +548,14 @@ async function getMetricSeries( // reads without the Coach's lookback limit, so it takes no reach. const snapshot = sourceSnapshot ? await buildCoachSourceSnapshot(userId, metric, window ?? fallbackWindow) - : await buildCoachSnapshot( + : await readToolSnapshot( userId, - scopeFor([metric], window, fallbackWindow, sharedScope, reach), - { reach }, + [metric], + [sectionKey], + window, + fallbackWindow, + sharedScope, + reach, ); const section = pickSection(snapshot.sections, sectionKey); if (section === undefined) { @@ -522,13 +567,66 @@ async function getMetricSeries( reach, ); } + // A block without a single figure (a bare unit, an `omitted` marker) is + // not an answer: "present" with nothing in it is what the model then + // repeats to the person. Say the read failed instead. + if (!carriesFigures(section)) { + annotate({ + action: { name: "coach.tool.section_without_values" }, + meta: { tool: "get_metric_series", metric }, + }); + return { present: false, reason: "retrieval_failed" }; + } + if (sourceSnapshot) { + // MCP: the section exactly as it always was. + return { + present: true, + data: { metric, section }, + grounding: snapshot.referenceGrounding ?? undefined, + }; + } + // Outside MCP the read is a Coach snapshot, which counts what it read. + const counts = (snapshot as Partial).provenance + ?.counts as Record | undefined; + const readings = counts?.[metric]; + const effectiveWindow = limitWindow(window ?? fallbackWindow, reach); + const condensed = isRecord(section) && Array.isArray(section.condensed); + const long = effectiveWindow === "allTime" || effectiveWindow === "lastYear"; return { present: true, - data: { metric, section }, + data: { + metric, + section, + ...(typeof readings === "number" ? { readings } : {}), + ...(long || condensed ? { coverage: SERIES_COVERAGE_NOTE } : {}), + }, grounding: snapshot.referenceGrounding ?? undefined, }; } +/** + * What a long or condensed series read carries, and where the rest is. Rides + * the result so the model switches to the table tool on its own instead of + * asking the person whether it should. + */ +const SERIES_COVERAGE_NOTE = + "Daily values for the newest 14 days, weekly means before that, back at most 12 months; " + + "timeline.coarse (when present) adds monthly and yearly means for older history. " + + "A 'condensed' list names what was cut to fit, and 'summary' holds first, last, min, max, mean and change over every point. " + + "For the full history or a finer table, call get_metric_table yourself in this answer (window allTime, granularity month or week); do not ask the person first."; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** Whether a payload holds at least one finite number anywhere. */ +function carriesFigures(value: unknown): boolean { + if (typeof value === "number") return Number.isFinite(value); + if (Array.isArray(value)) return value.some(carriesFigures); + if (isRecord(value)) return Object.values(value).some(carriesFigures); + return false; +} + async function getGlucosePanel( userId: string, rawArgs: unknown, @@ -538,16 +636,14 @@ async function getGlucosePanel( ): Promise { const parsed = getGlucosePanelArgsSchema.safeParse(rawArgs); if (!parsed.success) return badArgs("get_glucose_panel", parsed.error); - const snapshot = await buildCoachSnapshot( + const snapshot = await readToolSnapshot( userId, - scopeFor( - ["glucose"], - parsed.data.window, - fallbackWindow, - sharedScope, - reach, - ), - { reach }, + ["glucose"], + ["glucose"], + parsed.data.window, + fallbackWindow, + sharedScope, + reach, ); const section = pickSection(snapshot.sections, "glucose"); if (section === undefined) { @@ -575,10 +671,14 @@ async function getSleep( ): Promise { const parsed = getSleepArgsSchema.safeParse(rawArgs); if (!parsed.success) return badArgs("get_sleep", parsed.error); - const snapshot = await buildCoachSnapshot( + const snapshot = await readToolSnapshot( userId, - scopeFor(["sleep"], parsed.data.window, fallbackWindow, sharedScope, reach), - { reach }, + ["sleep"], + ["sleep", "sleepRhythm"], + parsed.data.window, + fallbackWindow, + sharedScope, + reach, ); const nights = pickSection(snapshot.sections, "sleep"); const rhythm = pickSection(snapshot.sections, "sleepRhythm"); @@ -612,16 +712,14 @@ async function getMedicationCompliance( if (!parsed.success) { return badArgs("get_medication_compliance", parsed.error); } - const snapshot = await buildCoachSnapshot( + const snapshot = await readToolSnapshot( userId, - scopeFor( - ["compliance"], - parsed.data.window, - fallbackWindow, - sharedScope, - reach, - ), - { reach }, + ["compliance"], + ["compliance", "weeklyContext"], + parsed.data.window, + fallbackWindow, + sharedScope, + reach, ); const compliance = pickSection(snapshot.sections, "compliance"); // GLP-1 context rides the `weeklyContext` block. @@ -789,16 +887,14 @@ async function getWorkouts( if (!parsed.success) return badArgs("get_workouts", parsed.error); // The workouts block builds when the `workouts` cluster is active AND the // user has workout rows in the window. Scope the read to that single source. - const snapshot = await buildCoachSnapshot( + const snapshot = await readToolSnapshot( userId, - scopeFor( - ["workouts"], - parsed.data.window, - fallbackWindow, - sharedScope, - reach, - ), - { reach }, + ["workouts"], + ["workouts"], + parsed.data.window, + fallbackWindow, + sharedScope, + reach, ); const workouts = pickSection(snapshot.sections, "workouts"); if (workouts === undefined) { diff --git a/src/lib/ai/coach/tools/system-addendum.ts b/src/lib/ai/coach/tools/system-addendum.ts index ce9091531..3760f1687 100644 --- a/src/lib/ai/coach/tools/system-addendum.ts +++ b/src/lib/ai/coach/tools/system-addendum.ts @@ -45,7 +45,8 @@ You have read-only retrieval tools and a DATA INVENTORY listing what the user ha In every case: do NOT infer, estimate, or fabricate a value, and do NOT ask the user to supply readings the record already holds. 4. Stay on the metric the user asked about. A miss on that metric is an answer ABOUT that metric — never answer a question about one metric by reporting a different one instead. On a vague follow-up ("check again", "how were they?"), the subject is still the metric the conversation was already on. 5. If you can answer without data (a definition, a "what can you help with?"), just answer — do not call a tool. -6. Keep using the EVIDENCE (---KEYVALUES---) block exactly as before, citing only numbers you fetched this turn.`; +6. Keep using the EVIDENCE (---KEYVALUES---) block exactly as before, citing only numbers you fetched this turn. +7. A present get_metric_series result always carries numbers. When it is "condensed", cite its "summary" and the points it kept. When the question needs more history or finer detail than a series carries (all time, every month, a table), call get_metric_table yourself in this same answer. Never ask the user whether you should fetch it, and never tell them the numbers were not given to you.`; const DE = `TOOL-BASIERTE ABFRAGE (diese Unterhaltung) @@ -62,7 +63,8 @@ Du hast schreibgeschützte Abfrage-Tools und ein DATA INVENTORY, das auflistet, In jedem Fall: leite nichts ab, schätze nichts, erfinde keinen Wert — und bitte den Nutzer nicht, Werte nachzuliefern, die längst erfasst sind. 4. Bleibe bei der Metrik, nach der gefragt wurde. Ein Fehlschlag zu dieser Metrik ist eine Antwort ÜBER diese Metrik — beantworte eine Frage zu einer Metrik niemals dadurch, dass du stattdessen über eine andere berichtest. Bei einer vagen Rückfrage („schau nochmal", „wie waren sie?") bleibt das Thema die Metrik, um die es gerade ging. 5. Wenn du ohne Daten antworten kannst (eine Definition, „Wobei kannst du helfen?"), antworte einfach — rufe kein Tool auf. -6. Nutze den EVIDENZ-Block (---KEYVALUES---) genau wie zuvor und zitiere nur Zahlen, die du in diesem Zug abgerufen hast.`; +6. Nutze den EVIDENZ-Block (---KEYVALUES---) genau wie zuvor und zitiere nur Zahlen, die du in diesem Zug abgerufen hast. +7. Ein vorhandenes Ergebnis von get_metric_series enthält immer Zahlen. Ist es „condensed", nenne die Werte aus „summary" und die Punkte, die es behalten hat. Braucht die Frage mehr Verlauf oder feinere Werte, als eine Serie trägt (gesamter Zeitraum, jeder Monat, eine Tabelle), rufe get_metric_table in derselben Antwort selbst auf. Frage den Nutzer nie, ob du das abrufen sollst, und sage nie, die Zahlen seien dir nicht mitgegeben worden.`; export function buildToolModeAddendum(locale: Locale): string { return locale === "de" ? DE : EN; diff --git a/src/lib/ai/coach/turn/steps.ts b/src/lib/ai/coach/turn/steps.ts index 882b5cfb6..96a7f0126 100644 --- a/src/lib/ai/coach/turn/steps.ts +++ b/src/lib/ai/coach/turn/steps.ts @@ -173,7 +173,8 @@ function tableSummaryCount(data: Record): number | undefined { /** * How many readings or rows a present result covered, when the result says * so in a field the server counted: - * - a metric series: the aggregate's coverage count; + * - a metric series: the aggregate's coverage count, else the readings the + * snapshot counted for it; * - a metric table: the readings its periods fold; * - workouts: the sessions in the window; * - labs: the biomarkers returned (one row each). @@ -186,9 +187,17 @@ function presentCount(tool: CoachToolName, data: unknown): number | undefined { return tableSummaryCount(data); case "get_metric_series": { const section = data.section; - if (!isRecord(section) || !isRecord(section.aggregate)) return undefined; - const coverage = section.aggregate.coverage; - return isRecord(coverage) ? asCount(coverage.count) : undefined; + const coverage = + isRecord(section) && isRecord(section.aggregate) + ? section.aggregate.coverage + : undefined; + // The aggregate's count where the block has one; a series without an + // aggregate (body composition, the synced series) carries the readings + // the snapshot counted beside the section. + return ( + (isRecord(coverage) ? asCount(coverage.count) : undefined) ?? + asCount(data.readings) + ); } case "get_workouts": return asCount(data.totalInWindow); diff --git a/tests/integration/coach-series-long-window.test.ts b/tests/integration/coach-series-long-window.test.ts new file mode 100644 index 000000000..b7df7e6a9 --- /dev/null +++ b/tests/integration/coach-series-long-window.test.ts @@ -0,0 +1,249 @@ +/** + * A Coach series read over a long window on an account with years of scale + * data must hand the model numbers. + * + * Inside a chat turn every tool read lands on the turn's shared full-source + * snapshot when its window matches the turn's. On an account with many synced + * series that snapshot passes the prompt budget, and the budget pass collapses + * the body-composition blocks first to a bare unit and then to an + * `{ omitted }` marker. The tool used to slice that marker out and answer + * `present: true` with it, so the model was told the readings exist and got + * no figure: "the data are there, but the numbers were not handed to me". + * + * The account here holds four years of daily scale readings (every body + * composition type a scale reports) plus a year of steps and pulse, which is + * enough for the full-source build to collapse those blocks. The tool must + * still return the series with its values, and the step the person sees must + * name what was read. + */ +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; + +import { getPrismaClient, truncateAllTables } from "./setup"; +import { executeCoachTool } from "@/lib/ai/coach/tools/executor"; +import { buildCoachDataInventory } from "@/lib/ai/coach/tools/inventory"; +import { + __resetCoachSnapshotCacheForTests, + buildCoachSnapshot, +} from "@/lib/ai/coach/snapshot"; +import { UNBOUNDED_REACH } from "@/lib/ai/coach/history-reach"; +import { recomputeUserRollups } from "@/lib/rollups/measurement-rollups"; +import { toStep } from "@/lib/ai/coach/turn/steps"; +import { describeStep } from "@/components/insights/coach-panel/turn-steps"; +import { getServerTranslator } from "@/lib/i18n/server-translator"; +import { pluralKey } from "@/lib/i18n/plural"; +import type { MeasurementType } from "@/generated/prisma/client"; + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/jobs/boss-instance", () => ({ + getGlobalBoss: vi.fn(() => null), +})); + +const DAY = 86_400_000; +const YEARS = 4; + +const SCALE: Array<{ + type: MeasurementType; + unit: string; + value: (d: number) => number; +}> = [ + { type: "WEIGHT", unit: "kg", value: (d) => 84 - d * 0.002 }, + { type: "BODY_FAT", unit: "%", value: (d) => 24 - d * 0.001 }, + { type: "FAT_MASS", unit: "kg", value: (d) => 20.1 - d * 0.001 }, + { type: "FAT_FREE_MASS", unit: "kg", value: (d) => 63.4 + (d % 5) * 0.1 }, + { type: "MUSCLE_MASS", unit: "kg", value: (d) => 60.2 + (d % 7) * 0.1 }, + { type: "LEAN_BODY_MASS", unit: "kg", value: (d) => 64.1 + (d % 3) * 0.1 }, + { type: "BONE_MASS", unit: "kg", value: (d) => 3.2 + (d % 2) * 0.1 }, + { type: "TOTAL_BODY_WATER", unit: "kg", value: (d) => 46.3 + (d % 4) * 0.1 }, + { type: "BODY_MASS_INDEX", unit: "kg/m2", value: (d) => 26 - d * 0.0005 }, + { type: "VISCERAL_FAT", unit: "", value: (d) => 9 + (d % 3) }, +]; + +let userId = ""; + +function call( + name: string, + args: Record, + sharedScope?: { + sources?: string[]; + window?: string; + }, +) { + return executeCoachTool({ + userId, + name, + rawArguments: JSON.stringify(args), + fallbackWindow: "allTime", + sharedScope: sharedScope as never, + reach: UNBOUNDED_REACH, + }); +} + +/** Every finite number anywhere in a payload. */ +function numbersIn(value: unknown): number[] { + if (typeof value === "number") return Number.isFinite(value) ? [value] : []; + if (Array.isArray(value)) return value.flatMap(numbersIn); + if (value && typeof value === "object") { + return Object.values(value).flatMap(numbersIn); + } + return []; +} + +beforeAll(async () => { + const prisma = getPrismaClient(); + await truncateAllTables(prisma); + const user = await prisma.user.create({ + data: { + username: "coach-series-long-window", + email: "coach-series-long-window@example.test", + timezone: "Europe/Berlin", + dateOfBirth: new Date("1980-05-01T00:00:00Z"), + heightCm: 180, + createdAt: new Date(Date.now() - (YEARS * 365 + 10) * DAY), + }, + }); + userId = user.id; + const now = Date.now(); + const rows: Array<{ + userId: string; + type: MeasurementType; + value: number; + unit: string; + source: "WITHINGS" | "APPLE_HEALTH"; + measuredAt: Date; + externalId?: string; + }> = []; + for (let d = 0; d < YEARS * 365; d++) { + const at = new Date(now - d * DAY - 6 * 3_600_000); + for (const s of SCALE) { + rows.push({ + userId, + type: s.type, + value: Math.round(s.value(d) * 10) / 10, + unit: s.unit, + source: "WITHINGS", + measuredAt: at, + }); + } + } + for (let d = 1; d <= 365; d++) { + rows.push({ + userId, + type: "ACTIVITY_STEPS", + value: 6000 + ((d * 977) % 5000), + unit: "steps", + source: "APPLE_HEALTH", + measuredAt: new Date(now - d * DAY), + externalId: `stats:HKQuantityTypeIdentifierStepCount:${d}`, + }); + for (const [type, unit, base] of [ + ["PULSE", "bpm", 62], + ["RESTING_HEART_RATE", "bpm", 54], + ["HEART_RATE_VARIABILITY", "ms", 41], + ["ACTIVE_ENERGY_BURNED", "kcal", 480], + ["WALKING_RUNNING_DISTANCE", "km", 5], + ["WALKING_SPEED", "km/h", 4.8], + ["RESPIRATORY_RATE", "breaths/min", 14], + ["OXYGEN_SATURATION", "%", 96], + ] as const) { + rows.push({ + userId, + type, + value: base + (d % 5), + unit, + source: "APPLE_HEALTH", + measuredAt: new Date(now - d * DAY - 9 * 3_600_000), + }); + } + } + for (let i = 0; i < rows.length; i += 5_000) { + await prisma.measurement.createMany({ data: rows.slice(i, i + 5_000) }); + } + await recomputeUserRollups(userId, { + from: new Date(now - (YEARS * 365 + 5) * DAY), + to: new Date(now), + }); +}, 240_000); + +afterAll(() => { + __resetCoachSnapshotCacheForTests(); +}); + +describe("a body-composition series over all time inside a chat turn", () => { + it("collapses the scale blocks in the shared full-source build (the precondition)", async () => { + __resetCoachSnapshotCacheForTests(); + const inventory = await buildCoachDataInventory( + userId, + { window: "allTime" }, + UNBOUNDED_REACH, + ); + const shared = await buildCoachSnapshot(userId, inventory.probeScope, { + reach: UNBOUNDED_REACH, + }); + // Without this the test below would not exercise the defect at all. + expect(numbersIn(shared.sections.fatMass)).toEqual([]); + }); + + it.each(["fat_mass", "body_fat", "weight", "muscle_mass"] as const)( + "%s returns its values, not a bare 'present'", + async (metric) => { + __resetCoachSnapshotCacheForTests(); + const inventory = await buildCoachDataInventory( + userId, + { window: "allTime" }, + UNBOUNDED_REACH, + ); + const result = await call( + "get_metric_series", + { metric, window: "allTime" }, + inventory.probeScope, + ); + expect(result.present).toBe(true); + const data = result.data as { section: Record }; + expect(data.section).not.toHaveProperty("omitted"); + const timeline = data.section.timeline as + { recent?: unknown[]; weekly?: unknown[] } | undefined; + expect(timeline?.recent?.length ?? 0).toBeGreaterThan(0); + expect(timeline?.weekly?.length ?? 0).toBeGreaterThan(40); + expect(numbersIn(data.section).length).toBeGreaterThan(100); + // A long window says what it holds and where the rest is. + expect((result.data as { coverage?: string }).coverage).toContain( + "get_metric_table", + ); + }, + ); + + it("labels the step with the domain and the window, separated cleanly", async () => { + __resetCoachSnapshotCacheForTests(); + const inventory = await buildCoachDataInventory( + userId, + { window: "allTime" }, + UNBOUNDED_REACH, + ); + const args = { metric: "fat_mass", window: "allTime" }; + const result = await call("get_metric_series", args, inventory.probeScope); + const step = toStep({ + call: { + id: "c1", + name: "get_metric_series", + arguments: JSON.stringify(args), + }, + index: 0, + parsedArgs: args, + result, + locale: "de", + fallbackWindow: "allTime", + }); + expect(step).not.toBeNull(); + expect(step!.status).toBe("done"); + expect(step!.label).toBe("Prüfe: Fettmasse, gesamter Zeitraum"); + const { t } = getServerTranslator("de"); + const tCount = (base: string, count: number) => + t(pluralKey(base, count, "de"), { count }); + const { title, meta } = describeStep(step!, t, tCount); + expect([title, ...meta].join(" · ")).toMatch( + /^Fettmasse · gesamter Zeitraum · \d+ Messwerte$/, + ); + }); +}); From ff0769f045e0587d00ead7027773ea57ceb53c83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 5 Oct 2026 21:30:29 +0200 Subject: [PATCH 05/19] fix(dashboard): lay the Today hero out for a phone Below md the score ring sat centred under the Today facts as a 168 px dial, where the bottom navigation covered it. The hero is now one grid with two arrangements: on a phone the ring is a compact 80 px dial at the top right beside the lead and signal line, flush with the card's trailing edge, and the Today facts run the full card width under both; from md up the ring spans both rows of the trailing column exactly as before (desktop captures are pixel-identical). Without a lead the facts take the leading cell, so the ring never sits beside an empty row. ScoreRing gains a `responsive` size (80 px, md:168 px) whose label is left out below md; the ring keeps its aria-label and the surrounding link names it. The meta lines under the phone dial are capped to its width, so the lead keeps at least 180 px beside it at 360 px. Each fact now stacks its muted label above its foreground value on the reading edge at every width, instead of a label-left / value-right row that broke long values into a ragged right-aligned block. Rows stay at the 44 px tap floor below md. The Today overview e2e covers 1440, 390 and 360 px in both themes for an AI lead, a long AI lead, an AI-off day and a quiet day, and asserts the dial size, its place beside the lead, symmetric insets, full-width facts and stacked 44 px rows on a phone. --- e2e/today-overview.spec.ts | 277 +++++++++++------ .../daily/__tests__/today-hero.test.tsx | 48 ++- src/components/daily/today-hero.tsx | 282 +++++++++++------- .../insights/derived/score-ring.tsx | 37 ++- 4 files changed, 441 insertions(+), 203 deletions(-) diff --git a/e2e/today-overview.spec.ts b/e2e/today-overview.spec.ts index 459201129..4216f5b82 100644 --- a/e2e/today-overview.spec.ts +++ b/e2e/today-overview.spec.ts @@ -16,11 +16,13 @@ import { DIGEST_AI_AVAILABLE } from "@/__tests__/helpers/ai-capability-fixtures" * The Today overview on the dashboard hero, at a desktop and a phone width. * * What it proves is geometry, because that is what the unit suites cannot - * see: the facts sit beside the ring on a wide screen and above it on a - * phone, a phone shows four facts and a wide screen five, nothing pushes the - * page sideways, the hero keeps the width of the cards around it, and the - * ring keeps its size. Each state is also captured as an element screenshot - * under `test-results/today-overview/` for a visual check. + * see: the facts sit beside the ring on a wide screen; on a phone the ring is + * a compact dial at the top right beside the lead and the facts run the full + * card width under both; a phone shows four facts and a wide screen five, + * nothing pushes the page sideways, the hero keeps the width of the cards + * around it, and the ring keeps its size per breakpoint. Each state is also + * captured as an element screenshot, in both themes, under + * `test-results/today-overview/` for a visual check. * * The digests are what the server would publish for each state. They carry * the pre-overview fields too (`briefingLead`, `line`), with the values the @@ -36,8 +38,17 @@ const SHOTS = join(process.cwd(), "test-results", "today-overview"); const VIEWPORTS = [ { name: "desktop", width: 1440, height: 900 }, { name: "mobile", width: 390, height: 844 }, + { name: "narrow", width: 360, height: 780 }, ] as const; +const THEMES = ["dark", "light"] as const; + +/** The dial's edge per breakpoint: compact on a phone, md from 768 px. */ +const RING_PX = { phone: 80, wide: 168 } as const; + +/** Below this the lead beside the ring would read as a column of words. */ +const MIN_LEAD_WIDTH = 180; + const BASE: DailyDigest = { generatedAt: new Date().toISOString(), ai: DIGEST_AI_AVAILABLE, @@ -158,6 +169,39 @@ const STATES: Record = { }, today: [FACTS[0], FACTS[2], FACTS[4]], }, + // AI on with a long lead and a long sleep value: the phone case the ring + // used to fall under the facts for. + "ai-on-long": { + ...BASE, + score: { + value: 63, + band: "yellow", + delta: -6, + deltaReason: null, + steadyWeeks: null, + }, + lead: { + text: "Your pulse sits well above its usual level this evening, after a shorter night than you normally get; a quieter evening would suit it.", + source: "briefing", + }, + signalLine: { headline: null, delta: "+34 bpm vs your 30-day average" }, + today: [ + FACTS[0], + { + kind: "sleep", + label: "Last night", + value: "5h 45m, 42m less than usual", + href: "/insights/sleep", + moduleKey: "sleep", + }, + { + kind: "vitals", + label: "Vitals", + value: "Pulse above your range", + href: "/insights", + }, + ], + }, // A quiet day: one calm sentence and no facts beyond it. quiet: { ...BASE, @@ -195,96 +239,163 @@ test.describe("Today overview geometry", () => { }); for (const viewport of VIEWPORTS) { - for (const [state, digest] of Object.entries(STATES)) { - test(`${state} at ${viewport.width}px`, async ({ page }) => { - await page.setViewportSize(viewport); - const hero = await openDashboard(page, digest); + for (const theme of THEMES) { + for (const [state, digest] of Object.entries(STATES)) { + test(`${state} at ${viewport.width}px, ${theme}`, async ({ page }) => { + // The app paints from its own stored preference, not the media + // query, so both are set. + await page.emulateMedia({ + colorScheme: theme, + reducedMotion: "reduce", + }); + await page.addInitScript((value: string) => { + window.localStorage.setItem("healthlog-theme", value); + }, theme); + await page.setViewportSize(viewport); + const hero = await openDashboard(page, digest); + const phone = viewport.width < 768; - mkdirSync(SHOTS, { recursive: true }); - await hero.screenshot({ - path: join(SHOTS, `${viewport.name}-${state}.png`), - }); + // Late banners (the key-backup nudge) insert above the hero; let + // the page settle so the capture shows one stable layout. + await page.waitForLoadState("networkidle"); + mkdirSync(SHOTS, { recursive: true }); + await hero.screenshot({ + path: join(SHOTS, `${viewport.name}-${theme}-${state}.png`), + }); + // The viewport as a reader sees it, bottom navigation included. + await page.evaluate(() => window.scrollTo(0, 0)); + await page.screenshot({ + path: join( + SHOTS, + `${viewport.name}-${theme}-${state}-viewport.png`, + ), + }); - // Nothing pushes the page sideways. - const overflow = await page.evaluate( - () => document.documentElement.scrollWidth - window.innerWidth, - ); - expect(overflow).toBeLessThanOrEqual(0); + // Nothing pushes the page sideways. + const overflow = await page.evaluate( + () => document.documentElement.scrollWidth - window.innerWidth, + ); + expect(overflow).toBeLessThanOrEqual(0); - // The hero spans the same column as the tile strip below it. - const heroBox = (await hero.boundingBox())!; - const stripBox = (await page - .locator('[data-slot="dashboard-tile-strip"]') - .boundingBox())!; - expect(Math.abs(heroBox.x - stripBox.x)).toBeLessThanOrEqual(1); - expect(Math.abs(heroBox.width - stripBox.width)).toBeLessThanOrEqual(1); + // The hero spans the same column as the tile strip below it. + const heroBox = (await hero.boundingBox())!; + const stripBox = (await page + .locator('[data-slot="dashboard-tile-strip"]') + .boundingBox())!; + expect(Math.abs(heroBox.x - stripBox.x)).toBeLessThanOrEqual(1); + expect(Math.abs(heroBox.width - stripBox.width)).toBeLessThanOrEqual( + 1, + ); - // The ring keeps its size: the same 168 px dial as before. - const ring = hero.locator('[data-slot="score-ring"]').first(); - const ringBox = (await ring.boundingBox())!; - expect(Math.round(ringBox.width)).toBe(168); - expect(Math.round(ringBox.height)).toBe(168); + // The ring keeps its size: a compact dial on a phone, the same + // 168 px dial as before from md up. + const ring = hero.locator('[data-slot="score-ring"]').first(); + const ringBox = (await ring.boundingBox())!; + const edge = phone ? RING_PX.phone : RING_PX.wide; + expect(Math.round(ringBox.width)).toBe(edge); + expect(Math.round(ringBox.height)).toBe(edge); - const lead = hero.locator('[data-slot="today-hero-lead"]'); - await expect(lead).toBeVisible(); - await expect(lead).toHaveAttribute("data-source", digest.lead!.source); - const leadBox = (await lead.boundingBox())!; + const lead = hero.locator('[data-slot="today-hero-lead"]'); + await expect(lead).toBeVisible(); + await expect(lead).toHaveAttribute( + "data-source", + digest.lead!.source, + ); + const leadBox = (await lead.boundingBox())!; - const facts = hero.locator('[data-slot="today-hero-fact"]'); - const expected = digest.today.length; - await expect(facts).toHaveCount(expected); - const visible = await facts.evaluateAll( - (items) => - items.filter((el) => (el as HTMLElement).offsetParent !== null) - .length, - ); - expect(visible).toBe( - viewport.width < 768 ? Math.min(expected, 4) : expected, - ); + // The ring sits at the top right, beside the lead, and the lead + // keeps a readable width next to it. + expect(ringBox.x).toBeGreaterThanOrEqual(leadBox.x + leadBox.width); + expect(ringBox.y).toBeLessThan(leadBox.y + leadBox.height); + expect(Math.abs(ringBox.y - leadBox.y)).toBeLessThanOrEqual(8); + expect(leadBox.width).toBeGreaterThanOrEqual(MIN_LEAD_WIDTH); + // Symmetric insets: the ring's right edge mirrors the lead's left. + const leftInset = leadBox.x - heroBox.x; + const rightInset = + heroBox.x + heroBox.width - (ringBox.x + ringBox.width); + expect(Math.abs(leftInset - rightInset)).toBeLessThanOrEqual(1); - // The all-clear sentence never sits under a lead or facts. - await expect( - hero.locator('[data-slot="today-hero-all-clear"]'), - ).toHaveCount(0); + const facts = hero.locator('[data-slot="today-hero-fact"]'); + const expected = digest.today.length; + await expect(facts).toHaveCount(expected); + const visible = await facts.evaluateAll( + (items) => + items.filter((el) => (el as HTMLElement).offsetParent !== null) + .length, + ); + expect(visible).toBe(phone ? Math.min(expected, 4) : expected); - if (expected === 0) { + // The all-clear sentence never sits under a lead or facts. await expect( - hero.locator('[data-slot="today-hero-today"]'), + hero.locator('[data-slot="today-hero-all-clear"]'), ).toHaveCount(0); - // A quiet day keeps the full composition: lead beside the ring. - await expect(hero).toHaveAttribute("data-layout", "narrative"); - return; - } - const block = hero.locator('[data-slot="today-hero-today"]'); - const blockBox = (await block.boundingBox())!; - // The facts follow the lead. - expect(blockBox.y).toBeGreaterThanOrEqual( - leadBox.y + leadBox.height - 1, - ); - if (viewport.width >= 768) { - // Beside the ring on a wide screen, never under it. - expect(blockBox.x + blockBox.width).toBeLessThanOrEqual( - ringBox.x + 1, - ); - expect(blockBox.y).toBeLessThan(ringBox.y + ringBox.height); - } else { - // Above the ring on a phone, so the day reads before the dial. - expect(blockBox.y + blockBox.height).toBeLessThanOrEqual( - ringBox.y + 1, - ); - } + if (expected === 0) { + await expect( + hero.locator('[data-slot="today-hero-today"]'), + ).toHaveCount(0); + // A quiet day keeps the full composition: lead beside the ring. + await expect(hero).toHaveAttribute("data-layout", "narrative"); + return; + } - // No fact line overflows its own box. - const clipped = await facts.evaluateAll( - (items) => - items.filter((el) => { - const link = el.querySelector("a"); - return link ? link.scrollWidth > link.clientWidth + 1 : false; - }).length, - ); - expect(clipped).toBe(0); - }); + const block = hero.locator('[data-slot="today-hero-today"]'); + const blockBox = (await block.boundingBox())!; + // The facts follow the lead. + expect(blockBox.y).toBeGreaterThanOrEqual( + leadBox.y + leadBox.height - 1, + ); + if (!phone) { + // Beside the ring on a wide screen, never under it. + expect(blockBox.x + blockBox.width).toBeLessThanOrEqual( + ringBox.x + 1, + ); + expect(blockBox.y).toBeLessThan(ringBox.y + ringBox.height); + } else { + // Under the dial on a phone, across the full card width. + expect(blockBox.y).toBeGreaterThanOrEqual( + ringBox.y + ringBox.height - 1, + ); + expect( + Math.abs( + blockBox.x + blockBox.width - (ringBox.x + ringBox.width), + ), + ).toBeLessThanOrEqual(1); + // Each tappable fact keeps the 44 px floor, label over value, + // both on the reading edge. + const rows = await facts.evaluateAll((items) => + items + .filter((el) => (el as HTMLElement).offsetParent !== null) + .map((el) => { + const link = el.querySelector("a")!; + const [label, value] = Array.from(link.children).map((c) => + c.getBoundingClientRect(), + ); + return { + height: link.getBoundingClientRect().height, + stacked: value.top >= label.bottom - 1, + sameEdge: Math.abs(value.left - label.left) <= 1, + }; + }), + ); + for (const row of rows) { + expect(row.height).toBeGreaterThanOrEqual(44); + expect(row.stacked).toBe(true); + expect(row.sameEdge).toBe(true); + } + } + + // No fact line overflows its own box. + const clipped = await facts.evaluateAll( + (items) => + items.filter((el) => { + const link = el.querySelector("a"); + return link ? link.scrollWidth > link.clientWidth + 1 : false; + }).length, + ); + expect(clipped).toBe(0); + }); + } } } diff --git a/src/components/daily/__tests__/today-hero.test.tsx b/src/components/daily/__tests__/today-hero.test.tsx index c0939590f..d8d8640cf 100644 --- a/src/components/daily/__tests__/today-hero.test.tsx +++ b/src/components/daily/__tests__/today-hero.test.tsx @@ -388,7 +388,7 @@ describe("", () => { expect(text.match(/\b82\b/g)).toHaveLength(1); }); - it("keeps the full narrative composition and md score ring when a lead exists", () => { + it("keeps the full narrative composition and the responsive score ring when a lead exists", () => { const html = render( ", () => { expect(html).toContain('data-layout="narrative"'); expect(html).toContain('data-slot="today-hero-lead"'); - expect(html).toContain('style="width:168px;height:168px"'); + // A compact dial on a phone, the md dial (168 px) from md up. + expect(html).toContain("size-20 md:size-42"); expect(html).not.toContain('data-layout="compact-all-clear"'); }); @@ -587,6 +588,49 @@ describe(" Today overview", () => { } }); + it("puts the ring beside the lead and the overview under both on a phone", () => { + const html = render( + , + ); + // The read grid: lead cell, overview cell, ring cell, in that order. + const read = html.slice(html.indexOf('data-slot="today-hero-read"')); + // The cell is the grid child wrapping the slot: the nearest preceding + // div that carries a grid placement. + const cellClass = (slot: string) => { + const before = read.slice(0, read.indexOf(`data-slot="${slot}"`)); + const cells = [ + ...before.matchAll(/
{ + const html = render(); + const links = [ + ...html.matchAll( + /data-slot="today-hero-fact"[^>]*>]*class="([^"]*)"[^>]*>(.*?)<\/a>/g, + ), + ]; + expect(links).toHaveLength(FACTS.length); + for (const [, linkClass, inner] of links) { + expect(linkClass).toContain("flex-col"); + expect(linkClass).not.toContain("justify-between"); + expect(linkClass).toContain("min-h-11"); + expect(inner).not.toContain("text-right"); + // Label muted, value foreground. + expect(inner).toMatch( + /text-muted-foreground[^"]*"[^>]*>[^<]+<\/span> { const html = render(); const items = [ diff --git a/src/components/daily/today-hero.tsx b/src/components/daily/today-hero.tsx index 53b3f9f59..65bbd4f4e 100644 --- a/src/components/daily/today-hero.tsx +++ b/src/components/daily/today-hero.tsx @@ -268,14 +268,19 @@ export function TodayHero({ i >= TODAY_FACTS_NARROW && "hidden md:block", )} > + {/* Label above value at every width, both on the reading edge. + A label-left / value-right row pushed a long value ("5 h 45 + min, 42 min less than usual") into a ragged right-aligned + block on a phone; stacked, every value reads as one left- + aligned sentence. The row keeps the 44 px tap floor below md. */} - + {fact.label} - + {fact.value} @@ -285,6 +290,109 @@ export function TodayHero({
) : null; + /* The lead — the read leads large in the foreground token, calm and + legible — and, one muted step down, the top signal's present-tense + headline + delta, supporting it without competing. */ + const leadBlock = + lead || signalLine ? ( +
+ {lead ? ( +
+ +
+ ) : null} + {signalLine ? ( +

+ {signalLine.headline} + {signalLine.headline && signalLine.delta ? " · " : null} + {signalLine.delta} +

+ ) : null} +
+ ) : null; + + /* Health score ring — `flat` (no sweep/bloom), server-computed band. A + null score paints the ring's honest provisional face at the same + footprint, so the column never collapses. The meta lines under it are + capped to the dial's width on a phone so the column stays compact. */ + const scoreColumn = (size: "sm" | "responsive") => { + const phoneMeta = size === "responsive" && "max-w-20 md:max-w-none"; + return ( +
+
+ {/* The ring opens the Insights overview — the destination the + health-score card owns — so every hero ring is a door, not a + poster. */} + + + +
+ {deltaShown && digest.score?.delta != null ? ( + + {formatDelta(digest.score.delta, t)} + + ) : null} + {steadyLine ? ( + + {steadyLine} + + ) : null} + {basisLine ? ( + + {basisLine} + + ) : null} +
+ ); + }; + const heroShellClassName = cn( // The tile strip's surface plus the ONE sanctioned Today atmosphere: // `.today-hero-wash` leans a faint `--primary` mix over the theme @@ -336,121 +444,75 @@ export function TodayHero({ gaps let the worth-a-look rail sit close under the lead so the card reads filled, not padded out. */}
- {/* The day's read — the numeric face on the trailing edge, the - narrative lead leading. On md+ the lead sits flush-top with the - score ring (items-start) rather than floating centred beside it. */} -
- {/* The overview is its own section under the lead, so it keeps a - section's distance from it; the compact all-clear stays tight. */} -
- {/* Hero numeric face: the read leads large in the foreground - token, calm and legible — the day's read, not a slogan. */} - {lead || signalLine ? ( -
- {lead ? ( -
- -
- ) : null} - {/* Top signal — present-tense headline + optional delta, one - muted step down so it supports the lead without - competing. */} - {signalLine ? ( -

- {signalLine.headline} - {signalLine.headline && signalLine.delta ? " · " : null} - {signalLine.delta} -

- ) : null} -
- ) : null} - {todayBlock} - {compactAllClear ? ( + {compactAllClear ? ( + /* Score-only digest: the all-clear copy leads and a smaller + version of the same ring stays the one numeric face. */ +
+

{t("daily.today.allClear")}

- ) : null} - - {/* In the compact fallback the quiet freshness tier belongs next - to the smaller ring. That uses the otherwise-empty leading - space and avoids adding a second row below the dial. */} - {compactAllClear ? sleepPendingNote : null} -
- - {/* Health score ring — `flat` (no sweep/bloom), server-computed - band. A null score paints the ring's honest provisional face - at the same footprint, so the column never collapses. */} -
-
- {/* The ring opens the Insights overview — the destination the - health-score card owns — matching the cluster rings' tap - behaviour, so every hero ring is a door, not a poster. */} - - - + {/* The quiet freshness tier belongs next to the smaller ring: + it uses the otherwise-empty leading space and adds no + second row below the dial. */} + {sleepPendingNote}
- {deltaShown && digest.score?.delta != null ? ( - - {formatDelta(digest.score.delta, t)} - - ) : null} - {steadyLine ? ( - - {steadyLine} - - ) : null} - {basisLine ? ( - - {basisLine} - - ) : null} + {scoreColumn("sm")}
-
+ ) : ( + /* The day's read: the narrative lead on the reading edge, the + numeric face on the trailing one, the Today overview under the + lead. + + One grid, two arrangements. Below md the ring is a compact dial + at the top right beside the lead, and the overview runs the full + card width underneath both; a phone has no room for a 168 px + dial under the facts, where it used to sit centred and fall + behind the bottom navigation. From md up the ring spans both + rows of the trailing column and the overview stays in the + leading column, flush-top with the ring, exactly as before. + + Without a lead the overview takes the leading cell itself, so + the ring never floats beside an empty row. */ +
+ {leadBlock ? ( +
+ {leadBlock} +
+ ) : null} + {todayBlock ? ( +
+ {todayBlock} +
+ ) : null} +
+ {scoreColumn("responsive")} +
+
+ )} {/* Meta row — the hero's quiet tier (UI-STANDARDS §text: `text-xs text-muted-foreground` is the meta floor; never an accent, never an diff --git a/src/components/insights/derived/score-ring.tsx b/src/components/insights/derived/score-ring.tsx index 917330473..675cff2d6 100644 --- a/src/components/insights/derived/score-ring.tsx +++ b/src/components/insights/derived/score-ring.tsx @@ -54,11 +54,27 @@ const SPRING_SAFE_MAX = 94; const SIZE: Record< NonNullable, - { px: number; numberClass: string; labelClass: string } + { + /** Fixed edge length; null when `boxClass` sizes the ring per breakpoint. */ + px: number | null; + boxClass?: string; + numberClass: string; + labelClass: string; + } > = { sm: { px: 120, numberClass: "text-3xl", labelClass: "text-2xs" }, md: { px: 168, numberClass: "text-5xl", labelClass: "text-xs" }, lg: { px: 232, numberClass: "text-7xl", labelClass: "text-sm" }, + // The dashboard hero: a compact 80 px dial beside the lead on a phone, the + // `md` dial from the md breakpoint up. Below md the label is left out (the + // number alone is legible at this size; the ring's aria-label and the + // surrounding link still name it), so the centre never crowds. + responsive: { + px: null, + boxClass: "size-20 md:size-42", + numberClass: "text-2xl md:text-5xl", + labelClass: "hidden text-xs md:block", + }, }; export interface ScoreRingProps { @@ -68,8 +84,11 @@ export interface ScoreRingProps { band?: ScoreBand; /** Short label rendered under the number (e.g. "Readiness", "/100"). */ label?: string; - /** Render size. `sm` in a grid tile, `md`/`lg` on the anatomy view. */ - size?: "sm" | "md" | "lg"; + /** + * Render size. `sm` in a grid tile, `md`/`lg` on the anatomy view, + * `responsive` on the dashboard hero (compact on a phone, `md` above). + */ + size?: "sm" | "md" | "lg" | "responsive"; /** Disable the sweep + count-up (e.g. when already revealed this session). */ animate?: boolean; /** @@ -190,12 +209,14 @@ export function ScoreRing({ data-provisional={hasScore ? undefined : "true"} role="img" aria-label={ariaLabel} - className={cn("relative shrink-0", className)} + className={cn("relative shrink-0", dims.boxClass, className)} style={ - { - width: dims.px, - height: dims.px, - } as React.CSSProperties + dims.px === null + ? undefined + : ({ + width: dims.px, + height: dims.px, + } as React.CSSProperties) } > {/* The -90° rotation starts the arc at 12 o'clock so it sweeps clockwise From 28517affd096d54d6612492cd761d3e808805c4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 5 Oct 2026 21:38:46 +0200 Subject: [PATCH 06/19] chore(release): v1.40.6 --- CHANGELOG.md | 31 +++++++++++++++++++++++++++++++ docs/api/openapi.yaml | 2 +- package.json | 2 +- public/sw.js | 2 +- 4 files changed, 34 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0017353d7..70a6f3d3a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,36 @@ # Changelog +## [1.40.6] — 2026-10-05 + +The Coach reads long series again; "today" only from today's readings; the +dashboard card fits a phone; text fields lose the purple focus ring. + +### Fixed + +- **Coach series without numbers.** Since 1.21.0 every Coach tool read one + shared per-turn snapshot capped at 24,000 characters; on accounts with many + series whole blocks became a placeholder and the tool still answered + `present: true`. A trimmed block is now re-read for the tool's own source, + a single-source read is condensed (summary, newest days, weekly and monthly + means) instead of emptied, a block without numbers answers + `retrieval_failed`, and the Coach calls the table tool itself for long + histories. The step list shows the reading count; no stray comma on copy. +- **"Today" from yesterday's readings.** Signals of the day, the vitals fact, + the lead line, the health status and the morning push only speak about + today from readings taken today in the profile timezone; a stored briefing + from an earlier day is no longer served. +- **Deltas** use each metric's display precision ("+34 bpm"). + +### Changed + +- **Dashboard card on a phone.** A compact score ring at the top right beside + the lead; Today facts as label over value, full width. Desktop unchanged. +- **Text-field focus.** No purple ring on inputs, textareas, selects and date + fields; focus shows as a neutral border (`--input-focus`). Invalid fields + keep their red border. +- **Coach panel** no longer has a search field (the full conversation list + keeps its search). + ## [1.40.5] — 2026-10-05 The Coach's conversation panel runs the full height of the window. diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 59e63e736..fde964456 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.40.5 + version: 1.40.6 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. diff --git a/package.json b/package.json index e1a6e9449..dceafe607 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.40.5", + "version": "1.40.6", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "PolyForm-Noncommercial-1.0.0", "homepage": "https://healthlog.dev", diff --git a/public/sw.js b/public/sw.js index dea9a680e..2bfd0ac0d 100644 --- a/public/sw.js +++ b/public/sw.js @@ -36,7 +36,7 @@ try { // v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild. const CACHE_VERSION = (typeof self !== "undefined" && self.__APP_VERSION__) || - /* @sw-version-fallback */ "v1.40.5"; + /* @sw-version-fallback */ "v1.40.6"; const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`; const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`; // v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*` From af79f4fb5c8fc4734628665bf6a49eec26057eda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 5 Oct 2026 21:50:48 +0200 Subject: [PATCH 07/19] fix(coach): do not count the freshness stamp as a figure in a series block A block trimmed to its unit and freshness stamp passed the figure check, because asOf.daysAgo is a number. The check now skips the asOf and scope fields, so that shape answers retrieval_failed like any other block without a reading. --- .../ai/coach/__tests__/series-long-window.test.ts | 6 ++++++ src/lib/ai/coach/tools/executor.ts | 15 +++++++++++++-- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/src/lib/ai/coach/__tests__/series-long-window.test.ts b/src/lib/ai/coach/__tests__/series-long-window.test.ts index bb40f918b..7bc3bd4a6 100644 --- a/src/lib/ai/coach/__tests__/series-long-window.test.ts +++ b/src/lib/ai/coach/__tests__/series-long-window.test.ts @@ -244,6 +244,12 @@ describe("get_metric_series inside a chat turn", () => { for (const empty of [ { unit: "kg" }, { omitted: "trimmed for prompt budget" }, + // The shape the budget pass really leaves: the unit and the freshness + // stamp, whose `daysAgo` is a number but not a reading. + { + unit: "kg", + asOf: { daysAgo: 3, isToday: false, currentForTodayClaims: false }, + }, ]) { buildCoachSnapshot.mockReset(); buildCoachSnapshot.mockResolvedValue(snapshot({ fatMass: empty })); diff --git a/src/lib/ai/coach/tools/executor.ts b/src/lib/ai/coach/tools/executor.ts index fde8110dc..a431530e5 100644 --- a/src/lib/ai/coach/tools/executor.ts +++ b/src/lib/ai/coach/tools/executor.ts @@ -619,11 +619,22 @@ function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } -/** Whether a payload holds at least one finite number anywhere. */ +/** + * Fields that describe a block rather than measure anything: the freshness + * stamp (`asOf.daysAgo` is a number, but no reading), and the scope echo. A + * block trimmed to `{ unit, asOf }` holds no figure. + */ +const METADATA_FIELDS: ReadonlySet = new Set(["asOf", "scope"]); + +/** Whether a payload holds at least one finite measured number anywhere. */ function carriesFigures(value: unknown): boolean { if (typeof value === "number") return Number.isFinite(value); if (Array.isArray(value)) return value.some(carriesFigures); - if (isRecord(value)) return Object.values(value).some(carriesFigures); + if (isRecord(value)) { + return Object.entries(value).some( + ([key, child]) => !METADATA_FIELDS.has(key) && carriesFigures(child), + ); + } return false; } From 73ce721ff26f1a0c5132e46b1d4d77efc7090649 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 5 Oct 2026 21:52:48 +0200 Subject: [PATCH 08/19] fix(mcp): keep the metric series read as it was The Coach's figure check ran before the MCP branch, so an MCP client was told retrieval_failed for a block it used to receive, and the plain-text renderer then said no data was on file. Any single-source build also condensed its own block, which reached MCP through the source snapshot's fallback to the full builder. The figure check now runs after the MCP branch, and condensing is an explicit option of the snapshot build that only the Coach tools set (it is part of the cache key). MCP output is what it was before. --- CHANGELOG.md | 9 +-- .../__tests__/series-long-window.test.ts | 70 ++++++++++++++++++- src/lib/ai/coach/snapshot.ts | 22 +++++- src/lib/ai/coach/tools/executor.ts | 27 ++++--- 4 files changed, 108 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 70a6f3d3a..c87841dab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,10 +11,11 @@ dashboard card fits a phone; text fields lose the purple focus ring. shared per-turn snapshot capped at 24,000 characters; on accounts with many series whole blocks became a placeholder and the tool still answered `present: true`. A trimmed block is now re-read for the tool's own source, - a single-source read is condensed (summary, newest days, weekly and monthly - means) instead of emptied, a block without numbers answers - `retrieval_failed`, and the Coach calls the table tool itself for long - histories. The step list shows the reading count; no stray comma on copy. + a Coach tool's read of one source is condensed (summary, newest days, + weekly and monthly means) instead of emptied, a block without numbers + answers `retrieval_failed` to the Coach, and the Coach calls the table tool + itself for long histories. MCP reads are unchanged. The step list shows + the reading count; no stray comma on copy. - **"Today" from yesterday's readings.** Signals of the day, the vitals fact, the lead line, the health status and the morning push only speak about today from readings taken today in the profile timezone; a stored briefing diff --git a/src/lib/ai/coach/__tests__/series-long-window.test.ts b/src/lib/ai/coach/__tests__/series-long-window.test.ts index 7bc3bd4a6..f05151f4d 100644 --- a/src/lib/ai/coach/__tests__/series-long-window.test.ts +++ b/src/lib/ai/coach/__tests__/series-long-window.test.ts @@ -21,11 +21,21 @@ import { } from "@/lib/ai/coach/series-condense"; const buildCoachSnapshot = - vi.fn<(userId: string, scope?: unknown) => Promise>(); + vi.fn< + ( + userId: string, + scope?: unknown, + options?: unknown, + ) => Promise + >(); vi.mock("@/lib/ai/coach/snapshot", async (importOriginal) => ({ ...(await importOriginal()), - buildCoachSnapshot: (userId: string, scope?: unknown) => - buildCoachSnapshot(userId, scope), + buildCoachSnapshot: (userId: string, scope?: unknown, options?: unknown) => + buildCoachSnapshot(userId, scope, options), +})); +const buildCoachSourceSnapshot = vi.fn(); +vi.mock("@/lib/ai/coach/source-snapshot", () => ({ + buildCoachSourceSnapshot: (...a: unknown[]) => buildCoachSourceSnapshot(...a), })); vi.mock("@/lib/db", () => ({ prisma: { @@ -308,3 +318,57 @@ describe("the prompt-budget pass on a single-source read", () => { expect(degraded.map((d) => d.key).at(-1)).toBe("fatMass"); }); }); + +describe("the MCP read stays as it was", () => { + beforeEach(() => { + buildCoachSnapshot.mockReset(); + buildCoachSourceSnapshot.mockReset(); + }); + + it("hands on the section as built, without the Coach's figure check", async () => { + buildCoachSourceSnapshot.mockResolvedValue({ + sections: { fatMass: { unit: "kg" } }, + referenceGrounding: null, + }); + const result = await executeCoachTool({ + userId: "u1", + name: "get_metric_series", + rawArguments: JSON.stringify({ metric: "fat_mass", window: "allTime" }), + sourceSnapshot: true, + }); + expect(JSON.stringify(result)).toBe( + JSON.stringify({ + present: true, + data: { metric: "fat_mass", section: { unit: "kg" } }, + }), + ); + expect(buildCoachSnapshot).not.toHaveBeenCalled(); + }); + + it("condenses only on a Coach read of one source", async () => { + buildCoachSnapshot.mockImplementation(async (_u, scope) => + scope === SHARED + ? snapshot( + { fatMass: { omitted: "trimmed for prompt budget" } }, + { degradedBlocks: ["fatMass"] }, + ) + : snapshot({ fatMass: FAT_MASS }), + ); + // Shared build first (plain), then the re-read of the one source. + await series({ metric: "fat_mass", window: "allTime" }); + expect(buildCoachSnapshot.mock.calls[0][2]).toEqual({ + reach: UNBOUNDED_REACH, + }); + expect(buildCoachSnapshot.mock.calls[1][2]).toEqual({ + reach: UNBOUNDED_REACH, + condenseRequested: true, + }); + // A window other than the turn's is its own single-source read. + buildCoachSnapshot.mockClear(); + await series({ metric: "fat_mass", window: "last90days" }); + expect(buildCoachSnapshot.mock.calls[0][2]).toEqual({ + reach: UNBOUNDED_REACH, + condenseRequested: true, + }); + }); +}); diff --git a/src/lib/ai/coach/snapshot.ts b/src/lib/ai/coach/snapshot.ts index 4c7bae0dd..a104ca00d 100644 --- a/src/lib/ai/coach/snapshot.ts +++ b/src/lib/ai/coach/snapshot.ts @@ -199,13 +199,26 @@ export async function buildCoachSnapshot( * one, as it always did. */ reach?: CoachHistoryReach; + /** + * A Coach tool reading one source: condense that source's blocks to the + * prompt budget instead of shedding them (`series-condense.ts`). Every + * other caller, MCP among them, keeps the plain budget pass, so its + * output is what it always was. + */ + condenseRequested?: boolean; }, ): Promise { const reach = options?.reach ?? UNBOUNDED_REACH; - const key = snapshotCacheKey(userId, scope, reach); + const condenseRequested = options?.condenseRequested === true; + const key = `${snapshotCacheKey(userId, scope, reach)}${condenseRequested ? "|condense" : ""}`; const cached = readSnapshotCache(key); if (cached) return cached; - const result = await buildCoachSnapshotImpl(userId, scope, reach); + const result = await buildCoachSnapshotImpl( + userId, + scope, + reach, + condenseRequested, + ); writeSnapshotCache(key, result); return result; } @@ -237,6 +250,7 @@ async function buildCoachSnapshotImpl( userId: string, scope: CoachScope | undefined, reach: CoachHistoryReach, + condenseRequested: boolean, ): Promise { // Prefs, module gates and the admitted sources — see `snapshot-prelude.ts`. // The lookback limit caps the narration window there, whatever the caller @@ -1062,7 +1076,9 @@ async function buildCoachSnapshotImpl( // a metric page) asked for that source's blocks: those are condensed, never // emptied, and only after everything else has been shed. const requestedSource = - Array.isArray(scope?.sources) && scope.sources.length === 1 + condenseRequested && + Array.isArray(scope?.sources) && + scope.sources.length === 1 ? scope.sources[0] : null; const requestedBlocks = new Set( diff --git a/src/lib/ai/coach/tools/executor.ts b/src/lib/ai/coach/tools/executor.ts index a431530e5..ea65a685c 100644 --- a/src/lib/ai/coach/tools/executor.ts +++ b/src/lib/ai/coach/tools/executor.ts @@ -276,8 +276,13 @@ async function readToolSnapshot( reach: CoachHistoryReach, ) { const scope = scopeFor(sources, window, fallbackWindow, sharedScope, reach); + if (scope !== sharedScope) { + return buildCoachSnapshot(userId, scope, { + reach, + condenseRequested: true, + }); + } const snapshot = await buildCoachSnapshot(userId, scope, { reach }); - if (scope !== sharedScope) return snapshot; const cut = sectionKeys.filter((key) => snapshot.degradedBlocks?.includes(key), ); @@ -289,7 +294,7 @@ async function readToolSnapshot( return buildCoachSnapshot( userId, { sources, window: scope.window }, - { reach }, + { reach, condenseRequested: true }, ); } @@ -567,6 +572,16 @@ async function getMetricSeries( reach, ); } + if (sourceSnapshot) { + // MCP: the section exactly as it always was, without the figure check + // below: an MCP client reads `present` by its own contract (absence is + // "not recorded"), and its output stays byte for byte what it was. + return { + present: true, + data: { metric, section }, + grounding: snapshot.referenceGrounding ?? undefined, + }; + } // A block without a single figure (a bare unit, an `omitted` marker) is // not an answer: "present" with nothing in it is what the model then // repeats to the person. Say the read failed instead. @@ -577,14 +592,6 @@ async function getMetricSeries( }); return { present: false, reason: "retrieval_failed" }; } - if (sourceSnapshot) { - // MCP: the section exactly as it always was. - return { - present: true, - data: { metric, section }, - grounding: snapshot.referenceGrounding ?? undefined, - }; - } // Outside MCP the read is a Coach snapshot, which counts what it read. const counts = (snapshot as Partial).provenance ?.counts as Record | undefined; From 14a2931a991cd40ccf6aa6f41b6e0a442c1ab61d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 5 Oct 2026 21:54:01 +0200 Subject: [PATCH 09/19] fix(daily): read signal deltas in the reader's number format MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit formatSignalDelta matched only the locale's decimal mark, so an English-formatted figure inside German text was cut at the comma ("+1,234.5 kg" read "+1,2.5 kg"), and a difference that rounded to nothing lost its sign ("+0.04 kg" read "0.0 kg"). A figure with a decimal or grouping mark is now read as a whole: with a point and a comma the later one is the decimal, thin spaces and repeated marks are grouping, and a single mark is grouping only when it is the locale's grouping mark before exactly three digits. The figure is re-rounded to the metric's precision and re-written with the locale's own marks. A non-zero difference that rounds to nothing reads "±0" at that precision. Plain integers stay as written. Glucose joins the reading types, its precision following the unit the delta is written in (whole mg/dL, one decimal of mmol/L). The map is keyed by string, because the published sourceMetric enum has no glucose value yet and widening it would change the iOS contract. Tests cover all seven locales. --- .../daily/__tests__/briefing-today.test.ts | 66 ++++++++- src/lib/daily/briefing-today.ts | 133 +++++++++++++----- 2 files changed, 164 insertions(+), 35 deletions(-) diff --git a/src/lib/daily/__tests__/briefing-today.test.ts b/src/lib/daily/__tests__/briefing-today.test.ts index 7a9ff8f00..0be476d9f 100644 --- a/src/lib/daily/__tests__/briefing-today.test.ts +++ b/src/lib/daily/__tests__/briefing-today.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from "vitest"; import type { DailyBriefing } from "@/lib/ai/schema"; +import { locales } from "@/lib/i18n/config"; +import { resolveIntlLocale } from "@/lib/format-locale"; import { briefingForToday, formatSignalDelta, @@ -37,9 +39,28 @@ describe("formatSignalDelta", () => { expect(formatSignalDelta("1.234 bpm", "pulse", "de")).toBe("1.234 bpm"); }); - it("drops the sign of a difference that rounds to nothing", () => { + it("says ±0 for a difference that rounds to nothing, never an unsigned zero", () => { expect(formatSignalDelta("+0.3 bpm vs your normal", "pulse", "en")).toBe( - "0 bpm vs your normal", + "±0 bpm vs your normal", + ); + expect(formatSignalDelta("+0.04 kg", "weight", "en")).toBe("±0.0 kg"); + }); + + it("re-writes a figure in the reader's own number format", () => { + // An English-formatted figure inside German text was cut at the comma + // into "+1,2.5 kg". + expect(formatSignalDelta("+1,234.5 kg", "weight", "de")).toBe( + "+1.234,5 kg", + ); + expect(formatSignalDelta("+1.234,56 kg", "weight", "de")).toBe( + "+1.234,6 kg", + ); + }); + + it("reads glucose at the precision of the unit it is written in", () => { + expect(formatSignalDelta("+12.4 mg/dL", "glucose", "en")).toBe("+12 mg/dL"); + expect(formatSignalDelta("+0,74 mmol/L", "glucose", "de")).toBe( + "+0,7 mmol/L", ); }); @@ -48,6 +69,47 @@ describe("formatSignalDelta", () => { }); }); +describe.each(locales)("formatSignalDelta in %s", (locale) => { + const intl = resolveIntlLocale(locale); + // The locale's own grouping convention: Spanish and Polish leave a + // four-digit figure ungrouped, French groups with a thin space. + const fmt = (value: number, digits: number) => + new Intl.NumberFormat(intl, { + minimumFractionDigits: digits, + maximumFractionDigits: digits, + }).format(value); + + it("rounds a pulse difference to whole beats", () => { + expect(formatSignalDelta(`+${fmt(33.72, 2)} bpm`, "pulse", locale)).toBe( + "+34 bpm", + ); + }); + + it("reads weight at one decimal, grouping included", () => { + expect(formatSignalDelta(`−${fmt(1234.56, 2)} kg`, "weight", locale)).toBe( + `−${fmt(1234.6, 1)} kg`, + ); + }); + + it("keeps the sign of a small difference as ±0 at weight precision", () => { + expect(formatSignalDelta(`+${fmt(0.04, 2)} kg`, "weight", locale)).toBe( + `±${fmt(0, 1)} kg`, + ); + }); + + it("re-reads an English-formatted figure in the reader's format", () => { + expect(formatSignalDelta("+1,234.5 kg", "weight", locale)).toBe( + `+${fmt(1234.5, 1)} kg`, + ); + }); + + it("leaves plain integers and window words as written", () => { + expect(formatSignalDelta("+6 mmHg vs 30-day", "bp", locale)).toBe( + "+6 mmHg vs 30-day", + ); + }); +}); + const BRIEFING: DailyBriefing = { paragraph: "Dein Puls ist heute deutlich erhöht.", signalsOfDay: [ diff --git a/src/lib/daily/briefing-today.ts b/src/lib/daily/briefing-today.ts index bce8c7be8..e4c2280f2 100644 --- a/src/lib/daily/briefing-today.ts +++ b/src/lib/daily/briefing-today.ts @@ -19,7 +19,7 @@ * metric's display precision, so a pulse difference reads "+34 bpm", never * "+33.72 bpm". */ -import type { DailyBriefing, DailyBriefingSignal } from "@/lib/ai/schema"; +import type { DailyBriefing } from "@/lib/ai/schema"; import type { Locale } from "@/lib/i18n/config"; import { resolveIntlLocale } from "@/lib/format-locale"; import { vitalDisplayDecimals } from "@/lib/measurements/vital-precision"; @@ -30,66 +30,133 @@ import { userDayKey } from "@/lib/tz/format"; * here (sleep, steps, mood, compliance, derived scores) are not a single * dated reading and keep their signal; the prompt input already decides * those. + * + * Keyed by string rather than by the briefing's `sourceMetric` enum: the + * signals-of-the-day block does carry glucose, and the published enum + * (which the iOS client decodes) does not yet. A glucose signal is covered + * here the day the enum gains it, without a contract change today. */ -export const SIGNAL_READING_TYPE: Partial< - Record -> = { +export const SIGNAL_READING_TYPE: Readonly> = { bp: "BLOOD_PRESSURE_SYS", weight: "WEIGHT", pulse: "PULSE", resting_hr: "RESTING_HEART_RATE", hrv: "HEART_RATE_VARIABILITY", body_temp: "BODY_TEMPERATURE", + glucose: "BLOOD_GLUCOSE", }; -/** The locale's decimal separator ("," for de, "." for en). */ -function decimalSeparator(locale: Locale): string { - return ( - new Intl.NumberFormat(resolveIntlLocale(locale)) - .formatToParts(1.5) - .find((part) => part.type === "decimal")?.value ?? "." +/** The locale's decimal and grouping separators ("," and "." for de). */ +function separators(locale: Locale): { decimal: string; group: string } { + const parts = new Intl.NumberFormat(resolveIntlLocale(locale)).formatToParts( + 12345.5, ); + return { + decimal: parts.find((p) => p.type === "decimal")?.value ?? ".", + group: parts.find((p) => p.type === "group")?.value ?? ",", + }; } -function escapeRegExp(text: string): string { - return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +/** Separators a figure may carry: point, comma, and the two thin spaces. */ +const SEP = "[.,\\u00a0\\u202f]"; + +/** + * A figure with at least one separator in it. Plain integers ("+6 mmHg", + * "30-day") are never rewritten, so the window words stay as written. + */ +const FIGURE = new RegExp( + `(? m[0]); + const points = marks.filter((m) => m === "." || m === ","); + const spaced = points.length < marks.length; + let decimal: string | null = null; + if (new Set(points).size === 2) { + decimal = raw.lastIndexOf(".") > raw.lastIndexOf(",") ? "." : ","; + } else if (points.length === 1) { + // Beside thin-space grouping ("1 234,5") the one mark is the decimal. + const mark = points[0]; + const after = raw.slice(raw.indexOf(mark) + 1); + const asGroup = !spaced && mark === localeGroup && after.length === 3; + decimal = asGroup ? null : mark; + } + // Repeated marks of one kind, and thin spaces, are grouping. + const [intPart, fracPart = ""] = + decimal === null + ? [raw] + : [ + raw.slice(0, raw.lastIndexOf(decimal)), + raw.slice(raw.lastIndexOf(decimal) + 1), + ]; + const groups = intPart.split(/[.,\u00a0\u202f]/); + if (groups.length > 1 && groups.slice(1).some((g) => g.length !== 3)) { + return null; + } + const value = Number(`${groups.join("")}.${fracPart || "0"}`); + if (!Number.isFinite(value)) return null; + // Written the way the locale writes it: decimal mark and grouping both. + const native = + (decimal === null || decimal === localeDecimal) && + marks.every((m) => m === decimal || m === localeGroup); + return { value, fractionDigits: fracPart.length, native }; } /** * Re-read the figures in a signal's delta string at the metric's display - * precision. + * precision, in the reader's number format. * - * Only a number written with the locale's own decimal separator is touched, - * and only when it carries more fraction digits than the metric is shown - * with: "1.234" in German is a thousand, not a decimal, and is left alone. - * A figure that rounds to zero loses its sign ("+0.3 bpm" reads "0 bpm"). - * A metric with no reading type keeps its delta verbatim. + * Only figures with a decimal or grouping mark are touched; a plain integer + * stays as written. A figure already at the metric's precision and written + * the locale's way is left alone; anything else is re-rounded and re-written + * with the locale's own marks ("+1,234.5 kg" in German reads "+1.234,5 kg"). + * A non-zero difference that rounds to nothing reads "±0" at the metric's + * precision rather than an unsigned zero that looks like a measurement. A + * metric with no reading type keeps its delta verbatim. */ export function formatSignalDelta( delta: string, - sourceMetric: DailyBriefingSignal["sourceMetric"], + sourceMetric: string, locale: Locale, ): string { const type = SIGNAL_READING_TYPE[sourceMetric]; if (!type) return delta; - // Every reading type above reads at the same precision in both unit - // systems (whole bpm / mmHg / ms, one decimal of kg / lb / °C / °F). - const decimals = vitalDisplayDecimals(type, 1); - const sep = decimalSeparator(locale); + // Every reading type reads at one precision in both unit systems (whole + // bpm / mmHg / ms, one decimal of kg / lb / °C / °F), except glucose, + // whose precision follows the unit the delta is written in. + const decimals = vitalDisplayDecimals( + type, + type === "BLOOD_GLUCOSE" && /mmol/i.test(delta) ? 1 : 0, + ); const number = new Intl.NumberFormat(resolveIntlLocale(locale), { minimumFractionDigits: decimals, maximumFractionDigits: decimals, - useGrouping: false, }); - const pattern = new RegExp( - `([+\\-−±]?)(\\d+)${escapeRegExp(sep)}(\\d+)(?![\\d])`, - "g", - ); - return delta.replace(pattern, (match, sign: string, int: string, frac) => { - if ((frac as string).length <= decimals) return match; - const value = Number(`${int}.${frac as string}`); - const rounded = Math.round(value * 10 ** decimals) / 10 ** decimals; - return `${rounded === 0 ? "" : sign}${number.format(rounded)}`; + return delta.replace(FIGURE, (match, sign: string, raw: string) => { + const figure = readFigure(raw, locale); + if (!figure) return match; + if (figure.native && figure.fractionDigits <= decimals) return match; + const scale = 10 ** decimals; + const rounded = Math.round(figure.value * scale) / scale; + if (rounded === 0 && figure.value !== 0) return `±${number.format(0)}`; + return `${sign}${number.format(rounded)}`; }); } From 756c3fb8454d860d21a05cadb7b920d56a52f7c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 5 Oct 2026 21:54:46 +0200 Subject: [PATCH 10/19] fix(coach): condense each series block in its own fields The condensing step read every block as a value series: daily rows under value, weekly rows under mean and count. Sleep nights carry minutes, so a condensed sleep summary ended on the last weekly mean weeks before the newest night; adherence rates fold by doses, not readings; glucose keeps a series per measurement context; workouts are a sessions list. Condensing now knows each block's shape: sleep reads minutes, adherence folds rate weighted by total doses, glucose condenses every context, and a workouts block keeps its newest sessions beside the whole-window rollup. --- .../__tests__/series-long-window.test.ts | 111 ++++++- src/lib/ai/coach/series-condense.ts | 286 ++++++++++++------ src/lib/ai/coach/snapshot.ts | 6 +- 3 files changed, 302 insertions(+), 101 deletions(-) diff --git a/src/lib/ai/coach/__tests__/series-long-window.test.ts b/src/lib/ai/coach/__tests__/series-long-window.test.ts index f05151f4d..a6e93f0ab 100644 --- a/src/lib/ai/coach/__tests__/series-long-window.test.ts +++ b/src/lib/ai/coach/__tests__/series-long-window.test.ts @@ -14,6 +14,7 @@ import type { CoachSnapshotResult } from "@/lib/ai/coach/snapshot"; import { KEEP_DAILY_ROWS, KEEP_MONTHLY_ROWS, + KEEP_SESSIONS, condenseSeriesBlock, isoWeekMonth, summarisePoints, @@ -118,14 +119,14 @@ describe("series-condense", () => { unit: "kg", timeline: { recent: recent(14), weekly: weeks(150) }, }; - expect(condenseSeriesBlock(block, 1)).toBe(true); + expect(condenseSeriesBlock(block, 1, "fatMass")).toBe(true); expect(block.timeline.recent).toHaveLength(KEEP_DAILY_ROWS); expect(block.timeline.recent.at(-1)?.value).toBe(43); - expect(condenseSeriesBlock(block, 2)).toBe(true); + expect(condenseSeriesBlock(block, 2, "fatMass")).toBe(true); expect(block.timeline).not.toHaveProperty("weekly"); const monthly = (block.timeline as { monthly?: unknown[] }).monthly; expect(monthly!.length).toBeGreaterThan(30); - expect(condenseSeriesBlock(block, 3)).toBe(true); + expect(condenseSeriesBlock(block, 3, "fatMass")).toBe(true); expect((block.timeline as { monthly?: unknown[] }).monthly).toHaveLength( KEEP_MONTHLY_ROWS, ); @@ -140,8 +141,8 @@ describe("series-condense", () => { }); expect((block as { condensed?: string[] }).condensed).toEqual([ `daily values: newest ${KEEP_DAILY_ROWS} days kept`, - "weekly means folded into monthly means", - `monthly means: newest ${KEEP_MONTHLY_ROWS} months kept`, + "weekly values folded into monthly values", + `monthly values: newest ${KEEP_MONTHLY_ROWS} months kept`, ]); }); @@ -153,7 +154,7 @@ describe("series-condense", () => { weeklyDia: [{ weekISO: "2026-W30", mean: 85, count: 4 }], }, }; - condenseSeriesBlock(block, 2); + condenseSeriesBlock(block, 2, "bloodPressure"); expect(block).toMatchObject({ summary: { sys: { first: 130, last: 121, change: -9 }, @@ -168,7 +169,7 @@ describe("series-condense", () => { it("leaves a block without a timeline alone", () => { const block = { aggregate: { mean: 1 } }; - expect(condenseSeriesBlock(block, 1)).toBe(false); + expect(condenseSeriesBlock(block, 1, "fatMass")).toBe(false); expect(block).toEqual({ aggregate: { mean: 1 } }); }); }); @@ -286,6 +287,102 @@ describe("the tool-mode rules", () => { ); }); +describe("series-condense reads each block in its own fields", () => { + it("sleep: nights carry minutes, so the summary ends on the latest night", () => { + const block = { + timeline: { + recent: Array.from({ length: 14 }, (_, i) => ({ + date: `2026-09-${String(i + 10).padStart(2, "0")}`, + weekday: "Mon", + minutes: 400 + i, + stages: { core: 200, deep: 80, rem: 90 }, + })), + weekly: weeks(6, 2026, () => 320).map((w, i) => ({ + ...w, + weekISO: `2026-W${30 + i}`, + })), + }, + }; + condenseSeriesBlock(block, 1, "sleep"); + expect(block).toMatchObject({ + summary: { to: "2026-09-23", last: 413 }, + }); + expect(block.timeline.recent).toHaveLength(KEEP_DAILY_ROWS); + expect(block.timeline.recent.at(-1)).toMatchObject({ minutes: 413 }); + condenseSeriesBlock(block, 2, "sleep"); + expect( + (block.timeline as unknown as { monthly: unknown[] }).monthly, + ).toEqual([ + { month: "2026-07", mean: 320, count: 14 }, + { month: "2026-08", mean: 320, count: 28 }, + ]); + }); + + it("adherence: rates fold weighted by doses, not by readings", () => { + const block = { + rate: 88, + timeline: { + recent: [ + { date: "2026-09-30", weekday: "Wed", rate: 1, taken: 2, total: 2 }, + { date: "2026-10-01", weekday: "Thu", rate: 0.5, taken: 1, total: 2 }, + ], + weekly: [ + { weekISO: "2026-W36", rate: 1, taken: 2, total: 2 }, + { weekISO: "2026-W37", rate: 0.5, taken: 7, total: 14 }, + ], + }, + }; + condenseSeriesBlock(block, 2, "compliance"); + expect(block).toMatchObject({ + summary: { to: "2026-10-01", last: 0.5 }, + timeline: { monthly: [{ month: "2026-09", rate: 0.56, total: 16 }] }, + }); + expect(block.timeline).not.toHaveProperty("weekly"); + }); + + it("glucose: each measurement context condenses on its own", () => { + const ctx = (base: number) => ({ + recent: recent(14, (i) => base + i), + weekly: weeks(30, 2026, () => base), + }); + const block = { + unit: "mg/dL", + panel: { tir: 0.8 }, + byContext: { FASTING: ctx(95), POST_MEAL: ctx(140) }, + }; + expect(condenseSeriesBlock(block, 1, "glucose")).toBe(true); + expect(condenseSeriesBlock(block, 2, "glucose")).toBe(true); + expect(block).toMatchObject({ + summary: { + FASTING: { last: 108, to: "2026-09-14" }, + POST_MEAL: { last: 153 }, + }, + }); + for (const c of Object.values(block.byContext)) { + expect(c.recent).toHaveLength(KEEP_DAILY_ROWS); + expect(c).not.toHaveProperty("weekly"); + expect((c as unknown as { monthly: unknown[] }).monthly.length).toBe(7); + } + }); + + it("workouts: keeps the newest sessions and the whole-window rollup", () => { + const block = { + recent: Array.from({ length: 15 }, (_, i) => ({ + date: `2026-09-${String(30 - i).padStart(2, "0")}`, + sport: "RUNNING", + durationMin: 30 + i, + })), + perSport: [{ sport: "RUNNING", count: 40, totalDurationMin: 1500 }], + totalInWindow: 40, + }; + expect(condenseSeriesBlock(block, 1, "workouts")).toBe(true); + expect(block.recent).toHaveLength(KEEP_SESSIONS); + expect(block.recent[0].date).toBe("2026-09-30"); + expect(block.perSport[0].count).toBe(40); + expect(condenseSeriesBlock(block, 2, "workouts")).toBe(false); + }); +}); + describe("the prompt-budget pass on a single-source read", () => { it("condenses the requested block last and never swaps it for a marker", async () => { const { degradeToBudget } = await vi.importActual< diff --git a/src/lib/ai/coach/series-condense.ts b/src/lib/ai/coach/series-condense.ts index 8dcc2d714..164a15b3f 100644 --- a/src/lib/ai/coach/series-condense.ts +++ b/src/lib/ai/coach/series-condense.ts @@ -44,18 +44,6 @@ export interface SeriesSummary { points: number; } -interface WeeklyBucket { - weekISO: string; - mean: number; - count: number; -} - -export interface MonthlyBucket { - month: string; - mean: number; - count: number; -} - type Rec = Record; function isRecord(value: unknown): value is Rec { @@ -66,19 +54,6 @@ function round(value: number): number { return Math.round(value * 100) / 100; } -function isWeekly(value: unknown): value is WeeklyBucket[] { - return ( - Array.isArray(value) && - value.every( - (b) => - isRecord(b) && - typeof b.weekISO === "string" && - typeof b.mean === "number" && - typeof b.count === "number", - ) - ); -} - /** ISO week `YYYY-Www` → the Monday that starts it, as `YYYY-MM-DD`. */ function isoWeekMonday(weekISO: string): string | null { const match = /^(\d{4})-W(\d{2})$/.exec(weekISO); @@ -144,64 +119,160 @@ export function isoWeekMonth(weekISO: string): string | null { return dateOnlyKey(thursday).slice(0, 7); } -/** Weekly means folded into count-weighted monthly means, in time order. */ +/** A monthly bucket: the month, the weighted value, the summed weight. */ +export type MonthlyBucket = { month: string } & Record; + +/** + * Weekly buckets folded into monthly ones, in time order. Each month's value + * is the weekly values weighted by `weightField` (readings for a mean, doses + * for an adherence rate), and the month carries the summed weight. + */ export function weeklyToMonthly( - weekly: ReadonlyArray, + weekly: ReadonlyArray, + valueField = "mean", + weightField = "count", ): MonthlyBucket[] { - const months = new Map(); + const months = new Map(); for (const bucket of weekly) { + if (!isRecord(bucket) || typeof bucket.weekISO !== "string") continue; + const value = bucket[valueField]; + const weight = bucket[weightField]; + if (typeof value !== "number" || typeof weight !== "number") continue; const month = isoWeekMonth(bucket.weekISO); - if (!month || bucket.count <= 0) continue; - const acc = months.get(month) ?? { sum: 0, count: 0 }; - acc.sum += bucket.mean * bucket.count; - acc.count += bucket.count; + if (!month || weight <= 0) continue; + const acc = months.get(month) ?? { sum: 0, weight: 0 }; + acc.sum += value * weight; + acc.weight += weight; months.set(month, acc); } + const places = valueField === "rate" ? 100 : 10; return Array.from(months.entries()) .sort(([a], [b]) => a.localeCompare(b)) - .map(([month, { sum, count }]) => ({ + .map(([month, { sum, weight }]) => ({ month, - mean: Math.round((sum / count) * 10) / 10, - count, + [valueField]: Math.round((sum / weight) * places) / places, + [weightField]: weight, })); } /** - * The value series a timeline carries: `value` over `weekly` for a single - * series, `sys`/`dia` over `weeklySys`/`weeklyDia` for blood pressure. + * One value series inside a block: where its rows live (`container`), which + * field of a daily row holds the value, and which weekly field holds the + * period value and its weight. The blocks name these differently: sleep + * nights carry `minutes`, adherence days and weeks a `rate` over `total` + * doses, blood pressure `sys`/`dia` beside separate weekly lists, glucose + * one series per measurement context. */ -const SERIES: ReadonlyArray<{ +interface Track { + /** The summary key; null for a block with a single series. */ name: string | null; + container: Rec; field: string; weekly: string; monthly: string; -}> = [ - { name: null, field: "value", weekly: "weekly", monthly: "monthly" }, - { name: "sys", field: "sys", weekly: "weeklySys", monthly: "monthlySys" }, - { name: "dia", field: "dia", weekly: "weeklyDia", monthly: "monthlyDia" }, -]; + weeklyValue: string; + weight: string; +} -function blockSummary(timeline: Rec): unknown { - const recent = Array.isArray(timeline.recent) ? timeline.recent : []; - const out: Rec = {}; - for (const s of SERIES) { - const points: Array<{ at: string; value: number }> = []; - const weekly = timeline[s.weekly]; - if (isWeekly(weekly)) { - for (const b of weekly) points.push({ at: b.weekISO, value: b.mean }); +/** Which shape a snapshot block has, by its key. */ +export type SeriesBlockKind = + "value" | "bloodPressure" | "sleep" | "compliance" | "glucose" | "workouts"; + +export function seriesBlockKind(key: string): SeriesBlockKind { + switch (key) { + case "bloodPressure": + case "sleep": + case "compliance": + case "glucose": + case "workouts": + return key; + default: + return "value"; + } +} + +function tracksOf(kind: SeriesBlockKind, block: Rec): Track[] { + const single = (container: unknown, field: string, name: string | null) => + isRecord(container) + ? [ + { + name, + container, + field, + weekly: "weekly", + monthly: "monthly", + weeklyValue: field === "rate" ? "rate" : "mean", + weight: field === "rate" ? "total" : "count", + }, + ] + : []; + switch (kind) { + case "value": + return single(block.timeline, "value", null); + case "sleep": + return single(block.timeline, "minutes", null); + case "compliance": + return single(block.timeline, "rate", null); + case "bloodPressure": + if (!isRecord(block.timeline)) return []; + return (["sys", "dia"] as const).map((name) => ({ + name, + container: block.timeline as Rec, + field: name, + weekly: name === "sys" ? "weeklySys" : "weeklyDia", + monthly: name === "sys" ? "monthlySys" : "monthlyDia", + weeklyValue: "mean", + weight: "count", + })); + case "glucose": { + const byContext = block.byContext; + if (!isRecord(byContext)) return []; + return Object.keys(byContext).flatMap((ctx) => + single(byContext[ctx], "value", ctx), + ); } + case "workouts": + return []; + } +} + +/** Whether `condenseSeriesBlock` knows how to condense this block. */ +export function isCondensable(key: string, block: unknown): boolean { + if (!isRecord(block)) return false; + const kind = seriesBlockKind(key); + if (kind === "workouts") return Array.isArray(block.recent); + return tracksOf(kind, block).length > 0; +} + +function trackSummary(track: Track): SeriesSummary | null { + const points: Array<{ at: string; value: number }> = []; + const weekly = track.container[track.weekly]; + if (Array.isArray(weekly)) { + for (const b of weekly) { + if (!isRecord(b) || typeof b.weekISO !== "string") continue; + const value = b[track.weeklyValue]; + if (typeof value === "number") points.push({ at: b.weekISO, value }); + } + } + const recent = track.container.recent; + if (Array.isArray(recent)) { for (const row of recent) { - if (isRecord(row) && typeof row.date === "string") { - const value = row[s.field]; - if (typeof value === "number") points.push({ at: row.date, value }); - } - } - const summary = summarisePoints(points); - if (summary) { - if (s.name === null) return summary; - out[s.name] = summary; + if (!isRecord(row) || typeof row.date !== "string") continue; + const value = row[track.field]; + if (typeof value === "number") points.push({ at: row.date, value }); } } + return summarisePoints(points); +} + +function blockSummary(tracks: ReadonlyArray): unknown { + const out: Rec = {}; + for (const track of tracks) { + const summary = trackSummary(track); + if (!summary) continue; + if (track.name === null) return summary; + out[track.name] = summary; + } return Object.keys(out).length > 0 ? out : undefined; } @@ -211,47 +282,80 @@ function note(block: Rec, text: string): void { block.condensed = list; } +/** Workout sessions a condensed block keeps (the list is newest first). */ +export const KEEP_SESSIONS = 5; + /** - * Take one condensing step on a block that carries a `timeline`. Returns - * whether anything changed; a block without a timeline is left alone and - * reports false. + * Take one condensing step on the block stored under `key`. Returns whether + * anything changed; a block of a shape this module does not know is left + * alone and reports false. */ -export function condenseSeriesBlock(block: unknown, step: 1 | 2 | 3): boolean { - if (!isRecord(block) || !isRecord(block.timeline)) return false; - const timeline = block.timeline; +export function condenseSeriesBlock( + block: unknown, + step: 1 | 2 | 3, + key: string, +): boolean { + if (!isRecord(block)) return false; + const kind = seriesBlockKind(key); + if (kind === "workouts") { + // The sessions list is newest first; the per-sport rollup and the window + // total already cover every session, so they stay whole. + if ( + step === 1 && + Array.isArray(block.recent) && + block.recent.length > KEEP_SESSIONS + ) { + block.recent = block.recent.slice(0, KEEP_SESSIONS); + note( + block, + `sessions: newest ${KEEP_SESSIONS} kept; perSport and totalInWindow cover the whole window`, + ); + return true; + } + return false; + } + const tracks = tracksOf(kind, block); + if (tracks.length === 0) return false; if (block.summary === undefined) { - const summary = blockSummary(timeline); + const summary = blockSummary(tracks); if (summary !== undefined) block.summary = summary; } + const containers = new Set(tracks.map((t) => t.container)); let changed = false; if (step === 1) { - if ( - Array.isArray(timeline.recent) && - timeline.recent.length > KEEP_DAILY_ROWS - ) { - timeline.recent = timeline.recent.slice(-KEEP_DAILY_ROWS); - note(block, `daily values: newest ${KEEP_DAILY_ROWS} days kept`); - changed = true; - } - } else if (step === 2) { - for (const s of SERIES) { - const weekly = timeline[s.weekly]; - if (!isWeekly(weekly)) continue; - timeline[s.monthly] = weeklyToMonthly(weekly); - delete timeline[s.weekly]; - note(block, "weekly means folded into monthly means"); - changed = true; - } - } else { - for (const s of SERIES) { - const monthly = timeline[s.monthly]; - if (Array.isArray(monthly) && monthly.length > KEEP_MONTHLY_ROWS) { - timeline[s.monthly] = monthly.slice(-KEEP_MONTHLY_ROWS); - note(block, `monthly means: newest ${KEEP_MONTHLY_ROWS} months kept`); + for (const container of containers) { + if ( + Array.isArray(container.recent) && + container.recent.length > KEEP_DAILY_ROWS + ) { + container.recent = container.recent.slice(-KEEP_DAILY_ROWS); + note(block, `daily values: newest ${KEEP_DAILY_ROWS} days kept`); changed = true; } } - const coarse = timeline.coarse; + } else if (step === 2) { + for (const track of tracks) { + const weekly = track.container[track.weekly]; + if (!Array.isArray(weekly)) continue; + track.container[track.monthly] = weeklyToMonthly( + weekly, + track.weeklyValue, + track.weight, + ); + delete track.container[track.weekly]; + note(block, "weekly values folded into monthly values"); + changed = true; + } + } else { + for (const track of tracks) { + const monthly = track.container[track.monthly]; + if (Array.isArray(monthly) && monthly.length > KEEP_MONTHLY_ROWS) { + track.container[track.monthly] = monthly.slice(-KEEP_MONTHLY_ROWS); + note(block, `monthly values: newest ${KEEP_MONTHLY_ROWS} months kept`); + changed = true; + } + } + const coarse = isRecord(block.timeline) ? block.timeline.coarse : undefined; if (isRecord(coarse) && ("monthly" in coarse || "anomalies" in coarse)) { delete coarse.monthly; delete coarse.anomalies; diff --git a/src/lib/ai/coach/snapshot.ts b/src/lib/ai/coach/snapshot.ts index a104ca00d..284ef092a 100644 --- a/src/lib/ai/coach/snapshot.ts +++ b/src/lib/ai/coach/snapshot.ts @@ -33,7 +33,7 @@ import { resolveSnapshotPrelude, } from "./snapshot-prelude"; import { annotateSnapshotFreshness } from "./snapshot-freshness"; -import { condenseSeriesBlock } from "./series-condense"; +import { condenseSeriesBlock, isCondensable } from "./series-condense"; import { buildGlp1SnapshotBlock } from "./glp1-snapshot"; import { buildDerivedSnapshotBlock } from "./derived-snapshot"; import { buildCorrelationsSnapshotBlock } from "./correlations-snapshot"; @@ -1314,10 +1314,10 @@ export function degradeToBudget( if (size() <= MAX_SNAPSHOT_CHARS) break; // A requested series is condensed step by step and keeps its numbers // (`series-condense.ts`); it is never swapped for the `omitted` marker. - if (requested.has(key) && asRecord(asRecord(snapshot[key])?.timeline)) { + if (requested.has(key) && isCondensable(key, snapshot[key])) { for (const step of [1, 2, 3] as const) { if (size() <= MAX_SNAPSHOT_CHARS) break; - if (condenseSeriesBlock(snapshot[key], step)) { + if (condenseSeriesBlock(snapshot[key], step, key)) { degraded.push({ key, cluster, pass: step }); } } From 491d542f5847a42bb444da5352d8661abece8bea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 5 Oct 2026 21:57:49 +0200 Subject: [PATCH 11/19] fix(daily): judge the briefing's day by when its text was written The check that keeps an earlier day's briefing off the Today surfaces read the cache row's insightsCachedAt. Every warm moves that column, including the unchanged-hash short-circuit that keeps yesterday's text and only refreshes the stamp, so text written the day before passed as today's after the nightly warm. The cached payload now carries briefingGeneratedAt, written only where new text is written: a full generation in the warm and in the on-demand route, and the daily paragraph re-roll. The stamp refresh leaves it untouched. briefingForToday reads that moment, and a payload without it counts as not from today, so the hero falls back to its own lead until the next generation. --- src/app/api/insights/generate/route.ts | 5 +- .../daily/__tests__/briefing-today.test.ts | 18 ++++-- src/lib/daily/__tests__/load-digest.test.ts | 61 +++++++++++++++++++ src/lib/daily/briefing-today.ts | 17 ++++-- src/lib/daily/load-digest.ts | 3 +- .../__tests__/briefing-generated-at.test.ts | 27 ++++++++ .../comprehensive-briefing-reroll.test.ts | 13 ++++ .../comprehensive-generate-force.test.ts | 6 ++ src/lib/insights/briefing-generated-at.ts | 45 ++++++++++++++ src/lib/insights/comprehensive-generate.ts | 12 +++- 10 files changed, 193 insertions(+), 14 deletions(-) create mode 100644 src/lib/insights/__tests__/briefing-generated-at.test.ts create mode 100644 src/lib/insights/briefing-generated-at.ts diff --git a/src/app/api/insights/generate/route.ts b/src/app/api/insights/generate/route.ts index 2180ace0c..a6d53d23b 100644 --- a/src/app/api/insights/generate/route.ts +++ b/src/app/api/insights/generate/route.ts @@ -11,6 +11,7 @@ * write breaks that convergence — the settling seam tests pin the client * half, this comment is the server half of the contract. */ +import { withBriefingGeneratedAt } from "@/lib/insights/briefing-generated-at"; import { prisma } from "@/lib/db"; import { auditLog } from "@/lib/auth/audit"; import { @@ -1051,7 +1052,9 @@ export const POST = apiHandler((request: NextRequest) => }, data: { insightsCachedAt: new Date(), - insightsCachedText: JSON.stringify(insights), + insightsCachedText: JSON.stringify( + withBriefingGeneratedAt(insights, new Date()), + ), insightsCachedLocale: locale, insightsSnapshotHash: hashInsightSnapshot({ features: compactFeatures, diff --git a/src/lib/daily/__tests__/briefing-today.test.ts b/src/lib/daily/__tests__/briefing-today.test.ts index 0be476d9f..a3b877539 100644 --- a/src/lib/daily/__tests__/briefing-today.test.ts +++ b/src/lib/daily/__tests__/briefing-today.test.ts @@ -142,7 +142,7 @@ describe("briefingForToday", () => { it("drops a pulse signal whose last reading was yesterday at 08:50", () => { const out = briefingForToday(BRIEFING, { ...BERLIN, - updatedAt: "2026-10-05T04:00:00Z", + generatedAt: "2026-10-05T04:00:00Z", lastSeenAt: (type) => (type === "PULSE" ? "2026-10-04T06:50:00Z" : null), }); expect(out?.signalsOfDay?.map((s) => s.sourceMetric)).toEqual(["sleep"]); @@ -151,7 +151,7 @@ describe("briefingForToday", () => { it("keeps a pulse signal measured today, with its delta in whole beats", () => { const out = briefingForToday(BRIEFING, { ...BERLIN, - updatedAt: "2026-10-05T04:00:00Z", + generatedAt: "2026-10-05T04:00:00Z", lastSeenAt: (type) => (type === "PULSE" ? "2026-10-05T06:50:00Z" : null), }); expect(out?.signalsOfDay?.[0]?.delta).toBe( @@ -164,16 +164,26 @@ describe("briefingForToday", () => { expect( briefingForToday(BRIEFING, { ...BERLIN, - updatedAt: "2026-10-04T21:30:00Z", + generatedAt: "2026-10-04T21:30:00Z", lastSeenAt: () => "2026-10-04T21:00:00Z", }), ).toBeNull(); }); + it("serves no briefing whose generation moment is unknown", () => { + expect( + briefingForToday(BRIEFING, { + ...BERLIN, + generatedAt: null, + lastSeenAt: () => "2026-10-05T06:50:00Z", + }), + ).toBeNull(); + }); + it("decides the day in the reader's zone", () => { // 05:00 UTC on the 5th is the evening of the 4th in Los Angeles. const ctx = { - updatedAt: "2026-10-05T05:00:00Z", + generatedAt: "2026-10-05T05:00:00Z", lastSeenAt: () => "2026-10-05T05:00:00Z", language: "en" as const, }; diff --git a/src/lib/daily/__tests__/load-digest.test.ts b/src/lib/daily/__tests__/load-digest.test.ts index 6544fd576..fb459be1e 100644 --- a/src/lib/daily/__tests__/load-digest.test.ts +++ b/src/lib/daily/__tests__/load-digest.test.ts @@ -363,6 +363,67 @@ describe("loadDailyDigest — AI parts", () => { }); }); +describe("loadDailyDigest — the briefing's own day", () => { + const BRIEFING = { + paragraph: "Last night's sleep came in a little short of your usual.", + signalsOfDay: [ + { + sourceMetric: "sleep", + tone: "info", + headline: "Shorter night", + nudge: "An earlier night would suit it.", + delta: null, + }, + ], + keyFindings: [], + }; + + function withBriefing(cachedText: string) { + vi.mocked(readDashboardSnapshotCached).mockResolvedValueOnce({ + ...SNAPSHOT, + // The row was stamped this morning, as every warm stamps it. + body: { + ...SNAPSHOT.body, + briefing: BRIEFING, + briefingUpdatedAt: "2026-07-17T02:30:00.000Z", + }, + } as never); + return { ...USER, insightsCachedText: cachedText } as User; + } + + it("does not serve yesterday's text that a warm only re-stamped", async () => { + // Generated yesterday; the unchanged-hash warm at 04:30 refreshed only + // insightsCachedAt. + const user = withBriefing( + JSON.stringify({ + dailyBriefing: BRIEFING, + briefingGeneratedAt: "2026-07-16T02:30:00.000Z", + }), + ); + const digest = await loadDailyDigest(user, NOW); + expect(digest.briefingLead).toBeNull(); + expect(digest.topSignal).toBeNull(); + }); + + it("does not serve a payload that predates the generation moment", async () => { + const user = withBriefing(JSON.stringify({ dailyBriefing: BRIEFING })); + const digest = await loadDailyDigest(user, NOW); + expect(digest.topSignal).toBeNull(); + }); + + it("serves text generated today", async () => { + const user = withBriefing( + JSON.stringify({ + dailyBriefing: BRIEFING, + briefingGeneratedAt: "2026-07-17T02:30:00.000Z", + }), + ); + const digest = await loadDailyDigest(user, NOW); + expect(digest.briefingLead).toBe(BRIEFING.paragraph); + expect(digest.topSignal?.sourceMetric).toBe("sleep"); + }); +}); + /** A reminder row as the preventive read selects it; due this morning. */ function reminderRow(over: Record = {}) { return { diff --git a/src/lib/daily/briefing-today.ts b/src/lib/daily/briefing-today.ts index e4c2280f2..4ca2b7720 100644 --- a/src/lib/daily/briefing-today.ts +++ b/src/lib/daily/briefing-today.ts @@ -161,8 +161,13 @@ export function formatSignalDelta( } export interface BriefingForTodayContext { - /** When the cached briefing was written (ISO), or null when unknown. */ - updatedAt: string | null; + /** + * When the briefing's content was generated (ISO, from the payload's + * `briefingGeneratedAt`), or null when the payload predates the field. + * Never the cache row's `insightsCachedAt`: that moves on every warm, + * including the unchanged-hash refresh that keeps yesterday's text. + */ + generatedAt: string | null; /** The newest reading of a measurement type (ISO), or null when none. */ lastSeenAt: (type: string) => string | null; /** The reader's zone and today's day key in it. */ @@ -173,8 +178,8 @@ export interface BriefingForTodayContext { } /** - * The briefing as the Today surfaces may use it: null when it was written on - * an earlier calendar day, otherwise with every reading-backed signal whose + * The briefing as the Today surfaces may use it: null when its content was + * generated on an earlier calendar day, or at an unknown time, otherwise with every reading-backed signal whose * metric was not measured today removed and every remaining delta re-read at * its metric's precision. */ @@ -184,8 +189,8 @@ export function briefingForToday( ): DailyBriefing | null { if (!briefing) return null; if ( - ctx.updatedAt !== null && - userDayKey(new Date(ctx.updatedAt), ctx.timezone) !== ctx.todayLocalDate + ctx.generatedAt === null || + userDayKey(new Date(ctx.generatedAt), ctx.timezone) !== ctx.todayLocalDate ) { return null; } diff --git a/src/lib/daily/load-digest.ts b/src/lib/daily/load-digest.ts index a786cbbf1..03dfa95ed 100644 --- a/src/lib/daily/load-digest.ts +++ b/src/lib/daily/load-digest.ts @@ -84,6 +84,7 @@ import { addDays, dayDiff } from "@/lib/cycle/day-math"; import { makeFormatters, resolveIntlLocale } from "@/lib/format-locale"; import { vitalDisplayDecimals } from "@/lib/measurements/vital-precision"; import { briefingForToday } from "@/lib/daily/briefing-today"; +import { readBriefingGeneratedAt } from "@/lib/insights/briefing-generated-at"; import type { DateFormatPreference, TimeFormatPreference, @@ -938,7 +939,7 @@ export async function loadDailyDigest( // earlier day is not served as today's read, and a signal whose // metric has no reading today is dropped (`briefing-today.ts`). briefing: briefingForToday(snapshot.briefing, { - updatedAt: snapshot.briefingUpdatedAt, + generatedAt: readBriefingGeneratedAt(user.insightsCachedText), lastSeenAt: (type) => snapshot.tiles.lastSeenByType[type]?.lastSeenAt ?? null, timezone: user.timezone, diff --git a/src/lib/insights/__tests__/briefing-generated-at.test.ts b/src/lib/insights/__tests__/briefing-generated-at.test.ts new file mode 100644 index 000000000..21517e1ee --- /dev/null +++ b/src/lib/insights/__tests__/briefing-generated-at.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; + +import { + readBriefingGeneratedAt, + withBriefingGeneratedAt, +} from "@/lib/insights/briefing-generated-at"; + +describe("briefing generation moment", () => { + it("round-trips through the stored JSON text", () => { + const at = new Date("2026-10-05T04:30:00.000Z"); + const text = JSON.stringify( + withBriefingGeneratedAt({ dailyBriefing: { paragraph: "x" } }, at), + ); + expect(readBriefingGeneratedAt(text)).toBe(at.toISOString()); + }); + + it("is unknown for a payload that predates it, or does not parse", () => { + expect(readBriefingGeneratedAt(JSON.stringify({ dailyBriefing: {} }))).toBe( + null, + ); + expect(readBriefingGeneratedAt("not json")).toBeNull(); + expect(readBriefingGeneratedAt(null)).toBeNull(); + expect( + readBriefingGeneratedAt({ briefingGeneratedAt: "yesterday-ish" }), + ).toBeNull(); + }); +}); diff --git a/src/lib/insights/__tests__/comprehensive-briefing-reroll.test.ts b/src/lib/insights/__tests__/comprehensive-briefing-reroll.test.ts index b805f1e75..4d83ec073 100644 --- a/src/lib/insights/__tests__/comprehensive-briefing-reroll.test.ts +++ b/src/lib/insights/__tests__/comprehensive-briefing-reroll.test.ts @@ -178,6 +178,10 @@ describe("daily-briefing re-roll on unchanged data", () => { { headline: "BP steady", tone: "good" }, ]); expect(stored.summary).toBe("stable summary"); + // New text, so a new generation moment. + expect(Date.parse(stored.briefingGeneratedAt)).toBeGreaterThan( + Date.now() - 60_000, + ); expect(data.insightsBriefingRerollDate).toBe(todayKey); expect(invalidateUserInsights).toHaveBeenCalledWith("u1"); }); @@ -223,5 +227,14 @@ describe("daily-briefing re-roll on unchanged data", () => { .insightsBriefingRerollDate !== undefined, ); expect(write).toBeTruthy(); + // The stamp refresh keeps yesterday's text and its generation moment: + // it writes no text at all. + expect( + userUpdate.mock.calls.some( + (c) => + (c[0] as { data: Record }).data + .insightsCachedText !== undefined, + ), + ).toBe(false); }); }); diff --git a/src/lib/insights/__tests__/comprehensive-generate-force.test.ts b/src/lib/insights/__tests__/comprehensive-generate-force.test.ts index 1bf266d3f..5eb22ab81 100644 --- a/src/lib/insights/__tests__/comprehensive-generate-force.test.ts +++ b/src/lib/insights/__tests__/comprehensive-generate-force.test.ts @@ -310,6 +310,12 @@ describe("generateComprehensiveInsight — content-hash gate (v1.16.8)", () => { expect(data.insightsSnapshotHash).toBe(FEATURES_HASH); // The cache row records the language it was generated in. expect(data.insightsCachedLocale).toBe("de"); + // New text carries the moment it was generated, which the "is this + // briefing from today" check reads instead of insightsCachedAt. + const stored = JSON.parse(data.insightsCachedText as string); + expect(Date.parse(stored.briefingGeneratedAt)).toBeGreaterThan( + Date.now() - 60_000, + ); // v1.16.8 — the blanket per-status eviction is gone. expect(auditDeleteMany).not.toHaveBeenCalled(); }); diff --git a/src/lib/insights/briefing-generated-at.ts b/src/lib/insights/briefing-generated-at.ts new file mode 100644 index 000000000..903b7626f --- /dev/null +++ b/src/lib/insights/briefing-generated-at.ts @@ -0,0 +1,45 @@ +/** + * When the cached briefing's content was written, as opposed to when the + * cache row was last touched. + * + * `User.insightsCachedAt` moves on every warm, including the content-hash + * short-circuit that keeps yesterday's text and only refreshes the stamp. + * A "is this briefing from today?" check read off that column therefore + * passed for text written the day before. The moment the content itself was + * produced rides inside the cached payload instead, written only where new + * text is written: a full generation (the warm and the on-demand route) and + * the daily paragraph re-roll. The plain stamp refresh leaves it alone. + * + * A payload without the field predates it, and its age is unknown: the + * readers treat it as not from today, which is the safe side (the hero falls + * back to its own lead and a warm is asked for). + */ + +/** The top-level key on the cached insight payload. */ +export const BRIEFING_GENERATED_AT_KEY = "briefingGeneratedAt"; + +/** The payload with the generation moment set. */ +export function withBriefingGeneratedAt( + payload: T, + at: Date, +): T & { [BRIEFING_GENERATED_AT_KEY]: string } { + return { ...payload, [BRIEFING_GENERATED_AT_KEY]: at.toISOString() }; +} + +/** + * The generation moment of a cached payload (an object or its stored JSON + * text), or null when it carries none or does not parse. + */ +export function readBriefingGeneratedAt(payload: unknown): string | null { + let value: unknown = payload; + if (typeof payload === "string") { + try { + value = JSON.parse(payload); + } catch { + return null; + } + } + if (value === null || typeof value !== "object") return null; + const at = (value as Record)[BRIEFING_GENERATED_AT_KEY]; + return typeof at === "string" && !Number.isNaN(Date.parse(at)) ? at : null; +} diff --git a/src/lib/insights/comprehensive-generate.ts b/src/lib/insights/comprehensive-generate.ts index 5469072e9..c6d4c3162 100644 --- a/src/lib/insights/comprehensive-generate.ts +++ b/src/lib/insights/comprehensive-generate.ts @@ -18,6 +18,7 @@ * NO synchronous request lifecycle: the cron path runs entirely on * pg-boss, never inside an HTTP handler. */ +import { withBriefingGeneratedAt } from "@/lib/insights/briefing-generated-at"; import pLimit from "p-limit"; import { prisma } from "@/lib/db"; import { @@ -298,7 +299,12 @@ async function rerollBriefingParagraph(args: { ...(cachedBriefing as Record), paragraph: freshParagraph, }; - const merged = { ...cached, dailyBriefing: mergedBriefing }; + // A re-rolled paragraph is new text, so it carries a new generation + // moment; the plain stamp refresh that a miss falls back to does not. + const merged = withBriefingGeneratedAt( + { ...cached, dailyBriefing: mergedBriefing }, + new Date(), + ); return { text: JSON.stringify(merged), providerType }; } @@ -1443,7 +1449,9 @@ export async function generateComprehensiveInsight( workingProviderType, { insightsCachedAt: new Date(), - insightsCachedText: JSON.stringify(insights), + insightsCachedText: JSON.stringify( + withBriefingGeneratedAt(insights, new Date()), + ), insightsCachedLocale: locale, insightsSnapshotHash: snapshotHash, }, From f6b0131731ca166bc1e992ee57abdf1c7ada8293 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 5 Oct 2026 22:03:58 +0200 Subject: [PATCH 12/19] fix(daily): serve the briefing as it stands today on every surface Only the digest applied briefingForToday. The dashboard snapshot (the web dashboard, its server prefetch and the iOS cold-launch read) and the /insights advisor payload still served an earlier day's text and the raw model deltas ("+33.72 bpm"). The snapshot read now resolves the briefing after the cache, beside the capability decision, from the payload's generation moment and the snapshot's own newest reading per type; a withheld briefing reads "preparing", never "ready" with nothing in it. The digest takes the resolved briefing from that read on its own clock instead of resolving again. The advisor route resolves dailyBriefing on every branch it returns, reading the newest reading per signal type in one grouped query only when a reading-backed signal is present; text generated on an earlier day no longer counts as fresh on the read GET (so the warm is enqueued) and no longer short-circuits a provider-backed POST. The OpenAPI descriptions of both advisor responses say so. --- docs/api/openapi.yaml | 14 +- .../insights/generate/__tests__/route.test.ts | 96 +++++++++++++- src/app/api/insights/generate/route.ts | 62 +++++++-- src/lib/daily/__tests__/load-digest.test.ts | 62 +-------- src/lib/daily/briefing-today-read.ts | 101 ++++++++++++++ src/lib/daily/load-digest.ts | 21 +-- .../snapshot-read-briefing-capability.test.ts | 11 +- .../snapshot-read-briefing-today.test.ts | 125 ++++++++++++++++++ src/lib/dashboard/snapshot-read.ts | 55 +++++++- src/lib/openapi/routes/insights/schemas.ts | 6 +- 10 files changed, 462 insertions(+), 91 deletions(-) create mode 100644 src/lib/daily/briefing-today-read.ts create mode 100644 src/lib/dashboard/__tests__/snapshot-read-briefing-today.test.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index fde964456..41a985a94 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -57957,9 +57957,12 @@ components: type: string additionalProperties: {} - type: "null" - description: The cached insight payload, verbatim from the account's cache row. Null when nothing is cached or the - stored row failed to parse — the two are indistinguishable here, and the second also silently enqueues a - repair. + description: "The cached insight payload from the account's cache row, with its `dailyBriefing` resolved for today: null + when the text was generated on an earlier calendar day in the account's timezone (or carries no + `briefingGeneratedAt`, the moment it was generated), otherwise without the signals of the day whose + reading-backed metric has no reading today, and with each signal delta at its metric's display precision in + the reader's number format. Null when nothing is cached or the stored row failed to parse — the two are + indistinguishable here, and the second also silently enqueues a repair." cached: type: boolean description: True when a payload was served from the cache. @@ -58037,7 +58040,10 @@ components: propertyNames: type: string additionalProperties: {} - description: The insight payload, fresh or cached depending on `cached`. + description: "The insight payload, fresh or cached depending on `cached`, with its `dailyBriefing` resolved for today + exactly as on the read GET. A cached briefing generated on an earlier calendar day never short-circuits an + account with a provider: like a briefingless cache, it regenerates, and degrades to the cached payload on an + exhausted quota." cached: type: boolean cachedAt: diff --git a/src/app/api/insights/generate/__tests__/route.test.ts b/src/app/api/insights/generate/__tests__/route.test.ts index f77381dad..5b6ca3890 100644 --- a/src/app/api/insights/generate/__tests__/route.test.ts +++ b/src/app/api/insights/generate/__tests__/route.test.ts @@ -49,6 +49,8 @@ vi.mock("@/lib/db", () => ({ })), updateMany: vi.fn(async () => ({ count: 1 })), }, + // The newest reading per signal type (`readSignalLastSeen`). + measurement: { groupBy: vi.fn(async () => []) }, auditLog: { // v1.4.16 A7: route now evicts stale per-status cache rows // (`insights.-status.`) on every successful @@ -593,6 +595,7 @@ describe("POST /api/insights/generate — cache write (v1.16.8)", () => { insightsCachedText: JSON.stringify({ changed: "still fresh", dailyBriefing: { paragraph: "ok", keyFindings: [] }, + briefingGeneratedAt: new Date().toISOString(), }), locale: "en", } as never); @@ -632,6 +635,7 @@ describe("POST /api/insights/generate — cache write (v1.16.8)", () => { insightsCachedText: JSON.stringify({ changed: "still fresh", dailyBriefing: { paragraph: "ok", keyFindings: [] }, + briefingGeneratedAt: new Date().toISOString(), }), locale: "en", } as never); @@ -680,6 +684,7 @@ describe("POST /api/insights/generate — briefingless fresh cache (v1.28.30)", insightsCachedAt: new Date(Date.now() - 6 * 60 * 60 * 1000), insightsCachedText: JSON.stringify({ dailyBriefing: { paragraph: "today", keyFindings: [] }, + briefingGeneratedAt: new Date().toISOString(), }), locale: "en", } as never); @@ -802,7 +807,10 @@ describe("POST /api/insights/generate — payload-size hard downgrade (H1)", () describe("GET /api/insights/generate — read-only advisor read", () => { it("serves the cached briefing without ever calling the provider", async () => { - const cached = { dailyBriefing: { paragraph: "ok", keyFindings: [] } }; + const cached = { + dailyBriefing: { paragraph: "ok", keyFindings: [] }, + briefingGeneratedAt: new Date().toISOString(), + }; vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({ insightsCachedAt: new Date(), insightsCachedText: JSON.stringify(cached), @@ -878,7 +886,10 @@ describe("GET /api/insights/generate — read-only advisor read", () => { }); it("serves a fresh cache whose tag matches the reader's language", async () => { - const cached = { dailyBriefing: { paragraph: "English", keyFindings: [] } }; + const cached = { + dailyBriefing: { paragraph: "English", keyFindings: [] }, + briefingGeneratedAt: new Date().toISOString(), + }; vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({ insightsCachedAt: new Date(), insightsCachedText: JSON.stringify(cached), @@ -898,6 +909,87 @@ describe("GET /api/insights/generate — read-only advisor read", () => { expect(enqueueForceWarm).not.toHaveBeenCalled(); }); + it("treats yesterday's text that a warm only re-stamped as stale, and asks for a warm", async () => { + // Stamped a minute ago, generated yesterday in the reader's zone. + const yesterday = new Date(Date.now() - 30 * 60 * 60 * 1000); + vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({ + insightsCachedAt: new Date(Date.now() - 60_000), + insightsCachedText: JSON.stringify({ + dailyBriefing: { paragraph: "Yesterday's read.", keyFindings: [] }, + briefingGeneratedAt: yesterday.toISOString(), + }), + insightsCachedLocale: "en", + locale: "en", + timezone: "UTC", + } as never); + + const res = await (GET as unknown as (req: Request) => Promise)( + new Request("http://localhost/api/insights/generate"), + ); + const body = (await res.json()) as { + data: { + insights: { dailyBriefing: unknown }; + revalidating: boolean; + }; + }; + expect(body.data.insights.dailyBriefing).toBeNull(); + expect(body.data.revalidating).toBe(true); + expect(enqueueForceWarm).toHaveBeenCalledTimes(1); + }); + + it("serves today's signals with deltas at display precision", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({ + insightsCachedAt: new Date(), + insightsCachedText: JSON.stringify({ + dailyBriefing: { + paragraph: "ok", + keyFindings: [], + signalsOfDay: [ + { + sourceMetric: "sleep", + tone: "info", + headline: "Shorter night", + nudge: "An earlier night.", + delta: null, + }, + { + sourceMetric: "pulse", + tone: "watch", + headline: "Pulse up", + nudge: "Rest.", + delta: "+33.72 bpm vs your 30-day average", + }, + ], + }, + briefingGeneratedAt: new Date().toISOString(), + }), + insightsCachedLocale: "en", + locale: "en", + timezone: "UTC", + } as never); + // The pulse was measured yesterday: its signal is not today's. + vi.mocked(prisma.measurement.groupBy).mockResolvedValueOnce([ + { + type: "PULSE", + _max: { measuredAt: new Date(Date.now() - 36 * 60 * 60 * 1000) }, + }, + ] as never); + + const res = await (GET as unknown as (req: Request) => Promise)( + new Request("http://localhost/api/insights/generate"), + ); + const body = (await res.json()) as { + data: { + insights: { + dailyBriefing: { signalsOfDay: Array<{ sourceMetric: string }> }; + }; + }; + }; + expect( + body.data.insights.dailyBriefing.signalsOfDay.map((s) => s.sourceMetric), + ).toEqual(["sleep"]); + }); + it("returns an empty payload (no warm) on a cold cache without a provider", async () => { vi.mocked(hasUsableStatusProvider).mockResolvedValueOnce(false); vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({ diff --git a/src/app/api/insights/generate/route.ts b/src/app/api/insights/generate/route.ts index a6d53d23b..f19736286 100644 --- a/src/app/api/insights/generate/route.ts +++ b/src/app/api/insights/generate/route.ts @@ -11,6 +11,12 @@ * write breaks that convergence — the settling seam tests pin the client * half, this comment is the server half of the contract. */ +import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; +import { readBriefingGeneratedAt } from "@/lib/insights/briefing-generated-at"; +import { + generatedOnLocalToday, + insightsPayloadForToday, +} from "@/lib/daily/briefing-today-read"; import { withBriefingGeneratedAt } from "@/lib/insights/briefing-generated-at"; import { prisma } from "@/lib/db"; import { auditLog } from "@/lib/auth/audit"; @@ -168,6 +174,7 @@ export const GET = apiHandler(async (request: NextRequest) => { insightsCachedText: true, insightsCachedLocale: true, locale: true, + timezone: true, }, }); @@ -194,9 +201,19 @@ export const GET = apiHandler(async (request: NextRequest) => { dbUser.insightsCachedLocale !== readerLocale; const cachedAt = dbUser?.insightsCachedAt ?? null; + const timezone = dbUser?.timezone ?? DEFAULT_TIMEZONE; + // Fresh means written today on the reader's calendar, by the moment the + // text was generated: `insightsCachedAt` also moves when an unchanged + // warm only re-stamps yesterday's text. + const generatedToday = generatedOnLocalToday( + readBriefingGeneratedAt(dbUser?.insightsCachedText ?? null), + timezone, + new Date(), + ); const isFresh = !languageMismatch && cachedAt !== null && + generatedToday && Date.now() - cachedAt.getTime() < BRIEFING_FRESH_MS; // v1.18.9 (#4) — resolve provider availability once, up front. The @@ -254,7 +271,13 @@ export const GET = apiHandler(async (request: NextRequest) => { meta: { cached: true, legacyPayload, revalidating, hasProvider }, }); return apiSuccess({ - insights: cached, + // The briefing as every surface serves it: today's text only, its + // signals measured today, its deltas at display precision. + insights: await insightsPayloadForToday(cached, { + userId, + timezone, + language: readerLocale, + }), cached: true, cachedAt, legacyPayload, @@ -329,6 +352,7 @@ export const POST = apiHandler((request: NextRequest) => insightsCachedAt: true, insightsCachedText: true, insightsCachedLocale: true, + timezone: true, // v1.4.36 W3 T3 — per-user opt-out list mirroring the Coach // settings. Filtered off `features` before serialisation so the // LLM never sees the excluded blocks. @@ -376,6 +400,7 @@ export const POST = apiHandler((request: NextRequest) => request, userLocale: dbUser?.locale ?? user.locale ?? null, }); + const postTimezone = dbUser?.timezone ?? DEFAULT_TIMEZONE; const { data: body, error: jsonError } = await safeJson<{ force?: unknown; @@ -420,9 +445,16 @@ export const POST = apiHandler((request: NextRequest) => // We don't auto-regenerate — that would burn rate-limit tokens // on a cache-hit silently. User-initiated only. const legacyPayload = isLegacyInsightPayload(cached); - const carriesBriefing = cachedPayloadCarriesBriefing( - dbUser.insightsCachedText, - ); + // A briefing generated on an earlier day is not today's read: like + // a briefingless cache, it does not short-circuit a provider-backed + // account, and degrades to the cached payload on a 429 below. + const carriesBriefing = + cachedPayloadCarriesBriefing(dbUser.insightsCachedText) && + generatedOnLocalToday( + readBriefingGeneratedAt(dbUser.insightsCachedText), + postTimezone, + new Date(), + ); if (carriesBriefing || !(await hasUsableStatusProvider(userId))) { annotate({ action: { name: "insights.generate" }, @@ -433,7 +465,11 @@ export const POST = apiHandler((request: NextRequest) => }, }); return apiSuccess({ - insights: cached, + insights: await insightsPayloadForToday(cached, { + userId, + timezone: postTimezone, + language: locale, + }), cached: true, cachedAt: dbUser.insightsCachedAt, legacyPayload, @@ -480,7 +516,10 @@ export const POST = apiHandler((request: NextRequest) => meta: { cached: true, rate_limited_fallback: true }, }); return apiSuccess({ - insights: briefinglessCacheFallback.cached, + insights: await insightsPayloadForToday( + briefinglessCacheFallback.cached, + { userId, timezone: postTimezone, language: locale }, + ), cached: true, cachedAt: briefinglessCacheFallback.cachedAt, legacyPayload: briefinglessCacheFallback.legacyPayload, @@ -1039,6 +1078,7 @@ export const POST = apiHandler((request: NextRequest) => errorCode: "insights.generate.profileScopeChanged", }); } + const stored = withBriefingGeneratedAt(insights, new Date()); const committed = await prisma.user.updateMany({ where: { id: userId, @@ -1052,9 +1092,7 @@ export const POST = apiHandler((request: NextRequest) => }, data: { insightsCachedAt: new Date(), - insightsCachedText: JSON.stringify( - withBriefingGeneratedAt(insights, new Date()), - ), + insightsCachedText: JSON.stringify(stored), insightsCachedLocale: locale, insightsSnapshotHash: hashInsightSnapshot({ features: compactFeatures, @@ -1134,7 +1172,11 @@ export const POST = apiHandler((request: NextRequest) => }); return apiSuccess({ - insights, + insights: await insightsPayloadForToday(stored, { + userId, + timezone: postTimezone, + language: locale, + }), cached: false, legacyPayload: false, // v1.28.28 (#470) — additive: non-null when the grounding gate stripped diff --git a/src/lib/daily/__tests__/load-digest.test.ts b/src/lib/daily/__tests__/load-digest.test.ts index fb459be1e..dc7773590 100644 --- a/src/lib/daily/__tests__/load-digest.test.ts +++ b/src/lib/daily/__tests__/load-digest.test.ts @@ -363,64 +363,12 @@ describe("loadDailyDigest — AI parts", () => { }); }); -describe("loadDailyDigest — the briefing's own day", () => { - const BRIEFING = { - paragraph: "Last night's sleep came in a little short of your usual.", - signalsOfDay: [ - { - sourceMetric: "sleep", - tone: "info", - headline: "Shorter night", - nudge: "An earlier night would suit it.", - delta: null, - }, - ], - keyFindings: [], - }; - - function withBriefing(cachedText: string) { - vi.mocked(readDashboardSnapshotCached).mockResolvedValueOnce({ - ...SNAPSHOT, - // The row was stamped this morning, as every warm stamps it. - body: { - ...SNAPSHOT.body, - briefing: BRIEFING, - briefingUpdatedAt: "2026-07-17T02:30:00.000Z", - }, - } as never); - return { ...USER, insightsCachedText: cachedText } as User; - } - - it("does not serve yesterday's text that a warm only re-stamped", async () => { - // Generated yesterday; the unchanged-hash warm at 04:30 refreshed only - // insightsCachedAt. - const user = withBriefing( - JSON.stringify({ - dailyBriefing: BRIEFING, - briefingGeneratedAt: "2026-07-16T02:30:00.000Z", - }), +describe("loadDailyDigest — the briefing as the snapshot read resolved it", () => { + it("reads the snapshot on its own clock, so both agree on today", async () => { + await loadDailyDigest(USER, NOW); + expect(vi.mocked(readDashboardSnapshotCached).mock.calls[0][2]).toEqual( + expect.objectContaining({ now: NOW }), ); - const digest = await loadDailyDigest(user, NOW); - expect(digest.briefingLead).toBeNull(); - expect(digest.topSignal).toBeNull(); - }); - - it("does not serve a payload that predates the generation moment", async () => { - const user = withBriefing(JSON.stringify({ dailyBriefing: BRIEFING })); - const digest = await loadDailyDigest(user, NOW); - expect(digest.topSignal).toBeNull(); - }); - - it("serves text generated today", async () => { - const user = withBriefing( - JSON.stringify({ - dailyBriefing: BRIEFING, - briefingGeneratedAt: "2026-07-17T02:30:00.000Z", - }), - ); - const digest = await loadDailyDigest(user, NOW); - expect(digest.briefingLead).toBe(BRIEFING.paragraph); - expect(digest.topSignal?.sourceMetric).toBe("sleep"); }); }); diff --git a/src/lib/daily/briefing-today-read.ts b/src/lib/daily/briefing-today-read.ts new file mode 100644 index 000000000..f9d74bb20 --- /dev/null +++ b/src/lib/daily/briefing-today-read.ts @@ -0,0 +1,101 @@ +/** + * The briefing as every surface serves it: the digest, the dashboard + * snapshot and the `/insights` advisor payload all go through + * `briefingForToday`, so a client never decides for itself whether + * yesterday's text or a raw "+33.72 bpm" may be shown. + * + * The snapshot already carries each type's newest reading and passes it in; + * the advisor route has no snapshot at hand and reads the few reading types + * a signal can rest on here, in one grouped query, and only when the payload + * actually carries such a signal. + */ +import { dailyBriefingSchema, type DailyBriefing } from "@/lib/ai/schema"; +import { prisma } from "@/lib/db"; +import type { Locale } from "@/lib/i18n/config"; +import { readBriefingGeneratedAt } from "@/lib/insights/briefing-generated-at"; +import { userDayKey } from "@/lib/tz/format"; +import { + SIGNAL_READING_TYPE, + briefingForToday, +} from "@/lib/daily/briefing-today"; +import type { MeasurementType } from "@/generated/prisma/client"; + +/** Whether a generation moment falls on today's calendar day in `timezone`. */ +export function generatedOnLocalToday( + generatedAt: string | null, + timezone: string, + now: Date, +): boolean { + return ( + generatedAt !== null && + userDayKey(new Date(generatedAt), timezone) === userDayKey(now, timezone) + ); +} + +/** + * The newest reading of every type a briefing signal can rest on. A failed + * read answers "no reading" for every type, which drops reading-backed + * signals: the quiet side of the rule, never a stale claim. + */ +export async function readSignalLastSeen( + userId: string, +): Promise<(type: string) => string | null> { + try { + const rows = await prisma.measurement.groupBy({ + by: ["type"], + where: { + userId, + deletedAt: null, + type: { + in: Object.values(SIGNAL_READING_TYPE) as MeasurementType[], + }, + }, + _max: { measuredAt: true }, + }); + const byType = new Map( + rows.map((row) => [ + String(row.type), + row._max.measuredAt?.toISOString() ?? null, + ]), + ); + return (type) => byType.get(type) ?? null; + } catch { + return () => null; + } +} + +/** + * A cached advisor payload with its `dailyBriefing` resolved for today: + * null when the text was generated on an earlier day (or at an unknown + * time), otherwise without signals whose metric was not measured today and + * with every delta at its metric's precision. Anything that is not an + * advisor payload with a valid briefing passes through untouched. + */ +export async function insightsPayloadForToday( + payload: unknown, + ctx: { userId: string; timezone: string; language: Locale; now?: Date }, +): Promise { + if (payload === null || typeof payload !== "object") return payload; + const record = payload as Record; + if (record.dailyBriefing == null) return payload; + const parsed = dailyBriefingSchema.safeParse(record.dailyBriefing); + if (!parsed.success) return payload; + const briefing: DailyBriefing = parsed.data; + const now = ctx.now ?? new Date(); + const needsReadings = (briefing.signalsOfDay ?? []).some( + (s) => SIGNAL_READING_TYPE[s.sourceMetric] !== undefined, + ); + const lastSeenAt = needsReadings + ? await readSignalLastSeen(ctx.userId) + : () => null; + return { + ...record, + dailyBriefing: briefingForToday(briefing, { + generatedAt: readBriefingGeneratedAt(record), + lastSeenAt, + timezone: ctx.timezone, + todayLocalDate: userDayKey(now, ctx.timezone), + language: ctx.language, + }), + }; +} diff --git a/src/lib/daily/load-digest.ts b/src/lib/daily/load-digest.ts index 03dfa95ed..52db327b0 100644 --- a/src/lib/daily/load-digest.ts +++ b/src/lib/daily/load-digest.ts @@ -83,8 +83,6 @@ import { readTodayCycle, type TodayCycleRead } from "@/lib/cycle/today-verdict"; import { addDays, dayDiff } from "@/lib/cycle/day-math"; import { makeFormatters, resolveIntlLocale } from "@/lib/format-locale"; import { vitalDisplayDecimals } from "@/lib/measurements/vital-precision"; -import { briefingForToday } from "@/lib/daily/briefing-today"; -import { readBriefingGeneratedAt } from "@/lib/insights/briefing-generated-at"; import type { DateFormatPreference, TimeFormatPreference, @@ -573,7 +571,10 @@ export async function loadDailyDigest( coachAi, reactionLinesAi, ] = await Promise.all([ - readDashboardSnapshotCached(user, undefined, { locale: options.locale }), + readDashboardSnapshotCached(user, undefined, { + locale: options.locale, + now, + }), resolveModuleMap(user.id), prisma.integrationStatus.findMany({ where: { userId: user.id, state: { in: [...SYNC_ISSUE_STATES] } }, @@ -935,17 +936,9 @@ export async function loadDailyDigest( snapshot.layout.enabledHeroItemKinds ?? PRIORITY_ITEM_KINDS, score, - // Only what is still true about today: a briefing written on an - // earlier day is not served as today's read, and a signal whose - // metric has no reading today is dropped (`briefing-today.ts`). - briefing: briefingForToday(snapshot.briefing, { - generatedAt: readBriefingGeneratedAt(user.insightsCachedText), - lastSeenAt: (type) => - snapshot.tiles.lastSeenByType[type]?.lastSeenAt ?? null, - timezone: user.timezone, - todayLocalDate, - language: resolvedLocale, - }), + // Already resolved for today by the snapshot read (`briefing-today.ts` + // via `applyBriefingForToday`), as every surface serves it. + briefing: snapshot.briefing, medsToday: snapshot.medsToday, sleepLastSeenDaysAgo, morningRefreshedToday, diff --git a/src/lib/dashboard/__tests__/snapshot-read-briefing-capability.test.ts b/src/lib/dashboard/__tests__/snapshot-read-briefing-capability.test.ts index 0a2ab0f38..5c16fa762 100644 --- a/src/lib/dashboard/__tests__/snapshot-read-briefing-capability.test.ts +++ b/src/lib/dashboard/__tests__/snapshot-read-briefing-capability.test.ts @@ -44,7 +44,16 @@ vi.mock("@/lib/ai/capabilities/gate", () => ({ const { readDashboardSnapshotCached } = await import("../snapshot-read"); const { __resetAllCachesForTests } = await import("@/lib/cache/server-cache"); -const USER = { id: "user-briefing-gate", locale: "en" } as unknown as User; +// Generated a moment ago, so the today rule keeps it and these tests see +// only the capability decision. +const USER = { + id: "user-briefing-gate", + locale: "en", + timezone: "UTC", + insightsCachedText: JSON.stringify({ + briefingGeneratedAt: new Date().toISOString(), + }), +} as unknown as User; beforeEach(() => { __resetAllCachesForTests(); diff --git a/src/lib/dashboard/__tests__/snapshot-read-briefing-today.test.ts b/src/lib/dashboard/__tests__/snapshot-read-briefing-today.test.ts new file mode 100644 index 000000000..25ef9d114 --- /dev/null +++ b/src/lib/dashboard/__tests__/snapshot-read-briefing-today.test.ts @@ -0,0 +1,125 @@ +/** + * The snapshot read serves the briefing as it stands today, for every + * client: the dashboard, the RSC prefetch, iOS and the digest all read + * through `readDashboardSnapshotCached`. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { User } from "@/generated/prisma/client"; +import { AI_AVAILABLE } from "@/__tests__/helpers/ai-capability-fixtures"; + +const BRIEFING = { + paragraph: "Your pulse is well above its usual level today.", + signalsOfDay: [ + { + sourceMetric: "pulse", + tone: "watch", + headline: "Pulse is up today", + nudge: "Take it easy this evening.", + delta: "+33.72 bpm vs your 30-day average", + }, + { + sourceMetric: "sleep", + tone: "info", + headline: "Shorter night", + nudge: "An earlier night would suit it.", + delta: null, + }, + ], + keyFindings: [], +}; + +const body = (pulseLastSeen: string) => ({ + tiles: { + lastSeenByType: { PULSE: { lastSeenAt: pulseLastSeen, daysAgo: 0 } }, + }, + briefing: BRIEFING, + briefingMemory: null, + briefingState: "ready", + // Stamped this morning, as every warm stamps the row. + briefingUpdatedAt: "2026-10-05T02:30:00.000Z", + briefingStale: false, + briefingAi: null, +}); +let current = body("2026-10-04T06:50:00.000Z"); +const buildDashboardSnapshot = vi.fn(async () => current); + +vi.mock("@/lib/db", () => ({ prisma: {} })); +vi.mock("@/lib/dashboard/snapshot", async (importOriginal) => ({ + ...(await importOriginal()), + buildDashboardSnapshot: () => buildDashboardSnapshot(), +})); +vi.mock("@/lib/i18n/server-locale", () => ({ + resolveServerLocale: async () => "en", +})); +vi.mock("@/lib/ai/capabilities/gate", () => ({ + aiCapabilityToServe: async () => AI_AVAILABLE, +})); + +const { readDashboardSnapshotCached } = await import("../snapshot-read"); +const { __resetAllCachesForTests } = await import("@/lib/cache/server-cache"); + +/** 2026-10-05 21:00 in Berlin. */ +const NOW = new Date("2026-10-05T19:00:00.000Z"); + +function user(generatedAt: string | null): User { + return { + id: "user-briefing-today", + locale: "en", + timezone: "Europe/Berlin", + insightsCachedText: JSON.stringify({ + dailyBriefing: BRIEFING, + ...(generatedAt ? { briefingGeneratedAt: generatedAt } : {}), + }), + } as unknown as User; +} + +beforeEach(() => { + __resetAllCachesForTests(); + current = body("2026-10-04T06:50:00.000Z"); +}); + +describe("readDashboardSnapshotCached — the briefing's own day", () => { + it("does not serve yesterday's text that a warm only re-stamped", async () => { + const { body: read } = await readDashboardSnapshotCached( + user("2026-10-04T02:30:00.000Z"), + undefined, + { now: NOW }, + ); + expect(read.briefing).toBeNull(); + expect(read.briefingState).toBe("preparing"); + }); + + it("does not serve a payload with no generation moment", async () => { + const { body: read } = await readDashboardSnapshotCached( + user(null), + undefined, + { now: NOW }, + ); + expect(read.briefing).toBeNull(); + }); + + it("drops a pulse signal last measured yesterday and keeps the rest", async () => { + const { body: read } = await readDashboardSnapshotCached( + user("2026-10-05T02:30:00.000Z"), + undefined, + { now: NOW }, + ); + expect(read.briefing?.signalsOfDay?.map((s) => s.sourceMetric)).toEqual([ + "sleep", + ]); + expect(read.briefingState).toBe("ready"); + }); + + it("serves today's pulse signal with its delta at display precision", async () => { + current = body("2026-10-05T06:50:00.000Z"); + const { body: read } = await readDashboardSnapshotCached( + user("2026-10-05T02:30:00.000Z"), + undefined, + { now: NOW }, + ); + expect(read.briefing?.signalsOfDay?.[0]?.delta).toBe( + "+34 bpm vs your 30-day average", + ); + }); +}); diff --git a/src/lib/dashboard/snapshot-read.ts b/src/lib/dashboard/snapshot-read.ts index 41cfb684c..59949a411 100644 --- a/src/lib/dashboard/snapshot-read.ts +++ b/src/lib/dashboard/snapshot-read.ts @@ -37,6 +37,9 @@ import { import { aiCapabilityToServe } from "@/lib/ai/capabilities/gate"; import { resolveServerLocale } from "@/lib/i18n/server-locale"; import type { Locale } from "@/lib/i18n/config"; +import { briefingForToday } from "@/lib/daily/briefing-today"; +import { readBriefingGeneratedAt } from "@/lib/insights/briefing-generated-at"; +import { userDayKey } from "@/lib/tz/format"; /** * Per-key TTL for the snapshot cache entry. Strictly greater than the @@ -50,6 +53,46 @@ import type { Locale } from "@/lib/i18n/config"; */ export const SNAPSHOT_CACHE_TTL_MS = DASHBOARD_REFETCH_INTERVAL_MS + 60_000; +/** + * Resolve the snapshot's briefing for today, on every read. + * + * The same rule the digest applies, here so the dashboard, the RSC prefetch, + * the iOS snapshot read and the digest all serve one answer: text generated + * on an earlier calendar day (or at an unknown time) is not served, a + * reading-backed signal whose metric has no reading today is dropped, and + * every delta reads at its metric's precision. Applied after the cache like + * the capability, so a body cached at 23:58 does not serve yesterday's text + * at 00:01. A withheld briefing reads "preparing" while a warm is due, never + * "ready" with nothing in it. + */ +export function applyBriefingForToday( + body: DashboardSnapshot, + ctx: { + generatedAt: string | null; + timezone: string; + language: Locale; + now: Date; + }, +): DashboardSnapshot { + if (!body.briefing) return body; + const lastSeen = body.tiles?.lastSeenByType ?? {}; + const briefing = briefingForToday(body.briefing, { + generatedAt: ctx.generatedAt, + lastSeenAt: (type) => lastSeen[type]?.lastSeenAt ?? null, + timezone: ctx.timezone, + todayLocalDate: userDayKey(ctx.now, ctx.timezone), + language: ctx.language, + }); + if (briefing) return { ...body, briefing }; + return { + ...body, + briefing: null, + briefingStale: false, + briefingState: + body.briefingState === "ready" ? "preparing" : body.briefingState, + }; +} + export interface SnapshotReadResult { body: DashboardSnapshot; /** The locale the snapshot was resolved (and cache-keyed) under. */ @@ -63,6 +106,8 @@ export interface SnapshotReadOptions { * resolution here so the digest and the push copy agree on a language. */ locale?: Locale; + /** The read's clock; the digest passes its own so both agree on today. */ + now?: Date; } /** @@ -130,7 +175,15 @@ export async function readDashboardSnapshotCached( // capability is available, decided on every read rather than baked into // the cached body. The record's own state decides, whoever is reading, so a // delegate sees the owner's briefing exactly when the owner would. - const body = applyBriefingCapability(cached, briefingAi); + const body = applyBriefingForToday( + applyBriefingCapability(cached, briefingAi), + { + generatedAt: readBriefingGeneratedAt(user.insightsCachedText), + timezone: user.timezone, + language: locale, + now: options.now ?? new Date(), + }, + ); return { body, locale }; } diff --git a/src/lib/openapi/routes/insights/schemas.ts b/src/lib/openapi/routes/insights/schemas.ts index 16b65fd5b..af86c029f 100644 --- a/src/lib/openapi/routes/insights/schemas.ts +++ b/src/lib/openapi/routes/insights/schemas.ts @@ -2545,7 +2545,7 @@ export const insightsGenerateReadResponse = z .record(z.string(), z.unknown()) .nullable() .describe( - "The cached insight payload, verbatim from the account's cache row. Null when nothing is cached or the stored row failed to parse — the two are indistinguishable here, and the second also silently enqueues a repair.", + "The cached insight payload from the account's cache row, with its `dailyBriefing` resolved for today: null when the text was generated on an earlier calendar day in the account's timezone (or carries no `briefingGeneratedAt`, the moment it was generated), otherwise without the signals of the day whose reading-backed metric has no reading today, and with each signal delta at its metric's display precision in the reader's number format. Null when nothing is cached or the stored row failed to parse — the two are indistinguishable here, and the second also silently enqueues a repair.", ), cached: z .boolean() @@ -2615,7 +2615,9 @@ export const insightsGeneratePostResponse = z .object({ insights: z .record(z.string(), z.unknown()) - .describe("The insight payload, fresh or cached depending on `cached`."), + .describe( + "The insight payload, fresh or cached depending on `cached`, with its `dailyBriefing` resolved for today exactly as on the read GET. A cached briefing generated on an earlier calendar day never short-circuits an account with a provider: like a briefingless cache, it regenerates, and degrades to the cached payload on an exhausted quota.", + ), cached: z.boolean(), cachedAt: z.iso .datetime({ offset: true }) From 143a39e369c5ea678be03528262a6c14af9c5e7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 5 Oct 2026 21:58:52 +0200 Subject: [PATCH 13/19] fix(coach): take a condensed series summary from its daily values The summary of a condensed block was taken over the block's mix of daily values and weekly means: over a year the newest fourteen days weighed as much as fourteen weeks, min and max were extremes of means, and change was one day minus a weekly mean. Each series block now hands the budget pass its daily values over the whole read, computed only when the block is condensed. The summary states the first and last day, the lowest and highest day, the mean of the days, and the change from the first calendar week to the latest one. A block without daily values gets no summary rather than one of what is left. The coverage note says how the summary is taken. --- CHANGELOG.md | 6 +- .../__tests__/series-long-window.test.ts | 162 +++++++++++++---- src/lib/ai/coach/series-condense.ts | 165 ++++++++++-------- .../coach/snapshot-blocks/compliance-block.ts | 27 ++- .../snapshot-blocks/core-metrics-block.ts | 29 ++- .../ai/coach/snapshot-blocks/glucose-block.ts | 24 ++- .../ai/coach/snapshot-blocks/sleep-block.ts | 10 +- .../snapshot-blocks/value-series-blocks.ts | 8 +- src/lib/ai/coach/snapshot-series.ts | 18 ++ src/lib/ai/coach/snapshot.ts | 18 +- src/lib/ai/coach/tools/executor.ts | 3 +- src/lib/ai/coach/tools/system-addendum.ts | 4 +- 12 files changed, 335 insertions(+), 139 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c87841dab..9b1fdeacc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,8 +11,10 @@ dashboard card fits a phone; text fields lose the purple focus ring. shared per-turn snapshot capped at 24,000 characters; on accounts with many series whole blocks became a placeholder and the tool still answered `present: true`. A trimmed block is now re-read for the tool's own source, - a Coach tool's read of one source is condensed (summary, newest days, - weekly and monthly means) instead of emptied, a block without numbers + a Coach tool's read of one source is condensed instead of emptied (a + summary from the daily values with the latest week against the first, + the newest days, monthly means, each block in its own fields), a block + without numbers answers `retrieval_failed` to the Coach, and the Coach calls the table tool itself for long histories. MCP reads are unchanged. The step list shows the reading count; no stray comma on copy. diff --git a/src/lib/ai/coach/__tests__/series-long-window.test.ts b/src/lib/ai/coach/__tests__/series-long-window.test.ts index a6e93f0ab..9a6f080d7 100644 --- a/src/lib/ai/coach/__tests__/series-long-window.test.ts +++ b/src/lib/ai/coach/__tests__/series-long-window.test.ts @@ -17,8 +17,9 @@ import { KEEP_SESSIONS, condenseSeriesBlock, isoWeekMonth, - summarisePoints, + summariseDaily, weeklyToMonthly, + type DailyPoint, } from "@/lib/ai/coach/series-condense"; const buildCoachSnapshot = @@ -71,6 +72,19 @@ function recent(count: number, value = (i: number) => 30 + i) { })); } +/** `count` consecutive days from `start`, valued by `value(i)`. */ +function days( + count: number, + start: string, + value: (i: number) => number, +): DailyPoint[] { + return Array.from({ length: count }, (_, i) => { + const at = new Date(`${start}T00:00:00Z`); + at.setUTCDate(at.getUTCDate() + i); + return { date: at.toISOString().slice(0, 10), value: value(i) }; + }); +} + describe("series-condense", () => { it("places an ISO week in the month of its Thursday", () => { expect(isoWeekMonth("2026-W01")).toBe("2026-01"); @@ -93,25 +107,50 @@ describe("series-condense", () => { ]); }); - it("summarises points in time order", () => { - expect( - summarisePoints([ - { at: "2026-09-02", value: 18 }, - { at: "2025-W10", value: 21 }, - { at: "2026-09-01", value: 17 }, - ]), - ).toEqual({ - from: "2025-W10", - to: "2026-09-02", - first: 21, - last: 18, - min: 17, - max: 21, - mean: 18.67, - change: -3, - points: 3, + it("summarises the daily values, each day weighing the same", () => { + // A year at 20, then a fortnight at 30: the fortnight is 14 of 379 days. + const year = days(379, "2025-09-01", (i) => (i < 365 ? 20 : 30)); + const summary = summariseDaily([...year].reverse()); + expect(summary).toEqual({ + from: "2025-09-01", + to: "2026-09-14", + days: 379, + first: 20, + last: 30, + min: 20, + max: 30, + mean: 20.37, + firstWeekMean: 20, + latestWeekMean: 30, + change: 10, }); - expect(summarisePoints([])).toBeNull(); + expect(summariseDaily([])).toBeNull(); + }); + + it("compares the first and latest calendar week, not two single days", () => { + // Noisy days: first day low, last day high, both weeks average 50. + const series = days(60, "2026-01-01", (i) => + i === 0 + ? 40 + : i === 59 + ? 60 + : 50 + (i % 2 === 0 ? 1 : -1) * (i < 7 || i > 52 ? 0 : 3), + ); + const summary = summariseDaily(series)!; + expect(summary.first).toBe(40); + expect(summary.last).toBe(60); + expect(summary.firstWeekMean).toBe(48.57); + expect(summary.latestWeekMean).toBe(51.43); + expect(summary.change).toBe(2.86); + }); + + it("gives a block no summary without its daily values", () => { + const block = { + unit: "kg", + timeline: { recent: recent(14), weekly: weeks(150) }, + }; + condenseSeriesBlock(block, 1, "fatMass"); + expect(block).not.toHaveProperty("summary"); }); it("keeps numbers through every step and names what it cut", () => { @@ -119,25 +158,25 @@ describe("series-condense", () => { unit: "kg", timeline: { recent: recent(14), weekly: weeks(150) }, }; - expect(condenseSeriesBlock(block, 1, "fatMass")).toBe(true); + const daily = { value: days(1050, "2023-10-25", (i) => (2000 + i) / 100) }; + expect(condenseSeriesBlock(block, 1, "fatMass", daily)).toBe(true); expect(block.timeline.recent).toHaveLength(KEEP_DAILY_ROWS); expect(block.timeline.recent.at(-1)?.value).toBe(43); - expect(condenseSeriesBlock(block, 2, "fatMass")).toBe(true); + expect(condenseSeriesBlock(block, 2, "fatMass", daily)).toBe(true); expect(block.timeline).not.toHaveProperty("weekly"); const monthly = (block.timeline as { monthly?: unknown[] }).monthly; expect(monthly!.length).toBeGreaterThan(30); - expect(condenseSeriesBlock(block, 3, "fatMass")).toBe(true); + expect(condenseSeriesBlock(block, 3, "fatMass", daily)).toBe(true); expect((block.timeline as { monthly?: unknown[] }).monthly).toHaveLength( KEEP_MONTHLY_ROWS, ); - // The summary was taken before anything was cut: every week and day. + // The summary is the daily values', whatever the block still holds. const summary = (block as { summary?: Record }).summary; expect(summary).toMatchObject({ - from: "2023-W01", - to: "2026-09-14", + from: "2023-10-25", + days: 1050, first: 20, - last: 43, - points: 164, + last: 30.49, }); expect((block as { condensed?: string[] }).condensed).toEqual([ `daily values: newest ${KEEP_DAILY_ROWS} days kept`, @@ -154,7 +193,10 @@ describe("series-condense", () => { weeklyDia: [{ weekISO: "2026-W30", mean: 85, count: 4 }], }, }; - condenseSeriesBlock(block, 2, "bloodPressure"); + condenseSeriesBlock(block, 2, "bloodPressure", { + sys: days(40, "2026-07-24", (i) => (i < 33 ? 130 : 121)), + dia: days(40, "2026-07-24", (i) => (i < 33 ? 85 : 79)), + }); expect(block).toMatchObject({ summary: { sys: { first: 130, last: 121, change: -9 }, @@ -240,6 +282,10 @@ describe("get_metric_series inside a chat turn", () => { expect(data.section).toEqual(FAT_MASS); expect(data.readings).toBe(1460); expect(data.coverage).toContain("call get_metric_table yourself"); + // The note says how the summary was taken, and claims no more. + expect(data.coverage).toContain("daily values over the whole read"); + expect(data.coverage).toContain("latestWeekMean"); + expect(data.coverage).not.toContain("every point"); }); it("keeps the shared build when the block was not cut there", async () => { @@ -303,13 +349,19 @@ describe("series-condense reads each block in its own fields", () => { })), }, }; - condenseSeriesBlock(block, 1, "sleep"); + const nights = { + value: [ + ...days(49, "2026-07-20", () => 320), + ...days(14, "2026-09-10", (i) => 400 + i), + ], + }; + condenseSeriesBlock(block, 1, "sleep", nights); expect(block).toMatchObject({ - summary: { to: "2026-09-23", last: 413 }, + summary: { to: "2026-09-23", last: 413, days: 63 }, }); expect(block.timeline.recent).toHaveLength(KEEP_DAILY_ROWS); expect(block.timeline.recent.at(-1)).toMatchObject({ minutes: 413 }); - condenseSeriesBlock(block, 2, "sleep"); + condenseSeriesBlock(block, 2, "sleep", nights); expect( (block.timeline as unknown as { monthly: unknown[] }).monthly, ).toEqual([ @@ -332,7 +384,12 @@ describe("series-condense reads each block in its own fields", () => { ], }, }; - condenseSeriesBlock(block, 2, "compliance"); + condenseSeriesBlock(block, 2, "compliance", { + value: [ + { date: "2026-09-30", value: 1 }, + { date: "2026-10-01", value: 0.5 }, + ], + }); expect(block).toMatchObject({ summary: { to: "2026-10-01", last: 0.5 }, timeline: { monthly: [{ month: "2026-09", rate: 0.56, total: 16 }] }, @@ -350,8 +407,12 @@ describe("series-condense reads each block in its own fields", () => { panel: { tir: 0.8 }, byContext: { FASTING: ctx(95), POST_MEAL: ctx(140) }, }; - expect(condenseSeriesBlock(block, 1, "glucose")).toBe(true); - expect(condenseSeriesBlock(block, 2, "glucose")).toBe(true); + const daily = { + FASTING: days(14, "2026-09-01", (i) => 95 + i), + POST_MEAL: days(14, "2026-09-01", (i) => 140 + i), + }; + expect(condenseSeriesBlock(block, 1, "glucose", daily)).toBe(true); + expect(condenseSeriesBlock(block, 2, "glucose", daily)).toBe(true); expect(block).toMatchObject({ summary: { FASTING: { last: 108, to: "2026-09-14" }, @@ -406,12 +467,16 @@ describe("the prompt-budget pass on a single-source read", () => { snap, new Map(clusters), new Set(["fatMass"]), + (key) => + key === "fatMass" + ? { value: days(534, "2025-03-01", (i) => 20 + i / 100) } + : undefined, ); expect(snap.steps).toEqual({ omitted: "trimmed for prompt budget" }); expect(snap.walkingSpeed).toEqual({ omitted: "trimmed for prompt budget" }); const fat = snap.fatMass as Record; expect(fat).not.toHaveProperty("omitted"); - expect(fat.summary).toMatchObject({ points: 534, last: 43 }); + expect(fat.summary).toMatchObject({ days: 534, last: 25.33 }); expect(degraded.map((d) => d.key).at(-1)).toBe("fatMass"); }); }); @@ -469,3 +534,30 @@ describe("the MCP read stays as it was", () => { }); }); }); + +describe("dailyPoints", () => { + it("states each local day's value in the reader's unit", async () => { + const { dailyPoints } = await import("@/lib/ai/coach/snapshot-series"); + const { getReadingTransform } = + await import("@/lib/measurements/display-transform"); + const rows = [ + { measuredAt: new Date("2026-09-01T06:00:00Z"), value: 80 }, + { measuredAt: new Date("2026-09-01T20:00:00Z"), value: 82 }, + { measuredAt: new Date("2026-09-02T06:00:00Z"), value: 81 }, + ]; + expect(dailyPoints(rows, "UTC")).toEqual([ + { date: "2026-09-01", value: 81 }, + { date: "2026-09-02", value: 81 }, + ]); + const lb = dailyPoints( + rows, + "UTC", + undefined, + getReadingTransform("WEIGHT", { + ...DEFAULT_UNIT_PREFERENCES, + system: "imperial", + }), + ); + expect(lb[0].value).toBeCloseTo(178.6, 0); + }); +}); diff --git a/src/lib/ai/coach/series-condense.ts b/src/lib/ai/coach/series-condense.ts index 164a15b3f..e068e9019 100644 --- a/src/lib/ai/coach/series-condense.ts +++ b/src/lib/ai/coach/series-condense.ts @@ -9,8 +9,11 @@ * readings exist and holds no figure, and the model then says exactly that. * * A requested block is condensed instead. Before anything is cut it gains a - * `summary` (first, last, min, max, mean and change over every point it held) - * and each step after that keeps numbers: + * `summary` per series, taken from the series' daily values over the whole + * read (not from the block's mix of daily and weekly means, where fourteen + * days would weigh as much as fourteen weeks): the first and last day, the + * lowest and highest day, the mean of the days, and the change from the + * first week to the latest week. Each step after that keeps numbers: * * 1. the daily rows keep their newest `KEEP_DAILY_ROWS`; * 2. the weekly means fold into monthly means (count-weighted); @@ -20,28 +23,61 @@ * Every step it took is named in `condensed`, so the block says what it no * longer carries. Pure over its input: no I/O, no clock. */ -import { dateOnlyKey } from "@/lib/tz/date-only"; +import { dateOnlyKey, dayKeyAsUtcMidnight } from "@/lib/tz/date-only"; +import type { CoachScopeSource } from "./types"; /** Daily rows a condensed block keeps, newest first in time order. */ export const KEEP_DAILY_ROWS = 7; /** Monthly means a fully condensed block keeps. */ export const KEEP_MONTHLY_ROWS = 6; +/** One day's value of a series, in the reader's unit. */ +export interface DailyPoint { + /** `YYYY-MM-DD`, the person's local day. */ + date: string; + value: number; +} + +/** + * A block's daily values per series: `value` for a block with one series, + * else keyed like the summary (`sys`/`dia`, a glucose context). + */ +export type DailySeries = Readonly>>; + +/** + * How a block builder registers its block with the budget pass, and, for a + * series block, how to read its daily values should the block be condensed. + * The values are only computed then. + */ +export type RegisterBlock = ( + key: string, + source: CoachScopeSource, + daily?: () => DailySeries, +) => void; + +/** Days the first-week and latest-week means each span. */ +export const COMPARE_DAYS = 7; + export interface SeriesSummary { - /** Earliest point's period: a day (`YYYY-MM-DD`) or an ISO week. */ + /** First and last day with a value. */ from: string; - /** Newest point's period. */ to: string; + /** Days with a value; every figure below is over these days. */ + days: number; + /** The first day's and the last day's value. */ first: number; last: number; + /** The lowest and highest daily value. */ min: number; max: number; - /** Mean of the points (daily and weekly means alike), not of readings. */ + /** Mean of the daily values. */ mean: number; - /** `last - first`. */ + /** Mean of the days in the first `COMPARE_DAYS` calendar days. */ + firstWeekMean: number; + /** Mean of the days in the latest `COMPARE_DAYS` calendar days. */ + latestWeekMean: number; + /** `latestWeekMean - firstWeekMean`. */ change: number; - /** How many points the figures above were taken over. */ - points: number; } type Rec = Record; @@ -54,52 +90,47 @@ function round(value: number): number { return Math.round(value * 100) / 100; } -/** ISO week `YYYY-Www` → the Monday that starts it, as `YYYY-MM-DD`. */ -function isoWeekMonday(weekISO: string): string | null { - const match = /^(\d{4})-W(\d{2})$/.exec(weekISO); - if (!match) return null; - const jan4 = new Date(Date.UTC(Number(match[1]), 0, 4)); - const monday = new Date(jan4); - monday.setUTCDate( - jan4.getUTCDate() - - (jan4.getUTCDay() || 7) + - 1 + - (Number(match[2]) - 1) * 7, - ); - return dateOnlyKey(monday); +/** The day `days` after a `YYYY-MM-DD` key, as a key. */ +function shiftDay(key: string, days: number): string { + const at = dayKeyAsUtcMidnight(key); + at.setUTCDate(at.getUTCDate() + days); + return dateOnlyKey(at); } -/** - * A sortable key for a point's period. A week and a day do not compare as - * strings ("2026-W30" sorts after "2026-09-01"), so a week sorts by its - * Monday. - */ -function timeKey(at: string): string { - return isoWeekMonday(at) ?? at; -} - -/** Summary over points (any order), or null when there are none. */ -export function summarisePoints( - points: ReadonlyArray<{ at: string; value: number }>, +/** Summary over a series' daily values (any order), or null when empty. */ +export function summariseDaily( + points: ReadonlyArray, ): SeriesSummary | null { - const finite = points.filter((p) => Number.isFinite(p.value)); - if (finite.length === 0) return null; - const sorted = [...finite].sort((a, b) => - timeKey(a.at).localeCompare(timeKey(b.at)), + const days = points + .filter((p) => Number.isFinite(p.value)) + .slice() + .sort((a, b) => a.date.localeCompare(b.date)); + if (days.length === 0) return null; + const values = days.map((d) => d.value); + const mean = (list: number[]) => + list.reduce((s, v) => s + v, 0) / list.length; + const from = days[0].date; + const to = days[days.length - 1].date; + const firstEnd = shiftDay(from, COMPARE_DAYS - 1); + const latestStart = shiftDay(to, -(COMPARE_DAYS - 1)); + const firstWeek = mean( + days.filter((d) => d.date <= firstEnd).map((d) => d.value), + ); + const latestWeek = mean( + days.filter((d) => d.date >= latestStart).map((d) => d.value), ); - const values = sorted.map((p) => p.value); - const first = values[0]; - const last = values[values.length - 1]; return { - from: sorted[0].at, - to: sorted[sorted.length - 1].at, - first, - last, + from, + to, + days: days.length, + first: values[0], + last: values[values.length - 1], min: Math.min(...values), max: Math.max(...values), - mean: round(values.reduce((s, v) => s + v, 0) / values.length), - change: round(last - first), - points: values.length, + mean: round(mean(values)), + firstWeekMean: round(firstWeek), + latestWeekMean: round(latestWeek), + change: round(latestWeek - firstWeek), }; } @@ -244,31 +275,13 @@ export function isCondensable(key: string, block: unknown): boolean { return tracksOf(kind, block).length > 0; } -function trackSummary(track: Track): SeriesSummary | null { - const points: Array<{ at: string; value: number }> = []; - const weekly = track.container[track.weekly]; - if (Array.isArray(weekly)) { - for (const b of weekly) { - if (!isRecord(b) || typeof b.weekISO !== "string") continue; - const value = b[track.weeklyValue]; - if (typeof value === "number") points.push({ at: b.weekISO, value }); - } - } - const recent = track.container.recent; - if (Array.isArray(recent)) { - for (const row of recent) { - if (!isRecord(row) || typeof row.date !== "string") continue; - const value = row[track.field]; - if (typeof value === "number") points.push({ at: row.date, value }); - } - } - return summarisePoints(points); -} - -function blockSummary(tracks: ReadonlyArray): unknown { +function blockSummary( + tracks: ReadonlyArray, + daily: DailySeries, +): unknown { const out: Rec = {}; for (const track of tracks) { - const summary = trackSummary(track); + const summary = summariseDaily(daily[track.name ?? "value"] ?? []); if (!summary) continue; if (track.name === null) return summary; out[track.name] = summary; @@ -294,6 +307,12 @@ export function condenseSeriesBlock( block: unknown, step: 1 | 2 | 3, key: string, + /** + * The block's daily values over the whole read, for its summary. Without + * them the block gets no summary: a summary of what is left would not be + * a summary of the series. + */ + daily?: DailySeries, ): boolean { if (!isRecord(block)) return false; const kind = seriesBlockKind(key); @@ -316,8 +335,8 @@ export function condenseSeriesBlock( } const tracks = tracksOf(kind, block); if (tracks.length === 0) return false; - if (block.summary === undefined) { - const summary = blockSummary(tracks); + if (block.summary === undefined && daily) { + const summary = blockSummary(tracks, daily); if (summary !== undefined) block.summary = summary; } const containers = new Set(tracks.map((t) => t.container)); diff --git a/src/lib/ai/coach/snapshot-blocks/compliance-block.ts b/src/lib/ai/coach/snapshot-blocks/compliance-block.ts index ebccd6a2e..e3bc7085f 100644 --- a/src/lib/ai/coach/snapshot-blocks/compliance-block.ts +++ b/src/lib/ai/coach/snapshot-blocks/compliance-block.ts @@ -26,11 +26,8 @@ import type { DoseHistoryRow } from "@/lib/medications/scheduling/dose-history"; import { annotate } from "@/lib/logging/context"; import { courseStatusOn } from "@/lib/medications/course-window"; import { isoWeekKey, tzDayKey, tzWeekday } from "../snapshot-series"; -import type { - CoachProvenance, - CoachProvenanceMetric, - CoachScopeSource, -} from "../types"; +import type { RegisterBlock } from "../series-condense"; +import type { CoachProvenance, CoachProvenanceMetric } from "../types"; /** * The medication row shape the block consumes — exactly what the @@ -53,7 +50,7 @@ interface ComplianceBlockContext { snapshot: Record; metrics: Set; counts: NonNullable; - registerBlock: (key: string, source: CoachScopeSource) => void; + registerBlock: RegisterBlock; } function courseSummary( @@ -208,7 +205,23 @@ export function buildComplianceBlock( }; metrics.add("compliance"); counts.compliance = countable.length; - registerBlock("compliance", "compliance"); + registerBlock("compliance", "compliance", () => { + // Every day's rate over the whole window, as the recent rows state it. + const byDay = new Map(); + for (const r of countable) { + const key = tzDayKey(r.at, userTz); + const e = byDay.get(key) ?? { taken: 0, total: 0 }; + e.total += 1; + if (isTaken(r)) e.taken += 1; + byDay.set(key, e); + } + return { + value: Array.from(byDay.entries()).map(([date, e]) => ({ + date, + value: Math.round((e.taken / e.total) * 100) / 100, + })), + }; + }); } else { // v1.7.0 — toggled-on cluster with no rows. Annotate so the // dashboards can distinguish "user has no medication data" from diff --git a/src/lib/ai/coach/snapshot-blocks/core-metrics-block.ts b/src/lib/ai/coach/snapshot-blocks/core-metrics-block.ts index d5586ea8a..8c4fa30c3 100644 --- a/src/lib/ai/coach/snapshot-blocks/core-metrics-block.ts +++ b/src/lib/ai/coach/snapshot-blocks/core-metrics-block.ts @@ -10,9 +10,11 @@ import { bucketWeekly, buildDailyBpRows, buildDailyValueRows, + dailyPoints, timelineInUnit, type CoarseTimelineTail, } from "../snapshot-series"; +import type { RegisterBlock } from "../series-condense"; import type { CoachProvenance, CoachProvenanceMetric, @@ -42,7 +44,7 @@ interface CoreMetricsBlockContext { windows: Set; metrics: Set; counts: NonNullable; - registerBlock: (key: string, source: CoachScopeSource) => void; + registerBlock: RegisterBlock; groundingValues: Map; /** The reader's units; the weight block is stated in them. */ units: UnitPreferences; @@ -91,7 +93,13 @@ export function buildCoreMetricsBlocks( ctx.windows.add("last30days"); ctx.windows.add("last90days"); ctx.counts.bp = ctx.features.bloodPressure.coverage?.count ?? undefined; - ctx.registerBlock("bloodPressure", "bp"); + ctx.registerBlock("bloodPressure", "bp", () => { + const days = buildDailyBpRows(sysRows, diaRows, new Date(0), ctx.userTz); + return { + sys: days.map((d) => ({ date: d.date, value: d.sys })), + dia: days.map((d) => ({ date: d.date, value: d.dia })), + }; + }); const sys30 = ctx.features.bloodPressure.avgSys30 ?? @@ -124,7 +132,14 @@ export function buildCoreMetricsBlocks( ctx.windows.add("last7days"); ctx.windows.add("last30days"); ctx.counts.weight = ctx.features.weight.coverage?.count ?? undefined; - ctx.registerBlock("weight", "weight"); + ctx.registerBlock("weight", "weight", () => ({ + value: dailyPoints( + rows, + ctx.userTz, + undefined, + getReadingTransform("WEIGHT", ctx.units), + ), + })); if (ctx.features.weight.bmi != null) { ctx.groundingValues.set("BMI", ctx.features.weight.bmi); @@ -157,7 +172,9 @@ export function buildCoreMetricsBlocks( ctx.windows.add("last30days"); ctx.windows.add("last90days"); ctx.counts.pulse = ctx.features.pulse.coverage?.count ?? undefined; - ctx.registerBlock("pulse", "pulse"); + ctx.registerBlock("pulse", "pulse", () => ({ + value: dailyPoints(rows, ctx.userTz, "PULSE"), + })); } if (ctx.sources.has("mood") && ctx.features.mood && ctx.moodRows) { @@ -179,6 +196,8 @@ export function buildCoreMetricsBlocks( ctx.windows.add("last7days"); ctx.windows.add("last30days"); ctx.counts.mood = ctx.features.mood.coverage?.count ?? undefined; - ctx.registerBlock("mood", "mood"); + ctx.registerBlock("mood", "mood", () => ({ + value: dailyPoints(normalised, ctx.userTz), + })); } } diff --git a/src/lib/ai/coach/snapshot-blocks/glucose-block.ts b/src/lib/ai/coach/snapshot-blocks/glucose-block.ts index 3d21dae11..fc683c2da 100644 --- a/src/lib/ai/coach/snapshot-blocks/glucose-block.ts +++ b/src/lib/ai/coach/snapshot-blocks/glucose-block.ts @@ -25,11 +25,8 @@ import { import { annotate } from "@/lib/logging/context"; import type { ReferenceMetric } from "@/lib/reference-ranges"; import { bucketWeekly, buildDailyValueRows } from "../snapshot-series"; -import type { - CoachProvenance, - CoachProvenanceMetric, - CoachScopeSource, -} from "../types"; +import type { RegisterBlock } from "../series-condense"; +import type { CoachProvenance, CoachProvenanceMetric } from "../types"; /** * v1.17.0 — the glucose clinical panel is a fixed trailing-30-day artifact, @@ -57,7 +54,7 @@ interface GlucoseBlockContext { snapshot: Record; metrics: Set; counts: NonNullable; - registerBlock: (key: string, source: CoachScopeSource) => void; + registerBlock: RegisterBlock; groundingValues: Map; } @@ -102,6 +99,10 @@ export function buildGlucoseBlock(ctx: Readonly): void { byContext.set(ctxKey, list); } const contexts: Record = {}; + const dailyByContext: Record< + string, + Array<{ date: string; value: number }> + > = {}; for (const [ctxKey, rows] of byContext) { // v1.16.16 — glucose is stored canonical mg/dL. A mmol/L-preference // user's Coach must read the same number every other surface shows @@ -123,6 +124,15 @@ export function buildGlucoseBlock(ctx: Readonly): void { : w, ); contexts[ctxKey] = { recent, weekly }; + dailyByContext[ctxKey] = buildDailyValueRows( + rows, + new Date(0), + userTz, + ).map((d) => ({ + date: d.date, + // The reader's unit; a mg/dL day is a whole number, as the app states it. + value: convertGlucose(d.value, glucoseUnit), + })); } // v1.17.0 — clinical panel summary from the ONE literature-locked engine // the insights panel + doctor report also consume, computed over the SAME @@ -203,7 +213,7 @@ export function buildGlucoseBlock(ctx: Readonly): void { }; metrics.add("glucose"); counts.glucose = glucoseRows.length; - registerBlock("glucose", "glucose"); + registerBlock("glucose", "glucose", () => dailyByContext); // W7 grounding: fasting glucose mean in RAW mg/dL (the reference band's // unit), independent of the user's mmol/L display preference. The // grounding line's band selection respects the W6 `hasDiabetes` opt-in; diff --git a/src/lib/ai/coach/snapshot-blocks/sleep-block.ts b/src/lib/ai/coach/snapshot-blocks/sleep-block.ts index c90da3d84..b3c92adec 100644 --- a/src/lib/ai/coach/snapshot-blocks/sleep-block.ts +++ b/src/lib/ai/coach/snapshot-blocks/sleep-block.ts @@ -28,6 +28,7 @@ import { import { annotate } from "@/lib/logging/context"; import type { ReferenceMetric } from "@/lib/reference-ranges"; import { bucketWeekly, tzWeekday } from "../snapshot-series"; +import type { RegisterBlock } from "../series-condense"; import type { CoachProvenance, CoachProvenanceMetric, @@ -42,7 +43,7 @@ interface SleepTimelineBlockContext { snapshot: Record; metrics: Set; counts: NonNullable; - registerBlock: (key: string, source: CoachScopeSource) => void; + registerBlock: RegisterBlock; groundingValues: Map; } @@ -130,7 +131,12 @@ export function buildSleepTimelineBlock( }; metrics.add("sleep"); counts.sleep = sleepRows.length; - registerBlock("sleep", "sleep"); + registerBlock("sleep", "sleep", () => ({ + value: nights.map((n) => ({ + date: n.night, + value: Math.round(n.asleepMinutes), + })), + })); // W7 grounding: recent nightly asleep duration in HOURS against the // AASM 7–9 h band (the reference unit is hours; the snapshot stores // minutes). Mean over the recent nights the block already reconstructed. diff --git a/src/lib/ai/coach/snapshot-blocks/value-series-blocks.ts b/src/lib/ai/coach/snapshot-blocks/value-series-blocks.ts index 5a2a8751f..948f636e1 100644 --- a/src/lib/ai/coach/snapshot-blocks/value-series-blocks.ts +++ b/src/lib/ai/coach/snapshot-blocks/value-series-blocks.ts @@ -8,8 +8,10 @@ import { sourceCluster } from "../clusters"; import { bucketWeekly, buildDailyValueRows, + dailyPoints, timelineInUnit, } from "../snapshot-series"; +import type { RegisterBlock } from "../series-condense"; import type { CoachProvenance, CoachProvenanceMetric, @@ -252,7 +254,7 @@ interface ValueSeriesBlocksContext { snapshot: Record; metrics: Set; counts: NonNullable; - registerBlock: (key: string, source: CoachScopeSource) => void; + registerBlock: RegisterBlock; groundingValues: Map; /** The reader's units; every block is stated and labelled in them. */ units: UnitPreferences; @@ -311,7 +313,9 @@ export function buildValueSeriesBlocks( }; ctx.metrics.add(block.metric); ctx.counts[block.metric] = rows.length; - ctx.registerBlock(block.snapshotKey, block.source); + ctx.registerBlock(block.snapshotKey, block.source, () => ({ + value: dailyPoints(rows, ctx.userTz, undefined, transform), + })); const referenceMetric = typeof block.type === "string" diff --git a/src/lib/ai/coach/snapshot-series.ts b/src/lib/ai/coach/snapshot-series.ts index 70e3a7e24..7725c39b1 100644 --- a/src/lib/ai/coach/snapshot-series.ts +++ b/src/lib/ai/coach/snapshot-series.ts @@ -19,6 +19,7 @@ import type { MeasurementType } from "@/generated/prisma/client"; import { buildBaselineBand } from "@/lib/insights/derived/baseline"; import { DEFAULT_WINDOW } from "./snapshot-cache"; import { COACH_DAILY_DETAIL_DAYS } from "./history-reach"; +import type { DailyPoint } from "./series-condense"; import type { CoachScope, CoachScopeSource, CoachScopeWindow } from "./types"; const WEEKDAY_KEYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] as const; @@ -322,6 +323,23 @@ export async function buildCoarseTimelineTail( }; } +/** + * Every day's value over `rows`, the same day value the timeline's daily + * rows use, in the reader's unit when a transform is given. Feeds the + * summary of a condensed block (`series-condense.ts`). + */ +export function dailyPoints( + rows: Array<{ measuredAt: Date; value: number }>, + tz: string, + type?: string, + transform?: DisplayTransform, +): DailyPoint[] { + return buildDailyValueRows(rows, new Date(0), tz, type).map((row) => ({ + date: row.date, + value: transform ? applyDisplayTransform(row.value, transform) : row.value, + })); +} + /** * A built timeline in the reader's unit. The series are aggregated on the * canonical value and converted once at the end, so a mean in pounds is the diff --git a/src/lib/ai/coach/snapshot.ts b/src/lib/ai/coach/snapshot.ts index 284ef092a..3ab1ff58a 100644 --- a/src/lib/ai/coach/snapshot.ts +++ b/src/lib/ai/coach/snapshot.ts @@ -33,7 +33,12 @@ import { resolveSnapshotPrelude, } from "./snapshot-prelude"; import { annotateSnapshotFreshness } from "./snapshot-freshness"; -import { condenseSeriesBlock, isCondensable } from "./series-condense"; +import { + condenseSeriesBlock, + isCondensable, + type DailySeries, + type RegisterBlock, +} from "./series-condense"; import { buildGlp1SnapshotBlock } from "./glp1-snapshot"; import { buildDerivedSnapshotBlock } from "./derived-snapshot"; import { buildCorrelationsSnapshotBlock } from "./correlations-snapshot"; @@ -349,10 +354,13 @@ async function buildCoachSnapshotImpl( // The source each block was built for, so a single-source read can tell // which blocks it asked for (see `degradeToBudget`). const blockSources = new Map(); - const registerBlock = (key: string, source: CoachScopeSource) => { + // A series block's daily values, read only if the block is condensed. + const blockDaily = new Map DailySeries>(); + const registerBlock: RegisterBlock = (key, source, daily) => { const cluster = sourceCluster(source); if (cluster) blockClusters.set(key, cluster); blockSources.set(key, source); + if (daily) blockDaily.set(key, daily); }; // v1.7.0 — record which clusters resolved active for this build so @@ -1092,6 +1100,7 @@ async function buildCoachSnapshotImpl( compactSnapshot, blockClusters, requestedBlocks, + (key) => blockDaily.get(key)?.(), ); // v1.18.6 (W7) — build the citation-aware reference-grounding block from the @@ -1158,6 +1167,8 @@ export function degradeToBudget( snapshot: Record, blockClusters: Map, requested: ReadonlySet = new Set(), + /** A requested block's daily values, for its condensed summary. */ + dailyOf: (key: string) => DailySeries | undefined = () => undefined, ): Array<{ key: string; cluster: CoachDataCluster; pass: number }> { const degraded: Array<{ key: string; @@ -1315,9 +1326,10 @@ export function degradeToBudget( // A requested series is condensed step by step and keeps its numbers // (`series-condense.ts`); it is never swapped for the `omitted` marker. if (requested.has(key) && isCondensable(key, snapshot[key])) { + const daily = dailyOf(key); for (const step of [1, 2, 3] as const) { if (size() <= MAX_SNAPSHOT_CHARS) break; - if (condenseSeriesBlock(snapshot[key], step, key)) { + if (condenseSeriesBlock(snapshot[key], step, key, daily)) { degraded.push({ key, cluster, pass: step }); } } diff --git a/src/lib/ai/coach/tools/executor.ts b/src/lib/ai/coach/tools/executor.ts index ea65a685c..f6d94e4b7 100644 --- a/src/lib/ai/coach/tools/executor.ts +++ b/src/lib/ai/coach/tools/executor.ts @@ -619,7 +619,8 @@ async function getMetricSeries( const SERIES_COVERAGE_NOTE = "Daily values for the newest 14 days, weekly means before that, back at most 12 months; " + "timeline.coarse (when present) adds monthly and yearly means for older history. " + - "A 'condensed' list names what was cut to fit, and 'summary' holds first, last, min, max, mean and change over every point. " + + "A 'condensed' list names what was cut to fit. A 'summary', when present, is taken from the series' daily values over the whole read ('from' to 'to', 'days'): " + + "the first and last day, the lowest and highest day, the mean of the days, and 'change' = 'latestWeekMean' minus 'firstWeekMean' (the latest 7 calendar days against the first 7). " + "For the full history or a finer table, call get_metric_table yourself in this answer (window allTime, granularity month or week); do not ask the person first."; function isRecord(value: unknown): value is Record { diff --git a/src/lib/ai/coach/tools/system-addendum.ts b/src/lib/ai/coach/tools/system-addendum.ts index 3760f1687..73bdac7a8 100644 --- a/src/lib/ai/coach/tools/system-addendum.ts +++ b/src/lib/ai/coach/tools/system-addendum.ts @@ -46,7 +46,7 @@ You have read-only retrieval tools and a DATA INVENTORY listing what the user ha 4. Stay on the metric the user asked about. A miss on that metric is an answer ABOUT that metric — never answer a question about one metric by reporting a different one instead. On a vague follow-up ("check again", "how were they?"), the subject is still the metric the conversation was already on. 5. If you can answer without data (a definition, a "what can you help with?"), just answer — do not call a tool. 6. Keep using the EVIDENCE (---KEYVALUES---) block exactly as before, citing only numbers you fetched this turn. -7. A present get_metric_series result always carries numbers. When it is "condensed", cite its "summary" and the points it kept. When the question needs more history or finer detail than a series carries (all time, every month, a table), call get_metric_table yourself in this same answer. Never ask the user whether you should fetch it, and never tell them the numbers were not given to you.`; +7. A present get_metric_series result always carries numbers. When it is "condensed", cite its "summary" (when it has one) and the points it kept. When the question needs more history or finer detail than a series carries (all time, every month, a table), call get_metric_table yourself in this same answer. Never ask the user whether you should fetch it, and never tell them the numbers were not given to you.`; const DE = `TOOL-BASIERTE ABFRAGE (diese Unterhaltung) @@ -64,7 +64,7 @@ Du hast schreibgeschützte Abfrage-Tools und ein DATA INVENTORY, das auflistet, 4. Bleibe bei der Metrik, nach der gefragt wurde. Ein Fehlschlag zu dieser Metrik ist eine Antwort ÜBER diese Metrik — beantworte eine Frage zu einer Metrik niemals dadurch, dass du stattdessen über eine andere berichtest. Bei einer vagen Rückfrage („schau nochmal", „wie waren sie?") bleibt das Thema die Metrik, um die es gerade ging. 5. Wenn du ohne Daten antworten kannst (eine Definition, „Wobei kannst du helfen?"), antworte einfach — rufe kein Tool auf. 6. Nutze den EVIDENZ-Block (---KEYVALUES---) genau wie zuvor und zitiere nur Zahlen, die du in diesem Zug abgerufen hast. -7. Ein vorhandenes Ergebnis von get_metric_series enthält immer Zahlen. Ist es „condensed", nenne die Werte aus „summary" und die Punkte, die es behalten hat. Braucht die Frage mehr Verlauf oder feinere Werte, als eine Serie trägt (gesamter Zeitraum, jeder Monat, eine Tabelle), rufe get_metric_table in derselben Antwort selbst auf. Frage den Nutzer nie, ob du das abrufen sollst, und sage nie, die Zahlen seien dir nicht mitgegeben worden.`; +7. Ein vorhandenes Ergebnis von get_metric_series enthält immer Zahlen. Ist es „condensed", nenne die Werte aus „summary" (falls vorhanden) und die Punkte, die es behalten hat. Braucht die Frage mehr Verlauf oder feinere Werte, als eine Serie trägt (gesamter Zeitraum, jeder Monat, eine Tabelle), rufe get_metric_table in derselben Antwort selbst auf. Frage den Nutzer nie, ob du das abrufen sollst, und sage nie, die Zahlen seien dir nicht mitgegeben worden.`; export function buildToolModeAddendum(locale: Locale): string { return locale === "de" ? DE : EN; From 79e0eca023ed76b7e082cbd0938fbd586883c670 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 5 Oct 2026 22:09:03 +0200 Subject: [PATCH 14/19] fix(mcp): advertise tool schemas as JSON Schema 2020-12 The SDK renders every tool's Zod shape with a fixed draft-07 target and stamps "$schema": "http://json-schema.org/draft-07/schema#" on each inputSchema and outputSchema. Clients that validate with a 2020-12-only validator refuse that dialect when the tool list arrives, so every tool was unusable there (#1170). Wrap the tools/list handler once, after the last tool is registered, and re-render each advertised schema from the same Zod shape with the draft-2020-12 target, without a $schema key. A schema with no $schema is 2020-12 under the MCP spec and still compiles in a draft-07 client. All other fields of a tool entry pass through unchanged, and the SDK keeps validating structuredContent against the Zod shape, so tool results are unaffected. For the current surface the 54 rendered schemas differ from the previous ones only by the dropped key. The new guard reads tools/list through a real client for a write-scoped session, rejects any draft-07 marker or construct, and compiles every schema with Ajv's 2020-12 build (ajv added as a dev dependency at the version the SDK already resolves). --- package.json | 1 + pnpm-lock.yaml | 3 + .../mcp/__tests__/tool-schema-dialect.test.ts | 124 ++++++++++++++++++ src/lib/mcp/schema-dialect.ts | 108 +++++++++++++++ src/lib/mcp/server.ts | 8 ++ 5 files changed, 244 insertions(+) create mode 100644 src/lib/mcp/__tests__/tool-schema-dialect.test.ts create mode 100644 src/lib/mcp/schema-dialect.ts diff --git a/package.json b/package.json index dceafe607..022eeaa01 100644 --- a/package.json +++ b/package.json @@ -123,6 +123,7 @@ "@types/web-push": "^3.6.4", "@vitejs/plugin-react": "^6.1.1", "@vitest/coverage-v8": "^4.1.5", + "ajv": "^8.20.0", "eslint": "^9", "eslint-config-next": "16.3.8", "knip": "^6.32.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9721f633b..d7aced555 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -242,6 +242,9 @@ importers: '@vitest/coverage-v8': specifier: ^4.1.5 version: 4.1.5(vitest@4.1.11) + ajv: + specifier: ^8.20.0 + version: 8.20.0 eslint: specifier: ^9 version: 9.39.3(jiti@2.7.0)(supports-color@7.2.0) diff --git a/src/lib/mcp/__tests__/tool-schema-dialect.test.ts b/src/lib/mcp/__tests__/tool-schema-dialect.test.ts new file mode 100644 index 000000000..15edcdfda --- /dev/null +++ b/src/lib/mcp/__tests__/tool-schema-dialect.test.ts @@ -0,0 +1,124 @@ +/** + * Guard: every schema `tools/list` advertises is valid JSON Schema 2020-12. + * + * The SDK converts each Zod shape with a hard-coded draft-07 target, so every + * `inputSchema` and `outputSchema` used to carry + * `"$schema": "http://json-schema.org/draft-07/schema#"`. A client that + * validates with a 2020-12-only allowlist refuses the dialect at registration + * and every tool becomes unusable (#1170). The server now advertises 2020-12 + * with no `$schema` key — the MCP default dialect — so a strict client and a + * draft-07 client both accept it. + * + * The list is read through a real SDK client over an in-memory transport, so + * the assertion is on the wire, not on the registry. A write-scoped session is + * used so the write tools are covered too. + */ +import { describe, it, expect, vi } from "vitest"; +import Ajv2020 from "ajv/dist/2020"; + +vi.mock("@/lib/db", () => ({ prisma: {} })); +vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); + +process.env.APP_URL = "http://localhost"; + +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { createMcpServer } from "../server"; +import { MCP_TOOLS } from "../tools"; +import { MCP_WRITE_TOOLS } from "../write-tools"; +import type { McpAuthContext } from "../auth"; + +const ctx: McpAuthContext = { + userId: "u-1", + tokenId: "t-1", + scopes: ["health:read", "health:write"], + binding: "u-1:t-1", + canRead: true, + canWrite: true, +}; + +async function listTools() { + const server = createMcpServer(ctx); + const client = new Client({ name: "dialect-test", version: "1.0.0" }); + const [clientSide, serverSide] = InMemoryTransport.createLinkedPair(); + await Promise.all([server.connect(serverSide), client.connect(clientSide)]); + const { tools } = await client.listTools(); + await client.close(); + await server.close(); + return tools; +} + +/** Every draft-07-only construct, anywhere in a schema tree. */ +function draft07Constructs(node: unknown, path = "$"): string[] { + if (Array.isArray(node)) { + return node.flatMap((v, i) => draft07Constructs(v, `${path}[${i}]`)); + } + if (!node || typeof node !== "object") return []; + const found: string[] = []; + for (const [key, value] of Object.entries(node)) { + const at = `${path}.${key}`; + if ( + key === "$schema" && + value !== "https://json-schema.org/draft/2020-12/schema" + ) { + found.push(`${at} = ${String(value)}`); + } + if (key === "definitions") found.push(at); + if (key === "dependencies") found.push(at); + if (key === "additionalItems") found.push(at); + if (key === "items" && Array.isArray(value)) found.push(`${at} (array)`); + if ( + key === "$ref" && + typeof value === "string" && + value.includes("/definitions/") + ) { + found.push(`${at} = ${value}`); + } + found.push(...draft07Constructs(value, at)); + } + return found; +} + +describe("tools/list advertises JSON Schema 2020-12", () => { + it("advertises every read and write tool", async () => { + const tools = await listTools(); + expect(tools.map((t) => t.name).sort()).toEqual( + [...MCP_TOOLS, ...MCP_WRITE_TOOLS].map((t) => t.name).sort(), + ); + }); + + it("carries no draft-07 marker or construct anywhere", async () => { + const tools = await listTools(); + expect(JSON.stringify(tools)).not.toContain("draft-07"); + const offenders = tools.flatMap((tool) => [ + ...draft07Constructs(tool.inputSchema, `${tool.name}.inputSchema`), + ...(tool.outputSchema + ? draft07Constructs(tool.outputSchema, `${tool.name}.outputSchema`) + : []), + ]); + expect(offenders).toEqual([]); + }); + + it("compiles every input and output schema under a 2020-12 validator", async () => { + const tools = await listTools(); + const ajv = new Ajv2020({ strict: false, validateSchema: true }); + let compiled = 0; + for (const tool of tools) { + for (const [kind, schema] of [ + ["inputSchema", tool.inputSchema], + ["outputSchema", tool.outputSchema], + ] as const) { + if (!schema) continue; + expect( + ajv.validateSchema(schema), + `${tool.name}.${kind}: ${ajv.errorsText()}`, + ).toBe(true); + expect(() => ajv.compile(schema), `${tool.name}.${kind}`).not.toThrow(); + compiled++; + } + } + // Every tool has an inputSchema, and the surface declares output shapes; + // a count of zero would mean the loop above proved nothing. + expect(compiled).toBeGreaterThan(tools.length); + }); +}); diff --git a/src/lib/mcp/schema-dialect.ts b/src/lib/mcp/schema-dialect.ts new file mode 100644 index 000000000..ac97f3630 --- /dev/null +++ b/src/lib/mcp/schema-dialect.ts @@ -0,0 +1,108 @@ +/** + * The JSON Schema dialect `tools/list` advertises. + * + * The SDK converts every tool's Zod shape with a hard-coded draft-07 target and + * stamps `"$schema": "http://json-schema.org/draft-07/schema#"` on each + * `inputSchema` and `outputSchema`. Clients that validate with a 2020-12-only + * validator refuse that dialect when the tool list arrives, so every tool is + * unusable there, before a call ever reaches the server (#1170). + * + * This module re-renders each advertised schema from the same Zod shape with + * the 2020-12 target (`$defs`, `prefixItems`, …) and drops the `$schema` key. + * A schema without `$schema` is 2020-12 under the MCP spec, and a draft-07 + * client still compiles it, so omitting the key serves both. Everything else + * the SDK puts on a tool entry is kept as it is. + * + * Only the advertised copy changes. The SDK validates `structuredContent` + * against the Zod shape itself, never against this JSON, so result validation + * is untouched. Prompts advertise an argument list rather than a schema, and + * resource templates carry no schema, so tools are the whole surface. + */ +import { z } from "zod/v4"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { + ListToolsRequestSchema, + type ListToolsRequest, + type ListToolsResult, +} from "@modelcontextprotocol/sdk/types.js"; + +import type { McpToolDefinition } from "./tools"; + +type JsonSchemaObject = Record; + +/** Render one Zod raw shape as a 2020-12 schema with no `$schema` key. */ +export function toAdvertisedJsonSchema( + shape: z.ZodRawShape, + io: "input" | "output", +): JsonSchemaObject { + const rendered = z.toJSONSchema(z.object(shape), { + target: "draft-2020-12", + io, + }) as JsonSchemaObject; + delete rendered.$schema; + return rendered; +} + +type ListToolsHandler = ( + request: ListToolsRequest, + extra: unknown, +) => Promise; + +/** + * Wrap the SDK's `tools/list` handler so every advertised schema is 2020-12. + * Call once, after every tool is registered. `tools` maps each registered name + * to its definition; a listed tool missing from it is a wiring error and + * throws rather than slipping through in the SDK's dialect. + * + * The SDK exposes no way to read its own handler, so it is taken from the + * protocol's handler map. When no tool was registered there is no handler and + * nothing to rewrite. `tool-schema-dialect.test.ts` reads the list through a + * real client, so an SDK change that moves the map fails there. + */ +export function advertiseToolSchemasAs2020( + server: McpServer, + tools: ReadonlyMap, +): void { + const handlers = ( + server.server as unknown as { + _requestHandlers?: Map; + } + )._requestHandlers; + const sdkList = handlers?.get("tools/list"); + if (!sdkList) return; + + server.server.setRequestHandler( + ListToolsRequestSchema, + async (request, extra) => { + const result = await sdkList(request, extra); + return { + ...result, + tools: result.tools.map((entry) => { + const definition = tools.get(entry.name); + if (!definition) { + throw new Error( + `tools/list: no definition registered for "${entry.name}"`, + ); + } + return { + ...entry, + inputSchema: toAdvertisedJsonSchema( + definition.inputShape, + "input", + ) as ListToolsResult["tools"][number]["inputSchema"], + ...(definition.outputShape + ? { + outputSchema: toAdvertisedJsonSchema( + definition.outputShape, + "output", + ) as NonNullable< + ListToolsResult["tools"][number]["outputSchema"] + >, + } + : {}), + }; + }), + }; + }, + ); +} diff --git a/src/lib/mcp/server.ts b/src/lib/mcp/server.ts index fd2ac69e3..19826bde9 100644 --- a/src/lib/mcp/server.ts +++ b/src/lib/mcp/server.ts @@ -26,6 +26,7 @@ import { MCP_WRITE_TOOLS } from "./write-tools"; import { MCP_RESOURCES, MCP_RESOURCE_TEMPLATES } from "./resources"; import { MCP_PROMPTS } from "./prompts"; import { resolveBaseOrigin } from "./oauth/config"; +import { advertiseToolSchemasAs2020 } from "./schema-dialect"; export const MCP_SERVER_NAME = "healthlog"; export const MCP_SERVER_VERSION = "1.0.0"; @@ -84,7 +85,9 @@ export function createMcpServer(ctx: McpAuthContext): McpServer { // Register one tool definition. Shared by the read registry and the // write-scoped registry so the wire shape can never fork between them. + const registeredTools = new Map(); const registerTool = (tool: McpToolDefinition) => { + registeredTools.set(tool.name, tool); server.registerTool( tool.name, { @@ -130,6 +133,11 @@ export function createMcpServer(ctx: McpAuthContext): McpServer { } } + // Advertise every tool schema as JSON Schema 2020-12 instead of the SDK's + // draft-07, which strict clients refuse (#1170). After the last + // registerTool, so the wrap covers every tool this session sees. + advertiseToolSchemasAs2020(server, registeredTools); + for (const prompt of MCP_PROMPTS) { server.registerPrompt( prompt.name, From 43361bd74ea623144d178bf1390cc03da5f00aa0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 5 Oct 2026 22:09:34 +0200 Subject: [PATCH 15/19] fix(daily): write the briefing for the reader's own morning and readings With signals of the day resting on today's readings only, the briefing had to be written after those readings, and it was only written at the 04:30 Berlin warm, the post-sleep morning refresh, or a page open on a day-old cache. A morning blood pressure or weigh-in never became a signal, and since an earlier day's briefing is no longer served, a reader in Los Angeles or Seoul had none for most of the day: the Berlin warm lands the previous evening there, or at noon. The snapshot read every surface goes through (dashboard, iOS, digest, morning push) now asks for a briefing for today at two moments: the first read of the reader's local day with no briefing written today, and the first read after a signal metric's first reading of the day that the briefing has not read. Each reason is claimed once per user per local day in a Postgres bucket and remembered in-process, so later reads cost no query; no write path needs a hook. A claim enqueues one today warm: immediately for a new day, after a five-minute window for readings, so a burst (weigh-in, then blood pressure) folds into one job. The warm writes the comprehensive briefing only, past the one-hour freshness gate but under the failure backoff, the daily forced-warm cap (two per 24 h) and the content-hash gate. Worst case per user per local day: one enqueue per reason, at most eight (the day plus seven signal reading types, measured over a day of reads every two minutes); at most two forced comprehensive attempts under the existing cap, each at most three completions (the answer, a JSON retry, a grounding retry), so six provider calls at the ceiling. A warm whose data has not changed costs at most the existing once-a-day paragraph re-roll, inside the same cap. --- .../__tests__/briefing-today-warm.test.ts | 207 ++++++++++++++++++ src/lib/daily/briefing-today-warm.ts | 126 +++++++++++ .../snapshot-read-briefing-today.test.ts | 45 +++- src/lib/dashboard/snapshot-read.ts | 26 ++- .../__tests__/insight-pregenerate.test.ts | 56 +++++ src/lib/jobs/insight-pregenerate-shared.ts | 66 ++++++ src/lib/jobs/insight-pregenerate.ts | 13 +- src/lib/jobs/reminder/insights-handlers.ts | 4 +- 8 files changed, 533 insertions(+), 10 deletions(-) create mode 100644 src/lib/daily/__tests__/briefing-today-warm.test.ts create mode 100644 src/lib/daily/briefing-today-warm.ts diff --git a/src/lib/daily/__tests__/briefing-today-warm.test.ts b/src/lib/daily/__tests__/briefing-today-warm.test.ts new file mode 100644 index 000000000..164cc1a96 --- /dev/null +++ b/src/lib/daily/__tests__/briefing-today-warm.test.ts @@ -0,0 +1,207 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const checkRateLimit = vi.hoisted(() => vi.fn()); +const enqueueTodayBriefingWarm = vi.hoisted(() => vi.fn()); +vi.mock("@/lib/rate-limit", () => ({ checkRateLimit })); +vi.mock("@/lib/jobs/insight-pregenerate-shared", () => ({ + enqueueTodayBriefingWarm, +})); + +import { + __resetTodayWarmClaimsForTests, + requestTodayBriefingWarm, + todayWarmReasons, + type TodayWarmInput, +} from "@/lib/daily/briefing-today-warm"; + +/** The nightly warm: 04:30 in Berlin on 2026-10-05. */ +const NIGHTLY = "2026-10-05T02:30:00.000Z"; + +function input(over: Partial): TodayWarmInput { + return { + userId: "u1", + locale: "en", + timezone: "Europe/Berlin", + generatedAt: NIGHTLY, + lastSeenAt: () => null, + now: new Date("2026-10-05T10:00:00.000Z"), + ...over, + }; +} + +beforeEach(() => { + __resetTodayWarmClaimsForTests(); + checkRateLimit.mockReset().mockResolvedValue({ allowed: true }); + enqueueTodayBriefingWarm.mockReset().mockResolvedValue(undefined); +}); + +describe("todayWarmReasons — the reader's own morning", () => { + it("Berlin: the nightly briefing covers the day", () => { + expect(todayWarmReasons(input({}))).toEqual([]); + }); + + it("Los Angeles: the Berlin nightly is yesterday evening, so the morning asks", () => { + // 07:00 on 5 October in Los Angeles; the warm ran 19:30 on the 4th. + expect( + todayWarmReasons( + input({ + timezone: "America/Los_Angeles", + now: new Date("2026-10-05T14:00:00.000Z"), + }), + ), + ).toEqual(["day"]); + }); + + it("Seoul: after local midnight the 11:30 briefing is yesterday's", () => { + // 01:00 on 6 October in Seoul; the warm ran 11:30 on the 5th. + expect( + todayWarmReasons( + input({ + timezone: "Asia/Seoul", + now: new Date("2026-10-05T16:00:00.000Z"), + }), + ), + ).toEqual(["day"]); + // 14:00 on the 5th, after the warm: covered. + expect( + todayWarmReasons( + input({ + timezone: "Asia/Seoul", + now: new Date("2026-10-05T05:00:00.000Z"), + }), + ), + ).toEqual([]); + }); + + it("asks when a signal metric's first reading lands after the briefing", () => { + // Blood pressure at 07:10 Berlin, weight at 07:12, after the 04:30 warm. + const seen: Record = { + BLOOD_PRESSURE_SYS: "2026-10-05T05:10:00.000Z", + WEIGHT: "2026-10-05T05:12:00.000Z", + // Yesterday's pulse is not today's and asks for nothing. + PULSE: "2026-10-04T06:50:00.000Z", + }; + expect( + todayWarmReasons(input({ lastSeenAt: (t) => seen[t] ?? null })), + ).toEqual(["reading:BLOOD_PRESSURE_SYS", "reading:WEIGHT"]); + }); + + it("does not ask for a reading the briefing already read", () => { + expect( + todayWarmReasons( + input({ + lastSeenAt: (t) => + t === "WEIGHT" ? "2026-10-05T02:00:00.000Z" : null, + }), + ), + ).toEqual([]); + }); + + it("asks when the briefing's generation moment is unknown", () => { + expect(todayWarmReasons(input({ generatedAt: null }))).toEqual(["day"]); + }); +}); + +describe("requestTodayBriefingWarm — once per reason per day", () => { + it("enqueues an immediate warm for the day's first read with no briefing", async () => { + await requestTodayBriefingWarm( + input({ + timezone: "America/Los_Angeles", + now: new Date("2026-10-05T14:00:00.000Z"), + }), + ); + expect(checkRateLimit).toHaveBeenCalledWith( + "briefing-today:u1:2026-10-05:day", + 1, + expect.any(Number), + ); + expect(enqueueTodayBriefingWarm).toHaveBeenCalledWith({ + userId: "u1", + locale: "en", + immediate: true, + }); + }); + + it("waits out a burst of readings before the warm runs", async () => { + await requestTodayBriefingWarm( + input({ + lastSeenAt: (t) => + t === "BLOOD_PRESSURE_SYS" ? "2026-10-05T05:10:00.000Z" : null, + }), + ); + expect(enqueueTodayBriefingWarm).toHaveBeenCalledWith( + expect.objectContaining({ immediate: false }), + ); + }); + + it("asks once: a second read in the same day neither queries nor enqueues", async () => { + const la = input({ + timezone: "America/Los_Angeles", + now: new Date("2026-10-05T14:00:00.000Z"), + }); + await requestTodayBriefingWarm(la); + await requestTodayBriefingWarm(la); + expect(checkRateLimit).toHaveBeenCalledTimes(1); + expect(enqueueTodayBriefingWarm).toHaveBeenCalledTimes(1); + }); + + it("does not enqueue when another process already claimed the reason", async () => { + checkRateLimit.mockResolvedValue({ allowed: false }); + await requestTodayBriefingWarm(input({ generatedAt: null })); + expect(enqueueTodayBriefingWarm).not.toHaveBeenCalled(); + }); + + it("asks again on the next local day", async () => { + await requestTodayBriefingWarm(input({ generatedAt: null })); + await requestTodayBriefingWarm( + input({ + generatedAt: null, + now: new Date("2026-10-06T10:00:00.000Z"), + }), + ); + expect(enqueueTodayBriefingWarm).toHaveBeenCalledTimes(2); + }); +}); + +describe("requestTodayBriefingWarm — the worst day", () => { + it("enqueues at most one warm per reason per local day, however often it is read", async () => { + // A Postgres bucket in memory: one claim per key. + const buckets = new Map(); + checkRateLimit.mockImplementation(async (key: string) => { + const n = (buckets.get(key) ?? 0) + 1; + buckets.set(key, n); + return { allowed: n <= 1 }; + }); + // Every signal type gets its first reading at a different hour, and the + // dashboard is read every two minutes from midnight to midnight, with + // the briefing never refreshed (the worst case: every warm capped). + const types = [ + "BLOOD_PRESSURE_SYS", + "WEIGHT", + "PULSE", + "RESTING_HEART_RATE", + "HEART_RATE_VARIABILITY", + "BODY_TEMPERATURE", + "BLOOD_GLUCOSE", + ]; + const dayStart = Date.parse("2026-10-04T22:00:00.000Z"); // 00:00 Berlin + for (let minute = 0; minute < 24 * 60; minute += 2) { + const now = new Date(dayStart + minute * 60_000); + await requestTodayBriefingWarm( + input({ + generatedAt: "2026-10-04T02:30:00.000Z", + now, + lastSeenAt: (type) => { + const hour = 6 + types.indexOf(type) * 2; + const at = dayStart + hour * 3_600_000; + return types.includes(type) && at <= now.getTime() + ? new Date(at).toISOString() + : null; + }, + }), + ); + } + // One for the day, one per signal reading type. + expect(enqueueTodayBriefingWarm).toHaveBeenCalledTimes(1 + types.length); + }); +}); diff --git a/src/lib/daily/briefing-today-warm.ts b/src/lib/daily/briefing-today-warm.ts new file mode 100644 index 000000000..885df5ecf --- /dev/null +++ b/src/lib/daily/briefing-today-warm.ts @@ -0,0 +1,126 @@ +/** + * Ask for a briefing written today, at the two moments the rule "a signal + * of the day rests on today's readings" needs one. + * + * - The day has no briefing yet. The nightly warm runs at 04:30 in Berlin, + * which is the previous evening in Los Angeles and the middle of the day + * in Seoul; a briefing written then is from an earlier day on the + * reader's calendar, and is not served. The first read of the reader's + * day asks for one. + * - A signal metric got its first reading of the day after the briefing + * was written. The briefing is otherwise only written at the nightly + * warm, the post-sleep morning refresh or a day-old page open, so a + * morning blood pressure or weigh-in would never become a signal. + * + * Both are read off the snapshot every surface already reads (the dashboard, + * iOS, the digest and the morning push), so no write path needs a hook. Each + * reason is claimed once per user per local day in a Postgres bucket (and + * remembered in-process, so a claimed reason costs no query on later reads); + * a newly claimed reason enqueues one today warm. What that warm may spend is + * bounded where every forced warm is bounded: the singleton window, the + * failure backoff, the daily forced-warm cap, and the generation's + * content-hash gate, which writes no new text without new data. + */ +import { checkRateLimit } from "@/lib/rate-limit"; +import { annotate } from "@/lib/logging/context"; +import type { Locale } from "@/lib/i18n/config"; +import { userDayKey } from "@/lib/tz/format"; +import { SIGNAL_READING_TYPE } from "@/lib/daily/briefing-today"; +import { enqueueTodayBriefingWarm } from "@/lib/jobs/insight-pregenerate-shared"; + +/** A claim outlives the longest local day (a 25 h DST day) with margin. */ +const CLAIM_WINDOW_MS = 36 * 60 * 60 * 1000; + +/** In-process memory of claimed reasons, so a claimed one costs no query. */ +const claimed = new Map(); +const CLAIMED_MAX = 10_000; + +export interface TodayWarmInput { + userId: string; + locale: Locale; + timezone: string; + /** When the cached briefing's text was generated, or null when unknown. */ + generatedAt: string | null; + /** The newest reading of a measurement type (ISO), or null when none. */ + lastSeenAt: (type: string) => string | null; + now: Date; +} + +/** + * Why a briefing for today is due, if it is: `day` when no briefing was + * written today, `reading:` for each signal reading type measured + * today after the briefing was written. Empty when the briefing already + * covers the day. + */ +export function todayWarmReasons(input: TodayWarmInput): string[] { + const today = userDayKey(input.now, input.timezone); + const generatedMs = + input.generatedAt === null ? null : Date.parse(input.generatedAt); + const reasons: string[] = []; + if ( + generatedMs === null || + userDayKey(new Date(generatedMs), input.timezone) !== today + ) { + reasons.push("day"); + } + for (const type of new Set(Object.values(SIGNAL_READING_TYPE))) { + const at = input.lastSeenAt(type); + if (at === null) continue; + const atMs = Date.parse(at); + if (userDayKey(new Date(atMs), input.timezone) !== today) continue; + if (generatedMs !== null && atMs <= generatedMs) continue; + reasons.push(`reading:${type}`); + } + return reasons; +} + +/** Claim a reason for the day; true only for the first claim. */ +async function claimOnce(key: string): Promise { + if (claimed.has(key)) return false; + if (claimed.size >= CLAIMED_MAX) claimed.clear(); + claimed.set(key, true); + try { + const result = await checkRateLimit(key, 1, CLAIM_WINDOW_MS); + return result.allowed; + } catch { + // A failed claim asks again on a later read rather than never. + claimed.delete(key); + return false; + } +} + +/** + * Enqueue a today warm when a reason is due and not yet claimed today. + * Fire-and-forget from the read path: it never throws and never delays the + * read beyond the claim query. + */ +export async function requestTodayBriefingWarm( + input: TodayWarmInput, +): Promise { + const reasons = todayWarmReasons(input); + if (reasons.length === 0) return; + const today = userDayKey(input.now, input.timezone); + const fresh: string[] = []; + for (const reason of reasons) { + if (await claimOnce(`briefing-today:${input.userId}:${today}:${reason}`)) { + fresh.push(reason); + } + } + if (fresh.length === 0) return; + annotate({ + action: { name: "daily.briefing.today_warm_requested" }, + meta: { reasons: fresh.join(","), timezone: input.timezone }, + }); + await enqueueTodayBriefingWarm({ + userId: input.userId, + locale: input.locale, + // Nobody waits on a burst of readings when the day simply has no + // briefing yet; a reading waits out its burst so one warm reads it all. + immediate: fresh.every((reason) => reason === "day"), + }); +} + +/** Test seam: forget the in-process claims. */ +export function __resetTodayWarmClaimsForTests(): void { + claimed.clear(); +} diff --git a/src/lib/dashboard/__tests__/snapshot-read-briefing-today.test.ts b/src/lib/dashboard/__tests__/snapshot-read-briefing-today.test.ts index 25ef9d114..9be475763 100644 --- a/src/lib/dashboard/__tests__/snapshot-read-briefing-today.test.ts +++ b/src/lib/dashboard/__tests__/snapshot-read-briefing-today.test.ts @@ -6,7 +6,10 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { User } from "@/generated/prisma/client"; -import { AI_AVAILABLE } from "@/__tests__/helpers/ai-capability-fixtures"; +import { + AI_AVAILABLE, + aiUnavailable, +} from "@/__tests__/helpers/ai-capability-fixtures"; const BRIEFING = { paragraph: "Your pulse is well above its usual level today.", @@ -52,8 +55,15 @@ vi.mock("@/lib/dashboard/snapshot", async (importOriginal) => ({ vi.mock("@/lib/i18n/server-locale", () => ({ resolveServerLocale: async () => "en", })); +const capability = vi.hoisted(() => ({ current: null as unknown })); vi.mock("@/lib/ai/capabilities/gate", () => ({ - aiCapabilityToServe: async () => AI_AVAILABLE, + aiCapabilityToServe: async () => capability.current, +})); +const requestTodayBriefingWarm = vi.hoisted(() => + vi.fn(async (_input: unknown) => undefined), +); +vi.mock("@/lib/daily/briefing-today-warm", () => ({ + requestTodayBriefingWarm, })); const { readDashboardSnapshotCached } = await import("../snapshot-read"); @@ -75,6 +85,8 @@ function user(generatedAt: string | null): User { } beforeEach(() => { + capability.current = AI_AVAILABLE; + requestTodayBriefingWarm.mockClear(); __resetAllCachesForTests(); current = body("2026-10-04T06:50:00.000Z"); }); @@ -123,3 +135,32 @@ describe("readDashboardSnapshotCached — the briefing's own day", () => { ); }); }); + +describe("readDashboardSnapshotCached — asking for today's briefing", () => { + it("hands the read's view of the day to the today warm", async () => { + await readDashboardSnapshotCached( + user("2026-10-04T02:30:00.000Z"), + undefined, + { + now: NOW, + }, + ); + expect(requestTodayBriefingWarm).toHaveBeenCalledTimes(1); + const arg = requestTodayBriefingWarm.mock.calls[0][0] as { + generatedAt: string; + timezone: string; + now: Date; + lastSeenAt: (type: string) => string | null; + }; + expect(arg.generatedAt).toBe("2026-10-04T02:30:00.000Z"); + expect(arg.timezone).toBe("Europe/Berlin"); + expect(arg.now).toBe(NOW); + expect(arg.lastSeenAt("PULSE")).toBe("2026-10-04T06:50:00.000Z"); + }); + + it("asks for nothing while the briefing capability is unavailable", async () => { + capability.current = aiUnavailable("consent_required"); + await readDashboardSnapshotCached(user(null), undefined, { now: NOW }); + expect(requestTodayBriefingWarm).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/dashboard/snapshot-read.ts b/src/lib/dashboard/snapshot-read.ts index 59949a411..6095f7d45 100644 --- a/src/lib/dashboard/snapshot-read.ts +++ b/src/lib/dashboard/snapshot-read.ts @@ -38,6 +38,7 @@ import { aiCapabilityToServe } from "@/lib/ai/capabilities/gate"; import { resolveServerLocale } from "@/lib/i18n/server-locale"; import type { Locale } from "@/lib/i18n/config"; import { briefingForToday } from "@/lib/daily/briefing-today"; +import { requestTodayBriefingWarm } from "@/lib/daily/briefing-today-warm"; import { readBriefingGeneratedAt } from "@/lib/insights/briefing-generated-at"; import { userDayKey } from "@/lib/tz/format"; @@ -175,15 +176,28 @@ export async function readDashboardSnapshotCached( // capability is available, decided on every read rather than baked into // the cached body. The record's own state decides, whoever is reading, so a // delegate sees the owner's briefing exactly when the owner would. + const now = options.now ?? new Date(); + const generatedAt = readBriefingGeneratedAt(user.insightsCachedText); const body = applyBriefingForToday( applyBriefingCapability(cached, briefingAi), - { - generatedAt: readBriefingGeneratedAt(user.insightsCachedText), - timezone: user.timezone, - language: locale, - now: options.now ?? new Date(), - }, + { generatedAt, timezone: user.timezone, language: locale, now }, ); + // The day's first read with no briefing written today, or the first read + // after a signal metric's first reading of the day, asks for a briefing + // for today (`briefing-today-warm.ts`). Never awaited by the read beyond + // its claim, never thrown. + if (briefingAi.available) { + const lastSeen = cached.tiles?.lastSeenByType ?? {}; + void requestTodayBriefingWarm({ + userId: user.id, + locale, + timezone: user.timezone, + generatedAt, + lastSeenAt: (type) => lastSeen[type]?.lastSeenAt ?? null, + now, + }).catch(() => undefined); + } + return { body, locale }; } diff --git a/src/lib/jobs/__tests__/insight-pregenerate.test.ts b/src/lib/jobs/__tests__/insight-pregenerate.test.ts index 163d0d209..846055bc1 100644 --- a/src/lib/jobs/__tests__/insight-pregenerate.test.ts +++ b/src/lib/jobs/__tests__/insight-pregenerate.test.ts @@ -1007,6 +1007,62 @@ describe("forceWarmUser — on-demand single-user warm (v1.8.7.1)", () => { } }); + it("a today warm runs past the freshness window, under the daily cap, and warms no card", async () => { + // The weigh-in at 07:05 after the blood-pressure warm at 07:00: the + // briefing is minutes old, and still has to read the new reading. + const now = new Date("2026-06-10T12:00:00.000Z"); + const { prisma, findUnique } = makePrisma([]); + findUnique.mockResolvedValue({ + insightsCachedAt: new Date(now.getTime() - 5 * 60 * 1000), + insightsWarmFailedAt: null, + }); + const generate = vi + .fn() + .mockResolvedValue({ status: "generated", providerType: "openai" }); + const statusGenerators = Array.from({ length: 7 }, () => + vi.fn().mockResolvedValue({ hasProvider: true, cached: true }), + ); + const warmGenericMetrics = vi.fn().mockResolvedValue(0); + + const result = await forceWarmUser(prisma as never, "u1", "de", { + generate, + statusGenerators, + warmGenericMetrics, + now, + today: true, + }); + + expect(generate).toHaveBeenCalledTimes(1); + expect(result.comprehensive).toBe("generated"); + expect(checkRateLimit).toHaveBeenCalledWith( + "insight-pregenerate-daily:u1", + FORCE_WARM_DAILY_LIMIT, + expect.any(Number), + ); + for (const g of statusGenerators) expect(g).not.toHaveBeenCalled(); + expect(warmGenericMetrics).not.toHaveBeenCalled(); + }); + + it("a today warm stays inside the daily forced-warm cap", async () => { + const now = new Date("2026-06-10T12:00:00.000Z"); + const { prisma, findUnique } = makePrisma([]); + findUnique.mockResolvedValue({ + insightsCachedAt: new Date(now.getTime() - 5 * 60 * 1000), + insightsWarmFailedAt: null, + }); + checkRateLimit.mockResolvedValue({ allowed: false }); + const generate = vi.fn(); + const result = await forceWarmUser(prisma as never, "u1", "de", { + generate, + statusGenerators: [], + warmGenericMetrics: vi.fn().mockResolvedValue(0), + now, + today: true, + }); + expect(generate).not.toHaveBeenCalled(); + expect(result.comprehensive).toBe("capped"); + }); + it("backs off after a recent failed attempt instead of re-driving the provider chain", async () => { const now = new Date("2026-06-10T12:00:00.000Z"); const { prisma, findUnique } = makePrisma([]); diff --git a/src/lib/jobs/insight-pregenerate-shared.ts b/src/lib/jobs/insight-pregenerate-shared.ts index e6353b37a..95e5aee07 100644 --- a/src/lib/jobs/insight-pregenerate-shared.ts +++ b/src/lib/jobs/insight-pregenerate-shared.ts @@ -45,6 +45,15 @@ export interface InsightPregeneratePayload { * unrecognised value to English. */ locale?: SupportedLocale; + /** + * A briefing-for-today warm (`enqueueTodayBriefingWarm`): the day started + * with no briefing written today, or a signal metric got its first + * reading of the day after the briefing was written. Runs the + * comprehensive briefing only, past the one-hour freshness gate (a + * reading at 07:05 after a warm at 07:00 is exactly what it is for), and + * under the same failure backoff and daily forced-warm cap. + */ + today?: boolean; } /** @@ -108,6 +117,63 @@ export async function enqueueForceWarm(payload: { } } +/** + * How long a today warm waits before it runs, and the window that folds + * every request inside it into one job. A morning weigh-in, the blood + * pressure five minutes later and the sync that brings both arrive as one + * burst; one warm after the burst reads all of it. + */ +export const TODAY_WARM_DELAY_SECONDS = 5 * 60; + +/** + * Enqueue the briefing-for-today warm. `immediate` is for the day's first + * read with no briefing written today, where nobody is waiting on a burst + * of readings; otherwise the job starts after the burst window. The + * singleton per user folds requests in the window into one job; what runs + * is still bounded by the warm's own gates (failure backoff, the daily + * forced-warm cap) and the generation's content-hash gate. + */ +export async function enqueueTodayBriefingWarm(payload: { + userId: string; + locale: SupportedLocale; + immediate: boolean; +}): Promise { + const boss = getGlobalBoss(); + if (!boss) { + annotate({ + action: { name: "insights.pregenerate.today.no_boss" }, + meta: { locale: payload.locale }, + }); + return; + } + try { + await boss.send( + INSIGHT_PREGENERATE_QUEUE, + { + userId: payload.userId, + force: true, + today: true, + locale: payload.locale, + } satisfies InsightPregeneratePayload, + { + singletonKey: `today:${payload.userId}`, + singletonSeconds: TODAY_WARM_DELAY_SECONDS, + startAfter: payload.immediate ? 0 : TODAY_WARM_DELAY_SECONDS, + expireInSeconds: INSIGHT_PREGENERATE_EXPIRE_SECONDS, + retryLimit: 2, + retryDelay: 60, + retryBackoff: true, + }, + ); + annotate({ + action: { name: "insights.pregenerate.today.enqueued" }, + meta: { locale: payload.locale, immediate: payload.immediate }, + }); + } catch { + // Best-effort, like every warm enqueue: the next read asks again. + } +} + /** * Delay before the single bounded retry of a failed nightly comprehensive * warm. 45 minutes: far enough out that a transient 02:30–04:30 provider diff --git a/src/lib/jobs/insight-pregenerate.ts b/src/lib/jobs/insight-pregenerate.ts index cdf911576..5e37cea90 100644 --- a/src/lib/jobs/insight-pregenerate.ts +++ b/src/lib/jobs/insight-pregenerate.ts @@ -898,6 +898,14 @@ export async function forceWarmUser( ) => Promise; /** Injected for the test — defaults to `new Date()`. */ now?: Date; + /** + * A briefing-for-today warm (`enqueueTodayBriefingWarm`): the + * comprehensive briefing only, past the one-hour freshness gate. The + * failure backoff, the daily forced-warm cap and the generation's own + * content-hash gate still hold, so a warm with nothing new to read + * costs no provider call. + */ + today?: boolean; } = {}, ): Promise { // v1.18.11 P3 — one feature-cache scope for the whole forced warm of this @@ -1001,6 +1009,7 @@ export async function forceWarmUser( const cachedAt = freshness?.insightsCachedAt ?? null; const failedAt = freshness?.insightsWarmFailedAt ?? null; const isFresh = + !options.today && cachedAt !== null && now.getTime() - cachedAt.getTime() < FORCE_WARM_FRESH_MS; const inBackoff = @@ -1125,7 +1134,9 @@ export async function forceWarmUser( // comprehensive is safe and bounded by their own per-card budgets. The only // outcome that has nothing to warm is a missing provider, and the // generators detect that themselves at near-zero cost. - if (statusText.available) { + // A today warm is about the briefing alone; the cards refill through + // their own read path when someone opens them. + if (statusText.available && !options.today) { // Refill-only (v1.16.8): a card already generated today is a cheap // cache read; a cold card runs its generator, whose content-hash gate // skips the LLM when the underlying data did not change. There is no diff --git a/src/lib/jobs/reminder/insights-handlers.ts b/src/lib/jobs/reminder/insights-handlers.ts index 4f529f204..c0443ac43 100644 --- a/src/lib/jobs/reminder/insights-handlers.ts +++ b/src/lib/jobs/reminder/insights-handlers.ts @@ -334,7 +334,9 @@ export async function handleInsightPregenerateJob( // GERMAN, so a forced warm produced German prose for a French reader. const locale = normalizeLocale(job.data.locale); try { - const summary = await forceWarmUser(getWorkerPrisma(), userId, locale); + const summary = await forceWarmUser(getWorkerPrisma(), userId, locale, { + today: job.data.today === true, + }); evt.addMeta( "force_warm", `${summary.comprehensive}:${summary.assessmentsWarmed}+${summary.metricAssessmentsWarmed}`, From 7c7576423f5b14001bebf41da1d8a68cc61027d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 5 Oct 2026 22:21:07 +0200 Subject: [PATCH 16/19] fix(dashboard): give the score's meta lines their own row on a phone Below md the lines under the 80 px dial (the score delta, how long it has held, what it rests on) were capped at the dial's width. In German they broke into three to five rows each, and beside a short lead that column set the height of the read row and left a gap above the facts. The score column now dissolves into the grid below md: the dial stays the top-right cell beside the lead, and its meta lines take a full-width row right after the read row, flush with the dial's edge, one line each. The facts move one row down. From md up the column is the same flex column as before (desktop captures are pixel-identical). The Today overview e2e renders German at 390 and 360 px with a short and a long lead and asserts the dial size, one row per meta line, the lines flush with the dial, the meta row right after the read row, and exactly one row gap above the facts. --- e2e/today-overview.spec.ts | 154 ++++++++++++++++ .../daily/__tests__/today-hero.test.tsx | 41 ++++- src/components/daily/today-hero.tsx | 170 ++++++++++-------- 3 files changed, 290 insertions(+), 75 deletions(-) diff --git a/e2e/today-overview.spec.ts b/e2e/today-overview.spec.ts index 4216f5b82..1b7782963 100644 --- a/e2e/today-overview.spec.ts +++ b/e2e/today-overview.spec.ts @@ -435,3 +435,157 @@ test.describe("Today overview geometry", () => { expect(Math.abs(after.width - before.width)).toBeLessThanOrEqual(1); }); }); + +/** + * The phone in German, where the score's meta lines are longest. In the + * dial's 80 px column "−6 vs. dein Mittel" and the basis sentence broke into + * three to five rows each; a short lead then left a gap above the facts as + * tall as that column. The lines now run on their own row under the dial. + */ +const GERMAN_SCORE: DailyDigest["score"] = { + value: 63, + band: "yellow", + delta: -6, + deltaReason: null, + steadyWeeks: null, + scoreBasis: { + domains: 2, + recommended: 3, + tier: "partial", + physiological: true, + }, +}; + +const GERMAN_FACTS: DailyDigest["today"] = [ + { + kind: "medications", + label: "Medikamente", + value: "1 von 2 genommen", + href: "/medications", + moduleKey: "medications", + }, + { + kind: "sleep", + label: "Letzte Nacht", + value: "5 Std. 45 Min., 42 Min. weniger als sonst", + href: "/insights/sleep", + moduleKey: "sleep", + }, + { + kind: "vitals", + label: "Vitalwerte", + value: "Puls über deinem Bereich", + href: "/insights", + }, +]; + +const GERMAN_STATES: Record = { + "de-short": { + ...BASE, + score: GERMAN_SCORE, + lead: { text: "Dein Puls liegt heute erhöht.", source: "signal" }, + today: GERMAN_FACTS, + }, + "de-long": { + ...BASE, + score: GERMAN_SCORE, + lead: { + text: "Dein Puls liegt heute Abend deutlich über seinem üblichen Niveau, nach einer kürzeren Nacht als sonst; ein ruhiger Abend täte ihm gut.", + source: "briefing", + }, + signalLine: { headline: null, delta: "+34 bpm vs. dein 30-Tage-Mittel" }, + today: GERMAN_FACTS, + }, +}; + +test.describe("Today overview on a German phone", () => { + test.beforeEach(async ({ context }, testInfo) => { + test.skip( + testInfo.project.name !== "chromium-desktop", + "viewport-driven spec; desktop project only", + ); + const baseURL = testInfo.project.use.baseURL ?? "http://localhost:3000"; + await context.addCookies([ + { name: "healthlog-locale", value: "de", url: baseURL }, + ]); + }); + + for (const viewport of VIEWPORTS.filter((v) => v.width < 768)) { + for (const [state, digest] of Object.entries(GERMAN_STATES)) { + test(`${state} at ${viewport.width}px`, async ({ page }) => { + await page.emulateMedia({ + colorScheme: "dark", + reducedMotion: "reduce", + }); + await page.addInitScript(() => { + window.localStorage.setItem("healthlog-theme", "dark"); + }); + await page.setViewportSize(viewport); + const hero = await openDashboard(page, digest); + await page.waitForLoadState("networkidle"); + mkdirSync(SHOTS, { recursive: true }); + await hero.screenshot({ + path: join(SHOTS, `${viewport.name}-dark-${state}.png`), + }); + + const box = async (slot: string) => + (await hero.locator(`[data-slot="${slot}"]`).first().boundingBox())!; + const ring = await box("score-ring"); + const lead = await box("today-hero-lead"); + const meta = await box("today-hero-score-meta"); + const block = await box("today-hero-today"); + + // The dial keeps its size and its place beside the lead. + expect(Math.round(ring.width)).toBe(RING_PX.phone); + expect(lead.width).toBeGreaterThanOrEqual(MIN_LEAD_WIDTH); + + // Each meta line reads on one line, flush with the dial's edge. + const lines = await hero + .locator('[data-slot="today-hero-score-meta"] > span') + .evaluateAll((spans) => + spans.map((span) => { + const rect = span.getBoundingClientRect(); + const lineHeight = parseFloat(getComputedStyle(span).lineHeight); + return { + rows: Math.round(rect.height / lineHeight), + right: rect.right, + }; + }), + ); + expect(lines.length).toBe(2); + for (const line of lines) { + expect(line.rows).toBe(1); + expect( + Math.abs(line.right - (ring.x + ring.width)), + ).toBeLessThanOrEqual(1); + } + + // The meta row follows the read row at once: right under the dial + // beside a short lead, right under a long lead's last line. + // The lead's cell ends with its signal line when there is one. + const signalLine = hero.locator('[data-slot="today-hero-signal"]'); + const signal = + (await signalLine.count()) > 0 + ? await signalLine.boundingBox() + : null; + const leadBottom = signal + ? signal.y + signal.height + : lead.y + lead.height; + const readBottom = Math.max(ring.y + ring.height, leadBottom); + expect(meta.y - readBottom).toBeGreaterThanOrEqual(0); + expect(meta.y - readBottom).toBeLessThanOrEqual(6); + + // No gap above the facts beyond one section step: they start one + // row gap (16 px) under whatever ends lowest, the lead or the meta. + const above = Math.max(leadBottom, meta.y + meta.height); + expect(block.y - above).toBeGreaterThanOrEqual(15); + expect(block.y - above).toBeLessThanOrEqual(17); + + const overflow = await page.evaluate( + () => document.documentElement.scrollWidth - window.innerWidth, + ); + expect(overflow).toBeLessThanOrEqual(0); + }); + } + } +}); diff --git a/src/components/daily/__tests__/today-hero.test.tsx b/src/components/daily/__tests__/today-hero.test.tsx index d8d8640cf..a334795ed 100644 --- a/src/components/daily/__tests__/today-hero.test.tsx +++ b/src/components/daily/__tests__/today-hero.test.tsx @@ -607,8 +607,45 @@ describe(" Today overview", () => { // Full width under lead and ring below md, the leading column from md. expect(cellClass("today-hero-today")).toContain("col-span-2"); expect(cellClass("today-hero-today")).toContain("md:col-span-1"); - expect(cellClass("today-hero-score")).toContain("col-start-2"); - expect(cellClass("today-hero-score")).toContain("md:row-span-2"); + // The slot's own classes, for elements that are grid items themselves. + const slotClass = (slot: string) => + new RegExp(`data-slot="${slot}" class="([^"]*)"`).exec(read)?.[1] ?? ""; + // Below md the dial is the top-right cell and its meta lines a row of + // their own under it; from md up the column spans both rows as before. + expect(slotClass("today-hero-score")).toContain("col-start-2"); + expect(slotClass("today-hero-score")).toContain("row-start-1"); + expect(slotClass("today-hero-score-column")).toContain("contents"); + expect(slotClass("today-hero-score-column")).toContain("md:row-span-2"); + }); + + it("gives the score's meta lines a full-width row on a phone, not the dial's width", () => { + const html = render( + , + "de", + ); + const meta = /data-slot="today-hero-score-meta" class="([^"]*)"/.exec( + html, + )?.[1]; + expect(meta).toContain("col-span-2"); + expect(meta).toContain("md:contents"); + // No line is capped to the 80 px dial any more. + expect(html).not.toContain("max-w-20"); + // The facts move one row down to make room for it. + expect(html).toMatch( + /class="[^"]*row-start-3[^"]*">]*data-slot="today-hero-today"/, + ); }); it("stacks each fact's label above its value, both on the reading edge", () => { diff --git a/src/components/daily/today-hero.tsx b/src/components/daily/today-hero.tsx index 65bbd4f4e..d2325fd0c 100644 --- a/src/components/daily/today-hero.tsx +++ b/src/components/daily/today-hero.tsx @@ -322,76 +322,77 @@ export function TodayHero({ null score paints the ring's honest provisional face at the same footprint, so the column never collapses. The meta lines under it are capped to the dial's width on a phone so the column stays compact. */ - const scoreColumn = (size: "sm" | "responsive") => { - const phoneMeta = size === "responsive" && "max-w-20 md:max-w-none"; - return ( -
( +
+ {/* The ring opens the Insights overview — the destination the + health-score card owns — so every hero ring is a door, not a + poster. */} + -
- {/* The ring opens the Insights overview — the destination the - health-score card owns — so every hero ring is a door, not a - poster. */} - - - -
- {deltaShown && digest.score?.delta != null ? ( - - {formatDelta(digest.score.delta, t)} - - ) : null} - {steadyLine ? ( - - {steadyLine} - - ) : null} - {basisLine ? ( - - {basisLine} - - ) : null} -
- ); - }; + + +
+ ); + + /* The score's meta lines: its delta, how long it has held, and what it + rests on. On the phone's full-width row the basis sentence keeps one + line; beside a dial it wraps at the dial column's width. */ + const scoreMetaLines = (size: "sm" | "responsive") => ( + <> + {deltaShown && digest.score?.delta != null ? ( + + {formatDelta(digest.score.delta, t)} + + ) : null} + {steadyLine ? ( + + {steadyLine} + + ) : null} + {basisLine ? ( + + {basisLine} + + ) : null} + + ); + + /* Health score ring — `flat` (no sweep/bloom), server-computed band. A + null score paints the ring's honest provisional face at the same + footprint, so the column never collapses. The compact all-clear keeps + the ring and its meta lines in one centred column. */ + const compactScoreColumn = ( +
+ {scoreRing("sm")} + {scoreMetaLines("sm")} +
+ ); const heroShellClassName = cn( // The tile strip's surface plus the ONE sanctioned Today atmosphere: @@ -460,7 +461,7 @@ export function TodayHero({ second row below the dial. */} {sleepPendingNote} - {scoreColumn("sm")} + {compactScoreColumn} ) : ( /* The day's read: the narrative lead on the reading edge, the @@ -496,20 +497,43 @@ export function TodayHero({ className={cn( "min-w-0", leadBlock - ? "col-span-2 row-start-2 md:col-span-1 md:col-start-1" + ? cn( + "col-span-2 md:col-span-1 md:col-start-1 md:row-start-2", + hasScoreMeta ? "row-start-3" : "row-start-2", + ) : "col-start-1 row-start-1", )} > {todayBlock} ) : null} + {/* One column from md up (the ring, its meta lines under it, + trailing edge), as before. Below md the column dissolves + (`contents`) into two grid items: the dial in the top-right + cell beside the lead, and the meta lines on a full-width row + right after the read row, flush with the dial's edge: under + the dial beside a short lead, under a long lead's last line. + In the dial's 80 px column the German lines broke into three + to five rows each and pushed the facts down. */}
- {scoreColumn("responsive")} + {scoreRing( + "responsive", + "col-start-2 row-start-1 self-start md:self-auto", + )} + {hasScoreMeta ? ( +
+ {scoreMetaLines("responsive")} +
+ ) : null}
)} From 63b395915e8edd5fe3f3d53090c65b9bc39ed454 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 5 Oct 2026 22:28:10 +0200 Subject: [PATCH 17/19] test: seed cached briefings with their generation moment The today check now reads briefingGeneratedAt from the cached payload and treats a payload without it as not from today. The integration and e2e fixtures that seed a fresh briefing write it, as every real generation does. --- e2e/setup/ai-optional-fixture.ts | 2 ++ tests/integration/ai-optional-mixed-routes.test.ts | 6 +++++- tests/integration/briefing-cache-locale.test.ts | 6 +++++- tests/integration/sharing-front-door.test.ts | 2 ++ 4 files changed, 14 insertions(+), 2 deletions(-) diff --git a/e2e/setup/ai-optional-fixture.ts b/e2e/setup/ai-optional-fixture.ts index bea502931..4a417d3e1 100644 --- a/e2e/setup/ai-optional-fixture.ts +++ b/e2e/setup/ai-optional-fixture.ts @@ -77,6 +77,8 @@ export async function resetAiOptionalAccount(): Promise { export async function seedStoredBriefing(marker: string): Promise { const insights = { dailyBriefing: { paragraph: marker, keyFindings: [] }, + // Generated now: only text written today is served as today's read. + briefingGeneratedAt: new Date().toISOString(), }; await withAccount(async (pool, userId) => { await pool.query( diff --git a/tests/integration/ai-optional-mixed-routes.test.ts b/tests/integration/ai-optional-mixed-routes.test.ts index e45966dc6..4529cae12 100644 --- a/tests/integration/ai-optional-mixed-routes.test.ts +++ b/tests/integration/ai-optional-mixed-routes.test.ts @@ -93,7 +93,11 @@ async function seed(recordId: string): Promise { await prisma.user.update({ where: { id: recordId }, data: { - insightsCachedText: JSON.stringify(BRIEFING), + // Generated now: only text written today is served as today's read. + insightsCachedText: JSON.stringify({ + ...BRIEFING, + briefingGeneratedAt: new Date().toISOString(), + }), insightsCachedAt: new Date(), insightsCachedLocale: "en", }, diff --git a/tests/integration/briefing-cache-locale.test.ts b/tests/integration/briefing-cache-locale.test.ts index b1073e961..0469710b9 100644 --- a/tests/integration/briefing-cache-locale.test.ts +++ b/tests/integration/briefing-cache-locale.test.ts @@ -66,7 +66,11 @@ async function seedUserWithCachedBriefing(cachedLocale: string | null) { // snapshot read resolves the reader's locale from. locale: "de", insightsCachedAt: new Date(), - insightsCachedText: JSON.stringify({ dailyBriefing: BRIEFING }), + // Generated now: only text written today is served as today's read. + insightsCachedText: JSON.stringify({ + dailyBriefing: BRIEFING, + briefingGeneratedAt: new Date().toISOString(), + }), insightsCachedLocale: cachedLocale, // The briefing is shown only while its AI capability is available, so // the account has its own provider (presence only) and a receipt. diff --git a/tests/integration/sharing-front-door.test.ts b/tests/integration/sharing-front-door.test.ts index 301a918da..04ebedfe0 100644 --- a/tests/integration/sharing-front-door.test.ts +++ b/tests/integration/sharing-front-door.test.ts @@ -279,6 +279,8 @@ function cachedBriefing(marker: string): string { paragraph: `Briefing for the ${marker}. Second sentence.`, keyFindings: [], }, + // Generated now: only text written today is served as today's read. + briefingGeneratedAt: new Date().toISOString(), }); } From 68720418e3ac7e56a604c7bc9a38612691bddb89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 5 Oct 2026 22:34:13 +0200 Subject: [PATCH 18/19] docs(changelog): add the MCP schema dialect and the local-day briefing to 1.40.6 --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b1fdeacc..1ea674146 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,13 @@ dashboard card fits a phone; text fields lose the purple focus ring. the lead line, the health status and the morning push only speak about today from readings taken today in the profile timezone; a stored briefing from an earlier day is no longer served. +- **MCP schemas for strict clients (#1170).** `tools/list` advertises every + input and output schema as JSON Schema 2020-12 without a `$schema` key, so + clients that only accept 2020-12 load the tools; tool results are unchanged. +- **Briefing for the local day.** The first read of a local day and the first + reading of a signal metric that day warm the briefing once (within the + existing daily limit), so users outside Europe and readings taken in the + morning get a current briefing; every surface resolves it the same way. - **Deltas** use each metric's display precision ("+34 bpm"). ### Changed From ef9dc1ddc60d5f5f2a506bd61e6f231f1d25ef60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 6 Oct 2026 00:30:13 +0200 Subject: [PATCH 19/19] test: pin the Coach layout clock to midday and read copied text without a tag-stripping replace The panel groups conversations by the browser's local day, so the e2e fixture's "an hour ago" landed in yesterday when CI ran just after midnight. The turn-steps test reads its markup by splitting rather than with a replace CodeQL reads as an incomplete sanitiser. --- e2e/coach-layout.spec.ts | 6 +++++- .../insights/coach-panel/__tests__/turn-steps.test.tsx | 10 +++++----- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/e2e/coach-layout.spec.ts b/e2e/coach-layout.spec.ts index d13cf9965..67b455d96 100644 --- a/e2e/coach-layout.spec.ts +++ b/e2e/coach-layout.spec.ts @@ -41,7 +41,10 @@ import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities"; * Screenshots are attached to the report (no pixel baselines). */ -const NOW = Date.now(); +// A fixed midday, pinned in the browser too (see beforeEach): the panel groups +// conversations by the browser's local day, so a run just after midnight in +// the runner's or the profile's zone would move "an hour ago" into yesterday. +const NOW = Date.parse("2026-06-04T12:00:00Z"); const HOUR = 60 * 60 * 1000; interface Conversation { @@ -406,6 +409,7 @@ test.describe("Coach page frame", () => { testInfo.project.name !== "chromium-desktop", "each case sets its own viewport; the desktop project runs them all", ); + await page.clock.setFixedTime(new Date(NOW)); await serveAiBlock(page, aiBlockAvailable()); await page.emulateMedia({ reducedMotion: "reduce" }); }); diff --git a/src/components/insights/coach-panel/__tests__/turn-steps.test.tsx b/src/components/insights/coach-panel/__tests__/turn-steps.test.tsx index d29209a83..0bda1cd08 100644 --- a/src/components/insights/coach-panel/__tests__/turn-steps.test.tsx +++ b/src/components/insights/coach-panel/__tests__/turn-steps.test.tsx @@ -363,12 +363,12 @@ describe("a step row copied out of the list", () => { "de", ); // What a selection copies: every text node except the unselectable ones. + // Reads this test's own static markup; not a sanitiser for input. const copied = html - .replace( - /]*class="[^"]*\bselect-none\b[^"]*"[^>]*>[^<]*<\/span>/g, - "", - ) - .replace(/<[^>]+>/g, "") + .split(/]*class="[^"]*\bselect-none\b[^"]*"[^>]*>[^<]*<\/span>/) + .join("") + .split(/<[^>]*>/) + .join("") .replace(/\s+/g, " ") .trim(); expect(copied).toBe("Gewicht · letzte 90 Tage");