diff --git a/src/app/api/insights/layout/route.ts b/src/app/api/insights/layout/route.ts index cc59124f1..88a242ece 100644 --- a/src/app/api/insights/layout/route.ts +++ b/src/app/api/insights/layout/route.ts @@ -170,7 +170,12 @@ export const PUT = apiHandler(async (request: NextRequest) => { return returnAllZodIssues(parsed.error, 422); } - const normalized = serializeInsightsLayout(parsed.data); + // v1.16.13 — load the user's stored layout so a PUT that omits a + // dimension (iOS reorders tiles with a tiles-only body, no `sections` + // key) preserves the other dimension's customization instead of + // resetting it to defaults. + const previous = await buildInsightsLayout(user.id); + const normalized = serializeInsightsLayout(parsed.data, previous); await prisma.user.update({ where: { id: user.id }, diff --git a/src/lib/__tests__/insights-layout.test.ts b/src/lib/__tests__/insights-layout.test.ts index 8813c96e3..96c67f823 100644 --- a/src/lib/__tests__/insights-layout.test.ts +++ b/src/lib/__tests__/insights-layout.test.ts @@ -209,6 +209,86 @@ describe("serializeInsightsLayout — defaults + dense order", () => { }); }); +describe("serializeInsightsLayout — stored-dimension preservation (v1.16.13)", () => { + it("keeps the stored sections when a PUT omits sections (tiles-only)", () => { + // The user previously customised sections (a hidden + reordered set). + const stored = serializeInsightsLayout({ + version: 2, + sections: [ + { id: "trends", visible: true, order: 0 }, + { id: "vitals", visible: false, order: 1 }, + ], + tiles: [{ id: "overview", visible: true, order: 0 }], + }); + + // An iOS tiles-only reorder PUT — no `sections` key. + const serialized = serializeInsightsLayout( + { + version: 1, + tiles: [ + { id: "weight", visible: true, order: 0 }, + { id: "overview", visible: true, order: 1 }, + ], + }, + stored, + ); + + // Stored sections survive verbatim instead of resetting to defaults. + expect( + serialized.sections.map((s) => ({ + id: s.id, + visible: s.visible, + order: s.order, + })), + ).toEqual([ + { id: "trends", visible: true, order: 0 }, + { id: "vitals", visible: false, order: 1 }, + ]); + // The PUT's tiles still apply. + expect(serialized.tiles.map((t) => t.id)).toEqual(["weight", "overview"]); + }); + + it("keeps the stored tiles when a PUT omits tiles (sections-only)", () => { + const stored = serializeInsightsLayout({ + version: 2, + sections: [{ id: "vitals", visible: true, order: 0 }], + tiles: [ + { id: "weight", visible: true, order: 0 }, + { id: "bloodPressure", visible: false, order: 1 }, + ], + }); + + const serialized = serializeInsightsLayout( + { + version: 2, + sections: [ + { id: "trends", visible: true, order: 0 }, + { id: "vitals", visible: false, order: 1 }, + ], + }, + stored, + ); + + // Stored tiles survive verbatim instead of resetting to defaults. + expect( + serialized.tiles.map((t) => ({ id: t.id, visible: t.visible })), + ).toEqual([ + { id: "weight", visible: true }, + { id: "bloodPressure", visible: false }, + ]); + expect(serialized.sections.map((s) => s.id)).toEqual(["trends", "vitals"]); + }); + + it("falls back to defaults for an absent dimension when no stored layout is supplied", () => { + const serialized = serializeInsightsLayout({ + version: 2, + tiles: [{ id: "overview", visible: true, order: 0 }], + }); + // First-ever tiles-only PUT still persists a complete v2 blob. + expect(serialized.sections.length).toBe(INSIGHTS_SECTION_IDS.length); + }); +}); + describe("orderedVisibleSectionIds — W2 render-order helper", () => { it("returns every section in default order for the default layout", () => { expect(orderedVisibleSectionIds(DEFAULT_INSIGHTS_LAYOUT)).toEqual([ diff --git a/src/lib/insights-layout.ts b/src/lib/insights-layout.ts index 501136496..412af7f9b 100644 --- a/src/lib/insights-layout.ts +++ b/src/lib/insights-layout.ts @@ -300,7 +300,15 @@ export interface InsightsLayoutInput { export function serializeInsightsLayout( layout: InsightsLayoutInput, + previous?: InsightsLayout, ): InsightsLayout { + // v1.16.13 — a PUT that omits a dimension must KEEP the user's stored + // value for it, not reset it to defaults. iOS reorders tiles with a + // tiles-only PUT (no `sections` key); without the stored fallback that + // PUT silently wipes the user's section customization (and vice-versa + // for a section-only PUT against stored tiles). When no stored layout + // is supplied we still fall back to defaults so a first-ever PUT on a + // single dimension persists a complete blob. // v1.8.0 — normalise legacy German tile ids onto canonical English // before persisting so the stored blob is always canonical even when // the PUT body carried the old ids (the Zod enum accepts both via @@ -308,18 +316,21 @@ 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(); - // An omitted `tiles` (a section-only PUT) falls back to the canonical - // default tile set so the persisted blob is always complete. + // An omitted `tiles` (a section-only PUT) keeps the stored tiles when + // available, else 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 })), + sections: serializeInsightsSections(layout.sections, previous), + tiles: (previous?.tiles ?? DEFAULT_INSIGHTS_LAYOUT.tiles).map((t) => ({ + ...t, + })), }; } return { version: INSIGHTS_LAYOUT_VERSION, - sections: serializeInsightsSections(layout.sections), + sections: serializeInsightsSections(layout.sections, previous), tiles: layout.tiles .slice() .sort((a, b) => a.order - b.order) @@ -340,14 +351,19 @@ 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. + * order. If the input omits `sections` entirely, keep the user's stored + * sections when supplied (v1.16.13 — a tiles-only PUT must not wipe the + * section customization), else fill from defaults so a first-ever + * tiles-only PUT still persists a valid v2 blob. */ function serializeInsightsSections( sections: InsightsLayoutInput["sections"], + previous?: InsightsLayout, ): InsightsSectionConfig[] { if (!sections) { - return DEFAULT_INSIGHTS_LAYOUT.sections.map((s) => ({ ...s })); + return (previous?.sections ?? DEFAULT_INSIGHTS_LAYOUT.sections).map((s) => ({ + ...s, + })); } const knownIds = new Set(INSIGHTS_SECTION_IDS);