mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 06:40:50 +00:00
The two-o'clock status crons generated for every account, ignoring both the per-user coach opt-out and the operator kill switch; they now share one gated discovery that also skips users the pregenerate pass will rewarm anyway, and the mood card finally gets the nightly run the other six always had. Deleted measurements no longer feed any prompt. Locales without an assistant language fall back to English consistently instead of German texts with English fallbacks. A rate-limited regenerate stops claiming success, the dashboard briefing state distinguishes a missing provider from a pending generation and serves a stale briefing with its timestamp instead of hiding it, narrative warming actually processes oldest first, and worker failures reach the error tracker through one shared reporter while the insight queues gain real retries.
175 lines
5.1 KiB
TypeScript
175 lines
5.1 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,
|
|
} from "@/lib/dashboard/snapshot";
|
|
|
|
/**
|
|
* 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: {},
|
|
tiles: {
|
|
summaries,
|
|
lastSeenByType,
|
|
mood: { summary: null, entries: [] },
|
|
},
|
|
extras: 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 }),
|
|
});
|
|
});
|
|
}
|