mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
Merge branch 'fix/dashboard-hero-mobile' into release/v1.40.6
This commit is contained in:
commit
ab14747013
21 files changed
+1086
-278
No files matched your search
+194
-83
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 })],
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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`).
|
||||
*
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user