fix(insights): reconcile customizable-overview review findings

- Layout PUT accepts version 1 as well as 2 (live iOS client still PUTs
  version 1); the serializer normalises to the canonical v2 blob, so a v1
  body never 422s. Mirrored in the OpenAPI schema + description; yaml regen.
- Add the Weight tile to the inline tile editor so it can be hidden /
  reordered like every other Vitals grid tile.
- Replace the non-transitive Vitals tile-drag comparator with a pure,
  total-order rebuild helper (reorder the Vitals subset, substitute it back
  into the full list in original-order slots, densify).
- Gated-off section rows keep a disabled eye toggle in the same position
  with the gate hint as a caption below the title, so the row never jumps.
- Cap the disclosed Vitals tile list height (max-h-[60vh] scroll,
  overscroll-contain) so a tile drag does not fight the page scroll.
- Gate edit mode until the layout GET settles so a save can never flush
  defaults over a real saved layout.
- Drop the duplicate top-right "Done" affordance: hide the customize toggle
  while editing; the in-card Done / Reset own save and reset.
- Restore focus on enter (edit-card heading) and on close (customize toggle).

Tests: route regression for a version-1 PUT persisting a resolved v2 layout;
unit coverage for the new Vitals rebuild helper (order, non-Vitals stability,
per-tile visibility, no mutation).
This commit is contained in:
Marc-André Bombeck committed 2026-06-07 17:08:25 +02:00
1 parent 9b13b41f1a
commit 6ebac22c65
9 files changed
+331 -98

No files matched your search

@@ -1,5 +1,8 @@
import { describe, it, expect } from "vitest";
import { reorderById } from "@/lib/insights-layout-reorder";
import {
reorderById,
rebuildTilesWithReorderedVitals,
} from "@/lib/insights-layout-reorder";
describe("reorderById", () => {
const rows = [
@@ -66,3 +69,69 @@ describe("reorderById", () => {
expect(next.map((r) => r.id)).toEqual(["b", "c", "a"]);
});
});
describe("rebuildTilesWithReorderedVitals", () => {
// v1.15.11 QA M2 — the inline edit mode reorders only the Vitals subset; the
// non-Vitals sub-page strip must keep its relative order. The full list is
// interleaved: overview (non-vitals), then vitals tiles, then a couple of
// sub-page slugs (non-vitals).
const VITALS = new Set(["v1", "v2", "v3"]);
const isVitals = (id: string) => VITALS.has(id);
const tiles = [
{ id: "overview", order: 0, visible: true }, // non-vitals
{ id: "v1", order: 1, visible: true },
{ id: "v2", order: 2, visible: false },
{ id: "sub-a", order: 3, visible: true }, // non-vitals
{ id: "v3", order: 4, visible: true },
{ id: "sub-b", order: 5, visible: false }, // non-vitals
];
it("substitutes the Vitals slots in their new relative order", () => {
// Vitals reordered to v3, v1, v2.
const next = rebuildTilesWithReorderedVitals(
tiles,
["v3", "v1", "v2"],
isVitals,
);
// Vitals positions (originally slots 1, 2, 4) now carry v3, v1, v2.
expect(next.map((t) => t.id)).toEqual([
"overview",
"v3",
"v1",
"sub-a",
"v2",
"sub-b",
]);
// Densely renumbered.
expect(next.map((t) => t.order)).toEqual([0, 1, 2, 3, 4, 5]);
});
it("keeps the non-Vitals tiles in their original relative order", () => {
const next = rebuildTilesWithReorderedVitals(
tiles,
["v3", "v2", "v1"],
isVitals,
);
const nonVitals = next.filter((t) => !isVitals(t.id)).map((t) => t.id);
expect(nonVitals).toEqual(["overview", "sub-a", "sub-b"]);
});
it("carries each substituted tile's own visible flag (not the slot's)", () => {
// v2 is hidden; after moving v2 into the first vitals slot it must still be
// hidden — the flag travels with the tile id, not the position.
const next = rebuildTilesWithReorderedVitals(
tiles,
["v2", "v1", "v3"],
isVitals,
);
const moved = next.find((t) => t.id === "v2");
expect(moved?.visible).toBe(false);
expect(moved?.order).toBe(1);
});
it("does not mutate the input", () => {
const snapshot = JSON.parse(JSON.stringify(tiles));
rebuildTilesWithReorderedVitals(tiles, ["v3", "v1", "v2"], isVitals);
expect(tiles).toEqual(snapshot);
});
});