diff --git a/messages/de.json b/messages/de.json
index c09d62ccd..654e9d673 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -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",
diff --git a/messages/en.json b/messages/en.json
index a8d9cf9a4..d5bc6e7fe 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -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",
diff --git a/messages/es.json b/messages/es.json
index d11008a7e..acfecf876 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -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",
diff --git a/messages/fr.json b/messages/fr.json
index ab8aaf052..0c3954a1b 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -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",
diff --git a/messages/it.json b/messages/it.json
index 1df6a9855..093d200f5 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -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",
diff --git a/messages/pl.json b/messages/pl.json
index bd5133174..4f43e1734 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -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",
diff --git a/src/app/settings/[section]/page.tsx b/src/app/settings/[section]/page.tsx
index 38e5fadae..ad7eb5f22 100644
--- a/src/app/settings/[section]/page.tsx
+++ b/src/app/settings/[section]/page.tsx
@@ -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: () => ,
api: ApiSection,
diff --git a/src/components/settings/__tests__/settings-shell.test.tsx b/src/components/settings/__tests__/settings-shell.test.tsx
index dae7fd79a..b55afb91f 100644
--- a/src/components/settings/__tests__/settings-shell.test.tsx
+++ b/src/components/settings/__tests__/settings-shell.test.tsx
@@ -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",
diff --git a/src/components/settings/insights-overview-arrange-section.tsx b/src/components/settings/insights-overview-arrange-section.tsx
new file mode 100644
index 000000000..35b3fd4b9
--- /dev/null
+++ b/src/components/settings/insights-overview-arrange-section.tsx
@@ -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 `` (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();
+ 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 (
+
+
+
+ {t("insights.settings.overviewTitle")}
+
+
+ {t("insights.settings.overviewDescription")}
+
+
+
+ {/* 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 ? (
+
+
+
+ ) : (
+ {}}
+ />
+ )}
+
+ );
+}
diff --git a/src/components/settings/insights-pill-order-section.tsx b/src/components/settings/insights-pill-order-section.tsx
new file mode 100644
index 000000000..186042d10
--- /dev/null
+++ b/src/components/settings/insights-pill-order-section.tsx
@@ -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 = {
+ 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(
+ () => [...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(
+ 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 (
+
+
+ );
+}
diff --git a/src/components/settings/insights-section.tsx b/src/components/settings/insights-section.tsx
new file mode 100644
index 000000000..355de8554
--- /dev/null
+++ b/src/components/settings/insights-section.tsx
@@ -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 (
+
+
+