mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user