mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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.
258 lines
7.4 KiB
TypeScript
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 }),
|
|
});
|
|
});
|
|
}
|