mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
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:
1 parent
d652b6c4b3
commit
f6221623a4
6 files changed
+583
-37
No files matched your search
+79
-16
@@ -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);
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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
@@ -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
|
||||
}));
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user