From 69359a609a86610264f762d53923064cb33f505a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 24 Jul 2026 11:07:09 +0200 Subject: [PATCH 1/3] 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 --- messages/de.json | 1 + messages/en.json | 1 + messages/es.json | 1 + messages/fr.json | 1 + messages/it.json | 1 + messages/pl.json | 1 + src/app/api/dashboard/widgets/route.ts | 84 ++++++++++++-- .../settings/dashboard-layout-section.tsx | 103 ++++++++++++++++-- src/lib/dashboard-layout.ts | 50 ++++++++- 9 files changed, 218 insertions(+), 25 deletions(-) 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/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/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; /** From cd344047dda9da9ac4e0edd5ef0af25114d66af2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 24 Jul 2026 11:09:30 +0200 Subject: [PATCH 2/3] test(dashboard): cover optimistic-concurrency + the serialised ring/Save writes Server: a write with the current base token succeeds and returns the advanced token; a stale base returns 409 and writes nothing (no unconditional fallback); a malformed base 422s; an omitted base keeps the unconditional write. Client: the ring payload never carries widgets and threads the base token, the shared busy gate covers either write, and the Save/ring wiring is source-pinned. Refs #581 --- .../dashboard/widgets/__tests__/route.test.ts | 100 ++++++++++++++++++ .../dashboard-layout-section.test.tsx | 66 +++++++++++- 2 files changed, 165 insertions(+), 1 deletion(-) 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/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"); + }); +}); From ed260f1ed8b94b02eaf828953f749fa6dd0d1f34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 24 Jul 2026 11:09:44 +0200 Subject: [PATCH 3/3] =?UTF-8?q?chore(release):=20v1.32.16=20=E2=80=94=20ke?= =?UTF-8?q?ep=20a=20saved=20dashboard=20layout=20from=20a=20concurrent=20r?= =?UTF-8?q?ing=20save?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refs #581 --- CHANGELOG.md | 22 ++++++++++++++++++++++ docs/api/openapi.yaml | 2 +- package.json | 2 +- public/sw.js | 2 +- 4 files changed, 25 insertions(+), 3 deletions(-) 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/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/*`