mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
fix(dashboard): keep a saved layout authoritative against a concurrent ring save
A dashboard layout change could be silently reverted when a hero score-ring toggle was made around the same moment. Both writes PUT the whole layout to /api/dashboard/widgets, and whichever reached the server last won, so a ring write built from a pre-save snapshot overwrote a fresher Save. Guard the write with optimistic concurrency. The client sends the updatedAt it based its edit on; the route writes conditionally on the stored row still carrying that token (updateMany where updatedAt = base) and returns 409 with a typed body on a stale base, writing nothing. The advanced token rides back on every GET/PUT response so the client can rebase. No migration: the User row already has an @updatedAt column. On the client, the ring PUT carries only its own fields plus the base token, the Save and ring writes share one mutation scope so they serialise, the Save button and ring controls disable while either write is in flight, and a 409 refetches the winning layout and asks the user to redo the change. Refs #581
This commit is contained in:
1 parent
872fe047e3
commit
69359a609a
9 files changed
+218
-25
No files matched your search
@@ -454,6 +454,7 @@
|
||||
"layoutReset": "Auf Defaults zurücksetzen",
|
||||
"layoutSaveSuccess": "Dashboard-Layout gespeichert",
|
||||
"layoutSaveError": "Layout konnte nicht gespeichert werden",
|
||||
"layoutConflictReloaded": "Layout wurde vom Server neu geladen. Bitte nimm deine Änderung erneut vor.",
|
||||
"layoutResetSuccess": "Auf Standard-Layout zurückgesetzt",
|
||||
"layoutUsingDefaults": "Standard-Layout aktiv",
|
||||
"layoutCustomized": "Eigenes Layout aktiv",
|
||||
|
||||
@@ -444,6 +444,7 @@
|
||||
"layoutReset": "Reset to defaults",
|
||||
"layoutSaveSuccess": "Dashboard layout saved",
|
||||
"layoutSaveError": "Could not save dashboard layout",
|
||||
"layoutConflictReloaded": "Layout reloaded from the server. Please make your change again.",
|
||||
"layoutResetSuccess": "Reset to default layout",
|
||||
"layoutUsingDefaults": "Using default layout",
|
||||
"layoutCustomized": "Custom layout active",
|
||||
|
||||
@@ -456,6 +456,7 @@
|
||||
"layoutReset": "Restablecer valores por defecto",
|
||||
"layoutSaveSuccess": "Diseño del panel guardado",
|
||||
"layoutSaveError": "No se ha podido guardar el diseño",
|
||||
"layoutConflictReloaded": "Se ha recargado el diseño desde el servidor. Vuelve a aplicar tu cambio.",
|
||||
"layoutResetSuccess": "Restablecido al diseño por defecto",
|
||||
"layoutUsingDefaults": "Diseño por defecto activo",
|
||||
"layoutCustomized": "Diseño personalizado activo",
|
||||
|
||||
@@ -458,6 +458,7 @@
|
||||
"layoutReset": "Rétablir les valeurs par défaut",
|
||||
"layoutSaveSuccess": "Mise en page enregistrée",
|
||||
"layoutSaveError": "Impossible d’enregistrer la mise en page",
|
||||
"layoutConflictReloaded": "Mise en page rechargée depuis le serveur. Veuillez refaire votre modification.",
|
||||
"layoutResetSuccess": "Mise en page par défaut restaurée",
|
||||
"layoutUsingDefaults": "Mise en page par défaut active",
|
||||
"layoutCustomized": "Mise en page personnalisée active",
|
||||
|
||||
@@ -448,6 +448,7 @@
|
||||
"layoutReset": "Ripristina i valori predefiniti",
|
||||
"layoutSaveSuccess": "Layout della dashboard salvato",
|
||||
"layoutSaveError": "Impossibile salvare il layout",
|
||||
"layoutConflictReloaded": "Layout ricaricato dal server. Applica di nuovo la tua modifica.",
|
||||
"layoutResetSuccess": "Layout predefinito ripristinato",
|
||||
"layoutUsingDefaults": "Layout predefinito attivo",
|
||||
"layoutCustomized": "Layout personalizzato attivo",
|
||||
|
||||
@@ -442,6 +442,7 @@
|
||||
"layoutReset": "Przywróć wartości domyślne",
|
||||
"layoutSaveSuccess": "Układ panelu zapisany",
|
||||
"layoutSaveError": "Nie udało się zapisać układu",
|
||||
"layoutConflictReloaded": "Układ został ponownie wczytany z serwera. Wprowadź zmianę jeszcze raz.",
|
||||
"layoutResetSuccess": "Przywrócono układ domyślny",
|
||||
"layoutUsingDefaults": "Aktywny układ domyślny",
|
||||
"layoutCustomized": "Aktywny układ niestandardowy",
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiSuccess,
|
||||
apiError,
|
||||
buildPayloadDiagnostic,
|
||||
safeJson,
|
||||
returnAllZodIssues,
|
||||
@@ -29,6 +30,7 @@ import {
|
||||
layoutNeedsPreserveRead,
|
||||
mergePreservedLayoutFields,
|
||||
type DashboardLayout,
|
||||
type DashboardLayoutWithToken,
|
||||
} from "@/lib/dashboard-layout";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import { z } from "zod/v4";
|
||||
@@ -150,14 +152,27 @@ const layoutSchema = z.object({
|
||||
.array(z.enum(HERO_RING_IDS))
|
||||
.max(MAX_HERO_RING_ORDER)
|
||||
.optional(),
|
||||
// v1.32.16 (issue #581) — optimistic-concurrency base token. The client
|
||||
// sends the ISO `updatedAt` it read the layout at; the write is then
|
||||
// guarded on the stored row still carrying it (else 409, no write).
|
||||
// Optional so older web builds and the native client keep the prior
|
||||
// unconditional write. Not a layout field — stripped before serialize.
|
||||
baseUpdatedAt: z.string().optional(),
|
||||
});
|
||||
|
||||
async function buildDashboardLayout(userId: string): Promise<DashboardLayout> {
|
||||
async function buildDashboardLayout(
|
||||
userId: string,
|
||||
): Promise<DashboardLayoutWithToken> {
|
||||
const row = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { dashboardWidgetsJson: true },
|
||||
// v1.32.16 (issue #581) — `updatedAt` rides the cached layout so GET
|
||||
// hands the client the optimistic-concurrency token alongside the
|
||||
// layout it applies to. Cached together (and busted on every write)
|
||||
// so the (layout, token) pair a client reads is always consistent.
|
||||
select: { dashboardWidgetsJson: true, updatedAt: true },
|
||||
});
|
||||
return resolveDashboardLayout(row?.dashboardWidgetsJson);
|
||||
const layout = resolveDashboardLayout(row?.dashboardWidgetsJson);
|
||||
return { ...layout, updatedAt: row?.updatedAt?.toISOString() };
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
@@ -167,7 +182,7 @@ export const GET = apiHandler(async () => {
|
||||
// hits the Settings → Dashboard save button, which invalidates this
|
||||
// cache via `invalidateUserDashboardWidgets()`.
|
||||
const layout = await cached(
|
||||
caches.dashboardWidgets as ServerCache<DashboardLayout>,
|
||||
caches.dashboardWidgets as ServerCache<DashboardLayoutWithToken>,
|
||||
user.id,
|
||||
() => buildDashboardLayout(user.id),
|
||||
annotate,
|
||||
@@ -310,7 +325,11 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
// `ChartOverlayPrefs` requires it; `coerceChartOverlayPrefsMap` inside the
|
||||
// serializer fills the gap, so the assertion is safe here exactly as it was
|
||||
// on the previous per-field cast.
|
||||
let toSerialize = parsed.data as Partial<DashboardLayout>;
|
||||
// v1.32.16 (issue #581) — the concurrency token is a request-only field,
|
||||
// not part of the layout; split it out before the serialize pipeline so
|
||||
// it can never leak into the stored blob.
|
||||
const { baseUpdatedAt, ...layoutData } = parsed.data;
|
||||
let toSerialize = layoutData as Partial<DashboardLayout>;
|
||||
const incomingChartOverlayPrefs = toSerialize.chartOverlayPrefs;
|
||||
const needsRangePointsPreserve =
|
||||
incomingChartOverlayPrefs !== undefined &&
|
||||
@@ -358,12 +377,49 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
}
|
||||
const normalized = serializeDashboardLayout(toSerialize as DashboardLayout);
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
dashboardWidgetsJson: toJson(normalized),
|
||||
},
|
||||
});
|
||||
// v1.32.16 (issue #581) — optimistic concurrency. When the client sends
|
||||
// the `baseUpdatedAt` it read the layout at, the write is CONDITIONAL on
|
||||
// the stored row still carrying that token: `updateMany` matches zero
|
||||
// rows once any other request has advanced `updatedAt`, so a Save that
|
||||
// already committed can never be silently reverted by a later request
|
||||
// (e.g. an instant hero-ring PUT) that started from the pre-Save
|
||||
// snapshot. A stale base returns 409 and writes NOTHING. Clients that
|
||||
// omit the token (older web builds, the native client) keep the prior
|
||||
// unconditional write — backward-compatible, still preserve-when-absent.
|
||||
let freshUpdatedAt: Date;
|
||||
if (baseUpdatedAt !== undefined) {
|
||||
const base = new Date(baseUpdatedAt);
|
||||
if (Number.isNaN(base.getTime())) {
|
||||
return apiError("Invalid base token", 422, {
|
||||
errorCode: "invalid_base_updated_at",
|
||||
});
|
||||
}
|
||||
const guarded = await prisma.user.updateMany({
|
||||
where: { id: user.id, updatedAt: base },
|
||||
data: { dashboardWidgetsJson: toJson(normalized) },
|
||||
});
|
||||
if (guarded.count === 0) {
|
||||
annotate({
|
||||
action: { name: "dashboard.widgets.conflict" },
|
||||
meta: { base_updated_at: baseUpdatedAt },
|
||||
});
|
||||
return apiError("Dashboard layout changed since it was loaded", 409, {
|
||||
errorCode: "dashboard_layout_conflict",
|
||||
});
|
||||
}
|
||||
const fresh = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { updatedAt: true },
|
||||
});
|
||||
freshUpdatedAt = fresh?.updatedAt ?? new Date();
|
||||
} else {
|
||||
const updated = await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { dashboardWidgetsJson: toJson(normalized) },
|
||||
select: { updatedAt: true },
|
||||
});
|
||||
freshUpdatedAt = updated?.updatedAt ?? new Date();
|
||||
}
|
||||
|
||||
annotate({
|
||||
action: { name: "dashboard.widgets.update" },
|
||||
@@ -374,7 +430,11 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
// next dashboard mount paints the new layout.
|
||||
invalidateUserDashboardWidgets(user.id);
|
||||
|
||||
return apiSuccess(normalized);
|
||||
// Echo the advanced token so the client can base its next write on it.
|
||||
return apiSuccess({
|
||||
...normalized,
|
||||
updatedAt: freshUpdatedAt.toISOString(),
|
||||
} satisfies DashboardLayoutWithToken);
|
||||
});
|
||||
|
||||
export const DELETE = apiHandler(async () => {
|
||||
|
||||
@@ -48,6 +48,8 @@ import {
|
||||
HEALTH_SCORE_RING_ID,
|
||||
resolveHeroRingOrder,
|
||||
buildRingMutationPayload,
|
||||
widgetWritesBusy,
|
||||
type DashboardLayoutWithToken,
|
||||
} from "@/lib/dashboard-layout";
|
||||
import {
|
||||
Select,
|
||||
@@ -56,7 +58,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { apiDelete, apiGet, apiPut } from "@/lib/api/api-fetch";
|
||||
import { apiDelete, apiGet, apiPut, ApiError } 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";
|
||||
@@ -127,6 +129,16 @@ function mergeReorderIntoLayout(
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.32.16 (issue #581) — the normal layout Save and the instant hero-ring
|
||||
* PUT both write the whole `/api/dashboard/widgets` layout. Sharing one
|
||||
* TanStack mutation `scope` serialises them: a second write in this scope
|
||||
* waits for the first to settle, so the two PUTs can never overlap at the
|
||||
* server and a committed Save cannot be reverted by a ring write that
|
||||
* started from the pre-Save snapshot.
|
||||
*/
|
||||
const DASHBOARD_WIDGETS_MUTATION_SCOPE = "dashboard-widgets" as const;
|
||||
|
||||
const WIDGET_LABEL_KEYS: Record<DashboardWidgetId, string> = {
|
||||
weight: "dashboard.weight",
|
||||
bp: "dashboard.bloodPressure",
|
||||
@@ -224,10 +236,21 @@ export function DashboardLayoutSection({ id }: { id: string }) {
|
||||
const { data: remote, isLoading } = useQuery({
|
||||
queryKey: queryKeys.dashboardWidgets(),
|
||||
queryFn: async () => {
|
||||
return apiGet<DashboardLayout>("/api/dashboard/widgets");
|
||||
return apiGet<DashboardLayoutWithToken>("/api/dashboard/widgets");
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* v1.32.16 (issue #581) — 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
|
||||
@@ -236,8 +259,15 @@ export function DashboardLayoutSection({ id }: { id: string }) {
|
||||
const layout = draft ?? remote ?? null;
|
||||
|
||||
const saveMutation = useMutation({
|
||||
// v1.32.16 (issue #581) — shared scope serialises this against the
|
||||
// instant ring PUT so they can never overlap at the server.
|
||||
scope: { id: DASHBOARD_WIDGETS_MUTATION_SCOPE },
|
||||
mutationFn: async (next: DashboardLayout) => {
|
||||
return apiPut<DashboardLayout>("/api/dashboard/widgets", next);
|
||||
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);
|
||||
@@ -264,12 +294,28 @@ export function DashboardLayoutSection({ id }: { id: string }) {
|
||||
setDraft(null);
|
||||
toast.success(t("dashboard.layoutSaveSuccess"));
|
||||
},
|
||||
onError: () => toast.error(t("dashboard.layoutSaveError")),
|
||||
onError: (err) => {
|
||||
// v1.32.16 (issue #581) — a 409 means someone else (another tab, or
|
||||
// an interleaved ring write) committed since this edit was based.
|
||||
// Refetch the winning layout so the base token advances, KEEP the
|
||||
// draft so the user can re-apply, and nudge gently instead of a hard
|
||||
// error — nothing was lost, the change just needs re-saving.
|
||||
if (err instanceof ApiError && err.status === 409) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.dashboardWidgets(),
|
||||
});
|
||||
toast.message(t("dashboard.layoutConflictReloaded"));
|
||||
return;
|
||||
}
|
||||
toast.error(t("dashboard.layoutSaveError"));
|
||||
},
|
||||
});
|
||||
|
||||
const resetMutation = useMutation({
|
||||
// v1.32.16 — same scope so a reset can't race the layout / ring writes.
|
||||
scope: { id: DASHBOARD_WIDGETS_MUTATION_SCOPE },
|
||||
mutationFn: async () => {
|
||||
return apiDelete<DashboardLayout>("/api/dashboard/widgets");
|
||||
return apiDelete<DashboardLayoutWithToken>("/api/dashboard/widgets");
|
||||
},
|
||||
onSuccess: (saved) => {
|
||||
queryClient.setQueryData(queryKeys.dashboardWidgets(), saved);
|
||||
@@ -348,13 +394,19 @@ export function DashboardLayoutSection({ id }: { id: string }) {
|
||||
* Save no matter which one lands last.
|
||||
*/
|
||||
const ringMutation = useMutation({
|
||||
// v1.32.16 (issue #581) — shared scope serialises this against the full
|
||||
// layout Save so the two writes can never overlap at the server.
|
||||
scope: { id: DASHBOARD_WIDGETS_MUTATION_SCOPE },
|
||||
mutationFn: async (next: {
|
||||
selectedScoreRings: ScoreRingId[];
|
||||
heroRingOrder: HeroRingId[];
|
||||
}) => {
|
||||
return apiPut<DashboardLayout>(
|
||||
return apiPut<DashboardLayoutWithToken>(
|
||||
"/api/dashboard/widgets",
|
||||
buildRingMutationPayload(next),
|
||||
// Carries ONLY the ring fields (never `widgets`) plus the base
|
||||
// token, so it can neither clobber a saved layout nor win a stale
|
||||
// race — a conflict 409s instead.
|
||||
buildRingMutationPayload({ ...next, baseUpdatedAt: readBaseToken() }),
|
||||
);
|
||||
},
|
||||
onMutate: async (next) => {
|
||||
@@ -373,13 +425,24 @@ export function DashboardLayoutSection({ id }: { id: string }) {
|
||||
}
|
||||
return { previous };
|
||||
},
|
||||
onError: (_err, _next, context) => {
|
||||
onError: (err, _next, context) => {
|
||||
if (context?.previous) {
|
||||
queryClient.setQueryData(
|
||||
queryKeys.dashboardWidgets(),
|
||||
context.previous,
|
||||
);
|
||||
}
|
||||
// v1.32.16 (issue #581) — a 409 means a concurrent write landed
|
||||
// first. Roll back the optimistic ring change (above), refetch the
|
||||
// winning layout so the token advances, drop this now-stale delta,
|
||||
// and nudge gently so the user can re-toggle against fresh state.
|
||||
if (err instanceof ApiError && err.status === 409) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.dashboardWidgets(),
|
||||
});
|
||||
toast.message(t("dashboard.layoutConflictReloaded"));
|
||||
return;
|
||||
}
|
||||
toast.error(t("dashboard.layoutSaveError"));
|
||||
},
|
||||
onSuccess: (saved) => {
|
||||
@@ -400,7 +463,10 @@ export function DashboardLayoutSection({ id }: { id: string }) {
|
||||
* the server resolver and the hero read one consistent sequence.
|
||||
*/
|
||||
function applyHeroRingOrder(next: HeroRingId[]) {
|
||||
if (!remote) return;
|
||||
// v1.32.16 (issue #581) — gate on the freshest layout (`draft ?? remote`)
|
||||
// the rest of the section uses, not the `remote` server snapshot alone,
|
||||
// so an open draft's edits stay the source of truth.
|
||||
if (!layout) return;
|
||||
const selectedScoreRings = next.filter(
|
||||
(id): id is ScoreRingId => id !== HEALTH_SCORE_RING_ID,
|
||||
);
|
||||
@@ -643,7 +709,12 @@ export function DashboardLayoutSection({ id }: { id: string }) {
|
||||
(ringId) => !heroOrder.includes(ringId),
|
||||
);
|
||||
const atCap = selected.length >= MAX_SELECTED_SCORE_RINGS;
|
||||
const busy = ringMutation.isPending;
|
||||
// v1.32.16 (issue #581) — ring controls also lock while a Save
|
||||
// is in flight, so the two writes can never overlap.
|
||||
const busy = widgetWritesBusy(
|
||||
saveMutation.isPending,
|
||||
ringMutation.isPending,
|
||||
);
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<DndContext
|
||||
@@ -831,7 +902,10 @@ export function DashboardLayoutSection({ id }: { id: string }) {
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
onClick={() => setDraft(null)}
|
||||
disabled={saveMutation.isPending}
|
||||
disabled={widgetWritesBusy(
|
||||
saveMutation.isPending,
|
||||
ringMutation.isPending,
|
||||
)}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
@@ -839,7 +913,12 @@ export function DashboardLayoutSection({ id }: { id: string }) {
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
onClick={() => layout && saveMutation.mutate(layout)}
|
||||
disabled={saveMutation.isPending}
|
||||
// v1.32.16 (issue #581) — disabled while EITHER write is in
|
||||
// flight so a Save can't be issued mid ring-write and vice versa.
|
||||
disabled={widgetWritesBusy(
|
||||
saveMutation.isPending,
|
||||
ringMutation.isPending,
|
||||
)}
|
||||
>
|
||||
{saveMutation.isPending && (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
|
||||
@@ -680,17 +680,65 @@ export function mergePreservedLayoutFields(
|
||||
* carries forward whatever is CURRENTLY stored, so this payload can no
|
||||
* longer race a concurrent Save no matter which request lands last.
|
||||
*/
|
||||
/**
|
||||
* v1.32.16 (issue #581) — a `/api/dashboard/widgets` write payload plus the
|
||||
* optimistic-concurrency base token. `baseUpdatedAt` is the ISO
|
||||
* `User.updatedAt` the client read the layout at; the route writes only when
|
||||
* the stored row still carries it (else 409). It is request-only and never
|
||||
* lands in the persisted `dashboardWidgetsJson` blob.
|
||||
*/
|
||||
export type DashboardLayoutWritePayload = Partial<DashboardLayout> & {
|
||||
baseUpdatedAt?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* v1.32.16 — the shape every GET / PUT `/api/dashboard/widgets` response
|
||||
* carries: the resolved layout plus the server-stamped `updatedAt`
|
||||
* concurrency token (mirrors `User.updatedAt`, never persisted inside the
|
||||
* blob). The client sends it back as `baseUpdatedAt` on the next write.
|
||||
*/
|
||||
export type DashboardLayoutWithToken = DashboardLayout & {
|
||||
updatedAt?: string;
|
||||
};
|
||||
|
||||
export function buildRingMutationPayload(next: {
|
||||
selectedScoreRings: ScoreRingId[];
|
||||
heroRingOrder: HeroRingId[];
|
||||
}): Partial<DashboardLayout> {
|
||||
/**
|
||||
* v1.32.16 (issue #581) — the base token the ring change was made
|
||||
* against. Included when known so the guarded write can 409 rather than
|
||||
* silently overwrite a Save that committed first. Omitted (not sent as
|
||||
* `undefined`) when the client hasn't got a token yet.
|
||||
*/
|
||||
baseUpdatedAt?: string;
|
||||
}): DashboardLayoutWritePayload {
|
||||
return {
|
||||
version: DASHBOARD_LAYOUT_VERSION,
|
||||
selectedScoreRings: next.selectedScoreRings,
|
||||
heroRingOrder: next.heroRingOrder,
|
||||
...(next.baseUpdatedAt !== undefined
|
||||
? { baseUpdatedAt: next.baseUpdatedAt }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.32.16 (issue #581) — the Save button and the instant hero-ring controls
|
||||
* both PUT the whole `/api/dashboard/widgets` layout. While EITHER write is in
|
||||
* flight the other must be disabled, so the two PUTs can never overlap at the
|
||||
* server and a committed Save cannot be reverted by a ring write that started
|
||||
* from the pre-Save snapshot. Belt-and-braces with the shared TanStack
|
||||
* mutation `scope` (which already serialises the two) and the server's
|
||||
* optimistic-concurrency token. Exported as a pure predicate so the JSX binds
|
||||
* one source of truth and a unit test can pin the gate.
|
||||
*/
|
||||
export function widgetWritesBusy(
|
||||
saving: boolean,
|
||||
ringPending: boolean,
|
||||
): boolean {
|
||||
return saving || ringPending;
|
||||
}
|
||||
|
||||
const DASHBOARD_LAYOUT_VERSION = 1;
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user