mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
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:
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);
|
||||
});
|
||||
});
|
||||
@@ -41,3 +41,33 @@ export function reorderById<T extends OrderableRow>(
|
||||
next.splice(toIdx, 0, removed);
|
||||
return next.map((r, i) => ({ ...r, order: i }));
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.15.11 QA M2 — rebuild a full tile list after reordering ONLY the Vitals
|
||||
* subset, without the non-transitive mixed-key comparator the inline edit mode
|
||||
* used to run. Walks the ORIGINAL `order` of every tile: a non-Vitals tile
|
||||
* (the routed sub-page strip) keeps its slot verbatim; each Vitals slot, in
|
||||
* original-order position, is filled by the next id from `reorderedVitalsIds`.
|
||||
* The result is densely renumbered 0-based so the persisted blob stays
|
||||
* canonical. Pure + total-order by construction — no `sort` with a key that
|
||||
* flips per pair.
|
||||
*
|
||||
* `tiles` is the full draft list; `isVitals` decides membership; the returned
|
||||
* array preserves every non-`order` field on each row.
|
||||
*/
|
||||
export function rebuildTilesWithReorderedVitals<T extends OrderableRow>(
|
||||
tiles: readonly T[],
|
||||
reorderedVitalsIds: readonly string[],
|
||||
isVitals: (id: string) => boolean,
|
||||
): T[] {
|
||||
const original = [...tiles].sort((a, b) => a.order - b.order);
|
||||
const byId = new Map(tiles.map((t) => [t.id, t]));
|
||||
let vIdx = 0;
|
||||
return original
|
||||
.map((t) => {
|
||||
if (!isVitals(t.id)) return t;
|
||||
const replacementId = reorderedVitalsIds[vIdx++] ?? t.id;
|
||||
return byId.get(replacementId) ?? t;
|
||||
})
|
||||
.map((t, i) => ({ ...t, order: i }));
|
||||
}
|
||||
@@ -2042,7 +2042,10 @@ medicationExtractionSchema.meta({
|
||||
// present.
|
||||
const insightsLayoutSchema = z
|
||||
.object({
|
||||
version: z.literal(2),
|
||||
// v1.15.11 QA C1 — both v1 and v2 are accepted on input. The live iOS
|
||||
// client still PUTs `version: 1`; the server normalises to the canonical
|
||||
// v2 blob on persist, so a v1 body never 422s on the layout-schema bump.
|
||||
version: z.union([z.literal(1), z.literal(2)]),
|
||||
sections: z
|
||||
.array(
|
||||
z.object({
|
||||
@@ -2068,7 +2071,7 @@ const insightsLayoutSchema = z
|
||||
.meta({
|
||||
id: "InsightsLayoutBody",
|
||||
description:
|
||||
"Per-user Insights layout (v2). `tiles` is the per-metric pill list (ordered, with a visibility flag); `sections` is the additive v2 list of the overview's big semantic blocks (wellness-scores, daily-briefing, vitals, trends, period-review, cycle-summary, signals, rhythm-events), each with order + visibility. `version` is the layout schema version (2). Both arrays are optional on input — a client sending only `tiles` (the pre-v2 contract) still validates, and the server fills missing defaults; a v1 blob with no `sections` resolves forward with all sections default-visible. Tile ids are a closed enum: the canonical ids are English (matching the routed `/insights/<slug>` sub-pages). The legacy German tile ids (blutdruck, puls, sauerstoff, koerpertemperatur, gewicht, aktive-energie, schlaf, ruhepuls, stimmung, medikamente) remain accepted on input for backward compatibility and are normalised to their English equivalents before persisting; GET responses always carry the canonical English ids. Section ids are English-only. The legacy tile ids are deprecated and will be removed in a future major version.",
|
||||
"Per-user Insights layout (v2). `tiles` is the per-metric pill list (ordered, with a visibility flag); `sections` is the additive v2 list of the overview's big semantic blocks (wellness-scores, daily-briefing, vitals, trends, period-review, cycle-summary, signals, rhythm-events), each with order + visibility. `version` is the layout schema version: BOTH 1 and 2 are accepted on input (a pre-v2 iOS client still PUTs `version: 1`); the server always normalises to the canonical v2 blob before persisting, and GET responses always carry `version: 2`. Both arrays are optional on input — a client sending only `tiles` (the pre-v2 contract) still validates, and the server fills missing defaults; a v1 blob with no `sections` resolves forward with all sections default-visible. Tile ids are a closed enum: the canonical ids are English (matching the routed `/insights/<slug>` sub-pages). The legacy German tile ids (blutdruck, puls, sauerstoff, koerpertemperatur, gewicht, aktive-energie, schlaf, ruhepuls, stimmung, medikamente) remain accepted on input for backward compatibility and are normalised to their English equivalents before persisting; GET responses always carry the canonical English ids. Section ids are English-only. The legacy tile ids are deprecated and will be removed in a future major version.",
|
||||
});
|
||||
|
||||
// v1.7.0 — health-record export selection. Strict shape: unknown keys
|
||||
|
||||
Reference in new issue
Block a user