mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
1 parent
f0b16c1a39
commit
7db97eabca
4 files changed
+166
-7
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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")}
|
||||
|
||||
Reference in new issue
Block a user