mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
Merge branch 'fix/mobile-density' into release/v1.27.14
This commit is contained in:
commit
d7b98ad9f8
8 files changed
+342
-33
No files matched your search
@@ -0,0 +1,155 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
import {
|
||||
LONG_HEADLINE_BRIEFING,
|
||||
MOCK_SCORE_RINGS,
|
||||
mockDashboardSnapshot,
|
||||
POPULATED_SUMMARIES,
|
||||
} from "./utils/mock-dashboard-snapshot";
|
||||
|
||||
/**
|
||||
* Phone-width density guard (390×844 iPhone class, 360×780 small
|
||||
* Android class).
|
||||
*
|
||||
* Two invariants, per the report against the dashboard hero (a long
|
||||
* briefing-signal headline squeezed into a one-word-per-line column
|
||||
* beside a wide delta, the delta spilling past the tile edge at 360 px):
|
||||
*
|
||||
* 1. No horizontal page overflow — `scrollWidth` never exceeds the
|
||||
* viewport width (1 px sub-pixel tolerance).
|
||||
* 2. Nothing escapes its tile — for every card / hero band / list
|
||||
* row, no descendant's visible box crosses the tile's left or
|
||||
* right edge (clip-aware: content genuinely hidden by an inner
|
||||
* `overflow-hidden` wrapper below the tile is fine; content cut
|
||||
* off at the tile's own edge is the bug).
|
||||
*
|
||||
* The snapshot mock pins the worst case: hero band on, three score
|
||||
* rings, and a fresh briefing whose signal rows pair wrapping German
|
||||
* headlines with wide delta strings.
|
||||
*
|
||||
* Desktop project only — the assertions are viewport-driven via
|
||||
* `setViewportSize` (the `ipad-viewport.spec.ts` pattern).
|
||||
*/
|
||||
const VIEWPORTS = [
|
||||
{ width: 390, height: 844 },
|
||||
{ width: 360, height: 780 },
|
||||
] as const;
|
||||
|
||||
const ROUTES = ["/", "/insights"] as const;
|
||||
|
||||
interface TileEscape {
|
||||
tile: string;
|
||||
child: string;
|
||||
overRight: number;
|
||||
overLeft: number;
|
||||
text: string;
|
||||
}
|
||||
|
||||
/** Serialised into the page: measure children escaping their tile. */
|
||||
function collectTileEscapes(): TileEscape[] {
|
||||
const escapes: TileEscape[] = [];
|
||||
const tiles = document.querySelectorAll(
|
||||
'[data-slot="card"], [data-slot="dashboard-hero"], [data-slot="list-row"]',
|
||||
);
|
||||
const TOLERANCE = 1;
|
||||
for (const tile of tiles) {
|
||||
const tileRect = tile.getBoundingClientRect();
|
||||
if (tileRect.width === 0) continue;
|
||||
for (const child of tile.querySelectorAll("*")) {
|
||||
const rect = child.getBoundingClientRect();
|
||||
if (rect.width === 0 && rect.height === 0) continue;
|
||||
if (getComputedStyle(child).position === "fixed") continue;
|
||||
// Clamp by clipping ancestors BELOW the tile only — the tile's
|
||||
// own overflow-hidden cutting content off is exactly the bug.
|
||||
let left = rect.left;
|
||||
let right = rect.right;
|
||||
let ancestor = child.parentElement;
|
||||
while (ancestor && ancestor !== tile) {
|
||||
if (getComputedStyle(ancestor).overflowX !== "visible") {
|
||||
const ancestorRect = ancestor.getBoundingClientRect();
|
||||
left = Math.max(left, ancestorRect.left);
|
||||
right = Math.min(right, ancestorRect.right);
|
||||
}
|
||||
ancestor = ancestor.parentElement;
|
||||
}
|
||||
if (right <= left) continue;
|
||||
const overRight = right - tileRect.right;
|
||||
const overLeft = tileRect.left - left;
|
||||
if (overRight > TOLERANCE || overLeft > TOLERANCE) {
|
||||
escapes.push({
|
||||
tile: tile.getAttribute("data-slot") ?? tile.tagName,
|
||||
child:
|
||||
child.tagName.toLowerCase() +
|
||||
(child.getAttribute("data-slot")
|
||||
? `[${child.getAttribute("data-slot")}]`
|
||||
: ""),
|
||||
overRight: Math.round(overRight * 10) / 10,
|
||||
overLeft: Math.round(overLeft * 10) / 10,
|
||||
text: (child.textContent ?? "").trim().slice(0, 60),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return escapes;
|
||||
}
|
||||
|
||||
test.describe("phone-width density guard", () => {
|
||||
test.use({ storageState: STORAGE_STATE_PATH });
|
||||
|
||||
test.beforeEach(({}, testInfo) => {
|
||||
test.skip(
|
||||
testInfo.project.name !== "chromium-desktop",
|
||||
"viewport-driven spec; desktop project only",
|
||||
);
|
||||
});
|
||||
|
||||
for (const viewport of VIEWPORTS) {
|
||||
for (const route of ROUTES) {
|
||||
test(`no overflow and no tile escape on ${route} at ${viewport.width}x${viewport.height}`, async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setViewportSize(viewport);
|
||||
await mockDashboardSnapshot(page, {
|
||||
summaries: POPULATED_SUMMARIES,
|
||||
heroVisible: true,
|
||||
briefing: LONG_HEADLINE_BRIEFING,
|
||||
scoreRings: MOCK_SCORE_RINGS,
|
||||
});
|
||||
await page.goto(route, { waitUntil: "domcontentloaded" });
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
if (route === "/") {
|
||||
// The guard must not pass vacuously — the hero band and its
|
||||
// long-headline spotlight rows have to actually render.
|
||||
await expect(
|
||||
page.locator('[data-slot="dashboard-hero"]'),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.locator('[data-slot="dashboard-briefing-spotlight-row"]'),
|
||||
).toHaveCount(3);
|
||||
}
|
||||
|
||||
const dims = await page.evaluate(() => ({
|
||||
scrollWidth: document.documentElement.scrollWidth,
|
||||
bodyScrollWidth: document.body.scrollWidth,
|
||||
innerWidth: window.innerWidth,
|
||||
}));
|
||||
expect(
|
||||
dims.scrollWidth,
|
||||
`page scrollWidth=${dims.scrollWidth}, innerWidth=${dims.innerWidth}`,
|
||||
).toBeLessThanOrEqual(dims.innerWidth + 1);
|
||||
expect(
|
||||
dims.bodyScrollWidth,
|
||||
`body scrollWidth=${dims.bodyScrollWidth}`,
|
||||
).toBeLessThanOrEqual(dims.innerWidth + 1);
|
||||
|
||||
const escapes = await page.evaluate(collectTileEscapes);
|
||||
expect(
|
||||
escapes,
|
||||
`tile escapes: ${JSON.stringify(escapes, null, 2)}`,
|
||||
).toEqual([]);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -4,6 +4,8 @@ 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,
|
||||
@@ -172,6 +174,54 @@ function buildLayoutCatalogue(): DashboardLayoutCatalogueEntry[] {
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -181,6 +231,12 @@ export interface MockSnapshotOptions {
|
||||
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. */
|
||||
@@ -207,7 +263,9 @@ export function buildMockSnapshot(
|
||||
onboardingTourCompleted: true,
|
||||
greetingHour: 9,
|
||||
},
|
||||
layout: DEFAULT_DASHBOARD_LAYOUT,
|
||||
layout: options.heroVisible
|
||||
? { ...DEFAULT_DASHBOARD_LAYOUT, heroVisible: true }
|
||||
: DEFAULT_DASHBOARD_LAYOUT,
|
||||
layoutCatalogue: buildLayoutCatalogue(),
|
||||
metricStates: {},
|
||||
targetBands: mockTargetBands(),
|
||||
@@ -226,10 +284,13 @@ export function buildMockSnapshot(
|
||||
nextDueOverdue: false,
|
||||
nextDueMedicationName: null,
|
||||
},
|
||||
healthScore: null,
|
||||
briefing: null,
|
||||
briefingState: "preparing",
|
||||
briefingUpdatedAt: 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,
|
||||
};
|
||||
|
||||
@@ -180,4 +180,34 @@ describe("<BriefingSpotlight>", () => {
|
||||
const rows = html.match(/data-slot="dashboard-briefing-spotlight-row"/g);
|
||||
expect(rows).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("stacks headline over delta below sm and keeps the row at sm+", () => {
|
||||
const html = render({
|
||||
briefing: briefing({
|
||||
signalsOfDay: [
|
||||
{
|
||||
sourceMetric: "bp",
|
||||
tone: "watch",
|
||||
headline:
|
||||
"Systolischer Blutdruck deutlich unter deinem Monatsmittel gemessen",
|
||||
nudge: "n",
|
||||
delta: "−12 mmHg vs. 30-Tage-Mittel",
|
||||
},
|
||||
],
|
||||
}),
|
||||
briefingState: "ready",
|
||||
briefingStale: false,
|
||||
});
|
||||
// Phone-first column, row restored at sm+ — a wide delta must never
|
||||
// squeeze the headline into a narrow multi-line column (or push
|
||||
// itself past the tile edge).
|
||||
expect(html).toMatch(
|
||||
/class="[^"]*flex min-w-0 flex-1 flex-col gap-1 sm:flex-row[^"]*"/,
|
||||
);
|
||||
// The delta is left-aligned content-width in the stacked state and
|
||||
// only refuses to shrink in the sm+ row.
|
||||
expect(html).toMatch(
|
||||
/data-slot="dashboard-briefing-spotlight-delta"[^>]*class="[^"]*self-start[^"]*sm:shrink-0[^"]*"/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -196,7 +196,14 @@ export function BriefingSpotlight({
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className="flex min-w-0 flex-1 items-start justify-between gap-3">
|
||||
{/* Phone widths stack headline over delta — a long
|
||||
signal headline beside a long delta squeezed the
|
||||
headline into a one-word-per-line column and pushed
|
||||
the delta past the tile edge. At `sm+` the
|
||||
side-by-side row returns. The headline always wraps
|
||||
(content is never truncated); only the delta is meta
|
||||
and may stay on one line. */}
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1 sm:flex-row sm:items-start sm:justify-between sm:gap-3">
|
||||
<p className="text-foreground text-sm leading-snug font-medium">
|
||||
{stripChartTokens(row.headline)}
|
||||
</p>
|
||||
@@ -204,7 +211,7 @@ export function BriefingSpotlight({
|
||||
<span
|
||||
data-slot="dashboard-briefing-spotlight-delta"
|
||||
className={cn(
|
||||
"shrink-0 text-xs font-semibold tabular-nums",
|
||||
"self-start text-xs font-semibold tabular-nums sm:shrink-0",
|
||||
TONE_TEXT_CLASSNAME[row.tone],
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -102,6 +102,20 @@ describe("<DailyBriefing>", () => {
|
||||
expect(html).toContain("+6 mmHg");
|
||||
});
|
||||
|
||||
it("stacks headline over delta below sm and keeps the row at sm+", () => {
|
||||
const html = render(<DailyBriefing briefing={baseBriefing} />);
|
||||
// Phone-first column (headline above, delta beneath), side-by-side
|
||||
// restored at sm+ — a wide delta must never squeeze the headline
|
||||
// into a narrow multi-line column.
|
||||
expect(html).toMatch(
|
||||
/class="flex flex-col gap-1 sm:flex-row sm:items-start sm:justify-between sm:gap-3"/,
|
||||
);
|
||||
// Delta badge stays content-width + left-aligned in the stacked state.
|
||||
expect(html).toMatch(
|
||||
/data-slot="daily-briefing-delta"[^>]*class="[^"]*self-start[^"]*sm:shrink-0[^"]*"/,
|
||||
);
|
||||
});
|
||||
|
||||
it("does NOT render a delta badge when delta is null", () => {
|
||||
const html = render(
|
||||
<DailyBriefing
|
||||
|
||||
@@ -157,6 +157,31 @@ describe("<HealthScoreCard>", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("stacks the explainer caption BELOW the delta line, not beside it", () => {
|
||||
// The caption is a multi-sentence read; rendering it inside the
|
||||
// delta's inline-flex row squeezed the short delta into a narrow
|
||||
// multi-line column on every viewport. The wrapper stacks instead.
|
||||
const html = ssr(
|
||||
<HealthScoreCard
|
||||
score={64}
|
||||
band="yellow"
|
||||
components={baseComponents}
|
||||
delta={-3}
|
||||
/>,
|
||||
);
|
||||
const wrapper = html.match(
|
||||
/data-slot="health-score-card-delta"[^>]*class="([^"]*)"/,
|
||||
);
|
||||
expect(wrapper).not.toBeNull();
|
||||
expect(wrapper?.[1]).toContain("space-y-1");
|
||||
expect(wrapper?.[1]).not.toContain("inline-flex");
|
||||
// The delta line's <p> closes before the explainer body opens —
|
||||
// the caption is a sibling below, never a flex neighbour.
|
||||
expect(html).toMatch(
|
||||
/vs last week[^<]*<\/span><\/p><span[^>]*data-slot="health-score-delta-explainer-body"/,
|
||||
);
|
||||
});
|
||||
|
||||
it("renders four component rows with their values", () => {
|
||||
const html = ssr(
|
||||
<HealthScoreCard
|
||||
|
||||
@@ -197,7 +197,9 @@ function DeltaBadge({
|
||||
<span
|
||||
data-slot="daily-briefing-delta"
|
||||
className={cn(
|
||||
"text-xs font-semibold tabular-nums",
|
||||
// `self-start` keeps the badge left-aligned + content-width when
|
||||
// the parent row stacks into a column below `sm`.
|
||||
"self-start text-xs font-semibold tabular-nums sm:shrink-0",
|
||||
TONE_TEXT_CLASSNAME[tone],
|
||||
)}
|
||||
>
|
||||
@@ -243,7 +245,11 @@ function BriefingRow({
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
{/* Phone widths stack headline over delta (same treatment as the
|
||||
dashboard spotlight rows): side-by-side squeezed a long
|
||||
headline into a narrow multi-line column beside a long delta.
|
||||
At `sm+` the row layout returns. */}
|
||||
<div className="flex flex-col gap-1 sm:flex-row sm:items-start sm:justify-between sm:gap-3">
|
||||
<p className="text-sm leading-snug font-medium">
|
||||
{stripChartTokens(headline)}
|
||||
</p>
|
||||
|
||||
@@ -407,41 +407,52 @@ export function HealthScoreCard({
|
||||
on phone-class viewports. The explainer only mounts when
|
||||
a numeric delta is available; the "no history yet"
|
||||
branch keeps the existing single-line caption. */}
|
||||
<p
|
||||
{/* The delta digit and the explainer caption stack — the caption
|
||||
is a multi-sentence read, and rendering it INSIDE the delta's
|
||||
inline-flex row squeezed the short delta line into a narrow
|
||||
multi-line column beside it on every viewport. The delta line
|
||||
keeps its icon row; the caption sits below at full width. */}
|
||||
<div
|
||||
data-slot="health-score-card-delta"
|
||||
className="text-muted-foreground inline-flex items-center gap-1 text-xs"
|
||||
className="text-muted-foreground space-y-1 text-xs"
|
||||
>
|
||||
{delta === null ? (
|
||||
<span>{t("insights.healthScore.deltaUnavailable")}</span>
|
||||
<p>{t("insights.healthScore.deltaUnavailable")}</p>
|
||||
) : (
|
||||
<>
|
||||
{delta > 0 && (
|
||||
<ArrowUp className="text-success h-3 w-3" aria-hidden="true" />
|
||||
)}
|
||||
{delta < 0 && (
|
||||
<ArrowDown
|
||||
className="text-destructive h-3 w-3"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
{delta === 0 && (
|
||||
<Minus
|
||||
className="text-muted-foreground h-3 w-3"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
<span aria-describedby={deltaExplainerId}>
|
||||
{t("insights.healthScore.deltaVsLastWeek", {
|
||||
delta: delta > 0 ? `+${delta}` : `${delta}`,
|
||||
})}
|
||||
</span>
|
||||
<p className="flex items-center gap-1">
|
||||
{delta > 0 && (
|
||||
<ArrowUp
|
||||
className="text-success h-3 w-3"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
{delta < 0 && (
|
||||
<ArrowDown
|
||||
className="text-destructive h-3 w-3"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
{delta === 0 && (
|
||||
<Minus
|
||||
className="text-muted-foreground h-3 w-3"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
<span aria-describedby={deltaExplainerId}>
|
||||
{t("insights.healthScore.deltaVsLastWeek", {
|
||||
delta: delta > 0 ? `+${delta}` : `${delta}`,
|
||||
})}
|
||||
</span>
|
||||
</p>
|
||||
<HealthScoreDeltaExplainer
|
||||
className="block"
|
||||
delta={delta}
|
||||
bodyId={deltaExplainerId}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* v1.18.6 — Rest Mode legibility. While an illness episode is
|
||||
active the server suppresses (never penalises) the score, so
|
||||
|
||||
Reference in new issue
Block a user