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

+18
View File
@@ -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",
+18
View File
@@ -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",
+18
View File
@@ -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",
+18
View File
@@ -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",
+18
View File
@@ -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",
+18
View File
@@ -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",
+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 }));
}