mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
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:
1 parent
32cb75c1aa
commit
a5546507fa
3 files changed
+213
-36
No files matched your search
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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],
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user