fix(insights): keep a pill save when the overview arrangement saves after it

The arrange card sent the tile list it copied on mount, with a token
read fresh from the cache, so a pill-order save made beside it was
overwritten without a conflict. Send the current server tiles, and let
an untouched section draft follow the server copy so the pill
manager's ECG toggle no longer reads as a pending edit.
This commit is contained in:
Marc-André Bombeck committed 2026-10-08 22:57:47 +02:00
1 parent 32cb75c1aa
commit a5546507fa
3 files changed
+213 -36

No files matched your search

+40 -36
View File
@@ -45,6 +45,12 @@ import {
type InsightsSectionConfig,
type InsightsSectionId,
} from "@/lib/insights-layout";
import {
arrangeSavePayload,
arrangementSignature,
reconcileArrangeDraft,
seedArrangeDraft,
} from "@/lib/insights-arrange-draft";
import { apiDelete, apiPut } from "@/lib/api/api-fetch";
import {
readUpdatedAtToken,
@@ -66,8 +72,9 @@ import {
* Tile-level management (the per-metric detail pages + their nav pills)
* moved to Settings → Insights in v1.15.20 — the pill-order section there
* carries both sorting AND the eye toggles, so the disclosure this card used
* to nest under the Vitals row was a duplicate surface. The card keeps the
* draft's `tiles` untouched and links to the settings section instead.
* to nest under the Vitals row was a duplicate surface. A save sends the
* CURRENT server tiles (never a copy taken at mount), so a pill-order save
* made beside this card survives a later section save.
*/
/** Localized title key per section id — used for the edit-card label. */
@@ -118,16 +125,21 @@ export function InsightsEditMode({
const { t } = useTranslations();
const queryClient = useQueryClient();
// Local draft seeded from the resolved layout. Edits mutate the draft only;
// "Fertig" flushes it via the PUT mutation. Sections/tiles are sorted by
// order for stable rendering.
const [draft, setDraft] = useState<InsightsLayout>(() => ({
version: layout.version,
sections: [...layout.sections].sort((a, b) => a.order - b.order),
// Tiles pass through the save verbatim — pill order + visibility are
// managed on Settings → Insights since v1.15.20.
tiles: [...layout.tiles].sort((a, b) => a.order - b.order),
}));
// Local section draft seeded from the resolved layout. Edits mutate the
// draft only; "Fertig" flushes it via the PUT mutation. When the server copy
// changes underneath (the pill manager beside the Settings card also flips
// the "ecg" section), an untouched draft re-seeds in render — the
// React-sanctioned "adjust state on prop change" pattern — so it never turns
// someone else's save into a pending edit of its own.
const [draftState, setDraftState] = useState(() =>
seedArrangeDraft(layout.sections),
);
const reconciled = reconcileArrangeDraft(draftState, layout.sections);
if (reconciled) setDraftState(reconciled);
const draftSections = (reconciled ?? draftState).sections;
const setDraftSections = (
update: (sections: InsightsSectionConfig[]) => InsightsSectionConfig[],
) => setDraftState((d) => ({ ...d, sections: update(d.sections) }));
// v1.15.11 QA L5 — on mount move focus to the edit-card heading so keyboard /
// screen-reader users land on the surface they just opened (not the top of
@@ -147,14 +159,14 @@ export function InsightsEditMode({
);
const saveMutation = useMutation({
mutationFn: async (next: InsightsLayout) => {
mutationFn: async (sections: InsightsSectionConfig[]) => {
// v1.32.21 (R5a) — echo the optimistic-concurrency base token this edit
// was based on so an interleaved write (a Settings pill-order Save, or
// this surface open in another tab) 409s instead of clobbering.
return apiPut<InsightsLayoutWithToken>(
"/api/insights/layout",
withBaseToken(
{ version: 2, sections: next.sections, tiles: next.tiles },
arrangeSavePayload(sections, layout),
readUpdatedAtToken(queryClient, queryKeys.insightsLayout()),
),
);
@@ -196,11 +208,7 @@ export function InsightsEditMode({
queryKey: queryKeys.insightsLayout(),
});
// Re-seed the draft so the open editor reflects the restored defaults.
setDraft({
version: saved.version,
sections: [...saved.sections].sort((a, b) => a.order - b.order),
tiles: [...saved.tiles].sort((a, b) => a.order - b.order),
});
setDraftState(seedArrangeDraft(saved.sections));
toast.success(t("insights.editMode.resetSuccess"));
},
onError: () => toast.error(t("insights.editMode.saveError")),
@@ -210,38 +218,34 @@ export function InsightsEditMode({
// The Settings card's Save waits for a change, like the pill-order Save
// beside it; the overview's inline editor keeps "Done" live because it
// also closes the editor.
const arrangement = (rows: readonly InsightsSectionConfig[]) =>
[...rows]
.sort((a, b) => a.order - b.order)
.map((r) => `${r.id}:${r.visible ? 1 : 0}`)
.join(",");
const dirty = arrangement(draft.sections) !== arrangement(layout.sections);
const dirty =
arrangementSignature(draftSections) !==
arrangementSignature(layout.sections);
const sections = useMemo(
() => [...draft.sections].sort((a, b) => a.order - b.order),
[draft.sections],
() => [...draftSections].sort((a, b) => a.order - b.order),
[draftSections],
);
const sectionIds = sections.map((s) => s.id);
function toggleSection(id: InsightsSectionId, visible: boolean) {
setDraft((d) => ({
...d,
sections: d.sections.map((s) => (s.id === id ? { ...s, visible } : s)),
}));
setDraftSections((rows) =>
rows.map((s) => (s.id === id ? { ...s, visible } : s)),
);
}
function handleSectionDragEnd(event: DragEndEvent) {
const { active, over } = event;
if (!over || active.id === over.id) return;
const reordered = reorderById<InsightsSectionConfig>(
draft.sections,
draftSections,
String(active.id),
String(over.id),
);
setDraft((d) => ({ ...d, sections: reordered }));
setDraftSections(() => reordered);
}
const allHidden = draft.sections.every((s) => !s.visible);
const allHidden = draftSections.every((s) => !s.visible);
return (
<SettingsCard data-slot="insights-edit-mode">
@@ -282,7 +286,7 @@ export function InsightsEditMode({
/>
<Button
size="sm"
onClick={() => saveMutation.mutate(draft)}
onClick={() => saveMutation.mutate(draftSections)}
disabled={busy}
data-slot="insights-edit-done"
>
@@ -356,7 +360,7 @@ export function InsightsEditMode({
<Button
size="sm"
className="min-h-11 sm:min-h-9"
onClick={() => saveMutation.mutate(draft)}
onClick={() => saveMutation.mutate(draftSections)}
disabled={busy || !dirty}
data-slot="insights-edit-done"
>
@@ -0,0 +1,92 @@
/**
* The overview arrange editor and the pill manager share one layout blob.
* A pill save followed by a section save must keep the pill order, and an
* untouched section draft must follow the server copy (the pill manager also
* flips the "ecg" section) instead of reading as a pending edit.
*/
import { describe, expect, it } from "vitest";
import {
DEFAULT_INSIGHTS_LAYOUT,
type InsightsLayout,
} from "@/lib/insights-layout";
import {
arrangeSavePayload,
arrangementSignature,
reconcileArrangeDraft,
seedArrangeDraft,
} from "@/lib/insights-arrange-draft";
function clone(layout: InsightsLayout): InsightsLayout {
return {
version: layout.version,
sections: layout.sections.map((s) => ({ ...s })),
tiles: layout.tiles.map((t) => ({ ...t })),
};
}
/** The layout after a pill save: tile order reversed, ECG section hidden. */
function afterPillSave(base: InsightsLayout): InsightsLayout {
const next = clone(base);
const n = next.tiles.length;
next.tiles = next.tiles.map((t) => ({ ...t, order: n - 1 - t.order }));
next.sections = next.sections.map((s) =>
s.id === "ecg" ? { ...s, visible: false } : s,
);
return next;
}
describe("insights arrange draft", () => {
it("keeps a pill order saved after the editor mounted", () => {
const mounted = clone(DEFAULT_INSIGHTS_LAYOUT);
const draft = seedArrangeDraft(mounted.sections);
const server = afterPillSave(mounted);
// The user then moves a section and saves.
const reconciled = reconcileArrangeDraft(draft, server.sections) ?? draft;
const moved = reconciled.sections.map((s, i, all) =>
i === 0 ? { ...s, order: all.length } : { ...s, order: s.order - 1 },
);
const body = arrangeSavePayload(moved, server);
expect(body.tiles).toEqual(server.tiles);
expect(body.tiles).not.toEqual(mounted.tiles);
// The ECG flip from the pill manager survives too.
expect(body.sections.find((s) => s.id === "ecg")?.visible).toBe(false);
});
it("re-seeds an untouched draft so a pill-manager ECG flip is not dirty", () => {
const mounted = clone(DEFAULT_INSIGHTS_LAYOUT);
const draft = seedArrangeDraft(mounted.sections);
const server = afterPillSave(mounted);
const next = reconcileArrangeDraft(draft, server.sections);
expect(next).not.toBeNull();
expect(arrangementSignature(next!.sections)).toBe(
arrangementSignature(server.sections),
);
});
it("keeps the user's own edits when the server copy moves", () => {
const mounted = clone(DEFAULT_INSIGHTS_LAYOUT);
const draft = seedArrangeDraft(mounted.sections);
const edited = {
...draft,
sections: draft.sections.map((s) =>
s.id === "trends" ? { ...s, visible: !s.visible } : s,
),
};
const server = afterPillSave(mounted);
const next = reconcileArrangeDraft(edited, server.sections);
expect(next?.sections).toEqual(edited.sections);
expect(next?.seeded).toBe(arrangementSignature(server.sections));
});
it("does nothing while the server copy is unchanged", () => {
const draft = seedArrangeDraft(DEFAULT_INSIGHTS_LAYOUT.sections);
expect(
reconcileArrangeDraft(draft, DEFAULT_INSIGHTS_LAYOUT.sections),
).toBeNull();
});
});
+81
View File
@@ -0,0 +1,81 @@
/**
* Pure draft logic for the Insights overview arrange editor.
*
* The editor owns `sections` (order + visibility) only. The pill manager on
* Settings → Insights writes the same `/api/insights/layout` blob, so a save
* here must carry the CURRENT server tiles rather than a copy taken when the
* editor mounted: the base token is read from the shared cache at save time,
* so a stale tile copy would never 409 and would silently undo a pill save.
*
* For the same reason the section draft follows the server copy whenever the
* user has not touched it — the pill manager also flips the `"ecg"` section's
* visibility, and an untouched draft must not turn that into a pending edit.
*/
import type {
InsightsLayout,
InsightsSectionConfig,
InsightsTileConfig,
} from "@/lib/insights-layout";
export function sortByOrder<T extends { order: number }>(
rows: readonly T[],
): T[] {
return [...rows].sort((a, b) => a.order - b.order);
}
/** Order + visibility fingerprint of a section list. */
export function arrangementSignature(
rows: readonly InsightsSectionConfig[],
): string {
return sortByOrder(rows)
.map((r) => `${r.id}:${r.visible ? 1 : 0}`)
.join(",");
}
export interface ArrangeDraftState {
/** Server signature the draft was last seeded from. */
seeded: string;
sections: InsightsSectionConfig[];
}
export function seedArrangeDraft(
serverSections: readonly InsightsSectionConfig[],
): ArrangeDraftState {
return {
seeded: arrangementSignature(serverSections),
sections: sortByOrder(serverSections),
};
}
/**
* Follow a server-side change to the sections. Returns `null` when nothing
* needs to change. An untouched draft re-seeds; a draft carrying the user's
* own edits is kept, only the baseline advances.
*/
export function reconcileArrangeDraft(
state: ArrangeDraftState,
serverSections: readonly InsightsSectionConfig[],
): ArrangeDraftState | null {
const server = arrangementSignature(serverSections);
if (server === state.seeded) return null;
const untouched = arrangementSignature(state.sections) === state.seeded;
return untouched
? seedArrangeDraft(serverSections)
: { seeded: server, sections: state.sections };
}
/** The PUT body: the draft's sections over the current server tiles. */
export function arrangeSavePayload(
draftSections: readonly InsightsSectionConfig[],
current: Pick<InsightsLayout, "tiles">,
): {
version: 2;
sections: InsightsSectionConfig[];
tiles: InsightsTileConfig[];
} {
return {
version: 2,
sections: [...draftSections],
tiles: [...current.tiles],
};
}