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).
This commit is contained in:
Marc-André Bombeck committed 2026-06-07 16:36:23 +02:00
1 parent d652b6c4b3
commit f6221623a4
6 files changed
+583 -37

No files matched your search

+79 -16
View File
@@ -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/<slug>` 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/<slug>` 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/<slug>` 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/<slug>` 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:
@@ -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<unknown> };
@@ -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);
+24 -2
View File
@@ -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<InsightsLayout> {
+207
View File
@@ -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"]);
});
});
+137 -2
View File
@@ -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<InsightsTileId>([
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<string>(INSIGHTS_SECTION_IDS);
const seen = new Set<string>();
const filtered: InsightsSectionConfig[] = (
raw as Array<Partial<InsightsSectionConfig>>
)
.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<string>();
// 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<string>(INSIGHTS_SECTION_IDS);
const seen = new Set<string>();
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
}));
}
+27 -4
View File
@@ -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/<slug>` 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/<slug>` 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