mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
feat(insights): widen the layout tile-id set to the full metric universe
Derive `INSIGHTS_TILE_IDS` from `SUB_PAGE_SLUGS` rather than maintaining a separate 14-id list. The layout tile-id universe and the routed tab strip now share one source of truth, so a client can persist a layout covering every metric page the web exposes (37 slugs + the overview mother page) instead of tripping a 422 on the ~25 slugs the fixed allow-list did not know about. Rebuild `DEFAULT_INSIGHTS_LAYOUT` from the derived id list with an explicit default-visible set; the long-tail HealthKit, body-composition, mobility, and audio tiles stay default-invisible. The accepted set stays closed — an unknown id still 422s — and the legacy German aliases keep round-tripping, so no persisted layout breaks. Regenerate the OpenAPI contract and extend the route deck: a PUT carrying the previously-rejected slugs now succeeds, a full-universe PUT round-trips through GET, and a genuinely-unknown id still 422s. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> (cherry picked from commit 0a161d430533845b0c4c322047701b93aea49a06)
This commit is contained in:
1 parent
2123c63af4
commit
d81ab69dc8
3 files changed
+196
-42
No files matched your search
@@ -2690,13 +2690,37 @@ components:
|
||||
- pulse
|
||||
- oxygen
|
||||
- body-temperature
|
||||
- respiratory-rate
|
||||
- weight
|
||||
- bmi
|
||||
- body-water
|
||||
- bone-mass
|
||||
- fat-free-mass
|
||||
- fat-mass
|
||||
- muscle-mass
|
||||
- visceral-fat
|
||||
- lean-body-mass
|
||||
- active-energy
|
||||
- workouts
|
||||
- flights-climbed
|
||||
- walking-distance
|
||||
- walking-steadiness
|
||||
- walking-heart-rate
|
||||
- walking-asymmetry
|
||||
- double-support-time
|
||||
- step-length
|
||||
- walking-speed
|
||||
- sleep
|
||||
- resting-pulse
|
||||
- hrv
|
||||
- pulse-wave-velocity
|
||||
- vascular-age
|
||||
- environmental-audio
|
||||
- headphone-audio
|
||||
- audio-events
|
||||
- daylight
|
||||
- blood-glucose
|
||||
- skin-temperature
|
||||
- mood
|
||||
- medications
|
||||
- blutdruck
|
||||
@@ -6431,13 +6455,37 @@ components:
|
||||
- pulse
|
||||
- oxygen
|
||||
- body-temperature
|
||||
- respiratory-rate
|
||||
- weight
|
||||
- bmi
|
||||
- body-water
|
||||
- bone-mass
|
||||
- fat-free-mass
|
||||
- fat-mass
|
||||
- muscle-mass
|
||||
- visceral-fat
|
||||
- lean-body-mass
|
||||
- active-energy
|
||||
- workouts
|
||||
- flights-climbed
|
||||
- walking-distance
|
||||
- walking-steadiness
|
||||
- walking-heart-rate
|
||||
- walking-asymmetry
|
||||
- double-support-time
|
||||
- step-length
|
||||
- walking-speed
|
||||
- sleep
|
||||
- resting-pulse
|
||||
- hrv
|
||||
- pulse-wave-velocity
|
||||
- vascular-age
|
||||
- environmental-audio
|
||||
- headphone-audio
|
||||
- audio-events
|
||||
- daylight
|
||||
- blood-glucose
|
||||
- skin-temperature
|
||||
- mood
|
||||
- medications
|
||||
- blutdruck
|
||||
|
||||
@@ -60,6 +60,7 @@ import { annotate } from "@/lib/logging/context";
|
||||
import { __resetAllCachesForTests } from "@/lib/cache/server-cache";
|
||||
import { __resetAuditDedupMemoForTests } from "@/lib/audit-dedup";
|
||||
import { DEFAULT_INSIGHTS_LAYOUT } from "@/lib/insights-layout";
|
||||
import { SUB_PAGE_SLUGS } from "@/lib/insights/sub-page-metric";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
@@ -252,6 +253,111 @@ describe("PUT /api/insights/layout — happy path", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("PUT /api/insights/layout — full metric-slug universe", () => {
|
||||
// v1.8.7.1 — the layout tile-id enum derives from `SUB_PAGE_SLUGS`, so
|
||||
// the long-tail HealthKit + body-composition + mobility + audio slugs
|
||||
// the routed tab strip already exposes are now valid layout ids. iOS
|
||||
// can persist a layout covering the full metric set rather than 422ing
|
||||
// on the ~25 slugs the fixed allow-list did not know about.
|
||||
const NEWLY_ACCEPTED_SLUGS = [
|
||||
"blood-glucose",
|
||||
"skin-temperature",
|
||||
"respiratory-rate",
|
||||
"flights-climbed",
|
||||
"walking-speed",
|
||||
"vascular-age",
|
||||
"headphone-audio",
|
||||
"muscle-mass",
|
||||
"daylight",
|
||||
] as const;
|
||||
|
||||
it("accepts a layout that includes every previously-rejected sub-page slug", async () => {
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
|
||||
|
||||
const tiles = NEWLY_ACCEPTED_SLUGS.map((id, order) => ({
|
||||
id,
|
||||
visible: order % 2 === 0,
|
||||
order,
|
||||
}));
|
||||
const res = await callPut(makeReq({ version: 1, tiles }));
|
||||
// No 422 — these are all canonical sub-page slugs now.
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const body = (await res.json()) as {
|
||||
data: { tiles: Array<{ id: string; visible: boolean }> };
|
||||
};
|
||||
const returnedIds = body.data.tiles.map((t) => t.id);
|
||||
for (const slug of NEWLY_ACCEPTED_SLUGS) {
|
||||
expect(returnedIds).toContain(slug);
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts a layout covering the ENTIRE slug universe in one PUT", async () => {
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
|
||||
|
||||
const tiles = ["overview", ...SUB_PAGE_SLUGS].map((id, order) => ({
|
||||
id,
|
||||
visible: true,
|
||||
order,
|
||||
}));
|
||||
const res = await callPut(makeReq({ version: 1, tiles }));
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const body = (await res.json()) as {
|
||||
data: { tiles: Array<{ id: string }> };
|
||||
};
|
||||
const returnedIds = new Set(body.data.tiles.map((t) => t.id));
|
||||
// Every slug the web tab strip enumerates round-trips through the PUT.
|
||||
for (const slug of SUB_PAGE_SLUGS) {
|
||||
expect(returnedIds.has(slug)).toBe(true);
|
||||
}
|
||||
expect(returnedIds.has("overview")).toBe(true);
|
||||
});
|
||||
|
||||
it("round-trips the new slugs back through GET after a save", async () => {
|
||||
const savedTiles = NEWLY_ACCEPTED_SLUGS.map((id, order) => ({
|
||||
id,
|
||||
visible: true,
|
||||
order: order + 1,
|
||||
}));
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
insightsLayoutJson: {
|
||||
version: 1,
|
||||
tiles: [{ id: "overview", visible: true, order: 0 }, ...savedTiles],
|
||||
},
|
||||
} as never);
|
||||
|
||||
const res = await callGet();
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: { tiles: Array<{ id: string }> };
|
||||
};
|
||||
const ids = body.data.tiles.map((t) => t.id);
|
||||
for (const slug of NEWLY_ACCEPTED_SLUGS) {
|
||||
expect(ids).toContain(slug);
|
||||
}
|
||||
});
|
||||
|
||||
it("still rejects a genuinely-unknown id even with the wider set", async () => {
|
||||
const res = await callPut(
|
||||
makeReq({
|
||||
version: 1,
|
||||
tiles: [
|
||||
{ id: "blood-glucose", visible: true, order: 0 },
|
||||
{ id: "not-a-metric-at-all", visible: true, order: 1 },
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as {
|
||||
details: { issues: Array<{ path: string }> };
|
||||
};
|
||||
expect(
|
||||
body.details.issues.some((i) => i.path.startsWith("tiles")),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PUT /api/insights/layout — 422 multi-issue envelope", () => {
|
||||
it("surfaces TWO simultaneous validation errors under details.issues", async () => {
|
||||
const res = await callPut(makeReq({ version: 2, tiles: [] }));
|
||||
|
||||
+42
-42
@@ -14,14 +14,26 @@
|
||||
* code paths against either surface.
|
||||
*/
|
||||
|
||||
import { SUB_PAGE_SLUGS } from "@/lib/insights/sub-page-metric";
|
||||
|
||||
/**
|
||||
* Every insights tile the layout knows about. Each entry maps to a
|
||||
* routed sub-page under `/insights/<slug>` (the mother-page overview
|
||||
* is keyed `"overview"`). The order of entries here is irrelevant —
|
||||
* Every insights tile the layout knows about. The mother-page overview
|
||||
* is keyed `"overview"`; every other id is a routed sub-page slug under
|
||||
* `/insights/<slug>`. The order of entries here is irrelevant —
|
||||
* `DEFAULT_INSIGHTS_LAYOUT.tiles[].order` drives the visual order.
|
||||
* Exported so the API Zod `tileIdEnum` can be built from a single
|
||||
* source of truth.
|
||||
*
|
||||
* v1.8.7.1 — the tile-id universe is derived FROM `SUB_PAGE_SLUGS`
|
||||
* (`src/lib/insights/sub-page-metric.ts`), the same record the routed
|
||||
* tab strip and the per-metric sub-pages enumerate. The two surfaces
|
||||
* therefore stay in lockstep by construction: adding a sub-page slug
|
||||
* automatically widens the layout enum, and the iOS client can persist
|
||||
* a layout covering the full metric set (blood-pressure … skin-
|
||||
* temperature) rather than tripping a 422 on the ~25 slugs the layout
|
||||
* previously did not know about. `"overview"` is layout-only — it is
|
||||
* the mother page, not a sub-page slug — so it is prepended explicitly.
|
||||
*
|
||||
* v1.8.0 — the canonical ids are English, matching the routed slugs
|
||||
* (`/insights/<slug>`) and the naming-convention ADR
|
||||
* (`docs/adr/0001-insights-naming-convention.md`). The ids were German
|
||||
@@ -32,29 +44,7 @@
|
||||
* `LEGACY_INSIGHTS_TILE_ID_ALIASES` + `normalizeInsightsTileId` below.
|
||||
* No client breaks; iOS migrates to the English ids at its own pace.
|
||||
*/
|
||||
export const INSIGHTS_TILE_IDS = [
|
||||
"overview",
|
||||
// ── vitals ──
|
||||
"blood-pressure",
|
||||
"pulse",
|
||||
"oxygen",
|
||||
"body-temperature",
|
||||
// ── body composition ──
|
||||
"weight",
|
||||
"bmi",
|
||||
// ── activity ──
|
||||
"active-energy",
|
||||
"workouts",
|
||||
// ── sleep ──
|
||||
"sleep",
|
||||
// ── cardiovascular ──
|
||||
"resting-pulse",
|
||||
"hrv",
|
||||
// ── mood ──
|
||||
"mood",
|
||||
// ── events ──
|
||||
"medications",
|
||||
] as const;
|
||||
export const INSIGHTS_TILE_IDS = ["overview", ...SUB_PAGE_SLUGS] as const;
|
||||
|
||||
export type InsightsTileId = (typeof INSIGHTS_TILE_IDS)[number];
|
||||
|
||||
@@ -123,24 +113,34 @@ const INSIGHTS_LAYOUT_VERSION = 1;
|
||||
* additional `{ id, visible: false, order: N }` row here and the
|
||||
* resolver auto-merges it onto existing users' saved layouts.
|
||||
*/
|
||||
// Core surfaces that ship default-visible for a fresh account. Every
|
||||
// other tile (the long-tail HealthKit + body-composition + mobility +
|
||||
// audio pills) stays default-invisible and the user opts in once the
|
||||
// matching data starts to flow. Keeping this as an explicit set means
|
||||
// the default-visibility decision is auditable in one place while the
|
||||
// tile-id universe itself derives from `SUB_PAGE_SLUGS`.
|
||||
const DEFAULT_VISIBLE_TILE_IDS = new Set<InsightsTileId>([
|
||||
"overview",
|
||||
"blood-pressure",
|
||||
"pulse",
|
||||
"weight",
|
||||
"bmi",
|
||||
"workouts",
|
||||
"mood",
|
||||
"medications",
|
||||
]);
|
||||
|
||||
export const DEFAULT_INSIGHTS_LAYOUT: InsightsLayout = {
|
||||
version: INSIGHTS_LAYOUT_VERSION,
|
||||
tiles: [
|
||||
{ id: "overview", visible: true, order: 0 },
|
||||
{ id: "blood-pressure", visible: true, order: 1 },
|
||||
{ id: "pulse", visible: true, order: 2 },
|
||||
{ id: "oxygen", visible: false, order: 3 },
|
||||
{ id: "body-temperature", visible: false, order: 4 },
|
||||
{ id: "weight", visible: true, order: 5 },
|
||||
{ id: "bmi", visible: true, order: 6 },
|
||||
{ id: "active-energy", visible: false, order: 7 },
|
||||
{ id: "workouts", visible: true, order: 8 },
|
||||
{ id: "sleep", visible: false, order: 9 },
|
||||
{ id: "resting-pulse", visible: false, order: 10 },
|
||||
{ id: "hrv", visible: false, order: 11 },
|
||||
{ id: "mood", visible: true, order: 12 },
|
||||
{ id: "medications", visible: true, order: 13 },
|
||||
],
|
||||
// Order follows `INSIGHTS_TILE_IDS` (overview first, then the
|
||||
// `SUB_PAGE_SLUGS` order, which itself tracks the MeasurementCategory
|
||||
// overlay: vitals → body → activity → sleep → cardiovascular → mood →
|
||||
// events). The dense 0-based `order` mirrors the routed tab strip.
|
||||
tiles: INSIGHTS_TILE_IDS.map((id, order) => ({
|
||||
id,
|
||||
visible: DEFAULT_VISIBLE_TILE_IDS.has(id),
|
||||
order,
|
||||
})),
|
||||
};
|
||||
|
||||
export function resolveInsightsLayout(raw: unknown): InsightsLayout {
|
||||
|
||||
Reference in new issue
Block a user