mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
Merge work/hero-score-panel
This commit is contained in:
commit
f8074e1b07
10 files changed
+1811
-367
No files matched your search
@@ -0,0 +1,136 @@
|
||||
import { expect, test } from "./setup/test";
|
||||
|
||||
import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
import { mockPopulatedInsights } from "./utils/mock-populated-insights";
|
||||
import {
|
||||
expectNoHorizontalOverflow,
|
||||
settleForOverflowMeasurement,
|
||||
} from "./utils/horizontal-overflow";
|
||||
|
||||
/**
|
||||
* The Health Score panel at 360 px.
|
||||
*
|
||||
* The panel is the widest-content block in the hero band: a fixed label
|
||||
* column, a fill bar, a value column and a popover trigger on one line, plus
|
||||
* a coalesced failed-read line carrying an inline button, plus a disclosure
|
||||
* whose open state adds a bordered tile and three prose paragraphs. 360 px is
|
||||
* the narrowest common Android width and the one this overview has scrolled
|
||||
* sideways at before.
|
||||
*
|
||||
* Two ways a guard like this proves nothing, both of which have bitten this
|
||||
* repo: measuring only `document.scrollWidth` when `AuthShell` scrolls inside
|
||||
* `<main>`, and measuring an empty page. The shared helper closes the first;
|
||||
* the fixture and the fixture-honesty test below close the second by asserting
|
||||
* the panel is actually on screen with rows in it before any width is read.
|
||||
*
|
||||
* Assertions are on `data-slot` attributes, never on rendered text, so a
|
||||
* locale change or a copy edit cannot turn this red for the wrong reason.
|
||||
*/
|
||||
|
||||
const PANEL = '[data-slot="health-score-card"]';
|
||||
|
||||
test.describe("the Health Score panel at phone width", () => {
|
||||
test.use({ storageState: STORAGE_STATE_PATH });
|
||||
|
||||
test.beforeEach(async ({}, testInfo) => {
|
||||
test.skip(testInfo.project.name !== "chromium-mobile", "mobile-only spec");
|
||||
});
|
||||
|
||||
test("is on screen with its rows before anything is measured", async ({
|
||||
page,
|
||||
}) => {
|
||||
await mockPopulatedInsights(page);
|
||||
await page.setViewportSize({ width: 360, height: 851 });
|
||||
await page.goto("/insights", { waitUntil: "domcontentloaded" });
|
||||
|
||||
const panel = page.locator(PANEL);
|
||||
await expect(panel).toBeVisible();
|
||||
await expect(
|
||||
panel.locator('[data-slot="health-score-card-number"]'),
|
||||
).toBeVisible();
|
||||
// Four scored pillars in the fixture; a panel with no rows would fit any
|
||||
// width and prove nothing about the row geometry.
|
||||
await expect(panel.locator("li[data-status='ok']")).toHaveCount(4);
|
||||
// The failed read and its retry are visible without opening anything.
|
||||
await expect(
|
||||
panel.locator('[data-slot="health-score-pillar-error"]'),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
panel.locator('[data-slot="health-score-pillar-retry"]'),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
panel.locator('[data-slot="health-score-crisis"]'),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
panel.locator('[data-slot="health-score-not-scored-count"]'),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("does not scroll the page sideways, closed or open", async ({
|
||||
page,
|
||||
}) => {
|
||||
await mockPopulatedInsights(page);
|
||||
await page.setViewportSize({ width: 360, height: 851 });
|
||||
await page.goto("/insights", { waitUntil: "domcontentloaded" });
|
||||
|
||||
const panel = page.locator(PANEL);
|
||||
await expect(panel).toBeVisible();
|
||||
await settleForOverflowMeasurement(page);
|
||||
await expectNoHorizontalOverflow(page, "/insights score panel @360px");
|
||||
|
||||
// Opened, the disclosure adds the bordered weight-goal tile and the method
|
||||
// paragraphs — the widest content the panel can hold.
|
||||
await panel.locator('[data-slot="health-score-anatomy-toggle"]').click();
|
||||
await expect(
|
||||
panel.locator('[data-slot="health-score-weight-goal"]'),
|
||||
).toBeVisible();
|
||||
await settleForOverflowMeasurement(page);
|
||||
await expectNoHorizontalOverflow(page, "/insights score panel open @360px");
|
||||
});
|
||||
|
||||
test("keeps the panel inside the band, never wider than it", async ({
|
||||
page,
|
||||
}) => {
|
||||
await mockPopulatedInsights(page);
|
||||
await page.setViewportSize({ width: 360, height: 851 });
|
||||
await page.goto("/insights", { waitUntil: "domcontentloaded" });
|
||||
|
||||
const panel = page.locator(PANEL);
|
||||
await expect(panel).toBeVisible();
|
||||
await panel.locator('[data-slot="health-score-anatomy-toggle"]').click();
|
||||
await expect(
|
||||
panel.locator('[data-slot="health-score-weight-goal"]'),
|
||||
).toBeVisible();
|
||||
|
||||
const measured = await panel.evaluate((el) => {
|
||||
const band = el.closest('[data-slot="insights-hero-strip"]');
|
||||
// The widest descendant that is not contained by its own scroller.
|
||||
let widest = 0;
|
||||
for (const child of Array.from(el.querySelectorAll("*"))) {
|
||||
const r = child.getBoundingClientRect();
|
||||
if (r.width > widest) widest = r.width;
|
||||
}
|
||||
return {
|
||||
panelScroll: el.scrollWidth,
|
||||
panelClient: el.clientWidth,
|
||||
panelRight: Math.round(el.getBoundingClientRect().right),
|
||||
bandRight: band ? Math.round(band.getBoundingClientRect().right) : null,
|
||||
widestChild: Math.round(widest),
|
||||
};
|
||||
});
|
||||
|
||||
expect(
|
||||
measured.panelScroll,
|
||||
`panel scrolls its own content: ${JSON.stringify(measured)}`,
|
||||
).toBeLessThanOrEqual(measured.panelClient + 1);
|
||||
expect(measured.bandRight).not.toBeNull();
|
||||
expect(
|
||||
measured.panelRight,
|
||||
`panel extends past the band: ${JSON.stringify(measured)}`,
|
||||
).toBeLessThanOrEqual(measured.bandRight! + 1);
|
||||
expect(
|
||||
measured.widestChild,
|
||||
`a child is wider than the panel: ${JSON.stringify(measured)}`,
|
||||
).toBeLessThanOrEqual(measured.panelClient + 1);
|
||||
});
|
||||
});
|
||||
@@ -97,6 +97,115 @@ const COINCIDENT_FIRED = {
|
||||
reason: null,
|
||||
};
|
||||
|
||||
/**
|
||||
* A populated Health Score report for the hero band's score column.
|
||||
*
|
||||
* The panel is the widest-content block in the band: a fixed-width label
|
||||
* column, a fill bar, a value column and a popover trigger all on one 20 px
|
||||
* line, plus a coalesced failed-read line that carries an inline button. An
|
||||
* empty score leaves the column unmounted entirely, so without this the
|
||||
* phone-width guard measured a band that had no panel in it.
|
||||
*
|
||||
* Every state the panel can paint is present at once: scored rows, a failed
|
||||
* read with its retry, safety signposting, and counted absence.
|
||||
*/
|
||||
function scorePillar(id: string, domain: string, score: number) {
|
||||
return {
|
||||
id,
|
||||
domain,
|
||||
result: {
|
||||
status: "ok" as const,
|
||||
value: {
|
||||
score,
|
||||
observed: {
|
||||
value: 126,
|
||||
unit: "mmHg",
|
||||
label: "126/78 mmHg",
|
||||
asOf: NOW,
|
||||
sources: ["MANUAL"],
|
||||
},
|
||||
reference: {
|
||||
kind: "clinical-threshold" as const,
|
||||
low: 120,
|
||||
high: 129,
|
||||
label: "120 to 129/70 to 79 mmHg",
|
||||
source: "ESH 2023",
|
||||
},
|
||||
noiseFloor: 1,
|
||||
deltaEligible: true,
|
||||
deltaIdentity: id,
|
||||
},
|
||||
coverage: coverage(),
|
||||
confidence: confidence(),
|
||||
provenance: provenance([id]),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function gatedPillar(id: string, domain: string, reason: string) {
|
||||
return {
|
||||
id,
|
||||
domain,
|
||||
result: {
|
||||
status: "insufficient" as const,
|
||||
coverage: {
|
||||
requiredInputs: 4,
|
||||
presentInputs: 0,
|
||||
historyDays: 0,
|
||||
missing: [] as string[],
|
||||
},
|
||||
provenance: provenance([id]),
|
||||
reason,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const HEALTH_SCORE = {
|
||||
composite: {
|
||||
status: "ok" as const,
|
||||
value: {
|
||||
score: 74,
|
||||
band: "green" as const,
|
||||
bandSetter: "SLEEP",
|
||||
composition: ["BLOOD_PRESSURE", "ACTIVITY", "SLEEP", "ADIPOSITY"],
|
||||
noiseFloor: 3,
|
||||
scoreVersion: 2,
|
||||
},
|
||||
coverage: coverage(),
|
||||
confidence: confidence(),
|
||||
provenance: provenance(["BLOOD_PRESSURE", "ACTIVITY", "SLEEP"]),
|
||||
},
|
||||
pillars: [
|
||||
scorePillar("BLOOD_PRESSURE", "cardiometabolic", 56),
|
||||
scorePillar("ACTIVITY", "activity", 88),
|
||||
scorePillar("SLEEP", "sleep", 41),
|
||||
scorePillar("ADIPOSITY", "adiposity", 72),
|
||||
gatedPillar("FITNESS", "fitness", "read_failed"),
|
||||
gatedPillar("LIPIDS", "cardiometabolic", "read_failed"),
|
||||
gatedPillar("WELLBEING", "wellbeing", "crisis_signposting"),
|
||||
gatedPillar("GLYCAEMIA", "cardiometabolic", "not_tracked"),
|
||||
],
|
||||
delta: 2,
|
||||
deltaReason: null,
|
||||
scoreVersion: 2,
|
||||
weightGoal: {
|
||||
status: "ok" as const,
|
||||
value: {
|
||||
currentKg: 81,
|
||||
target: { min: 74, max: 78 },
|
||||
distanceKg: 3,
|
||||
deltaKg: 1,
|
||||
asOf: NOW,
|
||||
source: "MANUAL",
|
||||
},
|
||||
coverage: coverage(),
|
||||
confidence: confidence(),
|
||||
provenance: provenance(["WEIGHT"]),
|
||||
},
|
||||
algorithmNotice: null,
|
||||
restMode: null,
|
||||
};
|
||||
|
||||
/** A deterministic sparkline series around `center`. */
|
||||
function series(center: number, spread: number): number[] {
|
||||
return Array.from({ length: 30 }, (_, i) =>
|
||||
@@ -253,6 +362,28 @@ export async function mockPopulatedInsights(page: Page): Promise<void> {
|
||||
}),
|
||||
);
|
||||
|
||||
// The hero band's score column. The real response is fetched and the score
|
||||
// injected into it, so every OTHER field the page reads off `/api/analytics`
|
||||
// stays authentic and this fixture cannot silently blank a section it was
|
||||
// never meant to touch.
|
||||
await page.route(/\/api\/analytics(\?|$)/, async (route) => {
|
||||
const response = await route.fetch();
|
||||
let payload: { data?: Record<string, unknown> | null; error?: unknown };
|
||||
try {
|
||||
payload = await response.json();
|
||||
} catch {
|
||||
payload = { data: {}, error: null };
|
||||
}
|
||||
await route.fulfill({
|
||||
response,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
...payload,
|
||||
data: { ...(payload.data ?? {}), healthScore: HEALTH_SCORE },
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
// The single-metric derived route. Only COINCIDENT_DEVIATION is stubbed —
|
||||
// it is the card whose heading carries the provenance sentence. Any other
|
||||
// metric falls through to the real handler.
|
||||
|
||||
@@ -174,6 +174,11 @@ const PINNED_AFFORDANCES: Record<
|
||||
"src/components/insights/confidence-badge.ts": { "text-success": 1 },
|
||||
"src/components/insights/daily-briefing.tsx": { "text-success": 1 },
|
||||
"src/components/insights/derived/band-tokens.ts": { "text-success": 1 },
|
||||
// The green `+N` chip beside the Health Score label. It reports a week that
|
||||
// went up, not an action that succeeded, and it wears the same band token
|
||||
// the score's own number does — there is no outcome here to route through
|
||||
// the outcome module.
|
||||
"src/components/insights/health-score-card.tsx": { "text-success": 1 },
|
||||
"src/components/insights/insights-edit-mode.tsx": { "toast.success": 2 },
|
||||
"src/components/insights/insights-tab-strip.tsx": { "toast.success": 1 },
|
||||
"src/components/insights/mood/mood-better-days.tsx": { "text-success": 1 },
|
||||
|
||||
@@ -268,42 +268,55 @@ describe("v1.16.8 — insights overview cold paint", () => {
|
||||
expect(src).not.toMatch(/useDashboardDerived\([^)]*data/);
|
||||
});
|
||||
|
||||
it("reserves the health-score card's footprint while the payload is pending", () => {
|
||||
it("reserves the score column's footprint while the payload is pending", () => {
|
||||
const src = load(INSIGHTS_PATH);
|
||||
// The pinned slot holds the collapsed card's footprint from first paint,
|
||||
// so the resolved card is a swap rather than a push.
|
||||
// The reserve holds the column from first paint, so the resolved panel is
|
||||
// a swap rather than a push.
|
||||
expect(src).toMatch(
|
||||
/analyticsQuery\.isPending && !analytics \? \(\s*<HealthScoreCardSkeleton/,
|
||||
/scorePending=\{analyticsQuery\.isPending && !analytics\}/,
|
||||
);
|
||||
});
|
||||
|
||||
it("pins the health score under the hero, outside the section registry", () => {
|
||||
it("mounts the health score once, in the hero, outside the section registry", () => {
|
||||
const src = load(INSIGHTS_PATH);
|
||||
// An id a saved layout has never seen merges in as default-INVISIBLE, so
|
||||
// registering the score would delete it for every customised page. It is
|
||||
// also no longer inside the briefing branch, which used to take the whole
|
||||
// also outside the briefing branch, which used to take the whole
|
||||
// breakdown down with the flag.
|
||||
const hero = src.indexOf("<HeroStrip");
|
||||
const score = src.indexOf("<HealthScoreCard");
|
||||
const region = src.indexOf("orderedSectionIds.map");
|
||||
// The JSX mount, not the `<HeroStrip>` mentioned in the file's header
|
||||
// comment — matching the comment would have made the slice below read a
|
||||
// block of prose and pass on nothing.
|
||||
const hero = src.search(/\n\s*<HeroStrip\n/);
|
||||
expect(hero).toBeGreaterThan(-1);
|
||||
expect(score).toBeGreaterThan(hero);
|
||||
expect(score).toBeLessThan(region);
|
||||
expect(src).not.toMatch(/"health-score":/);
|
||||
|
||||
// Exactly one score surface on the page, and it is the hero's prop.
|
||||
expect(src.match(/healthScore=\{/g) ?? []).toHaveLength(1);
|
||||
expect(src).not.toContain("<HealthScoreCard");
|
||||
expect(src).not.toContain("<HealthScoreCardSkeleton");
|
||||
|
||||
const briefingBranch = src.slice(
|
||||
src.indexOf('"daily-briefing": flags.briefing'),
|
||||
src.indexOf("vitals: <VitalsDashboard"),
|
||||
);
|
||||
expect(briefingBranch).not.toContain("HealthScoreCard");
|
||||
expect(briefingBranch).not.toContain("healthScore");
|
||||
// The band itself is not gated on the briefing flag either.
|
||||
const heroCall = src.slice(hero, src.indexOf("/>", hero));
|
||||
expect(heroCall).toContain("healthScore={analytics?.healthScore ?? null}");
|
||||
expect(heroCall).not.toContain("flags.briefing");
|
||||
});
|
||||
|
||||
it("hero strip carries no score surface at all", () => {
|
||||
it("hero strip owns the one score surface", () => {
|
||||
const heroSrc = readFileSync(
|
||||
join(ROOT, "src/components/insights/hero-strip.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
expect(heroSrc).not.toContain("HealthScoreCard");
|
||||
expect(heroSrc).not.toContain("health-score-card-skeleton");
|
||||
expect(heroSrc).not.toContain("healthScorePending");
|
||||
expect(heroSrc).toContain("<HealthScoreCard");
|
||||
expect(heroSrc).toContain("<HealthScoreCardSkeleton");
|
||||
// The split appears only when the column has something in it.
|
||||
expect(heroSrc).toContain("md:flex-row md:items-stretch");
|
||||
expect(heroSrc).toMatch(
|
||||
/\(healthScore \|\| scorePending\) &&\s*"md:flex-row/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -26,10 +26,6 @@ import { QueryErrorCard } from "@/components/ui/query-error-card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { HeroStrip } from "@/components/insights/hero-strip";
|
||||
import {
|
||||
HealthScoreCard,
|
||||
HealthScoreCardSkeleton,
|
||||
} from "@/components/insights/health-score-card";
|
||||
import { useInsightsAdvisorQuery } from "@/components/insights/use-insights-advisor";
|
||||
import { useAnalyticsQuery } from "@/lib/queries/use-analytics-query";
|
||||
import { useDashboardSnapshot } from "@/lib/queries/use-dashboard-snapshot";
|
||||
@@ -561,33 +557,27 @@ export default function InsightsPageClient() {
|
||||
// <relative>" line with a discreet connect-provider hint, matching
|
||||
// the briefing card footer. Only when a briefing is actually shown.
|
||||
noProviderStale={briefingPayload !== null && !advisor.hasProvider}
|
||||
// The Health Score lives in the band's right column and NOWHERE else
|
||||
// on this page. It is deliberately not a registry section: an id a
|
||||
// saved layout has never seen merges in as default-INVISIBLE
|
||||
// (`orderedVisibleSectionIds`), so registering it would silently
|
||||
// delete the score for every account that has ever customised this
|
||||
// page. It is also outside the daily-briefing branch, so an instance
|
||||
// with the briefing switched off keeps the whole breakdown.
|
||||
healthScore={analytics?.healthScore ?? null}
|
||||
// `isPending` settles false on success AND error, so a no-score
|
||||
// account drops the reserve exactly once.
|
||||
scorePending={analyticsQuery.isPending && !analytics}
|
||||
// v1.21.2 (A5 / A6) — server-resolved Tension Verdict + return-to-
|
||||
// baseline off the dashboard snapshot. The panel localises the keys;
|
||||
// null leaves it quiet.
|
||||
tension={heroTension}
|
||||
returnToBand={heroReturnToBand}
|
||||
// A pillar whose read FAILED is a named line inside the panel, and the
|
||||
// retry there refetches the payload that carries every pillar.
|
||||
onScoreRetry={() => void analyticsQuery.refetch()}
|
||||
/>
|
||||
|
||||
{/* The Health Score is PINNED here, between the greeting and the
|
||||
customizable region, and is deliberately NOT a registry section: an
|
||||
id a saved layout has never seen merges in as default-INVISIBLE
|
||||
(`orderedVisibleSectionIds`), so registering it would silently delete
|
||||
the score for every account that has ever customised this page. It
|
||||
also used to render inside the daily-briefing branch, which meant an
|
||||
instance with the briefing switched off lost the whole breakdown.
|
||||
`isPending` settles false on success AND error, so a no-score account
|
||||
drops the reserve exactly once. */}
|
||||
{analyticsQuery.isPending && !analytics ? (
|
||||
<HealthScoreCardSkeleton />
|
||||
) : analytics?.healthScore ? (
|
||||
<HealthScoreCard
|
||||
report={analytics.healthScore}
|
||||
// v1.21.2 (A5 / A6) — server-resolved Tension Verdict + return-to-
|
||||
// baseline off the dashboard snapshot. The card localises the keys;
|
||||
// null leaves it quiet.
|
||||
tension={heroTension}
|
||||
returnToBand={heroReturnToBand}
|
||||
// A pillar whose read FAILED is an error row inside the card, and a
|
||||
// retry there refetches the payload that carries every pillar.
|
||||
onRetry={() => void analyticsQuery.refetch()}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{/* v1.15.18 — the inline "Anpassen" toggle was removed. Customising the
|
||||
overview (section show/hide + order) and sorting the nav pills now
|
||||
live ONLY in Settings → Insights, reached via the top-right cog on the
|
||||
|
||||
@@ -0,0 +1,488 @@
|
||||
import { createRequire } from "node:module";
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import type { Browser, Page } from "@playwright/test";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { locales, type Locale } from "@/lib/i18n/config";
|
||||
import type {
|
||||
HealthScoreReport,
|
||||
ScorePillarResult,
|
||||
} from "@/lib/analytics/score/types";
|
||||
import { HeroStrip } from "../hero-strip";
|
||||
|
||||
/**
|
||||
* The hero band's geometry, measured rather than asserted from source.
|
||||
*
|
||||
* Two numbers in `health-score-card.tsx` cannot be checked by reading the
|
||||
* file: the height the loading reserve holds, and the width of the row's
|
||||
* label column. Both were carried for a while as docblock claims with no way
|
||||
* to reproduce them. This file is that way: it renders the REAL band with the
|
||||
* REAL compiled stylesheet, lays it out in a browser at the real column
|
||||
* width, and reads the boxes back.
|
||||
*
|
||||
* What it does NOT do is pretend to know the shipped typeface. The app loads
|
||||
* Inter through `next/font/google` at build time; no font file is committed,
|
||||
* so a headless browser here resolves the `font-sans` fallback chain instead.
|
||||
* Every assertion below is therefore written to be font-independent — a
|
||||
* relative comparison inside one run, or a structural property (does the row
|
||||
* push, does the label ellipsise, is the bar still there) that holds in any
|
||||
* typeface. The absolute pixel numbers this file prints are true of the run
|
||||
* that printed them and are reported as such, never baked into a comment as
|
||||
* if they were a property of the design.
|
||||
*
|
||||
* It needs a Chromium build. `pnpm test` on a machine that has one (any
|
||||
* machine that has run `pnpm e2e`) measures for real; a machine without one
|
||||
* skips loudly rather than passing quietly. The unit CI job installs no
|
||||
* browsers, so treat this as a check for the person editing the panel and for
|
||||
* the release audit, not as the gate.
|
||||
*/
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({ user: { unitPreference: "metric", glucoseUnit: "mg/dL" } }),
|
||||
}));
|
||||
|
||||
/** The `md` column: `md:basis-[22rem]`, i.e. 352 CSS px. */
|
||||
const COLUMN_PX = 352;
|
||||
/** Wide enough for the two-column split, narrow enough to stay below `xl`. */
|
||||
const VIEWPORT_PX = 1024;
|
||||
/** One `text-xs` row is a 16 px line; two lines would exceed this. */
|
||||
const ONE_LINE_PX = 20;
|
||||
/**
|
||||
* The row's info trigger carries `-mx-2` so its 44 px tap target does not
|
||||
* inflate a 20 px row: it reaches 8 px into the card's own padding on each
|
||||
* side, which is deliberate and stays inside the panel. Anything past that is
|
||||
* a real push, so the row check allows exactly this much and no more.
|
||||
*/
|
||||
const TAP_TARGET_OVERHANG_PX = 8;
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
/**
|
||||
* Compile `globals.css` the way the app does.
|
||||
*
|
||||
* `postcss` is not a direct dependency — it arrives under the Tailwind
|
||||
* PostCSS plugin, which is. Resolving it from that plugin's own location is
|
||||
* what keeps this working under pnpm's strict layout without adding a
|
||||
* dependency for a check.
|
||||
*/
|
||||
async function compileStylesheet(): Promise<string> {
|
||||
const pluginEntry = require.resolve("@tailwindcss/postcss");
|
||||
const postcss = require(require.resolve("postcss", { paths: [pluginEntry] }));
|
||||
const tailwind = require(pluginEntry);
|
||||
const fs = await import("node:fs/promises");
|
||||
const source = await fs.readFile("src/app/globals.css", "utf8");
|
||||
const plugin = tailwind.default ? tailwind.default() : tailwind();
|
||||
const result = await postcss([plugin]).process(source, {
|
||||
from: "src/app/globals.css",
|
||||
});
|
||||
return result.css;
|
||||
}
|
||||
|
||||
const provenance = {
|
||||
inputs: ["BLOOD_PRESSURE"],
|
||||
source: "live" as const,
|
||||
windowDays: 90,
|
||||
computedAt: "2026-07-28T12:00:00.000Z",
|
||||
};
|
||||
|
||||
const coverage = {
|
||||
requiredInputs: 3,
|
||||
presentInputs: 3,
|
||||
historyDays: 28,
|
||||
missing: [] as string[],
|
||||
};
|
||||
|
||||
function scored(id: string, score: number): ScorePillarResult {
|
||||
return {
|
||||
id,
|
||||
domain: "cardiometabolic",
|
||||
result: {
|
||||
status: "ok",
|
||||
value: {
|
||||
score,
|
||||
observed: {
|
||||
value: 126,
|
||||
unit: "mmHg",
|
||||
label: "126/78 mmHg",
|
||||
asOf: "2026-07-27T08:00:00.000Z",
|
||||
sources: ["MANUAL"],
|
||||
},
|
||||
reference: {
|
||||
kind: "clinical-threshold",
|
||||
low: 120,
|
||||
high: 129,
|
||||
label: "120 to 129/70 to 79 mmHg",
|
||||
source: "ESH 2023",
|
||||
},
|
||||
noiseFloor: 1,
|
||||
deltaEligible: true,
|
||||
deltaIdentity: `pillar_${id}`,
|
||||
},
|
||||
coverage,
|
||||
confidence: { score: 100, band: "high" },
|
||||
provenance,
|
||||
},
|
||||
} as ScorePillarResult;
|
||||
}
|
||||
|
||||
/** Every pillar id, so each locale is measured on its own longest label. */
|
||||
const ALL_PILLARS = [
|
||||
"BLOOD_PRESSURE",
|
||||
"GLYCAEMIA",
|
||||
"ACTIVITY",
|
||||
"SLEEP",
|
||||
"ADIPOSITY",
|
||||
"WELLBEING",
|
||||
"FITNESS",
|
||||
"LIPIDS",
|
||||
] as const;
|
||||
|
||||
function reportWith(pillars: ScorePillarResult[]): HealthScoreReport {
|
||||
return {
|
||||
composite: {
|
||||
status: "ok",
|
||||
value: {
|
||||
score: 74,
|
||||
band: "green",
|
||||
bandSetter: null,
|
||||
composition: pillars.map((p) => p.id),
|
||||
noiseFloor: 3,
|
||||
scoreVersion: 2,
|
||||
},
|
||||
coverage,
|
||||
confidence: { score: 100, band: "high" },
|
||||
provenance,
|
||||
},
|
||||
pillars,
|
||||
delta: 2,
|
||||
deltaReason: null,
|
||||
scoreVersion: 2,
|
||||
weightGoal: {
|
||||
status: "insufficient",
|
||||
coverage: {
|
||||
requiredInputs: 2,
|
||||
presentInputs: 0,
|
||||
historyDays: 0,
|
||||
missing: [],
|
||||
},
|
||||
provenance: { ...provenance, inputs: ["WEIGHT"] },
|
||||
reason: "no_personal_goal",
|
||||
},
|
||||
algorithmNotice: null,
|
||||
} as HealthScoreReport;
|
||||
}
|
||||
|
||||
/** N scored rows, the shape the report actually produces (3 to 6, per §5). */
|
||||
function reportWithRows(rows: number): HealthScoreReport {
|
||||
return reportWith(
|
||||
ALL_PILLARS.slice(0, rows).map((id, i) => scored(id, 80 - i * 4)),
|
||||
);
|
||||
}
|
||||
|
||||
function band(node: React.ReactNode, locale: Locale = "en"): string {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
let browser: Browser | null = null;
|
||||
let page: Page | null = null;
|
||||
let stylesheet = "";
|
||||
let skipReason: string | null = null;
|
||||
|
||||
beforeAll(async () => {
|
||||
try {
|
||||
const { chromium } = await import("@playwright/test");
|
||||
browser = await chromium.launch();
|
||||
} catch (error) {
|
||||
skipReason = `no Chromium build available (${String(error).slice(0, 120)})`;
|
||||
// Loud on purpose: a geometry check that quietly skips is the same thing
|
||||
// as no geometry check, and this repo has shipped that before.
|
||||
console.warn(`[hero geometry] SKIPPED — ${skipReason}`);
|
||||
return;
|
||||
}
|
||||
stylesheet = await compileStylesheet();
|
||||
page = await browser.newPage({
|
||||
viewport: { width: VIEWPORT_PX, height: 1200 },
|
||||
});
|
||||
}, 60_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await browser?.close();
|
||||
});
|
||||
|
||||
/** Lay the rendered band out and hand the page back for measuring. */
|
||||
async function layout(html: string, width = VIEWPORT_PX): Promise<Page> {
|
||||
const target = page;
|
||||
if (!target) throw new Error("browser page was never created");
|
||||
await target.setContent(
|
||||
`<!doctype html><html class="dark"><body class="font-sans bg-background" style="margin:0">
|
||||
<div style="width:${width}px">${html}</div>
|
||||
</body></html>`,
|
||||
);
|
||||
await target.addStyleTag({ content: stylesheet });
|
||||
return target;
|
||||
}
|
||||
|
||||
const PANEL = '[data-slot="health-score-card"]';
|
||||
const RESERVE = '[data-slot="health-score-card-skeleton"]';
|
||||
|
||||
describe("hero band geometry", () => {
|
||||
it("holds a reserve that sits inside the range the real report produces", async () => {
|
||||
if (skipReason) return expect(skipReason).toBeTruthy();
|
||||
|
||||
const heights: Record<string, number> = {};
|
||||
for (const rows of [3, 4, 5, 6]) {
|
||||
const view = await layout(
|
||||
band(
|
||||
<HeroStrip
|
||||
briefing={null}
|
||||
now={new Date("2026-05-10T07:00:00Z")}
|
||||
healthScore={reportWithRows(rows)}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
heights[`rows${rows}`] = await view
|
||||
.locator(PANEL)
|
||||
.evaluate((el) => Math.round(el.getBoundingClientRect().height));
|
||||
const width = await view
|
||||
.locator(PANEL)
|
||||
.evaluate((el) => Math.round(el.getBoundingClientRect().width));
|
||||
expect(width, "the panel is not on the md column width").toBe(COLUMN_PX);
|
||||
}
|
||||
|
||||
const view = await layout(
|
||||
band(
|
||||
<HeroStrip
|
||||
briefing={null}
|
||||
now={new Date("2026-05-10T07:00:00Z")}
|
||||
scorePending
|
||||
/>,
|
||||
),
|
||||
);
|
||||
// Measured twice: as it renders, and with the declared minimum removed.
|
||||
// If the two agree, the constant is decoration and the reserve is really
|
||||
// whatever its blocks happen to add up to — which is how a reserve drifts
|
||||
// without anyone touching the number that claims to own it.
|
||||
const { reserve, natural } = await view.locator(RESERVE).evaluate((el) => {
|
||||
const rendered = Math.round(el.getBoundingClientRect().height);
|
||||
const declared = el.style.minHeight;
|
||||
(el as HTMLElement).style.minHeight = "0px";
|
||||
const content = Math.round(el.getBoundingClientRect().height);
|
||||
(el as HTMLElement).style.minHeight = declared;
|
||||
return { reserve: rendered, natural: content };
|
||||
});
|
||||
|
||||
console.info(
|
||||
`[hero geometry] column ${COLUMN_PX}px · panel ${JSON.stringify(heights)} · reserve ${reserve}px (blocks alone ${natural}px)`,
|
||||
);
|
||||
|
||||
expect(
|
||||
natural,
|
||||
`the reserve's own blocks are ${natural}px, so the declared minimum never binds and the reserve is whatever the blocks happen to be`,
|
||||
).toBeLessThan(reserve);
|
||||
|
||||
// The reserve cannot match every report, so it must not be one-sided: a
|
||||
// three-row account may not be pushed down, a six-row account may not be
|
||||
// left with a gap that collapses. Between the two is the whole rule.
|
||||
expect(
|
||||
reserve,
|
||||
`reserve ${reserve}px is below a three-row panel (${heights.rows3}px): a short report gets pushed`,
|
||||
).toBeGreaterThanOrEqual(heights.rows3);
|
||||
expect(
|
||||
reserve,
|
||||
`reserve ${reserve}px is above a six-row panel (${heights.rows6}px): a long report leaves a collapsing gap`,
|
||||
).toBeLessThanOrEqual(heights.rows6);
|
||||
}, 60_000);
|
||||
|
||||
it("keeps every row on one line in all six locales", async () => {
|
||||
if (skipReason) return expect(skipReason).toBeTruthy();
|
||||
|
||||
const widest: Record<string, { label: string; natural: number }> = {};
|
||||
|
||||
for (const locale of locales) {
|
||||
const view = await layout(
|
||||
band(
|
||||
<HeroStrip
|
||||
briefing={null}
|
||||
now={new Date("2026-05-10T07:00:00Z")}
|
||||
healthScore={reportWith(
|
||||
ALL_PILLARS.map((id, i) => scored(id, 80 - i * 4)),
|
||||
)}
|
||||
/>,
|
||||
locale,
|
||||
),
|
||||
);
|
||||
|
||||
const rows = await view.locator(`${PANEL} li[data-status="ok"]`).all();
|
||||
expect(rows.length, `${locale}: no rows to measure`).toBe(
|
||||
ALL_PILLARS.length,
|
||||
);
|
||||
|
||||
let localeWidest = { label: "", natural: 0 };
|
||||
for (const row of rows) {
|
||||
const m = await row.evaluate((el) => {
|
||||
const label = el.querySelector("span");
|
||||
const bar = el.querySelector(
|
||||
'[data-slot="health-score-pillar-bar"]',
|
||||
)?.parentElement;
|
||||
const value = el.querySelectorAll("span")[1];
|
||||
// The natural width the label would take if nothing constrained it.
|
||||
const probe = document.createElement("span");
|
||||
probe.textContent = label?.textContent ?? "";
|
||||
probe.style.cssText =
|
||||
"position:absolute;visibility:hidden;white-space:nowrap;width:max-content";
|
||||
probe.className = label?.className ?? "";
|
||||
document.body.appendChild(probe);
|
||||
const natural = Math.ceil(probe.getBoundingClientRect().width);
|
||||
probe.remove();
|
||||
return {
|
||||
id: el.getAttribute("data-pillar") ?? "",
|
||||
text: label?.textContent ?? "",
|
||||
natural,
|
||||
labelWidth: Math.round(label?.getBoundingClientRect().width ?? 0),
|
||||
labelHeight: Math.round(label?.getBoundingClientRect().height ?? 0),
|
||||
labelOverflow: label
|
||||
? getComputedStyle(label).textOverflow
|
||||
: "none",
|
||||
barWidth: Math.round(bar?.getBoundingClientRect().width ?? 0),
|
||||
valueScroll: value?.scrollWidth ?? 0,
|
||||
valueClient: value?.clientWidth ?? 0,
|
||||
rowScroll: el.scrollWidth,
|
||||
rowClient: el.clientWidth,
|
||||
};
|
||||
});
|
||||
|
||||
// The row is the invariant, not the label: a long name may ellipsise,
|
||||
// but it may never widen the row or wrap it onto a second line. Drop
|
||||
// `min-w-0` or `truncate` from the label and the first grid track
|
||||
// grows to the longest word instead, which is exactly this.
|
||||
expect(
|
||||
m.rowScroll,
|
||||
`${locale}/${m.id}: the row is pushed wider than its column by "${m.text}"`,
|
||||
).toBeLessThanOrEqual(m.rowClient + TAP_TARGET_OVERHANG_PX);
|
||||
expect(
|
||||
m.labelWidth,
|
||||
`${locale}/${m.id}: the label track grew past 7rem for "${m.text}"`,
|
||||
).toBeLessThanOrEqual(112 + 1);
|
||||
expect(
|
||||
m.labelHeight,
|
||||
`${locale}/${m.id}: "${m.text}" wrapped onto a second line`,
|
||||
).toBeLessThanOrEqual(ONE_LINE_PX);
|
||||
expect(
|
||||
m.labelOverflow,
|
||||
`${locale}/${m.id}: "${m.text}" is cut without an ellipsis`,
|
||||
).toBe("ellipsis");
|
||||
// The label column may not eat the two things the row exists to show.
|
||||
expect(
|
||||
m.barWidth,
|
||||
`${locale}/${m.id}: the fill bar was squeezed out by the label column`,
|
||||
).toBeGreaterThan(24);
|
||||
expect(
|
||||
m.valueScroll,
|
||||
`${locale}/${m.id}: the score itself is truncated`,
|
||||
).toBeLessThanOrEqual(m.valueClient + 1);
|
||||
|
||||
if (m.natural > localeWidest.natural) {
|
||||
localeWidest = { label: m.text, natural: m.natural };
|
||||
}
|
||||
}
|
||||
widest[locale] = localeWidest;
|
||||
}
|
||||
|
||||
// Reported, never asserted: these widths are the measuring browser's
|
||||
// fallback typeface, not the shipped Inter, so they say how much room the
|
||||
// longest name wants in THIS run — not a pixel budget the design owns.
|
||||
console.info(
|
||||
`[hero geometry] longest label per locale against a 112px column: ${Object.entries(
|
||||
widest,
|
||||
)
|
||||
.map(([l, w]) => `${l} "${w.label}" ${w.natural}px`)
|
||||
.join(" · ")}`,
|
||||
);
|
||||
}, 60_000);
|
||||
|
||||
it.each([
|
||||
{ width: VIEWPORT_PX, where: "the md column" },
|
||||
{ width: 360, where: "a 360px phone, stacked under the greeting" },
|
||||
])(
|
||||
"never scrolls sideways in $where",
|
||||
async ({ width }) => {
|
||||
if (skipReason) return expect(skipReason).toBeTruthy();
|
||||
|
||||
const target = page!;
|
||||
await target.setViewportSize({ width, height: 1200 });
|
||||
// French carries the longest pillar names of the six, so it is the
|
||||
// width-worst case rather than an arbitrary pick.
|
||||
const view = await layout(
|
||||
band(
|
||||
<HeroStrip
|
||||
briefing={null}
|
||||
now={new Date("2026-05-10T07:00:00Z")}
|
||||
healthScore={reportWith(
|
||||
ALL_PILLARS.map((id, i) => scored(id, 80 - i * 4)),
|
||||
)}
|
||||
/>,
|
||||
"fr",
|
||||
),
|
||||
width,
|
||||
);
|
||||
|
||||
const m = await view.locator(PANEL).evaluate((el) => {
|
||||
let widest = 0;
|
||||
let culprit = "";
|
||||
for (const child of Array.from(el.querySelectorAll("*"))) {
|
||||
const w = child.getBoundingClientRect().width;
|
||||
if (w > widest) {
|
||||
widest = w;
|
||||
culprit = child.getAttribute("data-slot") ?? child.className;
|
||||
}
|
||||
}
|
||||
const strip = el.closest('[data-slot="insights-hero-strip"]')!;
|
||||
const stripBox = getComputedStyle(strip);
|
||||
return {
|
||||
scroll: el.scrollWidth,
|
||||
client: el.clientWidth,
|
||||
width: Math.round(el.getBoundingClientRect().width),
|
||||
widest: Math.round(widest),
|
||||
culprit: String(culprit).slice(0, 60),
|
||||
// The width the band actually offers, padding taken off.
|
||||
available: Math.round(
|
||||
strip.getBoundingClientRect().width -
|
||||
parseFloat(stripBox.paddingLeft) -
|
||||
parseFloat(stripBox.paddingRight),
|
||||
),
|
||||
docScroll: document.documentElement.scrollWidth,
|
||||
docClient: document.documentElement.clientWidth,
|
||||
};
|
||||
});
|
||||
|
||||
expect(
|
||||
m.scroll,
|
||||
`the panel scrolls its own content: ${JSON.stringify(m)}`,
|
||||
).toBeLessThanOrEqual(m.client + 1);
|
||||
expect(
|
||||
m.widest,
|
||||
`a child is wider than the panel: ${JSON.stringify(m)}`,
|
||||
).toBeLessThanOrEqual(m.client + 1);
|
||||
// The panel against the room the band gives it. This is the assertion
|
||||
// that has to carry the phone case: the band wears `overflow-hidden`
|
||||
// for its glow, so a panel wider than the band is silently CLIPPED
|
||||
// rather than scrolled, and the document check below stays green while
|
||||
// a person loses the right-hand end of every row.
|
||||
expect(
|
||||
m.width,
|
||||
`the panel is wider than the band gives it: ${JSON.stringify(m)}`,
|
||||
).toBeLessThanOrEqual(m.available + 1);
|
||||
// Kept anyway, for anything that escapes the band's clip.
|
||||
expect(
|
||||
m.docScroll,
|
||||
`the page scrolls sideways: ${JSON.stringify(m)}`,
|
||||
).toBeLessThanOrEqual(m.docClient + 1);
|
||||
|
||||
await target.setViewportSize({ width: VIEWPORT_PX, height: 1200 });
|
||||
},
|
||||
60_000,
|
||||
);
|
||||
});
|
||||
@@ -165,6 +165,20 @@ const FASTING_GLUCOSE: ScorePillarResult = {
|
||||
},
|
||||
};
|
||||
|
||||
/** A scored pillar at an arbitrary score, for the partition + tint tests. */
|
||||
function scoredAt(
|
||||
id: ScorePillarResult["id"],
|
||||
score: number,
|
||||
): ScorePillarResult {
|
||||
const base = BLOOD_PRESSURE.result;
|
||||
if (base.status !== "ok") throw new Error("fixture is not scored");
|
||||
return {
|
||||
...BLOOD_PRESSURE,
|
||||
id,
|
||||
result: { ...base, value: { ...base.value, score } },
|
||||
};
|
||||
}
|
||||
|
||||
function gatedPillar(
|
||||
id: ScorePillarResult["id"],
|
||||
reason: string,
|
||||
@@ -253,6 +267,35 @@ function anatomyRegion(html: string): string {
|
||||
return html.slice(start);
|
||||
}
|
||||
|
||||
/** Everything visible without opening the disclosure. */
|
||||
function atRest(html: string): string {
|
||||
const end = html.indexOf('data-slot="health-score-anatomy-region"');
|
||||
expect(end).toBeGreaterThan(-1);
|
||||
return html.slice(0, end);
|
||||
}
|
||||
|
||||
/** The named not-scored list itself, not merely the region containing it. */
|
||||
function notScoredList(html: string): string {
|
||||
const start = html.indexOf('data-slot="health-score-not-scored"');
|
||||
expect(start).toBeGreaterThan(-1);
|
||||
const end = html.indexOf("</ul>", start);
|
||||
expect(end).toBeGreaterThan(start);
|
||||
return html.slice(start, end);
|
||||
}
|
||||
|
||||
/** Every `data-pillar="X"` in document order — the plural attribute never matches. */
|
||||
function pillarAttrs(html: string): string[] {
|
||||
return [...html.matchAll(/data-pillar="([^"]+)"/g)].map((m) => m[1]);
|
||||
}
|
||||
|
||||
/** The ids a coalesced line declares it speaks for. */
|
||||
function pillarsAttr(html: string, slot: string): string[] {
|
||||
const m = html.match(
|
||||
new RegExp(`data-slot="${slot}"\\s+data-pillars="([^"]*)"`),
|
||||
);
|
||||
return m && m[1] ? m[1].split(",") : [];
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockAuthUser = { unitPreference: "metric", glucoseUnit: "mg/dL" };
|
||||
});
|
||||
@@ -268,42 +311,107 @@ describe("<HealthScoreCard> disclosure", () => {
|
||||
const controls = html.match(/aria-controls="([^"]+)"/);
|
||||
expect(controls).not.toBeNull();
|
||||
expect(html).toContain(`id="${controls![1]}" hidden=""`);
|
||||
expect(anatomyRegion(html)).toContain('data-slot="health-score-pillars"');
|
||||
expect(anatomyRegion(html)).toContain('data-slot="health-score-method"');
|
||||
});
|
||||
|
||||
it("keeps the score face outside the disclosure", () => {
|
||||
it("keeps the number and the contributor rows out of the disclosure", () => {
|
||||
const html = render(<HealthScoreCard report={scoredReport()} />);
|
||||
const face = html.slice(
|
||||
0,
|
||||
html.indexOf('data-slot="health-score-anatomy-toggle"'),
|
||||
);
|
||||
expect(face).toContain('data-slot="health-score-band"');
|
||||
expect(face).toContain('data-slot="health-score-delta"');
|
||||
expect(face).toContain(">86<");
|
||||
// The pillar grid and the method footer belong to the opened region only.
|
||||
expect(face).not.toContain('data-slot="health-score-pillars"');
|
||||
expect(face).not.toContain('data-slot="health-score-method"');
|
||||
const rest = atRest(html);
|
||||
// The number, the bar, the band sentence and the delta are the panel's
|
||||
// face; the rows sit directly under them, not behind a toggle.
|
||||
expect(rest).toContain('data-slot="health-score-card-number"');
|
||||
expect(rest).toContain('data-slot="health-score-card-progress"');
|
||||
expect(rest).toContain('data-slot="health-score-band"');
|
||||
expect(rest).toContain('data-slot="health-score-delta"');
|
||||
expect(rest).toContain(">86<");
|
||||
expect(rest).toContain('data-slot="health-score-pillars"');
|
||||
// The named reasons, the weight goal and the method footer are what the
|
||||
// disclosure is for.
|
||||
expect(rest).not.toContain('data-slot="health-score-method"');
|
||||
expect(rest).not.toContain('data-slot="health-score-weight-goal"');
|
||||
expect(rest).not.toContain('data-slot="health-score-not-scored"');
|
||||
});
|
||||
|
||||
it("wears the hero column's width and stretches to the band", () => {
|
||||
const html = render(<HealthScoreCard report={scoredReport()} />);
|
||||
// The panel owns its own column geometry, so the band's `items-stretch`
|
||||
// can equalise it against the greeting.
|
||||
expect(html).toContain("md:basis-[22rem]");
|
||||
expect(html).toContain("xl:basis-[26rem]");
|
||||
expect(html).toContain("md:shrink-0");
|
||||
expect(html).toContain("flex h-full flex-col");
|
||||
});
|
||||
});
|
||||
|
||||
describe("<HealthScoreCard> pillars", () => {
|
||||
it("grids the scored pillars only and never draws a gated one as a cell", () => {
|
||||
it("gives a row to the scored pillars only, never to a gated one", () => {
|
||||
const html = render(<HealthScoreCard report={scoredReport()} />);
|
||||
const region = anatomyRegion(html);
|
||||
expect(region).toContain("sm:grid-cols-2");
|
||||
expect(region).toContain("lg:grid-cols-3");
|
||||
expect(region).toContain('data-pillar="BLOOD_PRESSURE" data-status="ok"');
|
||||
expect(region).not.toContain('data-pillar="ACTIVITY" data-status="ok"');
|
||||
// Blood pressure scores 82, so its impact bar is band-green at 82%.
|
||||
expect(region).toContain("width:82%");
|
||||
expect(region).toContain("bg-success");
|
||||
const rest = atRest(html);
|
||||
expect(rest).toContain('data-pillar="BLOOD_PRESSURE" data-status="ok"');
|
||||
expect(rest).not.toContain('data-pillar="ACTIVITY" data-status="ok"');
|
||||
// One line per row: a truncating label column, a thin bar, the score.
|
||||
expect(rest).toContain("grid-cols-[minmax(0,7rem)_1fr_2rem_auto]");
|
||||
expect(rest).toContain("width:82%");
|
||||
});
|
||||
|
||||
it("names every gated pillar with its reason instead of scoring it zero", () => {
|
||||
it("tints each row with its OWN band, not the composite's", () => {
|
||||
// The composite is red and the two pillars sit either side of the green
|
||||
// threshold. The old rows took the composite's colour and painted the
|
||||
// strong pillar red.
|
||||
const html = render(
|
||||
<HealthScoreCard
|
||||
report={scoredReport({
|
||||
composite: {
|
||||
status: "ok",
|
||||
value: {
|
||||
score: 31,
|
||||
band: "red",
|
||||
bandSetter: null,
|
||||
composition: ["BLOOD_PRESSURE", "GLYCAEMIA"],
|
||||
noiseFloor: 3,
|
||||
scoreVersion: 2,
|
||||
},
|
||||
coverage: {
|
||||
requiredInputs: 3,
|
||||
presentInputs: 3,
|
||||
historyDays: 28,
|
||||
missing: [],
|
||||
},
|
||||
confidence: { score: 100, band: "high" },
|
||||
provenance,
|
||||
},
|
||||
pillars: [
|
||||
scoredAt("BLOOD_PRESSURE", 92),
|
||||
scoredAt("GLYCAEMIA", 22),
|
||||
scoredAt("SLEEP", 55),
|
||||
],
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
const rows = atRest(html).split('data-pillar="');
|
||||
const barClass = (id: string) => {
|
||||
const row = rows.find((r) => r.startsWith(id));
|
||||
expect(row, `${id} has no row`).toBeDefined();
|
||||
const m = row!.match(
|
||||
/data-slot="health-score-pillar-bar" class="([^"]*)"/,
|
||||
);
|
||||
expect(m, `${id} has no bar`).not.toBeNull();
|
||||
return m![1];
|
||||
};
|
||||
expect(barClass("BLOOD_PRESSURE")).toContain("bg-success");
|
||||
expect(barClass("GLYCAEMIA")).toContain("bg-destructive");
|
||||
expect(barClass("SLEEP")).toContain("bg-warning");
|
||||
// The composite is red, so a composite-tinted set would be all-red.
|
||||
expect(barClass("BLOOD_PRESSURE")).not.toContain("bg-destructive");
|
||||
});
|
||||
|
||||
it("counts absence at rest and names it behind the disclosure", () => {
|
||||
const html = render(<HealthScoreCard report={scoredReport()} />);
|
||||
// At rest: a count, never a name drawn as a zero.
|
||||
expect(atRest(html)).toContain("Not scored yet (1)");
|
||||
expect(atRest(html)).toContain('data-count="1"');
|
||||
const region = anatomyRegion(html);
|
||||
expect(region).toContain('data-slot="health-score-not-scored"');
|
||||
expect(region).toContain("Not scored yet (1)");
|
||||
expect(region).toContain('data-reason="below_day_floor_or_stale"');
|
||||
expect(region).toContain("Not enough recent eligible data");
|
||||
expect(region).not.toContain("below_day_floor_or_stale<");
|
||||
@@ -323,17 +431,52 @@ describe("<HealthScoreCard> pillars", () => {
|
||||
onRetry={retry}
|
||||
/>,
|
||||
);
|
||||
const region = anatomyRegion(html);
|
||||
expect(region).toContain('data-slot="health-score-pillar-error"');
|
||||
expect(region).toContain('role="alert"');
|
||||
expect(region).toContain('data-slot="health-score-pillar-retry"');
|
||||
expect(region).toContain("Data could not be loaded");
|
||||
// A failure may be quiet, but it may not hide: the line and its retry are
|
||||
// visible without opening anything.
|
||||
const rest = atRest(html);
|
||||
expect(rest).toContain('data-slot="health-score-pillar-error"');
|
||||
expect(rest).toContain('role="alert"');
|
||||
expect(rest).toContain('data-slot="health-score-pillar-retry"');
|
||||
expect(rest).toContain("Data could not be loaded");
|
||||
expect(rest).toContain("Not scored yet (1)");
|
||||
|
||||
// A failed read is NOT absence, so it stays out of the "not scored" list.
|
||||
expect(region).toContain("Not scored yet (1)");
|
||||
const region = anatomyRegion(html);
|
||||
expect(region).not.toContain('data-reason="read_failed"');
|
||||
expect(notScoredList(html)).not.toContain('data-reason="read_failed"');
|
||||
expect(notScoredList(html)).not.toContain('data-pillar="SLEEP"');
|
||||
});
|
||||
|
||||
it("leads the gated list with safety signposting", () => {
|
||||
it("coalesces every failed read into one line with one retry", () => {
|
||||
// Lipids and HbA1c come off one labs read, so a single failure gates two
|
||||
// pillars. One cause, one line, one retry — and the retry refetches the
|
||||
// whole payload anyway.
|
||||
const html = render(
|
||||
<HealthScoreCard
|
||||
report={scoredReport({
|
||||
pillars: [
|
||||
BLOOD_PRESSURE,
|
||||
gatedPillar("LIPIDS", "read_failed"),
|
||||
gatedPillar("GLYCAEMIA", "read_failed"),
|
||||
],
|
||||
})}
|
||||
onRetry={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
html.match(/data-slot="health-score-pillar-error"/g) ?? [],
|
||||
).toHaveLength(1);
|
||||
expect(
|
||||
html.match(/data-slot="health-score-pillar-retry"/g) ?? [],
|
||||
).toHaveLength(1);
|
||||
expect(pillarsAttr(html, "health-score-pillar-error")).toEqual([
|
||||
"LIPIDS",
|
||||
"GLYCAEMIA",
|
||||
]);
|
||||
expect(atRest(html)).toContain("Lipids, Glycaemia: Data could not be");
|
||||
});
|
||||
|
||||
it("names safety signposting at rest instead of counting it", () => {
|
||||
const html = render(
|
||||
<HealthScoreCard
|
||||
report={scoredReport({
|
||||
@@ -345,18 +488,108 @@ describe("<HealthScoreCard> pillars", () => {
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
const region = anatomyRegion(html);
|
||||
expect(region).toContain("Not scored yet (2)");
|
||||
const crisis = region.indexOf('data-reason="crisis_signposting"');
|
||||
const activity = region.indexOf('data-reason="below_day_floor_or_stale"');
|
||||
expect(crisis).toBeGreaterThan(-1);
|
||||
expect(crisis).toBeLessThan(activity);
|
||||
expect(region).toContain("Safety guidance is shown instead of a score");
|
||||
// Safety copy is content: it is named in full on the surface, not folded
|
||||
// into a count and not hidden behind the toggle.
|
||||
const rest = atRest(html);
|
||||
expect(rest).toContain('data-slot="health-score-crisis"');
|
||||
expect(rest).toContain("Wellbeing: Safety guidance is shown instead of");
|
||||
// …and therefore it is not also one of the anonymous counted absences.
|
||||
expect(rest).toContain("Not scored yet (1)");
|
||||
expect(rest).toContain('data-count="1"');
|
||||
expect(anatomyRegion(html)).not.toContain(
|
||||
'data-reason="crisis_signposting"',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("<HealthScoreCard> one pillar, one home", () => {
|
||||
/** Four scored, one failed read, one crisis, two plain absences. */
|
||||
function mixedReport() {
|
||||
return scoredReport({
|
||||
pillars: [
|
||||
scoredAt("BLOOD_PRESSURE", 82),
|
||||
scoredAt("GLYCAEMIA", 74),
|
||||
scoredAt("ACTIVITY", 61),
|
||||
scoredAt("SLEEP", 45),
|
||||
gatedPillar("FITNESS", "read_failed"),
|
||||
gatedPillar("WELLBEING", "crisis_signposting"),
|
||||
gatedPillar("ADIPOSITY", "missing_height"),
|
||||
gatedPillar("LIPIDS", "not_tracked"),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
it("puts every pillar in exactly one of the four homes", () => {
|
||||
const html = render(<HealthScoreCard report={mixedReport()} />);
|
||||
const rest = atRest(html);
|
||||
|
||||
const rowIds = [
|
||||
...rest.matchAll(/data-pillar="([^"]+)" data-status="ok"/g),
|
||||
].map((m) => m[1]);
|
||||
const failedIds = pillarsAttr(html, "health-score-pillar-error");
|
||||
const crisisIds = pillarsAttr(html, "health-score-crisis");
|
||||
const counted = Number(rest.match(/data-count="(\d+)"/)?.[1] ?? 0);
|
||||
|
||||
expect(rowIds).toEqual([
|
||||
"BLOOD_PRESSURE",
|
||||
"GLYCAEMIA",
|
||||
"ACTIVITY",
|
||||
"SLEEP",
|
||||
]);
|
||||
expect(failedIds).toEqual(["FITNESS"]);
|
||||
expect(crisisIds).toEqual(["WELLBEING"]);
|
||||
expect(counted).toBe(2);
|
||||
|
||||
// The four homes partition the eight pillars: disjoint, and complete.
|
||||
const named = [...rowIds, ...failedIds, ...crisisIds];
|
||||
expect(new Set(named).size).toBe(named.length);
|
||||
expect(named.length + counted).toBe(8);
|
||||
});
|
||||
|
||||
it("never renders one pillar id twice anywhere in the panel", () => {
|
||||
const html = render(<HealthScoreCard report={mixedReport()} />);
|
||||
// The scored rows and the named not-scored list both carry `data-pillar`;
|
||||
// the coalesced lines carry the plural `data-pillars` and are not matched
|
||||
// here. Seeing an id twice is exactly the complaint this panel answers.
|
||||
const ids = pillarAttrs(html);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
expect(ids.sort()).toEqual(
|
||||
[
|
||||
"ACTIVITY",
|
||||
"ADIPOSITY",
|
||||
"BLOOD_PRESSURE",
|
||||
"GLYCAEMIA",
|
||||
"LIPIDS",
|
||||
"SLEEP",
|
||||
].sort(),
|
||||
);
|
||||
});
|
||||
|
||||
it("never renders one pillar's name twice anywhere in the panel", () => {
|
||||
const html = render(<HealthScoreCard report={mixedReport()} />);
|
||||
// The method footer's "Overall band set by …" sentence names a pillar as
|
||||
// prose rather than listing it, so it is not part of this count.
|
||||
const methodAt = html.indexOf('data-slot="health-score-method"');
|
||||
expect(methodAt).toBeGreaterThan(-1);
|
||||
const listed = html.slice(0, methodAt);
|
||||
for (const label of [
|
||||
"Blood pressure",
|
||||
"Glycaemia",
|
||||
"Activity",
|
||||
"Sleep",
|
||||
"Adiposity",
|
||||
"Wellbeing",
|
||||
"Measured fitness",
|
||||
"Lipids",
|
||||
]) {
|
||||
const hits = listed.split(label).length - 1;
|
||||
expect(hits, `${label} appears ${hits} times`).toBeLessThanOrEqual(1);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("<HealthScoreCard> composite states", () => {
|
||||
it("shows a provisional ring and the learning gate when the composite is gated", () => {
|
||||
it("shows a dash and the learning line when the composite is gated", () => {
|
||||
const html = render(<HealthScoreCard report={report()} />);
|
||||
expect(html).toMatch(
|
||||
/data-slot="health-score-card"[^>]*data-status="insufficient"/,
|
||||
@@ -365,8 +598,11 @@ describe("<HealthScoreCard> composite states", () => {
|
||||
expect(html).toContain(
|
||||
"The score needs at least three, including a measured physiological pillar.",
|
||||
);
|
||||
// No band sentence and no fabricated number in the face.
|
||||
// No band sentence and no fabricated number: a dash, and no bar either.
|
||||
expect(html).not.toContain('data-slot="health-score-band"');
|
||||
expect(html).not.toContain('data-slot="health-score-card-progress"');
|
||||
expect(html).toContain("—");
|
||||
expect(html).not.toContain("/ 100");
|
||||
expect(html).toContain("Method version 2");
|
||||
});
|
||||
|
||||
@@ -389,17 +625,30 @@ describe("<HealthScoreCard> composite states", () => {
|
||||
returnToBand={{ metricType: "RESTING_HEART_RATE", daysInside: 6 }}
|
||||
/>,
|
||||
);
|
||||
const face = html.slice(
|
||||
0,
|
||||
html.indexOf('data-slot="health-score-anatomy-toggle"'),
|
||||
);
|
||||
expect(face).toContain("Rest Mode active since 2026-07-20");
|
||||
expect(face).toContain('data-slot="health-score-tension"');
|
||||
expect(face).toContain('data-slot="health-score-return-to-band"');
|
||||
const rest = atRest(html);
|
||||
expect(rest).toContain("Rest Mode active since 2026-07-20");
|
||||
expect(rest).toContain('data-slot="health-score-tension"');
|
||||
expect(rest).toContain('data-slot="health-score-return-to-band"');
|
||||
// The contributor key and the metric type are localised, never raw.
|
||||
expect(face).toContain("Resting heart rate");
|
||||
expect(face).not.toContain("RESTING_HEART_RATE");
|
||||
expect(face).not.toContain(">rhr<");
|
||||
expect(rest).toContain("Resting heart rate");
|
||||
expect(rest).not.toContain("RESTING_HEART_RATE");
|
||||
expect(rest).not.toContain(">rhr<");
|
||||
});
|
||||
|
||||
it("shows the weekly gain as a chip beside the label", () => {
|
||||
const html = render(<HealthScoreCard report={scoredReport()} />);
|
||||
expect(html).toContain('data-slot="health-score-card-label"');
|
||||
expect(html).toContain('data-slot="health-score-card-delta-chip"');
|
||||
expect(html).toMatch(/data-slot="health-score-card-delta-chip"[^>]*>\+2</);
|
||||
});
|
||||
|
||||
it("keeps the chip away from a flat or falling week", () => {
|
||||
const html = render(
|
||||
<HealthScoreCard report={scoredReport({ delta: -3 })} />,
|
||||
);
|
||||
expect(html).not.toContain('data-slot="health-score-card-delta-chip"');
|
||||
// The line still states it — only the green chip is reserved for a gain.
|
||||
expect(html).toContain('data-slot="health-score-delta"');
|
||||
});
|
||||
|
||||
it("keeps a one-sided tension quiet", () => {
|
||||
@@ -497,9 +746,17 @@ describe("pillar detail formatting", () => {
|
||||
const html = render(
|
||||
<HealthScoreCard report={scoredReport({ pillars: [FASTING_GLUCOSE] })} />,
|
||||
);
|
||||
// The card mounts with mmol/L accounts too; the visible observed line is
|
||||
// the one always on screen.
|
||||
expect(html).toContain("Observed: Fasting glucose: 100 mg/dL");
|
||||
// The observed value moved into the row's popover, which Radix mounts
|
||||
// only once opened — so the panel shows the row, and the value itself is
|
||||
// asserted through the same pure formatter the popover is fed.
|
||||
expect(html).toContain('data-pillar="GLYCAEMIA" data-status="ok"');
|
||||
expect(html).toContain('data-slot="info-popover-trigger"');
|
||||
expect(
|
||||
pillarObservedText(FASTING_GLUCOSE, {
|
||||
t: translator(),
|
||||
glucoseUnit: "mg/dL",
|
||||
}),
|
||||
).toBe("Fasting glucose: 100 mg/dL");
|
||||
|
||||
const mmol = pillarObservedText(FASTING_GLUCOSE, {
|
||||
t: translator(),
|
||||
@@ -575,12 +832,38 @@ describe("pillar detail formatting", () => {
|
||||
});
|
||||
|
||||
describe("<HealthScoreCardSkeleton>", () => {
|
||||
it("reserves the collapsed footprint and announces nothing", () => {
|
||||
it("reserves the column's footprint and announces nothing", () => {
|
||||
const html = render(<HealthScoreCardSkeleton />);
|
||||
expect(html).toContain('data-slot="health-score-card-skeleton"');
|
||||
expect(html).toContain('aria-hidden="true"');
|
||||
// The `md` ring is a fixed 168 px box; the reserve matches it exactly.
|
||||
expect(html).toContain("size-[168px]");
|
||||
// A reserve that is not the panel's width is not a reserve.
|
||||
expect(html).toContain("md:basis-[22rem]");
|
||||
expect(html).toContain("xl:basis-[26rem]");
|
||||
// That it declares a floor at all is what this file can honestly check;
|
||||
// whether the floor is the RIGHT height is a browser question, and
|
||||
// `health-score-card-geometry.test.tsx` answers it by measuring the panel
|
||||
// and the reserve side by side. Pinning the literal here would only mean
|
||||
// the two places have to be edited together.
|
||||
expect(html).toMatch(/min-h-\[[\d.]+rem\]/);
|
||||
});
|
||||
|
||||
it("holds the same column geometry the resolved panel does", () => {
|
||||
const skeleton = render(<HealthScoreCardSkeleton />);
|
||||
const panel = render(<HealthScoreCard report={scoredReport()} />);
|
||||
// Any drift between the two is a jump when the payload lands.
|
||||
for (const cls of [
|
||||
"w-full",
|
||||
"md:shrink-0",
|
||||
"md:grow-0",
|
||||
"md:basis-[22rem]",
|
||||
"xl:basis-[26rem]",
|
||||
"flex h-full flex-col",
|
||||
"rounded-xl border p-4",
|
||||
"md:p-6",
|
||||
]) {
|
||||
expect(skeleton, cls).toContain(cls);
|
||||
expect(panel, cls).toContain(cls);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -3,6 +3,11 @@ import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { HeroStrip } from "../hero-strip";
|
||||
import type { DailyBriefing as DailyBriefingPayload } from "@/lib/ai/schema";
|
||||
import type {
|
||||
HealthScoreReport,
|
||||
ScorePillarId,
|
||||
ScorePillarResult,
|
||||
} from "@/lib/analytics/score/types";
|
||||
|
||||
// The "generated" freshness line reads the profile timezone via `useAuth`; stub
|
||||
// it so the SSR render does not reach for a QueryClient the test omits.
|
||||
@@ -37,6 +42,120 @@ const sampleBriefing: DailyBriefingPayload = {
|
||||
// (the mocked `Europe/Berlin`), not the host's — so pin explicit UTC
|
||||
// instants and translate them to the intended Berlin wall-clock hour.
|
||||
// May 10 2026 is CEST (UTC+2), so Berlin = UTC + 2h.
|
||||
const provenance = {
|
||||
inputs: ["BLOOD_PRESSURE"],
|
||||
source: "live" as const,
|
||||
windowDays: 90,
|
||||
computedAt: "2026-07-28T12:00:00.000Z",
|
||||
};
|
||||
|
||||
function scored(id: ScorePillarId, score: number): ScorePillarResult {
|
||||
return {
|
||||
id,
|
||||
domain: "cardiometabolic",
|
||||
result: {
|
||||
status: "ok",
|
||||
value: {
|
||||
score,
|
||||
observed: {
|
||||
value: 126,
|
||||
unit: "mmHg",
|
||||
label: "126/78 mmHg",
|
||||
asOf: "2026-07-27T08:00:00.000Z",
|
||||
sources: ["MANUAL"],
|
||||
},
|
||||
reference: {
|
||||
kind: "clinical-threshold",
|
||||
low: 120,
|
||||
high: 129,
|
||||
label: "120 to 129 mmHg",
|
||||
source: "ESH 2023",
|
||||
},
|
||||
noiseFloor: 1,
|
||||
deltaEligible: true,
|
||||
deltaIdentity: id,
|
||||
},
|
||||
coverage: {
|
||||
requiredInputs: 12,
|
||||
presentInputs: 12,
|
||||
historyDays: 28,
|
||||
missing: [],
|
||||
},
|
||||
confidence: { score: 100, band: "high" },
|
||||
provenance,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function gated(id: ScorePillarId, reason: string): ScorePillarResult {
|
||||
return {
|
||||
id,
|
||||
domain: "wellbeing",
|
||||
result: {
|
||||
status: "insufficient",
|
||||
coverage: {
|
||||
requiredInputs: 1,
|
||||
presentInputs: 0,
|
||||
historyDays: 0,
|
||||
missing: [],
|
||||
},
|
||||
provenance: { ...provenance, inputs: [id] },
|
||||
reason,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function scoreReport(pillars: ScorePillarResult[]): HealthScoreReport {
|
||||
return {
|
||||
composite: {
|
||||
status: "ok",
|
||||
value: {
|
||||
score: 74,
|
||||
band: "green",
|
||||
bandSetter: null,
|
||||
composition: ["BLOOD_PRESSURE"],
|
||||
noiseFloor: 3,
|
||||
scoreVersion: 2,
|
||||
},
|
||||
coverage: {
|
||||
requiredInputs: 3,
|
||||
presentInputs: 3,
|
||||
historyDays: 28,
|
||||
missing: [],
|
||||
},
|
||||
confidence: { score: 100, band: "high" },
|
||||
provenance,
|
||||
},
|
||||
pillars,
|
||||
delta: 2,
|
||||
deltaReason: null,
|
||||
scoreVersion: 2,
|
||||
weightGoal: {
|
||||
status: "insufficient",
|
||||
coverage: {
|
||||
requiredInputs: 2,
|
||||
presentInputs: 0,
|
||||
historyDays: 0,
|
||||
missing: [],
|
||||
},
|
||||
provenance: { ...provenance, inputs: ["WEIGHT"] },
|
||||
reason: "no_personal_goal",
|
||||
},
|
||||
algorithmNotice: null,
|
||||
};
|
||||
}
|
||||
|
||||
const SCORE = scoreReport([
|
||||
scored("BLOOD_PRESSURE", 82),
|
||||
scored("ACTIVITY", 61),
|
||||
gated("LIPIDS", "not_tracked"),
|
||||
]);
|
||||
|
||||
const SCORE_WITH_FAILED_READ = scoreReport([
|
||||
scored("BLOOD_PRESSURE", 82),
|
||||
gated("LIPIDS", "read_failed"),
|
||||
]);
|
||||
|
||||
const morningLocal = new Date("2026-05-10T07:00:00Z"); // 09:00 Berlin
|
||||
const afternoonLocal = new Date("2026-05-10T12:00:00Z"); // 14:00 Berlin
|
||||
const eveningLocal = new Date("2026-05-10T18:00:00Z"); // 20:00 Berlin
|
||||
@@ -238,27 +357,95 @@ describe("<HeroStrip>", () => {
|
||||
expect(html).not.toContain('data-slot="insights-hero-strip-weekly-banner"');
|
||||
});
|
||||
|
||||
// ── B5 — the Health Score left the hero band ──────────────────────
|
||||
// The score used to mount twice: a compact face in this band and the full
|
||||
// breakdown below it. The breakdown is now one pinned card under the band
|
||||
// (`page-client.tsx`), so the hero carries no score surface at all — no
|
||||
// panel, no reserved column, no skeleton.
|
||||
it("never renders a Health Score surface in the band", () => {
|
||||
// ── The Health Score column ────────────────────────────────────────
|
||||
// The score is back in the band's right column, and this band is its only
|
||||
// mount on the overview.
|
||||
it("keeps the greeting full width when there is no score", () => {
|
||||
// An account without a score keeps the single-column band it always had;
|
||||
// the split must not appear around an empty slot.
|
||||
const html = render(<HeroStrip briefing={null} now={morningLocal} />);
|
||||
expect(html).not.toContain("md:flex-row");
|
||||
expect(html).not.toContain("md:items-stretch");
|
||||
expect(html).not.toContain('data-slot="health-score-card"');
|
||||
expect(html).not.toContain('data-slot="health-score-card-compact"');
|
||||
expect(html).not.toContain('data-slot="health-score-card-skeleton"');
|
||||
});
|
||||
|
||||
it("keeps the greeting on the full width with no reserved second column", () => {
|
||||
// With the panel gone the band is single-column on every breakpoint; the
|
||||
// `md:`/`lg:` split + stretch contract it carried for the score column
|
||||
// must not come back, or the greeting reflows around an empty slot.
|
||||
const html = render(<HeroStrip briefing={null} now={morningLocal} />);
|
||||
expect(html).not.toContain("md:flex-row");
|
||||
expect(html).not.toContain("lg:flex-row");
|
||||
expect(html).not.toContain("md:items-stretch");
|
||||
expect(html).not.toContain("md:basis-[22rem]");
|
||||
it("splits into two columns and paints the panel when a score is present", () => {
|
||||
const html = render(
|
||||
<HeroStrip briefing={null} now={morningLocal} healthScore={SCORE} />,
|
||||
);
|
||||
expect(html).toContain("md:flex-row");
|
||||
expect(html).toContain("md:items-stretch");
|
||||
expect(html).toContain('data-slot="health-score-card"');
|
||||
// The greeting keeps its own column and can shrink inside it.
|
||||
expect(html).toContain("min-w-0 flex-1");
|
||||
expect(html).toContain('data-slot="insights-hero-strip-greeting"');
|
||||
});
|
||||
|
||||
it("reserves the column while the payload is in flight", () => {
|
||||
const html = render(
|
||||
<HeroStrip briefing={null} now={morningLocal} scorePending />,
|
||||
);
|
||||
// The split is already there, so the greeting does not re-wrap when the
|
||||
// score lands — that reflow is why the reserve exists at all.
|
||||
expect(html).toContain("md:flex-row");
|
||||
expect(html).toContain('data-slot="health-score-card-skeleton"');
|
||||
expect(html).toContain("md:basis-[22rem]");
|
||||
expect(html).not.toContain('data-slot="health-score-card"');
|
||||
});
|
||||
|
||||
it("prefers the resolved panel over the reserve", () => {
|
||||
const html = render(
|
||||
<HeroStrip
|
||||
briefing={null}
|
||||
now={morningLocal}
|
||||
healthScore={SCORE}
|
||||
scorePending
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('data-slot="health-score-card"');
|
||||
expect(html).not.toContain('data-slot="health-score-card-skeleton"');
|
||||
});
|
||||
|
||||
it("renders the full panel with no briefing at all", () => {
|
||||
// The score used to live inside the daily-briefing branch, so an instance
|
||||
// with the briefing switched off lost the entire breakdown. The band
|
||||
// renders unconditionally and carries the panel whole.
|
||||
const html = render(
|
||||
<HeroStrip briefing={null} now={morningLocal} healthScore={SCORE} />,
|
||||
);
|
||||
expect(html).toContain("A daily read of your trends"); // the no-briefing subtitle
|
||||
for (const slot of [
|
||||
"health-score-card",
|
||||
"health-score-card-label",
|
||||
"health-score-card-number",
|
||||
"health-score-card-progress",
|
||||
"health-score-band",
|
||||
"health-score-pillars",
|
||||
"health-score-not-scored-count",
|
||||
"health-score-anatomy-toggle",
|
||||
"health-score-anatomy-region",
|
||||
"health-score-not-scored",
|
||||
"health-score-weight-goal",
|
||||
"health-score-method",
|
||||
]) {
|
||||
expect(html, slot).toContain(`data-slot="${slot}"`);
|
||||
}
|
||||
expect(html).toContain(">74<");
|
||||
expect(html).toContain('data-pillar="BLOOD_PRESSURE" data-status="ok"');
|
||||
});
|
||||
|
||||
it("forwards the retry so a failed read can be retried from the band", () => {
|
||||
const html = render(
|
||||
<HeroStrip
|
||||
briefing={null}
|
||||
now={morningLocal}
|
||||
healthScore={SCORE_WITH_FAILED_READ}
|
||||
onScoreRetry={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('data-slot="health-score-pillar-error"');
|
||||
expect(html).toContain('data-slot="health-score-pillar-retry"');
|
||||
});
|
||||
|
||||
it("does not render any Ask-the-Coach affordance in the hero band", () => {
|
||||
|
||||
@@ -1,22 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import { useId, useState } from "react";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { AlertTriangle, ChevronDown, RotateCw } from "lucide-react";
|
||||
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { InfoPopover } from "@/components/ui/info-popover";
|
||||
import { LearningGate } from "@/components/ui/learning-gate";
|
||||
import { QueryErrorRow } from "@/components/ui/query-error-row";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { TileHeader } from "@/components/insights/tile-header";
|
||||
import {
|
||||
BAND_BORDER_CLASS,
|
||||
BAND_NUMBER_CLASS,
|
||||
BAND_PROGRESS_CLASS,
|
||||
bandForScore,
|
||||
clampScore,
|
||||
} from "@/components/insights/derived/band-tokens";
|
||||
import { CoverageMeter } from "@/components/insights/derived/coverage-meter";
|
||||
import { ProvenanceExplainer } from "@/components/insights/derived/provenance-explainer";
|
||||
import { ScoreRing } from "@/components/insights/derived/score-ring";
|
||||
import { METRIC_PROVENANCE } from "@/components/insights/derived/standards";
|
||||
import {
|
||||
pillarDetailLines,
|
||||
@@ -36,23 +35,33 @@ import { useTranslations } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* The Health Score surface: one card, one score, one place.
|
||||
* The Health Score panel: the hero band's right-hand column.
|
||||
*
|
||||
* Pre-rework the score had two mounts — a compact face in the hero band and a
|
||||
* nine-block stack further down the overview that only rendered while the
|
||||
* daily-briefing flag was on. The stack is now a single progressive-disclosure
|
||||
* card pinned directly under the hero, and the hero band is the full-width
|
||||
* greeting again.
|
||||
* The score belongs beside the greeting, not under it. It sat there for
|
||||
* eleven weeks — big number at the top of a narrow column, thin contributor
|
||||
* rows directly beneath it inside the same card — and three arrangements in
|
||||
* four days moved it away, ending in a full-width card pinned below the
|
||||
* greeting that spent most of its height on things that do not count toward
|
||||
* the number. This is the original arrangement, restored, with the eight
|
||||
* pillars the composite now carries instead of the four it had then.
|
||||
*
|
||||
* Collapsed reads as the score face: the ring, the band sentence, the weekly
|
||||
* delta (or the honest reason there is none), Rest Mode, the two ambient
|
||||
* context lines, and the coverage meter. Expanded adds the pillar grid, the
|
||||
* named list of pillars that are not scored yet, the personal weight goal, and
|
||||
* the method footer with its cited standard.
|
||||
* The rules the intervening work established are all kept:
|
||||
*
|
||||
* Absence stays absence: a pillar the server gated as `insufficient` is listed
|
||||
* by name with its reason instead of being drawn as a zero, and a pillar whose
|
||||
* read FAILED is a visible error row with a retry, never silence.
|
||||
* - One mount. This panel is the only score surface on the overview.
|
||||
* - It renders whether or not the daily briefing is switched on.
|
||||
* - It is not a customisable section, so a saved layout cannot hide it.
|
||||
* - The column reserves its own height while the payload is in flight,
|
||||
* so the band does not reflow when the score lands.
|
||||
*
|
||||
* Only a pillar that carries a score gets a row. A pillar that contributes
|
||||
* nothing is one quiet line: a failed read is named with a retry, safety
|
||||
* signposting is named in full, and everything else that is simply not
|
||||
* recorded yet is counted, with the names and reasons one tap away in the
|
||||
* foot disclosure. Each pillar has exactly one home — a row, the failed-read
|
||||
* line, the safety line, or the count — and never two.
|
||||
*
|
||||
* Each contributor row is tinted by ITS OWN band. The old rows took the
|
||||
* composite's colour, which painted a pillar scoring 92 red on a red day.
|
||||
*/
|
||||
|
||||
const PILLAR_LABEL_KEY: Record<ScorePillarId, string> = {
|
||||
@@ -82,6 +91,39 @@ const REASON_KEY: Record<string, string> = {
|
||||
stale: "insufficientHistory",
|
||||
};
|
||||
|
||||
/**
|
||||
* The column geometry, shared by the panel and by its loading reserve so the
|
||||
* two can never drift apart. `basis` rather than a width so the column flexes
|
||||
* inside the hero band's `md:flex-row` split (~36 % at md, ~40 % at xl), and
|
||||
* `w-full` below `md` where the column stacks under the greeting.
|
||||
*/
|
||||
const PANEL_COLUMN_CLASS =
|
||||
"w-full md:shrink-0 md:grow-0 md:basis-[22rem] xl:basis-[26rem]";
|
||||
|
||||
/** The card chrome the panel wears inside the gradient band. */
|
||||
const PANEL_CHROME_CLASS =
|
||||
"bg-card/65 rounded-xl border p-4 shadow-sm backdrop-blur-sm md:p-6";
|
||||
|
||||
/**
|
||||
* Height the reserve holds while the payload is in flight, so the band does
|
||||
* not jump when the score lands.
|
||||
*
|
||||
* The rule, not a remembered number: the reserve sits between the shortest
|
||||
* and the tallest panel the report actually produces (three to six scored
|
||||
* rows), so a short report is not pushed down and a long one does not leave a
|
||||
* gap that collapses. It is deliberately near the middle of that range, which
|
||||
* is the best a single number can do when the panel's height depends on how
|
||||
* many pillars an account scores.
|
||||
*
|
||||
* `health-score-card-geometry.test.tsx` measures all of it in a browser
|
||||
* against the compiled stylesheet and fails if this value leaves that range —
|
||||
* and also if the value stops binding at all. It did stop: the blocks below
|
||||
* added up to more than the minimum declared here, so the reserve was
|
||||
* whatever the blocks happened to be and this constant was decoration. The
|
||||
* blocks were trimmed to sit under it again.
|
||||
*/
|
||||
const PANEL_RESERVE_CLASS = "min-h-[28rem]";
|
||||
|
||||
/**
|
||||
* v1.21.2 (A5) — readiness contributor keys the Tension Verdict surfaces.
|
||||
* Kept as a local string union so the client never imports the server
|
||||
@@ -202,24 +244,34 @@ export function HealthScoreCard({
|
||||
return t(`insights.healthScore.reason.${key}`);
|
||||
}
|
||||
|
||||
function labelsOf(pillars: ScorePillarResult[]): string {
|
||||
return pillars.map((p) => t(PILLAR_LABEL_KEY[p.id])).join(", ");
|
||||
}
|
||||
|
||||
const composite = report.composite;
|
||||
// Four homes, one pillar each. A pillar that scores gets a row; a pillar
|
||||
// whose read failed joins the one coalesced failure line; a pillar gated
|
||||
// for safety signposting is named in full at rest; everything else gated
|
||||
// is counted, and named a tap away. The sets are disjoint by construction
|
||||
// and each pillar falls into exactly one of them.
|
||||
const scored = report.pillars.filter((p) => p.result.status === "ok");
|
||||
const readFailed = report.pillars.filter(
|
||||
(p) => gateReason(p) === "read_failed",
|
||||
);
|
||||
const crisis = report.pillars.filter(
|
||||
(p) => gateReason(p) === "crisis_signposting",
|
||||
);
|
||||
// A failed read is not absence, so it never joins the "not scored yet" list;
|
||||
// it gets its own error row above. Safety copy leads the list — signposting
|
||||
// is content, never buried meta.
|
||||
const notScored = report.pillars
|
||||
.filter((p) => {
|
||||
const reason = gateReason(p);
|
||||
return reason !== null && reason !== "read_failed";
|
||||
})
|
||||
.sort(
|
||||
(a, b) =>
|
||||
Number(gateReason(b) === "crisis_signposting") -
|
||||
Number(gateReason(a) === "crisis_signposting"),
|
||||
// it gets its own line above. Safety copy is content, so it is named at rest
|
||||
// rather than counted — which is also why it is not in this list twice.
|
||||
const notScored = report.pillars.filter((p) => {
|
||||
const reason = gateReason(p);
|
||||
return (
|
||||
reason !== null &&
|
||||
reason !== "read_failed" &&
|
||||
reason !== "crisis_signposting"
|
||||
);
|
||||
});
|
||||
|
||||
// Only forwarded when a real disagreement is present on BOTH sides; the
|
||||
// server resolver already enforces that, but the guard keeps the card from
|
||||
@@ -245,122 +297,252 @@ export function HealthScoreCard({
|
||||
})
|
||||
: null;
|
||||
|
||||
const compositeScore =
|
||||
composite.status === "ok" ? clampScore(composite.value.score) : null;
|
||||
const compositeBand = composite.status === "ok" ? composite.value.band : null;
|
||||
|
||||
return (
|
||||
<Card
|
||||
<div
|
||||
data-slot="health-score-card"
|
||||
data-status={composite.status}
|
||||
className={cn("animate-insight-in", className)}
|
||||
data-band={compositeBand ?? "none"}
|
||||
className={cn(
|
||||
"animate-insight-in",
|
||||
PANEL_CHROME_CLASS,
|
||||
compositeBand ? BAND_BORDER_CLASS[compositeBand] : null,
|
||||
PANEL_COLUMN_CLASS,
|
||||
// `h-full` lets the hero band's `items-stretch` equalise the column
|
||||
// against the greeting; `mt-auto` on the foot collects the slack.
|
||||
"flex h-full flex-col gap-3.5",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<CardHeader>
|
||||
<TileHeader title={t("insights.healthScore.label")} titleAs="h2" />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* The score face. The ring keeps its own identity block; everything
|
||||
that qualifies the number sits beside it, so a person reads the
|
||||
score and the reason for it in one pass. */}
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center">
|
||||
<div className="flex justify-center sm:justify-start">
|
||||
<ScoreRing
|
||||
score={composite.status === "ok" ? composite.value.score : null}
|
||||
band={
|
||||
composite.status === "ok" ? composite.value.band : undefined
|
||||
}
|
||||
size="md"
|
||||
label={t("insights.derived.anatomy.outOf")}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
{composite.status === "ok" ? (
|
||||
<p
|
||||
data-slot="health-score-band"
|
||||
className="text-foreground text-sm"
|
||||
>
|
||||
{t(`insights.healthScore.band.${composite.value.band}`)}
|
||||
</p>
|
||||
) : null}
|
||||
{report.delta != null ? (
|
||||
<p
|
||||
data-slot="health-score-delta"
|
||||
className="text-muted-foreground text-xs tabular-nums"
|
||||
>
|
||||
{t("insights.healthScore.delta", {
|
||||
delta: report.delta > 0 ? `+${report.delta}` : report.delta,
|
||||
})}
|
||||
</p>
|
||||
) : report.deltaReason ? (
|
||||
<p
|
||||
data-slot="health-score-delta-reason"
|
||||
className="text-muted-foreground text-xs"
|
||||
>
|
||||
{t(`insights.healthScore.deltaReason.${report.deltaReason}`)}
|
||||
</p>
|
||||
) : null}
|
||||
{report.restMode?.active ? (
|
||||
<p
|
||||
data-slot="health-score-rest-mode"
|
||||
className="text-muted-foreground text-xs"
|
||||
>
|
||||
{t("insights.healthScore.restMode", {
|
||||
since:
|
||||
report.restMode.since ?? t("insights.healthScore.none"),
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
{tensionLine ? (
|
||||
<p
|
||||
data-slot="health-score-tension"
|
||||
className="text-muted-foreground text-xs"
|
||||
>
|
||||
{tensionLine}
|
||||
</p>
|
||||
) : null}
|
||||
{returnToBandLine ? (
|
||||
<p
|
||||
data-slot="health-score-return-to-band"
|
||||
className="text-muted-foreground text-xs"
|
||||
>
|
||||
{returnToBandLine}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{/* 1 — the label, and the weekly gain when there is one. */}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p
|
||||
data-slot="health-score-card-label"
|
||||
className="text-muted-foreground text-xs font-semibold tracking-[0.18em] uppercase"
|
||||
>
|
||||
{t("insights.healthScore.label")}
|
||||
</p>
|
||||
{report.delta != null && report.delta > 0 ? (
|
||||
<span
|
||||
data-slot="health-score-card-delta-chip"
|
||||
className="bg-success/15 text-success shrink-0 rounded-full px-2 py-0.5 text-xs font-semibold tabular-nums"
|
||||
>
|
||||
+{report.delta}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{composite.status !== "ok" ? (
|
||||
{/* 2 — the number. The reason the panel exists. */}
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span
|
||||
data-slot="health-score-card-number"
|
||||
className={cn(
|
||||
"text-5xl leading-none font-semibold tabular-nums sm:text-6xl",
|
||||
compositeBand
|
||||
? BAND_NUMBER_CLASS[compositeBand]
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{compositeScore == null ? "—" : Math.round(compositeScore)}
|
||||
</span>
|
||||
{compositeScore == null ? null : (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="text-muted-foreground text-sm tabular-nums"
|
||||
>
|
||||
/ 100
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 3 — the composite bar. */}
|
||||
{compositeScore != null && compositeBand ? (
|
||||
<div
|
||||
data-slot="health-score-card-progress"
|
||||
role="progressbar"
|
||||
aria-label={t("insights.healthScore.label")}
|
||||
aria-valuenow={Math.round(compositeScore)}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
className="bg-muted/50 h-2 w-full overflow-hidden rounded-full"
|
||||
>
|
||||
<div
|
||||
className={cn("h-full", BAND_PROGRESS_CLASS[compositeBand])}
|
||||
style={{ width: `${compositeScore}%` }}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* 4 — the lines that qualify the number, one each, only when true. */}
|
||||
<div className="space-y-1">
|
||||
{compositeBand ? (
|
||||
<p data-slot="health-score-band" className="text-foreground text-xs">
|
||||
{t(`insights.healthScore.band.${compositeBand}`)}
|
||||
</p>
|
||||
) : (
|
||||
<LearningGate
|
||||
variant="bordered"
|
||||
compact
|
||||
bodySlot="health-score-insufficient"
|
||||
message={t("insights.healthScore.insufficient", {
|
||||
count: composite.coverage.presentInputs,
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
{report.delta != null ? (
|
||||
<p
|
||||
data-slot="health-score-delta"
|
||||
className="text-muted-foreground text-xs tabular-nums"
|
||||
>
|
||||
{t("insights.healthScore.delta", {
|
||||
delta: report.delta > 0 ? `+${report.delta}` : report.delta,
|
||||
})}
|
||||
</p>
|
||||
) : report.deltaReason ? (
|
||||
<p
|
||||
data-slot="health-score-delta-reason"
|
||||
className="text-muted-foreground text-xs"
|
||||
>
|
||||
{t(`insights.healthScore.deltaReason.${report.deltaReason}`)}
|
||||
</p>
|
||||
) : null}
|
||||
{report.restMode?.active ? (
|
||||
<p
|
||||
data-slot="health-score-rest-mode"
|
||||
className="text-muted-foreground text-xs"
|
||||
>
|
||||
{t("insights.healthScore.restMode", {
|
||||
since: report.restMode.since ?? t("insights.healthScore.none"),
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
{tensionLine ? (
|
||||
<p
|
||||
data-slot="health-score-tension"
|
||||
className="text-muted-foreground text-xs"
|
||||
>
|
||||
{tensionLine}
|
||||
</p>
|
||||
) : null}
|
||||
{returnToBandLine ? (
|
||||
<p
|
||||
data-slot="health-score-return-to-band"
|
||||
className="text-muted-foreground text-xs"
|
||||
>
|
||||
{returnToBandLine}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* 5 — the contributing factors, always visible, directly under the
|
||||
number. Server registry order; no re-ranking. */}
|
||||
{scored.length > 0 ? (
|
||||
<ul
|
||||
data-slot="health-score-pillars"
|
||||
className="border-border/60 space-y-2 border-t pt-3"
|
||||
>
|
||||
{scored.map((pillar) => (
|
||||
<PillarRow key={pillar.id} pillar={pillar} detail={detail} />
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
|
||||
{/* 6 — what is not contributing. One weight class, two faces: the
|
||||
failure carries the glyph and the only retry on the surface, the
|
||||
absence is muted and actionless. */}
|
||||
{readFailed.length > 0 || crisis.length > 0 || notScored.length > 0 ? (
|
||||
<div className="space-y-1.5">
|
||||
{readFailed.length > 0 ? (
|
||||
<div
|
||||
data-slot="health-score-pillar-error"
|
||||
data-pillars={readFailed.map((p) => p.id).join(",")}
|
||||
role="alert"
|
||||
className="text-foreground flex flex-wrap items-center gap-x-2 gap-y-1 text-xs"
|
||||
>
|
||||
<AlertTriangle
|
||||
aria-hidden="true"
|
||||
className="text-muted-foreground size-3.5 shrink-0"
|
||||
/>
|
||||
<span className="min-w-0">
|
||||
{labelsOf(readFailed)}
|
||||
{": "}
|
||||
{reasonText("read_failed")}
|
||||
</span>
|
||||
{onRetry ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onRetry}
|
||||
data-slot="health-score-pillar-retry"
|
||||
className="h-7 gap-1.5 px-2 text-xs"
|
||||
>
|
||||
<RotateCw className="size-3.5" aria-hidden="true" />
|
||||
<span>{t("common.retry")}</span>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{crisis.length > 0 ? (
|
||||
<p
|
||||
data-slot="health-score-crisis"
|
||||
data-pillars={crisis.map((p) => p.id).join(",")}
|
||||
className="text-foreground text-xs"
|
||||
>
|
||||
{labelsOf(crisis)}
|
||||
{": "}
|
||||
{reasonText("crisis_signposting")}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{notScored.length > 0 ? (
|
||||
<p
|
||||
data-slot="health-score-not-scored-count"
|
||||
data-count={notScored.length}
|
||||
className="text-muted-foreground text-xs"
|
||||
>
|
||||
{t("insights.healthScore.notScored", {
|
||||
count: notScored.length,
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* 7 — the foot. `mt-auto` collects the slack here rather than under the
|
||||
number, so a short report and a long one both end on the same line. */}
|
||||
<div className="mt-auto space-y-3 pt-1">
|
||||
<CoverageMeter
|
||||
coverage={composite.coverage}
|
||||
confidence={
|
||||
composite.status === "ok" ? composite.confidence : undefined
|
||||
}
|
||||
size="md"
|
||||
size="sm"
|
||||
/>
|
||||
|
||||
{/* Progressive disclosure. No Collapsible primitive exists in the UI
|
||||
kit, so this is the same accessible button + region shape the
|
||||
glucose panel ships: `aria-expanded` on the trigger,
|
||||
`aria-controls` + `hidden` on the region. */}
|
||||
<div className="border-border border-t pt-3">
|
||||
<div className="border-border/60 border-t pt-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
aria-expanded={open}
|
||||
aria-controls={regionId}
|
||||
data-slot="health-score-anatomy-toggle"
|
||||
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 flex min-h-11 w-full items-center justify-between gap-2 rounded-sm text-sm font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none"
|
||||
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 flex min-h-11 w-full items-center justify-between gap-2 rounded-sm text-left text-xs font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none"
|
||||
>
|
||||
<span>{t("insights.healthScore.anatomyToggle")}</span>
|
||||
<span className="min-w-0">
|
||||
{t("insights.healthScore.anatomyToggle")}
|
||||
</span>
|
||||
<ChevronDown
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"h-4 w-4 transition-transform",
|
||||
"size-4 shrink-0 transition-transform",
|
||||
open && "rotate-180",
|
||||
)}
|
||||
/>
|
||||
@@ -370,71 +552,28 @@ export function HealthScoreCard({
|
||||
id={regionId}
|
||||
hidden={!open}
|
||||
data-slot="health-score-anatomy-region"
|
||||
className="space-y-4 pt-3"
|
||||
className="space-y-3 pt-1 pb-1"
|
||||
>
|
||||
{scored.length > 0 ? (
|
||||
<ul
|
||||
data-slot="health-score-pillars"
|
||||
className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3"
|
||||
>
|
||||
{scored.map((pillar) => (
|
||||
<PillarCell key={pillar.id} pillar={pillar} detail={detail} />
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
|
||||
{readFailed.map((pillar) => (
|
||||
<QueryErrorRow
|
||||
key={pillar.id}
|
||||
slot="health-score-pillar-error"
|
||||
retrySlot="health-score-pillar-retry"
|
||||
message={
|
||||
<>
|
||||
{t(PILLAR_LABEL_KEY[pillar.id])}
|
||||
{": "}
|
||||
{reasonText("read_failed")}
|
||||
</>
|
||||
}
|
||||
onRetry={onRetry}
|
||||
/>
|
||||
))}
|
||||
|
||||
{notScored.length > 0 ? (
|
||||
<div data-slot="health-score-not-scored" className="space-y-2">
|
||||
<p className="text-muted-foreground text-xs font-semibold tracking-wide uppercase">
|
||||
{t("insights.healthScore.notScored", {
|
||||
count: notScored.length,
|
||||
})}
|
||||
</p>
|
||||
<ul className="space-y-1">
|
||||
{notScored.map((pillar) => {
|
||||
const reason = gateReason(pillar) ?? "";
|
||||
const crisis = reason === "crisis_signposting";
|
||||
return (
|
||||
<li
|
||||
key={pillar.id}
|
||||
data-pillar={pillar.id}
|
||||
data-reason={reason}
|
||||
className="flex flex-col gap-1 sm:flex-row sm:items-baseline sm:gap-2"
|
||||
>
|
||||
<span className="text-foreground text-xs font-medium">
|
||||
{t(PILLAR_LABEL_KEY[pillar.id])}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs",
|
||||
crisis
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{reasonText(reason)}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
<ul data-slot="health-score-not-scored" className="space-y-1">
|
||||
{notScored.map((pillar) => {
|
||||
const reason = gateReason(pillar) ?? "";
|
||||
return (
|
||||
<li
|
||||
key={pillar.id}
|
||||
data-pillar={pillar.id}
|
||||
data-reason={reason}
|
||||
className="text-muted-foreground text-xs"
|
||||
>
|
||||
<span className="text-foreground font-medium">
|
||||
{t(PILLAR_LABEL_KEY[pillar.id])}
|
||||
</span>
|
||||
{": "}
|
||||
{reasonText(reason)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
) : null}
|
||||
|
||||
<WeightGoalRow report={report} />
|
||||
@@ -472,18 +611,23 @@ export function HealthScoreCard({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One scored pillar: name + score, a band-tinted impact bar, and the observed
|
||||
* line with its detail popover and coverage meter. The reference band, the
|
||||
* personal target and the source live in the popover — the same strings as
|
||||
* before, one tap away instead of four permanent lines per pillar.
|
||||
* One scored pillar, one line: a truncating name, a thin fill bar, the score,
|
||||
* and the (i) that opens everything else. The bar takes the pillar's OWN band
|
||||
* colour — a pillar at 92 reads green even when the composite is red, which
|
||||
* is the point of showing the contributors at all.
|
||||
*
|
||||
* The observed value, the reference band, the personal target, the source and
|
||||
* the per-pillar confidence all live in the popover. That is one tap more
|
||||
* than the pinned card charged for the observed line, and it is what buys the
|
||||
* column back.
|
||||
*/
|
||||
function PillarCell({
|
||||
function PillarRow({
|
||||
pillar,
|
||||
detail,
|
||||
}: {
|
||||
@@ -494,25 +638,28 @@ function PillarCell({
|
||||
if (pillar.result.status !== "ok") return null;
|
||||
const score = clampScore(pillar.result.value.score);
|
||||
const band = bandForScore(score);
|
||||
const observed = pillarObservedText(pillar, detail);
|
||||
const lines = pillarDetailLines(pillar, detail);
|
||||
|
||||
return (
|
||||
<li
|
||||
data-pillar={pillar.id}
|
||||
data-status="ok"
|
||||
className="border-border/70 space-y-2 rounded-lg border p-3"
|
||||
className="grid grid-cols-[minmax(0,7rem)_1fr_2rem_auto] items-center gap-2 text-xs"
|
||||
>
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className="text-foreground min-w-0 text-sm font-medium">
|
||||
{t(PILLAR_LABEL_KEY[pillar.id])}
|
||||
</span>
|
||||
<span className="text-foreground text-lg font-semibold tabular-nums">
|
||||
{Math.round(score)}
|
||||
</span>
|
||||
</div>
|
||||
{/* The label truncates; the row never grows. `minmax(0,…)` plus
|
||||
`min-w-0 truncate` is what makes that structural rather than a
|
||||
promise — drop either and the track grows to the longest word and
|
||||
pushes the row. Widening the column does not rescue the long names:
|
||||
the longest label in five of the six locales overruns any column
|
||||
this panel can afford, so the extra width would come off the fill
|
||||
bar and buy a few more characters of one language. */}
|
||||
<span className="text-muted-foreground min-w-0 truncate">
|
||||
{t(PILLAR_LABEL_KEY[pillar.id])}
|
||||
</span>
|
||||
<div
|
||||
className="bg-muted/40 h-1.5 w-full overflow-hidden rounded-full"
|
||||
role="presentation"
|
||||
className="bg-muted/50 h-1.5 min-w-0 overflow-hidden rounded-full"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div
|
||||
data-slot="health-score-pillar-bar"
|
||||
@@ -520,36 +667,41 @@ function PillarCell({
|
||||
style={{ width: `${score}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-muted-foreground min-w-0 text-xs">
|
||||
{t("insights.healthScore.observed", {
|
||||
value: pillarObservedText(pillar, detail),
|
||||
})}
|
||||
</span>
|
||||
<span className="flex shrink-0 items-center gap-2">
|
||||
<InfoPopover
|
||||
content={lines.map((line) => (
|
||||
<span className="text-foreground text-right tabular-nums">
|
||||
{Math.round(score)}
|
||||
</span>
|
||||
<InfoPopover
|
||||
content={
|
||||
<>
|
||||
{observed ? (
|
||||
<span className="block">
|
||||
{t("insights.healthScore.observed", { value: observed })}
|
||||
</span>
|
||||
) : null}
|
||||
{lines.map((line) => (
|
||||
<span key={line} className="block">
|
||||
{line}
|
||||
</span>
|
||||
))}
|
||||
bodyDataSlot="health-score-pillar-detail"
|
||||
/>
|
||||
<CoverageMeter
|
||||
coverage={pillar.result.coverage}
|
||||
confidence={pillar.result.confidence}
|
||||
size="sm"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
<span className="mt-1.5 block">
|
||||
<CoverageMeter
|
||||
coverage={pillar.result.coverage}
|
||||
confidence={pillar.result.confidence}
|
||||
size="sm"
|
||||
/>
|
||||
</span>
|
||||
</>
|
||||
}
|
||||
bodyDataSlot="health-score-pillar-detail"
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The personal weight goal: an inner tile inside the disclosure, deliberately
|
||||
* NOT a pillar cell, because it is context the user set for themselves and it
|
||||
* never moves the score.
|
||||
* NOT a contributor row, because it is context the user set for themselves and
|
||||
* it never moves the score. That is also why it left the at-rest column.
|
||||
*/
|
||||
function WeightGoalRow({ report }: { report: HealthScoreReport }) {
|
||||
const { t } = useTranslations();
|
||||
@@ -610,38 +762,49 @@ function WeightGoalRow({ report }: { report: HealthScoreReport }) {
|
||||
}
|
||||
|
||||
/**
|
||||
* The pinned slot's reserve while the analytics payload is in flight. Mirrors
|
||||
* the COLLAPSED footprint — header, ring, face lines, coverage meter, trigger
|
||||
* row — so first paint lands at the card's real height and the resolved card
|
||||
* is a swap rather than a push. Decorative: the card announces nothing while
|
||||
* it loads.
|
||||
* The hero column's reserve while the analytics payload is in flight. Wears
|
||||
* the panel's own column classes and chrome and holds a measured minimum
|
||||
* height, so the greeting beside it does not reflow when the score lands —
|
||||
* the reason this reserve was added in the first place.
|
||||
*/
|
||||
export function HealthScoreCardSkeleton({ className }: { className?: string }) {
|
||||
return (
|
||||
<Card
|
||||
<div
|
||||
data-slot="health-score-card-skeleton"
|
||||
aria-hidden="true"
|
||||
className={className}
|
||||
className={cn(
|
||||
PANEL_CHROME_CLASS,
|
||||
"border-border/60",
|
||||
PANEL_COLUMN_CLASS,
|
||||
"flex h-full flex-col gap-3.5",
|
||||
PANEL_RESERVE_CLASS,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-5 w-32" />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center">
|
||||
{/* The `md` ring paints at a fixed 168 px box; the reserve matches it
|
||||
exactly so the swap moves nothing. */}
|
||||
<Skeleton className="size-[168px] shrink-0 self-center rounded-full sm:self-auto" />
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
<Skeleton className="h-4 w-48" />
|
||||
<Skeleton className="h-3 w-40" />
|
||||
<Skeleton className="h-3 w-32" />
|
||||
</div>
|
||||
</div>
|
||||
<Skeleton className="h-3 w-28" />
|
||||
<div className="border-border border-t pt-3">
|
||||
{/* Block for block, the panel above: label, number, bar, two qualifier
|
||||
lines, three contributor rows, one not-contributing line, and the
|
||||
foot with its meter and toggle. Three rows rather than five is what
|
||||
keeps these blocks under the minimum above, so the minimum is the
|
||||
thing that decides the height. */}
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-15 w-28" />
|
||||
<Skeleton className="h-2 w-full rounded-full" />
|
||||
<div className="space-y-1">
|
||||
<Skeleton className="h-4 w-40" />
|
||||
<Skeleton className="h-4 w-32" />
|
||||
</div>
|
||||
<div className="border-border/60 space-y-2 border-t pt-3">
|
||||
<Skeleton className="h-3 w-full" />
|
||||
<Skeleton className="h-3 w-full" />
|
||||
<Skeleton className="h-3 w-full" />
|
||||
</div>
|
||||
<Skeleton className="h-4 w-36" />
|
||||
<div className="mt-auto space-y-3 pt-1">
|
||||
<Skeleton className="h-11 w-28 rounded-sm" />
|
||||
<div className="border-border/60 border-t pt-1">
|
||||
<Skeleton className="h-11 w-full rounded-sm" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -11,8 +11,14 @@ import { formatUpdatedLabel } from "@/lib/i18n/relative-time";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { hourInTz, DEFAULT_TIMEZONE } from "@/lib/tz/format";
|
||||
import { ProseBlocks } from "@/components/insights/prose-blocks";
|
||||
import {
|
||||
HealthScoreCard,
|
||||
HealthScoreCardSkeleton,
|
||||
type ReadinessContributorKey,
|
||||
} from "@/components/insights/health-score-card";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { DailyBriefing as DailyBriefingPayload } from "@/lib/ai/schema";
|
||||
import type { HealthScoreReport, ScoreBand } from "@/lib/analytics/score/types";
|
||||
|
||||
/**
|
||||
* Insights redesign hero strip.
|
||||
@@ -33,14 +39,14 @@ import type { DailyBriefing as DailyBriefingPayload } from "@/lib/ai/schema";
|
||||
* overview now leads with the present-focused daily briefing, not a
|
||||
* coach prompt.
|
||||
*
|
||||
* The right-side Health Score panel is gone too. The score had two mounts,
|
||||
* a compact face here and the full breakdown below; the breakdown is now one
|
||||
* progressive-disclosure card pinned directly under this band, which needs
|
||||
* the full page width when it opens. The hero is back to the full-width
|
||||
* greeting + subtitle + baseline meta — the shape it already had for an
|
||||
* account without a score.
|
||||
* The Health Score is back in the band's right column, where it lived for
|
||||
* eleven weeks. It is the score's ONLY mount on the overview — there is no
|
||||
* second card below the greeting any more. The band splits into two columns
|
||||
* from `md` up, and only when there is a score (or one on its way): an
|
||||
* account without a score keeps the full-width greeting exactly as it is.
|
||||
* Below `md` the panel stacks under the greeting at full width.
|
||||
*
|
||||
* Pure presentational — the page owns the briefing data.
|
||||
* Pure presentational — the page owns the briefing and the score data.
|
||||
*/
|
||||
|
||||
interface HeroStripProps {
|
||||
@@ -68,6 +74,24 @@ interface HeroStripProps {
|
||||
* footer hint below.
|
||||
*/
|
||||
noProviderStale?: boolean;
|
||||
/**
|
||||
* The Health Score report. Non-null paints the right column; null with
|
||||
* `scorePending` paints the reserve; null without it keeps the band
|
||||
* full-width, which is the shape an account without a score already had.
|
||||
*/
|
||||
healthScore?: HealthScoreReport | null;
|
||||
/** True while the analytics payload is in flight and nothing is cached. */
|
||||
scorePending?: boolean;
|
||||
/** Server-resolved Tension Verdict, forwarded to the panel. */
|
||||
tension?: {
|
||||
band: ScoreBand;
|
||||
positive: ReadinessContributorKey[];
|
||||
negative: ReadinessContributorKey[];
|
||||
} | null;
|
||||
/** Server-resolved return-to-baseline, forwarded to the panel. */
|
||||
returnToBand?: { metricType: string; daysInside: number } | null;
|
||||
/** Retry for a pillar whose read failed — refetches the whole payload. */
|
||||
onScoreRetry?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -94,6 +118,11 @@ export function HeroStrip({
|
||||
userName,
|
||||
now,
|
||||
noProviderStale = false,
|
||||
healthScore = null,
|
||||
scorePending = false,
|
||||
tension = null,
|
||||
returnToBand = null,
|
||||
onScoreRetry,
|
||||
}: HeroStripProps) {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
@@ -134,12 +163,17 @@ export function HeroStrip({
|
||||
"relative isolate overflow-hidden rounded-xl px-4 py-4 md:px-6 md:py-6",
|
||||
)}
|
||||
>
|
||||
{/* One column. The band carried a right-hand score panel from v1.4.20
|
||||
until the score moved to its own pinned card below; with the panel
|
||||
gone the greeting owns the full width on every breakpoint, which is
|
||||
the shape this band already had for an account without a score. */}
|
||||
<div className="flex min-w-0 flex-col gap-4">
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* Two columns from `md` up, and only when the score column has
|
||||
something to show. Without a score the band stays one column, which
|
||||
is the shape it already had for an account that has none. */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-w-0 flex-col gap-4",
|
||||
(healthScore || scorePending) &&
|
||||
"md:flex-row md:items-stretch md:gap-6",
|
||||
)}
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Header glyph stays in the foreground tier — UI-STANDARDS §1
|
||||
reserves primary/accent for actions, not decorative header
|
||||
@@ -214,6 +248,20 @@ export function HeroStrip({
|
||||
* untouched — only the hero entry points are gone, so the band
|
||||
* is now greeting + subtitle + baseline meta.
|
||||
*/}
|
||||
|
||||
{/* The score column. The reserve holds the column's footprint while
|
||||
the payload is in flight so the greeting beside it does not shift
|
||||
when the score lands. Both wear the same width classes. */}
|
||||
{healthScore ? (
|
||||
<HealthScoreCard
|
||||
report={healthScore}
|
||||
tension={tension}
|
||||
returnToBand={returnToBand}
|
||||
onRetry={onScoreRetry}
|
||||
/>
|
||||
) : scorePending ? (
|
||||
<HealthScoreCardSkeleton />
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user