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

@@ -253,6 +253,50 @@ describe("PUT /api/insights/layout — happy path", () => {
});
});
describe("PUT /api/insights/layout — accepts version 1 (iOS contract)", () => {
// v1.15.11 QA C1 — the live iOS client still PUTs `version: 1`. The schema
// accepts both 1 and 2; the serializer always normalises to the canonical
// v2 blob, so a v1 body persists a resolved v2 layout rather than 422ing.
it("accepts a PUT with version 1 and no sections, persisting a resolved v2 layout", async () => {
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const res = await callPut(
makeReq({
version: 1,
tiles: [
{ id: "overview", visible: true, order: 0 },
{ id: "blood-pressure", visible: true, order: 1 },
],
}),
);
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: {
version: number;
sections: Array<{ id: string; visible: boolean }>;
tiles: Array<{ id: string }>;
};
};
// Stored + returned blob is canonical v2 regardless of the input version.
expect(body.data.version).toBe(2);
// Sections were absent on input → filled from defaults, all visible.
expect(body.data.sections.length).toBeGreaterThan(0);
for (const s of body.data.sections) expect(s.visible).toBe(true);
expect(body.data.tiles.map((t) => t.id)).toContain("overview");
// The persisted column is the canonical v2 blob too.
const updateCall = vi.mocked(prisma.user.update).mock
.calls[0]?.[0] as unknown as {
data: { insightsLayoutJson: { version: number; sections: unknown[] } };
};
expect(updateCall.data.insightsLayoutJson.version).toBe(2);
expect(
Array.isArray(updateCall.data.insightsLayoutJson.sections),
).toBe(true);
});
});
describe("PUT /api/insights/layout — full metric-slug universe", () => {
// v1.8.7.1 — the layout tile-id enum derives from `SUB_PAGE_SLUGS`, so
// the long-tail HealthKit + body-composition + mobility + audio slugs
+5 -1
View File
@@ -57,7 +57,11 @@ const tileIdEnum = z.enum(ACCEPTED_INSIGHTS_TILE_IDS);
const sectionIdEnum = z.enum(INSIGHTS_SECTION_IDS);
const layoutSchema = z.object({
version: z.literal(2),
// v1.15.11 QA C1 — accept BOTH version 1 and 2 on input. The live iOS
// client still PUTs `version: 1`; `serializeInsightsLayout` hardcodes the
// stored version to the canonical `INSIGHTS_LAYOUT_VERSION` (2) regardless,
// so accepting a v1 body writes a canonical v2 blob with zero downside.
version: z.union([z.literal(1), z.literal(2)]),
// Additive + optional so a current iOS client PUTting only `tiles`
// (no `sections` key) still validates; `serializeInsightsLayout` fills
// the section defaults when the field is absent.