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 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; +}