mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
The dashboard now leads with a daily band: the greeting, the one item that matters right now — an overdue or upcoming dose, a very high blood-pressure reading, weight drifting from its range, a run of short nights, a quiet stretch, a score drop, or the briefing's key finding — with a single action, today's dose tally beside the next due time, and the health score as a ring on the right. The verdict resolves from a fixed nine-rung ladder on the client with the thresholds shared from the nudge engine through a dependency-free module, the blood-pressure rung uses fixed crisis floors rather than personal target ranges, and a cached next-due in the past never renders as overdue. The band reserves its exact footprint from the first paint, swaps in with the tiles in one pass, respects the hour-format preference, and can be turned off in the dashboard settings; the greeting moves out of the page header into the band.
719 lines
27 KiB
TypeScript
719 lines
27 KiB
TypeScript
"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<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",
|
||
};
|
||
|
||
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<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);
|
||
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);
|
||
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 (
|
||
<div
|
||
id={id}
|
||
className="bg-card border-border scroll-mt-28 space-y-4 rounded-xl border p-4 sm:p-6"
|
||
>
|
||
{/* 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. */}
|
||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-3">
|
||
<div className="flex items-center gap-2">
|
||
<LayoutDashboard className="text-muted-foreground h-5 w-5" />
|
||
<h2 className="text-lg font-semibold">
|
||
{t("dashboard.customizeTitle")}
|
||
</h2>
|
||
</div>
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
onClick={() => resetMutation.mutate()}
|
||
disabled={resetMutation.isPending}
|
||
className="self-end sm:self-auto"
|
||
>
|
||
<RotateCcw className="h-3.5 w-3.5" />
|
||
{t("dashboard.layoutReset")}
|
||
</Button>
|
||
</div>
|
||
{/*
|
||
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 !== false}
|
||
onCheckedChange={(v) => setHeroVisible(v)}
|
||
disabled={saveMutation.isPending}
|
||
data-slot="hero-visible-switch"
|
||
/>
|
||
</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>
|
||
) : (
|
||
<div className="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),
|
||
)
|
||
.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"
|
||
onClick={() => setDraft(null)}
|
||
disabled={saveMutation.isPending}
|
||
>
|
||
{t("common.cancel")}
|
||
</Button>
|
||
<Button
|
||
size="sm"
|
||
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>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 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>
|
||
);
|
||
}
|