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.
This commit is contained in:
Marc-André Bombeck committed 2026-06-07 16:45:26 +02:00
1 parent f6221623a4
commit e3539d7c94
5 files changed
+472 -98

No files matched your search

+78 -43
View File
@@ -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 <VitalsDashboard>). 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<InsightsSectionId, ReactNode> = {
"wellness-scores": (
<WellnessScores
read={dashboardDerived.read}
isLoading={dashboardDerived.isLoading}
isError={dashboardDerived.isError}
refetch={dashboardDerived.refetch}
// v1.15.3 — the cycle ring rides the scores strip as a gated sibling
// tile, only for a cycle-tracking account, so its calendar read never
// fires otherwise (the same `/api/auth/me` gate the sidebar nav uses).
extraTile={user?.cycleTrackingEnabled ? <CycleRingTile /> : 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 ? (
<DailyBriefing
briefing={briefingPayload}
updatedAt={heroStripUpdatedAt}
loading={advisor.isLoading}
onRegenerate={advisor.regenerate}
regenerating={advisor.isRegenerating}
/>
) : null,
vitals: <VitalsDashboard batch={dashboardDerived} layout={layout} />,
trends: (
<TrendsRow
briefing={briefingPayload}
annotations={advisor.payload?.trendAnnotations ?? null}
loading={advisor.isLoading || advisor.isRegenerating}
/>
),
"period-review": flags.briefing ? (
<PeriodNarrativeCard enabled={isAuthenticated} />
) : 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 ? (
<CycleInsightSummaryCard />
) : null,
signals: <CoincidentDeviationCard enabled={isAuthenticated} />,
"rhythm-events": <RhythmEventsCard enabled={isAuthenticated} />,
};
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}
/>
<WellnessScores
read={dashboardDerived.read}
isLoading={dashboardDerived.isLoading}
isError={dashboardDerived.isError}
refetch={dashboardDerived.refetch}
// v1.15.3 — the cycle ring rides the scores strip as a gated sibling
// tile, only for a cycle-tracking account, so its calendar read never
// fires otherwise (the same `/api/auth/me` gate the sidebar nav uses).
extraTile={user?.cycleTrackingEnabled ? <CycleRingTile /> : 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 && (
<DailyBriefing
briefing={briefingPayload}
updatedAt={heroStripUpdatedAt}
loading={advisor.isLoading}
onRegenerate={advisor.regenerate}
regenerating={advisor.isRegenerating}
/>
)}
<VitalsDashboard batch={dashboardDerived} />
<TrendsRow
briefing={briefingPayload}
annotations={advisor.payload?.trendAnnotations ?? null}
loading={advisor.isLoading || advisor.isRegenerating}
/>
{flags.briefing && <PeriodNarrativeCard enabled={isAuthenticated} />}
{/* 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 ? <CycleInsightSummaryCard /> : null}
<CoincidentDeviationCard enabled={isAuthenticated} />
<RhythmEventsCard enabled={isAuthenticated} />
{/* 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) => (
<Fragment key={id}>{SECTION_REGISTRY[id]}</Fragment>
))}
</div>
);
}
@@ -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<string, string> = {
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<string, string> = {
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<FitnessAgeValue>({ 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<FitnessAgeValue>({ metric: "FITNESS_AGE" });
if (fitness?.status === "ok" && fitness.value) return true;
}
const vascular = read<VascularAgeDeltaValue>({
metric: "VASCULAR_AGE_DELTA",
});
if (vascular?.status === "ok" && vascular.value) return true;
if (tileVisible(layout, "vascular-age")) {
const vascular = read<VascularAgeDeltaValue>({
metric: "VASCULAR_AGE_DELTA",
});
if (vascular?.status === "ok" && vascular.value) return true;
}
const bmi = read<BmiValue>({ metric: "BMI" });
if (bmi?.status === "ok" && bmi.value) return true;
if (tileVisible(layout, "bmi")) {
const bmi = read<BmiValue>({ metric: "BMI" });
if (bmi?.status === "ok" && bmi.value) return true;
}
const hrv = read<HrvBalanceValue>({ metric: "HRV_BALANCE" });
if (
hrv &&
!(hrv.status === "insufficient" && hrv.reason === "no_readings_in_window")
) {
return true;
if (tileVisible(layout, "hrv")) {
const hrv = read<HrvBalanceValue>({ 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<VitalsBaselineValue>({
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<SixMinuteWalkValue>({ 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<SixMinuteWalkValue>({ 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<VitalsBaselineValue>({ 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: <FitnessAgeTile key="cardio-fitness" {...tileProps} />,
},
{
id: "vascular-age",
order: tileOrder(layout, "vascular-age"),
node: <VascularAgeTile key="vascular-age" {...tileProps} />,
},
{
id: "hrv",
order: tileOrder(layout, "hrv"),
node: <HrvBalanceTile key="hrv" {...tileProps} />,
},
{
id: "bmi",
order: tileOrder(layout, "bmi"),
node: <BmiTile key="bmi" {...tileProps} />,
},
...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: (
<BaselineTile
key={type}
metric="VITALS_BASELINE"
type={type}
{...tileProps}
/>
),
};
}),
];
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: <SixMinuteWalkTile key="six-minute-walk" {...tileProps} />,
},
...SECTION_MOBILITY.map(({ metric, type }) => {
const id = MOBILITY_TILE_LAYOUT_ID[type] ?? type;
return {
id,
order: tileOrder(layout, id),
node: (
<BaselineTile
key={metric}
metric={metric}
type={type}
{...tileProps}
/>
),
};
}),
];
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 (
<div
@@ -614,28 +782,11 @@ export function VitalsDashboard({ batch, className }: DashboardProps) {
}
className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3"
>
{isLoading ? (
Array.from({ length: 6 }).map((_, i) => (
<VitalsTileSkeleton key={`vitals-skeleton-${i}`} />
))
) : (
<>
<FitnessAgeTile {...tileProps} />
<VascularAgeTile {...tileProps} />
<HrvBalanceTile {...tileProps} />
<BmiTile {...tileProps} />
{vitals
.filter((type) => type !== "HEART_RATE_VARIABILITY")
.map((type) => (
<BaselineTile
key={type}
metric="VITALS_BASELINE"
type={type}
{...tileProps}
/>
))}
</>
)}
{isLoading
? Array.from({ length: 6 }).map((_, i) => (
<VitalsTileSkeleton key={`vitals-skeleton-${i}`} />
))
: vitalTiles.map((tile) => tile.node)}
</div>
</section>
)}
@@ -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"
>
<SixMinuteWalkTile {...tileProps} />
{SECTION_MOBILITY.map(({ metric, type }) => (
<BaselineTile
key={metric}
metric={metric}
type={type}
{...tileProps}
/>
))}
{mobilityTiles.map((tile) => tile.node)}
</div>
</section>
)}
+39
View File
@@ -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;
}
+94
View File
@@ -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);
}
});
});
+63
View File
@@ -162,6 +162,31 @@ const DEFAULT_VISIBLE_TILE_IDS = new Set<InsightsTileId>([
"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 };
}