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 ( +
+
+
+

{t("insights.editMode.title")}

+

+ {t("insights.editMode.description")} +

+
+
+ + +
+
+ + {allHidden && ( +

+ {t("insights.editMode.allHiddenHint")} +

+ )} + + + +
+ {sections.map((section) => { + const gatedOff = gatedOffSectionIds.has(section.id); + const isVitals = section.id === "vitals"; + return ( + + {isVitals && ( +
+ + {tilesOpen && ( + + +
+ {vitalsTiles.map((tile) => ( + + ))} +
+
+
+ )} +
+ )} +
+ ); + })} +
+
+
+
+ ); +} + +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 ( + + ); +} + +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 ( +
+
+ + + {title} + + {gatedOff ? ( + + {labels.gatedHint} + + ) : ( + onToggle(section.id, !section.visible)} + slot="insights-edit-section-eye" + /> + )} +
+ {gatedOff && ( +

+ {labels.gatedHint} +

+ )} + {children} +
+ ); +} + +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 ( +
+ + + {title} + + onToggle(id, !visible)} + slot="insights-edit-tile-eye" + /> +
+ ); +} diff --git a/src/lib/__tests__/insights-layout-reorder.test.ts b/src/lib/__tests__/insights-layout-reorder.test.ts new file mode 100644 index 000000000..f18b5c494 --- /dev/null +++ b/src/lib/__tests__/insights-layout-reorder.test.ts @@ -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"]); + }); +}); diff --git a/src/lib/insights-layout-reorder.ts b/src/lib/insights-layout-reorder.ts new file mode 100644 index 000000000..9de4d4a04 --- /dev/null +++ b/src/lib/insights-layout-reorder.ts @@ -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( + 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 })); +}