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

@@ -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>
);
}