mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
1 parent
706aa5fd73
commit
36b9db34c5
4 files changed
+113
-41
No files matched your search
@@ -1060,7 +1060,15 @@ export default function InsightsPage() {
|
|||||||
annotations={bpStoryboardAnnotations}
|
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>
|
<Card>
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -1262,7 +1270,13 @@ export default function InsightsPage() {
|
|||||||
compareBaseline={compareBaseline}
|
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>
|
<Card>
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -1483,7 +1497,15 @@ export default function InsightsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{data.medications.length > 0 ? (
|
{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) => {
|
{data.medications.map((med) => {
|
||||||
const medicationSummary = medicationSummaryById.get(med.id);
|
const medicationSummary = medicationSummaryById.get(med.id);
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -5,13 +5,17 @@ import type { CorrelationResult } from "@/lib/insights/correlations";
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* v1.4.20 phase B3 — `<CorrelationRow>` layout + disclaimer.
|
* v1.4.20 phase B3 — `<CorrelationRow>` layout + disclaimer.
|
||||||
|
* v1.4.22 A4 update — empty-state cards are dropped instead of rendered.
|
||||||
*
|
*
|
||||||
* Acceptance:
|
* Acceptance:
|
||||||
* 1. Renders the three hypothesis cards.
|
* 1. Renders only the hypothesis cards whose `status === "ok"`.
|
||||||
* 2. Mounts the correlation-disclaimer footer once at the row level.
|
* 2. Mounts the correlation-disclaimer footer once at the row level
|
||||||
* 3. 2-up grid on >= md, single column on < md.
|
* when at least one card renders; the row hides itself entirely
|
||||||
* 4. Below-threshold cards delegate their empty-state to
|
* when zero cards render (no header, no disclaimer).
|
||||||
* `<CorrelationCard>` (smoke-checked via the per-card slot).
|
* 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", () => ({
|
vi.mock("next/dynamic", () => ({
|
||||||
@@ -67,7 +71,7 @@ const okWeightWeekday: CorrelationResult = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
describe("<CorrelationRow>", () => {
|
describe("<CorrelationRow>", () => {
|
||||||
it("renders all three hypothesis cards", () => {
|
it("renders only the ok-status hypothesis cards (insufficient ones are dropped)", () => {
|
||||||
const html = render(
|
const html = render(
|
||||||
<CorrelationRow
|
<CorrelationRow
|
||||||
results={{
|
results={{
|
||||||
@@ -78,9 +82,9 @@ describe("<CorrelationRow>", () => {
|
|||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
const matches = html.match(/data-slot="correlation-card"/g) ?? [];
|
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="bp-compliance"/);
|
||||||
expect(html).toMatch(/data-kind="mood-pulse"/);
|
expect(html).not.toMatch(/data-kind="mood-pulse"/);
|
||||||
expect(html).toMatch(/data-kind="weight-weekday"/);
|
expect(html).toMatch(/data-kind="weight-weekday"/);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -99,7 +103,7 @@ describe("<CorrelationRow>", () => {
|
|||||||
expect(html).toContain("Patterns are observational, not causal");
|
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(
|
const html = render(
|
||||||
<CorrelationRow
|
<CorrelationRow
|
||||||
results={{
|
results={{
|
||||||
@@ -113,6 +117,36 @@ describe("<CorrelationRow>", () => {
|
|||||||
expect(html).toMatch(/md:grid-cols-2/);
|
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", () => {
|
it("renders German copy when locale=de", () => {
|
||||||
const html = render(
|
const html = render(
|
||||||
<CorrelationRow
|
<CorrelationRow
|
||||||
@@ -124,21 +158,6 @@ describe("<CorrelationRow>", () => {
|
|||||||
/>,
|
/>,
|
||||||
"de",
|
"de",
|
||||||
);
|
);
|
||||||
expect(html).toContain("Muster");
|
|
||||||
expect(html).toContain("nicht kausal");
|
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);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
@@ -7,14 +7,18 @@ import { CorrelationCard } from "./correlation-card";
|
|||||||
/**
|
/**
|
||||||
* v1.4.20 phase B3 — Correlation row.
|
* v1.4.20 phase B3 — Correlation row.
|
||||||
*
|
*
|
||||||
* Renders the three pre-defined hypothesis cards in a 2-up grid on
|
* Renders the pre-defined hypothesis cards in a 2-up grid on `>=md` and
|
||||||
* `>=md` and a single column on `<md`. A single correlation-disclaimer
|
* a single column on `<md`. A single correlation-disclaimer footer sits
|
||||||
* footer sits below the row — the disclaimer applies to every card so
|
* below the row — the disclaimer applies to every card so we don't
|
||||||
* we don't repeat it per-card.
|
* repeat it per-card.
|
||||||
*
|
*
|
||||||
* The `<CorrelationCard>` itself owns the empty-state for below-
|
* v1.4.22 A4 — empty-state cards are dropped instead of rendered. Any
|
||||||
* threshold results, so this row never has to filter — it always
|
* hypothesis whose `status !== "ok"` is filtered out so the layout
|
||||||
* mounts all three cards regardless of `status`.
|
* 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 {
|
interface CorrelationRowProps {
|
||||||
@@ -28,6 +32,16 @@ interface CorrelationRowProps {
|
|||||||
export function CorrelationRow({ results }: CorrelationRowProps) {
|
export function CorrelationRow({ results }: CorrelationRowProps) {
|
||||||
const { t } = useTranslations();
|
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 (
|
return (
|
||||||
<section
|
<section
|
||||||
data-slot="correlation-row"
|
data-slot="correlation-row"
|
||||||
@@ -42,10 +56,16 @@ export function CorrelationRow({ results }: CorrelationRowProps) {
|
|||||||
{t("insights.correlationRow.subtitle")}
|
{t("insights.correlationRow.subtitle")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
<div
|
||||||
<CorrelationCard result={results.bpCompliance} />
|
className={
|
||||||
<CorrelationCard result={results.moodPulse} />
|
okResults.length === 1
|
||||||
<CorrelationCard result={results.weightWeekday} />
|
? "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>
|
</div>
|
||||||
<p
|
<p
|
||||||
data-slot="correlation-row-disclaimer"
|
data-slot="correlation-row-disclaimer"
|
||||||
|
|||||||
@@ -95,8 +95,19 @@ export function TrendsRow({ annotations, confidence }: TrendsRowProps) {
|
|||||||
{t("insights.trendsRow.subtitle")}
|
{t("insights.trendsRow.subtitle")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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 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
|
<HealthChart
|
||||||
types={["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"]}
|
types={["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"]}
|
||||||
title={t("charts.bloodPressure")}
|
title={t("charts.bloodPressure")}
|
||||||
@@ -114,7 +125,7 @@ export function TrendsRow({ annotations, confidence }: TrendsRowProps) {
|
|||||||
<div
|
<div
|
||||||
data-slot="trends-row-card"
|
data-slot="trends-row-card"
|
||||||
data-metric="weight"
|
data-metric="weight"
|
||||||
className="space-y-2"
|
className="flex min-h-[300px] flex-col gap-2"
|
||||||
>
|
>
|
||||||
<HealthChart
|
<HealthChart
|
||||||
types={["WEIGHT"]}
|
types={["WEIGHT"]}
|
||||||
@@ -132,7 +143,7 @@ export function TrendsRow({ annotations, confidence }: TrendsRowProps) {
|
|||||||
<div
|
<div
|
||||||
data-slot="trends-row-card"
|
data-slot="trends-row-card"
|
||||||
data-metric="mood"
|
data-metric="mood"
|
||||||
className="space-y-2"
|
className="flex min-h-[300px] flex-col gap-2"
|
||||||
>
|
>
|
||||||
<MoodChart title={t("charts.mood")} mini />
|
<MoodChart title={t("charts.mood")} mini />
|
||||||
<TrendAnnotation
|
<TrendAnnotation
|
||||||
|
|||||||
Reference in new issue
Block a user