diff --git a/messages/de.json b/messages/de.json
index 431fa1863..6e28dbb65 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -1584,6 +1584,24 @@
"emptyTitle": "Noch nicht genug Daten",
"emptyDescription": "Insights erscheinen, sobald du ein paar Messwerte erfasst hast. Beginne mit Gewicht oder Blutdruck.",
"emptyAddMeasurement": "Messwert erfassen",
+ "editMode": {
+ "open": "Anpassen",
+ "done": "Fertig",
+ "reset": "Zurücksetzen",
+ "title": "Übersicht anpassen",
+ "description": "Zum Sortieren ziehen, zum Ein- oder Ausblenden auf das Auge tippen.",
+ "dragHandle": "Zum Sortieren ziehen",
+ "show": "Einblenden",
+ "hide": "Ausblenden",
+ "manageTiles": "Kacheln verwalten",
+ "gatedHint": "Erscheint, sobald Daten oder die Funktion verfügbar sind",
+ "allHiddenHint": "Alle Bereiche sind ausgeblendet. Blende mindestens einen ein, um deine Übersicht zu sehen.",
+ "saveSuccess": "Übersicht gespeichert",
+ "saveError": "Übersicht konnte nicht gespeichert werden",
+ "resetSuccess": "Übersicht auf Standard zurückgesetzt",
+ "emptyTitle": "Alle Karten ausgeblendet",
+ "emptyDescription": "Du hast alle Bereiche ausgeblendet. Öffne „Anpassen“, um Karten wieder einzublenden."
+ },
"bmiEmptyTitle": "Größe hinzufügen für BMI",
"bmiEmptyDescription": "Für den BMI wird deine Körpergröße benötigt. Hinterlege sie unter Einstellungen → Account.",
"bmiEmptyAction": "Account-Einstellungen öffnen",
diff --git a/messages/en.json b/messages/en.json
index b83e09a1d..350e1e68a 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -1586,6 +1586,24 @@
"emptyTitle": "Not enough data yet",
"emptyDescription": "Insights light up once you've logged a handful of measurements. Start with weight or blood pressure.",
"emptyAddMeasurement": "Add a measurement",
+ "editMode": {
+ "open": "Customize",
+ "done": "Done",
+ "reset": "Reset",
+ "title": "Customize overview",
+ "description": "Drag to reorder, tap the eye to show or hide.",
+ "dragHandle": "Drag to reorder",
+ "show": "Show",
+ "hide": "Hide",
+ "manageTiles": "Manage tiles",
+ "gatedHint": "Appears once data or the feature is available",
+ "allHiddenHint": "Every section is hidden. Show at least one to see your overview.",
+ "saveSuccess": "Overview layout saved",
+ "saveError": "Could not save your overview layout",
+ "resetSuccess": "Overview reset to defaults",
+ "emptyTitle": "Every card is hidden",
+ "emptyDescription": "You've hidden every section. Open customize to bring cards back."
+ },
"bmiEmptyTitle": "Set your height to see BMI",
"bmiEmptyDescription": "BMI needs your height. Add it under Settings → Account.",
"bmiEmptyAction": "Open account settings",
diff --git a/messages/es.json b/messages/es.json
index 13e215365..33284f26e 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -1586,6 +1586,24 @@
"emptyTitle": "Aún no hay datos suficientes",
"emptyDescription": "Los insights aparecen en cuanto registres algunas mediciones. Empieza por peso o tensión arterial.",
"emptyAddMeasurement": "Registrar medición",
+ "editMode": {
+ "open": "Personalizar",
+ "done": "Listo",
+ "reset": "Restablecer",
+ "title": "Personalizar resumen",
+ "description": "Arrastra para reordenar, toca el ojo para mostrar u ocultar.",
+ "dragHandle": "Arrastra para reordenar",
+ "show": "Mostrar",
+ "hide": "Ocultar",
+ "manageTiles": "Gestionar tarjetas",
+ "gatedHint": "Aparece cuando haya datos o la función esté disponible",
+ "allHiddenHint": "Todas las secciones están ocultas. Muestra al menos una para ver tu resumen.",
+ "saveSuccess": "Resumen guardado",
+ "saveError": "No se pudo guardar tu resumen",
+ "resetSuccess": "Resumen restablecido a los valores predeterminados",
+ "emptyTitle": "Todas las tarjetas están ocultas",
+ "emptyDescription": "Has ocultado todas las secciones. Abre Personalizar para volver a mostrarlas."
+ },
"bmiEmptyTitle": "Añade tu altura para calcular el IMC",
"bmiEmptyDescription": "El IMC necesita tu altura. Guárdala en Ajustes → Cuenta.",
"bmiEmptyAction": "Abrir ajustes de cuenta",
diff --git a/messages/fr.json b/messages/fr.json
index 4481bde38..b34577c5b 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -1586,6 +1586,24 @@
"emptyTitle": "Pas encore assez de données",
"emptyDescription": "Les insights apparaissent dès que tu auras saisi quelques mesures. Commence par le poids ou la tension.",
"emptyAddMeasurement": "Saisir une mesure",
+ "editMode": {
+ "open": "Personnaliser",
+ "done": "Terminé",
+ "reset": "Réinitialiser",
+ "title": "Personnaliser l’aperçu",
+ "description": "Fais glisser pour réorganiser, touche l’œil pour afficher ou masquer.",
+ "dragHandle": "Fais glisser pour réorganiser",
+ "show": "Afficher",
+ "hide": "Masquer",
+ "manageTiles": "Gérer les tuiles",
+ "gatedHint": "Apparaît dès que des données ou la fonction sont disponibles",
+ "allHiddenHint": "Toutes les sections sont masquées. Affiches-en au moins une pour voir ton aperçu.",
+ "saveSuccess": "Aperçu enregistré",
+ "saveError": "Impossible d’enregistrer ton aperçu",
+ "resetSuccess": "Aperçu réinitialisé aux valeurs par défaut",
+ "emptyTitle": "Toutes les cartes sont masquées",
+ "emptyDescription": "Tu as masqué toutes les sections. Ouvre Personnaliser pour les réafficher."
+ },
"bmiEmptyTitle": "Ajoute ta taille pour l’IMC",
"bmiEmptyDescription": "L’IMC nécessite ta taille. Renseigne-la dans Paramètres → Compte.",
"bmiEmptyAction": "Ouvrir les paramètres du compte",
diff --git a/messages/it.json b/messages/it.json
index dc804f209..cbb8ccfb5 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -1586,6 +1586,24 @@
"emptyTitle": "Dati ancora insufficienti",
"emptyDescription": "Gli insights compaiono appena registri qualche misurazione. Inizia da peso o pressione.",
"emptyAddMeasurement": "Registra misurazione",
+ "editMode": {
+ "open": "Personalizza",
+ "done": "Fine",
+ "reset": "Reimposta",
+ "title": "Personalizza panoramica",
+ "description": "Trascina per riordinare, tocca l’occhio per mostrare o nascondere.",
+ "dragHandle": "Trascina per riordinare",
+ "show": "Mostra",
+ "hide": "Nascondi",
+ "manageTiles": "Gestisci riquadri",
+ "gatedHint": "Compare quando i dati o la funzione sono disponibili",
+ "allHiddenHint": "Tutte le sezioni sono nascoste. Mostrane almeno una per vedere la panoramica.",
+ "saveSuccess": "Panoramica salvata",
+ "saveError": "Impossibile salvare la panoramica",
+ "resetSuccess": "Panoramica reimpostata ai valori predefiniti",
+ "emptyTitle": "Tutte le schede sono nascoste",
+ "emptyDescription": "Hai nascosto tutte le sezioni. Apri Personalizza per mostrarle di nuovo."
+ },
"bmiEmptyTitle": "Aggiungi l’altezza per il BMI",
"bmiEmptyDescription": "Per il BMI serve la tua altezza. Salvala in Impostazioni → Account.",
"bmiEmptyAction": "Apri impostazioni account",
diff --git a/messages/pl.json b/messages/pl.json
index 36aab26fc..00c462378 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -1586,6 +1586,24 @@
"emptyTitle": "Jeszcze za mało danych",
"emptyDescription": "Insights pojawią się, gdy zarejestrujesz kilka pomiarów. Zacznij od wagi lub ciśnienia.",
"emptyAddMeasurement": "Zarejestruj pomiar",
+ "editMode": {
+ "open": "Dostosuj",
+ "done": "Gotowe",
+ "reset": "Przywróć",
+ "title": "Dostosuj przegląd",
+ "description": "Przeciągnij, aby zmienić kolejność, dotknij oka, aby pokazać lub ukryć.",
+ "dragHandle": "Przeciągnij, aby zmienić kolejność",
+ "show": "Pokaż",
+ "hide": "Ukryj",
+ "manageTiles": "Zarządzaj kafelkami",
+ "gatedHint": "Pojawi się, gdy dane lub funkcja będą dostępne",
+ "allHiddenHint": "Wszystkie sekcje są ukryte. Pokaż co najmniej jedną, aby zobaczyć przegląd.",
+ "saveSuccess": "Przegląd zapisany",
+ "saveError": "Nie udało się zapisać przeglądu",
+ "resetSuccess": "Przegląd przywrócony do ustawień domyślnych",
+ "emptyTitle": "Wszystkie karty są ukryte",
+ "emptyDescription": "Ukryto wszystkie sekcje. Otwórz Dostosuj, aby je ponownie pokazać."
+ },
"bmiEmptyTitle": "Dodaj wzrost, aby obliczyć BMI",
"bmiEmptyDescription": "BMI wymaga twojego wzrostu. Zapisz go w Ustawieniach → Konto.",
"bmiEmptyAction": "Otwórz ustawienia konta",
diff --git a/src/app/insights/page.tsx b/src/app/insights/page.tsx
index dab06ea3f..76e624c59 100644
--- a/src/app/insights/page.tsx
+++ b/src/app/insights/page.tsx
@@ -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": ,
};
+ // 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();
+ 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) => (
- {SECTION_REGISTRY[id]}
- ))}
+ {/* 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). */}
+
+
+
+
+ {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. */
+ 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. */
+ }
+ title={t("insights.editMode.emptyTitle")}
+ description={t("insights.editMode.emptyDescription")}
+ action={
+
+ }
+ />
+ ) : (
+ /* 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) => (
+ {SECTION_REGISTRY[id]}
+ ))
+ )}
);
}
diff --git a/src/components/insights/insights-edit-mode.tsx b/src/components/insights/insights-edit-mode.tsx
new file mode 100644
index 000000000..ec59a40af
--- /dev/null
+++ b/src/components/insights/insights-edit-mode.tsx
@@ -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 = {
+ "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;
+ /** 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(() => ({
+ 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(
+ 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(
+ 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 (
+