mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
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:
15 files changed
+408
-29
No files matched your search
@@ -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,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.
|
||||
|
||||
|
||||
@@ -454,6 +454,7 @@
|
||||
"layoutReset": "Auf Defaults zurücksetzen",
|
||||
"layoutSaveSuccess": "Dashboard-Layout gespeichert",
|
||||
"layoutSaveError": "Layout konnte nicht gespeichert werden",
|
||||
"layoutConflictReloaded": "Layout wurde vom Server neu geladen. Bitte nimm deine Änderung erneut vor.",
|
||||
"layoutResetSuccess": "Auf Standard-Layout zurückgesetzt",
|
||||
"layoutUsingDefaults": "Standard-Layout aktiv",
|
||||
"layoutCustomized": "Eigenes Layout aktiv",
|
||||
|
||||
@@ -444,6 +444,7 @@
|
||||
"layoutReset": "Reset to defaults",
|
||||
"layoutSaveSuccess": "Dashboard layout saved",
|
||||
"layoutSaveError": "Could not save dashboard layout",
|
||||
"layoutConflictReloaded": "Layout reloaded from the server. Please make your change again.",
|
||||
"layoutResetSuccess": "Reset to default layout",
|
||||
"layoutUsingDefaults": "Using default layout",
|
||||
"layoutCustomized": "Custom layout active",
|
||||
|
||||
@@ -456,6 +456,7 @@
|
||||
"layoutReset": "Restablecer valores por defecto",
|
||||
"layoutSaveSuccess": "Diseño del panel guardado",
|
||||
"layoutSaveError": "No se ha podido guardar el diseño",
|
||||
"layoutConflictReloaded": "Se ha recargado el diseño desde el servidor. Vuelve a aplicar tu cambio.",
|
||||
"layoutResetSuccess": "Restablecido al diseño por defecto",
|
||||
"layoutUsingDefaults": "Diseño por defecto activo",
|
||||
"layoutCustomized": "Diseño personalizado activo",
|
||||
|
||||
@@ -458,6 +458,7 @@
|
||||
"layoutReset": "Rétablir les valeurs par défaut",
|
||||
"layoutSaveSuccess": "Mise en page enregistrée",
|
||||
"layoutSaveError": "Impossible d’enregistrer la mise en page",
|
||||
"layoutConflictReloaded": "Mise en page rechargée depuis le serveur. Veuillez refaire votre modification.",
|
||||
"layoutResetSuccess": "Mise en page par défaut restaurée",
|
||||
"layoutUsingDefaults": "Mise en page par défaut active",
|
||||
"layoutCustomized": "Mise en page personnalisée active",
|
||||
|
||||
@@ -448,6 +448,7 @@
|
||||
"layoutReset": "Ripristina i valori predefiniti",
|
||||
"layoutSaveSuccess": "Layout della dashboard salvato",
|
||||
"layoutSaveError": "Impossibile salvare il layout",
|
||||
"layoutConflictReloaded": "Layout ricaricato dal server. Applica di nuovo la tua modifica.",
|
||||
"layoutResetSuccess": "Layout predefinito ripristinato",
|
||||
"layoutUsingDefaults": "Layout predefinito attivo",
|
||||
"layoutCustomized": "Layout personalizzato attivo",
|
||||
|
||||
@@ -442,6 +442,7 @@
|
||||
"layoutReset": "Przywróć wartości domyślne",
|
||||
"layoutSaveSuccess": "Układ panelu zapisany",
|
||||
"layoutSaveError": "Nie udało się zapisać układu",
|
||||
"layoutConflictReloaded": "Układ został ponownie wczytany z serwera. Wprowadź zmianę jeszcze raz.",
|
||||
"layoutResetSuccess": "Przywrócono układ domyślny",
|
||||
"layoutUsingDefaults": "Aktywny układ domyślny",
|
||||
"layoutCustomized": "Aktywny układ niestandardowy",
|
||||
|
||||
+1
-1
@@ -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
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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" />
|
||||
|
||||
@@ -680,17 +680,65 @@ export function mergePreservedLayoutFields(
|
||||
* carries forward whatever is CURRENTLY stored, so this payload can no
|
||||
* longer race a concurrent Save no matter which request lands last.
|
||||
*/
|
||||
/**
|
||||
* v1.32.16 (issue #581) — a `/api/dashboard/widgets` write payload plus the
|
||||
* optimistic-concurrency base token. `baseUpdatedAt` is the ISO
|
||||
* `User.updatedAt` the client read the layout at; the route writes only when
|
||||
* the stored row still carries it (else 409). It is request-only and never
|
||||
* lands in the persisted `dashboardWidgetsJson` blob.
|
||||
*/
|
||||
export type DashboardLayoutWritePayload = Partial<DashboardLayout> & {
|
||||
baseUpdatedAt?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* v1.32.16 — the shape every GET / PUT `/api/dashboard/widgets` response
|
||||
* carries: the resolved layout plus the server-stamped `updatedAt`
|
||||
* concurrency token (mirrors `User.updatedAt`, never persisted inside the
|
||||
* blob). The client sends it back as `baseUpdatedAt` on the next write.
|
||||
*/
|
||||
export type DashboardLayoutWithToken = DashboardLayout & {
|
||||
updatedAt?: string;
|
||||
};
|
||||
|
||||
export function buildRingMutationPayload(next: {
|
||||
selectedScoreRings: ScoreRingId[];
|
||||
heroRingOrder: HeroRingId[];
|
||||
}): Partial<DashboardLayout> {
|
||||
/**
|
||||
* v1.32.16 (issue #581) — the base token the ring change was made
|
||||
* against. Included when known so the guarded write can 409 rather than
|
||||
* silently overwrite a Save that committed first. Omitted (not sent as
|
||||
* `undefined`) when the client hasn't got a token yet.
|
||||
*/
|
||||
baseUpdatedAt?: string;
|
||||
}): DashboardLayoutWritePayload {
|
||||
return {
|
||||
version: DASHBOARD_LAYOUT_VERSION,
|
||||
selectedScoreRings: next.selectedScoreRings,
|
||||
heroRingOrder: next.heroRingOrder,
|
||||
...(next.baseUpdatedAt !== undefined
|
||||
? { baseUpdatedAt: next.baseUpdatedAt }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.32.16 (issue #581) — the Save button and the instant hero-ring controls
|
||||
* both PUT the whole `/api/dashboard/widgets` layout. While EITHER write is in
|
||||
* flight the other must be disabled, so the two PUTs can never overlap at the
|
||||
* server and a committed Save cannot be reverted by a ring write that started
|
||||
* from the pre-Save snapshot. Belt-and-braces with the shared TanStack
|
||||
* mutation `scope` (which already serialises the two) and the server's
|
||||
* optimistic-concurrency token. Exported as a pure predicate so the JSX binds
|
||||
* one source of truth and a unit test can pin the gate.
|
||||
*/
|
||||
export function widgetWritesBusy(
|
||||
saving: boolean,
|
||||
ringPending: boolean,
|
||||
): boolean {
|
||||
return saving || ringPending;
|
||||
}
|
||||
|
||||
const DASHBOARD_LAYOUT_VERSION = 1;
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user