From e3539d7c949b7bcdc73e2e4cf9f9693219cf8ca9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 7 Jun 2026 16:45:26 +0200 Subject: [PATCH] feat(insights): drive overview section + tile render from saved layout Read the resolved insights layout on /insights and render the customizable region from it. Sections render in saved order, hidden ones drop out, and the Vitals grid honours the per-tile order + visibility. HeroStrip stays anchored above the customizable region; every existing feature/data gate (briefing flag, cycle-enabled, per-tile data) still applies on top, so the default layout reproduces the prior page. - useInsightsLayout() reads GET /api/insights/layout through the query-key factory, defaulting to DEFAULT_INSIGHTS_LAYOUT while in flight. - orderedVisibleSectionIds + resolveTileLayout: pure, unit-tested helpers. - Widen DEFAULT_VISIBLE_TILE_IDS with the Vitals/Mobility grid tiles so a fresh account keeps the tiles it shows today. --- src/app/insights/page.tsx | 121 ++++++--- .../insights/derived/vitals-dashboard.tsx | 253 ++++++++++++++---- src/hooks/use-insights-layout.ts | 39 +++ src/lib/__tests__/insights-layout.test.ts | 94 +++++++ src/lib/insights-layout.ts | 63 +++++ 5 files changed, 472 insertions(+), 98 deletions(-) create mode 100644 src/hooks/use-insights-layout.ts diff --git a/src/app/insights/page.tsx b/src/app/insights/page.tsx index 70487d82b..dab06ea3f 100644 --- a/src/app/insights/page.tsx +++ b/src/app/insights/page.tsx @@ -1,5 +1,6 @@ "use client"; +import { Fragment, type ReactNode } from "react"; import { useQuery } from "@tanstack/react-query"; import dynamic from "next/dynamic"; import Link from "next/link"; @@ -8,6 +9,11 @@ import { RefreshCw, TrendingUp } from "lucide-react"; import { useAuth } from "@/hooks/use-auth"; import { queryKeys } from "@/lib/query-keys"; import { useFeatureFlags } from "@/hooks/use-feature-flags"; +import { useInsightsLayout } from "@/hooks/use-insights-layout"; +import { + orderedVisibleSectionIds, + type InsightsSectionId, +} from "@/lib/insights-layout"; import { useScrollResetOnRoute } from "@/hooks/use-scroll-reset-on-route"; import { useTranslations } from "@/lib/i18n/context"; import { Button } from "@/components/ui/button"; @@ -271,6 +277,13 @@ export default function InsightsPage() { // single query instance, so the wellness lift adds no second request. const dashboardDerived = useDashboardDerived(isAuthenticated); + // v1.15.11 W2 — the resolved overview layout (sections + tiles). Drives + // both the section render order/visibility below and the Vitals grid's + // per-tile order/visibility (passed into ). Defaults to + // the canonical layout while in-flight so the first paint matches the + // default order with no flicker. + const layout = useInsightsLayout(isAuthenticated); + // Error branch — a transient 500 / network drop (after the query's // retries are exhausted) settles the comprehensive query with no data. // Without this gate the page would fall through to the "no data yet — @@ -360,6 +373,62 @@ export default function InsightsPage() { // correlation row. The duplicate footer "prepare assessments" control was // removed in v1.12.4 — the tab-strip regenerate button is the single // affordance. The generic disclaimer lives once in the layout-shell footer. + + // v1.15.11 W2 — decouple the section render order from the JSX. Each section + // id maps to its existing node here; the customizable region below renders + // `orderedVisibleSectionIds(layout)` against this registry. Every existing + // feature/data gate is preserved INSIDE each entry: a section the layout + // marks visible but whose gate is off (briefing flag, cycle-enabled) still + // resolves to `null`, exactly as today. With the default layout the order + + // gates reproduce the pre-v1.15.11 page byte-for-byte. HeroStrip stays + // anchored above this region, OUTSIDE the customizable set. + const SECTION_REGISTRY: Record = { + "wellness-scores": ( + : undefined} + // v1.15.5 — when the cycle ring is shown it TAKES the Strain slot: + // hide Strain so the strip stays compact instead of growing a sixth + // tile. Strain stays visible for non-cycle accounts. + hideStrain={user?.cycleTrackingEnabled === true} + /> + ), + "daily-briefing": flags.briefing ? ( + + ) : null, + vitals: , + trends: ( + + ), + "period-review": flags.briefing ? ( + + ) : null, + // v1.15.2 — gated cycle teaser. Render only for a cycle-tracking account; + // for everyone else this is nothing (no card, no layout gap). The card + // itself stays silent until its reads resolve. + "cycle-summary": user?.cycleTrackingEnabled ? ( + + ) : null, + signals: , + "rhythm-events": , + }; + return ( // v1.12.7 (L3) — one consistent vertical rhythm down the overview. The // page used `space-y-8` (32 px) between top-level blocks while the vitals @@ -389,49 +458,15 @@ export default function InsightsPage() { healthScore={analytics?.healthScore ?? undefined} /> - : undefined} - // v1.15.5 — when the cycle ring is shown it TAKES the Strain slot: - // hide Strain so the strip stays compact instead of growing a sixth - // tile. Strain stays visible for non-cycle accounts. - hideStrain={user?.cycleTrackingEnabled === true} - /> - - {flags.briefing && ( - - )} - - - - - - {flags.briefing && } - - {/* v1.15.2 — gated cycle teaser. Render only for a cycle-tracking - account; for everyone else this is nothing (no card, no layout gap). - The card itself stays silent until its reads resolve. */} - {user?.cycleTrackingEnabled ? : null} - - - - + {/* v1.15.11 W2 — the customizable region: sections render in the + resolved layout order, skipping any the user has hidden. A + layout-visible-but-gate-off section resolves to `null` from the + registry, so the `space-y-6` rhythm closes the gap with no hole. + Each registry node is wrapped in a keyed Fragment so React keeps a + stable identity across a reorder. */} + {orderedVisibleSectionIds(layout).map((id) => ( + {SECTION_REGISTRY[id]} + ))} ); } diff --git a/src/components/insights/derived/vitals-dashboard.tsx b/src/components/insights/derived/vitals-dashboard.tsx index 37501ab1b..409f62977 100644 --- a/src/components/insights/derived/vitals-dashboard.tsx +++ b/src/components/insights/derived/vitals-dashboard.tsx @@ -1,6 +1,6 @@ "use client"; -import { useMemo } from "react"; +import { useMemo, type ReactNode } from "react"; import { Footprints, Gauge, HeartPulse, RefreshCw, Scale } from "lucide-react"; import { cn } from "@/lib/utils"; import { Button } from "@/components/ui/button"; @@ -23,6 +23,10 @@ import { SECTION_MOBILITY, type DashboardDerived, } from "./use-dashboard-derived"; +import { + resolveTileLayout, + type InsightsLayout, +} from "@/lib/insights-layout"; import type { TrendDirectionSentiment } from "@/lib/insights/trend-sentiment"; // Type-only — the compute payloads never drag the server graph into the bundle. import type { VitalsBaselineValue } from "@/lib/insights/derived/baseline"; @@ -99,9 +103,71 @@ interface DashboardProps { * full-width section above the daily briefing. */ batch: DashboardDerived; + /** + * v1.15.11 W2c — the resolved insights layout, passed down from the page + * (which already mounts `useInsightsLayout()`) so this grid honours the + * user's per-tile order + visibility without a second fetch. Each grid tile + * maps to a layout tile id (see `VITAL_TILE_LAYOUT_ID` / + * `MOBILITY_TILE_LAYOUT_ID`); a tile the layout marks `visible: false` does + * not render even if it has data, and visible tiles render in `order`. Tiles + * still self-gate on data availability on top of that. Optional so a caller + * that has no layout yet falls back to the default (everything shown, + * data-gated as before). + */ + layout?: InsightsLayout; className?: string; } +/** + * v1.15.11 W2c — the dashboard's vitals-grid tile concepts mapped onto their + * layout tile id (the routed sub-page slug). The four derived re-frames map to + * the slug of the metric they re-frame; the per-vital baseline tiles map by + * their `MeasurementType`. A tile concept absent from this map (none today) + * would fall through `resolveTileLayout` as always-on, so the grid never drops + * a tile it cannot place. + * + * FitnessAge (VO₂max re-frame) → `cardio-fitness` + * VascularAge → `vascular-age` + * HrvBalance → `hrv` + * Bmi → `bmi` + */ +const VITAL_BASELINE_TILE_LAYOUT_ID: Record = { + RESTING_HEART_RATE: "resting-pulse", + RESPIRATORY_RATE: "respiratory-rate", + OXYGEN_SATURATION: "oxygen", + BODY_TEMPERATURE: "body-temperature", + BLOOD_GLUCOSE: "blood-glucose", + WEIGHT: "weight", +}; + +/** Mobility-section `MeasurementType` → layout tile id (routed slug). */ +const MOBILITY_TILE_LAYOUT_ID: Record = { + STAIR_ASCENT_SPEED: "stair-ascent-speed", + STAIR_DESCENT_SPEED: "stair-descent-speed", + WRIST_TEMPERATURE: "wrist-temperature", +}; + +/** + * v1.15.11 W2c — `true` when the layout marks this tile id visible (or the + * layout does not enumerate the id, in which case the tile is always-on). A + * `null` layout (caller passed none) is treated as everything-visible so the + * default behaviour is unchanged. + */ +function tileVisible(layout: InsightsLayout | undefined, tileId: string): boolean { + if (!layout) return true; + return resolveTileLayout(layout, tileId).visible; +} + +/** + * v1.15.11 W2c — the layout `order` for a tile id, used to sort the grid. + * Falls back to `MAX_SAFE_INTEGER` (render last) for an unenumerated id and to + * a stable 0 when no layout is present (callers keep their source order). + */ +function tileOrder(layout: InsightsLayout | undefined, tileId: string): number { + if (!layout) return 0; + return resolveTileLayout(layout, tileId).order; +} + /** The provenance ⓘ explainer for one derived metric, wired from the map. */ function MetricProvenance({ metric, @@ -465,28 +531,42 @@ function VitalsTileSkeleton() { * the CLS the load-state QoL pass closes). Kept in sync with the tile gates * above — if a new tile gate changes, reflect it here. */ -function hasRenderableVital(read: DerivedBatchRead): boolean { - const fitness = read({ metric: "FITNESS_AGE" }); - if (fitness?.status === "ok" && fitness.value) return true; +function hasRenderableVital( + read: DerivedBatchRead, + layout: InsightsLayout | undefined, +): boolean { + if (tileVisible(layout, "cardio-fitness")) { + const fitness = read({ metric: "FITNESS_AGE" }); + if (fitness?.status === "ok" && fitness.value) return true; + } - const vascular = read({ - metric: "VASCULAR_AGE_DELTA", - }); - if (vascular?.status === "ok" && vascular.value) return true; + if (tileVisible(layout, "vascular-age")) { + const vascular = read({ + metric: "VASCULAR_AGE_DELTA", + }); + if (vascular?.status === "ok" && vascular.value) return true; + } - const bmi = read({ metric: "BMI" }); - if (bmi?.status === "ok" && bmi.value) return true; + if (tileVisible(layout, "bmi")) { + const bmi = read({ metric: "BMI" }); + if (bmi?.status === "ok" && bmi.value) return true; + } - const hrv = read({ metric: "HRV_BALANCE" }); - if ( - hrv && - !(hrv.status === "insufficient" && hrv.reason === "no_readings_in_window") - ) { - return true; + if (tileVisible(layout, "hrv")) { + const hrv = read({ metric: "HRV_BALANCE" }); + if ( + hrv && + !(hrv.status === "insufficient" && hrv.reason === "no_readings_in_window") + ) { + return true; + } } for (const type of SECTION_VITALS) { if (type === "HEART_RATE_VARIABILITY") continue; + if (!tileVisible(layout, VITAL_BASELINE_TILE_LAYOUT_ID[type] ?? type)) { + continue; + } const baseline = read({ metric: "VITALS_BASELINE", type, @@ -511,11 +591,17 @@ function hasRenderableVital(read: DerivedBatchRead): boolean { * only rendered once there is content under it (no stranded heading over * blank space). Most users carry none of these → the whole section hides. */ -function hasRenderableMobility(read: DerivedBatchRead): boolean { - const sixmw = read({ metric: "SIX_MINUTE_WALK_BAND" }); - if (sixmw?.status === "ok" && sixmw.value) return true; +function hasRenderableMobility( + read: DerivedBatchRead, + layout: InsightsLayout | undefined, +): boolean { + if (tileVisible(layout, "six-minute-walk")) { + const sixmw = read({ metric: "SIX_MINUTE_WALK_BAND" }); + if (sixmw?.status === "ok" && sixmw.value) return true; + } - for (const { metric } of SECTION_MOBILITY) { + for (const { metric, type } of SECTION_MOBILITY) { + if (!tileVisible(layout, MOBILITY_TILE_LAYOUT_ID[type] ?? type)) continue; const band = read({ metric }); if ( band && @@ -531,9 +617,8 @@ function hasRenderableMobility(read: DerivedBatchRead): boolean { return false; } -export function VitalsDashboard({ batch, className }: DashboardProps) { +export function VitalsDashboard({ batch, layout, className }: DashboardProps) { const { t } = useTranslations(); - const vitals = useMemo(() => SECTION_VITALS, []); const read = batch.read; const isLoading = batch.isLoading; @@ -541,6 +626,89 @@ export function VitalsDashboard({ batch, className }: DashboardProps) { const refetch = batch.refetch; const tileProps: TileProps = { read, isLoading }; + // v1.15.11 W2c — the vitals-grid tiles in resolved layout order, dropping + // any the user has hidden. Each tile is `{ id, order, node }`; the tile's + // own component still self-gates on data, so a visible tile with no readings + // still renders nothing. The four derived re-frames map to the slug of the + // metric they re-frame; the per-vital baseline tiles map by `MeasurementType`. + const vitalTiles = useMemo(() => { + const entries: { id: string; order: number; node: ReactNode }[] = [ + { + id: "cardio-fitness", + order: tileOrder(layout, "cardio-fitness"), + node: , + }, + { + id: "vascular-age", + order: tileOrder(layout, "vascular-age"), + node: , + }, + { + id: "hrv", + order: tileOrder(layout, "hrv"), + node: , + }, + { + id: "bmi", + order: tileOrder(layout, "bmi"), + node: , + }, + ...SECTION_VITALS.filter( + (type) => type !== "HEART_RATE_VARIABILITY", + ).map((type) => { + const id = VITAL_BASELINE_TILE_LAYOUT_ID[type] ?? type; + return { + id, + order: tileOrder(layout, id), + node: ( + + ), + }; + }), + ]; + return entries + .filter((e) => tileVisible(layout, e.id)) + .sort((a, b) => a.order - b.order); + // `tileProps` is rebuilt every render (read/isLoading); the node closures + // capture the current values, so memoising on the layout + load state keeps + // the ordered list stable across a no-op rerender without staleness. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [layout, read, isLoading]); + + const mobilityTiles = useMemo(() => { + const entries: { id: string; order: number; node: ReactNode }[] = [ + { + id: "six-minute-walk", + order: tileOrder(layout, "six-minute-walk"), + node: , + }, + ...SECTION_MOBILITY.map(({ metric, type }) => { + const id = MOBILITY_TILE_LAYOUT_ID[type] ?? type; + return { + id, + order: tileOrder(layout, id), + node: ( + + ), + }; + }), + ]; + return entries + .filter((e) => tileVisible(layout, e.id)) + .sort((a, b) => a.order - b.order); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [layout, read, isLoading]); + // A failed batch (retry:0 + the 8 s client ceiling) must read as an error, // not as "no data" — otherwise the whole Vitals + Wellness surface silently // vanishes. Surface a compact message + a Retry that refetches the one @@ -587,8 +755,8 @@ export function VitalsDashboard({ batch, className }: DashboardProps) { // skeleton row reserves the final height). When the batch resolves to zero // vitals the heading would otherwise strand over blank space, then content // pops in below it — the CLS this pass removes. - const showSection = isLoading || hasRenderableVital(read); - const showMobility = !isLoading && hasRenderableMobility(read); + const showSection = isLoading || hasRenderableVital(read, layout); + const showMobility = !isLoading && hasRenderableMobility(read, layout); return (
- {isLoading ? ( - Array.from({ length: 6 }).map((_, i) => ( - - )) - ) : ( - <> - - - - - {vitals - .filter((type) => type !== "HEART_RATE_VARIABILITY") - .map((type) => ( - - ))} - - )} + {isLoading + ? Array.from({ length: 6 }).map((_, i) => ( + + )) + : vitalTiles.map((tile) => tile.node)}
)} @@ -653,15 +804,7 @@ export function VitalsDashboard({ batch, className }: DashboardProps) { data-slot="vitals-mobility-grid" className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3" > - - {SECTION_MOBILITY.map(({ metric, type }) => ( - - ))} + {mobilityTiles.map((tile) => tile.node)} )} diff --git a/src/hooks/use-insights-layout.ts b/src/hooks/use-insights-layout.ts new file mode 100644 index 000000000..e484ccde6 --- /dev/null +++ b/src/hooks/use-insights-layout.ts @@ -0,0 +1,39 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; + +import { queryKeys } from "@/lib/query-keys"; +import { + DEFAULT_INSIGHTS_LAYOUT, + resolveInsightsLayout, + type InsightsLayout, +} from "@/lib/insights-layout"; + +/** + * v1.15.11 W2 — the resolved insights overview layout (sections + tiles). + * + * Reads `GET /api/insights/layout` through the centralised query-key factory + * (`queryKeys.insightsLayout()` → `["user","insightsLayout"]`), the same key + * the W3 PUT mutation invalidates on save so the overview repaints in + * lockstep with the Settings / inline-edit surfaces. The response is already + * the server-resolved v2 layout; we pass it back through + * `resolveInsightsLayout` defensively so a partial / legacy blob from a stale + * cache still normalises to a valid `InsightsLayout`. + * + * Returns `DEFAULT_INSIGHTS_LAYOUT` while the query is in-flight so the page + * renders in the default section + tile order on first paint — no layout + * flicker, no empty state, no "everything hidden" flash before the saved + * layout lands. + */ +export function useInsightsLayout(enabled: boolean): InsightsLayout { + const { data } = useQuery({ + queryKey: queryKeys.insightsLayout(), + queryFn: async () => { + const res = await fetch("/api/insights/layout"); + if (!res.ok) throw new Error("Failed to load insights layout"); + return resolveInsightsLayout((await res.json()).data); + }, + enabled, + }); + return data ?? DEFAULT_INSIGHTS_LAYOUT; +} diff --git a/src/lib/__tests__/insights-layout.test.ts b/src/lib/__tests__/insights-layout.test.ts index 3d25e5d50..12689687f 100644 --- a/src/lib/__tests__/insights-layout.test.ts +++ b/src/lib/__tests__/insights-layout.test.ts @@ -14,6 +14,8 @@ import { DEFAULT_INSIGHTS_LAYOUT, resolveInsightsLayout, serializeInsightsLayout, + orderedVisibleSectionIds, + resolveTileLayout, } from "@/lib/insights-layout"; describe("insights-layout v2 — section id universe", () => { @@ -205,3 +207,95 @@ describe("serializeInsightsLayout — defaults + dense order", () => { expect(serialized.sections.map((s) => s.id)).toEqual(["vitals"]); }); }); + +describe("orderedVisibleSectionIds — W2 render-order helper", () => { + it("returns every section in default order for the default layout", () => { + expect(orderedVisibleSectionIds(DEFAULT_INSIGHTS_LAYOUT)).toEqual([ + ...INSIGHTS_SECTION_IDS, + ]); + }); + + it("drops hidden sections and keeps the visible ones in order", () => { + const layout = resolveInsightsLayout({ + version: 2, + sections: [ + { id: "wellness-scores", visible: true, order: 0 }, + { id: "daily-briefing", visible: false, order: 1 }, + { id: "vitals", visible: true, order: 2 }, + { id: "trends", visible: false, order: 3 }, + { id: "period-review", visible: true, order: 4 }, + { id: "cycle-summary", visible: false, order: 5 }, + { id: "signals", visible: true, order: 6 }, + { id: "rhythm-events", visible: true, order: 7 }, + ], + tiles: [{ id: "overview", visible: true, order: 0 }], + }); + expect(orderedVisibleSectionIds(layout)).toEqual([ + "wellness-scores", + "vitals", + "period-review", + "signals", + "rhythm-events", + ]); + }); + + it("honours a reordered section list", () => { + const layout = resolveInsightsLayout({ + version: 2, + sections: [ + { id: "trends", visible: true, order: 0 }, + { id: "vitals", visible: true, order: 1 }, + { id: "wellness-scores", visible: true, order: 2 }, + ], + tiles: [{ id: "overview", visible: true, order: 0 }], + }); + const visible = orderedVisibleSectionIds(layout); + // The three explicit sections lead in their saved order; the rest + // auto-merge invisible (new-id semantics), so they do NOT appear. + expect(visible.slice(0, 3)).toEqual(["trends", "vitals", "wellness-scores"]); + }); +}); + +describe("resolveTileLayout — W2c per-tile decision", () => { + it("returns the saved visible + order for a known tile", () => { + const layout = resolveInsightsLayout({ + version: 2, + sections: [{ id: "vitals", visible: true, order: 0 }], + tiles: [ + { id: "overview", visible: true, order: 0 }, + { id: "hrv", visible: false, order: 1 }, + ], + }); + expect(resolveTileLayout(layout, "hrv")).toEqual({ + visible: false, + order: 1, + }); + }); + + it("treats a tile the layout does not enumerate as always-on, ordered last", () => { + const res = resolveTileLayout(DEFAULT_INSIGHTS_LAYOUT, "not-a-tile"); + expect(res.visible).toBe(true); + expect(res.order).toBe(Number.MAX_SAFE_INTEGER); + }); + + it("keeps the grid's default vitals tiles visible by default (no regression)", () => { + for (const id of [ + "weight", + "bmi", + "cardio-fitness", + "vascular-age", + "hrv", + "resting-pulse", + "respiratory-rate", + "oxygen", + "body-temperature", + "blood-glucose", + "six-minute-walk", + "stair-ascent-speed", + "stair-descent-speed", + "wrist-temperature", + ]) { + expect(resolveTileLayout(DEFAULT_INSIGHTS_LAYOUT, id).visible).toBe(true); + } + }); +}); diff --git a/src/lib/insights-layout.ts b/src/lib/insights-layout.ts index f731f2bca..3a8eb7bc6 100644 --- a/src/lib/insights-layout.ts +++ b/src/lib/insights-layout.ts @@ -162,6 +162,31 @@ const DEFAULT_VISIBLE_TILE_IDS = new Set([ "workouts", "mood", "medications", + // v1.15.11 W2c — the Vitals + Mobility grid on the overview surfaces every + // one of these tiles today, gated only on data availability. Now that the + // grid honours this layout's tile visibility, they must ship default-visible + // or a fresh account (no saved layout) would lose the tiles it shows today — + // a regression. They stay data-gated on top of visibility, so a tile with no + // readings still renders nothing exactly as before; widening the default set + // only ensures the layout never *suppresses* a tile the user already sees. + // The ids map to the grid's tile concepts: + // cardio-fitness → FitnessAge (VO₂max re-frame); vascular-age → + // VascularAge; hrv → HRV balance; resting-pulse / respiratory-rate / + // oxygen / body-temperature / blood-glucose → the per-vital baseline + // tiles; six-minute-walk / stair-ascent-speed / stair-descent-speed / + // wrist-temperature → the Mobility sub-grid. + "cardio-fitness", + "vascular-age", + "hrv", + "resting-pulse", + "respiratory-rate", + "oxygen", + "body-temperature", + "blood-glucose", + "six-minute-walk", + "stair-ascent-speed", + "stair-descent-speed", + "wrist-temperature", ]); export const DEFAULT_INSIGHTS_LAYOUT: InsightsLayout = { @@ -364,3 +389,41 @@ function serializeInsightsSections( order: i, // normalize to 0-based dense order })); } + +/** + * v1.15.11 W2 — the visible section ids in render order, derived from a + * resolved layout. Pure helper so the page can map each id onto its JSX in + * `SECTION_REGISTRY` without re-implementing the sort/filter inline (and so + * the ordering is unit-testable without rendering React). Sections marked + * `visible: false` are dropped; the rest sort by `order`. Feature/data gates + * still apply on top at render time — a gated-off section that is + * layout-visible still renders nothing (its component self-gates / returns + * null), so this list is the *candidate* order, not a render guarantee. + */ +export function orderedVisibleSectionIds( + layout: InsightsLayout, +): InsightsSectionId[] { + return layout.sections + .filter((s) => s.visible) + .slice() + .sort((a, b) => a.order - b.order) + .map((s) => s.id); +} + +/** + * v1.15.11 W2c — resolve a single tile's layout decision against a resolved + * layout. Returns `{ visible, order }` for a known tile id; for a tile id the + * layout does not enumerate (a dashboard tile with no corresponding layout + * entry — e.g. a derived re-frame like FITNESS_AGE) the tile is treated as + * always-on and sorted last so it never disappears on the user. Data-gating + * still applies on top — a `visible: true` tile with no data renders nothing. + */ +export function resolveTileLayout( + layout: InsightsLayout, + tileId: InsightsTileId | string, +): { visible: boolean; order: number } { + const entry = layout.tiles.find((t) => t.id === tileId); + if (entry) return { visible: entry.visible, order: entry.order }; + // Unknown-to-layout tile → always render, ordered after every known tile. + return { visible: true, order: Number.MAX_SAFE_INTEGER }; +}