From 8168e60dfd63b871884ce1d3b41892b453e92f44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 19 Jul 2026 12:19:17 +0200 Subject: [PATCH] fix(dashboard): stop the widgets PUT discarding a comparison baseline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The layout PUT preserved chartOverlayPrefs, selectedScoreRings and heroRingOrder when a client omitted them, but the top-level comparisonBaseline was not in that merge. serializeDashboardLayout clamps a missing baseline to "none", so a layout save from a client that does not know the field — the native client treats it as web-only and never sends it — silently reset the comparison the user had picked on the web. No error, no audit row. Replace the hand-maintained three-field merge with an explicit disposition map over every top-level layout field. The map is declared `satisfies Record`, so adding a layout field without deciding replace-or-preserve fails typecheck; the preserve set and the merge are derived from it rather than restated. The stored-layout read is skipped when the client sent the full set. Also catalogue the four clinical signals the native client pins — grip strength, pain NRS, waist circumference, waist-to-height. They reached the route as unknown ids, were filtered out ahead of Zod, and vanished from the persisted layout, so a tile placement was lost on every save. They join the iOS-only set because the web dashboard has no render path for them; their charts live on the insights sub-pages. Raise the widget array cap to 50 to clear the now-42-id catalogue. --- .../dashboard/widgets/__tests__/route.test.ts | 119 +++++++++++ src/app/api/dashboard/widgets/route.ts | 68 +++---- src/lib/__tests__/dashboard-layout.test.ts | 185 +++++++++++++++++- src/lib/dashboard-layout.ts | 101 ++++++++++ 4 files changed, 432 insertions(+), 41 deletions(-) diff --git a/src/app/api/dashboard/widgets/__tests__/route.test.ts b/src/app/api/dashboard/widgets/__tests__/route.test.ts index 53e9bc085..5611f3fc3 100644 --- a/src/app/api/dashboard/widgets/__tests__/route.test.ts +++ b/src/app/api/dashboard/widgets/__tests__/route.test.ts @@ -517,3 +517,122 @@ describe("dashboard widgets — 27-id catalogue round-trip (v1.7.0 W1)", () => { ); }); }); + +describe("dashboard widgets — preserve-when-absent on PUT", () => { + it("keeps the stored comparisonBaseline when the client omits it", async () => { + // The regression: a layout save from a client that doesn't know + // `comparisonBaseline` (the native client documents the field as + // web-only and never sends it) used to fall through to the serializer, + // which clamps a missing baseline to "none". The user's web-chosen + // comparison silently reset on every tile reorder from the phone. + const stored: DashboardLayout = serializeDashboardLayout({ + version: 1, + widgets: [{ id: "weight", visible: true, tileVisible: true, order: 0 }], + comparisonBaseline: "lastYear", + }); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + dashboardWidgetsJson: stored, + } as never); + vi.mocked(prisma.user.update).mockResolvedValue({} as never); + + const res = await callPut( + makeReq({ + version: 1, + widgets: [ + { id: "weight", visible: false, tileVisible: true, order: 0 }, + ], + }), + ); + expect(res.status).toBe(200); + + const updateArg = vi.mocked(prisma.user.update).mock + .calls[0]?.[0] as unknown as { + data: { dashboardWidgetsJson: { comparisonBaseline: string } }; + }; + expect(updateArg.data.dashboardWidgetsJson.comparisonBaseline).toBe( + "lastYear", + ); + + const body = (await res.json()) as { + data: { comparisonBaseline: string }; + }; + expect(body.data.comparisonBaseline).toBe("lastYear"); + }); + + it("honours an explicitly sent comparisonBaseline over the stored one", async () => { + // Preserve-when-absent must not become preserve-always: the web + // CompareToggle sends the field, including an explicit "none" to clear. + const stored: DashboardLayout = serializeDashboardLayout({ + version: 1, + widgets: [{ id: "weight", visible: true, tileVisible: true, order: 0 }], + comparisonBaseline: "lastYear", + }); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + dashboardWidgetsJson: stored, + } as never); + vi.mocked(prisma.user.update).mockResolvedValue({} as never); + + const res = await callPut( + makeReq({ + version: 1, + widgets: [{ id: "weight", visible: true, tileVisible: true, order: 0 }], + comparisonBaseline: "none", + }), + ); + expect(res.status).toBe(200); + + const updateArg = vi.mocked(prisma.user.update).mock + .calls[0]?.[0] as unknown as { + data: { dashboardWidgetsJson: { comparisonBaseline: string } }; + }; + expect(updateArg.data.dashboardWidgetsJson.comparisonBaseline).toBe("none"); + }); + + it("persists the four clinical tiles the native client pins", async () => { + // They used to hit the unknown-id filter ahead of Zod and vanish from + // the persisted layout, so the placement was lost on every save. + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + dashboardWidgetsJson: null, + } as never); + vi.mocked(prisma.user.update).mockResolvedValue({} as never); + + const clinicalIds = [ + "gripStrength", + "painNRS", + "waistCircumference", + "waistToHeight", + ]; + const res = await callPut( + makeReq({ + version: 1, + widgets: clinicalIds.map((id, i) => ({ + id, + visible: true, + tileVisible: true, + order: i, + })), + }), + ); + expect(res.status).toBe(200); + + const dropAnnotate = vi + .mocked(annotate) + .mock.calls.find( + (c) => + (c[0] as { action?: { name?: string } }).action?.name === + "dashboard.widgets.unknown-id-dropped", + ); + expect(dropAnnotate).toBeUndefined(); + + const updateArg = vi.mocked(prisma.user.update).mock + .calls[0]?.[0] as unknown as { + data: { dashboardWidgetsJson: { widgets: Array<{ id: string }> } }; + }; + const persistedIds = updateArg.data.dashboardWidgetsJson.widgets.map( + (w) => w.id, + ); + for (const id of clinicalIds) { + expect(persistedIds).toContain(id); + } + }); +}); diff --git a/src/app/api/dashboard/widgets/route.ts b/src/app/api/dashboard/widgets/route.ts index 3522e63b6..71b4df844 100644 --- a/src/app/api/dashboard/widgets/route.ts +++ b/src/app/api/dashboard/widgets/route.ts @@ -26,10 +26,9 @@ import { MAX_SELECTED_SCORE_RINGS, HERO_RING_IDS, MAX_HERO_RING_ORDER, - type ChartOverlayPrefsMap, + layoutNeedsPreserveRead, + mergePreservedLayoutFields, type DashboardLayout, - type ScoreRingId, - type HeroRingId, } from "@/lib/dashboard-layout"; import { Prisma } from "@/generated/prisma/client"; import { z } from "zod/v4"; @@ -80,7 +79,12 @@ const layoutSchema = z.object({ // 11 iOS-only = 35) fits with headroom after the v1.10 additive // HealthKit signals became pinnable; the enum still bounds each id to // one of the catalogue ids. - .max(40), + // + // Raised again to 50 when the four clinical signals joined the + // catalogue (42 ids). The cap only has to clear the catalogue size — + // the enum bounds each entry and the id set is closed — so it carries + // headroom rather than tracking the count exactly. + .max(50), // v1.4.16 phase B8 — comparison baseline (Vormonat / Vorjahr) rides // on the layout blob per research §7 Q3 (no Prisma migration). Optional // so v1.4.15 clients that don't know the field can still PUT. @@ -274,24 +278,27 @@ export const PUT = apiHandler(async (request: NextRequest) => { return returnAllZodIssues(parsed.error, 422); } - // v1.4.18 — preserve any per-chart overlay prefs that the client - // didn't send. The dashboard-layout PUT typically saves widget - // visibility / order; chart prefs are PUT through their own route - // (`/api/dashboard/chart-overlay-prefs`) and would otherwise be - // wiped here on a subsequent layout save. `selectedScoreRings` and - // `heroRingOrder` ride the same preserve-when-absent contract — an - // older client's layout save must not reset either choice. One - // stored-layout read covers all fallbacks. - let mergedChartOverlayPrefs: ChartOverlayPrefsMap | undefined = parsed.data - .chartOverlayPrefs as ChartOverlayPrefsMap | undefined; - let mergedScoreRings: ScoreRingId[] | undefined = - parsed.data.selectedScoreRings; - let mergedHeroRingOrder: HeroRingId[] | undefined = parsed.data.heroRingOrder; - if ( - mergedChartOverlayPrefs === undefined || - mergedScoreRings === undefined || - mergedHeroRingOrder === undefined - ) { + // Preserve-when-absent: every field the client didn't send is carried + // forward from the stored layout rather than clamped to its default by + // the serializer. The dashboard-layout PUT typically saves widget + // visibility / order, while chart prefs are PUT through their own route + // (`/api/dashboard/chart-overlay-prefs`), the hero rings come from + // Settings, and `comparisonBaseline` is web-only — an omission from any + // one surface must not reset a choice made on another. + // + // The preserve set is not a list maintained here: it is derived from + // `LAYOUT_FIELD_MERGE_DISPOSITION`, which the type system forces to cover + // every field of `DashboardLayout`. Adding a layout field without a + // disposition fails typecheck, so the next field cannot silently miss this + // merge the way `comparisonBaseline` did. One stored-layout read covers + // every fallback, and the read is skipped entirely when the client sent + // the full set. + // The Zod shape leaves the per-chart `comparisonBaseline` optional while + // `ChartOverlayPrefs` requires it; `coerceChartOverlayPrefsMap` inside the + // serializer fills the gap, so the assertion is safe here exactly as it was + // on the previous per-field cast. + let toSerialize = parsed.data as Partial; + if (layoutNeedsPreserveRead(toSerialize)) { const existing = await prisma.user.findUnique({ where: { id: user.id }, select: { dashboardWidgetsJson: true }, @@ -299,22 +306,9 @@ export const PUT = apiHandler(async (request: NextRequest) => { const existingLayout = resolveDashboardLayout( existing?.dashboardWidgetsJson, ); - if (mergedChartOverlayPrefs === undefined) { - mergedChartOverlayPrefs = existingLayout.chartOverlayPrefs ?? {}; - } - if (mergedScoreRings === undefined) { - mergedScoreRings = existingLayout.selectedScoreRings; - } - if (mergedHeroRingOrder === undefined) { - mergedHeroRingOrder = existingLayout.heroRingOrder; - } + toSerialize = mergePreservedLayoutFields(toSerialize, existingLayout); } - const normalized = serializeDashboardLayout({ - ...parsed.data, - chartOverlayPrefs: mergedChartOverlayPrefs, - selectedScoreRings: mergedScoreRings, - heroRingOrder: mergedHeroRingOrder, - } as DashboardLayout); + const normalized = serializeDashboardLayout(toSerialize as DashboardLayout); await prisma.user.update({ where: { id: user.id }, diff --git a/src/lib/__tests__/dashboard-layout.test.ts b/src/lib/__tests__/dashboard-layout.test.ts index 8624620f6..8379f5c0c 100644 --- a/src/lib/__tests__/dashboard-layout.test.ts +++ b/src/lib/__tests__/dashboard-layout.test.ts @@ -9,6 +9,10 @@ import { IOS_PIN_ONLY_WIDGET_IDS, DEFAULT_HERO_RING_ORDER, resolveHeroRingOrder, + LAYOUT_FIELD_MERGE_DISPOSITION, + PRESERVED_LAYOUT_FIELDS, + layoutNeedsPreserveRead, + mergePreservedLayoutFields, type DashboardLayout, } from "@/lib/dashboard-layout"; @@ -438,7 +442,7 @@ describe("resolveDashboardLayout() — retired heroVisible field", () => { * iOS-only ids extend it without touching the writable PUT enum. */ describe("DASHBOARD_WIDGET_CATALOGUE_IDS — 27-id catalogue", () => { - it("carries exactly 37 distinct ids (28 server-known + 9 iOS-only)", () => { + it("carries exactly 42 distinct ids (29 server-known + 13 iOS-only)", () => { // v1.11.2 B5 — the 8 v1.10 additive metrics became web-writable, so // the server-known set grew 16 → 24 and the catalogue 27 → 35. // v1.18.2 — the Vorsorge summary widget added one server-known id @@ -450,10 +454,42 @@ describe("DASHBOARD_WIDGET_CATALOGUE_IDS — 27-id catalogue", () => { // v1.29 — the nutrients-store-backed fluid-intake strip tile // (`waterIntake`) added one server-known id, so the catalogue grew // 37 → 38. + // The four clinical signals the native client pins (grip strength, pain + // NRS, waist circumference, waist-to-height) joined the iOS-only set + // (9 → 13), so the catalogue grew 38 → 42. Before they were catalogued + // the widgets PUT dropped them as unknown ids and every native layout + // save lost the user's placement of those tiles. expect(DASHBOARD_WIDGET_IDS).toHaveLength(29); - expect(DASHBOARD_IOS_ONLY_WIDGET_IDS).toHaveLength(9); - expect(DASHBOARD_WIDGET_CATALOGUE_IDS).toHaveLength(38); - expect(new Set(DASHBOARD_WIDGET_CATALOGUE_IDS).size).toBe(38); + expect(DASHBOARD_IOS_ONLY_WIDGET_IDS).toHaveLength(13); + expect(DASHBOARD_WIDGET_CATALOGUE_IDS).toHaveLength(42); + expect(new Set(DASHBOARD_WIDGET_CATALOGUE_IDS).size).toBe(42); + }); + + it("catalogues the four clinical signals the native client pins", () => { + // Regression guard: these four reached the widgets PUT as unknown ids, + // were filtered out before Zod, and vanished from the persisted layout + // — a silent loss of the user's tile placement on every save. + for (const id of [ + "gripStrength", + "painNRS", + "waistCircumference", + "waistToHeight", + ]) { + expect(DASHBOARD_WIDGET_CATALOGUE_IDS).toContain(id); + } + }); + + it("keeps the four clinical signals out of the web-writable set", () => { + // The web dashboard has no render path for them — their charts live on + // the /insights sub-pages — so a web Settings toggle would do nothing. + for (const id of [ + "gripStrength", + "painNRS", + "waistCircumference", + "waistToHeight", + ]) { + expect(DASHBOARD_WIDGET_IDS).not.toContain(id); + } }); it("ships the sleep / steps / glucose strip tiles default-on (v1.20.0)", () => { @@ -498,6 +534,9 @@ describe("DASHBOARD_WIDGET_CATALOGUE_IDS — 27-id catalogue", () => { it("carries the locked iOS-only ids verbatim", () => { // v1.28.52 — `hrv` + `respiratoryRate` left this set for the writable // DASHBOARD_WIDGET_IDS (they gained a web strip tile). + // The four clinical signals joined so the widgets PUT stops dropping + // them as unknown ids; `painNRS` is spelled as the native client sends + // it, which differs in case from the `painNrs` chart-overlay slot. expect([...DASHBOARD_IOS_ONLY_WIDGET_IDS]).toEqual([ "restingHeartRate", "walkingSpeed", @@ -508,6 +547,10 @@ describe("DASHBOARD_WIDGET_CATALOGUE_IDS — 27-id catalogue", () => { "walkingDoubleSupport", "audioExposureEnvironment", "audioExposureHeadphone", + "gripStrength", + "painNRS", + "waistCircumference", + "waistToHeight", ]); }); }); @@ -909,3 +952,137 @@ describe("resolveDashboardLayout() / serialize — heroRingOrder", () => { expect(DEFAULT_HERO_RING_ORDER[0]).toBe("HEALTH_SCORE"); }); }); + +/** + * Preserve-when-absent contract for the widgets PUT. + * + * `comparisonBaseline` was a top-level layout field that the merge forgot. + * Because `serializeDashboardLayout` clamps a missing baseline to `"none"`, + * every layout save from a client that doesn't send the field — the native + * client documents it as web-only and never sends it — silently reset the + * comparison baseline the user had picked on the web. No error, no audit row. + * + * The fix is not "remember to add the field to the merge". It is a + * disposition map the type system forces to cover every field of + * `DashboardLayout`, from which the preserve set is derived. These tests pin + * the behaviour that map buys. + */ +describe("layout field merge disposition", () => { + /** + * A layout with EVERY top-level field populated. Typed as + * `Required` so adding a field to the interface without + * adding it here is a type error too — the runtime assertion below then + * proves the disposition map covers it. + */ + const fullyPopulatedLayout: Required = { + version: 1, + widgets: [{ id: "weight", visible: true, tileVisible: true, order: 0 }], + comparisonBaseline: "lastYear", + chartOverlayPrefs: { + weight: { + showTrendIndicator: true, + showTrendArrow: false, + showTargetRange: false, + comparisonBaseline: "none", + }, + }, + selectedScoreRings: ["MED_COMPLIANCE"], + heroRingOrder: ["HEALTH_SCORE", "MED_COMPLIANCE"], + }; + + it("assigns a disposition to every top-level layout field", () => { + // The guard that makes the next field impossible to forget. Adding a + // field to `DashboardLayout` without a disposition fails typecheck at + // the `satisfies Record`; this asserts the + // same coverage at runtime so the failure is loud in the suite too. + expect(Object.keys(LAYOUT_FIELD_MERGE_DISPOSITION).sort()).toEqual( + Object.keys(fullyPopulatedLayout).sort(), + ); + }); + + it("only ever assigns a known disposition", () => { + for (const disposition of Object.values(LAYOUT_FIELD_MERGE_DISPOSITION)) { + expect(["replace", "preserve"]).toContain(disposition); + } + }); + + it("preserves comparisonBaseline alongside the other client-owned fields", () => { + // The regression itself: `comparisonBaseline` must be in the preserve + // set, not merely present in the disposition map. + expect([...PRESERVED_LAYOUT_FIELDS].sort()).toEqual([ + "chartOverlayPrefs", + "comparisonBaseline", + "heroRingOrder", + "selectedScoreRings", + ]); + }); + + it("replaces the fields the request itself owns", () => { + expect(LAYOUT_FIELD_MERGE_DISPOSITION.version).toBe("replace"); + expect(LAYOUT_FIELD_MERGE_DISPOSITION.widgets).toBe("replace"); + }); + + it("skips the stored-layout read when the client sent every preserved field", () => { + expect(layoutNeedsPreserveRead(fullyPopulatedLayout)).toBe(false); + }); + + it("requires the stored-layout read when any preserved field is absent", () => { + for (const field of PRESERVED_LAYOUT_FIELDS) { + const incoming: Partial = { ...fullyPopulatedLayout }; + delete incoming[field]; + expect(layoutNeedsPreserveRead(incoming)).toBe(true); + } + }); + + it("carries an omitted comparisonBaseline forward from the stored layout", () => { + // A widgets-only save, which is exactly what the native client sends. + const incoming: Partial = { + version: 1, + widgets: fullyPopulatedLayout.widgets, + }; + const merged = mergePreservedLayoutFields(incoming, fullyPopulatedLayout); + expect(merged.comparisonBaseline).toBe("lastYear"); + }); + + it("carries every omitted preserved field forward, one field at a time", () => { + for (const field of PRESERVED_LAYOUT_FIELDS) { + const incoming: Partial = { ...fullyPopulatedLayout }; + delete incoming[field]; + const merged = mergePreservedLayoutFields(incoming, fullyPopulatedLayout); + expect(merged[field]).toEqual(fullyPopulatedLayout[field]); + } + }); + + it("never overwrites a preserved field the client did send", () => { + const stored: DashboardLayout = { + ...fullyPopulatedLayout, + comparisonBaseline: "lastMonth", + selectedScoreRings: ["MED_COMPLIANCE"], + }; + const incoming: Partial = { + version: 1, + widgets: fullyPopulatedLayout.widgets, + comparisonBaseline: "none", + chartOverlayPrefs: fullyPopulatedLayout.chartOverlayPrefs, + selectedScoreRings: [], + heroRingOrder: fullyPopulatedLayout.heroRingOrder, + }; + const merged = mergePreservedLayoutFields(incoming, stored); + // An explicit "none" is a real choice — clearing must survive the merge. + expect(merged.comparisonBaseline).toBe("none"); + expect(merged.selectedScoreRings).toEqual([]); + }); + + it("leaves the replace-fields untouched by the merge", () => { + const stored: DashboardLayout = { + ...fullyPopulatedLayout, + widgets: [{ id: "bp", visible: false, tileVisible: false, order: 0 }], + }; + const incoming: Partial = { + version: 1, + widgets: fullyPopulatedLayout.widgets, + }; + const merged = mergePreservedLayoutFields(incoming, stored); + expect(merged.widgets).toEqual(fullyPopulatedLayout.widgets); + }); +}); diff --git a/src/lib/dashboard-layout.ts b/src/lib/dashboard-layout.ts index dcf249db0..56501338b 100644 --- a/src/lib/dashboard-layout.ts +++ b/src/lib/dashboard-layout.ts @@ -139,6 +139,27 @@ export const DASHBOARD_IOS_ONLY_WIDGET_IDS = [ "walkingDoubleSupport", "audioExposureEnvironment", "audioExposureHeadphone", + // The four clinical signals the v1.25 sub-pages already chart + // (`/insights/grip-strength`, `/insights/pain`, `/insights/waist`, + // `/insights/waist-to-height`). The native client offers a dashboard tile + // for each; before they joined the catalogue the widgets PUT dropped them + // through the unknown-id filter, so every native layout save silently lost + // the user's placement of those four tiles. + // + // They live here rather than in `DASHBOARD_WIDGET_IDS` because the web + // dashboard has no render path for them — the chart lives on the insights + // sub-page, not on the Startseite. That keeps them out of the web Settings + // list (built from `DASHBOARD_WIDGET_IDS`) while the PUT enum, the on-read + // resolver, and the snapshot catalogue all accept and retain them. + // + // `painNRS` matches the id the native client sends. The sibling + // chart-overlay slot spells it `painNrs` (see `CHART_OVERLAY_KEYS`); the + // two namespaces are independent and neither is derived from the other, so + // the casing difference is deliberate rather than a typo. + "gripStrength", + "painNRS", + "waistCircumference", + "waistToHeight", ] as const; /** @@ -548,6 +569,86 @@ export interface DashboardLayout { heroRingOrder?: HeroRingId[]; } +/** + * How the widgets PUT treats each top-level layout field when the client + * omits it. + * + * - `"replace"` — the field is the point of the request. A PUT that omits it + * is either rejected by Zod (`version`, `widgets` are required) or means + * the caller genuinely has nothing to say. + * - `"preserve"` — the field is owned by a surface OTHER than the one making + * this request, so an omission means "I don't know about this", never + * "clear it". The route reads the stored layout and carries the existing + * value forward. + * + * The preserve set exists because clients legitimately don't know every + * field. The per-chart overlay popover PUTs through + * `/api/dashboard/chart-overlay-prefs`; the hero rings are a Settings-only + * control; `comparisonBaseline` is web-only and the native client documents + * that it never sends it. Without preserve-when-absent, a tile reorder on one + * surface silently resets a choice made on another — which is exactly what + * happened to `comparisonBaseline` before it joined this map: the serializer + * clamps a missing value to `"none"`, so every native layout save wiped the + * baseline the user had picked on the web. + * + * `satisfies Record` is the load-bearing part. + * Adding a field to `DashboardLayout` without adding it here is a TYPE error, + * so the next field cannot repeat the `comparisonBaseline` mistake by + * omission — somebody has to make the decision explicitly. + */ +export const LAYOUT_FIELD_MERGE_DISPOSITION = { + version: "replace", + widgets: "replace", + comparisonBaseline: "preserve", + chartOverlayPrefs: "preserve", + selectedScoreRings: "preserve", + heroRingOrder: "preserve", +} as const satisfies Record; + +export type LayoutFieldMergeDisposition = typeof LAYOUT_FIELD_MERGE_DISPOSITION; + +/** + * The top-level layout fields the widgets PUT carries forward from the stored + * layout when the request omits them. Derived from + * `LAYOUT_FIELD_MERGE_DISPOSITION` so the two can never drift. + */ +export const PRESERVED_LAYOUT_FIELDS = ( + Object.keys(LAYOUT_FIELD_MERGE_DISPOSITION) as Array +).filter( + (field) => LAYOUT_FIELD_MERGE_DISPOSITION[field] === "preserve", +) as ReadonlyArray; + +/** + * True when the incoming layout omits at least one preserved field, i.e. the + * route has to read the stored layout to merge. Lets the common full-layout + * save from Settings skip the extra query. + */ +export function layoutNeedsPreserveRead( + incoming: Partial, +): boolean { + return PRESERVED_LAYOUT_FIELDS.some((field) => incoming[field] === undefined); +} + +/** + * Carry every omitted preserved field forward from the stored layout. + * Fields the client DID send win untouched; `"replace"` fields are never + * consulted here. One stored-layout read covers all of them. + */ +export function mergePreservedLayoutFields( + incoming: Partial, + existing: DashboardLayout, +): Partial { + const merged: Partial = { ...incoming }; + for (const field of PRESERVED_LAYOUT_FIELDS) { + if (merged[field] === undefined) { + // Each key maps to its own value type; the loop is homogeneous over + // `keyof DashboardLayout`, which TypeScript cannot express per-key. + (merged as Record)[field] = existing[field]; + } + } + return merged; +} + const DASHBOARD_LAYOUT_VERSION = 1; /**