mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +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
+30
-20
@@ -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/<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."
|
||||
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."
|
||||
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/<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."
|
||||
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."
|
||||
Medication:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
+47
-28
@@ -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 <VitalsDashboard>). 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<HTMLButtonElement | null>(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). */}
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setEditMode((v) => !v)}
|
||||
data-slot="insights-customize-toggle"
|
||||
aria-pressed={editMode}
|
||||
className="gap-1.5"
|
||||
>
|
||||
{editMode ? (
|
||||
<Check className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
) : (
|
||||
customizable region, below the anchored HeroStrip. v1.15.11 QA
|
||||
L2-design: the toggle is HIDDEN while editing — the InsightsEditMode
|
||||
card owns save (Fertig) + reset (Zurücksetzen), so a second top-right
|
||||
"Fertig" that only closes-without-saving (a label-collision trap) no
|
||||
longer exists. The toggle only ENTERS edit mode; the component's
|
||||
Fertig/Zurücksetzen → onClose returns here. QA L1: disabled until the
|
||||
layout query settles so a save can never flush defaults. */}
|
||||
{!editMode && (
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
ref={customizeToggleRef}
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setEditMode(true)}
|
||||
disabled={!canEdit}
|
||||
data-slot="insights-customize-toggle"
|
||||
aria-pressed={editMode}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<SlidersHorizontal className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
)}
|
||||
<span>
|
||||
{editMode ? t("insights.editMode.done") : t("insights.editMode.open")}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
<span>{t("insights.editMode.open")}</span>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editMode ? (
|
||||
/* v1.15.11 W3 — edit mode swaps the live, heavy sections for the
|
||||
@@ -517,7 +532,11 @@ export default function InsightsPage() {
|
||||
<InsightsEditMode
|
||||
layout={layout}
|
||||
gatedOffSectionIds={gatedOffSectionIds}
|
||||
onClose={() => 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
|
||||
|
||||
@@ -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<HTMLHeadingElement | null>(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({
|
||||
>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-3">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">{t("insights.editMode.title")}</h2>
|
||||
<h2
|
||||
ref={headingRef}
|
||||
tabIndex={-1}
|
||||
className="text-lg font-semibold focus-visible:outline-none"
|
||||
>
|
||||
{t("insights.editMode.title")}
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("insights.editMode.description")}
|
||||
</p>
|
||||
@@ -386,7 +403,14 @@ export function InsightsEditMode({
|
||||
items={vitalsTileIds}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<div className="mt-2 space-y-1.5">
|
||||
{/* 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. */}
|
||||
<div className="mt-2 max-h-[60vh] space-y-1.5 overflow-y-auto overscroll-contain">
|
||||
{vitalsTiles.map((tile) => (
|
||||
<SortableTileRow
|
||||
key={tile.id}
|
||||
@@ -447,6 +471,7 @@ function EyeToggle({
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
aria-disabled={disabled || undefined}
|
||||
aria-pressed={visible}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
@@ -517,28 +542,34 @@ function SortableSectionRow({
|
||||
>
|
||||
<GripVertical className="h-4 w-4" />
|
||||
</button>
|
||||
<span className="min-w-0 flex-1 truncate text-sm font-medium" title={title}>
|
||||
{title}
|
||||
</span>
|
||||
{gatedOff ? (
|
||||
<span className="text-muted-foreground hidden text-xs sm:inline">
|
||||
{labels.gatedHint}
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<span className="truncate text-sm font-medium" title={title}>
|
||||
{title}
|
||||
</span>
|
||||
) : (
|
||||
<EyeToggle
|
||||
visible={section.visible}
|
||||
disabled={disabled}
|
||||
label={`${section.visible ? labels.hide : labels.show} — ${title}`}
|
||||
onClick={() => 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 && (
|
||||
<span
|
||||
className="text-muted-foreground truncate text-xs"
|
||||
data-slot="insights-edit-section-gated-hint"
|
||||
>
|
||||
{labels.gatedHint}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{/* 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. */}
|
||||
<EyeToggle
|
||||
visible={section.visible}
|
||||
disabled={disabled || gatedOff}
|
||||
label={`${section.visible ? labels.hide : labels.show} — ${title}`}
|
||||
onClick={() => onToggle(section.id, !section.visible)}
|
||||
slot="insights-edit-section-eye"
|
||||
/>
|
||||
</div>
|
||||
{gatedOff && (
|
||||
<p className="text-muted-foreground mt-1 pl-13 text-xs sm:hidden">
|
||||
{labels.gatedHint}
|
||||
</p>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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