fix(insights): row-fill rule + equal-height trends + hide-on-insufficient-data

Three layout problems landed together because they share the same
root: the `/insights` grids assumed every row would be full, so when a
section dropped a card the layout left a half-row of empty space.

  - Row-fill rule: every grid on `/insights` now picks `xl:grid-cols-2`
    only when 2+ cards actually render. With 1 card the grid collapses
    to a single full-width column. The BP-medication grid, the weight-
    correlation grid, and the medications-per-day grid all carried the
    half-empty-row bug; all three are normalised.

  - Trends row equal heights: each `<TrendsRow>` card is now a flex
    column with `min-h-[300px]` so a multi-line AI annotation under one
    chart doesn't push that card taller than its neighbours. The
    visual rhythm holds even when the prose varies in length.

  - Hide insufficient-data correlation tiles: `<CorrelationRow>` drops
    any hypothesis with `status !== "ok"` instead of painting a
    placeholder. When zero hypotheses qualify, the entire row hides
    itself (no header, no disclaimer). 1 ok card spans 100 % width, 2
    or 3 ok cards split 50/50 on `>=md`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-10 20:49:55 +02:00
1 parent 706aa5fd73
commit 36b9db34c5
4 files changed
+113 -41

No files matched your search

+25 -3
View File
@@ -1060,7 +1060,15 @@ export default function InsightsPage() {
annotations={bpStoryboardAnnotations}
/>
<div className="grid gap-4 xl:grid-cols-2">
{/* v1.4.22 A4 — row-fill rule: 2 cards → 50/50 (xl), 1 card →
100 % width. When the mood section is hidden the BP-medication
card collapses to full-width instead of leaving the right
half empty. */}
<div
className={
showMoodSection ? "grid gap-4 xl:grid-cols-2" : "grid gap-4"
}
>
<Card>
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
@@ -1262,7 +1270,13 @@ export default function InsightsPage() {
compareBaseline={compareBaseline}
/>
<div className="grid gap-4 xl:grid-cols-2">
{/* v1.4.22 A4 — row-fill rule: 2 cards → 50/50 (xl), 1 card →
100 % width. Mirrors the BP-section grid normalisation. */}
<div
className={
showMoodSection ? "grid gap-4 xl:grid-cols-2" : "grid gap-4"
}
>
<Card>
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
@@ -1483,7 +1497,15 @@ export default function InsightsPage() {
</div>
{data.medications.length > 0 ? (
<div className="grid gap-4 sm:grid-cols-2">
/* v1.4.22 A4 — row-fill rule: a single medication card fills
100 % width instead of half the row. */
<div
className={
data.medications.length >= 2
? "grid gap-4 sm:grid-cols-2"
: "grid gap-4"
}
>
{data.medications.map((med) => {
const medicationSummary = medicationSummaryById.get(med.id);
return (
@@ -5,13 +5,17 @@ import type { CorrelationResult } from "@/lib/insights/correlations";
/**
* v1.4.20 phase B3 — `<CorrelationRow>` layout + disclaimer.
* v1.4.22 A4 update — empty-state cards are dropped instead of rendered.
*
* Acceptance:
* 1. Renders the three hypothesis cards.
* 2. Mounts the correlation-disclaimer footer once at the row level.
* 3. 2-up grid on >= md, single column on < md.
* 4. Below-threshold cards delegate their empty-state to
* `<CorrelationCard>` (smoke-checked via the per-card slot).
* 1. Renders only the hypothesis cards whose `status === "ok"`.
* 2. Mounts the correlation-disclaimer footer once at the row level
* when at least one card renders; the row hides itself entirely
* when zero cards render (no header, no disclaimer).
* 3. 2-up grid on >= md when 2+ cards, full-width single column when
* exactly 1 card renders.
* 4. Below-threshold cards are filtered at the row level so the
* layout collapses cleanly.
*/
vi.mock("next/dynamic", () => ({
@@ -67,7 +71,7 @@ const okWeightWeekday: CorrelationResult = {
};
describe("<CorrelationRow>", () => {
it("renders all three hypothesis cards", () => {
it("renders only the ok-status hypothesis cards (insufficient ones are dropped)", () => {
const html = render(
<CorrelationRow
results={{
@@ -78,9 +82,9 @@ describe("<CorrelationRow>", () => {
/>,
);
const matches = html.match(/data-slot="correlation-card"/g) ?? [];
expect(matches.length).toBe(3);
expect(matches.length).toBe(2);
expect(html).toMatch(/data-kind="bp-compliance"/);
expect(html).toMatch(/data-kind="mood-pulse"/);
expect(html).not.toMatch(/data-kind="mood-pulse"/);
expect(html).toMatch(/data-kind="weight-weekday"/);
});
@@ -99,7 +103,7 @@ describe("<CorrelationRow>", () => {
expect(html).toContain("Patterns are observational, not causal");
});
it("applies the 2-up grid on >= md and single column on < md", () => {
it("applies the 2-up grid on >= md when 2+ ok cards render", () => {
const html = render(
<CorrelationRow
results={{
@@ -113,6 +117,36 @@ describe("<CorrelationRow>", () => {
expect(html).toMatch(/md:grid-cols-2/);
});
it("collapses to a full-width single column when exactly 1 ok card renders", () => {
const html = render(
<CorrelationRow
results={{
bpCompliance: okBpCompliance,
moodPulse: insufficientMoodPulse,
weightWeekday: insufficientMoodPulse,
}}
/>,
);
expect(html).toMatch(/grid-cols-1/);
// The 2-up rule must NOT activate when only one card has data —
// a single card should span 100 % width on its own row instead of
// half-width with empty space next to it.
expect(html).not.toMatch(/md:grid-cols-2/);
});
it("hides the row entirely (no header, no disclaimer) when zero cards have data", () => {
const html = render(
<CorrelationRow
results={{
bpCompliance: insufficientMoodPulse,
moodPulse: insufficientMoodPulse,
weightWeekday: insufficientMoodPulse,
}}
/>,
);
expect(html).toBe("");
});
it("renders German copy when locale=de", () => {
const html = render(
<CorrelationRow
@@ -124,21 +158,6 @@ describe("<CorrelationRow>", () => {
/>,
"de",
);
expect(html).toContain("Muster");
expect(html).toContain("nicht kausal");
});
it("delegates empty-state to per-card empty-state when below threshold", () => {
const html = render(
<CorrelationRow
results={{
bpCompliance: insufficientMoodPulse,
moodPulse: insufficientMoodPulse,
weightWeekday: insufficientMoodPulse,
}}
/>,
);
const matches = html.match(/Need more data to see this pattern/g) ?? [];
expect(matches.length).toBe(3);
});
});
+31 -11
View File
@@ -7,14 +7,18 @@ import { CorrelationCard } from "./correlation-card";
/**
* v1.4.20 phase B3 — Correlation row.
*
* Renders the three pre-defined hypothesis cards in a 2-up grid on
* `>=md` and a single column on `<md`. A single correlation-disclaimer
* footer sits below the row — the disclaimer applies to every card so
* we don't repeat it per-card.
* Renders the pre-defined hypothesis cards in a 2-up grid on `>=md` and
* a single column on `<md`. A single correlation-disclaimer footer sits
* below the row — the disclaimer applies to every card so we don't
* repeat it per-card.
*
* The `<CorrelationCard>` itself owns the empty-state for below-
* threshold results, so this row never has to filter — it always
* mounts all three cards regardless of `status`.
* v1.4.22 A4 — empty-state cards are dropped instead of rendered. Any
* hypothesis whose `status !== "ok"` is filtered out so the layout
* collapses cleanly: 2 ok cards → 50/50, 1 ok card → 100 %, 0 ok cards
* → the row hides itself entirely (no header, no disclaimer). Up to
* v1.4.21 the `<CorrelationCard>` painted an EmptyState for below-
* threshold results, which left half-rows of greyed-out placeholders
* on a sparse account.
*/
interface CorrelationRowProps {
@@ -28,6 +32,16 @@ interface CorrelationRowProps {
export function CorrelationRow({ results }: CorrelationRowProps) {
const { t } = useTranslations();
// Drop insufficient-data tiles up-front. The grid below picks 1-col vs
// 2-col automatically so 1 ok card spans 100 % width on its own row.
const okResults = [
results.bpCompliance,
results.moodPulse,
results.weightWeekday,
].filter((r) => r.status === "ok");
if (okResults.length === 0) return null;
return (
<section
data-slot="correlation-row"
@@ -42,10 +56,16 @@ export function CorrelationRow({ results }: CorrelationRowProps) {
{t("insights.correlationRow.subtitle")}
</p>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<CorrelationCard result={results.bpCompliance} />
<CorrelationCard result={results.moodPulse} />
<CorrelationCard result={results.weightWeekday} />
<div
className={
okResults.length === 1
? "grid grid-cols-1 gap-4"
: "grid grid-cols-1 gap-4 md:grid-cols-2"
}
>
{okResults.map((result) => (
<CorrelationCard key={result.kind} result={result} />
))}
</div>
<p
data-slot="correlation-row-disclaimer"
+14 -3
View File
@@ -95,8 +95,19 @@ export function TrendsRow({ annotations, confidence }: TrendsRowProps) {
{t("insights.trendsRow.subtitle")}
</p>
</div>
{/* v1.4.22 A4 — equal-height cards. The AI annotation prose
below each chart varies in length, which used to leave the
three cards on visibly different baselines. Each card is now
a flex column with `min-h-[300px]` so the chart anchors to
the top and the annotation grows downward into a shared
minimum height. The grid + min-h together preserve the visual
rhythm even on a row where only one annotation is multi-line. */}
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<div data-slot="trends-row-card" data-metric="bp" className="space-y-2">
<div
data-slot="trends-row-card"
data-metric="bp"
className="flex min-h-[300px] flex-col gap-2"
>
<HealthChart
types={["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"]}
title={t("charts.bloodPressure")}
@@ -114,7 +125,7 @@ export function TrendsRow({ annotations, confidence }: TrendsRowProps) {
<div
data-slot="trends-row-card"
data-metric="weight"
className="space-y-2"
className="flex min-h-[300px] flex-col gap-2"
>
<HealthChart
types={["WEIGHT"]}
@@ -132,7 +143,7 @@ export function TrendsRow({ annotations, confidence }: TrendsRowProps) {
<div
data-slot="trends-row-card"
data-metric="mood"
className="space-y-2"
className="flex min-h-[300px] flex-col gap-2"
>
<MoodChart title={t("charts.mood")} mini />
<TrendAnnotation