From a75b96b2a9cb97f2f07d32346a140a9f8f51bd5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 28 May 2026 22:13:51 +0200 Subject: [PATCH] feat(insights): server-persist insights tile order + visibility Mirrors /api/dashboard/widgets for the insights surface: GET returns the user's saved layout (defaulting to project order when unset), PUT writes the layout, the response is the canonical shape the clients can render directly. Closes the cross-device drift where insights ordering lived in iOS local state only. Storage is a new `insights_layout_json` JSONB column on `users`, parallel to `dashboard_widgets_json` so each surface's resolver owns its tile-id allowlist. Migration 0083 adds the column idempotently; default NULL resolves to `DEFAULT_INSIGHTS_LAYOUT`. PUT routes through the shared multi-issue 422 envelope + payload-redaction + audit-row dedup pattern the dashboard-widgets route established. A 5-minute per-user cache mirrors the dashboard-widgets cache and busts on every save / reset. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../migration.sql | 44 +++ prisma/schema.prisma | 9 + .../coach-route-gate-inventory.test.ts | 6 + .../insights/layout/__tests__/route.test.ts | 361 ++++++++++++++++++ src/app/api/insights/layout/route.ts | 168 ++++++++ src/lib/cache/invalidate.ts | 10 + src/lib/cache/server-cache.ts | 12 + src/lib/insights-layout.ts | 145 +++++++ src/lib/query-keys.ts | 8 + 9 files changed, 763 insertions(+) create mode 100644 prisma/migrations/0084_v155_user_insights_layout/migration.sql create mode 100644 src/app/api/insights/layout/__tests__/route.test.ts create mode 100644 src/app/api/insights/layout/route.ts create mode 100644 src/lib/insights-layout.ts diff --git a/prisma/migrations/0084_v155_user_insights_layout/migration.sql b/prisma/migrations/0084_v155_user_insights_layout/migration.sql new file mode 100644 index 000000000..cce03bfd4 --- /dev/null +++ b/prisma/migrations/0084_v155_user_insights_layout/migration.sql @@ -0,0 +1,44 @@ +-- v1.5.5 — per-user insights tile layout. +-- +-- Mirrors `dashboard_widgets_json` for the `/insights` surface. iOS +-- v0.8.0 audit flagged that insights tile order + visibility lived in +-- the iOS client's local state only, so reordering a tile on one +-- device never propagated to the web Settings UI or to a second iOS +-- install signed into the same account. This column closes that +-- cross-device drift by giving the new `GET / PUT /api/insights/layout` +-- endpoint somewhere to persist the user's choice. +-- +-- Shape: +-- { +-- "version": 1, +-- "tiles": [ +-- { "id": "", "visible": , "order": }, +-- … +-- ] +-- } +-- +-- Default `NULL`, which the application resolver +-- (`src/lib/insights-layout.ts:resolveInsightsLayout`) reads as +-- "use `DEFAULT_INSIGHTS_LAYOUT`". The GET endpoint never lazy-writes +-- on read — the column only carries data once the user has explicitly +-- saved a layout, matching the dashboard-widgets contract. +-- +-- Idempotent guard (`IF NOT EXISTS`) matches the 0067 / 0070 / 0071 / +-- 0075 / 0078 / 0079 pattern so reruns are safe on environments where +-- the migration has already been applied. +-- +-- Reversibility: down migration is +-- ALTER TABLE "users" DROP COLUMN IF EXISTS "insights_layout_json"; +-- A roll-back loses the saved layout for every user who set one (they +-- fall back to the default order), but the application reads tolerate +-- a missing column / null row and the resolver returns the default +-- layout in either case, so a partial deploy where the schema is +-- rolled back ahead of the app code keeps working. + +-- No additional index — reads at request time are per-user +-- (`WHERE id = $1`), matching the convention set by +-- `dashboard_widgets_json`, `thresholds_json`, `source_priority_json`, +-- and `doctor_report_prefs_json` on the same table (no GIN index on +-- any of them). +ALTER TABLE "users" + ADD COLUMN IF NOT EXISTS "insights_layout_json" JSONB; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 7eb28148a..1ff81cdff 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -71,6 +71,15 @@ model User { // Per-user dashboard layout — widget visibility + ordering. // Shape: { "widgets": [{ "id": "weight", "visible": true, "order": 0 }, ...], "version": 1 } dashboardWidgetsJson Json? @map("dashboard_widgets_json") + // Per-user insights tile layout — tile visibility + ordering for the + // `/insights` surface. Mirrors `dashboardWidgetsJson` so the iOS + // client and the web Settings UI can persist insights tile order + + // visibility through the same shape. Null = default layout (see + // `src/lib/insights-layout.ts:DEFAULT_INSIGHTS_LAYOUT`). The GET + // endpoint never lazy-writes — the column only carries data once + // the user has explicitly saved a layout. + // Shape: { "tiles": [{ "id": "blutdruck", "visible": true, "order": 0 }, ...], "version": 1 } + insightsLayoutJson Json? @map("insights_layout_json") // Blood-glucose display unit preference. Canonical storage is mg/dL. glucoseUnit String? @map("glucose_unit") // "mg/dL" | "mmol/L" | null = default "mg/dL" // AI provider selection for insights. Null = OPENAI fallback. diff --git a/src/app/api/insights/__tests__/coach-route-gate-inventory.test.ts b/src/app/api/insights/__tests__/coach-route-gate-inventory.test.ts index 1632559c4..669e476c7 100644 --- a/src/app/api/insights/__tests__/coach-route-gate-inventory.test.ts +++ b/src/app/api/insights/__tests__/coach-route-gate-inventory.test.ts @@ -63,6 +63,12 @@ const NOT_COACH_OWNED_ROUTES: ReadonlyArray = [ // ability to dispose of stale recommendations. "src/app/api/insights/feedback/route.ts", "src/app/api/insights/glp1-timeline/route.ts", + // v1.5.5 — per-user tile layout for the `/insights` surface. The + // endpoint persists tile visibility + ordering only; it carries no + // assistant prose and is the mirror of `/api/dashboard/widgets`, + // which also sits outside the Coach gate. Disabling Coach must not + // wedge the user's ability to reorder the insights tile strip. + "src/app/api/insights/layout/route.ts", "src/app/api/insights/targets/route.ts", ]; diff --git a/src/app/api/insights/layout/__tests__/route.test.ts b/src/app/api/insights/layout/__tests__/route.test.ts new file mode 100644 index 000000000..5fc162a6f --- /dev/null +++ b/src/app/api/insights/layout/__tests__/route.test.ts @@ -0,0 +1,361 @@ +/** + * v1.5.5 — `/api/insights/layout` route. + * + * Mirrors the dashboard-widgets test deck so the two surfaces share + * the same contract guarantees: multi-issue 422 envelopes, redacted + * wide-event payload diagnostics, audit-row dedup, GET returns the + * default layout for users who have not saved one, PUT round-trips + * through the resolver, DELETE returns the canonical default. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/db", () => ({ + prisma: { + user: { + findUnique: vi.fn(), + update: vi.fn(), + }, + auditLog: { + create: vi.fn(), + }, + }, + toJson: (v: unknown) => v, +})); + +vi.mock("@/lib/auth/session", () => ({ + getSession: vi.fn(), +})); + +vi.mock("@/lib/logging/transports", () => ({ + emitIfSampled: vi.fn(), +})); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/logging/context", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + annotate: vi.fn(), + }; +}); + +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { GET, PUT, DELETE } from "../route"; +import { prisma } from "@/lib/db"; +import { getSession } from "@/lib/auth/session"; +import { annotate } from "@/lib/logging/context"; +import { __resetAllCachesForTests } from "@/lib/cache/server-cache"; +import { __resetAuditDedupMemoForTests } from "@/lib/audit-dedup"; +import { DEFAULT_INSIGHTS_LAYOUT } from "@/lib/insights-layout"; + +const SESSION_OK = { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { + id: "user-1", + username: "tester", + role: "USER" as const, + displayName: null, + }, +}; + +const callGet = GET as unknown as () => Promise; +const callPut = PUT as unknown as (req: NextRequest) => Promise; +const callDelete = DELETE as unknown as () => Promise; + +function makeReq(body: unknown): NextRequest { + return new NextRequest("http://localhost/api/insights/layout", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +beforeEach(() => { + vi.resetAllMocks(); + __resetAllCachesForTests(); + __resetAuditDedupMemoForTests(); + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never); +}); + +describe("GET /api/insights/layout", () => { + it("returns the default layout when the user has no saved row", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + insightsLayoutJson: null, + } as never); + + const res = await callGet(); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: typeof DEFAULT_INSIGHTS_LAYOUT; + }; + expect(body.data.version).toBe(1); + expect(body.data.tiles.length).toBe(DEFAULT_INSIGHTS_LAYOUT.tiles.length); + expect(body.data.tiles[0]?.id).toBe(DEFAULT_INSIGHTS_LAYOUT.tiles[0]?.id); + }); + + it("does not lazy-write a row on GET", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + insightsLayoutJson: null, + } as never); + + await callGet(); + expect(prisma.user.update).not.toHaveBeenCalled(); + }); + + it("returns the saved layout when one exists, merged through the resolver", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + insightsLayoutJson: { + version: 1, + tiles: [ + { id: "overview", visible: false, order: 0 }, + { id: "blutdruck", visible: true, order: 1 }, + ], + }, + } as never); + + const res = await callGet(); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: typeof DEFAULT_INSIGHTS_LAYOUT; + }; + // Saved tiles surface in saved order; the resolver appends every + // missing tile invisibly so a new release introducing a tile id + // doesn't break a returning user's GET. + expect(body.data.tiles[0]).toEqual({ + id: "overview", + visible: false, + order: 0, + }); + expect(body.data.tiles[1]).toEqual({ + id: "blutdruck", + visible: true, + order: 1, + }); + expect(body.data.tiles.length).toBe(DEFAULT_INSIGHTS_LAYOUT.tiles.length); + }); +}); + +describe("PUT /api/insights/layout — happy path", () => { + it("persists a normalised layout and returns it verbatim", async () => { + vi.mocked(prisma.user.update).mockResolvedValue({} as never); + + const payload = { + version: 1, + tiles: [ + { id: "overview", visible: true, order: 0 }, + { id: "blutdruck", visible: true, order: 1 }, + { id: "puls", visible: false, order: 2 }, + ], + }; + const res = await callPut(makeReq(payload)); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { tiles: Array<{ id: string; visible: boolean; order: number }> }; + }; + expect(body.data.tiles[0]?.id).toBe("overview"); + expect(body.data.tiles[0]?.order).toBe(0); + expect(body.data.tiles[2]?.id).toBe("puls"); + expect(body.data.tiles[2]?.visible).toBe(false); + + expect(prisma.user.update).toHaveBeenCalledTimes(1); + const updateCall = vi.mocked(prisma.user.update).mock.calls[0]?.[0] as { + where: { id: string }; + data: { insightsLayoutJson: unknown }; + }; + expect(updateCall.where.id).toBe("user-1"); + expect(updateCall.data.insightsLayoutJson).toBeTruthy(); + }); +}); + +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: [] })); + expect(res.status).toBe(422); + + const body = (await res.json()) as { + data: null; + error: string; + details: { + issues: Array<{ path: string; code: string; message: string }>; + }; + }; + expect(body.data).toBeNull(); + expect(body.error).toBe("Validation failed"); + expect(body.details.issues.length).toBe(2); + const paths = body.details.issues.map((i) => i.path).sort(); + expect(paths).toEqual(["tiles", "version"]); + + for (const issue of body.details.issues) { + expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]); + } + }); + + it("rejects an unknown tile id", async () => { + const res = await callPut( + makeReq({ + version: 1, + tiles: [{ id: "not-a-real-tile", 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("tiles")), + ).toBe(true); + }); + + it("writes one audit-ledger row keyed insights.layout.validation-failed", async () => { + const res = await callPut(makeReq({ version: 2, tiles: [] })); + expect(res.status).toBe(422); + + await new Promise((r) => setTimeout(r, 5)); + + expect(prisma.auditLog.create).toHaveBeenCalledTimes(1); + const call = vi.mocked(prisma.auditLog.create).mock.calls[0]?.[0] as { + data: { userId: string; action: string; details: string }; + }; + expect(call.data.userId).toBe("user-1"); + expect(call.data.action).toBe("insights.layout.validation-failed"); + const details = JSON.parse(call.data.details) as { + issues: Array<{ path: string; code: string }>; + }; + expect(details.issues.length).toBe(2); + for (const issue of details.issues) { + // The persisted row strips `message` so a future Zod code that + // echoes the offending value cannot leak into the audit + // surface. + expect(Object.keys(issue).sort()).toEqual(["code", "path"]); + } + }); + + it("dedups the audit-ledger write across two sequential 422s for the same user", async () => { + const res1 = await callPut(makeReq({ version: 2, tiles: [] })); + expect(res1.status).toBe(422); + const res2 = await callPut(makeReq({ version: 2, tiles: [] })); + expect(res2.status).toBe(422); + + await new Promise((r) => setTimeout(r, 5)); + + expect(prisma.auditLog.create).toHaveBeenCalledTimes(1); + + // The full multi-issue envelope still rides on every 422 — the + // dedup only suppresses the breadcrumb write, never the response. + const body2 = (await res2.json()) as { + details: { issues: Array }; + }; + expect(body2.details.issues.length).toBe(2); + }); + + it("surfaces received_keys + received_shape_excerpt + zod_issues in the wide-event meta", async () => { + const payload = { version: 2, tiles: [], extraGarbage: "from-ios" }; + const res = await callPut(makeReq(payload)); + expect(res.status).toBe(422); + + const annotated = vi.mocked(annotate).mock.calls.find( + (call) => + (call[0] as { action?: { name?: string } })?.action?.name === + "insights.layout.validation-failed", + ); + expect(annotated, "validation-failed annotate call").toBeTruthy(); + const meta = (annotated![0] as { meta?: Record }).meta!; + + expect(meta.received_keys).toEqual( + expect.arrayContaining(["version", "tiles", "extraGarbage"]), + ); + + expect(typeof meta.received_shape_excerpt).toBe("string"); + expect((meta.received_shape_excerpt as string).length).toBeLessThanOrEqual( + 256, + ); + + const issues = meta.zod_issues as Array<{ path: string; code: string }>; + expect(Array.isArray(issues)).toBe(true); + expect(issues.length).toBeGreaterThanOrEqual(2); + for (const issue of issues) { + expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]); + } + }); + + it("redacts sensitive keys before writing the wide-event received_shape_excerpt", async () => { + const payload = { + version: 2, + tiles: [], + apnsToken: "ff".repeat(32), + authorization: "Bearer leaked", + payload: { apiKey: "sk_live_xxx" }, + }; + const res = await callPut(makeReq(payload)); + expect(res.status).toBe(422); + + const annotated = vi.mocked(annotate).mock.calls.find( + (call) => + (call[0] as { action?: { name?: string } })?.action?.name === + "insights.layout.validation-failed", + ); + const meta = (annotated![0] as { meta?: Record }).meta!; + const excerpt = meta.received_shape_excerpt as string; + + expect(excerpt).not.toContain("ff".repeat(32)); + expect(excerpt).not.toContain("Bearer leaked"); + expect(excerpt).not.toContain("sk_live_xxx"); + expect(excerpt).toContain("[redacted]"); + expect(meta.received_keys).toEqual( + expect.arrayContaining([ + "version", + "tiles", + "apnsToken", + "authorization", + "payload", + ]), + ); + }); + + it("does not block the 422 response when the audit-row write rejects", async () => { + vi.mocked(prisma.auditLog.create).mockRejectedValueOnce( + new Error("db down"), + ); + + const res = await callPut(makeReq({ version: 2, tiles: [] })); + expect(res.status).toBe(422); + const body = (await res.json()) as { + details: { issues: Array }; + }; + expect(body.details.issues.length).toBe(2); + }); +}); + +describe("DELETE /api/insights/layout", () => { + it("clears the saved row and returns the default layout", async () => { + vi.mocked(prisma.user.update).mockResolvedValue({} as never); + + const res = await callDelete(); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: typeof DEFAULT_INSIGHTS_LAYOUT; + }; + expect(body.data.tiles.length).toBe(DEFAULT_INSIGHTS_LAYOUT.tiles.length); + + expect(prisma.user.update).toHaveBeenCalledTimes(1); + const call = vi.mocked(prisma.user.update).mock.calls[0]?.[0] as { + data: { insightsLayoutJson: unknown }; + }; + // The Prisma JsonNull sentinel writes NULL into the column. + expect(call.data.insightsLayoutJson).toBeDefined(); + }); +}); diff --git a/src/app/api/insights/layout/route.ts b/src/app/api/insights/layout/route.ts new file mode 100644 index 000000000..20b0fb663 --- /dev/null +++ b/src/app/api/insights/layout/route.ts @@ -0,0 +1,168 @@ +/** + * GET / PUT / DELETE insights tile layout. + * + * GET returns the resolved effective layout (defaults merged in if the + * user hasn't customised yet). PUT replaces the layout atomically. + * DELETE resets to default. + * + * Mirrors the shape and semantics of `/api/dashboard/widgets` so the + * iOS client + the Settings UI can persist insights tile order + + * visibility through the same contract. The two surfaces stay separate + * columns on `User` (`dashboard_widgets_json` / `insights_layout_json`) + * to keep each resolver's known-id set authoritative — overloading a + * single blob with a `surface` discriminator would force every read to + * demux first. + */ +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { + apiSuccess, + buildPayloadDiagnostic, + safeJson, + returnAllZodIssues, + sanitiseZodIssues, +} from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { prisma, toJson } from "@/lib/db"; +import { + resolveInsightsLayout, + serializeInsightsLayout, + DEFAULT_INSIGHTS_LAYOUT, + INSIGHTS_TILE_IDS, + type InsightsLayout, +} from "@/lib/insights-layout"; +import { Prisma } from "@/generated/prisma/client"; +import { z } from "zod/v4"; +import { invalidateUserInsightsLayout } from "@/lib/cache/invalidate"; +import { cached, caches, type ServerCache } from "@/lib/cache/server-cache"; +import { redactSensitiveFields } from "@/lib/observability/redact-payload"; +import { shouldEmitAuditRow } from "@/lib/audit-dedup"; +import type { NextRequest } from "next/server"; + +// Derive the enum from `INSIGHTS_TILE_IDS` so the schema + the default +// layout cannot drift — the same root-cause class that produced the +// v1.4.16 A5 silent-422 on dashboard widgets when `achievements` was +// added to the layout but not to the validation enum. +const tileIdEnum = z.enum(INSIGHTS_TILE_IDS); + +const layoutSchema = z.object({ + version: z.literal(1), + tiles: z + .array( + z.object({ + id: tileIdEnum, + visible: z.boolean(), + order: z.number().int().min(0).max(99), + }), + ) + .min(1) + .max(50), +}); + +async function buildInsightsLayout(userId: string): Promise { + const row = await prisma.user.findUnique({ + where: { id: userId }, + select: { insightsLayoutJson: true }, + }); + return resolveInsightsLayout(row?.insightsLayoutJson); +} + +export const GET = apiHandler(async () => { + const { user } = await requireAuth(); + + // 5-minute TTL matches the dashboard-widgets cache; the layout + // changes only when the user hits the Settings save button, which + // invalidates this cache via `invalidateUserInsightsLayout()`. + const layout = await cached( + caches.insightsLayout as ServerCache, + user.id, + () => buildInsightsLayout(user.id), + annotate, + ); + return apiSuccess(layout); +}); + +export const PUT = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + + const { data: body, error: jsonError } = await safeJson(request); + if (jsonError) return jsonError; + + const parsed = layoutSchema.safeParse(body); + if (!parsed.success) { + // Multi-issue 422 envelope matches the dashboard-widgets route so + // iOS contract debugging surfaces every wrong field in one + // round-trip; the wide-event line carries the redacted payload + // shape for operator debugging without leaking the raw body. + const issues = sanitiseZodIssues(parsed.error.issues); + const payloadDiagnostic = buildPayloadDiagnostic(redactSensitiveFields(body)); + annotate({ + action: { name: "insights.layout.validation-failed" }, + meta: { + issue_count: issues.length, + ...payloadDiagnostic, + zod_issues: issues, + }, + }); + if ( + shouldEmitAuditRow( + user.id, + "insights.layout.validation-failed", + Date.now(), + ) + ) { + // Best-effort breadcrumb — never block the 422 on a write miss. + // `message` strips from the audit row so a future Zod code that + // embeds the offending value cannot leak through the audit + // surface (the wide-event excerpt above already carries the + // shape signal for debugging). + const auditIssues = sanitiseZodIssues(parsed.error.issues, { + stripValuesFromMessage: true, + }); + prisma.auditLog + .create({ + data: { + userId: user.id, + action: "insights.layout.validation-failed", + details: JSON.stringify({ issues: auditIssues }), + }, + }) + .catch(() => { + /* swallow — validation response is the contract, audit row is best-effort */ + }); + } + return returnAllZodIssues(parsed.error, 422); + } + + const normalized = serializeInsightsLayout(parsed.data); + + await prisma.user.update({ + where: { id: user.id }, + data: { + insightsLayoutJson: toJson(normalized), + }, + }); + + annotate({ + action: { name: "insights.layout.update" }, + meta: { visible_count: normalized.tiles.filter((t) => t.visible).length }, + }); + + invalidateUserInsightsLayout(user.id); + + return apiSuccess(normalized); +}); + +export const DELETE = apiHandler(async () => { + const { user } = await requireAuth(); + + await prisma.user.update({ + where: { id: user.id }, + data: { insightsLayoutJson: Prisma.JsonNull }, + }); + + annotate({ action: { name: "insights.layout.reset" } }); + + invalidateUserInsightsLayout(user.id); + + return apiSuccess(DEFAULT_INSIGHTS_LAYOUT); +}); diff --git a/src/lib/cache/invalidate.ts b/src/lib/cache/invalidate.ts index d1306c8d4..77dc75b65 100644 --- a/src/lib/cache/invalidate.ts +++ b/src/lib/cache/invalidate.ts @@ -84,6 +84,16 @@ export function invalidateUserDashboardWidgets(userId: string): void { caches.dashboardWidgets.deleteByPrefix(userId); } +/** + * Invalidate the insights tile-layout cache for a single user. Called + * from the insights-layout PUT / DELETE endpoints when the user + * reorders / disables a tile so the next `/insights` mount paints the + * new layout. + */ +export function invalidateUserInsightsLayout(userId: string): void { + caches.insightsLayout.deleteByPrefix(userId); +} + /** * Global eviction of the bug-report-status cache. * diff --git a/src/lib/cache/server-cache.ts b/src/lib/cache/server-cache.ts index 7ed377326..0ba8de25b 100644 --- a/src/lib/cache/server-cache.ts +++ b/src/lib/cache/server-cache.ts @@ -260,6 +260,18 @@ export const caches = { maxEntries: 1000, ttlMs: 60_000, }), + /** + * v1.5.5 — per-user insights tile layout cache. Mirrors + * `dashboardWidgets` (same 5-minute TTL, same per-user bucket size). + * Read on every `/insights` mount; the layout only changes on a + * Settings save, which invalidates this cache via + * `invalidateUserInsightsLayout()`. + */ + insightsLayout: new ServerCache({ + name: "insightsLayout", + maxEntries: 500, + ttlMs: 300_000, + }), } as const; /** diff --git a/src/lib/insights-layout.ts b/src/lib/insights-layout.ts new file mode 100644 index 000000000..418765dcf --- /dev/null +++ b/src/lib/insights-layout.ts @@ -0,0 +1,145 @@ +/** + * Insights tile layout — persisted in `User.insightsLayoutJson`. + * + * Single source of truth for which insights pills show on `/insights`, + * what order they render in, and what the default layout is for new + * users. Null / missing column = default layout below; the GET endpoint + * never lazy-writes a row, so the column only carries data once the + * user has explicitly saved a layout. + * + * Mirrors `dashboard-layout.ts` for the dashboard widget surface — same + * resolver / serializer semantics, same auto-merge of new tile IDs + * introduced in later releases. Keeping the two modules structurally + * identical means the iOS client + the Settings UI can share rendering + * code paths against either surface. + */ + +/** + * Every insights tile the layout knows about. Each entry maps to a + * routed sub-page under `/insights/` (the mother-page overview + * is keyed `"overview"`). The order of entries here is irrelevant — + * `DEFAULT_INSIGHTS_LAYOUT.tiles[].order` drives the visual order. + * Exported so the API Zod `tileIdEnum` can be built from a single + * source of truth. + */ +export const INSIGHTS_TILE_IDS = [ + "overview", + // ── vitals ── + "blutdruck", + "puls", + "sauerstoff", + "koerpertemperatur", + // ── body composition ── + "gewicht", + "bmi", + // ── activity ── + "aktive-energie", + "workouts", + // ── sleep ── + "schlaf", + // ── cardiovascular ── + "ruhepuls", + "hrv", + // ── mood ── + "stimmung", + // ── events ── + "medikamente", +] as const; + +export type InsightsTileId = (typeof INSIGHTS_TILE_IDS)[number]; + +export interface InsightsTileConfig { + id: InsightsTileId; + visible: boolean; + order: number; +} + +export interface InsightsLayout { + version: number; + tiles: InsightsTileConfig[]; +} + +const INSIGHTS_LAYOUT_VERSION = 1; + +/** + * Default layout mirrors the routed tab-strip order — vitals first, + * then body composition, activity, sleep, cardiovascular, mood, then + * events. Core surfaces are default-visible; the long-tail HealthKit + * pills (oxygen saturation, body temperature, active energy, sleep, + * resting HR, HRV) stay default-invisible and the user opts in once + * the matching data starts to flow. Adding a new tile lands as an + * additional `{ id, visible: false, order: N }` row here and the + * resolver auto-merges it onto existing users' saved layouts. + */ +export const DEFAULT_INSIGHTS_LAYOUT: InsightsLayout = { + version: INSIGHTS_LAYOUT_VERSION, + tiles: [ + { id: "overview", visible: true, order: 0 }, + { id: "blutdruck", visible: true, order: 1 }, + { id: "puls", visible: true, order: 2 }, + { id: "sauerstoff", visible: false, order: 3 }, + { id: "koerpertemperatur", visible: false, order: 4 }, + { id: "gewicht", visible: true, order: 5 }, + { id: "bmi", visible: true, order: 6 }, + { id: "aktive-energie", visible: false, order: 7 }, + { id: "workouts", visible: true, order: 8 }, + { id: "schlaf", visible: false, order: 9 }, + { id: "ruhepuls", visible: false, order: 10 }, + { id: "hrv", visible: false, order: 11 }, + { id: "stimmung", visible: true, order: 12 }, + { id: "medikamente", visible: true, order: 13 }, + ], +}; + +export function resolveInsightsLayout(raw: unknown): InsightsLayout { + if (!raw || typeof raw !== "object") return DEFAULT_INSIGHTS_LAYOUT; + const candidate = raw as Partial; + if ( + typeof candidate.version !== "number" || + !Array.isArray(candidate.tiles) + ) { + return DEFAULT_INSIGHTS_LAYOUT; + } + + // Drop tile ids the current build does not know about so a future + // retirement (mirroring the dashboard's v1.4.28 `glp1` drop) doesn't + // wedge the PUT round-trip on the next save. Filtering on read keeps + // the GET shape current-build-safe and lets the Settings UI re-PUT + // a clean array. + const knownIds = new Set(INSIGHTS_TILE_IDS); + const filtered = candidate.tiles.filter((t) => knownIds.has(t.id)); + + // Merge with defaults so new tiles introduced in later versions show + // up automatically (invisible by default, the user opts in). + const savedIds = new Set(filtered.map((t) => t.id)); + const missing = DEFAULT_INSIGHTS_LAYOUT.tiles.filter( + (t) => !savedIds.has(t.id), + ); + const maxOrder = Math.max(0, ...filtered.map((t) => t.order)); + const appended = missing.map((t, i) => ({ + ...t, + visible: false, // default-invisible on auto-upgrade + order: maxOrder + 1 + i, + })); + + return { + version: INSIGHTS_LAYOUT_VERSION, + tiles: [...filtered, ...appended].sort((a, b) => a.order - b.order), + }; +} + +export function serializeInsightsLayout( + layout: InsightsLayout, +): InsightsLayout { + return { + version: INSIGHTS_LAYOUT_VERSION, + tiles: layout.tiles + .slice() + .sort((a, b) => a.order - b.order) + .map((t, i) => ({ + id: t.id, + visible: t.visible, + order: i, // normalize to 0-based dense order + })), + }; +} diff --git a/src/lib/query-keys.ts b/src/lib/query-keys.ts index d838f0455..c667d5133 100644 --- a/src/lib/query-keys.ts +++ b/src/lib/query-keys.ts @@ -262,6 +262,14 @@ export const queryKeys = { */ dashboardWidgets: () => ["user", "dashboardWidgets"] as const, + /** + * v1.5.5 — per-user insights tile layout (mirrors + * `dashboardWidgets`). The Settings UI + the iOS client + the + * `/insights` shell all read this key; the PUT mutation invalidates + * it on save so every consumer paints the new layout in lockstep. + */ + insightsLayout: () => ["user", "insightsLayout"] as const, + /** * v1.4.25 W5e — per-user, per-metric-class source priority. The * Settings → Sources surface reads + writes this key; saving