diff --git a/CHANGELOG.md b/CHANGELOG.md index 8255a1cf2..3cfdc5807 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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, diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index f235548b0..0998fe392 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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. diff --git a/messages/de.json b/messages/de.json index 4eccd9736..1b2333180 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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", diff --git a/messages/en.json b/messages/en.json index d15eec67f..e55ba886d 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/es.json b/messages/es.json index 846f043ad..1bebc82c6 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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", diff --git a/messages/fr.json b/messages/fr.json index 0f4af6e6e..3e584e0dc 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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", diff --git a/messages/it.json b/messages/it.json index 28a474487..5116cb64e 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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", diff --git a/messages/pl.json b/messages/pl.json index d3cc56145..1b0352e1a 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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", diff --git a/package.json b/package.json index 71c76cbdb..4cba5ff7c 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/public/sw.js b/public/sw.js index da0f72c09..d672582af 100644 --- a/public/sw.js +++ b/public/sw.js @@ -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/*` diff --git a/src/app/api/dashboard/widgets/__tests__/route.test.ts b/src/app/api/dashboard/widgets/__tests__/route.test.ts index 258346380..9c2cf56a5 100644 --- a/src/app/api/dashboard/widgets/__tests__/route.test.ts +++ b/src/app/api/dashboard/widgets/__tests__/route.test.ts @@ -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"); + }); +}); diff --git a/src/app/api/dashboard/widgets/route.ts b/src/app/api/dashboard/widgets/route.ts index d7c8c97f6..ae08bc950 100644 --- a/src/app/api/dashboard/widgets/route.ts +++ b/src/app/api/dashboard/widgets/route.ts @@ -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 { +async function buildDashboardLayout( + userId: string, +): Promise { 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, + caches.dashboardWidgets as ServerCache, 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; + // 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; 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 () => { diff --git a/src/components/settings/__tests__/dashboard-layout-section.test.tsx b/src/components/settings/__tests__/dashboard-layout-section.test.tsx index 4594cd1f6..da6a8362a 100644 --- a/src/components/settings/__tests__/dashboard-layout-section.test.tsx +++ b/src/components/settings/__tests__/dashboard-layout-section.test.tsx @@ -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(" — 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"); + }); +}); diff --git a/src/components/settings/dashboard-layout-section.tsx b/src/components/settings/dashboard-layout-section.tsx index ddaf3bd5e..99f9a80d7 100644 --- a/src/components/settings/dashboard-layout-section.tsx +++ b/src/components/settings/dashboard-layout-section.tsx @@ -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 = { 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("/api/dashboard/widgets"); + return apiGet("/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( + 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("/api/dashboard/widgets", next); + return apiPut("/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("/api/dashboard/widgets"); + return apiDelete("/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( + return apiPut( "/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 (
setDraft(null)} - disabled={saveMutation.isPending} + disabled={widgetWritesBusy( + saveMutation.isPending, + ringMutation.isPending, + )} > {t("common.cancel")} @@ -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 && ( diff --git a/src/lib/dashboard-layout.ts b/src/lib/dashboard-layout.ts index 4c4e790a4..1b6849bd3 100644 --- a/src/lib/dashboard-layout.ts +++ b/src/lib/dashboard-layout.ts @@ -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 & { + 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 { + /** + * 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; /**