Files
HealthLog/src/components/settings/dashboard-layout-section.tsx
T
Marc-André Bombeck ae57025728 chore(release): v1.28.29 — dashboard edits show up on the way back; one scrollbar again
The third and final layer of the stale-tile bug: saving invalidated the
dashboard snapshot with the default refetchType 'active', but the dashboard
page is unmounted while the user sits in settings — the cell only went stale,
and the snapshot query's deliberate refetchOnMount: false then suppressed the
re-read on same-tab navigation back. The save, reset and ring mutations now
invalidate with refetchType 'all', refreshing the cell mounted or not
(proven live in one SPA session against the real query client).

The sortable settings editors (dashboard layout, medication order, modules,
mood tags x2) painted a second page scrollbar: the sr-only drag hint below
each list is position: absolute, no ancestor was positioned, so it resolved
against the initial containing block, escaped the shell's overflow clipping
and lengthened the DOCUMENT by its static offset. The list wrappers are now
relative; the e2e overscroll guard asserts the one-scroll-floor on these
routes and was negative-tested against the revert.
2026-07-11 12:04:53 +02:00

1158 lines
45 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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,
type ScoreRingId,
type HeroRingId,
COMPARISON_BASELINES,
DEFAULT_DASHBOARD_LAYOUT,
DASHBOARD_WIDGET_IDS,
IOS_PIN_ONLY_WIDGET_IDS,
SCORE_RING_IDS,
SCORE_RING_MODULE,
MAX_SELECTED_SCORE_RINGS,
HEALTH_SCORE_RING_ID,
resolveHeroRingOrder,
} from "@/lib/dashboard-layout";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { apiDelete, apiGet, apiPut } from "@/lib/api/api-fetch";
import { useAuth } from "@/hooks/use-auth";
import { WIDGET_MODULE_BY_ID } from "@/lib/dashboard/widget-modules";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
/**
* 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<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",
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",
// v1.18.2 — Vorsorge preventive-care summary card.
vorsorge: "measurementReminders.sectionTitle",
};
/**
* v1.27.7 — option labels for the hero score-ring picker. The three
* derived rings reuse their wellness-strip titles; the dose ring names
* today's tally (the exact thing the ring shows since it moved off the
* 7-day adherence percentage).
*/
const SCORE_RING_LABEL_KEYS: Record<ScoreRingId, string> = {
READINESS: "insights.derived.composite.READINESS.title",
RECOVERY_SCORE: "insights.derived.scores.recovery",
SLEEP_SCORE: "insights.derived.composite.SLEEP_SCORE.title",
MED_COMPLIANCE: "dashboard.hero.ringDoses",
};
/**
* v1.27.27 — label key for any hero ring id, including the always-present
* health-score anchor ring. Reuses the hero's own "Health Score" label so
* the picker row and the hero ring name match verbatim.
*/
const HERO_RING_LABEL_KEYS: Record<HeroRingId, string> = {
[HEALTH_SCORE_RING_ID]: "dashboard.hero.scoreLabel",
...SCORE_RING_LABEL_KEYS,
};
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 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<DashboardLayout>("/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<DashboardLayout | null>(null);
const layout = draft ?? remote ?? null;
const saveMutation = useMutation({
mutationFn: async (next: DashboardLayout) => {
return apiPut<DashboardLayout>("/api/dashboard/widgets", next);
},
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",
});
setDraft(null);
toast.success(t("dashboard.layoutSaveSuccess"));
},
onError: () => toast.error(t("dashboard.layoutSaveError")),
});
const resetMutation = useMutation({
mutationFn: async () => {
return apiDelete<DashboardLayout>("/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",
});
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.27.8 — the ring choices apply INSTANTLY, outside the draft/Save
* machine the rest of the section keeps. The first cut routed ring
* toggles through the draft, and the combination of an unflushed
* draft, the server snapshot cache, and client staleness read as
* "flipping does nothing, rings appear later". The mutation PUTs the
* SERVER layout copy with only `selectedScoreRings` changed (an open
* draft's other edits stay unsent and untouched), optimistically
* updates the widgets query, and invalidates the dashboard snapshot on
* settle so the hero reflects the choice on the next visit.
*/
const ringMutation = useMutation({
mutationFn: async (next: {
selectedScoreRings: ScoreRingId[];
heroRingOrder: HeroRingId[];
}) => {
if (!remote) throw new Error("layout not loaded");
return apiPut<DashboardLayout>("/api/dashboard/widgets", {
...remote,
selectedScoreRings: next.selectedScoreRings,
heroRingOrder: next.heroRingOrder,
});
},
onMutate: async (next) => {
await queryClient.cancelQueries({
queryKey: queryKeys.dashboardWidgets(),
});
const previous = queryClient.getQueryData<DashboardLayout>(
queryKeys.dashboardWidgets(),
);
if (previous) {
queryClient.setQueryData(queryKeys.dashboardWidgets(), {
...previous,
selectedScoreRings: next.selectedScoreRings,
heroRingOrder: next.heroRingOrder,
});
}
return { previous };
},
onError: (_err, _next, context) => {
if (context?.previous) {
queryClient.setQueryData(
queryKeys.dashboardWidgets(),
context.previous,
);
}
toast.error(t("dashboard.layoutSaveError"));
},
onSuccess: (saved) => {
queryClient.setQueryData(queryKeys.dashboardWidgets(), saved);
// `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",
});
},
});
/**
* v1.27.27 — apply a new hero ring ORDER instantly (see `ringMutation`).
* The order is the single source of truth: `selectedScoreRings` is
* derived from it (order minus the always-present health-score ring) so
* the server resolver and the hero read one consistent sequence.
*/
function applyHeroRingOrder(next: HeroRingId[]) {
if (!remote) return;
const selectedScoreRings = next.filter(
(id): id is ScoreRingId => id !== HEALTH_SCORE_RING_ID,
);
// Keep an open draft's ring slice in sync so a later Save of the
// OTHER layout edits can't revert the instantly-applied choice.
if (draft) setDraft({ ...draft, selectedScoreRings, heroRingOrder: next });
ringMutation.mutate({ selectedScoreRings, heroRingOrder: next });
}
/** The reconciled hero ring order (health-score anchor + selected rings). */
function currentHeroOrder(l: DashboardLayout): HeroRingId[] {
return resolveHeroRingOrder(l.heroRingOrder, l.selectedScoreRings ?? []);
}
/**
* v1.27.7 — toggle one hero score ring on/off. Enabling appends to the
* order (capped at `MAX_SELECTED_SCORE_RINGS`; the remaining switches
* disable at the cap); disabling drops it from the order. The
* health-score ring has no toggle — it is the always-present anchor.
* v1.27.8 / v1.27.27 — applies instantly via `applyHeroRingOrder`.
*/
function toggleScoreRing(ringId: ScoreRingId, enabled: boolean) {
if (!layout) return;
const order = currentHeroOrder(layout);
if (enabled) {
if (order.includes(ringId)) return;
const selectedCount = order.filter(
(id) => id !== HEALTH_SCORE_RING_ID,
).length;
if (selectedCount >= MAX_SELECTED_SCORE_RINGS) return;
applyHeroRingOrder([...order, ringId]);
} else {
applyHeroRingOrder(order.filter((id) => id !== ringId));
}
}
/**
* v1.27.8 / v1.27.27 — reorder the hero rings (the array order IS the
* render order on the hero; the health-score anchor moves like any other
* ring). Same interaction pair as the widget rows below: drag handle +
* arrow buttons.
*/
function moveHeroRing(ringId: HeroRingId, delta: -1 | 1) {
if (!layout) return;
const order = currentHeroOrder(layout);
const idx = order.indexOf(ringId);
const target = idx + delta;
if (idx < 0 || target < 0 || target >= order.length) return;
const next = [...order];
[next[idx], next[target]] = [next[target], next[idx]];
applyHeroRingOrder(next);
}
function handleRingDragEnd(event: DragEndEvent) {
if (!layout) return;
const { active, over } = event;
if (!over || active.id === over.id) return;
const order = currentHeroOrder(layout);
const from = order.indexOf(active.id as HeroRingId);
const to = order.indexOf(over.id as HeroRingId);
if (from < 0 || to < 0) return;
const next = [...order];
const [moved] = next.splice(from, 1);
next.splice(to, 0, moved);
applyHeroRingOrder(next);
}
/**
* 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 (
<SettingsCard id={id} className="scroll-mt-28 space-y-4">
{/* 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. */}
<SettingsCardHeader
icon={LayoutDashboard}
title={t("dashboard.customizeTitle")}
status={
<Button
variant="ghost"
size="sm"
onClick={() => resetMutation.mutate()}
disabled={resetMutation.isPending}
className="min-h-11 self-end sm:min-h-9 sm:self-auto"
>
<RotateCcw className="h-3.5 w-3.5" />
{t("dashboard.layoutReset")}
</Button>
}
/>
{/*
v1.4.22 D / F-32 — the surrounding `<DashboardSection>` 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 && (
<div className="space-y-2">
<label
htmlFor="comparison-baseline"
className="text-foreground text-sm font-medium"
>
{t("comparison.toggleLabel")}
</label>
<Select
value={layout.comparisonBaseline ?? "none"}
onValueChange={(value) =>
setComparisonBaseline(value as ComparisonBaseline)
}
disabled={saveMutation.isPending}
>
<SelectTrigger
id="comparison-baseline"
className="w-full sm:w-72"
data-slot="comparison-baseline-trigger"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{COMPARISON_BASELINES.map((value) => (
<SelectItem
key={value}
value={value}
data-slot={`comparison-baseline-option-${value}`}
>
{t(`comparison.baseline.${value}`)}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-muted-foreground text-xs">
{t("comparison.toggleHint")}
</p>
</div>
)}
{/* 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 && (
<div className="border-border bg-background/30 flex min-h-12 items-center justify-between gap-3 rounded-md border px-3 py-2">
<div className="min-w-0">
<label
htmlFor={heroToggleId}
className="text-foreground text-sm font-medium"
>
{t("dashboard.heroToggleLabel")}
</label>
<p className="text-muted-foreground text-xs">
{t("dashboard.heroToggleDescription")}
</p>
</div>
<Switch
id={heroToggleId}
checked={layout.heroVisible === true}
onCheckedChange={(v) => setHeroVisible(v)}
disabled={saveMutation.isPending}
data-slot="hero-visible-switch"
/>
</div>
)}
{/* v1.27.7 — hero score-ring picker. Only rendered while the hero
is on (the rings live inside the hero band, so the picker would
be a dead control otherwise). Offers only rings whose owning
module is enabled — the WIDGET_MODULE_BY_ID gating pattern; the
three derived rings additionally sit behind the insights
module, mirroring the derived routes. Selection caps at
MAX_SELECTED_SCORE_RINGS; unchecked switches disable at the cap.
v1.27.8 — choices apply instantly (no Save round) and the
selected rings reorder with the same drag-handle + arrow pair
as the widget rows below; the array order is the hero order. */}
{layout && layout.heroVisible === true && (
<div
data-slot="score-rings-picker"
className="border-border bg-background/30 space-y-3 rounded-md border px-3 py-2"
>
<div className="min-w-0">
<p className="text-foreground text-sm font-medium">
{t("dashboard.scoreRingsTitle")}
</p>
<p className="text-muted-foreground text-xs">
{t("dashboard.scoreRingsDescription")}
</p>
</div>
{(() => {
const available = SCORE_RING_IDS.filter((ringId) => {
if (modules?.[SCORE_RING_MODULE[ringId]] === false) return false;
if (ringId !== "MED_COMPLIANCE" && modules?.insights === false)
return false;
return true;
});
const selected = (layout.selectedScoreRings ?? []).filter(
(ringId) => available.includes(ringId),
);
// v1.27.27 — the reorderable list is the health-score anchor
// ring plus the selected score rings, in the persisted order
// (health-score first by default). The health-score row has no
// on/off switch — it is always present — but drags/moves like
// any other ring.
const heroOrder = resolveHeroRingOrder(
layout.heroRingOrder,
selected,
);
const unselected = available.filter(
(ringId) => !heroOrder.includes(ringId),
);
const atCap = selected.length >= MAX_SELECTED_SCORE_RINGS;
const busy = ringMutation.isPending;
return (
<div className="space-y-2">
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleRingDragEnd}
>
<SortableContext
items={heroOrder}
strategy={verticalListSortingStrategy}
>
{heroOrder.map((ringId, index) => (
<SortableRingRow
key={ringId}
ringId={ringId}
label={t(HERO_RING_LABEL_KEYS[ringId])}
index={index}
total={heroOrder.length}
dragHintId={dragHintId}
disabled={busy}
// The health-score ring is the anchor — no toggle.
pinned={ringId === HEALTH_SCORE_RING_ID}
moveUpLabel={t("dashboard.moveUp")}
moveDownLabel={t("dashboard.moveDown")}
dragHandleLabel={t("dashboard.dragHandle")}
onToggle={toggleScoreRing}
onMove={moveHeroRing}
/>
))}
</SortableContext>
</DndContext>
{unselected.map((ringId) => (
<div
key={ringId}
className="flex min-h-9 items-center justify-between gap-3 px-1"
>
<span className="text-foreground truncate text-sm">
{t(SCORE_RING_LABEL_KEYS[ringId])}
</span>
<Switch
checked={false}
onCheckedChange={(v) => toggleScoreRing(ringId, v)}
disabled={busy || atCap}
aria-label={t(SCORE_RING_LABEL_KEYS[ringId])}
data-slot="score-ring-switch"
data-ring={ringId}
/>
</div>
))}
</div>
);
})()}
</div>
)}
{isLoading || !layout ? (
<div className="text-muted-foreground flex items-center gap-2 text-sm">
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
{t("common.loading")}
</div>
) : (
// `relative` anchors the sr-only drag-hint paragraph below: Tailwind's
// `sr-only` is `position: absolute`, and without a positioned ancestor
// its containing block is the initial containing block — the 1px box
// then sits at its static position (below this long list) in DOCUMENT
// coordinates and silently makes the document itself scrollable, a
// second vertical scrollbar next to the shell-owned `<main>` scroll
// (UI-STANDARDS §9 one-scroll-floor; guarded by
// e2e/settings-overscroll.spec.ts).
<div className="relative space-y-2">
{/* 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. */}
<div className="text-muted-foreground flex items-center gap-2 px-3 pb-1 text-[10px] font-medium tracking-wide uppercase">
<span className="w-7" aria-hidden="true" />
<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>
<span className="w-22 sm:w-18" aria-hidden="true" />
</div>
{(() => {
// 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<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),
)
// v1.18.0 — hide a widget toggle whose owning module is
// disabled. Map the widget id → ModuleKey FIRST (undefined =
// core widget = always shown), THEN check the module map.
// Fail-open: only an explicit `false` hides the row.
.filter((w) => {
const moduleKey = WIDGET_MODULE_BY_ID[w.id];
return !moduleKey || modules?.[moduleKey] !== false;
})
.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>
);
})()}
{/* 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 && (
<p id={dragHintId} className="text-muted-foreground sr-only">
{t("dashboard.dragHandleHint")}
</p>
)}
</div>
)}
{dirty && (
<div className="flex items-center justify-end gap-2">
<Button
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
onClick={() => setDraft(null)}
disabled={saveMutation.isPending}
>
{t("common.cancel")}
</Button>
<Button
size="sm"
className="min-h-11 sm:min-h-9"
onClick={() => layout && saveMutation.mutate(layout)}
disabled={saveMutation.isPending}
>
{saveMutation.isPending && (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("common.save")}
</Button>
</div>
)}
{!dirty && remote && (
<p className="text-muted-foreground text-xs">
{layout &&
JSON.stringify(layout.widgets) ===
JSON.stringify(DEFAULT_DASHBOARD_LAYOUT.widgets)
? t("dashboard.layoutUsingDefaults")
: t("dashboard.layoutCustomized")}
</p>
)}
</SettingsCard>
);
}
/**
* 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.
<div
ref={setNodeRef}
style={style}
data-slot="widget-row"
data-dragging={isDragging ? "true" : undefined}
className={`border-border bg-background/30 flex min-h-12 items-center gap-2 rounded-md border px-3 py-2 ${
isDragging ? "ring-primary z-10 opacity-90 shadow-lg ring-2" : ""
}`}
>
<button
type="button"
{...attributes}
{...listeners}
aria-label={`${labels.dragHandle} — ${labels.widgetLabel}`}
// v1.4.47 W4 — `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"
// v1.4.47 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>
<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>
<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>
);
}
/**
* v1.27.8 — sortable row for one SELECTED hero score ring. Mirrors the
* `SortableWidgetRow` interaction exactly (drag handle + arrow buttons +
* switch) so the two lists in this section share one sorting language;
* only the payload differs — the ring order is the `selectedScoreRings`
* array itself, applied instantly through the ring mutation.
*/
function SortableRingRow({
ringId,
label,
index,
total,
dragHintId,
disabled,
pinned = false,
moveUpLabel,
moveDownLabel,
dragHandleLabel,
onToggle,
onMove,
}: {
ringId: HeroRingId;
label: string;
index: number;
total: number;
dragHintId: string;
disabled: boolean;
/**
* v1.27.27 — the health-score anchor ring: always present, no on/off
* switch (a disabled, checked switch communicates the locked-on state),
* but reorderable like any other ring.
*/
pinned?: boolean;
moveUpLabel: string;
moveDownLabel: string;
dragHandleLabel: string;
onToggle: (id: ScoreRingId, value: boolean) => void;
onMove: (id: HeroRingId, delta: -1 | 1) => void;
}) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: ringId });
const style: React.CSSProperties = {
transform: CSS.Transform.toString(transform),
transition: prefersReducedMotion() ? "none" : transition,
};
return (
<div
ref={setNodeRef}
style={style}
data-slot="score-ring-row"
data-ring={ringId}
data-dragging={isDragging ? "true" : undefined}
className={`border-border bg-background/30 flex min-h-12 items-center gap-2 rounded-md border px-3 py-2 ${
isDragging ? "ring-primary z-10 opacity-90 shadow-lg ring-2" : ""
}`}
>
<button
type="button"
{...attributes}
{...listeners}
aria-label={`${dragHandleLabel} — ${label}`}
aria-describedby={dragHintId}
title={dragHandleLabel}
disabled={disabled}
data-slot="score-ring-drag-handle"
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={label}>
{label}
</span>
<Switch
checked
onCheckedChange={
pinned ? undefined : (v) => onToggle(ringId as ScoreRingId, v)
}
// The health-score anchor cannot be switched off — a disabled,
// checked switch reads as "always on, locked".
disabled={disabled || pinned}
aria-label={label}
data-slot="score-ring-switch"
data-ring={ringId}
/>
<Button
type="button"
variant="ghost"
size="icon"
className="size-11 sm:size-9"
onClick={() => onMove(ringId, -1)}
disabled={index === 0 || disabled}
aria-label={moveUpLabel}
>
<ArrowUp className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="size-11 sm:size-9"
onClick={() => onMove(ringId, 1)}
disabled={index === total - 1 || disabled}
aria-label={moveDownLabel}
>
<ArrowDown className="h-4 w-4" />
</Button>
</div>
);
}