From f6221623a49f9646ed9836415576ee7ec16d6fcc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 7 Jun 2026 16:36:23 +0200 Subject: [PATCH] feat(insights): extend layout model to v2 with section ordering Add an additive, optional `sections` array to the persisted insights layout alongside the existing per-metric `tiles`, with the same merge/dedupe/dense-order semantics. v1 blobs resolve forward to a valid v2 layout with every section default-visible; the iOS `tiles` contract is unchanged and a PUT may omit either array (defaults fill in). --- docs/api/openapi.yaml | 95 ++++++-- .../insights/layout/__tests__/route.test.ts | 122 +++++++++-- src/app/api/insights/layout/route.ts | 26 ++- src/lib/__tests__/insights-layout.test.ts | 207 ++++++++++++++++++ src/lib/insights-layout.ts | 139 +++++++++++- src/lib/openapi/routes.ts | 31 ++- 6 files changed, 583 insertions(+), 37 deletions(-) create mode 100644 src/lib/__tests__/insights-layout.test.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 197c9f6b1..b0f9a1bbb 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -4556,7 +4556,34 @@ components: properties: version: type: number - const: 1 + const: 2 + sections: + maxItems: 50 + type: array + items: + type: object + properties: + id: + type: string + enum: + - wellness-scores + - daily-briefing + - vitals + - trends + - period-review + - cycle-summary + - signals + - rhythm-events + visible: + type: boolean + order: + type: integer + minimum: 0 + maximum: 99 + required: + - id + - visible + - order tiles: minItems: 1 maxItems: 50 @@ -4636,13 +4663,17 @@ components: - order required: - version - - tiles - description: "Per-user Insights tile layout: an ordered list of tiles with a visibility flag. `version` is the layout - schema version. Tile ids are a closed enum: the canonical ids are English (matching the routed - `/insights/` sub-pages). The legacy German ids (blutdruck, puls, sauerstoff, koerpertemperatur, gewicht, - aktive-energie, schlaf, ruhepuls, stimmung, medikamente) remain accepted on input for backward compatibility and - are normalised to their English equivalents before persisting; GET responses always carry the canonical English - ids. The legacy ids are deprecated and will be removed in a future major version." + description: "Per-user Insights layout (v2). `tiles` is the per-metric pill list (ordered, with a visibility flag); + `sections` is the additive v2 list of the overview's big semantic blocks (wellness-scores, daily-briefing, + vitals, trends, period-review, cycle-summary, signals, rhythm-events), each with order + visibility. `version` + is the layout schema version (2). Both arrays are optional on input — a client sending only `tiles` (the pre-v2 + contract) still validates, and the server fills missing defaults; a v1 blob with no `sections` resolves forward + with all sections default-visible. Tile ids are a closed enum: the canonical ids are English (matching the + routed `/insights/` sub-pages). The legacy German tile ids (blutdruck, puls, sauerstoff, + koerpertemperatur, gewicht, aktive-energie, schlaf, ruhepuls, stimmung, medikamente) remain accepted on input + for backward compatibility and are normalised to their English equivalents before persisting; GET responses + always carry the canonical English ids. Section ids are English-only. The legacy tile ids are deprecated and + will be removed in a future major version." CoachMessageFeedbackRequest: type: object properties: @@ -11444,7 +11475,35 @@ components: properties: version: type: number - const: 1 + const: 2 + sections: + maxItems: 50 + type: array + items: + type: object + properties: + id: + type: string + enum: + - wellness-scores + - daily-briefing + - vitals + - trends + - period-review + - cycle-summary + - signals + - rhythm-events + visible: + type: boolean + order: + type: integer + minimum: 0 + maximum: 99 + required: + - id + - visible + - order + additionalProperties: false tiles: minItems: 1 maxItems: 50 @@ -11525,14 +11584,18 @@ components: additionalProperties: false required: - version - - tiles additionalProperties: false - description: "Per-user Insights tile layout: an ordered list of tiles with a visibility flag. `version` is the layout - schema version. Tile ids are a closed enum: the canonical ids are English (matching the routed - `/insights/` sub-pages). The legacy German ids (blutdruck, puls, sauerstoff, koerpertemperatur, gewicht, - aktive-energie, schlaf, ruhepuls, stimmung, medikamente) remain accepted on input for backward compatibility and - are normalised to their English equivalents before persisting; GET responses always carry the canonical English - ids. The legacy ids are deprecated and will be removed in a future major version." + description: "Per-user Insights layout (v2). `tiles` is the per-metric pill list (ordered, with a visibility flag); + `sections` is the additive v2 list of the overview's big semantic blocks (wellness-scores, daily-briefing, + vitals, trends, period-review, cycle-summary, signals, rhythm-events), each with order + visibility. `version` + is the layout schema version (2). Both arrays are optional on input — a client sending only `tiles` (the pre-v2 + contract) still validates, and the server fills missing defaults; a v1 blob with no `sections` resolves forward + with all sections default-visible. Tile ids are a closed enum: the canonical ids are English (matching the + routed `/insights/` sub-pages). The legacy German tile ids (blutdruck, puls, sauerstoff, + koerpertemperatur, gewicht, aktive-energie, schlaf, ruhepuls, stimmung, medikamente) remain accepted on input + for backward compatibility and are normalised to their English equivalents before persisting; GET responses + always carry the canonical English ids. Section ids are English-only. The legacy tile ids are deprecated and + will be removed in a future major version." Medication: type: object properties: diff --git a/src/app/api/insights/layout/__tests__/route.test.ts b/src/app/api/insights/layout/__tests__/route.test.ts index 42ac6ef5d..b5b432153 100644 --- a/src/app/api/insights/layout/__tests__/route.test.ts +++ b/src/app/api/insights/layout/__tests__/route.test.ts @@ -103,7 +103,7 @@ describe("GET /api/insights/layout", () => { const body = (await res.json()) as { data: typeof DEFAULT_INSIGHTS_LAYOUT; }; - expect(body.data.version).toBe(1); + expect(body.data.version).toBe(2); expect(body.data.tiles.length).toBe(DEFAULT_INSIGHTS_LAYOUT.tiles.length); expect(body.data.tiles[0]?.id).toBe(DEFAULT_INSIGHTS_LAYOUT.tiles[0]?.id); }); @@ -185,7 +185,7 @@ describe("PUT /api/insights/layout — happy path", () => { vi.mocked(prisma.user.update).mockResolvedValue({} as never); const payload = { - version: 1, + version: 2, tiles: [ { id: "overview", visible: true, order: 0 }, { id: "blood-pressure", visible: true, order: 1 }, @@ -219,7 +219,7 @@ describe("PUT /api/insights/layout — happy path", () => { vi.mocked(prisma.user.update).mockResolvedValue({} as never); const payload = { - version: 1, + version: 2, tiles: [ { id: "overview", visible: true, order: 0 }, { id: "blutdruck", visible: true, order: 1 }, @@ -279,7 +279,7 @@ describe("PUT /api/insights/layout — full metric-slug universe", () => { visible: order % 2 === 0, order, })); - const res = await callPut(makeReq({ version: 1, tiles })); + const res = await callPut(makeReq({ version: 2, tiles })); // No 422 — these are all canonical sub-page slugs now. expect(res.status).toBe(200); @@ -300,7 +300,7 @@ describe("PUT /api/insights/layout — full metric-slug universe", () => { visible: true, order, })); - const res = await callPut(makeReq({ version: 1, tiles })); + const res = await callPut(makeReq({ version: 2, tiles })); expect(res.status).toBe(200); const body = (await res.json()) as { @@ -341,7 +341,7 @@ describe("PUT /api/insights/layout — full metric-slug universe", () => { it("still rejects a genuinely-unknown id even with the wider set", async () => { const res = await callPut( makeReq({ - version: 1, + version: 2, tiles: [ { id: "blood-glucose", visible: true, order: 0 }, { id: "not-a-metric-at-all", visible: true, order: 1 }, @@ -360,7 +360,7 @@ describe("PUT /api/insights/layout — full metric-slug universe", () => { 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: [] })); + const res = await callPut(makeReq({ version: 99, tiles: [] })); expect(res.status).toBe(422); const body = (await res.json()) as { @@ -384,7 +384,7 @@ describe("PUT /api/insights/layout — 422 multi-issue envelope", () => { it("rejects an unknown tile id", async () => { const res = await callPut( makeReq({ - version: 1, + version: 2, tiles: [{ id: "not-a-real-tile", visible: true, order: 0 }], }), ); @@ -398,7 +398,7 @@ describe("PUT /api/insights/layout — 422 multi-issue envelope", () => { }); it("writes one audit-ledger row keyed insights.layout.validation-failed", async () => { - const res = await callPut(makeReq({ version: 2, tiles: [] })); + const res = await callPut(makeReq({ version: 99, tiles: [] })); expect(res.status).toBe(422); await new Promise((r) => setTimeout(r, 5)); @@ -422,9 +422,9 @@ describe("PUT /api/insights/layout — 422 multi-issue envelope", () => { }); it("dedups the audit-ledger write across two sequential 422s for the same user", async () => { - const res1 = await callPut(makeReq({ version: 2, tiles: [] })); + const res1 = await callPut(makeReq({ version: 99, tiles: [] })); expect(res1.status).toBe(422); - const res2 = await callPut(makeReq({ version: 2, tiles: [] })); + const res2 = await callPut(makeReq({ version: 99, tiles: [] })); expect(res2.status).toBe(422); await new Promise((r) => setTimeout(r, 5)); @@ -440,7 +440,7 @@ describe("PUT /api/insights/layout — 422 multi-issue envelope", () => { }); it("surfaces received_keys + received_shape_excerpt + zod_issues in the wide-event meta", async () => { - const payload = { version: 2, tiles: [], extraGarbage: "from-ios" }; + const payload = { version: 99, tiles: [], extraGarbage: "from-ios" }; const res = await callPut(makeReq(payload)); expect(res.status).toBe(422); @@ -508,7 +508,7 @@ describe("PUT /api/insights/layout — 422 multi-issue envelope", () => { new Error("db down"), ); - const res = await callPut(makeReq({ version: 2, tiles: [] })); + const res = await callPut(makeReq({ version: 99, tiles: [] })); expect(res.status).toBe(422); const body = (await res.json()) as { details: { issues: Array }; @@ -517,6 +517,102 @@ describe("PUT /api/insights/layout — 422 multi-issue envelope", () => { }); }); +describe("PUT /api/insights/layout — v2 sections", () => { + it("persists a sections array alongside tiles", async () => { + vi.mocked(prisma.user.update).mockResolvedValue({} as never); + + const payload = { + version: 2, + sections: [ + { id: "vitals", visible: true, order: 0 }, + { id: "daily-briefing", visible: false, order: 1 }, + { id: "wellness-scores", visible: true, order: 2 }, + ], + tiles: [{ id: "overview", visible: true, order: 0 }], + }; + const res = await callPut(makeReq(payload)); + expect(res.status).toBe(200); + + const body = (await res.json()) as { + data: { sections: Array<{ id: string; visible: boolean; order: number }> }; + }; + // Saved sections surface in saved order, dense 0-based. + expect(body.data.sections[0]).toEqual({ + id: "vitals", + visible: true, + order: 0, + }); + expect(body.data.sections[1]).toEqual({ + id: "daily-briefing", + visible: false, + order: 1, + }); + // PUT persists exactly the sent sections (serialize, not resolve); + // the read path merges any missing defaults back in. + expect(body.data.sections.map((s) => s.id)).toEqual([ + "vitals", + "daily-briefing", + "wellness-scores", + ]); + }); + + it("succeeds when the body omits sections (backward-compat iOS client)", async () => { + vi.mocked(prisma.user.update).mockResolvedValue({} as never); + + const res = await callPut( + makeReq({ + version: 2, + tiles: [{ id: "overview", visible: true, order: 0 }], + }), + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { sections: Array<{ id: string; visible: boolean }> }; + }; + // Defaults fill in, all visible. + expect(body.data.sections.length).toBeGreaterThan(0); + for (const s of body.data.sections) expect(s.visible).toBe(true); + }); + + it("succeeds when the body omits tiles (section-only PUT)", async () => { + vi.mocked(prisma.user.update).mockResolvedValue({} as never); + + const res = await callPut( + makeReq({ + version: 2, + sections: [{ id: "vitals", visible: true, order: 0 }], + }), + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { + sections: Array<{ id: string }>; + tiles: Array<{ id: string }>; + }; + }; + // tiles fall back to the canonical default set. + expect(body.data.tiles.length).toBe(DEFAULT_INSIGHTS_LAYOUT.tiles.length); + expect(body.data.sections.some((s) => s.id === "vitals")).toBe(true); + }); + + it("rejects an unknown section id", async () => { + const res = await callPut( + makeReq({ + version: 2, + sections: [{ id: "not-a-section", visible: true, order: 0 }], + tiles: [{ id: "overview", visible: true, order: 0 }], + }), + ); + 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("sections")), + ).toBe(true); + }); +}); + describe("DELETE /api/insights/layout", () => { it("clears the saved row and returns the default layout", async () => { vi.mocked(prisma.user.update).mockResolvedValue({} as never); diff --git a/src/app/api/insights/layout/route.ts b/src/app/api/insights/layout/route.ts index 699e353c6..810c43d01 100644 --- a/src/app/api/insights/layout/route.ts +++ b/src/app/api/insights/layout/route.ts @@ -28,6 +28,7 @@ import { serializeInsightsLayout, DEFAULT_INSIGHTS_LAYOUT, ACCEPTED_INSIGHTS_TILE_IDS, + INSIGHTS_SECTION_IDS, type InsightsLayout, } from "@/lib/insights-layout"; import { Prisma } from "@/generated/prisma/client"; @@ -51,8 +52,28 @@ import type { NextRequest } from "next/server"; // always canonical regardless of which id the client sent. const tileIdEnum = z.enum(ACCEPTED_INSIGHTS_TILE_IDS); +// v1.15.11 — section ids are new in layout v2, English from birth, so the +// enum is the canonical id universe with no legacy-alias widening. +const sectionIdEnum = z.enum(INSIGHTS_SECTION_IDS); + const layoutSchema = z.object({ - version: z.literal(1), + version: z.literal(2), + // Additive + optional so a current iOS client PUTting only `tiles` + // (no `sections` key) still validates; `serializeInsightsLayout` fills + // the section defaults when the field is absent. + sections: z + .array( + z.object({ + id: sectionIdEnum, + visible: z.boolean(), + order: z.number().int().min(0).max(99), + }), + ) + .max(50) + .optional(), + // Optional too — a section-only PUT (no `tiles` key) is valid; the + // serializer fills the canonical default tile set. When present it must + // still carry at least one tile so an empty `[]` is a clear client bug. tiles: z .array( z.object({ @@ -62,7 +83,8 @@ const layoutSchema = z.object({ }), ) .min(1) - .max(50), + .max(50) + .optional(), }); async function buildInsightsLayout(userId: string): Promise { diff --git a/src/lib/__tests__/insights-layout.test.ts b/src/lib/__tests__/insights-layout.test.ts new file mode 100644 index 000000000..3d25e5d50 --- /dev/null +++ b/src/lib/__tests__/insights-layout.test.ts @@ -0,0 +1,207 @@ +/** + * v1.15.11 — insights layout model v2. + * + * The layout blob grew a `sections` array on top of the existing per- + * metric `tiles` list. These tests pin the v2 contract that the + * `/insights` page (web) and the iOS client share: a v1 blob resolves + * forward untouched, section reorder/hide round-trips, unknown section + * ids auto-merge invisible then drop on serialize, and an omitted + * `sections` / `tiles` field falls back to defaults. + */ +import { describe, it, expect } from "vitest"; +import { + INSIGHTS_SECTION_IDS, + DEFAULT_INSIGHTS_LAYOUT, + resolveInsightsLayout, + serializeInsightsLayout, +} from "@/lib/insights-layout"; + +describe("insights-layout v2 — section id universe", () => { + it("carries exactly the eight default sections in render order", () => { + expect([...INSIGHTS_SECTION_IDS]).toEqual([ + "wellness-scores", + "daily-briefing", + "vitals", + "trends", + "period-review", + "cycle-summary", + "signals", + "rhythm-events", + ]); + }); + + it("defaults every section visible with a dense 0-based order", () => { + expect(DEFAULT_INSIGHTS_LAYOUT.version).toBe(2); + expect(DEFAULT_INSIGHTS_LAYOUT.sections.length).toBe( + INSIGHTS_SECTION_IDS.length, + ); + DEFAULT_INSIGHTS_LAYOUT.sections.forEach((s, i) => { + expect(s.visible).toBe(true); + expect(s.order).toBe(i); + expect(s.id).toBe(INSIGHTS_SECTION_IDS[i]); + }); + }); +}); + +describe("resolveInsightsLayout — v1 → v2 forward upgrade", () => { + it("fills all sections default-visible and leaves tiles untouched for a v1 blob", () => { + const v1blob = { + version: 1, + tiles: [ + { id: "overview", visible: true, order: 0 }, + { id: "blood-pressure", visible: false, order: 1 }, + ], + }; + const resolved = resolveInsightsLayout(v1blob); + + // Upgraded to v2 with the full default section set, all visible. + expect(resolved.version).toBe(2); + expect(resolved.sections.length).toBe(INSIGHTS_SECTION_IDS.length); + for (const s of resolved.sections) expect(s.visible).toBe(true); + expect(resolved.sections.map((s) => s.id)).toEqual([ + ...INSIGHTS_SECTION_IDS, + ]); + + // The two saved tiles survive verbatim at the head; the rest merge. + expect(resolved.tiles[0]).toEqual({ + id: "overview", + visible: true, + order: 0, + }); + expect(resolved.tiles[1]).toEqual({ + id: "blood-pressure", + visible: false, + order: 1, + }); + }); + + it("returns the full default layout for garbage input", () => { + expect(resolveInsightsLayout(null).sections.length).toBe( + INSIGHTS_SECTION_IDS.length, + ); + expect(resolveInsightsLayout({ version: 2 }).sections.length).toBe( + INSIGHTS_SECTION_IDS.length, + ); + }); +}); + +describe("resolveInsightsLayout — section semantics", () => { + it("auto-merges a missing default section default-invisible", () => { + const resolved = resolveInsightsLayout({ + version: 2, + sections: [{ id: "vitals", visible: true, order: 0 }], + tiles: [{ id: "overview", visible: true, order: 0 }], + }); + const vitals = resolved.sections.find((s) => s.id === "vitals"); + const trends = resolved.sections.find((s) => s.id === "trends"); + expect(vitals?.visible).toBe(true); + // Every section the blob omitted merges in default-invisible. + expect(trends?.visible).toBe(false); + expect(resolved.sections.length).toBe(INSIGHTS_SECTION_IDS.length); + }); + + it("drops an unknown section id (auto-merge then filter)", () => { + // `resolveInsightsLayout` takes `unknown` (a raw JSON blob from the + // column), so a garbage section id models a malformed/forward blob. + const blob: unknown = { + version: 2, + sections: [ + { id: "vitals", visible: true, order: 0 }, + { id: "not-a-section", visible: true, order: 1 }, + ], + tiles: [{ id: "overview", visible: true, order: 0 }], + }; + const resolved = resolveInsightsLayout(blob); + expect( + resolved.sections.some((s) => (s.id as string) === "not-a-section"), + ).toBe(false); + expect(resolved.sections.length).toBe(INSIGHTS_SECTION_IDS.length); + }); + + it("dedupes a repeated section id, keeping the first occurrence", () => { + const resolved = resolveInsightsLayout({ + version: 2, + sections: [ + { id: "vitals", visible: true, order: 0 }, + { id: "vitals", visible: false, order: 1 }, + ], + tiles: [{ id: "overview", visible: true, order: 0 }], + }); + const vitalsRows = resolved.sections.filter((s) => s.id === "vitals"); + expect(vitalsRows.length).toBe(1); + expect(vitalsRows[0]?.visible).toBe(true); + }); +}); + +describe("section reorder + hide round-trips through serialize → resolve", () => { + it("preserves a custom order + hidden flag", () => { + const reordered = { + version: 2, + sections: [ + { id: "trends", visible: true, order: 0 }, + { id: "wellness-scores", visible: false, order: 1 }, + { id: "vitals", visible: true, order: 2 }, + ], + tiles: [{ id: "overview", visible: true, order: 0 }], + }; + const serialized = serializeInsightsLayout(reordered); + const resolved = resolveInsightsLayout(serialized); + + // The three explicitly-ordered sections lead in their chosen order. + expect(resolved.sections[0]?.id).toBe("trends"); + expect(resolved.sections[1]?.id).toBe("wellness-scores"); + expect(resolved.sections[1]?.visible).toBe(false); + expect(resolved.sections[2]?.id).toBe("vitals"); + // Orders stay dense 0-based. + resolved.sections.forEach((s, i) => expect(s.order).toBe(i)); + }); +}); + +describe("serializeInsightsLayout — defaults + dense order", () => { + it("fills section defaults when the input omits sections", () => { + const serialized = serializeInsightsLayout({ + version: 2, + tiles: [{ id: "overview", visible: true, order: 0 }], + }); + expect(serialized.sections.length).toBe(INSIGHTS_SECTION_IDS.length); + for (const s of serialized.sections) expect(s.visible).toBe(true); + }); + + it("fills tile defaults when the input omits tiles", () => { + const serialized = serializeInsightsLayout({ + version: 2, + sections: [{ id: "vitals", visible: true, order: 0 }], + }); + expect(serialized.tiles.length).toBe(DEFAULT_INSIGHTS_LAYOUT.tiles.length); + }); + + it("normalises sparse section orders to a dense 0-based sequence", () => { + const serialized = serializeInsightsLayout({ + version: 2, + sections: [ + { id: "vitals", visible: true, order: 40 }, + { id: "trends", visible: true, order: 10 }, + { id: "signals", visible: false, order: 99 }, + ], + tiles: [{ id: "overview", visible: true, order: 0 }], + }); + // Sorted by input order, then re-numbered 0,1,2. + expect(serialized.sections.map((s) => ({ id: s.id, order: s.order }))).toEqual([ + { id: "trends", order: 0 }, + { id: "vitals", order: 1 }, + { id: "signals", order: 2 }, + ]); + }); + + it("drops unknown section ids on serialize", () => { + const serialized = serializeInsightsLayout({ + version: 2, + sections: [ + { id: "vitals", visible: true, order: 0 }, + { id: "bogus", visible: true, order: 1 }, + ], + tiles: [{ id: "overview", visible: true, order: 0 }], + }); + expect(serialized.sections.map((s) => s.id)).toEqual(["vitals"]); + }); +}); diff --git a/src/lib/insights-layout.ts b/src/lib/insights-layout.ts index fa7ca7971..f731f2bca 100644 --- a/src/lib/insights-layout.ts +++ b/src/lib/insights-layout.ts @@ -90,6 +90,39 @@ export function normalizeInsightsTileId(id: string): string { return LEGACY_INSIGHTS_TILE_ID_ALIASES[id] ?? id; } +/** + * v1.15.11 — the overview's big semantic blocks, customizable at the + * section level on top of the per-metric `tiles` list. Order here is the + * default render order (Hero is NOT a section — Hero + Score + Coach + * stays an anchored, non-customizable top block above the customizable + * region). English from birth — unlike the tile ids these never had a + * German phase, so no alias map is needed. + * + * Maps to the `/insights` page blocks: WellnessScores → `wellness-scores`, + * DailyBriefing → `daily-briefing`, VitalsDashboard → `vitals`, TrendsRow + * → `trends`, PeriodNarrativeCard → `period-review`, CycleInsightSummary + * → `cycle-summary`, CoincidentDeviationCard → `signals`, RhythmEvents → + * `rhythm-events`. + */ +export const INSIGHTS_SECTION_IDS = [ + "wellness-scores", + "daily-briefing", + "vitals", + "trends", + "period-review", + "cycle-summary", + "signals", + "rhythm-events", +] as const; + +export type InsightsSectionId = (typeof INSIGHTS_SECTION_IDS)[number]; + +export interface InsightsSectionConfig { + id: InsightsSectionId; + visible: boolean; + order: number; +} + export interface InsightsTileConfig { id: InsightsTileId; visible: boolean; @@ -98,10 +131,11 @@ export interface InsightsTileConfig { export interface InsightsLayout { version: number; + sections: InsightsSectionConfig[]; tiles: InsightsTileConfig[]; } -const INSIGHTS_LAYOUT_VERSION = 1; +const INSIGHTS_LAYOUT_VERSION = 2; /** * Default layout mirrors the routed tab-strip order — vitals first, @@ -132,6 +166,14 @@ const DEFAULT_VISIBLE_TILE_IDS = new Set([ export const DEFAULT_INSIGHTS_LAYOUT: InsightsLayout = { version: INSIGHTS_LAYOUT_VERSION, + // Every section ships default-visible; feature/data gates still apply + // on top at render time. The dense 0-based `order` follows + // `INSIGHTS_SECTION_IDS`, which mirrors the current page render order. + sections: INSIGHTS_SECTION_IDS.map((id, order) => ({ + id, + visible: true, + order, + })), // 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 → @@ -185,10 +227,62 @@ export function resolveInsightsLayout(raw: unknown): InsightsLayout { return { version: INSIGHTS_LAYOUT_VERSION, + sections: resolveInsightsSections(candidate.sections), tiles: [...filtered, ...appended].sort((a, b) => a.order - b.order), }; } +/** + * v1.15.11 — section-level resolution, mirroring the tile semantics: + * filter unknown ids, dedupe (first occurrence wins), merge missing + * default sections, sort by order. A v1 blob (no `sections` key) — or + * any garbage `sections` value — yields the full default section set, all + * default-visible, so existing iOS-written layouts resolve forward to a + * valid v2 layout untouched. New section ids introduced in a later + * release auto-merge default-INVISIBLE onto a saved layout (same as + * tiles); the current 8 sections come from the defaults default-visible + * whenever the blob omits them. + */ +function resolveInsightsSections(raw: unknown): InsightsSectionConfig[] { + if (!Array.isArray(raw)) { + // Missing / garbage → full default set, all visible. + return DEFAULT_INSIGHTS_LAYOUT.sections.map((s) => ({ ...s })); + } + + const knownIds = new Set(INSIGHTS_SECTION_IDS); + const seen = new Set(); + const filtered: InsightsSectionConfig[] = ( + raw as Array> + ) + .filter((s): s is InsightsSectionConfig => { + if ( + !s || + typeof s.id !== "string" || + typeof s.visible !== "boolean" || + typeof s.order !== "number" + ) { + return false; + } + if (!knownIds.has(s.id) || seen.has(s.id)) return false; + seen.add(s.id); + return true; + }) + .map((s) => ({ id: s.id, visible: s.visible, order: s.order })); + + const savedIds = new Set(filtered.map((s) => s.id)); + const missing = DEFAULT_INSIGHTS_LAYOUT.sections.filter( + (s) => !savedIds.has(s.id), + ); + const maxOrder = Math.max(0, ...filtered.map((s) => s.order)); + const appended = missing.map((s, i) => ({ + ...s, + visible: false, // default-invisible on auto-upgrade of a NEW section id + order: maxOrder + 1 + i, + })); + + return [...filtered, ...appended].sort((a, b) => a.order - b.order); +} + /** * Input shape for {@link serializeInsightsLayout}. Looser than * {@link InsightsLayout}: the tile `id` is a bare `string` so the PUT @@ -198,7 +292,8 @@ export function resolveInsightsLayout(raw: unknown): InsightsLayout { */ export interface InsightsLayoutInput { version: number; - tiles: Array<{ id: string; visible: boolean; order: number }>; + sections?: Array<{ id: string; visible: boolean; order: number }>; + tiles?: Array<{ id: string; visible: boolean; order: number }>; } export function serializeInsightsLayout( @@ -211,8 +306,18 @@ export function serializeInsightsLayout( // legacy and canonical id for one tile collapses to a single entry, // keeping the dense 0-based order contiguous. const seen = new Set(); + // An omitted `tiles` (a section-only PUT) falls back to the canonical + // default tile set so the persisted blob is always complete. + if (!layout.tiles) { + return { + version: INSIGHTS_LAYOUT_VERSION, + sections: serializeInsightsSections(layout.sections), + tiles: DEFAULT_INSIGHTS_LAYOUT.tiles.map((t) => ({ ...t })), + }; + } return { version: INSIGHTS_LAYOUT_VERSION, + sections: serializeInsightsSections(layout.sections), tiles: layout.tiles .slice() .sort((a, b) => a.order - b.order) @@ -229,3 +334,33 @@ export function serializeInsightsLayout( })), }; } + +/** + * v1.15.11 — normalise an optional `sections` input: drop unknown ids, + * dedupe (first occurrence by order wins), re-number to a dense 0-based + * order. If the input omits `sections` entirely, fill from defaults so a + * current iOS client PUTting only `tiles` still persists a valid v2 blob. + */ +function serializeInsightsSections( + sections: InsightsLayoutInput["sections"], +): InsightsSectionConfig[] { + if (!sections) { + return DEFAULT_INSIGHTS_LAYOUT.sections.map((s) => ({ ...s })); + } + + const knownIds = new Set(INSIGHTS_SECTION_IDS); + const seen = new Set(); + return sections + .slice() + .sort((a, b) => a.order - b.order) + .filter((s) => { + if (!knownIds.has(s.id) || seen.has(s.id)) return false; + seen.add(s.id); + return true; + }) + .map((s, i) => ({ + id: s.id as InsightsSectionId, + visible: s.visible, + order: i, // normalize to 0-based dense order + })); +} diff --git a/src/lib/openapi/routes.ts b/src/lib/openapi/routes.ts index 328e2630c..84eefff83 100644 --- a/src/lib/openapi/routes.ts +++ b/src/lib/openapi/routes.ts @@ -44,7 +44,10 @@ import { MEDICATION_TREATMENT_CLASS_VALUES, } from "@/lib/validations/medication"; import { medicationExtractionSchema } from "@/lib/ai/coach/medication-extract-prompt"; -import { ACCEPTED_INSIGHTS_TILE_IDS } from "@/lib/insights-layout"; +import { + ACCEPTED_INSIGHTS_TILE_IDS, + INSIGHTS_SECTION_IDS, +} from "@/lib/insights-layout"; import { COACH_FACT_CATEGORIES } from "@/lib/ai/coach/facts"; import { exportSelectionSchema } from "@/lib/validations/health-record-export"; import { createShareLinkSchema } from "@/lib/validations/clinician-share-link"; @@ -2028,9 +2031,28 @@ medicationExtractionSchema.meta({ // English id before persisting, and GET always returns canonical ids. // The legacy ids are accepted-but-deprecated and will be dropped from // the accepted set in a future major. +// v1.15.11 — layout v2 adds an optional `sections` array on top of the +// per-metric `tiles` list so the overview's big semantic blocks can be +// reordered/hidden in their own right. `sections` is additive and +// optional: a client PUTting only `tiles` (the pre-v2 iOS contract) still +// validates, and a v1 blob resolves forward to a valid v2 layout with all +// sections default-visible. Section ids are English from birth — no +// legacy-alias widening. `tiles` is likewise optional now (a section-only +// PUT fills the default tile set), but must carry at least one entry when +// present. const insightsLayoutSchema = z .object({ - version: z.literal(1), + version: z.literal(2), + sections: z + .array( + z.object({ + id: z.enum(INSIGHTS_SECTION_IDS), + visible: z.boolean(), + order: z.number().int().min(0).max(99), + }), + ) + .max(50) + .optional(), tiles: z .array( z.object({ @@ -2040,12 +2062,13 @@ const insightsLayoutSchema = z }), ) .min(1) - .max(50), + .max(50) + .optional(), }) .meta({ id: "InsightsLayoutBody", description: - "Per-user Insights tile layout: an ordered list of tiles with a visibility flag. `version` is the layout schema version. Tile ids are a closed enum: the canonical ids are English (matching the routed `/insights/` sub-pages). The legacy German ids (blutdruck, puls, sauerstoff, koerpertemperatur, gewicht, aktive-energie, schlaf, ruhepuls, stimmung, medikamente) remain accepted on input for backward compatibility and are normalised to their English equivalents before persisting; GET responses always carry the canonical English ids. The legacy ids are deprecated and will be removed in a future major version.", + "Per-user Insights layout (v2). `tiles` is the per-metric pill list (ordered, with a visibility flag); `sections` is the additive v2 list of the overview's big semantic blocks (wellness-scores, daily-briefing, vitals, trends, period-review, cycle-summary, signals, rhythm-events), each with order + visibility. `version` is the layout schema version (2). Both arrays are optional on input — a client sending only `tiles` (the pre-v2 contract) still validates, and the server fills missing defaults; a v1 blob with no `sections` resolves forward with all sections default-visible. Tile ids are a closed enum: the canonical ids are English (matching the routed `/insights/` sub-pages). The legacy German tile ids (blutdruck, puls, sauerstoff, koerpertemperatur, gewicht, aktive-energie, schlaf, ruhepuls, stimmung, medikamente) remain accepted on input for backward compatibility and are normalised to their English equivalents before persisting; GET responses always carry the canonical English ids. Section ids are English-only. The legacy tile ids are deprecated and will be removed in a future major version.", }); // v1.7.0 — health-record export selection. Strict shape: unknown keys