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) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-28 22:32:40 +02:00
1 parent 19cfd69ff1
commit a75b96b2a9
9 files changed
+763

No files matched your search

@@ -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": "<tile-id>", "visible": <bool>, "order": <int 0..> },
-- …
-- ]
-- }
--
-- 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;
+9
View File
@@ -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.
@@ -63,6 +63,12 @@ const NOT_COACH_OWNED_ROUTES: ReadonlyArray<string> = [
// 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",
];
@@ -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<typeof import("@/lib/logging/context")>();
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<Response>;
const callPut = PUT as unknown as (req: NextRequest) => Promise<Response>;
const callDelete = DELETE as unknown as () => Promise<Response>;
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<unknown> };
};
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<string, unknown> }).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<string, unknown> }).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<unknown> };
};
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();
});
});
+168
View File
@@ -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<InsightsLayout> {
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<InsightsLayout>,
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);
});
+10
View File
@@ -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.
*
+12
View File
@@ -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<unknown>({
name: "insightsLayout",
maxEntries: 500,
ttlMs: 300_000,
}),
} as const;
/**
+145
View File
@@ -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/<slug>` (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<InsightsLayout>;
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<string>(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
})),
};
}
+8
View File
@@ -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