feat(insights): add inline edit mode to reorder and hide overview cards

This commit is contained in:
Marc-André Bombeck committed 2026-06-07 16:55:28 +02:00
1 parent e3539d7c94
commit 9b13b41f1a
10 files changed
+915 -11

No files matched your search

+88 -11
View File
@@ -1,10 +1,10 @@
"use client";
import { Fragment, type ReactNode } from "react";
import { Fragment, useState, type ReactNode } from "react";
import { useQuery } from "@tanstack/react-query";
import dynamic from "next/dynamic";
import Link from "next/link";
import { RefreshCw, TrendingUp } from "lucide-react";
import { Check, RefreshCw, SlidersHorizontal, TrendingUp } from "lucide-react";
import { useAuth } from "@/hooks/use-auth";
import { queryKeys } from "@/lib/query-keys";
@@ -21,6 +21,7 @@ import { EmptyState } from "@/components/ui/empty-state";
import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";
import { HeroStrip } from "@/components/insights/hero-strip";
import { InsightsEditMode } from "@/components/insights/insights-edit-mode";
import { useInsightsAdvisorQuery } from "@/components/insights/use-insights-advisor";
import { useCoachLaunch } from "@/lib/insights/coach-launch-context";
import { useAnalyticsQuery } from "@/lib/queries/use-analytics-query";
@@ -284,6 +285,12 @@ export default function InsightsPage() {
// default order with no flicker.
const layout = useInsightsLayout(isAuthenticated);
// v1.15.11 W3 — inline "Anpassen" edit mode. When on, the customizable
// region swaps the live (heavy) sections for lightweight edit cards; the
// section data is never refetched (the queries stay mounted via the page's
// own hooks but the section JSX is replaced). HeroStrip stays anchored.
const [editMode, setEditMode] = useState(false);
// 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 —
@@ -429,6 +436,24 @@ export default function InsightsPage() {
"rhythm-events": <RhythmEventsCard enabled={isAuthenticated} />,
};
// v1.15.11 W3 — sections whose feature/data gate is currently OFF. The edit
// row for these renders disabled with a hint so a toggle that does nothing
// never confuses the user (it stays orderable, just not enable-able past the
// gate). Mirrors the `null` registry entries above: `daily-briefing` +
// `period-review` ride the briefing flag; `cycle-summary` rides the cycle
// opt-in. Every other section is always available.
const gatedOffSectionIds = new Set<InsightsSectionId>();
if (!flags.briefing) {
gatedOffSectionIds.add("daily-briefing");
gatedOffSectionIds.add("period-review");
}
if (!user?.cycleTrackingEnabled) {
gatedOffSectionIds.add("cycle-summary");
}
const orderedSectionIds = orderedVisibleSectionIds(layout);
const everySectionHidden = orderedSectionIds.length === 0;
return (
// v1.12.7 (L3) — one consistent vertical rhythm down the overview. The
// page used `space-y-8` (32 px) between top-level blocks while the vitals
@@ -458,15 +483,67 @@ export default function InsightsPage() {
healthScore={analytics?.healthScore ?? undefined}
/>
{/* v1.15.11 W2 — the customizable region: sections render in the
resolved layout order, skipping any the user has hidden. A
layout-visible-but-gate-off section resolves to `null` from the
registry, so the `space-y-6` rhythm closes the gap with no hole.
Each registry node is wrapped in a keyed Fragment so React keeps a
stable identity across a reorder. */}
{orderedVisibleSectionIds(layout).map((id) => (
<Fragment key={id}>{SECTION_REGISTRY[id]}</Fragment>
))}
{/* 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" />
) : (
<SlidersHorizontal className="h-3.5 w-3.5" aria-hidden="true" />
)}
<span>
{editMode ? t("insights.editMode.done") : t("insights.editMode.open")}
</span>
</Button>
</div>
{editMode ? (
/* v1.15.11 W3 — edit mode swaps the live, heavy sections for the
lightweight edit cards. The section data queries stay mounted on the
page, but their JSX is not rendered, so toggling into edit mode never
refetches. */
<InsightsEditMode
layout={layout}
gatedOffSectionIds={gatedOffSectionIds}
onClose={() => setEditMode(false)}
/>
) : everySectionHidden ? (
/* v1.15.11 W3 — empty-state: every section hidden. The page is never
blank — surface a hint + a button that opens edit mode so the user
can bring sections back. */
<EmptyState
icon={<SlidersHorizontal className="size-6" />}
title={t("insights.editMode.emptyTitle")}
description={t("insights.editMode.emptyDescription")}
action={
<Button size="sm" onClick={() => setEditMode(true)}>
{t("insights.editMode.open")}
</Button>
}
/>
) : (
/* v1.15.11 W2 — the customizable region: sections render in the
resolved layout order, skipping any the user has hidden. A
layout-visible-but-gate-off section resolves to `null` from the
registry, so the `space-y-6` rhythm closes the gap with no hole.
Each registry node is wrapped in a keyed Fragment so React keeps a
stable identity across a reorder. */
orderedSectionIds.map((id) => (
<Fragment key={id}>{SECTION_REGISTRY[id]}</Fragment>
))
)}
</div>
);
}
@@ -0,0 +1,608 @@
"use client";
import { useMemo, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import {
ChevronDown,
Eye,
EyeOff,
GripVertical,
Loader2,
RotateCcw,
} from "lucide-react";
import {
DndContext,
KeyboardSensor,
PointerSensor,
closestCenter,
useSensor,
useSensors,
type DragEndEvent,
} from "@dnd-kit/core";
import {
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { Button } from "@/components/ui/button";
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 {
type InsightsLayout,
type InsightsSectionConfig,
type InsightsSectionId,
type InsightsTileConfig,
} from "@/lib/insights-layout";
/**
* v1.15.11 W3 — inline "Anpassen" edit mode for the customizable Insights
* overview.
*
* Renders LIGHTWEIGHT edit cards (drag handle + localized title + eye toggle)
* instead of the live, heavy section content, so toggling into edit mode never
* refetches the section data — it swaps the JSX, not the queries. Edits mutate a
* local draft; "Fertig" PUTs the merged `{ version: 2, sections, tiles }` blob
* and invalidates `queryKeys.insightsLayout()`; "Zurücksetzen" DELETEs to
* defaults.
*
* Tile-level depth (the Vitals section's per-metric tiles) ships as the
* spec-sanctioned DISCLOSURE fallback rather than nested cross-container drag:
* section rows drag in ONE top-level `SortableContext`; the Vitals row carries a
* "Kacheln verwalten" disclosure that opens a SEPARATE, independent
* `DndContext` + `SortableContext` for its tiles. The two drag contexts never
* share collision detection, so a tile drag can never cross-fire into a section
* drag — the failure mode flagged in the plan's Risks section. Still inline,
* still drag + eye per tile, robust on touch.
*/
/** Localized title key per section id — used for the edit-card label. */
const SECTION_TITLE_KEYS: Record<InsightsSectionId, string> = {
"wellness-scores": "insights.derived.scores.sectionTitle",
"daily-briefing": "insights.dailyBriefing.title",
vitals: "insights.derived.vitals.sectionTitle",
trends: "insights.trendsRow.title",
"period-review": "insights.narrativeTitle",
"cycle-summary": "cycle.insightsSummary.title",
signals: "insights.derived.coincident.cardTitle",
"rhythm-events": "insights.rhythmEvents.sectionTitle",
};
/**
* The per-metric tile ids the Vitals section renders on the overview grid,
* paired with their localized measurement label key. This is the subset of the
* full tile-id universe that the Vitals dashboard actually surfaces (the four
* derived re-frames + the per-vital baseline tiles + the mobility sub-grid);
* the other layout tile ids drive the routed sub-page strip, not this grid, so
* they stay out of the inline tile editor. Order here is the default grid order
* (see `vitals-dashboard.tsx`); the user's saved `order` overrides it.
*/
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" },
{ id: "bmi", labelKey: "measurements.typeBodyMassIndex" },
{ id: "resting-pulse", labelKey: "measurements.typeRestingHeartRate" },
{ id: "respiratory-rate", labelKey: "measurements.typeRespiratoryRate" },
{ id: "oxygen", labelKey: "measurements.typeOxygenSaturation" },
{ id: "body-temperature", labelKey: "measurements.typeBodyTemperature" },
{ id: "blood-glucose", labelKey: "measurements.typeBloodGlucose" },
{ id: "six-minute-walk", labelKey: "measurements.typeSixMinuteWalkDistance" },
{ id: "stair-ascent-speed", labelKey: "measurements.typeStairAscentSpeed" },
{ id: "stair-descent-speed", labelKey: "measurements.typeStairDescentSpeed" },
{ id: "wrist-temperature", labelKey: "measurements.typeWristTemperature" },
];
const VITALS_TILE_IDS = new Set(VITALS_TILE_LABEL_KEYS.map((t) => t.id));
interface InsightsEditModeProps {
/** Resolved layout currently in effect (server copy, defaults while loading). */
layout: InsightsLayout;
/**
* Which section ids are gated off right now (feature flag / data gate), so
* the edit row renders disabled with a hint rather than offering a toggle
* that does nothing.
*/
gatedOffSectionIds: ReadonlySet<InsightsSectionId>;
/** Close edit mode (the "Fertig" / save-success path calls this). */
onClose: () => void;
}
export function InsightsEditMode({
layout,
gatedOffSectionIds,
onClose,
}: InsightsEditModeProps) {
const { t } = useTranslations();
const queryClient = useQueryClient();
// Local draft seeded from the resolved layout. Edits mutate the draft only;
// "Fertig" flushes it via the PUT mutation. Sections/tiles are sorted by
// order for stable rendering.
const [draft, setDraft] = useState<InsightsLayout>(() => ({
version: layout.version,
sections: [...layout.sections].sort((a, b) => a.order - b.order),
tiles: [...layout.tiles].sort((a, b) => a.order - b.order),
}));
const [tilesOpen, setTilesOpen] = useState(false);
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
);
const saveMutation = useMutation({
mutationFn: async (next: InsightsLayout) => {
const res = await fetch("/api/insights/layout", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
version: 2,
sections: next.sections,
tiles: next.tiles,
}),
});
if (!res.ok) throw new Error("save failed");
return (await res.json()).data as InsightsLayout;
},
onSuccess: (saved) => {
// Optimistic-style settle: write the server-resolved layout into the
// shared cache so the overview repaints in the new order, then close.
queryClient.setQueryData(queryKeys.insightsLayout(), saved);
void queryClient.invalidateQueries({
queryKey: queryKeys.insightsLayout(),
});
toast.success(t("insights.editMode.saveSuccess"));
onClose();
},
onError: () => toast.error(t("insights.editMode.saveError")),
});
const resetMutation = useMutation({
mutationFn: async () => {
const res = await fetch("/api/insights/layout", { method: "DELETE" });
if (!res.ok) throw new Error("reset failed");
return (await res.json()).data as InsightsLayout;
},
onSuccess: (saved) => {
queryClient.setQueryData(queryKeys.insightsLayout(), saved);
void queryClient.invalidateQueries({
queryKey: queryKeys.insightsLayout(),
});
// Re-seed the draft so the open editor reflects the restored defaults.
setDraft({
version: saved.version,
sections: [...saved.sections].sort((a, b) => a.order - b.order),
tiles: [...saved.tiles].sort((a, b) => a.order - b.order),
});
toast.success(t("insights.editMode.resetSuccess"));
},
onError: () => toast.error(t("insights.editMode.saveError")),
});
const busy = saveMutation.isPending || resetMutation.isPending;
const sections = useMemo(
() => [...draft.sections].sort((a, b) => a.order - b.order),
[draft.sections],
);
const sectionIds = sections.map((s) => s.id);
const vitalsTiles = useMemo(() => {
const byId = new Map<string, InsightsTileConfig>(
draft.tiles.map((tt) => [tt.id, tt]),
);
return VITALS_TILE_LABEL_KEYS.map(({ id, labelKey }) => {
const cfg = byId.get(id);
return {
id,
labelKey,
visible: cfg?.visible ?? false,
order: cfg?.order ?? Number.MAX_SAFE_INTEGER,
};
}).sort((a, b) => a.order - b.order);
// Re-derive on any tile change.
}, [draft.tiles]);
const vitalsTileIds = vitalsTiles.map((tt) => tt.id);
function toggleSection(id: InsightsSectionId, visible: boolean) {
setDraft((d) => ({
...d,
sections: d.sections.map((s) => (s.id === id ? { ...s, visible } : s)),
}));
}
function handleSectionDragEnd(event: DragEndEvent) {
const { active, over } = event;
if (!over || active.id === over.id) return;
const reordered = reorderById<InsightsSectionConfig>(
draft.sections,
String(active.id),
String(over.id),
);
setDraft((d) => ({ ...d, sections: reordered }));
}
function toggleTile(id: string, visible: boolean) {
setDraft((d) => ({
...d,
tiles: d.tiles.map((tt) => (tt.id === id ? { ...tt, visible } : tt)),
}));
}
/**
* Reorder the Vitals tiles WITHIN the draft's full tile list. The drag
* operates only over the Vitals subset, but the persisted `tiles` array
* carries every tile id (the routed sub-page strip reads it too), so we
* splice the reordered subset back into the full array, re-densifying the
* order only across the Vitals slots while leaving the non-Vitals tiles'
* relative order intact.
*/
function handleTileDragEnd(event: DragEndEvent) {
const { active, over } = event;
if (!over || active.id === over.id) return;
const subset = vitalsTiles.map((tt) => ({
id: tt.id,
order: tt.order,
visible: tt.visible,
}));
const reordered = reorderById(subset, String(active.id), String(over.id));
const newOrderById = new Map(reordered.map((r, i) => [r.id, i]));
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 })),
};
});
}
const allHidden = draft.sections.every((s) => !s.visible);
return (
<div
data-slot="insights-edit-mode"
className="bg-card border-border space-y-4 rounded-xl border p-4 sm:p-6"
>
<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>
<p className="text-muted-foreground text-sm">
{t("insights.editMode.description")}
</p>
</div>
<div className="flex items-center gap-2 self-end sm:self-auto">
<Button
variant="ghost"
size="sm"
onClick={() => resetMutation.mutate()}
disabled={busy}
data-slot="insights-edit-reset"
>
{resetMutation.isPending ? (
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<RotateCcw className="mr-2 h-3.5 w-3.5" />
)}
{t("insights.editMode.reset")}
</Button>
<Button
size="sm"
onClick={() => saveMutation.mutate(draft)}
disabled={busy}
data-slot="insights-edit-done"
>
{saveMutation.isPending && (
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("insights.editMode.done")}
</Button>
</div>
</div>
{allHidden && (
<p
className="text-muted-foreground text-sm"
data-slot="insights-edit-all-hidden"
>
{t("insights.editMode.allHiddenHint")}
</p>
)}
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleSectionDragEnd}
>
<SortableContext
items={sectionIds}
strategy={verticalListSortingStrategy}
>
<div className="space-y-2">
{sections.map((section) => {
const gatedOff = gatedOffSectionIds.has(section.id);
const isVitals = section.id === "vitals";
return (
<SortableSectionRow
key={section.id}
section={section}
title={t(SECTION_TITLE_KEYS[section.id])}
gatedOff={gatedOff}
disabled={busy}
labels={{
dragHandle: t("insights.editMode.dragHandle"),
show: t("insights.editMode.show"),
hide: t("insights.editMode.hide"),
gatedHint: t("insights.editMode.gatedHint"),
}}
onToggle={toggleSection}
>
{isVitals && (
<div className="mt-2 border-t pt-2">
<button
type="button"
onClick={() => setTilesOpen((o) => !o)}
disabled={busy}
aria-expanded={tilesOpen}
data-slot="insights-edit-tiles-disclosure"
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring inline-flex min-h-11 items-center gap-1.5 rounded text-sm focus-visible:ring-2 focus-visible:outline-none disabled:opacity-50"
>
<ChevronDown
className={cn(
"h-4 w-4 transition-transform motion-reduce:transition-none",
tilesOpen && "rotate-180",
)}
aria-hidden="true"
/>
{t("insights.editMode.manageTiles")}
</button>
{tilesOpen && (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleTileDragEnd}
>
<SortableContext
items={vitalsTileIds}
strategy={verticalListSortingStrategy}
>
<div className="mt-2 space-y-1.5">
{vitalsTiles.map((tile) => (
<SortableTileRow
key={tile.id}
id={tile.id}
title={t(tile.labelKey)}
visible={tile.visible}
disabled={busy}
labels={{
dragHandle: t(
"insights.editMode.dragHandle",
),
show: t("insights.editMode.show"),
hide: t("insights.editMode.hide"),
}}
onToggle={toggleTile}
/>
))}
</div>
</SortableContext>
</DndContext>
)}
</div>
)}
</SortableSectionRow>
);
})}
</div>
</SortableContext>
</DndContext>
</div>
);
}
interface RowLabels {
dragHandle: string;
show: string;
hide: string;
}
const DRAG_HANDLE_CLASS =
"text-muted-foreground hover:text-foreground focus-visible:ring-ring focus-visible:ring-offset-background relative inline-flex h-11 w-11 shrink-0 cursor-grab touch-none items-center justify-center rounded transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none active:cursor-grabbing disabled:cursor-not-allowed disabled:opacity-50 motion-reduce:transition-none sm:h-9 sm:w-9 sm:before:absolute sm:before:inset-[-6px] sm:before:content-['']";
function EyeToggle({
visible,
disabled,
label,
onClick,
slot,
}: {
visible: boolean;
disabled: boolean;
label: string;
onClick: () => void;
slot: string;
}) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
aria-pressed={visible}
aria-label={label}
title={label}
data-slot={slot}
data-visible={visible ? "true" : "false"}
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring focus-visible:ring-offset-background inline-flex h-11 w-11 shrink-0 items-center justify-center rounded transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 motion-reduce:transition-none sm:h-9 sm:w-9"
>
{visible ? (
<Eye className="h-4 w-4" />
) : (
<EyeOff className="h-4 w-4" />
)}
</button>
);
}
interface SortableSectionRowProps {
section: InsightsSectionConfig;
title: string;
gatedOff: boolean;
disabled: boolean;
labels: RowLabels & { gatedHint: string };
onToggle: (id: InsightsSectionId, visible: boolean) => void;
children?: React.ReactNode;
}
function SortableSectionRow({
section,
title,
gatedOff,
disabled,
labels,
onToggle,
children,
}: SortableSectionRowProps) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
useSortable({ id: section.id });
const style: React.CSSProperties = {
transform: CSS.Transform.toString(transform),
transition: prefersReducedMotion() ? "none" : transition,
};
return (
<div
ref={setNodeRef}
style={style}
data-slot="insights-edit-section-row"
data-section={section.id}
data-dragging={isDragging ? "true" : undefined}
data-gated={gatedOff ? "true" : undefined}
className={cn(
"border-border bg-background/30 w-full rounded-md border px-2 py-2 sm:px-3",
isDragging && "ring-primary z-10 opacity-90 shadow-lg ring-2",
gatedOff && "opacity-60",
)}
>
<div className="flex items-center gap-2">
<button
type="button"
{...attributes}
{...listeners}
aria-label={`${labels.dragHandle} — ${title}`}
title={labels.dragHandle}
disabled={disabled}
data-slot="insights-edit-section-handle"
className={DRAG_HANDLE_CLASS}
>
<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}
</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"
/>
)}
</div>
{gatedOff && (
<p className="text-muted-foreground mt-1 pl-13 text-xs sm:hidden">
{labels.gatedHint}
</p>
)}
{children}
</div>
);
}
interface SortableTileRowProps {
id: string;
title: string;
visible: boolean;
disabled: boolean;
labels: RowLabels;
onToggle: (id: string, visible: boolean) => void;
}
function SortableTileRow({
id,
title,
visible,
disabled,
labels,
onToggle,
}: SortableTileRowProps) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
useSortable({ id });
const style: React.CSSProperties = {
transform: CSS.Transform.toString(transform),
transition: prefersReducedMotion() ? "none" : transition,
};
return (
<div
ref={setNodeRef}
style={style}
data-slot="insights-edit-tile-row"
data-tile={id}
data-dragging={isDragging ? "true" : undefined}
className={cn(
"border-border bg-card flex items-center gap-2 rounded-md border px-2 py-1.5",
isDragging && "ring-primary z-10 opacity-90 shadow-md ring-2",
)}
>
<button
type="button"
{...attributes}
{...listeners}
aria-label={`${labels.dragHandle} — ${title}`}
title={labels.dragHandle}
disabled={disabled}
data-slot="insights-edit-tile-handle"
className={DRAG_HANDLE_CLASS}
>
<GripVertical className="h-4 w-4" />
</button>
<span className="min-w-0 flex-1 truncate text-sm" title={title}>
{title}
</span>
<EyeToggle
visible={visible}
disabled={disabled}
label={`${visible ? labels.hide : labels.show} — ${title}`}
onClick={() => onToggle(id, !visible)}
slot="insights-edit-tile-eye"
/>
</div>
);
}
@@ -0,0 +1,68 @@
import { describe, it, expect } from "vitest";
import { reorderById } from "@/lib/insights-layout-reorder";
describe("reorderById", () => {
const rows = [
{ id: "a", order: 0, visible: true },
{ id: "b", order: 1, visible: false },
{ id: "c", order: 2, visible: true },
];
it("moves a row forward and renumbers densely", () => {
const next = reorderById(rows, "a", "c");
expect(next.map((r) => r.id)).toEqual(["b", "c", "a"]);
expect(next.map((r) => r.order)).toEqual([0, 1, 2]);
});
it("moves a row backward and renumbers densely", () => {
const next = reorderById(rows, "c", "a");
expect(next.map((r) => r.id)).toEqual(["c", "a", "b"]);
expect(next.map((r) => r.order)).toEqual([0, 1, 2]);
});
it("preserves every non-order field on each row", () => {
const next = reorderById(rows, "a", "b");
const moved = next.find((r) => r.id === "a");
expect(moved?.visible).toBe(true);
const hidden = next.find((r) => r.id === "b");
expect(hidden?.visible).toBe(false);
});
it("is a no-op (but still densely renumbered) when ids match", () => {
const next = reorderById(rows, "b", "b");
expect(next.map((r) => r.id)).toEqual(["a", "b", "c"]);
expect(next.map((r) => r.order)).toEqual([0, 1, 2]);
});
it("returns a densely renumbered copy when an id is missing", () => {
const sparse = [
{ id: "a", order: 5 },
{ id: "b", order: 9 },
];
const next = reorderById(sparse, "a", "zzz");
expect(next).toEqual([
{ id: "a", order: 0 },
{ id: "b", order: 1 },
]);
});
it("does not mutate the input array or its rows", () => {
const input = [
{ id: "a", order: 0 },
{ id: "b", order: 1 },
];
const snapshot = JSON.parse(JSON.stringify(input));
reorderById(input, "a", "b");
expect(input).toEqual(snapshot);
});
it("sorts an unsorted input by order before reordering", () => {
const unsorted = [
{ id: "c", order: 2 },
{ id: "a", order: 0 },
{ id: "b", order: 1 },
];
const next = reorderById(unsorted, "a", "c");
expect(next.map((r) => r.id)).toEqual(["b", "c", "a"]);
});
});
+43
View File
@@ -0,0 +1,43 @@
/**
* v1.15.11 W3 — pure reorder helper for the inline Insights edit mode.
*
* Mirrors the `reorderWidgets` contract in
* `src/components/settings/dashboard-layout-section.tsx` (move an item from
* `fromId` next to `toId`, renumber the whole list to a dense 0-based
* `order`) but is generic over any `{ id, order }` row so the section list
* AND the Vitals tile sub-list can both reorder through one tested helper.
*
* Kept as a standalone module (not imported from the dashboard component) so
* the inline-edit surface does not depend on a Settings component, and so the
* helper is unit-testable without spinning up a `DndContext`. The dashboard's
* own `reorderWidgets` stays untouched — extracting it there would have
* touched the stable dashboard surface for no behavioural gain.
*/
export interface OrderableRow {
id: string;
order: number;
}
/**
* Move the row identified by `fromId` to the position of `toId`, then
* renumber every row to a dense 0-based `order`. Returns a NEW array of NEW
* row objects (every input field is preserved via the spread); the input is
* never mutated. A no-op move (same id, or either id missing) still returns a
* densely-renumbered copy so the caller can treat the output as canonical.
*/
export function reorderById<T extends OrderableRow>(
rows: readonly T[],
fromId: string,
toId: string,
): T[] {
const sorted = [...rows].sort((a, b) => a.order - b.order);
const fromIdx = sorted.findIndex((r) => r.id === fromId);
const toIdx = sorted.findIndex((r) => r.id === toId);
if (fromIdx < 0 || toIdx < 0 || fromIdx === toIdx) {
return sorted.map((r, i) => ({ ...r, order: i }));
}
const next = [...sorted];
const [removed] = next.splice(fromIdx, 1);
next.splice(toIdx, 0, removed);
return next.map((r, i) => ({ ...r, order: i }));
}