Files
HealthLog/e2e/today-overview.spec.ts
T
Marc-André Bombeck 5cfacba16a feat(dashboard): add a Today overview to the hero
The hero led with the first sentence of the cached briefing, which was
often a greeting ("Good morning.") while the sentence with content was
never shown. Without an AI provider it fell back to "Your health score
today is N.", which the hero then removed because the ring already
shows the number, leaving a ring and "Nothing needs your attention".

The daily digest now publishes the lead resolved on the server and a
short list of facts about the day:

- lead: the reaction line or the first briefing sentence that is not a
  greeting and does not repeat the score while AI text is available,
  otherwise a deterministic sentence about the strongest signal (a vital
  outside its personal range, a night an hour off the usual, vitals in
  range, last night). A fact the lead was built from leaves the list.
- today: up to five statements in priority order (Rest Mode, doses taken
  today, an appointment today or tomorrow that is not already on the
  rail, last night's sleep against the usual, vitals against their
  personal range, cycle day). Module-gated on the server, never padded,
  never asking for anything. A phone shows the first four.
- restMode: passed through while an illness episode is active.
- score.steadyWeeks: how long the score has held, read off the stored
  daily scores, shown under the ring in place of a delta.

briefingLead skips greetings and score sentences the same way, so the
morning push improves with it. Visit times are rendered in the profile
timezone and hour-cycle preference; the cycle day comes from the same
verdict the cycle page uses. The ring, the rail and the charts are
unchanged. OpenAPI regenerated; additive fields only.
2026-10-04 20:00:18 +02:00

320 lines
10 KiB
TypeScript

import { mkdirSync } from "node:fs";
import { join } from "node:path";
import type { Page } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import {
mockDashboardSnapshot,
POPULATED_SUMMARIES,
} from "./utils/mock-dashboard-snapshot";
import type { DailyDigest } from "@/lib/daily/digest";
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.
*
* The digests are what the server would publish for each state. They carry
* the pre-overview fields too (`briefingLead`, `line`), with the values the
* old server sent, so the same fixtures render the previous hero on an older
* build for a before/after comparison.
*
* Assertions anchor on `data-slot` / `data-kind`, never on visible text.
*/
test.use({ storageState: STORAGE_STATE_PATH });
const SHOTS = join(process.cwd(), "test-results", "today-overview");
const VIEWPORTS = [
{ name: "desktop", width: 1440, height: 900 },
{ name: "mobile", width: 390, height: 844 },
] as const;
const BASE: DailyDigest = {
generatedAt: new Date().toISOString(),
ai: DIGEST_AI_AVAILABLE,
phase: "final",
sleepPending: false,
score: {
value: 94,
band: "green",
delta: null,
deltaReason: "first_eligibility_window",
steadyWeeks: 4,
},
topSignal: null,
briefingLead: null,
lead: null,
today: [],
restMode: null,
line: "Your health score today is 94.",
worthALook: [],
justIn: null,
reactionLine: null,
};
const FACTS: DailyDigest["today"] = [
{
kind: "medications",
label: "Medications",
value: "1 of 3 taken",
href: "/medications",
moduleKey: "medications",
},
{
kind: "appointment",
label: "Appointment",
value: "Tomorrow 09:30, Routine visit",
href: "/checkups",
},
{
kind: "sleep",
label: "Last night",
value: "7h 20m, close to your usual",
href: "/insights/sleep",
moduleKey: "sleep",
},
{
kind: "vitals",
label: "Vitals",
value: "6 checked, all in your range",
href: "/insights",
},
{
kind: "cycle",
label: "Cycle",
value: "Follicular, day 12",
href: "/cycle",
moduleKey: "cycle",
},
];
const STATES: Record<string, DailyDigest> = {
// AI on. The old server led with the briefing's first sentence, which was
// the greeting.
"ai-on": {
...BASE,
briefingLead: "Good morning.",
line: "Good morning.",
topSignal: {
sourceMetric: "bp",
tone: "good",
headline: "Blood pressure is sitting in the optimal band",
nudge: "",
delta: "↓ ~10 mmHg systolic vs the start of the window",
},
lead: {
text: "Your latest blood pressure is sitting in the optimal band.",
source: "briefing",
},
today: FACTS,
worthALook: [
{
kind: "dose_window",
title: "Medication due",
body: "Ramipril is due soon.",
status: "info",
actions: [
{
labelKey: "daily.action.logDose",
intent: "dose.log",
href: "/medications",
},
],
moduleKey: "medications",
},
],
},
// AI off. The old server had only the score sentence, which the hero then
// removed again, leaving the ring and "Nothing needs your attention".
"ai-off": {
...BASE,
ai: {
...DIGEST_AI_AVAILABLE,
briefing: {
available: false,
reason: "no_provider",
onDeviceAllowed: false,
},
},
lead: {
text: "Resting heart rate is at 61 bpm, above your usual range of 50 to 58 bpm.",
source: "signal",
},
today: [FACTS[0], FACTS[2], FACTS[4]],
},
// A quiet day: one calm sentence and no facts beyond it.
quiet: {
...BASE,
lead: {
text: "All 6 of your latest vitals sit inside their usual range.",
source: "signal",
},
},
};
async function openDashboard(page: Page, digest: DailyDigest) {
await mockDashboardSnapshot(page, { summaries: POPULATED_SUMMARIES });
await page.route(/\/api\/daily\/digest(\?|$)/, (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ data: digest, error: null }),
}),
);
await page.goto("/", { waitUntil: "domcontentloaded" });
const hero = page.locator('[data-slot="today-hero"]');
await expect(hero).toBeVisible({ timeout: 15_000 });
await expect(page.locator('[data-slot="dashboard-tile-strip"]')).toBeVisible({
timeout: 15_000,
});
return hero;
}
test.describe("Today overview geometry", () => {
test.beforeEach(({}, testInfo) => {
test.skip(
testInfo.project.name !== "chromium-desktop",
"viewport-driven spec; desktop project only",
);
});
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);
mkdirSync(SHOTS, { recursive: true });
await hero.screenshot({
path: join(SHOTS, `${viewport.name}-${state}.png`),
});
// 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 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);
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 all-clear sentence never sits under a lead or facts.
await expect(
hero.locator('[data-slot="today-hero-all-clear"]'),
).toHaveCount(0);
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;
}
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,
);
}
// 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);
});
}
}
test("the hero width does not move when the overview arrives", async ({
page,
}) => {
await page.setViewportSize({ width: 1440, height: 900 });
let calls = 0;
await mockDashboardSnapshot(page, { summaries: POPULATED_SUMMARIES });
await page.route(/\/api\/daily\/digest(\?|$)/, async (route) => {
calls += 1;
// First paint: a quiet digest. Every later read carries the overview.
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: calls > 1 ? STATES["ai-on"] : STATES.quiet,
error: null,
}),
});
});
await page.goto("/", { waitUntil: "domcontentloaded" });
const hero = page.locator('[data-slot="today-hero"]');
await expect(hero).toBeVisible({ timeout: 15_000 });
const before = (await hero.boundingBox())!;
// The tab becomes visible again: the signal TanStack Query's focus
// manager refetches on, as `today-just-in.spec.ts` uses.
await page.evaluate(() =>
window.dispatchEvent(new Event("visibilitychange")),
);
await expect(hero.locator('[data-slot="today-hero-today"]')).toBeVisible({
timeout: 15_000,
});
const after = (await hero.boundingBox())!;
expect(Math.abs(after.x - before.x)).toBeLessThanOrEqual(1);
expect(Math.abs(after.width - before.width)).toBeLessThanOrEqual(1);
});
});