diff --git a/e2e/mobile-density.spec.ts b/e2e/mobile-density.spec.ts index 5478a0919..9a56f140c 100644 --- a/e2e/mobile-density.spec.ts +++ b/e2e/mobile-density.spec.ts @@ -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, diff --git a/e2e/utils/mock-mood-insights.ts b/e2e/utils/mock-mood-insights.ts new file mode 100644 index 000000000..e8dc21e8e --- /dev/null +++ b/e2e/utils/mock-mood-insights.ts @@ -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 { + 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 }), + }); + }); +} diff --git a/src/components/insights/mood/mood-correlation-cards.tsx b/src/components/insights/mood/mood-correlation-cards.tsx index 4efb3f351..bb3bd5e9c 100644 --- a/src/components/insights/mood/mood-correlation-cards.tsx +++ b/src/components/insights/mood/mood-correlation-cards.tsx @@ -105,7 +105,13 @@ function MoodCorrelationCard({ title={t(TITLE_KEY[kind])} right={ hasResult && data.result ? ( -
+ // 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 {t(STRENGTH_KEY[data.result.strength])} @@ -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. */} + {/* + ) : null} {hasResult && data.result ? ( diff --git a/src/components/insights/mood/mood-discovered-relations.tsx b/src/components/insights/mood/mood-discovered-relations.tsx index b56b70d1e..7440d8c36 100644 --- a/src/components/insights/mood/mood-discovered-relations.tsx +++ b/src/components/insights/mood/mood-discovered-relations.tsx @@ -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.
-
+ {/* {t("insights.mood.discovery.subheading")} {/* 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. */} - + 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. */} + {t(sentenceKey, { factor: factorLabel })}