"use client"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useId, useState } from "react"; import { toast } from "sonner"; 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 { Switch } from "@/components/ui/switch"; import { prefersReducedMotion } from "@/lib/charts/reduced-motion"; import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import { type DashboardLayout, type DashboardWidgetId, type ComparisonBaseline, COMPARISON_BASELINES, DEFAULT_DASHBOARD_LAYOUT, DASHBOARD_WIDGET_IDS, IOS_PIN_ONLY_WIDGET_IDS, } from "@/lib/dashboard-layout"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { apiDelete, apiGet, apiPut } from "@/lib/api/api-fetch"; /** * v1.4.47 W4 — 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 M6a — 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 })); } /** * v1.4.48 M6a — 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 }; }); } 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", bpInTarget: "dashboard.bpInTarget", oxygenSaturation: "measurements.typeOxygenSaturation", achievements: "achievements.title", // v1.4.25 W8d — 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 B5 — 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", }; export function DashboardLayoutSection({ id }: { id: string }) { const { t } = useTranslations(); const queryClient = useQueryClient(); // v1.4.47 W4 — 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. const dragHintId = useId(); // Label ↔ switch hookup for the hero (Tagesüberblick) toggle below. const heroToggleId = useId(); // v1.4.47 W4 — 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"); }, }); // 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({ mutationFn: async (next: DashboardLayout) => { return apiPut("/api/dashboard/widgets", next); }, onSuccess: (saved) => { queryClient.setQueryData(queryKeys.dashboardWidgets(), saved); setDraft(null); toast.success(t("dashboard.layoutSaveSuccess")); }, onError: () => toast.error(t("dashboard.layoutSaveError")), }); const resetMutation = useMutation({ mutationFn: async () => { return apiDelete("/api/dashboard/widgets"); }, onSuccess: (saved) => { queryClient.setQueryData(queryKeys.dashboardWidgets(), saved); setDraft(null); toast.success(t("dashboard.layoutResetSuccess")); }, }); function toggle(widgetId: DashboardWidgetId, visible: boolean) { if (!layout) return; setDraft({ ...layout, widgets: layout.widgets.map((w) => w.id === widgetId ? { ...w, visible } : w, ), }); } /** * v1.4.15 Fix 5 — 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: DashboardWidgetId, tileVisible: boolean) { if (!layout) return; setDraft({ ...layout, widgets: layout.widgets.map((w) => w.id === widgetId ? { ...w, tileVisible } : w, ), }); } /** * v1.4.16 phase B8 — comparison baseline picker. The toggle persists * via the same `/api/dashboard/widgets` PUT the existing layout * controls already use; saving rides through the same `Save` button. */ function setComparisonBaseline(value: ComparisonBaseline) { if (!layout) return; setDraft({ ...layout, comparisonBaseline: value }); } /** * Dashboard hero (daily verdict) visibility. Rides the layout blob * (`heroVisible`, default on) and persists through the same PUT the * widget toggles use — the Save button flushes it. */ function setHeroVisible(value: boolean) { if (!layout) return; setDraft({ ...layout, heroVisible: value }); } /** * v1.4.48 M6a — 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 W4 — 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; return (
{/* v1.4.19 A6 — title / action row uses the same stack-on-mobile, right-align-on-desktop contract as Account → Password + Restart onboarding tour. Avoids the long German "Auf Standard zurücksetzen" copy clipping the card edge at narrow widths. */}

{t("dashboard.customizeTitle")}

{/* v1.4.22 D / F-32 — the surrounding `` page already renders a `settings.sections.dashboard.description` paragraph beneath the H1. The previous in-card help line repeated the same idea ("Choose which cards appear … Defaults work out of the box.") right next to the comparison picker, giving the page three muted-foreground help blocks stacked on top of each other. Removing this duplicate keeps the comparison picker as the only thing between the header and the widget table. */} {/* v1.4.16 phase B8 — comparison baseline picker. Lives at the top of the section because it changes how every chart + tile below renders, not just one. v1.4.19 A6: height standardised to the shared 36-px input contract used by every other Settings input / select; the previous `min-h-11` (44 px) made this trigger look like an outlier next to the 36-px Username/Email/Date-of- birth fields one card up. */} {layout && (

{t("comparison.toggleHint")}

)} {/* Hero (Tagesüberblick) visibility — one switch above the widget list because the band sits above every widget on the dashboard. Persists as `heroVisible` on the layout blob through the same PUT mutation the rows below use (Save flushes the draft). */} {layout && (

{t("dashboard.heroToggleDescription")}

setHeroVisible(v)} disabled={saveMutation.isPending} data-slot="hero-visible-switch" />
)} {isLoading || !layout ? (
{t("common.loading")}
) : (
{/* v1.4.15 Fix 5 — 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 W4 — 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. */}
{(() => { // 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 HIGH-1 — 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), ) .sort((a, b) => a.order - b.order); const sortedIds = sortedWidgets.map((w) => w.id); return ( {sortedWidgets.map((widget, index, arr) => ( ))} ); })()} {/* v1.4.47 W4 — 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 L8 — gate on widget count so an empty layout never orphans the paragraph (no handles to describe). */} {layout.widgets.length > 0 && (

{t("dashboard.dragHandleHint")}

)}
)} {dirty && (
)} {!dirty && remote && (

{layout && JSON.stringify(layout.widgets) === JSON.stringify(DEFAULT_DASHBOARD_LAYOUT.widgets) ? t("dashboard.layoutUsingDefaults") : t("dashboard.layoutCustomized")}

)}
); } /** * v1.4.47 W4 — 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 L7 — 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}
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" />
); }