mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 22:26:13 +00:00
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:
1 parent
f6221623a4
commit
e3539d7c94
5 files changed
+472
-98
No files matched your search
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in new issue
Block a user