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

@@ -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: [] }));