mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
fix(dashboard): compact score-only hero
This commit is contained in:
1 parent
8a19699a59
commit
bdca4f5724
3 files changed
+172
-5
No files matched your search
@@ -1,6 +1,7 @@
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import type { Page, TestInfo } from "@playwright/test";
|
||||
import type { DailyDigest } from "@/lib/daily/digest";
|
||||
|
||||
import { expect, test } from "./setup/test";
|
||||
import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
@@ -13,6 +14,26 @@ import { settleBeforeMeasure } from "./utils/settle";
|
||||
|
||||
const EVIDENCE_DIR = join(process.cwd(), "test-results", "v1341");
|
||||
|
||||
const NARRATIVE_DIGEST: DailyDigest = {
|
||||
generatedAt: "2026-07-29T08:30:00.000Z",
|
||||
phase: "final",
|
||||
sleepPending: false,
|
||||
score: {
|
||||
value: 84,
|
||||
band: "green",
|
||||
delta: -2,
|
||||
deltaReason: null,
|
||||
scoreVersion: 2,
|
||||
composition: ["BLOOD_PRESSURE", "ACTIVITY", "SLEEP"],
|
||||
},
|
||||
topSignal: LONG_HEADLINE_BRIEFING.signalsOfDay?.[0] ?? null,
|
||||
briefingLead: LONG_HEADLINE_BRIEFING.paragraph,
|
||||
line: LONG_HEADLINE_BRIEFING.paragraph,
|
||||
worthALook: [],
|
||||
justIn: null,
|
||||
reactionLine: null,
|
||||
};
|
||||
|
||||
async function settleAndCapture(
|
||||
page: Page,
|
||||
testInfo: TestInfo,
|
||||
@@ -46,14 +67,70 @@ test.describe("v1.34.1 deterministic UI evidence", () => {
|
||||
await mkdir(EVIDENCE_DIR, { recursive: true });
|
||||
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
let dailyDigest = NARRATIVE_DIGEST;
|
||||
// TodayHero reads the unified digest endpoint, not the dashboard
|
||||
// snapshot's legacy briefing field. Keep a mutable, deterministic reader
|
||||
// so this evidence covers both the narrative composition captured below
|
||||
// and the compact score-only fallback through the exact production seam.
|
||||
await page.route(/\/api\/daily\/digest(\?|$)/, (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ data: dailyDigest, error: null }),
|
||||
}),
|
||||
);
|
||||
await mockDashboardSnapshot(page, {
|
||||
briefing: LONG_HEADLINE_BRIEFING,
|
||||
});
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await expect(page.locator("#main-content")).toBeVisible();
|
||||
const hero = page.locator('[data-slot="today-hero"]');
|
||||
await expect(hero).toHaveAttribute("data-layout", "narrative");
|
||||
await expect(hero.locator('[data-slot="today-hero-lead"]')).toContainText(
|
||||
"Dein systolischer Blutdruck",
|
||||
);
|
||||
await expect(hero.locator('[data-slot="score-ring"]')).toHaveCSS(
|
||||
"width",
|
||||
"168px",
|
||||
);
|
||||
await settleAndCapture(page, testInfo, "dashboard-desktop.png");
|
||||
|
||||
dailyDigest = {
|
||||
...NARRATIVE_DIGEST,
|
||||
topSignal: null,
|
||||
briefingLead: null,
|
||||
line: "Dein Gesundheitsscore liegt heute bei 84.",
|
||||
reactionLine: null,
|
||||
};
|
||||
await page.reload({ waitUntil: "domcontentloaded" });
|
||||
await expect(hero).toHaveAttribute("data-layout", "compact-all-clear");
|
||||
await expect(hero.locator('[data-slot="today-hero-lead"]')).toHaveCount(0);
|
||||
await expect(
|
||||
hero.locator('[data-slot="today-hero-all-clear"]'),
|
||||
).toBeVisible();
|
||||
await expect(hero.locator('[data-slot="score-ring"]')).toHaveCSS(
|
||||
"width",
|
||||
"120px",
|
||||
);
|
||||
await expect(
|
||||
hero.locator('[data-slot="score-ring"]').getByText("84", { exact: true }),
|
||||
).toHaveCount(1);
|
||||
await settleAndCapture(page, testInfo, "dashboard-score-only-desktop.png");
|
||||
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
const compactHeroBox = await hero.boundingBox();
|
||||
expect(compactHeroBox).not.toBeNull();
|
||||
expect(
|
||||
compactHeroBox?.width ?? Number.POSITIVE_INFINITY,
|
||||
).toBeLessThanOrEqual(390);
|
||||
expect(
|
||||
await page.evaluate(
|
||||
() =>
|
||||
document.documentElement.scrollWidth <=
|
||||
document.documentElement.clientWidth,
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
await mockPopulatedInsights(page);
|
||||
await page.goto("/insights", { waitUntil: "domcontentloaded" });
|
||||
await expect(
|
||||
|
||||
@@ -260,4 +260,42 @@ describe("<TodayHero>", () => {
|
||||
expect(html).toContain('data-slot="today-hero-all-clear"');
|
||||
expect(html).not.toContain('data-slot="today-hero-rail"');
|
||||
});
|
||||
|
||||
it("uses the compact score-only composition when all-clear has no narrative", () => {
|
||||
const html = render(
|
||||
<TodayHero
|
||||
digest={digest({
|
||||
topSignal: null,
|
||||
briefingLead: null,
|
||||
reactionLine: null,
|
||||
line: "Your health score today is 82.",
|
||||
worthALook: [],
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
const text = visibleText(html);
|
||||
|
||||
expect(html).toContain('data-layout="compact-all-clear"');
|
||||
expect(html).toContain('data-slot="today-hero-all-clear"');
|
||||
expect(html).not.toContain('data-slot="today-hero-lead"');
|
||||
expect(html).toContain('style="width:120px;height:120px"');
|
||||
// The compact fallback keeps exactly one score face: the ring.
|
||||
expect(text.match(/\b82\b/g)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("keeps the full narrative composition and md score ring when a lead exists", () => {
|
||||
const html = render(
|
||||
<TodayHero
|
||||
digest={digest({
|
||||
topSignal: null,
|
||||
worthALook: [],
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain('data-layout="narrative"');
|
||||
expect(html).toContain('data-slot="today-hero-lead"');
|
||||
expect(html).toContain('style="width:168px;height:168px"');
|
||||
expect(html).not.toContain('data-layout="compact-all-clear"');
|
||||
});
|
||||
});
|
||||
@@ -160,6 +160,14 @@ export function TodayHero({
|
||||
const lead = withoutRepeatedScore(rawLead, digest.score?.value ?? null);
|
||||
const topSignal = digest.topSignal;
|
||||
const justInTime = digest.justIn ? formatJustInTime(digest.justIn.at) : null;
|
||||
// A score-only all-clear digest has no narrative content for the leading
|
||||
// column. Keeping the full md ring in that two-column shell left a blank
|
||||
// column beside a 168 px dial and pushed the actual all-clear read below it.
|
||||
// Treat that honest fallback as its own compact composition: the all-clear
|
||||
// copy occupies the leading column and a smaller version of the SAME score
|
||||
// ring remains the one numeric face. This branch depends only on the
|
||||
// server-delivered digest, so SSR and hydration choose it identically.
|
||||
const compactAllClear = !lead && !topSignal && !hasItems;
|
||||
|
||||
// Calm degrade (plan §3): a genuinely empty account — no score, no rail
|
||||
// items, no cached briefing lead, and no fresh arrival — surfaces nothing
|
||||
@@ -186,6 +194,7 @@ export function TodayHero({
|
||||
<section
|
||||
data-slot="today-hero"
|
||||
data-phase={digest.phase}
|
||||
data-layout={compactAllClear ? "compact-all-clear" : "narrative"}
|
||||
className={cn(
|
||||
// The tile strip's surface plus the ONE sanctioned Today atmosphere:
|
||||
// `.today-hero-wash` leans a faint `--primary` mix over the theme
|
||||
@@ -204,7 +213,14 @@ export function TodayHero({
|
||||
{/* 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="flex flex-col gap-4 md:flex-row md:items-start md:justify-between md:gap-6">
|
||||
<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",
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
{/* Hero numeric face: the read leads large in the foreground
|
||||
token, calm and legible — the day's read, not a slogan. */}
|
||||
@@ -232,6 +248,42 @@ export function TodayHero({
|
||||
) : null}
|
||||
</p>
|
||||
) : null}
|
||||
{compactAllClear ? (
|
||||
<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 && (digest.sleepPending || digest.justIn) ? (
|
||||
<div className="text-muted-foreground flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
|
||||
{digest.sleepPending ? (
|
||||
<p
|
||||
data-slot="today-hero-sleep-pending"
|
||||
className="flex items-center gap-1.5"
|
||||
>
|
||||
<Moon className="size-3.5 shrink-0" aria-hidden="true" />
|
||||
{t("daily.today.sleepPending")}
|
||||
</p>
|
||||
) : null}
|
||||
{digest.justIn ? (
|
||||
<p
|
||||
data-slot="today-hero-just-in"
|
||||
data-just-in-kind={digest.justIn.kind}
|
||||
className="flex items-center gap-1.5"
|
||||
>
|
||||
{mounted && justInTime
|
||||
? t("daily.today.justInAt", { time: justInTime })
|
||||
: t("daily.today.justIn")}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Health score ring — `flat` (no sweep/bloom), server-computed
|
||||
@@ -254,7 +306,7 @@ export function TodayHero({
|
||||
band={
|
||||
digest.score ? (digest.score.band as ScoreBand) : undefined
|
||||
}
|
||||
size="md"
|
||||
size={compactAllClear ? "sm" : "md"}
|
||||
flat
|
||||
label={t("daily.today.scoreLabel")}
|
||||
/>
|
||||
@@ -279,7 +331,7 @@ export function TodayHero({
|
||||
they can legitimately co-occur (a weight landed while last night's
|
||||
sleep is still pending), so they share one wrapping row rather than
|
||||
competing for the same slot. */}
|
||||
{digest.sleepPending || digest.justIn ? (
|
||||
{!compactAllClear && (digest.sleepPending || digest.justIn) ? (
|
||||
<div className="text-muted-foreground flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
|
||||
{/* Freshness note (plan §2.4) — provisional day, last night's
|
||||
sleep not yet folded in. Muted, non-blocking, refreshes in
|
||||
@@ -338,14 +390,14 @@ export function TodayHero({
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
) : !compactAllClear ? (
|
||||
<p
|
||||
data-slot="today-hero-all-clear"
|
||||
className="text-muted-foreground text-sm"
|
||||
>
|
||||
{t("daily.today.allClear")}
|
||||
</p>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user