diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index dfd5188dd..f2ed8234b 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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 diff --git a/src/app/api/insights/layout/__tests__/route.test.ts b/src/app/api/insights/layout/__tests__/route.test.ts index ac014f293..42ac6ef5d 100644 --- a/src/app/api/insights/layout/__tests__/route.test.ts +++ b/src/app/api/insights/layout/__tests__/route.test.ts @@ -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: [] })); diff --git a/src/lib/insights-layout.ts b/src/lib/insights-layout.ts index 13a6f65b4..fa7ca7971 100644 --- a/src/lib/insights-layout.ts +++ b/src/lib/insights-layout.ts @@ -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/` (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/`. 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/`) 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([ + "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 {