fix(insights): preserve stored layout dimension on a partial PUT

A PUT that omits sections (an iOS tiles-only reorder) reset the user's
section customization to defaults, and symmetrically a sections-only PUT
reset the stored tiles. serializeInsightsLayout now takes the resolved
stored layout and falls back to it — not to defaults — for whichever
dimension the body omits; the PUT handler loads the stored layout first.
A first-ever single-dimension PUT (no stored layout) still fills
defaults, so the v2 blob stays complete. Pins the merge behaviour with
three unit cases.
This commit is contained in:
Marc-André Bombeck committed 2026-06-14 10:15:47 +02:00
1 parent 7b0688b37e
commit 7fa10b43a0
3 files changed
+110 -9

No files matched your search

+6 -1
View File
@@ -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 },
+80
View File
@@ -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([
+24 -8
View File
@@ -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<string>();
// 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<string>(INSIGHTS_SECTION_IDS);