mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
Two areas of health score now, and one does. The arithmetic is the same
at every breadth, so nothing on the web surfaces distinguished a number
resting on blood pressure alone from one resting on five areas. The
panel now states its scope.
The basis line. Below the recommended breadth the card renders "Based on
{n} of 3 areas of health" under the band sentence, in the same weight,
and stops there. It stands where the learning gate used to stand for
those accounts, which is what the gate was for before the refusal moved.
The number keeps its size and its band colour at every tier, there is no
warning glyph, no second colour, no apology: the coverage meter and the
confidence band underneath already do the quantitative caveat work, and
the composition list names the areas one tap away. At full breadth the
line says nothing, because a fraction that reads "3 of 3" (or "4 of 3" —
the block counts domains, the recommendation caps at three) is not a
label. The daily hero carries the same sentence beside its ring, in the
hero's quiet tier, because the ring shows a number and nothing else.
The gate survives for the one case that is still a refusal: not a single
pillar readable. Its copy stopped claiming the score needs three areas
and stopped counting them — above zero there is a score now, and the
meter below renders the fraction anyway.
The coverage axis. "Covers the three areas the score needs" was false
the moment three stopped being needed, and the branch that chose it was
worse: it asked whether a score exists, so a two-area account would have
been told it covers the three areas the score recommends — the old lie
one floor up. The branch is the breadth tier now. Full breadth states
the recommendation; everything narrower shows the fraction, which since
this release is a real moving number rather than a floor every scored
account cleared by definition. The key was renamed off "minimumMet"
because there is no minimum left to name.
Settings names the recommendation rather than a requirement: three areas
is what the score recommends, a selection with one pillar saves, and a
narrower score is worked out the same way.
Copy lands in all seven catalogues.
Tests: the axis guard gains a third claim, rendered rather than read out
of the source — the real panel at each tier, including the two-area arm
that was unreachable for a scored account until now. The card gains a
partial-score block comparing the headline number's markup byte for byte
against a full-breadth render, so "renders the same" is asserted where
it can actually break, plus the counter-case that a refusal still gets
the gate and nothing else. The hero strip proves the sentence reaches
the band, not only the component. The geometry reserve was re-measured
with a browser rather than widened: a three-row panel carrying the basis
line lands at 420px, inside the 400-484px range the reserve brackets, so
the declared minimum is unchanged and the narrow shape is now measured.
412 lines
12 KiB
TypeScript
412 lines
12 KiB
TypeScript
import type { Page } from "@playwright/test";
|
|
|
|
import {
|
|
SECTION_MOBILITY,
|
|
SECTION_VITALS,
|
|
} from "@/components/insights/derived/use-dashboard-derived";
|
|
import { SCORE_VERSION } from "@/lib/analytics/score/types";
|
|
|
|
/**
|
|
* A POPULATED Insights overview fixture.
|
|
*
|
|
* The mobile-overflow guard exists to prove that a page carrying real content
|
|
* does not scroll sideways. Against the shared e2e account — which owns no
|
|
* measurements — `/insights` resolves to its empty state, and an empty page
|
|
* trivially fits any viewport. Every section below therefore gets a body:
|
|
* populated wellness scores, a full vitals grid, and the "signals of the day"
|
|
* card whose heading carries the long plain-language provenance caption.
|
|
*
|
|
* The strings are deliberately long. Prose in a heading's trailing `action`
|
|
* slot is exactly what widened the page in the first place, and a fixture with
|
|
* short labels would not reproduce it.
|
|
*/
|
|
|
|
const NOW = "2026-01-15T08:30:00.000Z";
|
|
|
|
function provenance(inputs: string[]) {
|
|
return {
|
|
inputs,
|
|
source: "DAY" as const,
|
|
windowDays: 30,
|
|
computedAt: NOW,
|
|
};
|
|
}
|
|
|
|
function coverage() {
|
|
return {
|
|
requiredInputs: 4,
|
|
presentInputs: 4,
|
|
historyDays: 90,
|
|
missing: [] as string[],
|
|
};
|
|
}
|
|
|
|
function confidence() {
|
|
return { score: 88, band: "high" as const };
|
|
}
|
|
|
|
function deviation(
|
|
type: string,
|
|
value: number,
|
|
low: number,
|
|
high: number,
|
|
direction: "above" | "below" | "in",
|
|
) {
|
|
return {
|
|
type,
|
|
value,
|
|
center: (low + high) / 2,
|
|
low,
|
|
high,
|
|
outside: direction !== "in",
|
|
direction,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* The "signals of the day" payload in its `fired` state — the branch that
|
|
* renders `SectionHeading action={<CoincidentProvenance/>}`, i.e. a full
|
|
* sentence pinned into the heading row.
|
|
*/
|
|
const COINCIDENT_FIRED = {
|
|
metric: "COINCIDENT_DEVIATION",
|
|
status: "ok" as const,
|
|
value: {
|
|
fired: true,
|
|
day: "2026-01-15",
|
|
illnessExplained: false,
|
|
vitals: [
|
|
deviation("RESTING_HEART_RATE", 71, 52, 62, "above"),
|
|
deviation("HEART_RATE_VARIABILITY", 38, 45, 78, "below"),
|
|
deviation("RESPIRATORY_RATE", 17.4, 12.5, 16.2, "above"),
|
|
deviation("OXYGEN_SATURATION", 97, 96, 99, "in"),
|
|
deviation("BODY_TEMPERATURE", 36.9, 36.2, 37.1, "in"),
|
|
],
|
|
contributing: [
|
|
deviation("RESTING_HEART_RATE", 71, 52, 62, "above"),
|
|
deviation("HEART_RATE_VARIABILITY", 38, 45, 78, "below"),
|
|
deviation("RESPIRATORY_RATE", 17.4, 12.5, 16.2, "above"),
|
|
],
|
|
},
|
|
coverage: coverage(),
|
|
confidence: confidence(),
|
|
provenance: provenance([
|
|
"RESTING_HEART_RATE",
|
|
"HEART_RATE_VARIABILITY",
|
|
"RESPIRATORY_RATE",
|
|
]),
|
|
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"],
|
|
configured: false,
|
|
// v1.38 — four pillars across four areas of health, so this is a
|
|
// full-breadth score and the panel adds no basis line. It rides
|
|
// anyway, because the wire carries the block on every ok composite
|
|
// and a fixture missing a field the server always sends stops
|
|
// exercising the branch a person meets.
|
|
scoreBasis: {
|
|
domains: 4,
|
|
recommended: 3,
|
|
tier: "full" as const,
|
|
physiological: true,
|
|
},
|
|
noiseFloor: 3,
|
|
scoreVersion: SCORE_VERSION,
|
|
},
|
|
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("LIPIDS", "cardiometabolic", "read_failed"),
|
|
gatedPillar("WELLBEING", "wellbeing", "crisis_signposting"),
|
|
gatedPillar("GLYCAEMIA", "cardiometabolic", "not_tracked"),
|
|
],
|
|
delta: 2,
|
|
deltaReason: null,
|
|
scoreVersion: SCORE_VERSION,
|
|
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) =>
|
|
Number((center + Math.sin(i / 2.5) * spread).toFixed(2)),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* A populated `WellnessScoreValue` entry — the exact wire shape the three
|
|
* persisted score types share (`RECOVERY_SCORE` / `STRESS_SCORE` /
|
|
* `STRAIN_SCORE`).
|
|
*/
|
|
function wellnessScore(
|
|
metric: string,
|
|
score: number,
|
|
band: "green" | "yellow" | "red",
|
|
) {
|
|
return {
|
|
metric,
|
|
status: "ok" as const,
|
|
value: {
|
|
score,
|
|
band,
|
|
trendDelta: 3.4,
|
|
daysInWindow: 28,
|
|
asOf: NOW,
|
|
series: series(score, 6),
|
|
},
|
|
coverage: coverage(),
|
|
confidence: confidence(),
|
|
provenance: provenance(["SLEEP_DURATION", "RESTING_HEART_RATE"]),
|
|
reason: null,
|
|
assessment: null,
|
|
};
|
|
}
|
|
|
|
/** A populated `VitalsBaselineValue` tile — exact wire shape. */
|
|
function vitalsBaseline(type: string, center: number, spread: number) {
|
|
return {
|
|
metric: "VITALS_BASELINE",
|
|
status: "ok" as const,
|
|
value: {
|
|
type,
|
|
center,
|
|
low: Number((center - spread).toFixed(2)),
|
|
high: Number((center + spread).toFixed(2)),
|
|
spread,
|
|
sampleDays: 30,
|
|
k: 2,
|
|
series: series(center, spread / 2),
|
|
},
|
|
coverage: coverage(),
|
|
confidence: confidence(),
|
|
provenance: provenance([type]),
|
|
reason: null,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* The honest "not enough history" arm. Metrics whose value shapes this fixture
|
|
* does not reproduce exactly are served as `insufficient` rather than as an
|
|
* invented payload — that is a real state those tiles handle (they un-mount),
|
|
* and a wrong-shaped `ok` payload would throw in the tile instead of testing
|
|
* anything.
|
|
*/
|
|
function insufficient(metric: string) {
|
|
return {
|
|
metric,
|
|
status: "insufficient" as const,
|
|
value: null,
|
|
coverage: {
|
|
requiredInputs: 4,
|
|
presentInputs: 1,
|
|
historyDays: 3,
|
|
missing: ["SLEEP_DURATION"],
|
|
},
|
|
confidence: null,
|
|
provenance: provenance([]),
|
|
reason: "not_enough_history",
|
|
};
|
|
}
|
|
|
|
/** Center + spread per vital, in each metric's own units. */
|
|
const VITAL_BANDS: Record<string, [number, number]> = {
|
|
RESTING_HEART_RATE: [57, 5],
|
|
RESPIRATORY_RATE: [14.1, 1.8],
|
|
OXYGEN_SATURATION: [97, 1.5],
|
|
BODY_TEMPERATURE: [36.6, 0.45],
|
|
BLOOD_GLUCOSE: [92, 13],
|
|
WEIGHT: [79.4, 3],
|
|
};
|
|
|
|
function buildBatchMetrics(): Record<string, unknown> {
|
|
const metrics: Record<string, unknown> = {
|
|
RECOVERY_SCORE: wellnessScore("RECOVERY_SCORE", 66, "yellow"),
|
|
STRESS_SCORE: wellnessScore("STRESS_SCORE", 41, "green"),
|
|
STRAIN_SCORE: wellnessScore("STRAIN_SCORE", 72, "yellow"),
|
|
READINESS: insufficient("READINESS"),
|
|
SLEEP_SCORE: insufficient("SLEEP_SCORE"),
|
|
FITNESS_AGE: insufficient("FITNESS_AGE"),
|
|
VASCULAR_AGE_DELTA: insufficient("VASCULAR_AGE_DELTA"),
|
|
HRV_BALANCE: insufficient("HRV_BALANCE"),
|
|
BMI: insufficient("BMI"),
|
|
SIX_MINUTE_WALK_BAND: insufficient("SIX_MINUTE_WALK_BAND"),
|
|
};
|
|
for (const type of SECTION_VITALS) {
|
|
if (type === "HEART_RATE_VARIABILITY") continue;
|
|
const [center, spread] = VITAL_BANDS[type] ?? [50, 5];
|
|
metrics[`VITALS_BASELINE:${type}`] = vitalsBaseline(type, center, spread);
|
|
}
|
|
for (const { metric } of SECTION_MOBILITY) {
|
|
metrics[metric] = insufficient(metric);
|
|
}
|
|
return metrics;
|
|
}
|
|
|
|
/**
|
|
* Route-mock a populated Insights overview onto `page`.
|
|
*
|
|
* Only the reads that decide whether a section renders AT ALL are stubbed;
|
|
* everything else (the i18n bundle, every layout decision, the real component
|
|
* tree) stays authentic, so the geometry under test is the shipped geometry.
|
|
*/
|
|
export async function mockPopulatedInsights(page: Page): Promise<void> {
|
|
// Without a non-empty comprehensive payload the page short-circuits to its
|
|
// "no data yet" empty state and no section mounts at all.
|
|
// RegExp matchers throughout, not globs: Playwright's URL glob treats `?` as
|
|
// a single-character wildcard, so `**/api/insights/derived?*` also matches
|
|
// `/api/insights/derived/batch?…` and the two handlers shadow each other.
|
|
await page.route(/\/api\/insights\/comprehensive(\?|$)/, (route) =>
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: JSON.stringify({
|
|
data: {
|
|
totalMeasurements: 4821,
|
|
moodSummary: { count: 96 },
|
|
medications: [{ id: "med-1" }, { id: "med-2" }, { id: "med-3" }],
|
|
},
|
|
error: null,
|
|
}),
|
|
}),
|
|
);
|
|
|
|
// The batched derived read behind the wellness strip + the vitals grid.
|
|
await page.route(/\/api\/insights\/derived\/batch(\?|$)/, (route) =>
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: JSON.stringify({
|
|
data: { metrics: buildBatchMetrics() },
|
|
error: null,
|
|
}),
|
|
}),
|
|
);
|
|
|
|
// 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.
|
|
await page.route(/\/api\/insights\/derived\?/, async (route) => {
|
|
const metric = new URL(route.request().url()).searchParams.get("metric");
|
|
if (metric !== "COINCIDENT_DEVIATION") return route.fallback();
|
|
return route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: JSON.stringify({ data: COINCIDENT_FIRED, error: null }),
|
|
});
|
|
});
|
|
}
|