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:
Marc-André Bombeck committed 2026-06-08 18:48:20 +02:00
1 parent 830c847d3b
commit a58a73608c
14 files changed
+595 -7

No files matched your search

+20 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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",
+2
View File
@@ -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,
+1
View File
@@ -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",