mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
New density guard at 390x844 and 360x780 on the dashboard and /insights: page scrollWidth never exceeds the viewport, and no descendant's visible box crosses its card / hero / list-row edge (clip-aware below the tile; content cut off at the tile's own edge counts as an escape). The snapshot mock gains hero fixtures — layout heroVisible, three score rings, and a briefing whose signal rows pair long wrapping headlines with wide delta strings — so the guard exercises the exact shape that regressed, and asserts the spotlight rows actually render rather than passing vacuously.
319 lines
9.7 KiB
TypeScript
319 lines
9.7 KiB
TypeScript
import type { Page } from "@playwright/test";
|
||
|
||
import {
|
||
DASHBOARD_WIDGET_CATALOGUE_IDS,
|
||
DEFAULT_DASHBOARD_LAYOUT,
|
||
} from "@/lib/dashboard-layout";
|
||
import type { DailyBriefing } from "@/lib/ai/schema";
|
||
import type { DashboardScoreRing } from "@/lib/dashboard/score-rings";
|
||
import type { DataSummary } from "@/lib/analytics/trends";
|
||
import type {
|
||
DashboardSnapshot,
|
||
DashboardLayoutCatalogueEntry,
|
||
DashboardTargetBands,
|
||
} from "@/lib/dashboard/snapshot";
|
||
import { getBpTargets } from "@/lib/analytics/bp-targets";
|
||
import {
|
||
buildTrafficLightBands,
|
||
buildTrafficRange,
|
||
buildWeightBandsFromHeight,
|
||
buildWeightRangeFromHeight,
|
||
getBodyFatTargetRange,
|
||
} from "@/lib/analytics/value-bands";
|
||
import {
|
||
getAgeFromDateOfBirth,
|
||
getPersonalizedPulseTarget,
|
||
} from "@/lib/analytics/pulse-targets";
|
||
|
||
// v1.18.6 — resolve the mock's band block from the same pure helpers the
|
||
// server builder uses, so the e2e fixture matches the real DTO shape.
|
||
function mockTargetBands(): DashboardTargetBands {
|
||
const dob = new Date("1990-01-01T00:00:00.000Z");
|
||
const bpTargets = getBpTargets(dob);
|
||
const pulseTarget = getPersonalizedPulseTarget(
|
||
getAgeFromDateOfBirth(dob),
|
||
"MALE",
|
||
);
|
||
const bodyFatRange = getBodyFatTargetRange("MALE");
|
||
return {
|
||
bpTargets,
|
||
bpSysRange: bpTargets
|
||
? buildTrafficRange(bpTargets.sysLow, bpTargets.sysHigh)
|
||
: null,
|
||
bpDiaRange: bpTargets
|
||
? buildTrafficRange(bpTargets.diaLow, bpTargets.diaHigh)
|
||
: null,
|
||
pulseDisplayRange: {
|
||
greenMin: pulseTarget.greenMin,
|
||
greenMax: pulseTarget.greenMax,
|
||
orangeMin: pulseTarget.orangeMin,
|
||
orangeMax: pulseTarget.orangeMax,
|
||
},
|
||
pulseBands: [
|
||
{ min: 30, max: pulseTarget.orangeMin, color: "#ff5555", opacity: 0.16 },
|
||
{
|
||
min: pulseTarget.orangeMin,
|
||
max: pulseTarget.greenMin,
|
||
color: "#ffb86c",
|
||
opacity: 0.18,
|
||
},
|
||
{
|
||
min: pulseTarget.greenMin,
|
||
max: pulseTarget.greenMax,
|
||
color: "#50fa7b",
|
||
opacity: 0.2,
|
||
},
|
||
{
|
||
min: pulseTarget.greenMax,
|
||
max: pulseTarget.orangeMax,
|
||
color: "#ffb86c",
|
||
opacity: 0.18,
|
||
},
|
||
{ min: pulseTarget.orangeMax, max: 220, color: "#ff5555", opacity: 0.16 },
|
||
].filter((b) => b.max > b.min),
|
||
bodyFatRange,
|
||
bodyFatBands: buildTrafficLightBands(bodyFatRange.min, bodyFatRange.max, {
|
||
lowerBound: 2,
|
||
upperBound: 55,
|
||
}),
|
||
weightRange: buildWeightRangeFromHeight(180),
|
||
weightBands: buildWeightBandsFromHeight(180, {
|
||
lowerBound: 30,
|
||
upperBound: 250,
|
||
}),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* v1.7.2 — shared mock for `GET /api/dashboard/snapshot`.
|
||
*
|
||
* The dashboard snapshot flag flipped default-ON in v1.7.2, so
|
||
* `src/app/page.tsx` reads every above-the-fold tile from the single
|
||
* `/api/dashboard/snapshot` cell unless `NEXT_PUBLIC_DASHBOARD_SNAPSHOT`
|
||
* is `"false"`. The e2e build sets no such var, so the snapshot path is
|
||
* live in CI. Specs that previously mocked only the legacy cells
|
||
* (`/api/analytics` + `/api/mood/analytics` + `/api/dashboard/widgets`)
|
||
* now hit the REAL snapshot route, which returns empty `summaries` for
|
||
* the seed user (zero measurements) and blanks the tile strip.
|
||
*
|
||
* `mockDashboardSnapshot` registers a `page.route` for the snapshot
|
||
* endpoint returning a correctly-typed `DashboardSnapshot` so the
|
||
* default-ON path paints deterministic tiles. Callers keep their legacy
|
||
* mocks alongside it — harmless, and they protect the reversible
|
||
* `NEXT_PUBLIC_DASHBOARD_SNAPSHOT=false` path.
|
||
*/
|
||
|
||
/** Fill a `DataSummary` from a sparse partial, defaulting the rest. */
|
||
function summary(partial: Partial<DataSummary>): DataSummary {
|
||
return {
|
||
count: 0,
|
||
latest: null,
|
||
min: null,
|
||
max: null,
|
||
mean: null,
|
||
median: null,
|
||
avg7: null,
|
||
avg30: null,
|
||
slope7: null,
|
||
slope30: null,
|
||
slope90: null,
|
||
anomalyCount: 0,
|
||
avg30LastMonth: null,
|
||
avg30LastYear: null,
|
||
...partial,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Four-metric populated tile set mirroring the legacy `/api/analytics`
|
||
* mock shared by `dashboard.spec.ts` / `charts-mobile.spec.ts` /
|
||
* `chart-overlay-controls.spec.ts`. Every summary has `count > 0` so the
|
||
* tile clears the data-floor gate in `src/app/page.tsx`.
|
||
*/
|
||
export const POPULATED_SUMMARIES: Record<string, DataSummary> = {
|
||
WEIGHT: summary({
|
||
latest: 78.5,
|
||
avg7: 78.2,
|
||
avg30: 77.9,
|
||
slope30: { slope: -0.05, direction: "down", confidence: 0.8 },
|
||
count: 30,
|
||
}),
|
||
BLOOD_PRESSURE_SYS: summary({
|
||
latest: 124,
|
||
avg7: 122,
|
||
avg30: 121,
|
||
slope30: { slope: 0.1, direction: "stable", confidence: 0.3 },
|
||
count: 30,
|
||
}),
|
||
BLOOD_PRESSURE_DIA: summary({
|
||
latest: 80,
|
||
avg7: 79,
|
||
avg30: 78,
|
||
slope30: { slope: 0.05, direction: "stable", confidence: 0.2 },
|
||
count: 30,
|
||
}),
|
||
PULSE: summary({
|
||
latest: 68,
|
||
avg7: 70,
|
||
avg30: 71,
|
||
slope30: { slope: -0.2, direction: "down", confidence: 0.6 },
|
||
count: 30,
|
||
}),
|
||
};
|
||
|
||
/** WEIGHT-only populated set for specs that only need one tile + chart. */
|
||
export const WEIGHT_ONLY_SUMMARIES: Record<string, DataSummary> = {
|
||
WEIGHT: POPULATED_SUMMARIES.WEIGHT,
|
||
};
|
||
|
||
function buildLayoutCatalogue(): DashboardLayoutCatalogueEntry[] {
|
||
return DASHBOARD_WIDGET_CATALOGUE_IDS.map((id, order) => ({
|
||
id,
|
||
visible: true,
|
||
order,
|
||
}));
|
||
}
|
||
|
||
/**
|
||
* Long-headline briefing fixture for the hero density guard: every
|
||
* signal pairs a wrapping German headline with a wide delta string —
|
||
* the exact shape that squeezed the spotlight rows on phone widths.
|
||
*/
|
||
export const LONG_HEADLINE_BRIEFING: DailyBriefing = {
|
||
paragraph:
|
||
"Dein systolischer Blutdruck liegt heute deutlich unter deinem Monatsmittel, während die Schlafdauer stabil bleibt.",
|
||
signalsOfDay: [
|
||
{
|
||
sourceMetric: "bp",
|
||
tone: "watch",
|
||
headline:
|
||
"Systolischer Blutdruck deutlich unter deinem Monatsmittel gemessen",
|
||
nudge: "Miss heute Abend erneut, um den Wert einzuordnen.",
|
||
delta: "−12 mmHg vs. 30-Tage-Mittel",
|
||
},
|
||
{
|
||
sourceMetric: "sleep",
|
||
tone: "info",
|
||
headline:
|
||
"Schlafdauer in den letzten sieben Nächten etwa eine Stunde kürzer als üblich",
|
||
nudge: "Plane heute eine frühere Nachtruhe ein.",
|
||
delta: "−58 min",
|
||
},
|
||
{
|
||
sourceMetric: "compliance",
|
||
tone: "good",
|
||
headline: "Alle geplanten Dosen der Woche bisher vollständig eingenommen",
|
||
nudge: "Weiter so — die Abenddosis steht um 20:00 an.",
|
||
delta: "7/7 Tage",
|
||
},
|
||
],
|
||
keyFindings: [],
|
||
};
|
||
|
||
/** Three-ring hero row fixture (max the hero renders beside the score). */
|
||
export const MOCK_SCORE_RINGS: DashboardScoreRing[] = [
|
||
{ id: "READINESS", score: 82, band: "green" },
|
||
{ id: "RECOVERY_SCORE", score: 74, band: "yellow" },
|
||
{
|
||
id: "MED_COMPLIANCE",
|
||
score: 33,
|
||
band: "yellow",
|
||
doses: { taken: 1, scheduled: 3 },
|
||
},
|
||
];
|
||
|
||
export interface MockSnapshotOptions {
|
||
/**
|
||
* Tile summaries keyed by `MeasurementType`. Defaults to the
|
||
* four-metric populated set. Pass `{}` for an empty strip (e.g. the
|
||
* incomplete-onboarding branch).
|
||
*/
|
||
summaries?: Record<string, DataSummary>;
|
||
/** Artificial latency in ms before the route fulfils (race specs). */
|
||
delayMs?: number;
|
||
/** Render the dashboard hero band (`layout.heroVisible` opt-in). */
|
||
heroVisible?: boolean;
|
||
/** Fresh (`ready`, non-stale) briefing for the hero spotlight rows. */
|
||
briefing?: DailyBriefing;
|
||
/** Selected hero score rings (renders beside the health-score ring). */
|
||
scoreRings?: DashboardScoreRing[];
|
||
}
|
||
|
||
/** Assemble a fully-typed snapshot body from the requested tile data. */
|
||
export function buildMockSnapshot(
|
||
options: MockSnapshotOptions = {},
|
||
): DashboardSnapshot {
|
||
const summaries = options.summaries ?? POPULATED_SUMMARIES;
|
||
const now = new Date().toISOString();
|
||
const lastSeenByType: Record<
|
||
string,
|
||
{ lastSeenAt: string; daysAgo: number } | null
|
||
> = {};
|
||
for (const type of Object.keys(summaries)) {
|
||
lastSeenByType[type] = { lastSeenAt: now, daysAgo: 0 };
|
||
}
|
||
return {
|
||
user: {
|
||
username: "e2e-tester",
|
||
timezone: "Europe/Berlin",
|
||
heightCm: 180,
|
||
dateOfBirth: "1990-01-01T00:00:00.000Z",
|
||
gender: "MALE",
|
||
glucoseUnit: "mg/dL",
|
||
onboardingTourCompleted: true,
|
||
greetingHour: 9,
|
||
},
|
||
layout: options.heroVisible
|
||
? { ...DEFAULT_DASHBOARD_LAYOUT, heroVisible: true }
|
||
: DEFAULT_DASHBOARD_LAYOUT,
|
||
layoutCatalogue: buildLayoutCatalogue(),
|
||
metricStates: {},
|
||
targetBands: mockTargetBands(),
|
||
tiles: {
|
||
summaries,
|
||
lastSeenByType,
|
||
mood: { summary: null, entries: [] },
|
||
},
|
||
extras: null,
|
||
medsToday: {
|
||
activeCount: 0,
|
||
scheduledToday: 0,
|
||
takenToday: 0,
|
||
skippedToday: 0,
|
||
nextDueAt: null,
|
||
nextDueOverdue: false,
|
||
nextDueMedicationName: null,
|
||
},
|
||
healthScore: options.briefing
|
||
? { score: 84, band: "green", delta: -2, restMode: null }
|
||
: null,
|
||
scoreRings: options.scoreRings,
|
||
briefing: options.briefing ?? null,
|
||
briefingState: options.briefing ? "ready" : "preparing",
|
||
briefingUpdatedAt: options.briefing ? now : null,
|
||
briefingStale: false,
|
||
generatedAt: now,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Register a `page.route` interceptor for `GET /api/dashboard/snapshot`
|
||
* returning the API envelope `{ data: DashboardSnapshot, error: null }`.
|
||
*/
|
||
export async function mockDashboardSnapshot(
|
||
page: Page,
|
||
options: MockSnapshotOptions = {},
|
||
): Promise<void> {
|
||
const body = buildMockSnapshot(options);
|
||
await page.route(/\/api\/dashboard\/snapshot(\?|$)/, async (route) => {
|
||
if (options.delayMs) {
|
||
await new Promise((resolve) => setTimeout(resolve, options.delayMs));
|
||
}
|
||
await route.fulfill({
|
||
status: 200,
|
||
contentType: "application/json",
|
||
body: JSON.stringify({ data: body, error: null }),
|
||
});
|
||
});
|
||
}
|