mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
feat(settings): add an Insights section to arrange the overview and sort the nav pills
The Insights overview customise surface now has a home in Settings, analogous to the Dashboard section. It hosts two blocks: - "Arrange the overview" embeds the inline section show/hide + order editor, persisting to insightsLayoutJson.sections. - "Sort the navigation pills" is a dedicated drag-reorder control for the tab-strip pills, persisting to insightsLayoutJson.tiles[].order — a field already separate from the section order in layout v2, so the two concerns stay independent. Both write through the existing /api/insights/layout contract the tab strip and overview share. Wire the new slug into the settings registry, the section router, the mobile/desktop nav, and the placeholder icon map. Adds the section copy and the pill-sort + overview-arrange strings across all six locales.
This commit is contained in:
1 parent
830c847d3b
commit
a58a73608c
14 files changed
+595
-7
No files matched your search
+20
-1
@@ -3041,7 +3041,22 @@
|
||||
},
|
||||
"disclaimer": {
|
||||
"footer": "HealthLog beschreibt deine eigenen erfassten Daten — es ist keine klinische Bewertung und keine Diagnose. Sprich mit deiner Ärztin oder deinem Arzt über alles, was dich beunruhigt."
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"overviewTitle": "Übersicht anordnen",
|
||||
"overviewDescription": "Abschnitte ein- oder ausblenden und ihre Reihenfolge auf der Insights-Übersicht festlegen."
|
||||
},
|
||||
"pillOrder": {
|
||||
"title": "Navigations-Pills sortieren",
|
||||
"description": "Ziehe die Pills in die Reihenfolge, in der sie oben in den Insights erscheinen sollen.",
|
||||
"dragHandle": "Verschieben",
|
||||
"saveSuccess": "Reihenfolge der Pills gespeichert.",
|
||||
"saveError": "Speichern der Pill-Reihenfolge fehlgeschlagen.",
|
||||
"save": "Speichern",
|
||||
"empty": "Noch keine Navigations-Pills verfügbar."
|
||||
},
|
||||
"customize": "Insights anpassen",
|
||||
"regenerateError": "Aktualisierung läuft noch — bitte gleich erneut versuchen."
|
||||
},
|
||||
"thresholds": {
|
||||
"defaultLabel": "Default",
|
||||
@@ -3429,6 +3444,10 @@
|
||||
"sharing": {
|
||||
"title": "Freigabe",
|
||||
"description": "Erstelle einen schreibgeschützten Link, um deine Gesundheitsakte mit einer Ärztin oder einem Arzt zu teilen."
|
||||
},
|
||||
"insights": {
|
||||
"title": "Insights",
|
||||
"description": "Reihenfolge der Übersicht und der Navigations-Pills."
|
||||
}
|
||||
},
|
||||
"profile": "Profil",
|
||||
|
||||
+20
-1
@@ -3041,7 +3041,22 @@
|
||||
},
|
||||
"disclaimer": {
|
||||
"footer": "HealthLog describes your own tracked data — it is not a clinical assessment or diagnosis. Talk to your doctor about anything that concerns you."
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"overviewTitle": "Arrange the overview",
|
||||
"overviewDescription": "Show or hide sections and set their order on the Insights overview."
|
||||
},
|
||||
"pillOrder": {
|
||||
"title": "Sort the navigation pills",
|
||||
"description": "Drag the pills into the order they should appear in along the top of Insights.",
|
||||
"dragHandle": "Reorder",
|
||||
"saveSuccess": "Pill order saved.",
|
||||
"saveError": "Saving the pill order failed.",
|
||||
"save": "Save",
|
||||
"empty": "No navigation pills available yet."
|
||||
},
|
||||
"customize": "Customize Insights",
|
||||
"regenerateError": "Still refreshing — please try again in a moment."
|
||||
},
|
||||
"thresholds": {
|
||||
"defaultLabel": "Default",
|
||||
@@ -3429,6 +3444,10 @@
|
||||
"sharing": {
|
||||
"title": "Sharing",
|
||||
"description": "Create a read-only link to share your health record with a clinician."
|
||||
},
|
||||
"insights": {
|
||||
"title": "Insights",
|
||||
"description": "Overview and navigation-pill order."
|
||||
}
|
||||
},
|
||||
"profile": "Profile",
|
||||
|
||||
+20
-1
@@ -3041,7 +3041,22 @@
|
||||
},
|
||||
"disclaimer": {
|
||||
"footer": "HealthLog describe tus propios datos registrados — no es una evaluación clínica ni un diagnóstico. Habla con tu médico sobre cualquier cosa que te preocupe."
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"overviewTitle": "Organizar la vista general",
|
||||
"overviewDescription": "Muestra u oculta secciones y define su orden en la vista general de Análisis."
|
||||
},
|
||||
"pillOrder": {
|
||||
"title": "Ordenar las pestañas de navegación",
|
||||
"description": "Arrastra las pestañas al orden en que deben aparecer en la parte superior de Análisis.",
|
||||
"dragHandle": "Reordenar",
|
||||
"saveSuccess": "Orden de las pestañas guardado.",
|
||||
"saveError": "No se pudo guardar el orden de las pestañas.",
|
||||
"save": "Guardar",
|
||||
"empty": "Aún no hay pestañas de navegación disponibles."
|
||||
},
|
||||
"customize": "Personalizar Análisis",
|
||||
"regenerateError": "Aún se está actualizando: inténtalo de nuevo en un momento."
|
||||
},
|
||||
"thresholds": {
|
||||
"defaultLabel": "Por defecto",
|
||||
@@ -3429,6 +3444,10 @@
|
||||
"sharing": {
|
||||
"title": "Compartir",
|
||||
"description": "Crea un enlace de solo lectura para compartir tu historial de salud con un profesional médico."
|
||||
},
|
||||
"insights": {
|
||||
"title": "Análisis",
|
||||
"description": "Orden de la vista general y de las pestañas de navegación."
|
||||
}
|
||||
},
|
||||
"profile": "Perfil",
|
||||
|
||||
+20
-1
@@ -3041,7 +3041,22 @@
|
||||
},
|
||||
"disclaimer": {
|
||||
"footer": "HealthLog décrit vos propres données suivies — il ne s'agit pas d'une évaluation clinique ni d'un diagnostic. Parlez à votre médecin de tout ce qui vous préoccupe."
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"overviewTitle": "Organiser la vue d'ensemble",
|
||||
"overviewDescription": "Affichez ou masquez des sections et définissez leur ordre sur la vue d'ensemble des Analyses."
|
||||
},
|
||||
"pillOrder": {
|
||||
"title": "Trier les onglets de navigation",
|
||||
"description": "Faites glisser les onglets dans l'ordre où ils doivent apparaître en haut des Analyses.",
|
||||
"dragHandle": "Réorganiser",
|
||||
"saveSuccess": "Ordre des onglets enregistré.",
|
||||
"saveError": "L'enregistrement de l'ordre des onglets a échoué.",
|
||||
"save": "Enregistrer",
|
||||
"empty": "Aucun onglet de navigation disponible pour le moment."
|
||||
},
|
||||
"customize": "Personnaliser les Analyses",
|
||||
"regenerateError": "Actualisation en cours — réessayez dans un instant."
|
||||
},
|
||||
"thresholds": {
|
||||
"defaultLabel": "Par défaut",
|
||||
@@ -3429,6 +3444,10 @@
|
||||
"sharing": {
|
||||
"title": "Partage",
|
||||
"description": "Créez un lien en lecture seule pour partager votre dossier de santé avec un professionnel de santé."
|
||||
},
|
||||
"insights": {
|
||||
"title": "Analyses",
|
||||
"description": "Ordre de la vue d'ensemble et des onglets de navigation."
|
||||
}
|
||||
},
|
||||
"profile": "Profil",
|
||||
|
||||
+20
-1
@@ -3041,7 +3041,22 @@
|
||||
},
|
||||
"disclaimer": {
|
||||
"footer": "HealthLog descrive i tuoi dati registrati — non è una valutazione clinica né una diagnosi. Parla con il tuo medico di qualsiasi cosa ti preoccupi."
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"overviewTitle": "Organizza la panoramica",
|
||||
"overviewDescription": "Mostra o nascondi le sezioni e definisci il loro ordine nella panoramica degli Approfondimenti."
|
||||
},
|
||||
"pillOrder": {
|
||||
"title": "Ordina le schede di navigazione",
|
||||
"description": "Trascina le schede nell'ordine in cui devono comparire in alto negli Approfondimenti.",
|
||||
"dragHandle": "Riordina",
|
||||
"saveSuccess": "Ordine delle schede salvato.",
|
||||
"saveError": "Salvataggio dell'ordine delle schede non riuscito.",
|
||||
"save": "Salva",
|
||||
"empty": "Nessuna scheda di navigazione ancora disponibile."
|
||||
},
|
||||
"customize": "Personalizza gli Approfondimenti",
|
||||
"regenerateError": "Aggiornamento ancora in corso — riprova tra un momento."
|
||||
},
|
||||
"thresholds": {
|
||||
"defaultLabel": "Predefinito",
|
||||
@@ -3429,6 +3444,10 @@
|
||||
"sharing": {
|
||||
"title": "Condivisione",
|
||||
"description": "Crea un link di sola lettura per condividere la tua cartella clinica con un medico."
|
||||
},
|
||||
"insights": {
|
||||
"title": "Approfondimenti",
|
||||
"description": "Ordine della panoramica e delle schede di navigazione."
|
||||
}
|
||||
},
|
||||
"profile": "Profilo",
|
||||
|
||||
+20
-1
@@ -3041,7 +3041,22 @@
|
||||
},
|
||||
"disclaimer": {
|
||||
"footer": "HealthLog opisuje Twoje własne zarejestrowane dane — nie jest oceną kliniczną ani diagnozą. Porozmawiaj z lekarzem o wszystkim, co Cię niepokoi."
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"overviewTitle": "Uporządkuj przegląd",
|
||||
"overviewDescription": "Pokaż lub ukryj sekcje i ustaw ich kolejność w przeglądzie Analiz."
|
||||
},
|
||||
"pillOrder": {
|
||||
"title": "Sortuj pigułki nawigacyjne",
|
||||
"description": "Przeciągnij pigułki w kolejności, w jakiej mają się pojawiać u góry Analiz.",
|
||||
"dragHandle": "Zmień kolejność",
|
||||
"saveSuccess": "Zapisano kolejność pigułek.",
|
||||
"saveError": "Zapisanie kolejności pigułek nie powiodło się.",
|
||||
"save": "Zapisz",
|
||||
"empty": "Brak dostępnych pigułek nawigacyjnych."
|
||||
},
|
||||
"customize": "Dostosuj Analizy",
|
||||
"regenerateError": "Trwa odświeżanie — spróbuj ponownie za chwilę."
|
||||
},
|
||||
"thresholds": {
|
||||
"defaultLabel": "Domyślne",
|
||||
@@ -3429,6 +3444,10 @@
|
||||
"sharing": {
|
||||
"title": "Udostępnianie",
|
||||
"description": "Utwórz link tylko do odczytu, aby udostępnić swoją dokumentację zdrowotną lekarzowi."
|
||||
},
|
||||
"insights": {
|
||||
"title": "Analizy",
|
||||
"description": "Kolejność przeglądu i pigułek nawigacyjnych."
|
||||
}
|
||||
},
|
||||
"profile": "Profil",
|
||||
|
||||
@@ -8,6 +8,7 @@ import { AiSection } from "@/components/settings/ai-section";
|
||||
import { ApiSection } from "@/components/settings/api-section";
|
||||
import { DashboardSection } from "@/components/settings/dashboard-section";
|
||||
import { ExportSection } from "@/components/settings/export-section";
|
||||
import { InsightsSection } from "@/components/settings/insights-section";
|
||||
import { IntegrationsSection } from "@/components/settings/integrations-section";
|
||||
import { NotificationsSection } from "@/components/settings/notifications-section";
|
||||
import { SectionPlaceholder } from "@/components/settings/section-placeholder";
|
||||
@@ -49,6 +50,7 @@ const SECTION_COMPONENTS: Record<
|
||||
integrations: IntegrationsSection,
|
||||
notifications: NotificationsSection,
|
||||
dashboard: DashboardSection,
|
||||
insights: InsightsSection,
|
||||
thresholds: ThresholdsSection,
|
||||
sources: () => <SourcesSection />,
|
||||
api: ApiSection,
|
||||
|
||||
@@ -35,7 +35,7 @@ function renderShell(props: {
|
||||
}
|
||||
|
||||
describe("SETTINGS_SECTION_SLUGS", () => {
|
||||
it("declares the twelve sections", () => {
|
||||
it("declares every section", () => {
|
||||
// Order matters — `generateStaticParams()` and the sidebar derive their
|
||||
// ordering from this constant, so a reorder is a behaviour change.
|
||||
// v1.4.3 split the dashboard panel: layout stays under `dashboard`,
|
||||
@@ -48,11 +48,14 @@ describe("SETTINGS_SECTION_SLUGS", () => {
|
||||
// IW-D merged it into `thresholds`.
|
||||
// v1.8.7.1 — `sources` (Sources) is its own slug again, sitting
|
||||
// between `thresholds` (Targets) and `ai`.
|
||||
// v1.15.18 — `insights` sits between `dashboard` and `thresholds`: the
|
||||
// overview-arrange + pill-sort customise surface for `/insights`.
|
||||
expect([...SETTINGS_SECTION_SLUGS]).toEqual([
|
||||
"account",
|
||||
"integrations",
|
||||
"notifications",
|
||||
"dashboard",
|
||||
"insights",
|
||||
"thresholds",
|
||||
"sources",
|
||||
"ai",
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useFeatureFlags } from "@/hooks/use-feature-flags";
|
||||
import { useInsightsLayoutQuery } from "@/hooks/use-insights-layout";
|
||||
import { InsightsEditMode } from "@/components/insights/insights-edit-mode";
|
||||
import { type InsightsSectionId } from "@/lib/insights-layout";
|
||||
|
||||
/**
|
||||
* v1.15.18 — overview-arrange block for the Insights settings section.
|
||||
*
|
||||
* Embeds the existing v1.15.11 `<InsightsEditMode>` (section show/hide + order,
|
||||
* persisted to `insightsLayoutJson.sections`) as an always-open surface. There
|
||||
* is no "Anpassen" toggle here — Settings IS the customise surface — so `onClose`
|
||||
* is a no-op (after a "Fertig" save the layout query has already settled into the
|
||||
* shared cache; the editor simply stays mounted).
|
||||
*
|
||||
* `gatedOffSectionIds` mirrors the mother page's gate logic so a section whose
|
||||
* feature flag / data gate is off renders its row disabled-with-a-hint rather
|
||||
* than offering a toggle that does nothing.
|
||||
*/
|
||||
export function InsightsOverviewArrangeSection({ id }: { id?: string }) {
|
||||
const { t } = useTranslations();
|
||||
const { isAuthenticated, user } = useAuth();
|
||||
const flags = useFeatureFlags();
|
||||
const { layout, isLoading } = useInsightsLayoutQuery(isAuthenticated);
|
||||
|
||||
const gatedOffSectionIds = useMemo(() => {
|
||||
const gated = new Set<InsightsSectionId>();
|
||||
if (!flags.briefing) {
|
||||
gated.add("daily-briefing");
|
||||
gated.add("period-review");
|
||||
}
|
||||
if (!user?.cycleTrackingEnabled) {
|
||||
gated.add("cycle-summary");
|
||||
}
|
||||
return gated;
|
||||
}, [flags.briefing, user?.cycleTrackingEnabled]);
|
||||
|
||||
return (
|
||||
<section
|
||||
id={id}
|
||||
data-slot="insights-overview-arrange-section"
|
||||
aria-labelledby="insights-overview-arrange-title"
|
||||
className="space-y-3"
|
||||
>
|
||||
<header className="space-y-1">
|
||||
<h2
|
||||
id="insights-overview-arrange-title"
|
||||
className="text-lg font-semibold"
|
||||
>
|
||||
{t("insights.settings.overviewTitle")}
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("insights.settings.overviewDescription")}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{/* Gate the editor mount until the layout GET settles. The editor seeds
|
||||
its draft once from `layout` on mount, which is the canonical default
|
||||
while in flight; mounting it early would let a "Fertig" save flush
|
||||
defaults over the user's real saved layout (the same QA-L1 gate the
|
||||
mother page applies to its "Anpassen" toggle). */}
|
||||
{isLoading ? (
|
||||
<div
|
||||
className="bg-card border-border text-muted-foreground flex items-center gap-2 rounded-xl border p-4 text-sm sm:p-6"
|
||||
data-slot="insights-overview-arrange-loading"
|
||||
>
|
||||
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
</div>
|
||||
) : (
|
||||
<InsightsEditMode
|
||||
layout={layout}
|
||||
gatedOffSectionIds={gatedOffSectionIds}
|
||||
onClose={() => {}}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { GripVertical, Loader2 } 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 { useAuth } from "@/hooks/use-auth";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { useInsightsLayoutQuery } from "@/hooks/use-insights-layout";
|
||||
import { reorderById } from "@/lib/insights-layout-reorder";
|
||||
import {
|
||||
type InsightsLayout,
|
||||
type InsightsTileConfig,
|
||||
} from "@/lib/insights-layout";
|
||||
import {
|
||||
MANAGER_GROUP_ORDER,
|
||||
SUB_PAGE_MANAGER_GROUP_SLUGS,
|
||||
type ManagerGroup,
|
||||
} from "@/lib/insights/sub-page-metric";
|
||||
import { SUB_PAGE_TABS } from "@/components/insights/insights-tab-strip";
|
||||
|
||||
/**
|
||||
* v1.15.18 — dedicated pill-sort control for the Insights settings section.
|
||||
*
|
||||
* The Insights nav PILLS persist to `insightsLayoutJson.tiles[].order`, a field
|
||||
* that is ALREADY separate from the overview `sections[].order` in layout v2.
|
||||
* Until now the pill order could only be changed implicitly through the
|
||||
* overview "Anpassen" → "Kacheln verwalten" disclosure (the Vitals row), which
|
||||
* the maintainer found unintuitive. This is the explicit surface: drag the
|
||||
* pills, "Speichern" PUTs the merged `{ tiles }` blob to `/api/insights/layout`,
|
||||
* which the tab strip + overview share via `queryKeys.insightsLayout()`.
|
||||
*
|
||||
* Drag mirrors `insights-edit-mode` — each manager group is its OWN
|
||||
* `DndContext` + `SortableContext` so a drag never crosses a group boundary.
|
||||
* The pills here are NOT visibility toggles; visibility lives on the overview
|
||||
* "Übersicht anordnen" block so the two concerns stay distinct.
|
||||
*/
|
||||
|
||||
const MANAGER_GROUP_HEADER_KEYS: Record<ManagerGroup, string> = {
|
||||
vitals: "insights.tabStrip.vitalsParent.label",
|
||||
body: "insights.tabStrip.bodyParent.label",
|
||||
activity: "insights.tabStrip.activityParent.label",
|
||||
sleep: "insights.editMode.groupSleep",
|
||||
cardiovascular: "insights.tabStrip.cardiovascularParent.label",
|
||||
hearing: "insights.tabStrip.hearingParent.label",
|
||||
environment: "insights.tabStrip.environmentParent.label",
|
||||
metabolic: "insights.tabStrip.metabolicParent.label",
|
||||
mood: "insights.editMode.groupMood",
|
||||
events: "insights.editMode.groupEvents",
|
||||
};
|
||||
|
||||
/** Stable id-order fingerprint used to detect when the server copy changed. */
|
||||
function orderSignature(tiles: readonly InsightsTileConfig[]): string {
|
||||
return [...tiles]
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map((tile) => tile.id)
|
||||
.join(",");
|
||||
}
|
||||
|
||||
export function InsightsPillOrderSection({ id }: { id?: string }) {
|
||||
const { t } = useTranslations();
|
||||
const { isAuthenticated } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
const { layout, isLoading, isSuccess } =
|
||||
useInsightsLayoutQuery(isAuthenticated);
|
||||
|
||||
// Local draft of the tiles, seeded from the resolved layout and re-seeded in
|
||||
// render (the React-sanctioned "adjust state on prop change" pattern, no
|
||||
// effect) whenever the server copy's id-order changes — e.g. after a save or
|
||||
// once the GET settles. Tracking the last-seeded signature in STATE (not a
|
||||
// ref) keeps the reseed render-safe and idempotent while leaving in-flight
|
||||
// drag edits untouched: the baseline only advances when the *server* order
|
||||
// differs from what we last seeded from.
|
||||
const serverSignature = orderSignature(layout.tiles);
|
||||
const [seededSignature, setSeededSignature] = useState(serverSignature);
|
||||
const [draftTiles, setDraftTiles] = useState<InsightsTileConfig[]>(
|
||||
() => [...layout.tiles].sort((a, b) => a.order - b.order),
|
||||
);
|
||||
if (isSuccess && serverSignature !== seededSignature) {
|
||||
setSeededSignature(serverSignature);
|
||||
setDraftTiles([...layout.tiles].sort((a, b) => a.order - b.order));
|
||||
}
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
}),
|
||||
);
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: async (tiles: InsightsTileConfig[]) => {
|
||||
const res = await fetch("/api/insights/layout", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
version: 2,
|
||||
// Preserve the overview section order/visibility verbatim — this
|
||||
// surface owns the PILL order only.
|
||||
sections: layout.sections,
|
||||
tiles,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error("save failed");
|
||||
return (await res.json()).data as InsightsLayout;
|
||||
},
|
||||
onSuccess: (saved) => {
|
||||
queryClient.setQueryData(queryKeys.insightsLayout(), saved);
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.insightsLayout(),
|
||||
});
|
||||
toast.success(t("insights.pillOrder.saveSuccess"));
|
||||
},
|
||||
onError: () => toast.error(t("insights.pillOrder.saveError")),
|
||||
});
|
||||
|
||||
const busy = saveMutation.isPending;
|
||||
|
||||
// Group the draft tiles the same way the tab strip + manager do, so each
|
||||
// group's pills sort within their own `SortableContext`.
|
||||
const tilesByGroup = useMemo(() => {
|
||||
const byId = new Map<string, InsightsTileConfig>(
|
||||
draftTiles.map((tile) => [tile.id, tile]),
|
||||
);
|
||||
return MANAGER_GROUP_ORDER.map((group) => {
|
||||
const rows = SUB_PAGE_MANAGER_GROUP_SLUGS[group]
|
||||
.map((slug) => {
|
||||
const cfg = byId.get(slug);
|
||||
return {
|
||||
id: slug as string,
|
||||
labelKey: SUB_PAGE_TABS[slug].labelKey,
|
||||
order: cfg?.order ?? Number.MAX_SAFE_INTEGER,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => a.order - b.order);
|
||||
return { group, rows };
|
||||
}).filter((g) => g.rows.length > 0);
|
||||
}, [draftTiles]);
|
||||
|
||||
function handleGroupDragEnd(group: ManagerGroup, event: DragEndEvent) {
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
const groupRows = tilesByGroup.find((g) => g.group === group)?.rows ?? [];
|
||||
const groupMemberIds = new Set(groupRows.map((r) => r.id));
|
||||
const subset = groupRows.map((r) => ({ id: r.id, order: r.order }));
|
||||
const reordered = reorderById(subset, String(active.id), String(over.id));
|
||||
const reorderedIds = reordered.map((r) => r.id);
|
||||
|
||||
setDraftTiles((tiles) => {
|
||||
// Substitute this group's slots in their new relative order, leaving
|
||||
// every other tile (and its order) untouched. Walk the tile list and,
|
||||
// at each group-member slot, take the next id from the reordered list.
|
||||
let cursor = 0;
|
||||
return tiles.map((tile) => {
|
||||
if (!groupMemberIds.has(tile.id)) return tile;
|
||||
const nextId = reorderedIds[cursor++];
|
||||
const replacement = tiles.find((tt) => tt.id === nextId);
|
||||
// Keep this slot's `order` and `visible`; swap the id into position.
|
||||
return replacement
|
||||
? { ...replacement, order: tile.order }
|
||||
: tile;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const dirty = useMemo(() => {
|
||||
const original = [...layout.tiles].sort((a, b) => a.order - b.order);
|
||||
if (original.length !== draftTiles.length) return true;
|
||||
const draftByOrder = [...draftTiles].sort((a, b) => a.order - b.order);
|
||||
return original.some((tt, i) => tt.id !== draftByOrder[i]?.id);
|
||||
}, [layout.tiles, draftTiles]);
|
||||
|
||||
return (
|
||||
<section
|
||||
id={id}
|
||||
data-slot="insights-pill-order-section"
|
||||
aria-labelledby="insights-pill-order-title"
|
||||
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-start sm:justify-between sm:gap-3">
|
||||
<div className="space-y-1">
|
||||
<h2
|
||||
id="insights-pill-order-title"
|
||||
className="text-lg font-semibold"
|
||||
>
|
||||
{t("insights.pillOrder.title")}
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("insights.pillOrder.description")}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => saveMutation.mutate(draftTiles)}
|
||||
disabled={busy || !dirty}
|
||||
data-slot="insights-pill-order-save"
|
||||
className="self-end sm:self-auto"
|
||||
>
|
||||
{saveMutation.isPending && (
|
||||
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
)}
|
||||
{t("insights.pillOrder.save")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<p
|
||||
className="text-muted-foreground text-sm"
|
||||
data-slot="insights-pill-order-loading"
|
||||
>
|
||||
<Loader2 className="mr-2 inline h-3.5 w-3.5 animate-spin align-text-bottom motion-reduce:animate-none" />
|
||||
</p>
|
||||
) : tilesByGroup.length === 0 ? (
|
||||
<p
|
||||
className="text-muted-foreground text-sm"
|
||||
data-slot="insights-pill-order-empty"
|
||||
>
|
||||
{t("insights.pillOrder.empty")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="max-h-[60vh] space-y-3 overflow-y-auto overscroll-contain">
|
||||
{tilesByGroup.map(({ group, rows }) => (
|
||||
<div
|
||||
key={group}
|
||||
data-slot="insights-pill-order-group"
|
||||
data-group={group}
|
||||
>
|
||||
<p className="text-muted-foreground px-1 py-1 text-[11px] font-semibold tracking-wide uppercase">
|
||||
{t(MANAGER_GROUP_HEADER_KEYS[group])}
|
||||
</p>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={(event) => handleGroupDragEnd(group, event)}
|
||||
>
|
||||
<SortableContext
|
||||
items={rows.map((r) => r.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<div className="space-y-1.5">
|
||||
{rows.map((tile) => (
|
||||
<SortablePillRow
|
||||
key={tile.id}
|
||||
id={tile.id}
|
||||
title={t(tile.labelKey)}
|
||||
disabled={busy}
|
||||
dragHandleLabel={t("insights.pillOrder.dragHandle")}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
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 SortablePillRow({
|
||||
id,
|
||||
title,
|
||||
disabled,
|
||||
dragHandleLabel,
|
||||
}: {
|
||||
id: string;
|
||||
title: string;
|
||||
disabled: boolean;
|
||||
dragHandleLabel: string;
|
||||
}) {
|
||||
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-pill-order-row"
|
||||
data-tile={id}
|
||||
data-dragging={isDragging ? "true" : undefined}
|
||||
className={cn(
|
||||
"border-border bg-background/30 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={`${dragHandleLabel} — ${title}`}
|
||||
title={dragHandleLabel}
|
||||
disabled={disabled}
|
||||
data-slot="insights-pill-order-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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
"use client";
|
||||
|
||||
import { InsightsOverviewArrangeSection } from "@/components/settings/insights-overview-arrange-section";
|
||||
import { InsightsPillOrderSection } from "@/components/settings/insights-pill-order-section";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
/**
|
||||
* v1.15.18 — the "Insights" settings section (analogous to the Dashboard
|
||||
* section). Hosts two blocks:
|
||||
* 1. Arrange the OVERVIEW — section show/hide + order, persisted to
|
||||
* `insightsLayoutJson.sections` (the v1.15.11 inline edit mode).
|
||||
* 2. Sort the navigation PILLS — pill order, persisted to
|
||||
* `insightsLayoutJson.tiles[].order` (already a separate field in v2).
|
||||
*
|
||||
* Both write through the same `/api/insights/layout` contract the tab strip
|
||||
* and the overview share, so a save here repaints both surfaces in lockstep.
|
||||
*/
|
||||
export function InsightsSection() {
|
||||
const { t } = useTranslations();
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-labelledby="settings-section-insights-title"
|
||||
className="space-y-6"
|
||||
>
|
||||
<header className="space-y-1">
|
||||
<h1
|
||||
id="settings-section-insights-title"
|
||||
className="text-2xl font-semibold tracking-tight"
|
||||
>
|
||||
{t("settings.sections.insights.title")}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("settings.sections.insights.description")}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<InsightsOverviewArrangeSection id="insights-overview-arrange" />
|
||||
<InsightsPillOrderSection id="insights-pill-order" />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
Settings2,
|
||||
SlidersHorizontal,
|
||||
Sparkles,
|
||||
TrendingUp,
|
||||
User,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
@@ -35,6 +36,7 @@ const SLUG_ICON: Record<SettingsSectionSlug, LucideIcon> = {
|
||||
integrations: Link2,
|
||||
notifications: Bell,
|
||||
dashboard: LayoutDashboard,
|
||||
insights: TrendingUp,
|
||||
thresholds: SlidersHorizontal,
|
||||
sources: Layers,
|
||||
ai: Sparkles,
|
||||
|
||||
@@ -18,6 +18,7 @@ export const SETTINGS_SECTION_SLUGS = [
|
||||
"integrations",
|
||||
"notifications",
|
||||
"dashboard",
|
||||
"insights",
|
||||
"thresholds",
|
||||
"sources",
|
||||
"ai",
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
Settings2,
|
||||
SlidersHorizontal,
|
||||
Sparkles,
|
||||
TrendingUp,
|
||||
User,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
@@ -97,6 +98,11 @@ export const SETTINGS_SECTIONS: readonly SettingsSection[] = [
|
||||
titleKey: "settings.sections.dashboard.title",
|
||||
icon: LayoutDashboard,
|
||||
},
|
||||
{
|
||||
slug: "insights",
|
||||
titleKey: "settings.sections.insights.title",
|
||||
icon: TrendingUp,
|
||||
},
|
||||
{
|
||||
slug: "thresholds",
|
||||
titleKey: "settings.sections.thresholds.title",
|
||||
|
||||
Reference in new issue
Block a user