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.
This commit is contained in:
Marc-André Bombeck committed 2026-10-23 12:52:36 +02:00
1 parent 58215f3338
commit 25b5833b11
12 files changed
+1053 -920

No files matched your search

@@ -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,
@@ -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,
@@ -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,
@@ -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",
@@ -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 },
@@ -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");
@@ -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",
);
File diff suppressed because it is too large. Load diff
@@ -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 (
<fieldset
aria-describedby={`${heroDescriptionId} ${heroNotificationNoteId}`}
data-slot="hero-content-settings"
className="border-border bg-background/30 space-y-3 rounded-lg border px-3 py-3"
>
<legend className="text-foreground text-sm font-medium">
{t("dashboard.heroItemsTitle")}
</legend>
<p id={heroDescriptionId} className="text-muted-foreground text-xs">
{t("dashboard.heroItemsDescription")}
</p>
{/* The top card itself can be switched off. Shown is the
default; the choices below shape the card while it is shown. */}
<div className="flex min-h-11 items-center justify-between gap-3">
<Label htmlFor={todayCardSwitchId} className="text-sm font-medium">
{t("dashboard.todayCardVisible")}
</Label>
<Switch
id={todayCardSwitchId}
checked={layout.todayCardVisible !== false}
onCheckedChange={setTodayCardVisible}
disabled={saveMutation.isPending}
data-slot="today-card-switch"
/>
</div>
{/* 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. */}
<div className="space-y-1.5">
<Label htmlFor={heroContentSelectId} className="text-sm font-medium">
{t("dashboard.heroContentTitle")}
</Label>
<NativeSelect
id={heroContentSelectId}
value={layout.hero ?? "score"}
onChange={(event) => setHeroContent(event.target.value)}
disabled={saveMutation.isPending}
data-slot="hero-content-select"
className="sm:max-w-xs"
>
<option value="score">{t("dashboard.heroContentScore")}</option>
<option value="reminders">
{t("dashboard.heroContentReminders")}
</option>
</NativeSelect>
</div>
<div className="divide-border divide-y">
{PRIORITY_ITEM_KINDS.map((kind) => {
const inputId = `hero-item-${kind}`;
const checked = (
layout.enabledHeroItemKinds ?? PRIORITY_ITEM_KINDS
).includes(kind);
return (
<div
key={kind}
className="flex min-h-11 items-center justify-between gap-3 py-2"
>
<Label htmlFor={inputId} className="text-sm font-medium">
{t(HERO_ITEM_LABEL_KEYS[kind])}
</Label>
<Switch
id={inputId}
checked={checked}
onCheckedChange={(value) => toggleHeroItem(kind, value)}
disabled={saveMutation.isPending}
aria-label={t(HERO_ITEM_LABEL_KEYS[kind])}
data-slot="hero-item-switch"
data-kind={kind}
/>
</div>
);
})}
</div>
<p id={heroNotificationNoteId} className="text-foreground text-sm">
{t("dashboard.heroItemsNotificationNote")}
</p>
</fieldset>
);
}
@@ -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<DashboardWidgetId, string> = {
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<PriorityItemKind, string> = {
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",
};
@@ -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<DashboardLayoutWithToken>("/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<DashboardLayoutWithToken>(
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<DashboardLayout | null>(null);
const layout = draft ?? remote ?? null;
const saveMutation = useMutation({
scope: { id: DASHBOARD_WIDGETS_MUTATION_SCOPE },
mutationFn: async (next: DashboardLayout) => {
return apiPut<DashboardLayoutWithToken>("/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<DashboardLayoutWithToken>("/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<typeof useDashboardLayoutDraft>;
/**
* 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,
};
}
@@ -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<string>(IOS_PIN_ONLY_WIDGET_IDS);
const webWidgetIds = new Set<string>(
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 (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext items={sortedIds} strategy={verticalListSortingStrategy}>
{sortedWidgets.map((widget, index, arr) => (
<SortableWidgetRow
key={widget.id}
widget={widget}
labelKey={WIDGET_LABEL_KEYS[widget.id] ?? widget.id}
index={index}
total={arr.length}
dragHintId={dragHintId}
disabled={saveMutation.isPending}
labels={{
tileColumn: t("dashboard.layoutTileColumn"),
chartColumn: t("dashboard.layoutChartColumn"),
moveUp: t("dashboard.moveUp"),
moveDown: t("dashboard.moveDown"),
dragHandle: t("dashboard.dragHandle"),
widgetLabel: t(WIDGET_LABEL_KEYS[widget.id] ?? widget.id),
}}
onToggleTile={toggleTile}
onToggleChart={toggle}
onMove={move}
/>
))}
</SortableContext>
</DndContext>
);
}
/**
* 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<string>([
...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 (
<div
data-slot="native-only-widgets"
className="border-border/60 mt-6 space-y-2 border-t pt-4"
>
<p className="text-sm font-medium">
{t("dashboard.layoutNativeOnlyTitle")}
</p>
<p className="text-muted-foreground text-xs">
{t("dashboard.layoutNativeOnlyHint")}
</p>
<div className="text-muted-foreground flex items-center gap-2 px-3 pt-2 pb-1 text-xs font-medium tracking-wide uppercase">
<span className="flex-1" aria-hidden="true" />
<span className="w-12 text-center">
{t("dashboard.layoutTileColumn")}
</span>
<span className="w-12 text-center">
{t("dashboard.layoutChartColumn")}
</span>
</div>
{nativeWidgets.map((widget) => {
const label = t(NATIVE_ONLY_WIDGET_LABEL_KEYS[widget.id] ?? widget.id);
return (
<StaticWidgetRow
key={widget.id}
widget={widget}
disabled={saveMutation.isPending}
labels={{
tileColumn: t("dashboard.layoutTileColumn"),
chartColumn: t("dashboard.layoutChartColumn"),
widgetLabel: label,
}}
onToggleTile={toggleTile}
onToggleChart={toggle}
/>
);
})}
</div>
);
}
/**
* 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.
<div
ref={setNodeRef}
style={style}
data-slot="widget-row"
data-dragging={isDragging ? "true" : undefined}
className={`border-border bg-background/30 flex min-h-12 flex-wrap items-center gap-2 rounded-md border px-3 py-2 sm:flex-nowrap ${
isDragging ? "ring-primary z-10 opacity-90 shadow-lg ring-2" : ""
}`}
>
<button
type="button"
{...attributes}
{...listeners}
aria-label={`${labels.dragHandle}: ${labels.widgetLabel}`}
// `aria-describedby` is set after `{...attributes}`
// so our shared hint paragraph wins over dnd-kit's own announcer
// hookup. The announcer still fires on drag-start / drag-over /
// drag-end via the screenReaderInstructions slot below.
aria-describedby={dragHintId}
title={labels.dragHandle}
disabled={disabled}
data-slot="widget-drag-handle"
// W10 design-H1 — extend the WCAG 2.5.5 hit target to
// 44 × 44 px via a `::before` pseudo-element while keeping the
// visible GripVertical at 28 px (matches the Switch primitive
// pattern from v1.4.43 W5-H1). v1.4.47 W10 design-M1 — drop
// dnd-kit's CSS transition under prefers-reduced-motion.
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring focus-visible:ring-offset-background relative -m-1 inline-flex h-7 w-7 cursor-grab touch-none items-center justify-center rounded transition-colors before:absolute before:inset-[-8px] before:content-[''] focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none active:cursor-grabbing disabled:cursor-not-allowed disabled:opacity-50 motion-reduce:transition-none"
>
<GripVertical className="h-4 w-4" />
</button>
<span className="flex-1 truncate text-sm" title={labels.widgetLabel}>
{labels.widgetLabel}
</span>
{/* 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. */}
<span className="basis-full sm:hidden" aria-hidden="true" />
<div className="flex w-12 justify-center max-sm:ml-auto">
<Switch
checked={tileChecked}
onCheckedChange={(v) => onToggleTile(widget.id, v)}
aria-label={`${labels.widgetLabel}: ${labels.tileColumn}`}
disabled={disabled}
data-slot="widget-tile-switch"
/>
</div>
<div className="flex w-12 justify-center">
<Switch
checked={widget.visible}
onCheckedChange={(v) => onToggleChart(widget.id, v)}
aria-label={`${labels.widgetLabel}: ${labels.chartColumn}`}
disabled={disabled}
data-slot="widget-chart-switch"
/>
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="size-11 sm:size-9"
onClick={() => onMove(widget.id, -1)}
disabled={index === 0 || disabled}
aria-label={labels.moveUp}
>
<ArrowUp className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="size-11 sm:size-9"
onClick={() => onMove(widget.id, 1)}
disabled={index === total - 1 || disabled}
aria-label={labels.moveDown}
>
<ArrowDown className="h-4 w-4" />
</Button>
</div>
);
}
/**
* 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 (
<div
data-slot="native-widget-row"
data-widget-id={widget.id}
className="border-border bg-background/30 flex min-h-12 items-center gap-2 rounded-md border px-3 py-2"
>
<span className="flex-1 truncate text-sm" title={labels.widgetLabel}>
{labels.widgetLabel}
</span>
<div className="flex w-12 justify-center">
<Switch
checked={tileChecked}
onCheckedChange={(v) => onToggleTile(widget.id, v)}
aria-label={`${labels.widgetLabel}: ${labels.tileColumn}`}
disabled={disabled}
data-slot="widget-tile-switch"
/>
</div>
<div className="flex w-12 justify-center">
<Switch
checked={widget.visible}
onCheckedChange={(v) => onToggleChart(widget.id, v)}
aria-label={`${labels.widgetLabel}: ${labels.chartColumn}`}
disabled={disabled}
data-slot="widget-chart-switch"
/>
</div>
</div>
);
}