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, + ); }