mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 21:32:18 +00:00
The digest-driven Today hero is the single promoted hero at the top of the dashboard, so the old opt-in hero band is redundant — a user who had opted in and has Insights on saw two heroes. Remove the legacy hero and its heroVisible layout flag. - Drop the DashboardHero / DashboardHeroSkeleton mount from the dashboard page; the header now renders the greeting unconditionally (its showGreeting gate is gone with the hero that owned it). - Retire the heroVisible field from the dashboard layout (type, defaults, resolver, serializer) and the widgets PUT schema. The resolver is a whitelist constructor, so a stored heroVisible is dropped on read and never re-persisted — no stored layout breaks. - Delete the now-orphaned hero components (band, skeleton, ring carousel, briefing spotlight) and their tests; drop the retired dashboard.hero copy across all locales. - Remove the hero-visibility toggle from Settings → Dashboard. The score-ring picker no longer hangs off that flag and stays available, since the rings it configures still ride the server-computed dashboard snapshot. - Repoint the phone-width density guard at the Today hero and mock the daily digest so it renders. - Repoint the preserve-when-absent doc analogy off the removed field.
315 lines
9.5 KiB
TypeScript
315 lines
9.5 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;
|
||
/** Fresh (`ready`, non-stale) briefing for the dashboard briefing card. */
|
||
briefing?: DailyBriefing;
|
||
/** Selected score rings carried on the snapshot (server-computed). */
|
||
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: 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 }),
|
||
});
|
||
});
|
||
}
|