mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
fix(dashboard): open the body fat page from the body fat tile
The body fat tile was the one strip tile that led nowhere. Its href map said there was no dedicated sub-page, which was true: body fat percentage was tiled and charted on the dashboard but never got the Insights page every body-composition sibling has. Add /insights/body-fat on the generic metric-page scaffold (body group, tab-strip pill, seven locales, manual-entry empty state) and link the tile to it. Move the tile href map out of the page into tile-destinations.ts, keyed by the tile-capable widget id and typed against the sub-page slug union, so a missing or non-existent destination fails to compile. The link passes the destination's insights-page surface gate, so it never points at a page a switched-off module hides. A guard derives the tile set from TILE_CAPABLE_WIDGET_IDS and from the ids the dashboard actually pushes into its strip, and fails when a tile's metric has a sub-page the tile does not open, when a destination has no route, or when a tile links to a page owned by another module. Tiles that deliberately lead nowhere need a written reason; there are none today. The reverse-lookup test pinned BODY_FAT as having no page; it now resolves to body-fat. Refs #1084
This commit is contained in:
1 parent
2ea9dc3936
commit
885efaafd9
17 files changed
+440
-48
No files matched your search
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* Every dashboard tile whose metric has an Insights sub-page opens it.
|
||||
*
|
||||
* The body fat tile was rendered without a link while its siblings each
|
||||
* opened their page, and nothing noticed, because the href map was a
|
||||
* hand-kept list beside the render code with no check tying the two
|
||||
* together. This guard derives the tile set from the registry
|
||||
* (`TILE_CAPABLE_WIDGET_IDS`) and from the dashboard's own render code, and
|
||||
* holds every tile against the sub-page registry.
|
||||
*/
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
TILE_CAPABLE_WIDGET_IDS,
|
||||
type TileCapableWidgetId,
|
||||
} from "@/components/dashboard/dashboard-gates";
|
||||
import {
|
||||
TILE_DESTINATION,
|
||||
dashboardTileHref,
|
||||
tileWidgetId,
|
||||
} from "@/components/dashboard/tile-destinations";
|
||||
import { subPageSlugForType } from "@/lib/insights/sub-page-metric";
|
||||
import { surfaceModule } from "@/lib/modules/surface";
|
||||
|
||||
const ROOT = path.resolve(__dirname, "../../../..");
|
||||
|
||||
/**
|
||||
* The series each tile paints. Kept here rather than in production code
|
||||
* because only this guard needs it; a tile id missing from this record
|
||||
* fails the totality test below, so a new tile cannot skip the question.
|
||||
*/
|
||||
const TILE_TYPES: Record<TileCapableWidgetId, readonly string[]> = {
|
||||
weight: ["WEIGHT"],
|
||||
bp: ["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"],
|
||||
pulse: ["PULSE"],
|
||||
bodyFat: ["BODY_FAT"],
|
||||
mood: ["MOOD"],
|
||||
sleep: ["SLEEP_DURATION"],
|
||||
steps: ["ACTIVITY_STEPS"],
|
||||
glucose: ["BLOOD_GLUCOSE"],
|
||||
bpInTarget: ["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"],
|
||||
vo2Max: ["VO2_MAX"],
|
||||
hrv: ["HEART_RATE_VARIABILITY", "HRV_RMSSD"],
|
||||
oxygenSaturation: ["OXYGEN_SATURATION"],
|
||||
respiratoryRate: ["RESPIRATORY_RATE"],
|
||||
wristTemperature: ["WRIST_TEMPERATURE"],
|
||||
muscleMass: ["MUSCLE_MASS"],
|
||||
totalBodyWater: ["TOTAL_BODY_WATER"],
|
||||
boneMass: ["BONE_MASS"],
|
||||
waterIntake: ["NUTRIENT_WATER"],
|
||||
};
|
||||
|
||||
/**
|
||||
* Tiles whose series is not a `MeasurementType` with a sub-page, but which
|
||||
* still lead to a page that shows it. Each entry names its reason.
|
||||
*/
|
||||
const NON_MEASUREMENT_DESTINATION: Partial<
|
||||
Record<TileCapableWidgetId, { slug: string; reason: string }>
|
||||
> = {
|
||||
waterIntake: {
|
||||
slug: "nutrients",
|
||||
reason:
|
||||
"fluid intake is a NutrientIntakeDay total, charted on the nutrients page",
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Tiles that deliberately lead nowhere, with the reason. Empty today: every
|
||||
* tile has a page. An entry here must be a tile whose destination is null.
|
||||
*/
|
||||
const NO_DESTINATION: Partial<Record<TileCapableWidgetId, string>> = {};
|
||||
|
||||
/** Tile ids the dashboard pushes into its strip, read from the render code. */
|
||||
function renderedTileIds(): string[] {
|
||||
const source = readFileSync(
|
||||
path.join(ROOT, "src/app/page-client.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
const ids: string[] = [];
|
||||
const pattern =
|
||||
/trendCards\.push\(\{\s*id:\s*(?:"([^"]+)"|`([^`$]+)\$\{[^`]*`)/g;
|
||||
for (const match of source.matchAll(pattern)) {
|
||||
ids.push(match[1] ?? `${match[2]}*`);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
describe("dashboard tile destinations", () => {
|
||||
it("reads a non-trivial tile set from the registry and the render code", () => {
|
||||
expect(TILE_CAPABLE_WIDGET_IDS.length).toBeGreaterThanOrEqual(18);
|
||||
expect(renderedTileIds().length).toBeGreaterThanOrEqual(19);
|
||||
});
|
||||
|
||||
it("knows every tile the dashboard renders, and renders every known tile", () => {
|
||||
const rendered = renderedTileIds();
|
||||
const widgets = new Set<string>();
|
||||
for (const id of rendered) {
|
||||
const sample = id.endsWith("*") ? `${id.slice(0, -1)}X` : id;
|
||||
const widget = tileWidgetId(sample);
|
||||
expect(widget, `rendered tile "${id}" has no widget`).not.toBeNull();
|
||||
widgets.add(widget as string);
|
||||
}
|
||||
expect([...widgets].sort()).toEqual([...TILE_CAPABLE_WIDGET_IDS].sort());
|
||||
});
|
||||
|
||||
it("routes the dashboard's tile links through this map and no other", () => {
|
||||
const source = readFileSync(
|
||||
path.join(ROOT, "src/app/page-client.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
expect(source).toMatch(/dashboardTileHref\(entry\.id,/);
|
||||
expect(source).not.toMatch(/"\/insights\/[a-z-]+"/);
|
||||
});
|
||||
|
||||
it("declares the series of every tile", () => {
|
||||
expect(Object.keys(TILE_TYPES).sort()).toEqual(
|
||||
[...TILE_CAPABLE_WIDGET_IDS].sort(),
|
||||
);
|
||||
});
|
||||
|
||||
it.each([...TILE_CAPABLE_WIDGET_IDS])(
|
||||
"%s opens the page of the metric it shows",
|
||||
(widget) => {
|
||||
const destination = TILE_DESTINATION[widget];
|
||||
const pages = new Set(
|
||||
TILE_TYPES[widget]
|
||||
.map((type) => subPageSlugForType(type))
|
||||
.filter((slug): slug is NonNullable<typeof slug> => slug != null),
|
||||
);
|
||||
if (pages.size > 0) {
|
||||
expect(
|
||||
destination,
|
||||
`${widget} shows ${TILE_TYPES[widget].join(", ")}, which has a page, but links nowhere`,
|
||||
).not.toBeNull();
|
||||
expect(pages.has(destination!)).toBe(true);
|
||||
return;
|
||||
}
|
||||
const nonMeasurement = NON_MEASUREMENT_DESTINATION[widget];
|
||||
if (nonMeasurement) {
|
||||
expect(destination).toBe(nonMeasurement.slug);
|
||||
expect(nonMeasurement.reason.length).toBeGreaterThan(10);
|
||||
return;
|
||||
}
|
||||
expect(destination).toBeNull();
|
||||
expect(
|
||||
NO_DESTINATION[widget],
|
||||
`${widget} has no destination and no written reason`,
|
||||
).toBeTruthy();
|
||||
},
|
||||
);
|
||||
|
||||
it("allowlists only tiles that really lead nowhere", () => {
|
||||
for (const widget of Object.keys(NO_DESTINATION) as TileCapableWidgetId[]) {
|
||||
expect(TILE_DESTINATION[widget]).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("links only to sub-pages that exist as routes", () => {
|
||||
const slugs = Object.values(TILE_DESTINATION).filter(
|
||||
(slug): slug is NonNullable<typeof slug> => slug !== null,
|
||||
);
|
||||
expect(slugs.length).toBeGreaterThanOrEqual(18);
|
||||
for (const slug of slugs) {
|
||||
expect(
|
||||
existsSync(path.join(ROOT, "src/app/insights", slug, "page.tsx")),
|
||||
`/insights/${slug} has no page`,
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("never links a tile to a page owned by a different module", () => {
|
||||
for (const widget of TILE_CAPABLE_WIDGET_IDS) {
|
||||
const slug = TILE_DESTINATION[widget];
|
||||
if (slug === null) continue;
|
||||
const pageOwner = surfaceModule(`insights-page:${slug}`);
|
||||
if (pageOwner === undefined) continue;
|
||||
expect(
|
||||
surfaceModule(`widget:${widget}`),
|
||||
`${widget} links to /insights/${slug}, owned by ${pageOwner}`,
|
||||
).toBe(pageOwner);
|
||||
}
|
||||
});
|
||||
|
||||
it("resolves the rendered tile ids to their hrefs", () => {
|
||||
expect(dashboardTileHref("bodyFat", undefined)).toBe("/insights/body-fat");
|
||||
expect(dashboardTileHref("bp-sys", undefined)).toBe(
|
||||
"/insights/blood-pressure",
|
||||
);
|
||||
expect(dashboardTileHref("bp-dia", null)).toBe("/insights/blood-pressure");
|
||||
expect(dashboardTileHref("glucose-FASTING", {})).toBe(
|
||||
"/insights/blood-glucose",
|
||||
);
|
||||
expect(dashboardTileHref("not-a-tile", undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it("drops the link when the destination's module is switched off", () => {
|
||||
expect(dashboardTileHref("mood", { mood: false })).toBeNull();
|
||||
expect(dashboardTileHref("glucose-FASTING", { glucose: false })).toBeNull();
|
||||
expect(dashboardTileHref("waterIntake", { nutrients: false })).toBeNull();
|
||||
expect(dashboardTileHref("bodyFat", { mood: false })).toBe(
|
||||
"/insights/body-fat",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -11,7 +11,10 @@ import {
|
||||
GLUCOSE_CONTEXT_BUCKETS,
|
||||
type GlucoseContextBucket,
|
||||
} from "@/lib/glucose";
|
||||
import type { DashboardLayout } from "@/lib/dashboard-layout";
|
||||
import type {
|
||||
DashboardLayout,
|
||||
DashboardWidgetId,
|
||||
} from "@/lib/dashboard-layout";
|
||||
|
||||
/**
|
||||
* v1.7.0 — first-paint gate for the dashboard tile strip.
|
||||
@@ -49,7 +52,7 @@ export function resolveDashboardFirstPaintGate(input: {
|
||||
* path at all — counting any of them over-reserved the loading
|
||||
* silhouette and made the strip reshuffle when the data landed.
|
||||
*/
|
||||
const TILE_CAPABLE_WIDGET_IDS = new Set<string>([
|
||||
export const TILE_CAPABLE_WIDGET_IDS = [
|
||||
"weight",
|
||||
"bp",
|
||||
"pulse",
|
||||
@@ -72,7 +75,13 @@ const TILE_CAPABLE_WIDGET_IDS = new Set<string>([
|
||||
// v1.29 — fluid intake strip tile: paints one strip card and self-gates
|
||||
// on having a NUTRIENT_WATER sample.
|
||||
"waterIntake",
|
||||
]);
|
||||
] as const satisfies readonly DashboardWidgetId[];
|
||||
|
||||
export type TileCapableWidgetId = (typeof TILE_CAPABLE_WIDGET_IDS)[number];
|
||||
|
||||
const TILE_CAPABLE_WIDGET_ID_SET: ReadonlySet<string> = new Set(
|
||||
TILE_CAPABLE_WIDGET_IDS,
|
||||
);
|
||||
|
||||
/**
|
||||
* v1.16.8 — silhouette count for the tile-strip skeleton: one card per
|
||||
@@ -85,7 +94,7 @@ const TILE_CAPABLE_WIDGET_IDS = new Set<string>([
|
||||
export function resolveConfiguredTileCount(layout: DashboardLayout): number {
|
||||
let count = 0;
|
||||
for (const widget of layout.widgets) {
|
||||
if (!TILE_CAPABLE_WIDGET_IDS.has(widget.id)) continue;
|
||||
if (!TILE_CAPABLE_WIDGET_ID_SET.has(widget.id)) continue;
|
||||
if (!(widget.tileVisible ?? widget.visible)) continue;
|
||||
count += widget.id === "bp" ? 2 : 1;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Where each dashboard strip tile leads.
|
||||
*
|
||||
* A tile shows one metric, and every metric with an Insights sub-page should
|
||||
* open it on click. The map is keyed by the tile-capable widget id and typed
|
||||
* against the sub-page slug union, so a destination that does not exist is a
|
||||
* compile error and a tile added to `TILE_CAPABLE_WIDGET_IDS` without a
|
||||
* decision here is one too. `null` means the tile deliberately leads nowhere;
|
||||
* the guard in `__tests__/tile-destinations.test.ts` demands a written reason
|
||||
* for every such entry and fails when a tile's metric has a sub-page the tile
|
||||
* does not open. It exists because the body fat tile was the one tile that
|
||||
* led nowhere, documented as having "no dedicated sub-page" while every
|
||||
* body-composition sibling had one.
|
||||
*
|
||||
* A link never points at a page the account cannot see: the destination
|
||||
* passes the same `insights-page:<slug>` surface gate the tab strip uses.
|
||||
*/
|
||||
import {
|
||||
INSIGHTS_OVERVIEW_PATH,
|
||||
type SubPageSlug,
|
||||
} from "@/lib/insights/sub-page-metric";
|
||||
import { isSurfaceVisible, type SurfaceModuleMap } from "@/lib/modules/surface";
|
||||
|
||||
import {
|
||||
TILE_CAPABLE_WIDGET_IDS,
|
||||
type TileCapableWidgetId,
|
||||
} from "./dashboard-gates";
|
||||
|
||||
export const TILE_DESTINATION: Readonly<
|
||||
Record<TileCapableWidgetId, SubPageSlug | null>
|
||||
> = {
|
||||
weight: "weight",
|
||||
bp: "blood-pressure",
|
||||
pulse: "pulse",
|
||||
bodyFat: "body-fat",
|
||||
mood: "mood",
|
||||
sleep: "sleep",
|
||||
steps: "steps",
|
||||
glucose: "blood-glucose",
|
||||
bpInTarget: "blood-pressure",
|
||||
vo2Max: "cardio-fitness",
|
||||
hrv: "hrv",
|
||||
oxygenSaturation: "oxygen",
|
||||
respiratoryRate: "respiratory-rate",
|
||||
wristTemperature: "wrist-temperature",
|
||||
muscleMass: "muscle-mass",
|
||||
totalBodyWater: "body-water",
|
||||
boneMass: "bone-mass",
|
||||
// Fluid intake lives on the nutrients page (hydration hero + quick-add),
|
||||
// not on a Measurement-backed detail page.
|
||||
waterIntake: "nutrients",
|
||||
};
|
||||
|
||||
const TILE_WIDGET_IDS: ReadonlySet<string> = new Set(TILE_CAPABLE_WIDGET_IDS);
|
||||
|
||||
/**
|
||||
* The widget a rendered tile belongs to. Blood pressure paints two tiles
|
||||
* (`bp-sys`, `bp-dia`) and glucose one per logged context (`glucose-<ctx>`);
|
||||
* every other tile carries its widget id verbatim.
|
||||
*/
|
||||
export function tileWidgetId(tileId: string): TileCapableWidgetId | null {
|
||||
if (tileId === "bp-sys" || tileId === "bp-dia") return "bp";
|
||||
if (tileId.startsWith("glucose-")) return "glucose";
|
||||
return TILE_WIDGET_IDS.has(tileId) ? (tileId as TileCapableWidgetId) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The href a rendered tile links to, or null when it has no destination or
|
||||
* the destination page is hidden by a switched-off module.
|
||||
*/
|
||||
export function dashboardTileHref(
|
||||
tileId: string,
|
||||
modules: SurfaceModuleMap | null | undefined,
|
||||
): string | null {
|
||||
const widget = tileWidgetId(tileId);
|
||||
if (widget === null) return null;
|
||||
const slug = TILE_DESTINATION[widget];
|
||||
if (slug === null) return null;
|
||||
if (!isSurfaceVisible(`insights-page:${slug}`, modules)) return null;
|
||||
return `${INSIGHTS_OVERVIEW_PATH}/${slug}`;
|
||||
}
|
||||
@@ -233,6 +233,7 @@ export const SUB_PAGE_TABS: Record<
|
||||
// ── body composition ──
|
||||
weight: { labelKey: "insights.navWeight", metric: "WEIGHT" },
|
||||
bmi: { labelKey: "insights.navBmi", metric: "BMI" },
|
||||
"body-fat": { labelKey: "insights.navBodyFat", metric: "BODY_FAT" },
|
||||
"body-water": {
|
||||
labelKey: "insights.navTotalBodyWater",
|
||||
metric: "TOTAL_BODY_WATER",
|
||||
|
||||
Reference in new issue
Block a user