mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +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
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in new issue
Block a user