mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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:
1 parent
7b0688b37e
commit
7fa10b43a0
3 files changed
+110
-9
No files matched your search
@@ -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 },
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user