Files
HealthLog/e2e/utils/mock-dashboard-snapshot.ts
T
Marc-André Bombeck bd80557749 perf(dashboard): compute chart band/target math server-side
Move the BP/pulse/body-fat/weight band + target derivation out of the
dashboard client component and into the snapshot DTO. The page recomputed
every band from the user profile on each render via the analytics band
helpers; the snapshot builder now resolves the same numeric structures
once server-side under a new targetBands block, calling the identical
helpers. Emit only the i18n-free numbers — the label-bearing BP target
zones stay client-assembled from the server numbers.

Add a parity test asserting buildTargetBands matches the prior client
derivation for a male, a female, and a no-profile user.
2026-06-18 15:29:09 +02:00

258 lines
7.4 KiB
TypeScript

import type { Page } from "@playwright/test";
import {
DASHBOARD_WIDGET_CATALOGUE_IDS,
DEFAULT_DASHBOARD_LAYOUT,
} from "@/lib/dashboard-layout";
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,
}));
}
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;
}
/** 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: 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: null,
briefing: null,
briefingState: "preparing",
briefingUpdatedAt: 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 }),
});
});
}