From 25b5833b11e3ba73c581156da8195d5f67bd0787 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 23 Oct 2026 12:52:36 +0200 Subject: [PATCH] refactor(settings): split the dashboard layout section into draft, lists and hero settings DashboardLayoutSection was a 620-line component. Its parts move to components/settings/dashboard-layout/: - layout-model: the reorder and hero-kind helpers and the label maps - use-dashboard-layout-draft: the tokened read, the draft, save and reset (with the snapshot and digest refresh) and the draft edits - hero-settings-fieldset: the top card's switch, lead and highlights - widget-lists: the sortable web list, the native-only group and rows The section composes them. Markup and behaviour are unchanged. --- .../baselines/source-file-lines.json | 5 +- .../baselines/source-function-lines.json | 5 +- .../baselines/version-stamp-comments.json | 5 +- .../destructive-control-guard.test.ts | 3 +- .../success-affordance-guard.test.ts | 2 +- .../dashboard-layout-section.test.tsx | 4 +- ...board-layout-snapshot-invalidation.test.ts | 5 +- .../settings/dashboard-layout-section.tsx | 954 +----------------- .../hero-settings-fieldset.tsx | 112 ++ .../settings/dashboard-layout/layout-model.ts | 139 +++ .../use-dashboard-layout-draft.ts | 308 ++++++ .../dashboard-layout/widget-lists.tsx | 431 ++++++++ 12 files changed, 1053 insertions(+), 920 deletions(-) create mode 100644 src/components/settings/dashboard-layout/hero-settings-fieldset.tsx create mode 100644 src/components/settings/dashboard-layout/layout-model.ts create mode 100644 src/components/settings/dashboard-layout/use-dashboard-layout-draft.ts create mode 100644 src/components/settings/dashboard-layout/widget-lists.tsx diff --git a/src/__tests__/baselines/source-file-lines.json b/src/__tests__/baselines/source-file-lines.json index a6acffeed..18a819df4 100644 --- a/src/__tests__/baselines/source-file-lines.json +++ b/src/__tests__/baselines/source-file-lines.json @@ -1,7 +1,7 @@ { "$comment": "Production files over 800 lines and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.", - "count": 117, - "total": 135490, + "count": 116, + "total": 134457, "entries": { "app/api/dashboard/summary/route.ts": 1032, "app/api/documents/inbound/route.ts": 811, @@ -30,7 +30,6 @@ "components/medications/wizard/wizard-payload.ts": 1414, "components/mood/mood-list.tsx": 1408, "components/records/life-circumstances-manager.tsx": 1080, - "components/settings/dashboard-layout-section.tsx": 1033, "components/settings/managed-record-settings-section.tsx": 1071, "components/timeline/timeline-chart.tsx": 1174, "components/timeline/timeline-geometry.ts": 1395, diff --git a/src/__tests__/baselines/source-function-lines.json b/src/__tests__/baselines/source-function-lines.json index ba2445663..d4f349978 100644 --- a/src/__tests__/baselines/source-function-lines.json +++ b/src/__tests__/baselines/source-function-lines.json @@ -1,7 +1,7 @@ { "$comment": "Production functions over 200 lines, keyed \"file :: name\", and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.", - "count": 403, - "total": 139587, + "count": 402, + "total": 138967, "entries": { "app/achievements/page.tsx :: AchievementsPage": 206, "app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 314, @@ -231,7 +231,6 @@ "components/settings/api-section.tsx :: ApiTokensCard": 366, "components/settings/coach-nudge-card.tsx :: CoachNudgeCard": 251, "components/settings/coach-prefs-section.tsx :: CoachPrefsSection": 350, - "components/settings/dashboard-layout-section.tsx :: DashboardLayoutSection": 620, "components/settings/environment-section.tsx :: EnvironmentSection": 666, "components/settings/import-panel/apple-health-import-card.tsx :: AppleHealthImportCard": 298, "components/settings/import-panel/csv-import-card.tsx :: CsvImportCard": 212, diff --git a/src/__tests__/baselines/version-stamp-comments.json b/src/__tests__/baselines/version-stamp-comments.json index a8a35605e..dfbd22269 100644 --- a/src/__tests__/baselines/version-stamp-comments.json +++ b/src/__tests__/baselines/version-stamp-comments.json @@ -1,7 +1,7 @@ { "$comment": "Comment lines that open with a release version, per file under src/. Maintained by src/__tests__/version-stamp-ratchet.test.ts.", - "count": 2949, - "total": 10287, + "count": 2948, + "total": 10257, "entries": { "__tests__/account-payload-consumer-guard.test.ts": 2, "__tests__/acting-account-boundary-guard.test.ts": 7, @@ -1376,7 +1376,6 @@ "components/settings/coach-prefs-section.tsx": 2, "components/settings/coach-reminders-section.tsx": 2, "components/settings/coach-section.tsx": 3, - "components/settings/dashboard-layout-section.tsx": 30, "components/settings/dashboard-section.tsx": 1, "components/settings/environment-section.tsx": 3, "components/settings/export-section.tsx": 4, diff --git a/src/__tests__/destructive-control-guard.test.ts b/src/__tests__/destructive-control-guard.test.ts index 3e5f7f9b6..d28464472 100644 --- a/src/__tests__/destructive-control-guard.test.ts +++ b/src/__tests__/destructive-control-guard.test.ts @@ -316,7 +316,8 @@ const REGISTRY: DestructiveEntry[] = [ confirm: ["ConfirmButton"], }, { - file: "components/settings/dashboard-layout-section.tsx", + file: "components/settings/dashboard-layout/use-dashboard-layout-draft.ts", + triggers: ["components/settings/dashboard-layout-section.tsx"], destroys: "the dashboard arrangement: tile order, hidden tiles, ring choice", recovery: "permanent", diff --git a/src/__tests__/success-affordance-guard.test.ts b/src/__tests__/success-affordance-guard.test.ts index e0515bbe4..16f699edd 100644 --- a/src/__tests__/success-affordance-guard.test.ts +++ b/src/__tests__/success-affordance-guard.test.ts @@ -360,7 +360,7 @@ const PINNED_AFFORDANCES: Record< }, "src/components/settings/coach-memory-section.tsx": { "toast.success": 2 }, "src/components/settings/coach-prefs-section.tsx": { "toast.success": 1 }, - "src/components/settings/dashboard-layout-section.tsx": { + "src/components/settings/dashboard-layout/use-dashboard-layout-draft.ts": { "toast.success": 2, }, "src/components/settings/environment-section.tsx": { "toast.success": 4 }, diff --git a/src/components/settings/__tests__/dashboard-layout-section.test.tsx b/src/components/settings/__tests__/dashboard-layout-section.test.tsx index 79cb3b143..a350585bc 100644 --- a/src/components/settings/__tests__/dashboard-layout-section.test.tsx +++ b/src/components/settings/__tests__/dashboard-layout-section.test.tsx @@ -80,11 +80,11 @@ vi.mock("@/hooks/use-mounted", () => ({ })); import { I18nProvider } from "@/lib/i18n/context"; +import { DashboardLayoutSection } from "../dashboard-layout-section"; import { - DashboardLayoutSection, reorderWidgets, setHeroItemKindEnabled, -} from "../dashboard-layout-section"; +} from "../dashboard-layout/layout-model"; import { primeLocales } from "@/__tests__/helpers/prime-locales"; primeLocales("de"); diff --git a/src/components/settings/__tests__/dashboard-layout-snapshot-invalidation.test.ts b/src/components/settings/__tests__/dashboard-layout-snapshot-invalidation.test.ts index 96ee1ad4b..954f80df2 100644 --- a/src/components/settings/__tests__/dashboard-layout-snapshot-invalidation.test.ts +++ b/src/components/settings/__tests__/dashboard-layout-snapshot-invalidation.test.ts @@ -128,7 +128,10 @@ describe("dashboard snapshot invalidation — same-tab staleness mechanism", () describe("dashboard layout save — every layout source is covered (source pins)", () => { const sectionSrc = readFileSync( - join(process.cwd(), "src/components/settings/dashboard-layout-section.tsx"), + join( + process.cwd(), + "src/components/settings/dashboard-layout/use-dashboard-layout-draft.ts", + ), "utf8", ); diff --git a/src/components/settings/dashboard-layout-section.tsx b/src/components/settings/dashboard-layout-section.tsx index fb1ee25ba..e38165ea0 100644 --- a/src/components/settings/dashboard-layout-section.tsx +++ b/src/components/settings/dashboard-layout-section.tsx @@ -1,215 +1,37 @@ "use client"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { useId, useMemo, useState } from "react"; -import { toast } from "sonner"; -import { showErrorToast } from "@/lib/errors/error-toast"; -import { - LayoutDashboard, - RotateCcw, - Loader2, - ArrowUp, - ArrowDown, - GripVertical, -} 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 { useId } from "react"; +import { LayoutDashboard, Loader2, RotateCcw } from "lucide-react"; + import { SettingsCardActions } from "@/components/settings/_card-actions"; -import { ConfirmButton } from "@/components/ui/confirm-button"; -import { Switch } from "@/components/ui/switch"; -import { Label } from "@/components/ui/label"; -import { NativeSelect } from "@/components/ui/native-select"; -import { prefersReducedMotion } from "@/lib/charts/reduced-motion"; -import { useTranslations } from "@/lib/i18n/context"; -import { queryKeys } from "@/lib/query-keys"; -import { - type DashboardLayout, - type DashboardWidgetId, - DEFAULT_DASHBOARD_LAYOUT, - DASHBOARD_WIDGET_IDS, - DASHBOARD_IOS_ONLY_WIDGET_IDS, - IOS_PIN_ONLY_WIDGET_IDS, - type DashboardWidgetCatalogueId, - type DashboardLayoutWithToken, -} from "@/lib/dashboard-layout"; -import { - PRIORITY_ITEM_KINDS, - type PriorityItemKind, -} from "@/lib/daily/priority-item"; -import { apiDelete, apiGet, apiPut, ApiError } from "@/lib/api/api-fetch"; -import { useMounted } from "@/hooks/use-mounted"; -import { useAuth } from "@/hooks/use-auth"; -import { NATIVE_ONLY_WIDGET_LABEL_KEYS } from "@/lib/dashboard/widget-modules"; -import { isSurfaceVisible } from "@/lib/modules/surface"; -import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; +import { SettingsCard } from "@/components/settings/settings-card"; +import { Button } from "@/components/ui/button"; +import { ConfirmButton } from "@/components/ui/confirm-button"; +import { useMounted } from "@/hooks/use-mounted"; +import { PRIORITY_ITEM_KINDS } from "@/lib/daily/priority-item"; +import { DEFAULT_DASHBOARD_LAYOUT } from "@/lib/dashboard-layout"; +import { useTranslations } from "@/lib/i18n/context"; + +import { HeroSettingsFieldset } from "./dashboard-layout/hero-settings-fieldset"; +import { useDashboardLayoutDraft } from "./dashboard-layout/use-dashboard-layout-draft"; +import { + NativeWidgetList, + WebWidgetList, +} from "./dashboard-layout/widget-lists"; /** - * v1.4.47 — pure reorder helper shared by the arrow buttons and the - * @dnd-kit drag-end handler. Both surfaces produce the same `widgets[]` - * shape (`order: 0..n-1`) so the existing PUT contract stays untouched - * and either input mode flushes via the same Save mutation. Exported so - * the unit test can pin the contract without spinning up a DndContext. - * - * v1.4.48 — also drives the arrow-button `move()` handler below; - * the previous in-file swap-and-renumber implementation was a second - * copy of the same logic. + * Settings → Dashboard: the top card's settings, the widget list (tile and + * chart switches, order by arrows or drag), the native-only widgets, and + * save / cancel / reset. The draft and its writes live in + * `./dashboard-layout/`. */ -export function reorderWidgets( - widgets: readonly { id: string; order: number }[], - fromId: string, - toId: string, -): { id: string; order: number }[] { - const sorted = [...widgets].sort((a, b) => a.order - b.order); - const fromIdx = sorted.findIndex((w) => w.id === fromId); - const toIdx = sorted.findIndex((w) => w.id === toId); - if (fromIdx < 0 || toIdx < 0 || fromIdx === toIdx) { - return sorted.map((w, i) => ({ ...w, order: i })); - } - const next = [...sorted]; - const [removed] = next.splice(fromIdx, 1); - next.splice(toIdx, 0, removed); - return next.map((w, i) => ({ ...w, order: i })); -} - -export function setHeroItemKindEnabled( - enabledKinds: readonly PriorityItemKind[] | undefined, - kind: PriorityItemKind, - enabled: boolean, -): PriorityItemKind[] { - const next = new Set(enabledKinds ?? PRIORITY_ITEM_KINDS); - if (enabled) next.add(kind); - else next.delete(kind); - return PRIORITY_ITEM_KINDS.filter((itemKind) => next.has(itemKind)); -} - -/** - * v1.4.48 — merge the `{ id, order }` shape returned by - * `reorderWidgets()` back into the full `DashboardWidgetConfig[]` so - * the section's draft state keeps every per-row flag (`visible`, - * `tileVisible`) while the order is rewritten. The arrow buttons and - * the @dnd-kit drag-end handler both flow through this helper so - * neither surface can silently drop a flag. - */ -function mergeReorderIntoLayout( - widgets: DashboardLayout["widgets"], - reordered: readonly { id: string; order: number }[], -): DashboardLayout["widgets"] { - const byId = new Map(widgets.map((w) => [w.id, w])); - return reordered.map((r, i) => { - const original = byId.get(r.id as DashboardWidgetId); - if (!original) { - // v1.4.49 — defence-in-depth dev warning for the orphan branch. - // Today this branch is statically unreachable: every id in - // `reordered` is sourced from `layout.widgets`, so `byId.get` - // always hits. The upcoming per-tile Suspense refactor will - // introduce dynamic widgets where this invariant could break; - // the warning fires in dev only so a regression surfaces in the - // console instead of silently dropping the row via the cast. - if ( - typeof window !== "undefined" && - process.env.NODE_ENV === "development" - ) { - console.warn( - `mergeReorderIntoLayout: orphan widget id "${r.id}" dropped`, - ); - } - return { ...r, order: i } as never; - } - return { ...original, order: i }; - }); -} - -/** Serialise layout saves and resets against the same endpoint. */ -const DASHBOARD_WIDGETS_MUTATION_SCOPE = "dashboard-widgets" as const; - -const WIDGET_LABEL_KEYS: Record = { - weight: "dashboard.weight", - bp: "dashboard.bloodPressure", - pulse: "dashboard.pulse", - bodyFat: "dashboard.bodyFat", - mood: "dashboard.mood", - medications: "dashboard.medications", - sleep: "measurements.typeSleep", - steps: "measurements.typeSteps", - glucose: "measurements.typeBloodGlucose", - totalBodyWater: "measurements.typeTotalBodyWater", - boneMass: "measurements.typeBoneMass", - // v1.28.52 — muscle mass joins the body-composition strip tiles. - muscleMass: "measurements.typeMuscleMass", - bpInTarget: "dashboard.bpInTarget", - oxygenSaturation: "measurements.typeOxygenSaturation", - // v1.28.52 — HRV + respiratory rate graduate to web-writable widgets. - hrv: "measurements.typeHeartRateVariability", - respiratoryRate: "measurements.typeRespiratoryRate", - achievements: "achievements.title", - // v1.4.25 — VO2 max secondary-metric tile (opt-in). - vo2Max: "dashboard.vo2Max", - // v1.4.32 — Recent workouts tile (default-on). - recentWorkouts: "dashboard.recentWorkouts.title", - // v1.11.2 — v1.10 additive HealthKit signals, now pinnable. Each - // reuses the existing measurement-type label key. - cardioRecovery: "measurements.typeCardioRecovery", - sixMinuteWalk: "measurements.typeSixMinuteWalkDistance", - stairAscentSpeed: "measurements.typeStairAscentSpeed", - stairDescentSpeed: "measurements.typeStairDescentSpeed", - breathingDisturbances: "measurements.typeBreathingDisturbances", - wristTemperature: "measurements.typeWristTemperature", - falls: "measurements.typeFallCount", - walkingSteadiness: "measurements.typeWalkingSteadiness", - // v1.18.2 — Vorsorge preventive-care summary card. - vorsorge: "measurementReminders.sectionTitle", - // v1.29 — fluid intake strip tile (nutrients-store-backed). - waterIntake: "nutrients.names.water", -}; - -const HERO_ITEM_LABEL_KEYS: Record = { - coach_checkin: "dashboard.heroItems.coach_checkin", - dose_window: "dashboard.heroItems.dose_window", - preventive_care: "dashboard.heroItems.preventive_care", - sync_issue: "dashboard.heroItems.sync_issue", - milestone: "dashboard.heroItems.milestone", - ecg_new_recording: "dashboard.heroItems.ecg_new_recording", - tension_window: "dashboard.heroItems.tension_window", - same_time_baseline: "dashboard.heroItems.same_time_baseline", - upcoming_visit: "dashboard.heroItems.upcoming_visit", -}; - export function DashboardLayoutSection({ id }: { id: string }) { const { t } = useTranslations(); - const queryClient = useQueryClient(); - // v1.18.0 — a widget toggle whose owning module is disabled is a dead - // control (the snapshot gates the tile/chart out server-side regardless - // of the switch). Read the resolved module map and hide those rows. - // Fail-open: a missing map / absent key (stale /me payload, core widget) - // reads as enabled, so the row always shows unless the module is - // explicitly `false`. - const { user } = useAuth(); - const modules = user?.modules; - // v1.4.47 — stable id namespace for the drag-handle `aria-describedby` - // tooltip. One hint paragraph is rendered once at the bottom of the list - // and referenced by every drag handle in this section. + // One hint paragraph, rendered once below the list and referenced by every + // drag handle's `aria-describedby`. const dragHintId = useId(); - const heroDescriptionId = useId(); - const heroNotificationNoteId = useId(); - const heroContentSelectId = useId(); - const todayCardSwitchId = useId(); - // v1.34 — the "Today highlights" fieldset below is gated on `layout`, + // The "Today highlights" fieldset below is gated on `layout`, // which can be truthy on the very first client render whenever a // returning visitor's browser already has a warm, offline-persisted // `dashboardWidgets` cache (queryKeys.dashboardWidgets() sits in @@ -220,258 +42,16 @@ export function DashboardLayoutSection({ id }: { id: string }) { // the server on the hydrating render (both false), the same pattern // `LayoutModuleGate` already uses for the same class of mismatch. const hydrated = useMounted(); - - // v1.4.47 — sensors: pointer for mouse/touch, keyboard for Tab + Space - // + arrow-key reordering. The KeyboardSensor still works for users who - // tab to the GripVertical handle; the legacy ArrowUp / ArrowDown buttons - // below remain the primary keyboard surface for accessibility. - const sensors = useSensors( - useSensor(PointerSensor, { - // Activation distance avoids the drag stealing every click on the row - // switches — only a 6 px pointer-down → move counts as drag intent. - activationConstraint: { distance: 6 }, - }), - useSensor(KeyboardSensor, { - coordinateGetter: sortableKeyboardCoordinates, - }), - ); - - const { data: remote, isLoading } = useQuery({ - queryKey: queryKeys.dashboardWidgets(), - queryFn: async () => { - return apiGet("/api/dashboard/widgets"); - }, - // v1.32.19 (B-3, belt for the #581 fix) — the home RSC seeds - // `dashboardWidgets()` from the dashboard SNAPSHOT layout, which carries - // NO `updatedAt` optimistic-concurrency token (it is a token-less - // `DashboardLayout`, not the `DashboardLayoutWithToken` this section's GET - // returns). Within the global 5-min `staleTime` a visit to `/` then Settings - // read that seeded token-less value with no mount refetch, so `readBaseToken()` - // returned undefined, the Save sent no `baseUpdatedAt`, and the route's - // compat fallback silently reverted to last-write-wins — disabling the - // just-shipped 409 belt. Force a mount refetch so this section always holds - // its own tokened GET response and every write carries the base token. The - // smallest correct option: the alternatives (a distinct seed key, or - // threading the token through the snapshot layout) touch the home client and - // the server snapshot shape; this is one line, config-independent, and makes - // the settings query authoritative for its own tokened read. - refetchOnMount: "always", - }); - - // Toggles the SERVER resolved as unavailable for this account — a switch - // that is on but can never paint a tile is exactly the confusion this - // screen must not create. Decided server-side (see `unavailableWidgetIds`) - // so this screen needs no dashboard data of its own. Absent field, or a - // query still in flight, means nothing is dead: the row shows, which is - // the fail-open direction the module gate below also takes. - const unavailableIds = useMemo( - () => new Set(remote?.unavailableWidgetIds ?? []), - [remote?.unavailableWidgetIds], - ); - - /** - * v1.32.16 (issue #581) — the freshest optimistic-concurrency token the - * client knows. Read straight from the widgets query cache at mutate time - * (not a render snapshot) so it reflects whatever the last settled write - * or refetch advanced it to. Every write sends it as `baseUpdatedAt`. - */ - const readBaseToken = () => - queryClient.getQueryData( - queryKeys.dashboardWidgets(), - )?.updatedAt; - - // Local draft state — null means "use server copy". User edits create the - // draft so reordering/toggling doesn't fire a network call per click; Save - // flushes it, Cancel clears it. Avoids a setState-in-effect (eslint - // react-hooks/set-state-in-effect is strict in this repo). - const [draft, setDraft] = useState(null); - const layout = draft ?? remote ?? null; - - const saveMutation = useMutation({ - scope: { id: DASHBOARD_WIDGETS_MUTATION_SCOPE }, - mutationFn: async (next: DashboardLayout) => { - return apiPut("/api/dashboard/widgets", { - ...next, - // Guard the write on the token this edit was based on. - baseUpdatedAt: readBaseToken(), - }); - }, - onSuccess: (saved) => { - queryClient.setQueryData(queryKeys.dashboardWidgets(), saved); - // v1.18.10 — the Startseite reads its layout (tile/chart visibility, - // order, comparison baseline, hero toggle) from the dashboard - // SNAPSHOT, not from this `dashboardWidgets` key. Without an - // invalidation the snapshot keeps its cached layout and the dashboard - // looks stale until a manual reload. Invalidate the snapshot so the - // next visit (or open tab) re-reads the saved layout immediately. - // - // `refetchType: "all"` is load-bearing: while the user sits on this - // settings page the dashboard is UNMOUNTED, so its snapshot cell is - // inactive — the default `refetchType: "active"` only marks it stale, - // and the snapshot query's deliberate `refetchOnMount: false` - // (use-dashboard-snapshot.ts) then suppresses the mount-time refetch - // on a same-tab navigation back to `/`. The saved tile selection only - // surfaced on the 120 s poll or a window-focus flick ("saved but the - // dashboard shows old tiles"). Refetching ALL matching cells fires the - // request right here, so the dashboard mounts onto fresh data. - queryClient.invalidateQueries({ - queryKey: queryKeys.dashboardSnapshot(), - refetchType: "all", - }); - queryClient.invalidateQueries({ - queryKey: queryKeys.dailyDigest(), - refetchType: "all", - }); - setDraft(null); - toast.success(t("dashboard.layoutSaveSuccess")); - }, - onError: (err) => { - // A 409 means another client committed since this edit was based. - // Refetch the winning layout so the base token advances, keep the draft, - // and let the user save the change again. - if (err instanceof ApiError && err.status === 409) { - queryClient.invalidateQueries({ - queryKey: queryKeys.dashboardWidgets(), - }); - toast.message(t("dashboard.layoutConflictReloaded")); - return; - } - showErrorToast(t("dashboard.layoutSaveError"), { cause: err }); - }, - }); - - const resetMutation = useMutation({ - scope: { id: DASHBOARD_WIDGETS_MUTATION_SCOPE }, - mutationFn: async () => { - return apiDelete("/api/dashboard/widgets"); - }, - onSuccess: (saved) => { - queryClient.setQueryData(queryKeys.dashboardWidgets(), saved); - // v1.18.10 — same snapshot invalidation as the save path so a reset - // to defaults reflects on the Startseite without a manual reload. - // `refetchType: "all"` for the same reason as the save path: the - // unmounted snapshot cell must refetch NOW, not on its next poll. - queryClient.invalidateQueries({ - queryKey: queryKeys.dashboardSnapshot(), - refetchType: "all", - }); - queryClient.invalidateQueries({ - queryKey: queryKeys.dailyDigest(), - refetchType: "all", - }); - setDraft(null); - toast.success(t("dashboard.layoutResetSuccess")); - }, - }); - - function toggle(widgetId: DashboardWidgetCatalogueId, visible: boolean) { - if (!layout) return; - setDraft({ - ...layout, - widgets: layout.widgets.map((w) => - w.id === widgetId ? { ...w, visible } : w, - ), - }); - } - - /** - * v1.4.15 — independent toggle for the *strip tile* (the upper - * row of trend cards). Until v1.4.14 a single switch controlled both - * the tile AND the chart for the same metric, which the maintainer found too - * coarse: they wanted a chart visible without the tile (for metrics they - * tracks without wanting the at-a-glance number) or vice versa. - */ - function toggleTile( - widgetId: DashboardWidgetCatalogueId, - tileVisible: boolean, - ) { - if (!layout) return; - setDraft({ - ...layout, - widgets: layout.widgets.map((w) => - w.id === widgetId ? { ...w, tileVisible } : w, - ), - }); - } - - function toggleHeroItem(kind: PriorityItemKind, enabled: boolean) { - if (!layout) return; - setDraft({ - ...layout, - enabledHeroItemKinds: setHeroItemKindEnabled( - layout.enabledHeroItemKinds, - kind, - enabled, - ), - }); - } - - /** v1.42 — the server-persisted on/off switch for the dashboard's top card. */ - function setTodayCardVisible(visible: boolean) { - if (!layout) return; - setDraft({ ...layout, todayCardVisible: visible }); - } - - /** - * Hero primary content — the server-persisted `hero` field on the layout - * blob. The value set is closed ("score" | "reminders"); anything else a - * DOM event could carry collapses to the default rather than a cast. - */ - function setHeroContent(value: string) { - if (!layout) return; - setDraft({ - ...layout, - hero: value === "reminders" ? "reminders" : "score", - }); - } - - /** - * v1.4.48 — the arrow buttons now delegate to `reorderWidgets()` - * so the swap-and-renumber logic lives in exactly one place. The - * neighbour id is derived from the sorted layout index + delta; - * out-of-bounds clicks (top row + ArrowUp, bottom row + ArrowDown) - * short-circuit before the helper sees them so the button disabled - * state stays the single source of truth for the boundary. - */ - function move(widgetId: DashboardWidgetId, delta: -1 | 1) { - if (!layout) return; - const sorted = [...layout.widgets].sort((a, b) => a.order - b.order); - const idx = sorted.findIndex((w) => w.id === widgetId); - const targetIdx = idx + delta; - if (idx < 0 || targetIdx < 0 || targetIdx >= sorted.length) return; - const neighbourId = sorted[targetIdx].id; - const reordered = reorderWidgets(layout.widgets, widgetId, neighbourId); - setDraft({ - ...layout, - widgets: mergeReorderIntoLayout(layout.widgets, reordered), - }); - } - - /** - * v1.4.47 — drag-and-drop reorder via @dnd-kit. Persists the same - * `order` rewrite shape the arrow buttons already use, so save / cancel - * / reset and the existing draft state machine work unchanged. The - * pointer + keyboard sensors are wired in the same `useSensors` call; - * keyboard a11y still also works through the legacy arrow buttons that - * remain on every row. - */ - function handleDragEnd(event: DragEndEvent) { - if (!layout) return; - const { active, over } = event; - if (!over || active.id === over.id) return; - const reordered = reorderWidgets( - layout.widgets, - String(active.id), - String(over.id), - ); - setDraft({ - ...layout, - widgets: mergeReorderIntoLayout(layout.widgets, reordered), - }); - } - - // Presence of a draft implies dirty — no JSON comparison needed. - const dirty = draft !== null && layout !== null; + const draft = useDashboardLayoutDraft(); + const { + remote, + isLoading, + layout, + setDraft, + dirty, + saveMutation, + resetMutation, + } = draft; return ( @@ -480,86 +60,7 @@ export function DashboardLayoutSection({ id }: { id: string }) { title={t("dashboard.customizeTitle")} /> {hydrated && layout && ( -
- - {t("dashboard.heroItemsTitle")} - -

- {t("dashboard.heroItemsDescription")} -

- {/* v1.42 — the top card itself can be switched off. Shown is the - default; the choices below shape the card while it is shown. */} -
- - -
- {/* Hero primary content — a single labelled select beside the - highlight toggles. "score" keeps the health-score read; - "reminders" promotes the highlight rail into the hero slot. */} -
- - setHeroContent(event.target.value)} - disabled={saveMutation.isPending} - data-slot="hero-content-select" - className="sm:max-w-xs" - > - - - -
-
- {PRIORITY_ITEM_KINDS.map((kind) => { - const inputId = `hero-item-${kind}`; - const checked = ( - layout.enabledHeroItemKinds ?? PRIORITY_ITEM_KINDS - ).includes(kind); - return ( -
- - toggleHeroItem(kind, value)} - disabled={saveMutation.isPending} - aria-label={t(HERO_ITEM_LABEL_KEYS[kind])} - data-slot="hero-item-switch" - data-kind={kind} - /> -
- ); - })} -
-

- {t("dashboard.heroItemsNotificationNote")} -

-
+ )} {isLoading || !layout ? ( @@ -577,20 +78,10 @@ export function DashboardLayoutSection({ id }: { id: string }) { // (ui-standards.md §9 one-scroll-floor; guarded by // e2e/settings-overscroll.spec.ts).
- {/* v1.4.15 — table-style header naming the two - switches. The "tile" column controls the strip tile in - the upper row; the "chart" column controls the line - chart in the lower row. the maintainer wanted independent control - of the two surfaces (per feedback_dashboard_top_tiles - _selectable.md). */} - {/* v1.4.29 — column-header alignment for the new - horizontal arrow pair on the trailing edge. The - right-hand spacer reserves the width of two - size-11/sm:size-9 buttons so the Tile / Chart column - headers continue to line up with the switches below. */} - {/* v1.4.47 — column header spacer additionally reserves the - width of the new drag-handle icon (w-7) so Tile / Chart - alignment with the row switches below stays pixel-perfect. */} + {/* The column header names the two switches (the tile in the + upper strip, the chart in the lower row); the spacers reserve + the drag handle and the arrow pair so the headings line up with + the switches below. */}
- {(() => { - // v1.7.0 — the stored layout now round-trips the 11 iOS-only - // widget ids so the native client can drop its local merge - // workarounds. The web Settings list has no tile/chart - // surface for them, so skip any id outside the web-known - // ids rather than render an unlabelled row with dead toggles. - // The skipped ids stay untouched in the persisted layout - // because the Save mutation PUTs `layout.widgets` whole and - // the server retains every catalogue id. - // - // v1.11.2 — the 8 B5 ids are WRITABLE (in - // `DASHBOARD_WIDGET_IDS` so the iOS pin PUT validates) but - // have no web render path either, so exclude - // `IOS_PIN_ONLY_WIDGET_IDS` too: a web toggle for them would - // be a silent no-op on the web dashboard. - const iosPinOnly = new Set(IOS_PIN_ONLY_WIDGET_IDS); - const webWidgetIds = new Set( - DASHBOARD_WIDGET_IDS.filter((wid) => !iosPinOnly.has(wid)), - ); - const sortedWidgets = [...layout.widgets] - .filter((w): w is typeof w & { id: DashboardWidgetId } => - webWidgetIds.has(w.id), - ) - // Hide a widget toggle whose owning module is off, through the - // one surface map. A core widget has no owner and always shows; - // only an explicit `false` hides the row. - .filter((w) => isSurfaceVisible(`widget:${w.id}`, modules)) - .filter((w) => !unavailableIds.has(w.id)) - .sort((a, b) => a.order - b.order); - const sortedIds = sortedWidgets.map((w) => w.id); - return ( - - - {sortedWidgets.map((widget, index, arr) => ( - - ))} - - - ); - })()} - {/* v1.4.47 — single shared aria-describedby target for all + + {/* Single shared aria-describedby target for all drag handles. Screen readers read this once per focused handle; sighted users see it in the native browser tooltip - via the matching `title` attribute on each handle. - v1.4.48 — gate on widget count so an empty layout never - orphans the paragraph (no handles to describe). */} + via the matching `title` attribute on each handle. Gated on + the widget count, so an empty layout never orphans it. */} {layout.widgets.length > 0 && (

{t("dashboard.dragHandleHint")}

)} - {/* issue #581 — widgets the mobile app draws and this page does - not. Filtering them out of the list above is right (a toggle - over something the web cannot draw would be a silent no-op), - but the native client materialises them into the SAME stored - layout, so the account ended up holding rows it never set and - could not change from anywhere. They get their own labelled - group: the same two flags, no reordering (placement is the - native client's), and only for ids the layout actually holds — - an account with no native client has nothing to configure here - and sees nothing. */} - {(() => { - const nativeOnlyIds = new Set([ - ...IOS_PIN_ONLY_WIDGET_IDS, - ...DASHBOARD_IOS_ONLY_WIDGET_IDS, - ]); - const nativeWidgets = layout.widgets - .filter((w) => nativeOnlyIds.has(w.id)) - // Same fail-open module gate as the web list: only an explicit - // `false` hides the row. - .filter((w) => isSurfaceVisible(`widget:${w.id}`, modules)) - .filter((w) => !unavailableIds.has(w.id)) - .sort((a, b) => a.order - b.order); - if (nativeWidgets.length === 0) return null; - return ( -
-

- {t("dashboard.layoutNativeOnlyTitle")} -

-

- {t("dashboard.layoutNativeOnlyHint")} -

-
-
- {nativeWidgets.map((widget) => { - const label = t( - NATIVE_ONLY_WIDGET_LABEL_KEYS[widget.id] ?? widget.id, - ); - return ( - - ); - })} -
- ); - })()} +
)} @@ -810,224 +173,3 @@ export function DashboardLayoutSection({ id }: { id: string }) {
); } - -/** - * v1.4.47 — sortable row primitive extracted from the section render - * so the @dnd-kit `useSortable` hook stays scoped to one row. Translation - * strings are passed in pre-resolved (rather than calling `useTranslations` - * inside) so this component stays cheap to re-render for the 13+ rows. - * - * The drag handle is the only listener-bearing surface — the row body - * stays click-through so the switches and arrow buttons keep working. - * Pointer activation has a 6 px distance constraint (configured on the - * parent sensor) so a tap on the handle never accidentally drags. - */ -interface SortableWidgetRowProps { - widget: { - id: DashboardWidgetId; - visible: boolean; - tileVisible?: boolean; - order: number; - }; - labelKey: string; - index: number; - total: number; - dragHintId: string; - disabled: boolean; - labels: { - tileColumn: string; - chartColumn: string; - moveUp: string; - moveDown: string; - dragHandle: string; - widgetLabel: string; - }; - onToggleTile: (id: DashboardWidgetId, value: boolean) => void; - onToggleChart: (id: DashboardWidgetId, value: boolean) => void; - onMove: (id: DashboardWidgetId, delta: -1 | 1) => void; -} - -function SortableWidgetRow({ - widget, - index, - total, - dragHintId, - disabled, - labels, - onToggleTile, - onToggleChart, - onMove, -}: SortableWidgetRowProps) { - const { - attributes, - listeners, - setNodeRef, - transform, - transition, - isDragging, - } = useSortable({ id: widget.id }); - - // v1.4.48 — honour the OS `prefers-reduced-motion` preference. The - // rest of HealthLog pairs every transition with a `motion-reduce` - // companion; dnd-kit's default `transform 250ms ease` was the lone - // surface that ignored it. Short-circuit to `none` when reduced - // motion is requested so dragged rows snap to place instead of - // sliding through the list. - const style: React.CSSProperties = { - transform: CSS.Transform.toString(transform), - transition: prefersReducedMotion() ? "none" : transition, - }; - - const tileChecked = - typeof widget.tileVisible === "boolean" - ? widget.tileVisible - : widget.visible; - - return ( - // v1.4.29 — row sized at 48 px (`min-h-12`) with 44-px mobile tap - // targets preserved on the trailing arrow buttons (`size-11`), - // shrunk to `sm:size-9` on desktop. v1.4.47 W4 — `isDragging` - // raises the row visually (elevation + accent ring) so the ghost - // overlay is unambiguous during a drag. -
- - - {labels.widgetLabel} - - {/* On a phone the two switches and two arrows (~250 px) left the name - five characters ("Gewic…"). Below `sm` the controls wrap onto a - second line, right-aligned under the column heads, and the name - keeps the first line to itself. */} -
- ); -} - -/** - * issue #581 — one row in the native-only group: the same two switches as a - * widget row above, without the drag handle or the arrow buttons. Placement - * of these widgets belongs to the client that draws them, so the web offers - * visibility and nothing it cannot honour. - */ -function StaticWidgetRow({ - widget, - disabled, - labels, - onToggleTile, - onToggleChart, -}: { - widget: { - id: DashboardWidgetCatalogueId; - visible: boolean; - tileVisible?: boolean; - order: number; - }; - disabled: boolean; - labels: { tileColumn: string; chartColumn: string; widgetLabel: string }; - onToggleTile: (id: DashboardWidgetCatalogueId, value: boolean) => void; - onToggleChart: (id: DashboardWidgetCatalogueId, value: boolean) => void; -}) { - const tileChecked = - typeof widget.tileVisible === "boolean" - ? widget.tileVisible - : widget.visible; - - return ( -
- - {labels.widgetLabel} - -
- onToggleTile(widget.id, v)} - aria-label={`${labels.widgetLabel}: ${labels.tileColumn}`} - disabled={disabled} - data-slot="widget-tile-switch" - /> -
-
- onToggleChart(widget.id, v)} - aria-label={`${labels.widgetLabel}: ${labels.chartColumn}`} - disabled={disabled} - data-slot="widget-chart-switch" - /> -
-
- ); -} diff --git a/src/components/settings/dashboard-layout/hero-settings-fieldset.tsx b/src/components/settings/dashboard-layout/hero-settings-fieldset.tsx new file mode 100644 index 000000000..208fc71b3 --- /dev/null +++ b/src/components/settings/dashboard-layout/hero-settings-fieldset.tsx @@ -0,0 +1,112 @@ +"use client"; + +import { useId } from "react"; + +import { Label } from "@/components/ui/label"; +import { NativeSelect } from "@/components/ui/native-select"; +import { Switch } from "@/components/ui/switch"; +import { PRIORITY_ITEM_KINDS } from "@/lib/daily/priority-item"; +import type { DashboardLayout } from "@/lib/dashboard-layout"; +import { useTranslations } from "@/lib/i18n/context"; + +import { HERO_ITEM_LABEL_KEYS } from "./layout-model"; +import type { DashboardLayoutDraft } from "./use-dashboard-layout-draft"; + +/** + * The top card's settings: whether it shows, what leads it (the health score + * or the reminders rail), and which highlights may appear in it. + */ +export function HeroSettingsFieldset({ + layout, + draft, +}: { + layout: DashboardLayout; + draft: DashboardLayoutDraft; +}) { + const { t } = useTranslations(); + const heroDescriptionId = useId(); + const heroNotificationNoteId = useId(); + const heroContentSelectId = useId(); + const todayCardSwitchId = useId(); + const { saveMutation, setTodayCardVisible, setHeroContent, toggleHeroItem } = + draft; + return ( +
+ + {t("dashboard.heroItemsTitle")} + +

+ {t("dashboard.heroItemsDescription")} +

+ {/* The top card itself can be switched off. Shown is the + default; the choices below shape the card while it is shown. */} +
+ + +
+ {/* Hero primary content — a single labelled select beside the + highlight toggles. "score" keeps the health-score read; + "reminders" promotes the highlight rail into the hero slot. */} +
+ + setHeroContent(event.target.value)} + disabled={saveMutation.isPending} + data-slot="hero-content-select" + className="sm:max-w-xs" + > + + + +
+
+ {PRIORITY_ITEM_KINDS.map((kind) => { + const inputId = `hero-item-${kind}`; + const checked = ( + layout.enabledHeroItemKinds ?? PRIORITY_ITEM_KINDS + ).includes(kind); + return ( +
+ + toggleHeroItem(kind, value)} + disabled={saveMutation.isPending} + aria-label={t(HERO_ITEM_LABEL_KEYS[kind])} + data-slot="hero-item-switch" + data-kind={kind} + /> +
+ ); + })} +
+

+ {t("dashboard.heroItemsNotificationNote")} +

+
+ ); +} diff --git a/src/components/settings/dashboard-layout/layout-model.ts b/src/components/settings/dashboard-layout/layout-model.ts new file mode 100644 index 000000000..a5dacf291 --- /dev/null +++ b/src/components/settings/dashboard-layout/layout-model.ts @@ -0,0 +1,139 @@ +import type { + DashboardLayout, + DashboardWidgetId, +} from "@/lib/dashboard-layout"; +import { + PRIORITY_ITEM_KINDS, + type PriorityItemKind, +} from "@/lib/daily/priority-item"; + +/** + * Pure reorder helper shared by the arrow buttons and the + * @dnd-kit drag-end handler. Both surfaces produce the same `widgets[]` + * shape (`order: 0..n-1`) so the existing PUT contract stays untouched + * and either input mode flushes via the same Save mutation. Exported so + * the unit test can pin the contract without spinning up a DndContext. + * + * Also drives the arrow-button `move()` handler below; + * the previous in-file swap-and-renumber implementation was a second + * copy of the same logic. + */ +export function reorderWidgets( + widgets: readonly { id: string; order: number }[], + fromId: string, + toId: string, +): { id: string; order: number }[] { + const sorted = [...widgets].sort((a, b) => a.order - b.order); + const fromIdx = sorted.findIndex((w) => w.id === fromId); + const toIdx = sorted.findIndex((w) => w.id === toId); + if (fromIdx < 0 || toIdx < 0 || fromIdx === toIdx) { + return sorted.map((w, i) => ({ ...w, order: i })); + } + const next = [...sorted]; + const [removed] = next.splice(fromIdx, 1); + next.splice(toIdx, 0, removed); + return next.map((w, i) => ({ ...w, order: i })); +} + +export function setHeroItemKindEnabled( + enabledKinds: readonly PriorityItemKind[] | undefined, + kind: PriorityItemKind, + enabled: boolean, +): PriorityItemKind[] { + const next = new Set(enabledKinds ?? PRIORITY_ITEM_KINDS); + if (enabled) next.add(kind); + else next.delete(kind); + return PRIORITY_ITEM_KINDS.filter((itemKind) => next.has(itemKind)); +} + +/** + * Merge the `{ id, order }` shape returned by + * `reorderWidgets()` back into the full `DashboardWidgetConfig[]` so + * the section's draft state keeps every per-row flag (`visible`, + * `tileVisible`) while the order is rewritten. The arrow buttons and + * the @dnd-kit drag-end handler both flow through this helper so + * neither surface can silently drop a flag. + */ +export function mergeReorderIntoLayout( + widgets: DashboardLayout["widgets"], + reordered: readonly { id: string; order: number }[], +): DashboardLayout["widgets"] { + const byId = new Map(widgets.map((w) => [w.id, w])); + return reordered.map((r, i) => { + const original = byId.get(r.id as DashboardWidgetId); + if (!original) { + // Defence-in-depth dev warning for the orphan branch. + // Today this branch is statically unreachable: every id in + // `reordered` is sourced from `layout.widgets`, so `byId.get` + // always hits. The upcoming per-tile Suspense refactor will + // introduce dynamic widgets where this invariant could break; + // the warning fires in dev only so a regression surfaces in the + // console instead of silently dropping the row via the cast. + if ( + typeof window !== "undefined" && + process.env.NODE_ENV === "development" + ) { + console.warn( + `mergeReorderIntoLayout: orphan widget id "${r.id}" dropped`, + ); + } + return { ...r, order: i } as never; + } + return { ...original, order: i }; + }); +} + +/** Serialise layout saves and resets against the same endpoint. */ +export const DASHBOARD_WIDGETS_MUTATION_SCOPE = "dashboard-widgets" as const; + +export const WIDGET_LABEL_KEYS: Record = { + weight: "dashboard.weight", + bp: "dashboard.bloodPressure", + pulse: "dashboard.pulse", + bodyFat: "dashboard.bodyFat", + mood: "dashboard.mood", + medications: "dashboard.medications", + sleep: "measurements.typeSleep", + steps: "measurements.typeSteps", + glucose: "measurements.typeBloodGlucose", + totalBodyWater: "measurements.typeTotalBodyWater", + boneMass: "measurements.typeBoneMass", + // Muscle mass joins the body-composition strip tiles. + muscleMass: "measurements.typeMuscleMass", + bpInTarget: "dashboard.bpInTarget", + oxygenSaturation: "measurements.typeOxygenSaturation", + // HRV + respiratory rate graduate to web-writable widgets. + hrv: "measurements.typeHeartRateVariability", + respiratoryRate: "measurements.typeRespiratoryRate", + achievements: "achievements.title", + // VO2 max secondary-metric tile (opt-in). + vo2Max: "dashboard.vo2Max", + // Recent workouts tile (default-on). + recentWorkouts: "dashboard.recentWorkouts.title", + // V1.10 additive HealthKit signals, now pinnable. Each + // reuses the existing measurement-type label key. + cardioRecovery: "measurements.typeCardioRecovery", + sixMinuteWalk: "measurements.typeSixMinuteWalkDistance", + stairAscentSpeed: "measurements.typeStairAscentSpeed", + stairDescentSpeed: "measurements.typeStairDescentSpeed", + breathingDisturbances: "measurements.typeBreathingDisturbances", + wristTemperature: "measurements.typeWristTemperature", + falls: "measurements.typeFallCount", + walkingSteadiness: "measurements.typeWalkingSteadiness", + // Vorsorge preventive-care summary card. + vorsorge: "measurementReminders.sectionTitle", + // Fluid intake strip tile (nutrients-store-backed). + waterIntake: "nutrients.names.water", +}; + +export const HERO_ITEM_LABEL_KEYS: Record = { + coach_checkin: "dashboard.heroItems.coach_checkin", + dose_window: "dashboard.heroItems.dose_window", + preventive_care: "dashboard.heroItems.preventive_care", + sync_issue: "dashboard.heroItems.sync_issue", + milestone: "dashboard.heroItems.milestone", + ecg_new_recording: "dashboard.heroItems.ecg_new_recording", + tension_window: "dashboard.heroItems.tension_window", + same_time_baseline: "dashboard.heroItems.same_time_baseline", + upcoming_visit: "dashboard.heroItems.upcoming_visit", +}; diff --git a/src/components/settings/dashboard-layout/use-dashboard-layout-draft.ts b/src/components/settings/dashboard-layout/use-dashboard-layout-draft.ts new file mode 100644 index 000000000..e931c362c --- /dev/null +++ b/src/components/settings/dashboard-layout/use-dashboard-layout-draft.ts @@ -0,0 +1,308 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import type { DragEndEvent } from "@dnd-kit/core"; +import { toast } from "sonner"; + +import { apiDelete, apiGet, apiPut, ApiError } from "@/lib/api/api-fetch"; +import type { PriorityItemKind } from "@/lib/daily/priority-item"; +import type { + DashboardLayout, + DashboardLayoutWithToken, + DashboardWidgetCatalogueId, + DashboardWidgetId, +} from "@/lib/dashboard-layout"; +import { showErrorToast } from "@/lib/errors/error-toast"; +import { useTranslations } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; + +import { + DASHBOARD_WIDGETS_MUTATION_SCOPE, + mergeReorderIntoLayout, + reorderWidgets, + setHeroItemKindEnabled, +} from "./layout-model"; + +/** + * The layout editor's state: the tokened server copy, a local draft that + * every toggle and move edits (null means "use the server copy"), and the + * save and reset writes, which also refresh the dashboard snapshot and the + * daily digest so the dashboard mounts onto the saved layout. + */ +export function useDashboardLayoutDraft() { + const { t } = useTranslations(); + const queryClient = useQueryClient(); + const { data: remote, isLoading } = useQuery({ + queryKey: queryKeys.dashboardWidgets(), + queryFn: async () => { + return apiGet("/api/dashboard/widgets"); + }, + // The home RSC seeds + // `dashboardWidgets()` from the dashboard SNAPSHOT layout, which carries + // NO `updatedAt` optimistic-concurrency token (it is a token-less + // `DashboardLayout`, not the `DashboardLayoutWithToken` this section's GET + // returns). Within the global 5-min `staleTime` a visit to `/` then Settings + // read that seeded token-less value with no mount refetch, so `readBaseToken()` + // returned undefined, the Save sent no `baseUpdatedAt`, and the route's + // compat fallback silently reverted to last-write-wins — disabling the + // just-shipped 409 belt. Force a mount refetch so this section always holds + // its own tokened GET response and every write carries the base token. The + // smallest correct option: the alternatives (a distinct seed key, or + // threading the token through the snapshot layout) touch the home client and + // the server snapshot shape; this is one line, config-independent, and makes + // the settings query authoritative for its own tokened read. + refetchOnMount: "always", + }); + + // Toggles the SERVER resolved as unavailable for this account — a switch + // that is on but can never paint a tile is exactly the confusion this + // screen must not create. Decided server-side (see `unavailableWidgetIds`) + // so this screen needs no dashboard data of its own. Absent field, or a + // query still in flight, means nothing is dead: the row shows, which is + // the fail-open direction the module gate below also takes. + const unavailableIds = useMemo( + () => new Set(remote?.unavailableWidgetIds ?? []), + [remote?.unavailableWidgetIds], + ); + + /** + * The freshest optimistic-concurrency token the + * client knows. Read straight from the widgets query cache at mutate time + * (not a render snapshot) so it reflects whatever the last settled write + * or refetch advanced it to. Every write sends it as `baseUpdatedAt`. + */ + const readBaseToken = () => + queryClient.getQueryData( + queryKeys.dashboardWidgets(), + )?.updatedAt; + + // Local draft state — null means "use server copy". User edits create the + // draft so reordering/toggling doesn't fire a network call per click; Save + // flushes it, Cancel clears it. Avoids a setState-in-effect (eslint + // react-hooks/set-state-in-effect is strict in this repo). + const [draft, setDraft] = useState(null); + const layout = draft ?? remote ?? null; + + const saveMutation = useMutation({ + scope: { id: DASHBOARD_WIDGETS_MUTATION_SCOPE }, + mutationFn: async (next: DashboardLayout) => { + return apiPut("/api/dashboard/widgets", { + ...next, + // Guard the write on the token this edit was based on. + baseUpdatedAt: readBaseToken(), + }); + }, + onSuccess: (saved) => { + queryClient.setQueryData(queryKeys.dashboardWidgets(), saved); + // The Startseite reads its layout (tile/chart visibility, + // order, comparison baseline, hero toggle) from the dashboard + // SNAPSHOT, not from this `dashboardWidgets` key. Without an + // invalidation the snapshot keeps its cached layout and the dashboard + // looks stale until a manual reload. Invalidate the snapshot so the + // next visit (or open tab) re-reads the saved layout immediately. + // + // `refetchType: "all"` is load-bearing: while the user sits on this + // settings page the dashboard is UNMOUNTED, so its snapshot cell is + // inactive — the default `refetchType: "active"` only marks it stale, + // and the snapshot query's deliberate `refetchOnMount: false` + // (use-dashboard-snapshot.ts) then suppresses the mount-time refetch + // on a same-tab navigation back to `/`. The saved tile selection only + // surfaced on the 120 s poll or a window-focus flick ("saved but the + // dashboard shows old tiles"). Refetching ALL matching cells fires the + // request right here, so the dashboard mounts onto fresh data. + queryClient.invalidateQueries({ + queryKey: queryKeys.dashboardSnapshot(), + refetchType: "all", + }); + queryClient.invalidateQueries({ + queryKey: queryKeys.dailyDigest(), + refetchType: "all", + }); + setDraft(null); + toast.success(t("dashboard.layoutSaveSuccess")); + }, + onError: (err) => { + // A 409 means another client committed since this edit was based. + // Refetch the winning layout so the base token advances, keep the draft, + // and let the user save the change again. + if (err instanceof ApiError && err.status === 409) { + queryClient.invalidateQueries({ + queryKey: queryKeys.dashboardWidgets(), + }); + toast.message(t("dashboard.layoutConflictReloaded")); + return; + } + showErrorToast(t("dashboard.layoutSaveError"), { cause: err }); + }, + }); + + const resetMutation = useMutation({ + scope: { id: DASHBOARD_WIDGETS_MUTATION_SCOPE }, + mutationFn: async () => { + return apiDelete("/api/dashboard/widgets"); + }, + onSuccess: (saved) => { + queryClient.setQueryData(queryKeys.dashboardWidgets(), saved); + // Same snapshot invalidation as the save path so a reset + // to defaults reflects on the Startseite without a manual reload. + // `refetchType: "all"` for the same reason as the save path: the + // unmounted snapshot cell must refetch NOW, not on its next poll. + queryClient.invalidateQueries({ + queryKey: queryKeys.dashboardSnapshot(), + refetchType: "all", + }); + queryClient.invalidateQueries({ + queryKey: queryKeys.dailyDigest(), + refetchType: "all", + }); + setDraft(null); + toast.success(t("dashboard.layoutResetSuccess")); + }, + }); + + const edits = layoutEdits(layout, setDraft); + + // Presence of a draft implies dirty — no JSON comparison needed. + const dirty = draft !== null && layout !== null; + + return { + remote, + isLoading, + unavailableIds, + layout, + setDraft, + dirty, + saveMutation, + resetMutation, + ...edits, + }; +} + +export type DashboardLayoutDraft = ReturnType; + +/** + * The draft edits: the tile and chart switches, the top card's settings, and + * reordering by arrow or drag. Each writes a new draft from the current + * layout; nothing is sent until Save. + */ +function layoutEdits( + layout: DashboardLayout | null, + setDraft: (next: DashboardLayout | null) => void, +) { + function toggle(widgetId: DashboardWidgetCatalogueId, visible: boolean) { + if (!layout) return; + setDraft({ + ...layout, + widgets: layout.widgets.map((w) => + w.id === widgetId ? { ...w, visible } : w, + ), + }); + } + + /** + * Independent toggle for the *strip tile* (the upper + * row of trend cards). Until v1.4.14 a single switch controlled both + * the tile AND the chart for the same metric, which the maintainer found too + * coarse: they wanted a chart visible without the tile (for metrics they + * tracks without wanting the at-a-glance number) or vice versa. + */ + function toggleTile( + widgetId: DashboardWidgetCatalogueId, + tileVisible: boolean, + ) { + if (!layout) return; + setDraft({ + ...layout, + widgets: layout.widgets.map((w) => + w.id === widgetId ? { ...w, tileVisible } : w, + ), + }); + } + + function toggleHeroItem(kind: PriorityItemKind, enabled: boolean) { + if (!layout) return; + setDraft({ + ...layout, + enabledHeroItemKinds: setHeroItemKindEnabled( + layout.enabledHeroItemKinds, + kind, + enabled, + ), + }); + } + + /** The server-persisted on/off switch for the dashboard's top card. */ + function setTodayCardVisible(visible: boolean) { + if (!layout) return; + setDraft({ ...layout, todayCardVisible: visible }); + } + + /** + * Hero primary content — the server-persisted `hero` field on the layout + * blob. The value set is closed ("score" | "reminders"); anything else a + * DOM event could carry collapses to the default rather than a cast. + */ + function setHeroContent(value: string) { + if (!layout) return; + setDraft({ + ...layout, + hero: value === "reminders" ? "reminders" : "score", + }); + } + + /** + * The arrow buttons now delegate to `reorderWidgets()` + * so the swap-and-renumber logic lives in exactly one place. The + * neighbour id is derived from the sorted layout index + delta; + * out-of-bounds clicks (top row + ArrowUp, bottom row + ArrowDown) + * short-circuit before the helper sees them so the button disabled + * state stays the single source of truth for the boundary. + */ + function move(widgetId: DashboardWidgetId, delta: -1 | 1) { + if (!layout) return; + const sorted = [...layout.widgets].sort((a, b) => a.order - b.order); + const idx = sorted.findIndex((w) => w.id === widgetId); + const targetIdx = idx + delta; + if (idx < 0 || targetIdx < 0 || targetIdx >= sorted.length) return; + const neighbourId = sorted[targetIdx].id; + const reordered = reorderWidgets(layout.widgets, widgetId, neighbourId); + setDraft({ + ...layout, + widgets: mergeReorderIntoLayout(layout.widgets, reordered), + }); + } + + /** + * Drag-and-drop reorder via @dnd-kit. Persists the same + * `order` rewrite shape the arrow buttons already use, so save / cancel + * / reset and the existing draft state machine work unchanged. The + * pointer + keyboard sensors are wired in the same `useSensors` call; + * keyboard a11y still also works through the legacy arrow buttons that + * remain on every row. + */ + function handleDragEnd(event: DragEndEvent) { + if (!layout) return; + const { active, over } = event; + if (!over || active.id === over.id) return; + const reordered = reorderWidgets( + layout.widgets, + String(active.id), + String(over.id), + ); + setDraft({ + ...layout, + widgets: mergeReorderIntoLayout(layout.widgets, reordered), + }); + } + + return { + toggle, + toggleTile, + toggleHeroItem, + setTodayCardVisible, + setHeroContent, + move, + handleDragEnd, + }; +} diff --git a/src/components/settings/dashboard-layout/widget-lists.tsx b/src/components/settings/dashboard-layout/widget-lists.tsx new file mode 100644 index 000000000..0a7c3dde2 --- /dev/null +++ b/src/components/settings/dashboard-layout/widget-lists.tsx @@ -0,0 +1,431 @@ +"use client"; + +import { + DndContext, + KeyboardSensor, + PointerSensor, + closestCenter, + useSensor, + useSensors, +} from "@dnd-kit/core"; +import { + SortableContext, + sortableKeyboardCoordinates, + useSortable, + verticalListSortingStrategy, +} from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import { ArrowDown, ArrowUp, GripVertical } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { Switch } from "@/components/ui/switch"; +import { useAuth } from "@/hooks/use-auth"; +import { prefersReducedMotion } from "@/lib/charts/reduced-motion"; +import { + DASHBOARD_IOS_ONLY_WIDGET_IDS, + DASHBOARD_WIDGET_IDS, + IOS_PIN_ONLY_WIDGET_IDS, + type DashboardLayout, + type DashboardWidgetCatalogueId, + type DashboardWidgetId, +} from "@/lib/dashboard-layout"; +import { NATIVE_ONLY_WIDGET_LABEL_KEYS } from "@/lib/dashboard/widget-modules"; +import { useTranslations } from "@/lib/i18n/context"; +import { isSurfaceVisible } from "@/lib/modules/surface"; + +import { WIDGET_LABEL_KEYS } from "./layout-model"; +import type { DashboardLayoutDraft } from "./use-dashboard-layout-draft"; + +/** + * The widgets the web dashboard draws, in their order, each with its tile + * and chart switches, arrow buttons and a drag handle. + */ +export function WebWidgetList({ + layout, + draft, + dragHintId, +}: { + layout: DashboardLayout; + draft: DashboardLayoutDraft; + dragHintId: string; +}) { + const { t } = useTranslations(); + // A widget toggle whose owning module is disabled is a dead control (the + // snapshot gates the tile and chart out regardless of the switch), so the + // resolved module map hides those rows. Fail-open: only an explicit + // `false` hides one. + const { user } = useAuth(); + const modules = user?.modules; + const { + unavailableIds, + saveMutation, + toggleTile, + toggle, + move, + handleDragEnd, + } = draft; + // Pointer for mouse and touch, keyboard for Tab + Space + arrows. The + // arrow buttons on every row stay the primary keyboard surface. + const sensors = useSensors( + useSensor(PointerSensor, { + // A 6 px move before a drag starts, so the row switches keep their + // clicks. + activationConstraint: { distance: 6 }, + }), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }), + ); + // The stored layout now round-trips the 11 iOS-only + // widget ids so the native client can drop its local merge + // workarounds. The web Settings list has no tile/chart + // surface for them, so skip any id outside the web-known + // ids rather than render an unlabelled row with dead toggles. + // The skipped ids stay untouched in the persisted layout + // because the Save mutation PUTs `layout.widgets` whole and + // the server retains every catalogue id. + // + // The 8 B5 ids are WRITABLE (in + // `DASHBOARD_WIDGET_IDS` so the iOS pin PUT validates) but + // have no web render path either, so exclude + // `IOS_PIN_ONLY_WIDGET_IDS` too: a web toggle for them would + // be a silent no-op on the web dashboard. + const iosPinOnly = new Set(IOS_PIN_ONLY_WIDGET_IDS); + const webWidgetIds = new Set( + DASHBOARD_WIDGET_IDS.filter((wid) => !iosPinOnly.has(wid)), + ); + const sortedWidgets = [...layout.widgets] + .filter((w): w is typeof w & { id: DashboardWidgetId } => + webWidgetIds.has(w.id), + ) + // Hide a widget toggle whose owning module is off, through the + // one surface map. A core widget has no owner and always shows; + // only an explicit `false` hides the row. + .filter((w) => isSurfaceVisible(`widget:${w.id}`, modules)) + .filter((w) => !unavailableIds.has(w.id)) + .sort((a, b) => a.order - b.order); + const sortedIds = sortedWidgets.map((w) => w.id); + return ( + + + {sortedWidgets.map((widget, index, arr) => ( + + ))} + + + ); +} + +/** + * Widgets the mobile app draws and this page does not (#581). The native + * client materialises them into the same stored layout, so they get their + * own labelled group: the same two flags, no reordering, and only for ids the + * layout holds. + */ +export function NativeWidgetList({ + layout, + draft, +}: { + layout: DashboardLayout; + draft: DashboardLayoutDraft; +}) { + const { t } = useTranslations(); + const { user } = useAuth(); + const modules = user?.modules; + const { unavailableIds, saveMutation, toggleTile, toggle } = draft; + const nativeOnlyIds = new Set([ + ...IOS_PIN_ONLY_WIDGET_IDS, + ...DASHBOARD_IOS_ONLY_WIDGET_IDS, + ]); + const nativeWidgets = layout.widgets + .filter((w) => nativeOnlyIds.has(w.id)) + // Same fail-open module gate as the web list: only an explicit + // `false` hides the row. + .filter((w) => isSurfaceVisible(`widget:${w.id}`, modules)) + .filter((w) => !unavailableIds.has(w.id)) + .sort((a, b) => a.order - b.order); + if (nativeWidgets.length === 0) return null; + return ( +
+

+ {t("dashboard.layoutNativeOnlyTitle")} +

+

+ {t("dashboard.layoutNativeOnlyHint")} +

+
+
+ {nativeWidgets.map((widget) => { + const label = t(NATIVE_ONLY_WIDGET_LABEL_KEYS[widget.id] ?? widget.id); + return ( + + ); + })} +
+ ); +} + +/** + * Sortable row primitive extracted from the section render + * so the @dnd-kit `useSortable` hook stays scoped to one row. Translation + * strings are passed in pre-resolved (rather than calling `useTranslations` + * inside) so this component stays cheap to re-render for the 13+ rows. + * + * The drag handle is the only listener-bearing surface — the row body + * stays click-through so the switches and arrow buttons keep working. + * Pointer activation has a 6 px distance constraint (configured on the + * parent sensor) so a tap on the handle never accidentally drags. + */ +interface SortableWidgetRowProps { + widget: { + id: DashboardWidgetId; + visible: boolean; + tileVisible?: boolean; + order: number; + }; + labelKey: string; + index: number; + total: number; + dragHintId: string; + disabled: boolean; + labels: { + tileColumn: string; + chartColumn: string; + moveUp: string; + moveDown: string; + dragHandle: string; + widgetLabel: string; + }; + onToggleTile: (id: DashboardWidgetId, value: boolean) => void; + onToggleChart: (id: DashboardWidgetId, value: boolean) => void; + onMove: (id: DashboardWidgetId, delta: -1 | 1) => void; +} + +function SortableWidgetRow({ + widget, + index, + total, + dragHintId, + disabled, + labels, + onToggleTile, + onToggleChart, + onMove, +}: SortableWidgetRowProps) { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id: widget.id }); + + // Honour the OS `prefers-reduced-motion` preference. The + // rest of HealthLog pairs every transition with a `motion-reduce` + // companion; dnd-kit's default `transform 250ms ease` was the lone + // surface that ignored it. Short-circuit to `none` when reduced + // motion is requested so dragged rows snap to place instead of + // sliding through the list. + const style: React.CSSProperties = { + transform: CSS.Transform.toString(transform), + transition: prefersReducedMotion() ? "none" : transition, + }; + + const tileChecked = + typeof widget.tileVisible === "boolean" + ? widget.tileVisible + : widget.visible; + + return ( + // Row sized at 48 px (`min-h-12`) with 44-px mobile tap + // targets preserved on the trailing arrow buttons (`size-11`), + // shrunk to `sm:size-9` on desktop. v1.4.47 W4 — `isDragging` + // raises the row visually (elevation + accent ring) so the ghost + // overlay is unambiguous during a drag. +
+ + + {labels.widgetLabel} + + {/* On a phone the two switches and two arrows (~250 px) left the name + five characters ("Gewic…"). Below `sm` the controls wrap onto a + second line, right-aligned under the column heads, and the name + keeps the first line to itself. */} +
+ ); +} + +/** + * issue #581 — one row in the native-only group: the same two switches as a + * widget row above, without the drag handle or the arrow buttons. Placement + * of these widgets belongs to the client that draws them, so the web offers + * visibility and nothing it cannot honour. + */ +function StaticWidgetRow({ + widget, + disabled, + labels, + onToggleTile, + onToggleChart, +}: { + widget: { + id: DashboardWidgetCatalogueId; + visible: boolean; + tileVisible?: boolean; + order: number; + }; + disabled: boolean; + labels: { tileColumn: string; chartColumn: string; widgetLabel: string }; + onToggleTile: (id: DashboardWidgetCatalogueId, value: boolean) => void; + onToggleChart: (id: DashboardWidgetCatalogueId, value: boolean) => void; +}) { + const tileChecked = + typeof widget.tileVisible === "boolean" + ? widget.tileVisible + : widget.visible; + + return ( +
+ + {labels.widgetLabel} + +
+ onToggleTile(widget.id, v)} + aria-label={`${labels.widgetLabel}: ${labels.tileColumn}`} + disabled={disabled} + data-slot="widget-tile-switch" + /> +
+
+ onToggleChart(widget.id, v)} + aria-label={`${labels.widgetLabel}: ${labels.chartColumn}`} + disabled={disabled} + data-slot="widget-chart-switch" + /> +
+
+ ); +}