From 81cb6aa63d2f714569252f99cb0878cbf96e13a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 24 Sep 2026 09:50:12 +0200 Subject: [PATCH] fix(insights): let the trends row fill a switched-off module's slot from the next metric selectTrendCharts takes the record's module map and skips every slot whose module is off, on the briefing path and on the fallback row, through the surface map. Sleep was never covered before, and the fallback row shrank to two charts with mood off; it now reaches for pulse. The row reads the module map itself. --- src/components/insights/trends-row.tsx | 16 +++-- .../__tests__/trend-chart-select.test.ts | 58 +++++++++++++++---- src/lib/insights/trend-chart-select.ts | 32 ++++++++-- 3 files changed, 85 insertions(+), 21 deletions(-) diff --git a/src/components/insights/trends-row.tsx b/src/components/insights/trends-row.tsx index 85d8255a8..a7056fd80 100644 --- a/src/components/insights/trends-row.tsx +++ b/src/components/insights/trends-row.tsx @@ -4,7 +4,7 @@ import dynamic from "next/dynamic"; import Link from "next/link"; import { ArrowRight, TrendingUp } from "lucide-react"; import { useTranslations } from "@/lib/i18n/context"; -import { useAuth } from "@/hooks/use-auth"; +import { useAccountOnceMounted, useAuth } from "@/hooks/use-auth"; import { ChartSkeleton } from "@/components/charts/chart-skeleton"; import { SectionHeading } from "@/components/ui/section-heading"; import { HealthChartDynamicMini } from "@/components/charts/health-chart-dynamic"; @@ -67,8 +67,8 @@ const MoodChart = dynamic( interface TrendsRowProps { /** - * Metrics to leave out because their module is switched off (for - * example `mood`). Passed straight to `selectTrendCharts`. + * Further metrics to leave out. Switched-off modules need no entry here: + * the row reads the record's module map itself. */ hiddenMetrics?: SelectTrendChartsOptions["hiddenMetrics"]; /** @@ -145,7 +145,15 @@ export function TrendsRow({ // v1.8.5 — derive the chart set from the briefing. No new fetch: the // briefing payload is already on the page (advisor cache), so this is // a pure read that respects the v1.8.3 anti-freeze contract. - const charts = selectTrendCharts(briefing, { hiddenMetrics }); + // + // A slot whose module is off is left out and the next eligible metric fills + // it (`trend:*` in the surface map). The mount-pinned account keeps the + // hydration render identical to the server's, which had no account. + const account = useAccountOnceMounted(); + const charts = selectTrendCharts(briefing, { + modules: account?.modules, + hiddenMetrics, + }); // v1.4.36 W2 T3 — derive the tri-state status per metric from the // advisor's loading flag + the annotation presence. Pending wins diff --git a/src/lib/insights/__tests__/trend-chart-select.test.ts b/src/lib/insights/__tests__/trend-chart-select.test.ts index eb812dddb..73162857a 100644 --- a/src/lib/insights/__tests__/trend-chart-select.test.ts +++ b/src/lib/insights/__tests__/trend-chart-select.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect } from "vitest"; import type { DailyBriefing, DailyBriefingKeyFinding } from "@/lib/ai/schema"; +import { isSurfaceVisible, surfaceModule } from "@/lib/modules/surface"; import { selectTrendCharts, DEFAULT_TREND_CHART_CAP, @@ -158,23 +159,58 @@ describe("selectTrendCharts", () => { }); }); -describe("selectTrendCharts — hidden metrics", () => { - it("drops mood from the fallback triple when the mood module is off", () => { - const charts = selectTrendCharts(null, { hiddenMetrics: ["mood"] }); - expect(charts.map((c) => c.metric)).not.toContain("mood"); - expect(charts).toHaveLength(2); +describe("selectTrendCharts — switched-off modules", () => { + it("fills the fallback row from the next metric when mood is off", () => { + const charts = selectTrendCharts(null, { modules: { mood: false } }); + expect(charts.map((c) => c.metric)).toEqual(["bp", "weight", "pulse"]); }); - it("skips a hidden metric the briefing flags", () => { + it("keeps the familiar blood pressure, weight, mood row with every module on", () => { + const charts = selectTrendCharts(null, { modules: {} }); + expect(charts.map((c) => c.metric)).toEqual(["bp", "weight", "mood"]); + }); + + it("skips a flagged metric whose module is off and lets the next finding take the slot", () => { + const charts = selectTrendCharts(briefing(["sleep", "mood", "weight"]), { + modules: { sleep: false, mood: false }, + }); + expect(charts.map((c) => c.metric)).toEqual(["weight"]); + }); + + it("still honours an explicit hide list", () => { const charts = selectTrendCharts(briefing(["mood", "weight"]), { hiddenMetrics: ["mood"], }); - expect(charts.map((c) => c.metric)).not.toContain("mood"); - expect(charts).toHaveLength(1); + expect(charts.map((c) => c.metric)).toEqual(["weight"]); }); - it("keeps mood when nothing is hidden", () => { - const charts = selectTrendCharts(null, { hiddenMetrics: [] }); - expect(charts.map((c) => c.metric)).toContain("mood"); + it("never returns a slot whose module is off, for any module map and briefing", () => { + // Exhaustive over every on/off combination of the owning modules, with + // every finding a briefing can carry, in both directions. + const owners = [ + ...new Set( + Object.keys(TREND_CHART_CONFIG) + .map((m) => surfaceModule(`trend:${m}`)) + .filter((k): k is NonNullable => k !== undefined), + ), + ]; + expect(owners.length).toBeGreaterThan(0); + const all = Object.keys(TREND_CHART_CONFIG) as Array< + keyof typeof TREND_CHART_CONFIG + >; + const briefings = [null, briefing(all), briefing([...all].reverse())]; + for (let mask = 0; mask < 1 << owners.length; mask += 1) { + const modules = Object.fromEntries( + owners.map((key, i) => [key, (mask & (1 << i)) === 0]), + ); + for (const b of briefings) { + for (const chart of selectTrendCharts(b, { modules })) { + expect( + isSurfaceVisible(`trend:${chart.metric}`, modules), + `${chart.metric} under ${JSON.stringify(modules)}`, + ).toBe(true); + } + } + } }); }); diff --git a/src/lib/insights/trend-chart-select.ts b/src/lib/insights/trend-chart-select.ts index 2c3114ecb..fc87a27c2 100644 --- a/src/lib/insights/trend-chart-select.ts +++ b/src/lib/insights/trend-chart-select.ts @@ -1,4 +1,5 @@ import type { DailyBriefing, DailyBriefingKeyFinding } from "@/lib/ai/schema"; +import { isSurfaceVisible, type SurfaceModuleMap } from "@/lib/modules/surface"; /** * v1.8.5 — derive the Insights overview "Trends" chart set from the @@ -261,6 +262,15 @@ export const DEFAULT_TREND_METRICS: ReadonlyArray< DailyBriefingKeyFinding["sourceMetric"] > = ["bp", "weight", "mood"]; +/** + * Metrics the fallback row reaches for, in order, when a default slot is left + * out because its module is off. Pulse is a core vital, so a row without mood + * still paints three charts rather than two. + */ +const FALLBACK_FILL_METRICS: ReadonlyArray< + DailyBriefingKeyFinding["sourceMetric"] +> = ["pulse"]; + /** Max charts the row renders. Three keeps the equal-height 3-up grid. */ export const DEFAULT_TREND_CHART_CAP = 3; @@ -268,10 +278,13 @@ export interface SelectTrendChartsOptions { /** Max number of charts to return. Defaults to {@link DEFAULT_TREND_CHART_CAP}. */ cap?: number; /** - * Metrics whose module is switched off. They are skipped on both the - * briefing path and the fallback triple, so a disabled module never - * charts on the overview. + * The record's resolved module map (`GET /api/auth/me` `modules`). A slot + * whose module is off (`trend:` in the surface map) is skipped on + * both the briefing path and the fallback row, and the next eligible + * metric takes its place. Absent or partial: every slot is eligible. */ + modules?: SurfaceModuleMap | null; + /** Further metrics to leave out, on top of the module map. */ hiddenMetrics?: ReadonlyArray; } @@ -279,12 +292,14 @@ function configsFor( metrics: ReadonlyArray, cap: number, hidden: ReadonlySet, + modules: SurfaceModuleMap | null | undefined, ): TrendChartConfig[] { const seen = new Set(); const out: TrendChartConfig[] = []; for (const metric of metrics) { if (out.length >= cap) break; - if (hidden.has(metric)) continue; // module switched off + if (hidden.has(metric)) continue; + if (!isSurfaceVisible(`trend:${metric}`, modules)) continue; // module off const config = TREND_CHART_CONFIG[metric]; if (!config) continue; // metric has no standalone trend chart if (seen.has(config.metric)) continue; // dedupe @@ -313,11 +328,16 @@ export function selectTrendCharts( const findingMetrics = briefing?.keyFindings?.map((f) => f.sourceMetric) ?? []; - const fromBriefing = configsFor(findingMetrics, cap, hidden); + const fromBriefing = configsFor(findingMetrics, cap, hidden, options.modules); if (fromBriefing.length > 0) { return fromBriefing; } - return configsFor(DEFAULT_TREND_METRICS, cap, hidden); + return configsFor( + [...DEFAULT_TREND_METRICS, ...FALLBACK_FILL_METRICS], + cap, + hidden, + options.modules, + ); }