From 6ebac22c65567d450cfdcbf02181c68665c03bce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 7 Jun 2026 17:08:25 +0200 Subject: [PATCH] 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). --- docs/api/openapi.yaml | 50 +++++--- .../insights/layout/__tests__/route.test.ts | 44 +++++++ src/app/api/insights/layout/route.ts | 6 +- src/app/insights/page.tsx | 75 ++++++----- .../insights/insights-edit-mode.tsx | 119 +++++++++++------- src/hooks/use-insights-layout.ts | 27 +++- .../__tests__/insights-layout-reorder.test.ts | 71 ++++++++++- src/lib/insights-layout-reorder.ts | 30 +++++ src/lib/openapi/routes.ts | 7 +- 9 files changed, 331 insertions(+), 98 deletions(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index b0f9a1bbb..6e502f405 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -4555,8 +4555,11 @@ components: type: object properties: version: - type: number - const: 2 + anyOf: + - type: number + const: 1 + - type: number + const: 2 sections: maxItems: 50 type: array @@ -4666,14 +4669,16 @@ components: 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/` 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." + 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/` 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." CoachMessageFeedbackRequest: type: object properties: @@ -11474,8 +11479,11 @@ components: type: object properties: version: - type: number - const: 2 + anyOf: + - type: number + const: 1 + - type: number + const: 2 sections: maxItems: 50 type: array @@ -11588,14 +11596,16 @@ components: 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/` 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." + 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/` 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." Medication: type: object properties: diff --git a/src/app/api/insights/layout/__tests__/route.test.ts b/src/app/api/insights/layout/__tests__/route.test.ts index b5b432153..e0086226e 100644 --- a/src/app/api/insights/layout/__tests__/route.test.ts +++ b/src/app/api/insights/layout/__tests__/route.test.ts @@ -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 diff --git a/src/app/api/insights/layout/route.ts b/src/app/api/insights/layout/route.ts index 810c43d01..551c8aed0 100644 --- a/src/app/api/insights/layout/route.ts +++ b/src/app/api/insights/layout/route.ts @@ -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. diff --git a/src/app/insights/page.tsx b/src/app/insights/page.tsx index 76e624c59..544370864 100644 --- a/src/app/insights/page.tsx +++ b/src/app/insights/page.tsx @@ -1,15 +1,15 @@ "use client"; -import { Fragment, useState, type ReactNode } from "react"; +import { Fragment, useRef, useState, type ReactNode } from "react"; import { useQuery } from "@tanstack/react-query"; import dynamic from "next/dynamic"; import Link from "next/link"; -import { Check, RefreshCw, SlidersHorizontal, TrendingUp } from "lucide-react"; +import { RefreshCw, SlidersHorizontal, TrendingUp } from "lucide-react"; import { useAuth } from "@/hooks/use-auth"; import { queryKeys } from "@/lib/query-keys"; import { useFeatureFlags } from "@/hooks/use-feature-flags"; -import { useInsightsLayout } from "@/hooks/use-insights-layout"; +import { useInsightsLayoutQuery } from "@/hooks/use-insights-layout"; import { orderedVisibleSectionIds, type InsightsSectionId, @@ -283,7 +283,10 @@ export default function InsightsPage() { // per-tile order/visibility (passed into ). Defaults to // the canonical layout while in-flight so the first paint matches the // default order with no flicker. - const layout = useInsightsLayout(isAuthenticated); + const { + layout, + isLoading: layoutLoading, + } = useInsightsLayoutQuery(isAuthenticated); // v1.15.11 W3 — inline "Anpassen" edit mode. When on, the customizable // region swaps the live (heavy) sections for lightweight edit cards; the @@ -291,6 +294,17 @@ export default function InsightsPage() { // own hooks but the section JSX is replaced). HeroStrip stays anchored. const [editMode, setEditMode] = useState(false); + // v1.15.11 QA L5 — focus restoration. On entering edit mode focus moves to + // the edit-card heading; on close/save focus returns to the "Anpassen" + // toggle so keyboard users are not dropped at the top of the document. + const customizeToggleRef = useRef(null); + + // v1.15.11 QA L1 — never let a "Fertig" save flush DEFAULT_INSIGHTS_LAYOUT + // over the user's real saved layout. The editor seeds its draft once from + // the `layout` prop, which is the default while the GET is in-flight, so the + // edit surface stays gated (the toggle disabled) until the query has settled. + const canEdit = !layoutLoading; + // Error branch — a transient 500 / network drop (after the query's // retries are exhausted) settles the comprehensive query with no data. // Without this gate the page would fall through to the "no data yet — @@ -484,30 +498,31 @@ export default function InsightsPage() { /> {/* v1.15.11 W3 — the "Anpassen" toggle sits at the top of the - customizable region, below the anchored HeroStrip. In edit mode it - reads "Fertig"; the InsightsEditMode component owns the actual - Fertig/Zurücksetzen save controls, so this button just enters edit - mode (exiting is handled by the component's "Fertig" → onClose). */} -
- -
+ {t("insights.editMode.open")} + + + )} {editMode ? ( /* v1.15.11 W3 — edit mode swaps the live, heavy sections for the @@ -517,7 +532,11 @@ export default function InsightsPage() { setEditMode(false)} + onClose={() => { + setEditMode(false); + // v1.15.11 QA L5 — return focus to the toggle that reappears. + requestAnimationFrame(() => customizeToggleRef.current?.focus()); + }} /> ) : everySectionHidden ? ( /* v1.15.11 W3 — empty-state: every section hidden. The page is never diff --git a/src/components/insights/insights-edit-mode.tsx b/src/components/insights/insights-edit-mode.tsx index ec59a40af..aaac26f46 100644 --- a/src/components/insights/insights-edit-mode.tsx +++ b/src/components/insights/insights-edit-mode.tsx @@ -1,6 +1,6 @@ "use client"; -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { @@ -33,7 +33,10 @@ import { cn } from "@/lib/utils"; import { prefersReducedMotion } from "@/lib/charts/reduced-motion"; import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; -import { reorderById } from "@/lib/insights-layout-reorder"; +import { + reorderById, + rebuildTilesWithReorderedVitals, +} from "@/lib/insights-layout-reorder"; import { type InsightsLayout, type InsightsSectionConfig, @@ -87,6 +90,10 @@ const VITALS_TILE_LABEL_KEYS: { id: string; labelKey: string }[] = [ { id: "cardio-fitness", labelKey: "measurements.typeVo2Max" }, { id: "vascular-age", labelKey: "measurements.typeVascularAge" }, { id: "hrv", labelKey: "measurements.typeHeartRateVariability" }, + // v1.15.11 QA M1 — the Vitals grid renders a default-visible Weight tile; + // without this entry the inline editor could not hide/reorder it like every + // other grid tile. + { id: "weight", labelKey: "measurements.typeWeight" }, { id: "bmi", labelKey: "measurements.typeBodyMassIndex" }, { id: "resting-pulse", labelKey: "measurements.typeRestingHeartRate" }, { id: "respiratory-rate", labelKey: "measurements.typeRespiratoryRate" }, @@ -131,6 +138,15 @@ export function InsightsEditMode({ })); const [tilesOpen, setTilesOpen] = useState(false); + // 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 + // the document). Inline, not modal, so no focus trap — focus returns to the + // "Anpassen" toggle on close via the page's onClose handler. + const headingRef = useRef(null); + useEffect(() => { + headingRef.current?.focus(); + }, []); + const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 6 } }), useSensor(KeyboardSensor, { @@ -253,28 +269,23 @@ export function InsightsEditMode({ order: tt.order, visible: tt.visible, })); + // Reorder ONLY the Vitals subset through the tested pure helper — a single + // total-order sort, never the mixed-key comparator that used to break + // transitivity (QA M2). const reordered = reorderById(subset, String(active.id), String(over.id)); - const newOrderById = new Map(reordered.map((r, i) => [r.id, i])); + const reorderedVitalsIds = reordered.map((r) => r.id); - setDraft((d) => { - // Build the next full tile list: Vitals tiles take their new relative - // order; everything else keeps its existing order. Then renumber the - // whole list densely so the persisted blob stays canonical. - const next: InsightsTileConfig[] = [...d.tiles].sort((a, b) => { - const aV = VITALS_TILE_IDS.has(a.id); - const bV = VITALS_TILE_IDS.has(b.id); - if (aV && bV) { - return ( - (newOrderById.get(a.id) ?? 0) - (newOrderById.get(b.id) ?? 0) - ); - } - return a.order - b.order; - }); - return { - ...d, - tiles: next.map((tt, i) => ({ ...tt, order: i })), - }; - }); + setDraft((d) => ({ + ...d, + // Substitute the Vitals slots in their new relative order while leaving + // the sub-page strip (non-Vitals tiles) untouched. Pure, total-order + // helper — no mixed-key comparator (QA M2). + tiles: rebuildTilesWithReorderedVitals( + d.tiles, + reorderedVitalsIds, + (id) => VITALS_TILE_IDS.has(id), + ), + })); } const allHidden = draft.sections.every((s) => !s.visible); @@ -286,7 +297,13 @@ export function InsightsEditMode({ >
-

{t("insights.editMode.title")}

+

+ {t("insights.editMode.title")} +

{t("insights.editMode.description")}

@@ -386,7 +403,14 @@ export function InsightsEditMode({ items={vitalsTileIds} strategy={verticalListSortingStrategy} > -
+ {/* v1.15.11 QA M2-design — cap the disclosed tile + list height and let it scroll inside its own + container so dragging a tile on a phone doesn't + fight the page scroll. `overscroll-contain` keeps + the scroll local; the container is full-width and + only clips on the y-axis, so the drag ghost + (which translates within the row) is not cut. */} +
{vitalsTiles.map((tile) => ( - - {title} - - {gatedOff ? ( - - {labels.gatedHint} +
+ + {title} - ) : ( - onToggle(section.id, !section.visible)} - slot="insights-edit-section-eye" - /> - )} + {/* v1.15.11 QA M1-design — the gated hint reads as a caption BELOW the + title so the row's right-edge control position stays stable whether + the section is gated or not. */} + {gatedOff && ( + + {labels.gatedHint} + + )} +
+ {/* v1.15.11 QA M1-design — a gated section keeps a DISABLED eye toggle in + the SAME position rather than swapping it for a text span, so the row + layout never jumps. The section stays reorderable; only the toggle is + inert until the gate opens. */} + onToggle(section.id, !section.visible)} + slot="insights-edit-section-eye" + />
- {gatedOff && ( -

- {labels.gatedHint} -

- )} {children}
); diff --git a/src/hooks/use-insights-layout.ts b/src/hooks/use-insights-layout.ts index e484ccde6..ba16f02a8 100644 --- a/src/hooks/use-insights-layout.ts +++ b/src/hooks/use-insights-layout.ts @@ -26,7 +26,24 @@ import { * layout lands. */ export function useInsightsLayout(enabled: boolean): InsightsLayout { - const { data } = useQuery({ + return useInsightsLayoutQuery(enabled).layout; +} + +/** + * v1.15.11 QA L1 — same query as {@link useInsightsLayout} but also surfaces + * the load status so a caller can gate a write surface (the inline "Anpassen" + * edit mode) until the GET has settled. Without this gate a user who enters + * edit mode while the layout is still in-flight would seed the editor from + * `DEFAULT_INSIGHTS_LAYOUT` and a "Fertig" save would PUT defaults over their + * real saved layout. Shares the query key, so mounting both hooks costs one + * request (TanStack dedupes). + */ +export function useInsightsLayoutQuery(enabled: boolean): { + layout: InsightsLayout; + isLoading: boolean; + isSuccess: boolean; +} { + const { data, isLoading, isSuccess } = useQuery({ queryKey: queryKeys.insightsLayout(), queryFn: async () => { const res = await fetch("/api/insights/layout"); @@ -35,5 +52,11 @@ export function useInsightsLayout(enabled: boolean): InsightsLayout { }, enabled, }); - return data ?? DEFAULT_INSIGHTS_LAYOUT; + return { + layout: data ?? DEFAULT_INSIGHTS_LAYOUT, + // `enabled: false` keeps the query in a pending-but-idle state; treat a + // disabled (unauthenticated) query as not-loading so the gate never sticks. + isLoading: enabled && isLoading, + isSuccess, + }; } diff --git a/src/lib/__tests__/insights-layout-reorder.test.ts b/src/lib/__tests__/insights-layout-reorder.test.ts index f18b5c494..c36d6013c 100644 --- a/src/lib/__tests__/insights-layout-reorder.test.ts +++ b/src/lib/__tests__/insights-layout-reorder.test.ts @@ -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); + }); +}); diff --git a/src/lib/insights-layout-reorder.ts b/src/lib/insights-layout-reorder.ts index 9de4d4a04..8a21af264 100644 --- a/src/lib/insights-layout-reorder.ts +++ b/src/lib/insights-layout-reorder.ts @@ -41,3 +41,33 @@ export function reorderById( 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( + 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 })); +} diff --git a/src/lib/openapi/routes.ts b/src/lib/openapi/routes.ts index 84eefff83..2acc5601c 100644 --- a/src/lib/openapi/routes.ts +++ b/src/lib/openapi/routes.ts @@ -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/` 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/` 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