Merge branch 'fix/dashboard-hero-mobile' into release/v1.40.6

This commit is contained in:
Marc-André Bombeck committed 2026-10-05 21:38:21 +02:00
commit ab14747013
21 files changed
+1086 -278

No files matched your search

+194 -83
View File
@@ -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<string, DailyDigest> = {
},
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);
});
}
}
}
@@ -388,7 +388,7 @@ describe("<TodayHero>", () => {
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(
<TodayHero
digest={digest({
@@ -401,7 +401,8 @@ describe("<TodayHero>", () => {
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("<TodayHero> Today overview", () => {
}
});
it("puts the ring beside the lead and the overview under both on a phone", () => {
const html = render(
<TodayHero digest={digest({ today: FACTS, worthALook: [] })} />,
);
// 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(/<div class="([^"]*(?:col-|row-)[^"]*)"/g),
];
return cells.at(-1)?.[1] ?? "";
};
expect(cellClass("today-hero-lead")).toContain("row-start-1");
// 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");
});
it("stacks each fact's label above its value, both on the reading edge", () => {
const html = render(<TodayHero digest={digest({ today: FACTS })} />);
const links = [
...html.matchAll(
/data-slot="today-hero-fact"[^>]*><a[^>]*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><span class="text-foreground/,
);
}
});
it("hides the fifth fact below md and never the first four", () => {
const html = render(<TodayHero digest={digest({ today: FACTS })} />);
const items = [
+172 -110
View File
@@ -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. */}
<Link
href={fact.href}
className="focus-visible:ring-ring/50 hover:bg-muted/50 -mx-1.5 flex min-h-11 items-center justify-between gap-3 rounded-md px-1.5 py-1 focus-visible:ring-2 focus-visible:outline-none md:min-h-0 md:flex-col md:items-start md:justify-start md:gap-0.5"
className="focus-visible:ring-ring/50 hover:bg-muted/50 -mx-1.5 flex min-h-11 flex-col justify-center gap-0.5 rounded-md px-1.5 py-1 focus-visible:ring-2 focus-visible:outline-none md:min-h-0 md:justify-start"
>
<span className="text-muted-foreground shrink-0 text-sm md:text-xs">
<span className="text-muted-foreground text-xs">
{fact.label}
</span>
<span className="text-foreground min-w-0 text-right text-sm font-medium md:text-left">
<span className="text-foreground min-w-0 text-sm font-medium">
{fact.value}
</span>
</Link>
@@ -285,6 +290,109 @@ export function TodayHero({
</div>
) : 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 ? (
<div className="space-y-2">
{lead ? (
<div
data-slot="today-hero-lead"
data-source={digest.lead?.source}
className="text-foreground text-lg leading-snug font-semibold tracking-tight sm:text-xl"
>
<ProseBlocks text={lead} strip linkify={false} />
</div>
) : null}
{signalLine ? (
<p
data-slot="today-hero-signal"
className="text-muted-foreground text-sm"
>
{signalLine.headline}
{signalLine.headline && signalLine.delta ? " · " : null}
{signalLine.delta}
</p>
) : null}
</div>
) : 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 (
<div
className={cn(
"flex shrink-0 flex-col gap-1 md:items-end",
// The phone dial sits flush with the card's trailing edge, mirroring
// the lead's leading one. Its meta lines align under it and are no
// wider than the dial, so the lead keeps the width beside it.
size === "responsive"
? "items-end text-right"
: "items-center text-center",
)}
>
<div data-slot="today-hero-score">
{/* The ring opens the Insights overview — the destination the
health-score card owns — so every hero ring is a door, not a
poster. */}
<Link
href="/insights"
aria-label={t("daily.today.ringLink", {
metric: t("daily.today.scoreLabel"),
})}
className="focus-visible:ring-ring/50 block rounded-full focus-visible:ring-2 focus-visible:outline-none"
>
<ScoreRing
score={digest.score?.value ?? null}
band={digest.score ? (digest.score.band as ScoreBand) : undefined}
size={size}
flat
label={t("daily.today.scoreLabel")}
/>
</Link>
</div>
{deltaShown && digest.score?.delta != null ? (
<span
data-slot="today-hero-score-delta"
className={cn(
"text-muted-foreground text-xs tabular-nums",
phoneMeta,
)}
>
{formatDelta(digest.score.delta, t)}
</span>
) : null}
{steadyLine ? (
<span
data-slot="today-hero-score-steady"
className={cn(
"text-muted-foreground text-xs text-balance",
phoneMeta,
)}
>
{steadyLine}
</span>
) : null}
{basisLine ? (
<span
data-slot="today-hero-score-basis"
className={cn(
"text-muted-foreground text-xs text-balance md:max-w-[11rem] md:text-right",
size === "responsive" ? "max-w-20" : "max-w-[11rem]",
)}
>
{basisLine}
</span>
) : null}
</div>
);
};
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. */}
<div className="flex flex-col gap-3 md:gap-4">
{/* 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. */}
<div
className={cn(
"flex gap-4",
compactAllClear
? "flex-row items-center justify-between"
: "flex-col md:flex-row md:items-start md:justify-between md:gap-6",
)}
>
{/* The overview is its own section under the lead, so it keeps a
section's distance from it; the compact all-clear stays tight. */}
<div
className={cn(
"min-w-0 flex-1",
compactAllClear ? "space-y-3" : "space-y-4 md:space-y-6",
)}
>
{/* Hero numeric face: the read leads large in the foreground
token, calm and legible — the day's read, not a slogan. */}
{lead || signalLine ? (
<div className="space-y-2">
{lead ? (
<div
data-slot="today-hero-lead"
data-source={digest.lead?.source}
className="text-foreground text-lg leading-snug font-semibold tracking-tight sm:text-xl"
>
<ProseBlocks text={lead} strip linkify={false} />
</div>
) : null}
{/* Top signal — present-tense headline + optional delta, one
muted step down so it supports the lead without
competing. */}
{signalLine ? (
<p
data-slot="today-hero-signal"
className="text-muted-foreground text-sm"
>
{signalLine.headline}
{signalLine.headline && signalLine.delta ? " · " : null}
{signalLine.delta}
</p>
) : null}
</div>
) : 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. */
<div className="flex flex-row items-center justify-between gap-4">
<div className="min-w-0 flex-1 space-y-3">
<p
data-slot="today-hero-all-clear"
className="text-muted-foreground text-sm"
>
{t("daily.today.allClear")}
</p>
) : 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}
</div>
{/* 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. */}
<div className="flex shrink-0 flex-col items-center gap-1 md:items-end">
<div data-slot="today-hero-score">
{/* 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. */}
<Link
href="/insights"
aria-label={t("daily.today.ringLink", {
metric: t("daily.today.scoreLabel"),
})}
className="focus-visible:ring-ring/50 block rounded-full focus-visible:ring-2 focus-visible:outline-none"
>
<ScoreRing
score={digest.score?.value ?? null}
band={
digest.score ? (digest.score.band as ScoreBand) : undefined
}
size={compactAllClear ? "sm" : "md"}
flat
label={t("daily.today.scoreLabel")}
/>
</Link>
{/* 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}
</div>
{deltaShown && digest.score?.delta != null ? (
<span
data-slot="today-hero-score-delta"
className="text-muted-foreground text-xs tabular-nums"
>
{formatDelta(digest.score.delta, t)}
</span>
) : null}
{steadyLine ? (
<span
data-slot="today-hero-score-steady"
className="text-muted-foreground text-xs"
>
{steadyLine}
</span>
) : null}
{basisLine ? (
<span
data-slot="today-hero-score-basis"
className="text-muted-foreground max-w-[11rem] text-center text-xs text-balance md:text-right"
>
{basisLine}
</span>
) : null}
{scoreColumn("sm")}
</div>
</div>
) : (
/* 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. */
<div
data-slot="today-hero-read"
className={cn(
"grid grid-cols-[minmax(0,1fr)_auto] gap-x-4 gap-y-4 md:gap-x-6 md:gap-y-6",
// A ring taller than the lead pushes its extra height into the
// second row only, so the overview stays right under the lead.
leadBlock && todayBlock && "md:grid-rows-[auto_1fr]",
)}
>
{leadBlock ? (
<div className="col-start-1 row-start-1 min-w-0 self-start">
{leadBlock}
</div>
) : null}
{todayBlock ? (
<div
className={cn(
"min-w-0",
leadBlock
? "col-span-2 row-start-2 md:col-span-1 md:col-start-1"
: "col-start-1 row-start-1",
)}
>
{todayBlock}
</div>
) : null}
<div
className={cn(
"col-start-2 row-start-1 self-start",
leadBlock && todayBlock && "md:row-span-2",
)}
>
{scoreColumn("responsive")}
</div>
</div>
)}
{/* Meta row — the hero's quiet tier (UI-STANDARDS §text: `text-xs
text-muted-foreground` is the meta floor; never an accent, never an
+29 -8
View File
@@ -54,11 +54,27 @@ const SPRING_SAFE_MAX = 94;
const SIZE: Record<
NonNullable<ScoreRingProps["size"]>,
{ 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
+8
View File
@@ -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,
@@ -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);
});
});
+45 -1
View File
@@ -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 })],
+151
View File
@@ -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<DailyBriefingSignal["sourceMetric"], string>
> = {
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 };
}
+14 -20
View File
@@ -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, string | number>) => 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,
+8 -3
View File
@@ -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)),
);
}
@@ -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);
@@ -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 : []),
@@ -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([]);
});
});
@@ -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) => ({
@@ -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;
+21 -7
View File
@@ -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,
};
+8 -1
View File
@@ -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;
}
+6 -8
View File
@@ -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,
+21
View File
@@ -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`).
*
+75 -22
View File
@@ -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
+37
View File
@@ -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;
}