fix(insights): keep mood correlation captions inside their tiles on phone widths

The correlation-card header's right slot kept its icon-era shrink-0
after v1.22 replaced the info glyph with an inline caption — the
unshrinkable badge+caption row escaped the card's right edge by
14–39 px at 390/360 across all five cards and squeezed the title to a
2-line 36–42 % sliver. The caption now stacks under the title below sm
(only the compact strength badge stays pinned right); on >=sm the
inline layout is unchanged.

Same surface: the discovered-relations stat caption ('{n} gepaarte
Tage · r · q') stacks below its finding sentence on phone widths
instead of wrapping to a 4-line sliver at ~25 % of the row, and the
subsection caveat caption stacks under the subheading instead of
wrapping 9 lines beside it.

The phone-width density guard now sweeps /insights/mood too, with
mocked correlation + discovery payloads pinning the populated worst
case (five cards, three mood pairs) so the guard cannot pass vacuously.

H6
This commit is contained in:
Marc-André Bombeck committed 2026-07-07 12:06:36 +02:00
1 parent f0b16c1a39
commit 7db97eabca
4 files changed
+166 -7

No files matched your search

+18 -1
View File
@@ -7,6 +7,7 @@ import {
mockDashboardSnapshot,
POPULATED_SUMMARIES,
} from "./utils/mock-dashboard-snapshot";
import { mockMoodInsights } from "./utils/mock-mood-insights";
/**
* Phone-width density guard (390×844 iPhone class, 360×780 small
@@ -36,7 +37,7 @@ const VIEWPORTS = [
{ width: 360, height: 780 },
] as const;
const ROUTES = ["/", "/insights"] as const;
const ROUTES = ["/", "/insights", "/insights/mood"] as const;
interface TileEscape {
tile: string;
@@ -116,6 +117,9 @@ test.describe("phone-width density guard", () => {
briefing: LONG_HEADLINE_BRIEFING,
scoreRings: MOCK_SCORE_RINGS,
});
if (route === "/insights/mood") {
await mockMoodInsights(page);
}
await page.goto(route, { waitUntil: "domcontentloaded" });
await page.waitForLoadState("networkidle");
@@ -130,6 +134,19 @@ test.describe("phone-width density guard", () => {
).toHaveCount(3);
}
if (route === "/insights/mood") {
// Non-vacuous: the five populated correlation cards and the
// discovered-relations list — the audit's only user-facing
// tile-escape cluster — must actually render before the guard
// sweeps them.
await expect(
page.locator('[data-slot="mood-correlation-card"]'),
).toHaveCount(5);
await expect(
page.locator('[data-slot="mood-discovered-relations"]'),
).toBeVisible();
}
const dims = await page.evaluate(() => ({
scrollWidth: document.documentElement.scrollWidth,
bodyScrollWidth: document.body.scrollWidth,
+119
View File
@@ -0,0 +1,119 @@
import type { Page } from "@playwright/test";
/**
* Route mocks for the /insights/mood density guard.
*
* The correlation cards and the discovered-relations list only render with
* data, and the seeded e2e account has none — an unmocked run would pass the
* tile-escape guard vacuously against empty states. These payloads pin the
* measured worst case: five populated correlation cards whose header caption
* ("269 gepaarte Tage · r = 0.37") used to escape the card edge by 14–39 px
* at 390/360, plus mood-involving discovered pairs whose stat caption wrapped
* to a 4-line sliver beside the finding sentence.
*/
function correlation(r: number, strength: string, n: number) {
return {
result: { r, strength, n },
n,
points: Array.from({ length: 24 }, (_, i) => ({
x: 1 + (i % 5),
y: 40 + ((i * 7) % 55),
})),
};
}
const MOOD_INSIGHTS_PAYLOAD = {
summary: { totalEntries: 269, inTargetPct: 62 },
heatmap: {
windowDays: 90,
cells: [
{ date: "2026-07-01", score: 4, samples: 2 },
{ date: "2026-07-02", score: 3, samples: 1 },
],
},
distribution: [],
weekday: [],
timeOfDay: { reliable: false },
stability: null,
tags: [],
structuredTags: [],
tagInfluence: { flat: [], structured: [] },
betterDays: [],
tagMetricCrosstab: [],
factorCrosstab: [],
narratives: [],
correlations: {
sleep: correlation(0.37, "schwach", 269),
steps: correlation(0.52, "moderat", 231),
pulse: correlation(-0.44, "moderat", 258),
weight: correlation(0.21, "schwach", 244),
bloodPressureSystolic: correlation(-0.73, "stark", 212),
},
};
const CORRELATION_DISCOVERY_PAYLOAD = {
discovered: [
{
behaviour: "SLEEP_DURATION",
outcome: "MOOD",
n: 269,
r: 0.37,
pValue: 0.001,
qValue: 0.012,
interpretation: "",
lagDays: 1,
},
{
behaviour: "MOOD",
outcome: "RESTING_HEART_RATE",
n: 231,
r: -0.41,
pValue: 0.002,
qValue: 0.031,
interpretation: "",
lagDays: 1,
},
{
behaviour: "TIME_IN_DAYLIGHT",
outcome: "MOOD",
n: 187,
r: 0.29,
pValue: 0.004,
qValue: 0.048,
interpretation: "",
lagDays: 0,
},
],
pairsTested: 42,
fdrQ: 0.1,
minPairs: 20,
};
/** Serve populated mood-insights + discovery payloads for the density guard. */
export async function mockMoodInsights(page: Page): Promise<void> {
await page.route(/\/api\/insights\/comprehensive(\?|$)/, async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: { moodSummary: { count: 269 } },
error: null,
}),
});
});
await page.route(/\/api\/mood\/insights(\?|$)/, async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ data: MOOD_INSIGHTS_PAYLOAD, error: null }),
});
});
await page.route(/\/api\/insights\/correlations(\?|$)/, async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ data: CORRELATION_DISCOVERY_PAYLOAD, error: null }),
});
});
}
@@ -105,7 +105,13 @@ function MoodCorrelationCard({
title={t(TITLE_KEY[kind])}
right={
hasResult && data.result ? (
<div className="flex shrink-0 items-center gap-1.5">
// No `shrink-0` here: the slot kept its icon-era rigidity after
// v1.22 swapped the info glyph for an inline caption, and the
// unshrinkable badge+caption row escaped the card's right edge
// by 14–39 px on phone viewports. The caption itself moves
// below the title on <sm (see the sm:hidden twin under the
// header); only the compact badge stays pinned right.
<div className="flex min-w-0 items-center gap-1.5">
<Badge variant="outline" className="text-[10px]">
{t(STRENGTH_KEY[data.result.strength])}
</Badge>
@@ -113,6 +119,7 @@ function MoodCorrelationCard({
scatter as a half-empty row. Move it into an explainer icon
in the header so the card footprint stays tight. */}
<MoodExplainerIcon
className="hidden whitespace-nowrap sm:inline"
label={t("insights.mood.correlation.sourceLabel")}
detail={t("insights.mood.correlation.source", {
n: data.n,
@@ -123,6 +130,18 @@ function MoodCorrelationCard({
) : undefined
}
/>
{/* <sm twin of the header caption: stacked under the title so the
title keeps the row width and nothing can escape the tile. */}
{hasResult && data.result ? (
<MoodExplainerIcon
className="sm:hidden"
label={t("insights.mood.correlation.sourceLabel")}
detail={t("insights.mood.correlation.source", {
n: data.n,
r: data.result.r.toFixed(2),
})}
/>
) : null}
</CardHeader>
<CardContent className="space-y-3">
{hasResult && data.result ? (
@@ -90,7 +90,10 @@ export function MoodDiscoveredRelations({
// observational disclaimer fold into the one tooltip) so the surface stops
// spending a muted paragraph on a footnote.
<div data-slot="mood-discovered-relations" className="space-y-1">
<div className="text-foreground flex items-center gap-1.5 text-sm font-medium">
{/* <sm stacks the caveat caption under the subheading — beside a
2-line heading it wrapped to 9 lines at ~58 % of the row width on
phone viewports. */}
<div className="text-foreground flex flex-col gap-1 text-sm font-medium sm:flex-row sm:items-center sm:gap-1.5">
<span>{t("insights.mood.discovery.subheading")}</span>
<MoodExplainerIcon
label={t("insights.mood.discovery.explainerLabel")}
@@ -121,10 +124,11 @@ export function MoodDiscoveredRelations({
data-direction={up ? "up" : "down"}
>
{/* The {n} paired days · r · q numeric detail rides an explainer
icon on the same line as the finding so the list stays one
row per pair. The finding itself reads in the standard
foreground colour. */}
<span className="text-foreground flex items-center gap-1.5">
caption beside the finding on ≥sm; on phone widths it stacks
below the sentence instead of squeezing to a 4-line sliver
at ~25 % of the row. The finding itself reads in the
standard foreground colour. */}
<span className="text-foreground flex flex-col gap-1 sm:flex-row sm:items-center sm:gap-1.5">
<span>{t(sentenceKey, { factor: factorLabel })}</span>
<MoodExplainerIcon
label={t("insights.mood.discovery.statLabel")}