Merge pull request #618 from MBombeck/release/v1.32.16

release/v1.32.16 — dashboard layout Save stays authoritative against a concurrent ring write
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-07-24 11:25:39 +02:00
commit 1e72d0aea0
15 files changed
+408 -29

No files matched your search

+22
View File
@@ -2,6 +2,28 @@
## [Unreleased]
## [1.32.16] — 2026-07-24
Changing which tiles and charts appear on your dashboard could get undone if
you also toggled a hero score ring at about the same moment. Both actions save
the whole layout, and the ring change was built from an older copy, so
whichever reached the server last won. Thanks to @lutzkind for the clear
report.
- **A saved layout now stays saved.** Your dashboard save is treated as the
authoritative one. If another change arrives after it but was based on an
older version of the layout, the server declines that write rather than
letting it overwrite your work. The ring toggle also carries only its own
fields, so it can no longer pull stale tiles along with it.
- **The two saves take turns instead of racing.** While a layout save is in
flight the score ring controls are held, and while a ring change is saving
the Save button waits, so the two writes can no longer overlap on the server.
- **A change underneath you reloads instead of getting lost.** In the rare case
where another tab saved first, the layout refetches from the server and a
small note asks you to make your change again against the fresh copy.
Refs #581.
## [1.32.15] — 2026-07-24
Your Polar training sessions now sync into HealthLog, alongside the recovery,
+1 -1
View File
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: HealthLog API
version: 1.32.15
version: 1.32.16
description: >-
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "1.32.15",
"version": "1.32.16",
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
"license": "PolyForm-Noncommercial-1.0.0",
"homepage": "https://healthlog.dev",
+1 -1
View File
@@ -36,7 +36,7 @@ try {
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
const CACHE_VERSION =
(typeof self !== "undefined" && self.__APP_VERSION__) ||
/* @sw-version-fallback */ "v1.32.15";
/* @sw-version-fallback */ "v1.32.16";
const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`;
const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`;
// v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*`
@@ -16,6 +16,8 @@ vi.mock("@/lib/db", () => ({
user: {
findUnique: vi.fn(),
update: vi.fn(),
// v1.32.16 (issue #581) — the guarded optimistic-concurrency write.
updateMany: vi.fn(),
},
auditLog: {
create: vi.fn(),
@@ -741,3 +743,101 @@ describe("dashboard widgets — ring-only PUT cannot race a concurrent full-layo
expect(body.data.widgets[0].visible).toBe(false);
});
});
/**
* v1.32.16 — optimistic concurrency (issue #581). A Save that carries the
* `baseUpdatedAt` it was made against writes CONDITIONALLY on the stored row
* still carrying that token, so a committed Save can never be silently
* reverted by a later request that started from an older snapshot. A full
* layout with every top-level field present skips the preserve-read, so the
* only `findUnique` here is the post-write token read.
*/
describe("dashboard widgets — optimistic concurrency (issue #581)", () => {
// A full layout carrying every preserved field so `needsPreserveRead`
// is false and the route takes the guarded write path directly.
function fullLayoutBody(baseUpdatedAt?: string) {
return {
version: 1,
widgets: DEFAULT_DASHBOARD_LAYOUT.widgets,
comparisonBaseline: "none",
chartOverlayPrefs: {},
selectedScoreRings: ["MED_COMPLIANCE"],
heroRingOrder: ["HEALTH_SCORE", "MED_COMPLIANCE"],
...(baseUpdatedAt !== undefined ? { baseUpdatedAt } : {}),
};
}
it("guards the write on the base token and returns the advanced token", async () => {
const base = new Date("2026-07-24T10:00:00.000Z");
const advanced = new Date("2026-07-24T10:05:00.000Z");
vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 1 } as never);
// The post-write token read.
vi.mocked(prisma.user.findUnique).mockResolvedValue({
updatedAt: advanced,
} as never);
const res = await callPut(makeReq(fullLayoutBody(base.toISOString())));
expect(res.status).toBe(200);
// Wrote conditionally on the exact base token — never unconditionally.
expect(prisma.user.updateMany).toHaveBeenCalledTimes(1);
const whereArg = vi.mocked(prisma.user.updateMany).mock.calls[0]?.[0]
?.where as { id: string; updatedAt: Date };
expect(whereArg.id).toBe("user-1");
expect((whereArg.updatedAt as Date).toISOString()).toBe(base.toISOString());
expect(prisma.user.update).not.toHaveBeenCalled();
// The response echoes the advanced token so the client can rebase.
const body = (await res.json()) as { data: { updatedAt: string } };
expect(body.data.updatedAt).toBe(advanced.toISOString());
});
it("rejects a stale base token with 409 and writes nothing", async () => {
// The guarded update matches zero rows — someone advanced updatedAt.
vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 0 } as never);
const res = await callPut(
makeReq(fullLayoutBody("2026-07-24T09:00:00.000Z")),
);
expect(res.status).toBe(409);
const body = (await res.json()) as {
data: null;
error: string;
meta?: { errorCode?: string };
};
expect(body.data).toBeNull();
expect(body.meta?.errorCode).toBe("dashboard_layout_conflict");
// The conditional write matched no row; there is NO unconditional
// fallback, so the stored layout is left exactly as it was.
expect(prisma.user.updateMany).toHaveBeenCalledTimes(1);
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("422s a malformed base token without touching the row", async () => {
const res = await callPut(makeReq(fullLayoutBody("not-a-date")));
expect(res.status).toBe(422);
const body = (await res.json()) as { meta?: { errorCode?: string } };
expect(body.meta?.errorCode).toBe("invalid_base_updated_at");
expect(prisma.user.updateMany).not.toHaveBeenCalled();
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("keeps the unconditional write when the client omits the token", async () => {
// Backward-compatible path for older web builds / the native client.
vi.mocked(prisma.user.findUnique).mockResolvedValue({
dashboardWidgetsJson: null,
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({
updatedAt: new Date("2026-07-24T11:00:00.000Z"),
} as never);
const res = await callPut(makeReq(fullLayoutBody()));
expect(res.status).toBe(200);
expect(prisma.user.update).toHaveBeenCalledTimes(1);
expect(prisma.user.updateMany).not.toHaveBeenCalled();
const body = (await res.json()) as { data: { updatedAt: string } };
expect(body.data.updatedAt).toBe("2026-07-24T11:00:00.000Z");
});
});
+72 -12
View File
@@ -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 () => {
@@ -8,6 +8,8 @@ import {
DASHBOARD_IOS_ONLY_WIDGET_IDS,
DASHBOARD_WIDGET_IDS,
IOS_PIN_ONLY_WIDGET_IDS,
buildRingMutationPayload,
widgetWritesBusy,
type DashboardLayout,
} from "@/lib/dashboard-layout";
@@ -540,10 +542,72 @@ describe("<DashboardLayoutSection> — health-score anchor + reorder (v1.27.27)"
),
"utf8",
);
expect(src).toContain("buildRingMutationPayload(next)");
// v1.32.16 — the ring body is built via `buildRingMutationPayload`,
// now also threading the optimistic-concurrency base token.
expect(src).toContain("buildRingMutationPayload({ ...next, baseUpdatedAt");
// The pre-fix shape must never come back: spreading a query-cache
// snapshot into the ring PUT body is exactly the stale-`widgets`
// race issue #581 reported.
expect(src).not.toMatch(/\.\.\.remote,/);
});
});
/**
* v1.32.16 (issue #581) — a committed Save must stay authoritative against a
* concurrent instant hero-ring write. The full DOM interaction (fire a ring
* write, then a Save, assert the disabled/pending gate) can't run under this
* suite's SSR-only convention, so the load-bearing client contract is pinned
* as pure exported units plus source pins for the wiring the JSX depends on.
*/
describe("dashboard layout — Save cannot be clobbered by a ring write (#581)", () => {
it("buildRingMutationPayload never carries widgets and threads the base token", () => {
const payload = buildRingMutationPayload({
selectedScoreRings: ["READINESS"],
heroRingOrder: ["HEALTH_SCORE", "READINESS"],
baseUpdatedAt: "2026-07-24T10:00:00.000Z",
});
// None of the fields a stale snapshot could carry and use to overwrite
// a fresher Save.
expect(payload).not.toHaveProperty("widgets");
expect(payload).not.toHaveProperty("comparisonBaseline");
expect(payload).not.toHaveProperty("chartOverlayPrefs");
// The ring selection + the guard token DO ride.
expect(payload.selectedScoreRings).toEqual(["READINESS"]);
expect(payload.baseUpdatedAt).toBe("2026-07-24T10:00:00.000Z");
});
it("buildRingMutationPayload omits the base-token key when none is known", () => {
const payload = buildRingMutationPayload({
selectedScoreRings: [],
heroRingOrder: ["HEALTH_SCORE"],
});
expect(payload).not.toHaveProperty("baseUpdatedAt");
});
it("widgetWritesBusy gates off EITHER write being in flight", () => {
expect(widgetWritesBusy(false, false)).toBe(false);
expect(widgetWritesBusy(true, false)).toBe(true); // Save pending
expect(widgetWritesBusy(false, true)).toBe(true); // ring pending
expect(widgetWritesBusy(true, true)).toBe(true);
});
it("the Save + ring controls bind the shared busy gate and serialise via one scope (source pin)", () => {
const src = readFileSync(
join(
process.cwd(),
"src/components/settings/dashboard-layout-section.tsx",
),
"utf8",
);
// Save button + ring controls both read `widgetWritesBusy(...)` so the
// gate is one source of truth — the button can never disable on
// `saveMutation.isPending` alone (the pre-fix hole).
expect(src).toContain("widgetWritesBusy(");
// Both writes share a mutation scope so they run serially at the client.
expect(src).toContain("scope: { id: DASHBOARD_WIDGETS_MUTATION_SCOPE }");
// Each write sends the optimistic-concurrency base token.
expect(src).toContain("baseUpdatedAt: readBaseToken()");
// A 409 is handled (refetch + gentle nudge), not swallowed as a hard error.
expect(src).toContain("err.status === 409");
});
});
@@ -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" />
+49 -1
View File
@@ -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;
/**