mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
refactor(insights): de-duplicate and reorder the overview to match the per-metric pages
The per-metric pages are the canonical home for each metric's primary tile, target band, and correlation card. The overview was still rendering its own correlation row (weight × weekday, mood × pulse, BP × compliance) — the exact cross-view duplication to kill now that the Weight and Pulse pages own those cards via MetricCorrelationCard. Remove the on-overview CorrelationRow (component + test) and reorder the overview toward the iOS Insights structure: hero (coach + briefing + prompt chips) → detailed daily briefing → dynamics/alerts (today's signal + rhythm events) → vitals dashboard → trends → period retrospective → warm-assessments footer control. The overview carried no target-tile grid, so nothing to remove there. Drop the now-dead insights.correlationRow.subtitle key across all six locales; title and disclaimer stay (the per-metric correlation card still uses them).
This commit is contained in:
1 parent
81096b9553
commit
2bb810852e
10 files changed
+57
-297
No files matched your search
@@ -1705,7 +1705,6 @@
|
||||
},
|
||||
"correlationRow": {
|
||||
"title": "Zusammenhänge",
|
||||
"subtitle": "Vorab definierte Hypothesen, gegated auf n ≥ 14 und p < 0,05",
|
||||
"disclaimer": "Zusammenhänge sind beobachtend, nicht kausal — sprich mit deinem Arzt, wenn der Blutdruck hoch ist.",
|
||||
"experimentCta": "7-Tage-Experiment starten",
|
||||
"experimentTooltip": "Bald verfügbar",
|
||||
|
||||
@@ -1705,7 +1705,6 @@
|
||||
},
|
||||
"correlationRow": {
|
||||
"title": "Relationships",
|
||||
"subtitle": "Pre-defined hypotheses, gated on n ≥ 14 and p < 0.05",
|
||||
"disclaimer": "Relationships are observational, not causal — talk to your doctor if BP runs high.",
|
||||
"experimentCta": "Try a 7-day experiment",
|
||||
"experimentTooltip": "Coming soon",
|
||||
|
||||
@@ -1705,7 +1705,6 @@
|
||||
},
|
||||
"correlationRow": {
|
||||
"title": "Correlaciones",
|
||||
"subtitle": "Hipótesis predefinidas, con umbral n ≥ 14 y p < 0,05",
|
||||
"disclaimer": "Las correlaciones son observacionales, no causales — habla con tu médico si tu tensión es alta.",
|
||||
"experimentCta": "Iniciar experimento de 7 días",
|
||||
"experimentTooltip": "Próximamente",
|
||||
|
||||
@@ -1705,7 +1705,6 @@
|
||||
},
|
||||
"correlationRow": {
|
||||
"title": "Corrélations",
|
||||
"subtitle": "Hypothèses prédéfinies, validées si n ≥ 14 et p < 0,05",
|
||||
"disclaimer": "Les corrélations sont observationnelles, pas causales — parle à ton médecin si ta tension est élevée.",
|
||||
"experimentCta": "Lancer une expérience de 7 jours",
|
||||
"experimentTooltip": "Bientôt disponible",
|
||||
|
||||
@@ -1705,7 +1705,6 @@
|
||||
},
|
||||
"correlationRow": {
|
||||
"title": "Correlazioni",
|
||||
"subtitle": "Ipotesi predefinite, vincolate a n ≥ 14 e p < 0,05",
|
||||
"disclaimer": "Le correlazioni sono osservazionali, non causali — parla con il medico se la pressione è alta.",
|
||||
"experimentCta": "Avvia esperimento di 7 giorni",
|
||||
"experimentTooltip": "In arrivo",
|
||||
|
||||
@@ -1705,7 +1705,6 @@
|
||||
},
|
||||
"correlationRow": {
|
||||
"title": "Korelacje",
|
||||
"subtitle": "Hipotezy zdefiniowane wcześniej, z progiem n ≥ 14 i p < 0,05",
|
||||
"disclaimer": "Korelacje są obserwacyjne, nie przyczynowe — porozmawiaj z lekarzem, jeśli ciśnienie jest wysokie.",
|
||||
"experimentCta": "Uruchom 7-dniowy eksperyment",
|
||||
"experimentTooltip": "Wkrótce",
|
||||
|
||||
@@ -3,13 +3,20 @@ import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
/**
|
||||
* v1.4.20 phase B3 — guards for the `/insights` wiring of the new
|
||||
* Trends row + Correlation row.
|
||||
* v1.4.20 phase B3 — guards for the `/insights` wiring of the
|
||||
* Trends row + the analytics correlations payload.
|
||||
*
|
||||
* The page-source scan pattern mirrors `insights-polish.test.ts` —
|
||||
* each test pins one load-bearing import / JSX mount so a future
|
||||
* refactor can't accidentally drop a wave of features without
|
||||
* breaking a CI lane.
|
||||
*
|
||||
* v1.12.0 — the on-overview correlation row was removed: the per-metric
|
||||
* correlation cards moved onto the metric pages they belong to (Weight
|
||||
* owns weight × weekday, Pulse owns mood × pulse), so the overview no
|
||||
* longer duplicates them. The analytics route still surfaces the
|
||||
* `correlations` block (the per-metric `MetricCorrelationCard` reads it),
|
||||
* so those backend guards stay; the overview-mount guards were dropped.
|
||||
*/
|
||||
|
||||
const ROOT = join(__dirname, "../../..");
|
||||
@@ -24,25 +31,25 @@ function load(path: string): string {
|
||||
return readFileSync(path, "utf8");
|
||||
}
|
||||
|
||||
describe("v1.4.20 B3 — /insights mounts CorrelationRow + TrendsRow", () => {
|
||||
it("imports both new components", () => {
|
||||
describe("v1.4.20 B3 — /insights mounts TrendsRow", () => {
|
||||
it("imports the trends-row component", () => {
|
||||
// v1.4.33 IW2 deferred below-the-fold blocks behind `next/dynamic`,
|
||||
// so the static `from "..."` import-string was replaced with a
|
||||
// `dynamic(() => import("..."))` call. Either spelling counts as
|
||||
// a load-bearing reference to the module path.
|
||||
const src = load(INSIGHTS_PATH);
|
||||
expect(src).toMatch(
|
||||
/(?:from\s+"@\/components\/insights\/correlation-row"|import\("@\/components\/insights\/correlation-row"\))/,
|
||||
);
|
||||
expect(src).toMatch(
|
||||
/(?:from\s+"@\/components\/insights\/trends-row"|import\("@\/components\/insights\/trends-row"\))/,
|
||||
);
|
||||
});
|
||||
|
||||
it("mounts <CorrelationRow> behind a non-null analytics.correlations guard", () => {
|
||||
it("no longer mounts an on-overview correlation row (relocated to the metric pages)", () => {
|
||||
// v1.12.0 — the correlation cards live on the per-metric pages now.
|
||||
// The overview must not duplicate them: neither the module import
|
||||
// nor the JSX mount may reappear here.
|
||||
const src = load(INSIGHTS_PATH);
|
||||
expect(src).toMatch(/analytics\?\.correlations\s*&&\s*\(/);
|
||||
expect(src).toMatch(/<CorrelationRow\b/);
|
||||
expect(src).not.toMatch(/components\/insights\/correlation-row/);
|
||||
expect(src).not.toMatch(/<CorrelationRow\b/);
|
||||
});
|
||||
|
||||
it("mounts <TrendsRow> with annotations from the advisor payload", () => {
|
||||
|
||||
+40
-40
@@ -69,16 +69,6 @@ const DailyBriefing = dynamic(
|
||||
loading: () => <BlockSkeleton minHeight="min-h-96" />,
|
||||
},
|
||||
);
|
||||
const CorrelationRow = dynamic(
|
||||
() =>
|
||||
import("@/components/insights/correlation-row").then((mod) => ({
|
||||
default: mod.CorrelationRow,
|
||||
})),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => <BlockSkeleton minHeight="min-h-80" />,
|
||||
},
|
||||
);
|
||||
const TrendsRow = dynamic(
|
||||
() =>
|
||||
import("@/components/insights/trends-row").then((mod) => ({
|
||||
@@ -160,8 +150,11 @@ const PeriodNarrativeCard = dynamic(
|
||||
* - The sticky tab strip lives in `src/app/insights/layout.tsx`
|
||||
* (the shared `<InsightsLayoutShell>` mounts it). The strip handles
|
||||
* navigation to every sub-page + the regenerate affordance.
|
||||
* - Hero + DailyBriefing + Correlation row + Trends row + advisor
|
||||
* card stay here — they're the cross-metric overview.
|
||||
* - Hero + DailyBriefing + Trends row + advisor card stay here —
|
||||
* they're the cross-metric overview. The per-metric correlation
|
||||
* cards moved onto the metric pages they belong to (Weight owns
|
||||
* weight × weekday, Pulse owns mood × pulse, …), so the overview
|
||||
* no longer renders a duplicate correlation row.
|
||||
* - The CoachDrawer is mounted in the mother-page body only (Marc
|
||||
* directive). Navigating to a sub-page unmounts the drawer.
|
||||
*
|
||||
@@ -223,10 +216,12 @@ export default function InsightsPage() {
|
||||
// beyond the React-state subscription.
|
||||
const advisor = useInsightsAdvisorQuery(isAuthenticated);
|
||||
|
||||
// v1.4.33 IW2 — the mother page reads `correlations` + `healthScore`
|
||||
// (thick-only fields) so it stays on the default thick slice. The
|
||||
// shared hook still centralises the cache settings so the consumer
|
||||
// dedups with the sub-page mounts that ride the slim slice instead.
|
||||
// v1.4.33 IW2 — the mother page reads `healthScore` (a thick-only
|
||||
// field) for the hero score card, so it stays on the default thick
|
||||
// slice. The shared hook still centralises the cache settings so the
|
||||
// consumer dedups with the sub-page mounts that ride the slim slice
|
||||
// instead. Correlations now live on the per-metric pages, so the
|
||||
// overview no longer reads `analytics.correlations`.
|
||||
const analyticsQuery = useAnalyticsQuery();
|
||||
const analytics = analyticsQuery.data as AnalyticsData | undefined;
|
||||
|
||||
@@ -278,6 +273,15 @@ export default function InsightsPage() {
|
||||
// server children would require a Server-Component refactor; that's
|
||||
// a v1.5.x track.
|
||||
|
||||
// v1.12.0 — section order mirrors the iOS Insights overview
|
||||
// (`v0.14` app-structure handover §2c): Coach hero + briefing + chips
|
||||
// (the HeroStrip bundles all three) → detailed "Heute auf einen Blick"
|
||||
// briefing → dynamics / alerts zone (today's signal + rhythm events,
|
||||
// out of the AI gate so a health alert is never hidden) → vitals
|
||||
// dashboard → trends row → "Zeitraum im Rückblick" retrospective →
|
||||
// footer (the warm-assessments utility control, no iOS equivalent —
|
||||
// iOS auto-warms). The per-metric correlation cards moved onto the
|
||||
// metric pages, so the overview renders no correlation row.
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<HeroStrip
|
||||
@@ -297,8 +301,28 @@ export default function InsightsPage() {
|
||||
healthScore={analytics?.healthScore ?? undefined}
|
||||
/>
|
||||
|
||||
{flags.briefing && (
|
||||
<DailyBriefing
|
||||
briefing={briefingPayload}
|
||||
updatedAt={heroStripUpdatedAt}
|
||||
loading={advisor.isLoading}
|
||||
onRegenerate={advisor.regenerate}
|
||||
regenerating={advisor.isRegenerating}
|
||||
/>
|
||||
)}
|
||||
|
||||
<CoincidentDeviationCard enabled={isAuthenticated} />
|
||||
|
||||
<RhythmEventsCard enabled={isAuthenticated} />
|
||||
|
||||
<VitalsDashboard enabled={isAuthenticated} />
|
||||
|
||||
<TrendsRow
|
||||
briefing={briefingPayload}
|
||||
annotations={advisor.payload?.trendAnnotations ?? null}
|
||||
loading={advisor.isLoading || advisor.isRegenerating}
|
||||
/>
|
||||
|
||||
{flags.briefing && <PeriodNarrativeCard enabled={isAuthenticated} />}
|
||||
|
||||
{/* v1.11.3 — anchor the warm-assessments control. It used to float
|
||||
@@ -320,30 +344,6 @@ export default function InsightsPage() {
|
||||
{t("insights.warmAssessments")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<VitalsDashboard enabled={isAuthenticated} />
|
||||
|
||||
<RhythmEventsCard enabled={isAuthenticated} />
|
||||
|
||||
{flags.briefing && (
|
||||
<DailyBriefing
|
||||
briefing={briefingPayload}
|
||||
updatedAt={heroStripUpdatedAt}
|
||||
loading={advisor.isLoading}
|
||||
onRegenerate={advisor.regenerate}
|
||||
regenerating={advisor.isRegenerating}
|
||||
/>
|
||||
)}
|
||||
|
||||
{flags.correlations && analytics?.correlations && (
|
||||
<CorrelationRow results={analytics.correlations} />
|
||||
)}
|
||||
|
||||
<TrendsRow
|
||||
briefing={briefingPayload}
|
||||
annotations={advisor.payload?.trendAnnotations ?? null}
|
||||
loading={advisor.isLoading || advisor.isRegenerating}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,163 +0,0 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
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 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", () => ({
|
||||
default: () => {
|
||||
const Stub = () => <div data-slot="scatter-stub">scatter</div>;
|
||||
Stub.displayName = "ScatterStub";
|
||||
return Stub;
|
||||
},
|
||||
}));
|
||||
|
||||
import { CorrelationRow } from "../correlation-row";
|
||||
|
||||
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
const okBpCompliance: CorrelationResult = {
|
||||
kind: "bp-compliance",
|
||||
status: "ok",
|
||||
statistic: -0.62,
|
||||
n: 28,
|
||||
pValue: 0.001,
|
||||
confidenceBand: { low: -0.85, high: -0.39, label: "high" },
|
||||
interpretation:
|
||||
"Higher medication compliance lines up with lower systolic — a pattern worth watching.",
|
||||
points: [{ x: 60, y: 140 }],
|
||||
xLabel: "Compliance %",
|
||||
yLabel: "Systolic (mmHg)",
|
||||
};
|
||||
|
||||
const insufficientMoodPulse: CorrelationResult = {
|
||||
kind: "mood-pulse",
|
||||
status: "insufficient",
|
||||
n: 8,
|
||||
reason: "too_few_pairs",
|
||||
points: [],
|
||||
};
|
||||
|
||||
const okWeightWeekday: CorrelationResult = {
|
||||
kind: "weight-weekday",
|
||||
status: "ok",
|
||||
statistic: 0.18,
|
||||
n: 28,
|
||||
pValue: 0.02,
|
||||
confidenceBand: { low: 0.18, high: 0.18, label: "moderate" },
|
||||
interpretation:
|
||||
"Monday weights run 0.6 kg above your other-day average — a pattern worth watching.",
|
||||
points: [{ x: 0, y: 84 }],
|
||||
xLabel: "Weekday",
|
||||
yLabel: "Weight (kg)",
|
||||
};
|
||||
|
||||
describe("<CorrelationRow>", () => {
|
||||
it("renders only the ok-status hypothesis cards (insufficient ones are dropped)", () => {
|
||||
const html = render(
|
||||
<CorrelationRow
|
||||
results={{
|
||||
bpCompliance: okBpCompliance,
|
||||
moodPulse: insufficientMoodPulse,
|
||||
weightWeekday: okWeightWeekday,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
const matches = html.match(/data-slot="correlation-card"/g) ?? [];
|
||||
expect(matches.length).toBe(2);
|
||||
expect(html).toMatch(/data-kind="bp-compliance"/);
|
||||
expect(html).not.toMatch(/data-kind="mood-pulse"/);
|
||||
expect(html).toMatch(/data-kind="weight-weekday"/);
|
||||
});
|
||||
|
||||
it("renders the correlation-disclaimer once at the row level", () => {
|
||||
const html = render(
|
||||
<CorrelationRow
|
||||
results={{
|
||||
bpCompliance: okBpCompliance,
|
||||
moodPulse: insufficientMoodPulse,
|
||||
weightWeekday: okWeightWeekday,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
const matches = html.match(/data-slot="correlation-row-disclaimer"/g) ?? [];
|
||||
expect(matches.length).toBe(1);
|
||||
expect(html).toContain("Relationships are observational, not causal");
|
||||
});
|
||||
|
||||
it("applies the 2-up grid on >= md when 2+ ok cards render", () => {
|
||||
const html = render(
|
||||
<CorrelationRow
|
||||
results={{
|
||||
bpCompliance: okBpCompliance,
|
||||
moodPulse: insufficientMoodPulse,
|
||||
weightWeekday: okWeightWeekday,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(html).toMatch(/grid-cols-1/);
|
||||
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
|
||||
results={{
|
||||
bpCompliance: okBpCompliance,
|
||||
moodPulse: insufficientMoodPulse,
|
||||
weightWeekday: okWeightWeekday,
|
||||
}}
|
||||
/>,
|
||||
"de",
|
||||
);
|
||||
expect(html).toContain("nicht kausal");
|
||||
});
|
||||
});
|
||||
@@ -1,78 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import type { CorrelationResult } from "@/lib/insights/correlations";
|
||||
import { CorrelationCard } from "./correlation-card";
|
||||
|
||||
/**
|
||||
* v1.4.20 phase B3 — Correlation row.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* 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 {
|
||||
results: {
|
||||
bpCompliance: CorrelationResult;
|
||||
moodPulse: CorrelationResult;
|
||||
weightWeekday: CorrelationResult;
|
||||
};
|
||||
}
|
||||
|
||||
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"
|
||||
aria-label={t("insights.correlationRow.title")}
|
||||
className="space-y-3"
|
||||
>
|
||||
<div className="flex items-baseline justify-between">
|
||||
<h2 className="text-lg font-semibold">
|
||||
{t("insights.correlationRow.title")}
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("insights.correlationRow.subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"grid grid-cols-1 gap-4",
|
||||
okResults.length > 1 && "md:grid-cols-2",
|
||||
)}
|
||||
>
|
||||
{okResults.map((result) => (
|
||||
<CorrelationCard key={result.kind} result={result} />
|
||||
))}
|
||||
</div>
|
||||
<p
|
||||
data-slot="correlation-row-disclaimer"
|
||||
className="text-muted-foreground text-xs italic"
|
||||
>
|
||||
{t("insights.correlationRow.disclaimer")}
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user