mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
feat(settings): apply hero ring choices instantly and make the selection sortable
The ring toggles rode the section's draft/Save machine; combined with the server snapshot cache and client staleness this read as 'flipping does nothing, rings appear later'. Ring choices now bypass the draft: a dedicated mutation PUTs the server layout copy with only selectedScoreRings changed (an open draft's other edits stay unsent, its ring slice is kept in sync), optimistically updates the widgets query, rolls back on error, and invalidates the dashboard snapshot on success. The other layout controls keep the draft/Save flow. Selected rings are now reorderable with the same drag-handle + arrow interaction as the widget rows below — the array order is the hero render order, which the server already preserves. Unselected rings stay plain switch rows and disable at the three-ring cap.
This commit is contained in:
1 parent
47d638cf02
commit
8c22d66dd9
1 file changed
+263
-30
@@ -300,12 +300,71 @@ export function DashboardLayoutSection({ id }: { id: string }) {
|
||||
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: ScoreRingId[]) => {
|
||||
if (!remote) throw new Error("layout not loaded");
|
||||
return apiPut<DashboardLayout>("/api/dashboard/widgets", {
|
||||
...remote,
|
||||
selectedScoreRings: next,
|
||||
});
|
||||
},
|
||||
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,
|
||||
});
|
||||
}
|
||||
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);
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.dashboardSnapshot(),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
/** Apply a new ring selection/order instantly (see `ringMutation`). */
|
||||
function applyScoreRings(next: ScoreRingId[]) {
|
||||
if (!remote) return;
|
||||
// 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: next });
|
||||
ringMutation.mutate(next);
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.27.7 — toggle one hero score ring. Selection order is preserved
|
||||
* (a newly-enabled ring appends), the count is capped at
|
||||
* `MAX_SELECTED_SCORE_RINGS` (the remaining switches disable at the
|
||||
* cap), and the choice persists as `selectedScoreRings` on the layout
|
||||
* blob through the same PUT the widget toggles use.
|
||||
* cap). v1.27.8 — applies instantly via `applyScoreRings`.
|
||||
*/
|
||||
function toggleScoreRing(ringId: ScoreRingId, enabled: boolean) {
|
||||
if (!layout) return;
|
||||
@@ -315,7 +374,37 @@ export function DashboardLayoutSection({ id }: { id: string }) {
|
||||
? current
|
||||
: [...current, ringId].slice(0, MAX_SELECTED_SCORE_RINGS)
|
||||
: current.filter((id) => id !== ringId);
|
||||
setDraft({ ...layout, selectedScoreRings: next });
|
||||
applyScoreRings(next);
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.27.8 — reorder the selected rings (the array order IS the render
|
||||
* order on the hero; the server preserves it). Same interaction pair
|
||||
* as the widget rows below: drag handle + arrow buttons.
|
||||
*/
|
||||
function moveScoreRing(ringId: ScoreRingId, delta: -1 | 1) {
|
||||
if (!layout) return;
|
||||
const current = layout.selectedScoreRings ?? [];
|
||||
const idx = current.indexOf(ringId);
|
||||
const target = idx + delta;
|
||||
if (idx < 0 || target < 0 || target >= current.length) return;
|
||||
const next = [...current];
|
||||
[next[idx], next[target]] = [next[target], next[idx]];
|
||||
applyScoreRings(next);
|
||||
}
|
||||
|
||||
function handleRingDragEnd(event: DragEndEvent) {
|
||||
if (!layout) return;
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
const current = layout.selectedScoreRings ?? [];
|
||||
const from = current.indexOf(active.id as ScoreRingId);
|
||||
const to = current.indexOf(over.id as ScoreRingId);
|
||||
if (from < 0 || to < 0) return;
|
||||
const next = [...current];
|
||||
const [moved] = next.splice(from, 1);
|
||||
next.splice(to, 0, moved);
|
||||
applyScoreRings(next);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -480,7 +569,10 @@ export function DashboardLayoutSection({ id }: { id: string }) {
|
||||
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. */}
|
||||
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"
|
||||
@@ -494,37 +586,71 @@ export function DashboardLayoutSection({ id }: { id: string }) {
|
||||
{t("dashboard.scoreRingsDescription")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{SCORE_RING_IDS.filter((ringId) => {
|
||||
{(() => {
|
||||
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;
|
||||
}).map((ringId) => {
|
||||
const selected = layout.selectedScoreRings ?? [];
|
||||
const checked = selected.includes(ringId);
|
||||
const atCap =
|
||||
!checked && selected.length >= MAX_SELECTED_SCORE_RINGS;
|
||||
return (
|
||||
<div
|
||||
key={ringId}
|
||||
className="flex min-h-9 items-center justify-between gap-3"
|
||||
});
|
||||
const selected = (layout.selectedScoreRings ?? []).filter(
|
||||
(ringId) => available.includes(ringId),
|
||||
);
|
||||
const unselected = available.filter(
|
||||
(ringId) => !selected.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}
|
||||
>
|
||||
<span className="text-foreground truncate text-sm">
|
||||
{t(SCORE_RING_LABEL_KEYS[ringId])}
|
||||
</span>
|
||||
<Switch
|
||||
checked={checked}
|
||||
onCheckedChange={(v) => toggleScoreRing(ringId, v)}
|
||||
disabled={saveMutation.isPending || atCap}
|
||||
aria-label={t(SCORE_RING_LABEL_KEYS[ringId])}
|
||||
data-slot="score-ring-switch"
|
||||
data-ring={ringId}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<SortableContext
|
||||
items={selected}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{selected.map((ringId, index) => (
|
||||
<SortableRingRow
|
||||
key={ringId}
|
||||
ringId={ringId}
|
||||
label={t(SCORE_RING_LABEL_KEYS[ringId])}
|
||||
index={index}
|
||||
total={selected.length}
|
||||
dragHintId={dragHintId}
|
||||
disabled={busy}
|
||||
moveUpLabel={t("dashboard.moveUp")}
|
||||
moveDownLabel={t("dashboard.moveDown")}
|
||||
dragHandleLabel={t("dashboard.dragHandle")}
|
||||
onToggle={toggleScoreRing}
|
||||
onMove={moveScoreRing}
|
||||
/>
|
||||
))}
|
||||
</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>
|
||||
)}
|
||||
|
||||
@@ -838,3 +964,110 @@ function SortableWidgetRow({
|
||||
</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,
|
||||
moveUpLabel,
|
||||
moveDownLabel,
|
||||
dragHandleLabel,
|
||||
onToggle,
|
||||
onMove,
|
||||
}: {
|
||||
ringId: ScoreRingId;
|
||||
label: string;
|
||||
index: number;
|
||||
total: number;
|
||||
dragHintId: string;
|
||||
disabled: boolean;
|
||||
moveUpLabel: string;
|
||||
moveDownLabel: string;
|
||||
dragHandleLabel: string;
|
||||
onToggle: (id: ScoreRingId, value: boolean) => void;
|
||||
onMove: (id: ScoreRingId, 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={(v) => onToggle(ringId, v)}
|
||||
disabled={disabled}
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user