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:
Marc-André Bombeck committed 2026-06-02 14:15:31 +02:00
1 parent 2123c63af4
commit d81ab69dc8
3 files changed
+196 -42

No files matched your search

+48
View File
@@ -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
View File
@@ -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 {