diff --git a/src/features/dashboard/layout/storage.ts b/src/features/dashboard/layout/storage.ts index b51fa8776..213c8ac9c 100644 --- a/src/features/dashboard/layout/storage.ts +++ b/src/features/dashboard/layout/storage.ts @@ -18,14 +18,15 @@ import type { DashboardLayout, DashboardWidgetId } from "./types"; * trade for a preference: rebuilding a dashboard costs a minute, and a half-migrated one * would be a puzzle. */ -const LAYOUT_VERSION = 2; +export const LAYOUT_VERSION = 2; /** * Keyed per user, because two people share a browser more often than a dashboard. * - * Local storage rather than the backend: there is no endpoint for a per-user layout, and a - * preference that follows the machine is closer to right than one that does not exist. - * Moving it server-side later means replacing these two functions. + * Local storage, and no longer only local storage: `useDashboardLayoutSync.ts` sends the layout to + * `PUT /users/me/dashboard/layout` after every change and reads it back on arrival, so the + * arrangement follows the user to another machine. This is still where the client writes first and + * reads from, which keeps every gesture instant and a failed request free. */ function storageKey(userId: string): string { return `sprintstart:dashboard-layout:${userId}`; @@ -96,3 +97,49 @@ export function clearStoredLayout(userId: string): void { // See storeLayout. } } + +/** + * Whether this browser's copy of the layout is exactly what the server had at the last exchange. + * + * What lets the sync tell a change that never made it up from a reset somewhere else, when the + * server has nothing: a local layout that is not in sync — never uploaded, or changed since and the + * upload failed — is the newer statement and is sent up; one that is in sync is a stale copy of a + * layout the user has since reset on another device, and must not bring it back. + * + * So it is cleared on every local change and set only after a request confirmed that both sides + * agree. Its own key rather than a field on the layout, because it has to describe "no layout" too: + * after a reset, nothing here and nothing there is in sync. + */ +function syncedKey(userId: string): string { + return `sprintstart:dashboard-layout-synced:${userId}`; +} + +export function readLayoutSynced(userId: string): boolean { + if (!userId) return false; + + try { + return window.localStorage.getItem(syncedKey(userId)) === "true"; + } catch { + return false; + } +} + +export function markLayoutSynced(userId: string): void { + if (!userId) return; + + try { + window.localStorage.setItem(syncedKey(userId), "true"); + } catch { + // See storeLayout. + } +} + +export function markLayoutUnsynced(userId: string): void { + if (!userId) return; + + try { + window.localStorage.removeItem(syncedKey(userId)); + } catch { + // See storeLayout. + } +} diff --git a/src/features/dashboard/layout/useDashboardLayout.ts b/src/features/dashboard/layout/useDashboardLayout.ts index 6eb8cfedd..d3deaa586 100644 --- a/src/features/dashboard/layout/useDashboardLayout.ts +++ b/src/features/dashboard/layout/useDashboardLayout.ts @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useCallback, useState } from "react"; import { useAuth } from "../../../context/useAuth"; import { useMyKnowledgeGaps } from "../../knowledge-gaps/useMyKnowledgeGaps"; import { useMyOnboardingStatus } from "../../onboarding/hooks/useMyOnboardingStatus"; @@ -6,6 +6,7 @@ import { useProjectContext } from "../../projects/useProjectContext"; import { DASHBOARD_WIDGET_IDS, getAvailableWidgets } from "./catalog"; import * as operations from "./layoutOperations"; import { clearStoredLayout, readStoredLayout, storeLayout } from "./storage"; +import { useDashboardLayoutSync } from "./useDashboardLayoutSync"; import type { DashboardLayout, DashboardWidgetDefinition, @@ -59,7 +60,8 @@ export type DashboardLayoutController = { * Storage is only read while the user has not touched anything this session; from the first * edit on, `arrangedLayout` is the truth and storage is write-only. Every mutation writes * through immediately — there is no save button because there is nothing to lose: each - * change is small, reversible, and the user is looking straight at the result. + * change is small, reversible, and the user is looking straight at the result. The server copy + * follows a moment later — see {@link useDashboardLayoutSync}. */ export function useDashboardLayout(): DashboardLayoutController { const { profile } = useAuth(); @@ -90,6 +92,14 @@ export function useDashboardLayout(): DashboardLayoutController { const [arrangedLayout, setArrangedLayout] = useState(null); + /* + Bumped when the server's layout has been written into storage, so this render reads it. The + value itself is never used — storage is the source, this only asks for the read to happen. + */ + const [, setPulledRevision] = useState(0); + const onPulled = useCallback(() => setPulledRevision((revision) => revision + 1), []); + const sync = useDashboardLayoutSync(userId, onPulled); + const availableWidgets = getAvailableWidgets({ profile, canManageSelectedProject: canManageSelected, @@ -111,6 +121,7 @@ export function useDashboardLayout(): DashboardLayoutController { function apply(next: DashboardLayout) { setArrangedLayout(next); storeLayout(userId, next); + sync.push(next); } /** A drag fires per pointer move; writing an unchanged layout would hammer storage. */ @@ -131,6 +142,7 @@ export function useDashboardLayout(): DashboardLayoutController { resetLayout: () => { clearStoredLayout(userId); + sync.reset(); setArrangedLayout(null); }, }; diff --git a/src/features/dashboard/layout/useDashboardLayoutSync.ts b/src/features/dashboard/layout/useDashboardLayoutSync.ts new file mode 100644 index 000000000..1408d3861 --- /dev/null +++ b/src/features/dashboard/layout/useDashboardLayoutSync.ts @@ -0,0 +1,208 @@ +import { useCallback, useEffect, useRef } from "react"; + +import { dashboardLayoutService } from "../../../services/dashboardLayoutService"; +import { DASHBOARD_WIDGET_IDS } from "./catalog"; +import { + clearStoredLayout, + LAYOUT_VERSION, + markLayoutSynced, + markLayoutUnsynced, + readLayoutSynced, + readStoredLayout, + storeLayout, +} from "./storage"; +import type { DashboardLayout } from "./types"; + +/** + * How long the dashboard waits after a change before sending the layout up. + * + * A drag or a few resizes in a row are one arrangement, not one request each. Same number as the + * board's sync, for the same reason. + */ +const QUIET_MS = 1200; + +/** A change made before the first read settled, held until it has. */ +type Pending = { kind: "layout"; layout: DashboardLayout } | { kind: "reset" } | null; + +export type DashboardLayoutSync = { + /** Sends the layout up, debounced. */ + push: (layout: DashboardLayout) => void; + /** Forgets the layout on the server too. */ + reset: () => void; +}; + +/** + * Keeps the user's dashboard layout on the server instead of only in this browser. + * + * Modelled on the board's `useBoardStructureSync`, and it decides the same way on arrival: + * + * - The server has a layout → it wins and is written into local storage, and `onPulled` asks the + * dashboard to read it again. It is the one that followed the user here. + * - The server has none and this browser's copy is not in sync with it → the browser's copy is + * sent up. That is the migration for everybody who arranged their dashboard before this + * existed, and equally a change whose upload failed last time. + * - The server has none and this browser's copy *was* in sync → the user reset it on another + * device. The stale copy here is dropped instead of uploaded, or a reset would only hold on the + * device it was made on. See `readLayoutSynced` for what "in sync" means. + * - Neither has one → nothing happens; the default is derived, not stored. + * + * A change made before that first read has settled is not lost and not overwritten: it is held, + * and sent instead of applying the server's copy — the user's latest gesture is the newer + * statement. + * + * Failures interrupt nothing. The layout is already on screen and in local storage, so a request + * that did not go through costs nothing today and is corrected by the next change. + */ +export function useDashboardLayoutSync(userId: string, onPulled: () => void): DashboardLayoutSync { + /** Set once the first read for this user has settled, so nothing is pushed before it. */ + const pulledFor = useRef(null); + const pending = useRef(null); + const timer = useRef | null>(null); + /** The layout waiting in `timer`, so leaving the page can still send it. */ + const queued = useRef(null); + /** + * Counts local changes. A request marks the layout as in sync only if no change was made while it + * was on its way — otherwise it would vouch for a newer local layout it never carried. + */ + const revision = useRef(0); + + /** Marks the layout as in sync, unless something changed locally since `sentAt`. */ + const confirmSynced = useCallback( + (sentAt: number) => { + if (revision.current === sentAt) markLayoutSynced(userId); + }, + [userId], + ); + + useEffect(() => { + if (!userId) return; + + let active = true; + pulledFor.current = null; + pending.current = null; + + void (async () => { + try { + const server = await dashboardLayoutService.fetchLayout(LAYOUT_VERSION); + if (!active) return; + + // Settled before anything below is awaited: a change made while the held one is being + // sent goes through the debounce like any other, instead of landing in `pending` after + // it has already been read and being thrown away with it. + const waiting = pending.current; + pending.current = null; + pulledFor.current = userId; + const sentAt = revision.current; + + if (waiting?.kind === "reset") { + await dashboardLayoutService.resetLayout(); + confirmSynced(sentAt); + } else if (waiting?.kind === "layout") { + await dashboardLayoutService.saveLayout(LAYOUT_VERSION, waiting.layout); + confirmSynced(sentAt); + } else if (server.updatedAt !== null) { + // Not checked here: `readStoredLayout` checks every item on the way out, the same as for + // anything else in storage, and drops widgets and sizes this version does not know. + storeLayout(userId, server.items as DashboardLayout); + markLayoutSynced(userId); + onPulled(); + } else { + const local = readStoredLayout(userId, DASHBOARD_WIDGET_IDS); + + if (local && readLayoutSynced(userId)) { + clearStoredLayout(userId); + onPulled(); + } else if (local) { + await dashboardLayoutService.saveLayout(LAYOUT_VERSION, local); + // Only once it is up there: marked before, a failed upload would read as a reset on + // the next visit and drop the very layout it exists to keep. + confirmSynced(sentAt); + } else { + markLayoutSynced(userId); + } + } + } catch { + // Offline, or the endpoint is not deployed yet: the dashboard keeps working from local + // storage exactly as it did before any of this existed. + } finally { + // A failed read settles too, so later changes are not held forever. + if (active && pulledFor.current !== userId) { + pulledFor.current = userId; + pending.current = null; + } + } + })(); + + return () => { + active = false; + }; + }, [userId, onPulled, confirmSynced]); + + useEffect(() => { + return () => { + // Leaving the page inside the quiet window would otherwise drop the last change. Sent with + // whatever token is current: harmless while signing out reloads the app, but a user switch + // without a reload would send the previous user's layout under the new user's token. + if (timer.current) clearTimeout(timer.current); + timer.current = null; + + const last = queued.current; + queued.current = null; + // Deliberately never marked as in sync: by the time this answers, the next mount may already + // have changed the layout again, and "not in sync" only ever costs an upload of what the + // server already has. + if (last) void dashboardLayoutService.saveLayout(LAYOUT_VERSION, last).catch(() => {}); + }; + }, [userId]); + + const push = useCallback( + (layout: DashboardLayout) => { + if (!userId) return; + + revision.current += 1; + markLayoutUnsynced(userId); + + if (pulledFor.current !== userId) { + pending.current = { kind: "layout", layout }; + return; + } + + if (timer.current) clearTimeout(timer.current); + queued.current = layout; + timer.current = setTimeout(() => { + timer.current = null; + queued.current = null; + const sentAt = revision.current; + void dashboardLayoutService + .saveLayout(LAYOUT_VERSION, layout) + .then(() => confirmSynced(sentAt)) + .catch(() => {}); + }, QUIET_MS); + }, + [userId, confirmSynced], + ); + + const reset = useCallback(() => { + if (!userId) return; + + revision.current += 1; + markLayoutUnsynced(userId); + + if (timer.current) clearTimeout(timer.current); + timer.current = null; + queued.current = null; + + if (pulledFor.current !== userId) { + pending.current = { kind: "reset" }; + return; + } + + const sentAt = revision.current; + void dashboardLayoutService + .resetLayout() + .then(() => confirmSynced(sentAt)) + .catch(() => {}); + }, [userId, confirmSynced]); + + return { push, reset }; +} diff --git a/src/services/dashboardLayoutService.ts b/src/services/dashboardLayoutService.ts new file mode 100644 index 000000000..a792b1b52 --- /dev/null +++ b/src/services/dashboardLayoutService.ts @@ -0,0 +1,43 @@ +import { apiClient } from "./apiClient"; + +const BASE = "/api/v1/users/me/dashboard/layout"; + +/** One placed widget as the server keeps it: the client's own id and size, stored as given. */ +export type DashboardLayoutItemWire = { + id: string; + size: string; +}; + +export type DashboardLayoutWire = { + version: number; + items: DashboardLayoutItemWire[]; + /** Null when there is nothing to use — none stored, or stored under another version. */ + updatedAt: string | null; +}; + +export const dashboardLayoutService = { + /** + * The signed-in user's dashboard arrangement, if one was stored under `version`. + * + * No arrangement, or one written under another version, answers with no items and a null + * `updatedAt` rather than a 404 — both mean "show the default". + */ + async fetchLayout(version: number): Promise { + return await apiClient.fetch( + `${BASE}?version=${encodeURIComponent(String(version))}`, + ); + }, + + /** Stores the whole arrangement, replacing whatever was there. */ + async saveLayout(version: number, items: DashboardLayoutItemWire[]): Promise { + await apiClient.fetch(BASE, { + method: "PUT", + body: JSON.stringify({ version, items }), + }); + }, + + /** Forgets the arrangement, so every device falls back to the default. */ + async resetLayout(): Promise { + await apiClient.fetch(BASE, { method: "DELETE" }); + }, +}; diff --git a/tests/unit/features/dashboard/useDashboardLayoutSync.test.tsx b/tests/unit/features/dashboard/useDashboardLayoutSync.test.tsx new file mode 100644 index 000000000..74c1341eb --- /dev/null +++ b/tests/unit/features/dashboard/useDashboardLayoutSync.test.tsx @@ -0,0 +1,254 @@ +import { act, renderHook, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../../../../src/services/dashboardLayoutService", () => ({ + dashboardLayoutService: { + fetchLayout: vi.fn(), + saveLayout: vi.fn(), + resetLayout: vi.fn(), + }, +})); + +import { dashboardLayoutService } from "../../../../src/services/dashboardLayoutService"; +import { + LAYOUT_VERSION, + markLayoutSynced, + readLayoutSynced, + readStoredLayout, + storeLayout, +} from "../../../../src/features/dashboard/layout/storage"; +import { DASHBOARD_WIDGET_IDS } from "../../../../src/features/dashboard/layout/catalog"; +import { useDashboardLayoutSync } from "../../../../src/features/dashboard/layout/useDashboardLayoutSync"; +import type { DashboardLayout } from "../../../../src/features/dashboard/layout/types"; +import type { DashboardLayoutWire } from "../../../../src/services/dashboardLayoutService"; + +const service = vi.mocked(dashboardLayoutService); + +const LOCAL: DashboardLayout = [ + { id: "greeting", size: "wide" }, + { id: "skills", size: "small" }, +]; + +const SERVER: DashboardLayout = [ + { id: "skills", size: "medium" }, + { id: "greeting", size: "wide" }, +]; + +function nothingStored(): DashboardLayoutWire { + return { version: LAYOUT_VERSION, items: [], updatedAt: null }; +} + +describe("useDashboardLayoutSync", () => { + beforeEach(() => { + vi.clearAllMocks(); + window.localStorage.clear(); + service.saveLayout.mockResolvedValue(undefined); + service.resetLayout.mockResolvedValue(undefined); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("asks for the layout under the client's version", async () => { + service.fetchLayout.mockResolvedValue(nothingStored()); + + renderHook(() => useDashboardLayoutSync("user-1", vi.fn())); + + await waitFor(() => expect(service.fetchLayout).toHaveBeenCalledWith(LAYOUT_VERSION)); + }); + + it("lets the server's layout win and asks the dashboard to read it again", async () => { + storeLayout("user-1", LOCAL); + service.fetchLayout.mockResolvedValue({ + version: LAYOUT_VERSION, + items: SERVER, + updatedAt: "2026-09-30T10:00:00Z", + }); + const onPulled = vi.fn(); + + renderHook(() => useDashboardLayoutSync("user-1", onPulled)); + + await waitFor(() => expect(onPulled).toHaveBeenCalledTimes(1)); + expect(readStoredLayout("user-1", DASHBOARD_WIDGET_IDS)).toEqual(SERVER); + expect(service.saveLayout).not.toHaveBeenCalled(); + }); + + it("sends this browser's layout up when the server has none", async () => { + storeLayout("user-1", LOCAL); + service.fetchLayout.mockResolvedValue(nothingStored()); + const onPulled = vi.fn(); + + renderHook(() => useDashboardLayoutSync("user-1", onPulled)); + + await waitFor(() => expect(service.saveLayout).toHaveBeenCalledWith(LAYOUT_VERSION, LOCAL)); + expect(onPulled).not.toHaveBeenCalled(); + await waitFor(() => expect(readLayoutSynced("user-1")).toBe(true)); + }); + + it("drops a stale copy instead of bringing back a layout that was reset on another device", async () => { + // This browser synced the layout earlier; since then the user reset it somewhere else. + storeLayout("user-1", LOCAL); + markLayoutSynced("user-1"); + service.fetchLayout.mockResolvedValue(nothingStored()); + const onPulled = vi.fn(); + + renderHook(() => useDashboardLayoutSync("user-1", onPulled)); + + await waitFor(() => expect(onPulled).toHaveBeenCalledTimes(1)); + expect(readStoredLayout("user-1", DASHBOARD_WIDGET_IDS)).toBeNull(); + expect(service.saveLayout).not.toHaveBeenCalled(); + }); + + it("does not treat a failed migration as synced, so the next visit tries again", async () => { + storeLayout("user-1", LOCAL); + service.fetchLayout.mockResolvedValue(nothingStored()); + service.saveLayout.mockRejectedValue(new Error("offline")); + + renderHook(() => useDashboardLayoutSync("user-1", vi.fn())); + + await waitFor(() => expect(service.saveLayout).toHaveBeenCalled()); + await act(async () => {}); + expect(readLayoutSynced("user-1")).toBe(false); + expect(readStoredLayout("user-1", DASHBOARD_WIDGET_IDS)).toEqual(LOCAL); + }); + + it("uploads a change whose PUT failed instead of reading it as a reset elsewhere", async () => { + // Nothing anywhere, and the first read settles — this browser is now in sync with the server. + service.fetchLayout.mockResolvedValue(nothingStored()); + const first = renderHook(() => useDashboardLayoutSync("user-1", vi.fn())); + await waitFor(() => expect(readLayoutSynced("user-1")).toBe(true)); + + // The user arranges the dashboard (the dashboard writes storage itself), and the PUT fails. + service.saveLayout.mockRejectedValue(new Error("offline")); + storeLayout("user-1", LOCAL); + act(() => first.result.current.push(LOCAL)); + await waitFor(() => expect(service.saveLayout).toHaveBeenCalledWith(LAYOUT_VERSION, LOCAL)); + first.unmount(); + + // Next visit: the server is still empty. + service.saveLayout.mockReset(); + service.saveLayout.mockResolvedValue(undefined); + const onPulled = vi.fn(); + renderHook(() => useDashboardLayoutSync("user-1", onPulled)); + + await waitFor(() => expect(service.saveLayout).toHaveBeenCalledWith(LAYOUT_VERSION, LOCAL)); + expect(readStoredLayout("user-1", DASHBOARD_WIDGET_IDS)).toEqual(LOCAL); + expect(onPulled).not.toHaveBeenCalled(); + }); + + it("does not vouch for a change made while an earlier PUT was on its way", async () => { + service.fetchLayout.mockResolvedValue(nothingStored()); + const { result } = renderHook(() => useDashboardLayoutSync("user-1", vi.fn())); + await waitFor(() => expect(readLayoutSynced("user-1")).toBe(true)); + + let finishFirst: () => void = () => {}; + service.saveLayout.mockReturnValueOnce(new Promise((resolve) => (finishFirst = resolve))); + + act(() => result.current.push(LOCAL)); + await waitFor(() => expect(service.saveLayout).toHaveBeenCalledWith(LAYOUT_VERSION, LOCAL)); + + // A newer change while the first PUT is still in flight; then the first one answers. + act(() => result.current.push(SERVER)); + await act(() => { + finishFirst(); + return Promise.resolve(); + }); + + expect(readLayoutSynced("user-1")).toBe(false); + }); + + it("does nothing for a user with a layout nowhere", async () => { + service.fetchLayout.mockResolvedValue(nothingStored()); + + renderHook(() => useDashboardLayoutSync("user-1", vi.fn())); + + await waitFor(() => expect(service.fetchLayout).toHaveBeenCalled()); + expect(service.saveLayout).not.toHaveBeenCalled(); + }); + + it("keeps a change made before the first read instead of applying the server's copy", async () => { + let answer: (value: DashboardLayoutWire) => void = () => {}; + service.fetchLayout.mockReturnValue(new Promise((resolve) => (answer = resolve))); + const onPulled = vi.fn(); + + const { result } = renderHook(() => useDashboardLayoutSync("user-1", onPulled)); + + act(() => result.current.push(LOCAL)); + await act(() => { + answer({ version: LAYOUT_VERSION, items: SERVER, updatedAt: "2026-09-30T10:00:00Z" }); + return Promise.resolve(); + }); + + await waitFor(() => expect(service.saveLayout).toHaveBeenCalledWith(LAYOUT_VERSION, LOCAL)); + expect(onPulled).not.toHaveBeenCalled(); + }); + + it("still sends a change made while the held one is being sent", async () => { + let answer: (value: DashboardLayoutWire) => void = () => {}; + service.fetchLayout.mockReturnValue(new Promise((resolve) => (answer = resolve))); + let finishFlush: () => void = () => {}; + service.saveLayout.mockReturnValueOnce(new Promise((resolve) => (finishFlush = resolve))); + + const { result } = renderHook(() => useDashboardLayoutSync("user-1", vi.fn())); + + act(() => result.current.push(LOCAL)); + await act(() => { + answer(nothingStored()); + return Promise.resolve(); + }); + await waitFor(() => expect(service.saveLayout).toHaveBeenCalledWith(LAYOUT_VERSION, LOCAL)); + + // The held change is still on its way when the next one is made. + act(() => result.current.push(SERVER)); + await act(() => { + finishFlush(); + return Promise.resolve(); + }); + + await waitFor(() => + expect(service.saveLayout).toHaveBeenLastCalledWith(LAYOUT_VERSION, SERVER), + ); + }); + + it("debounces changes into one request with the latest layout", async () => { + service.fetchLayout.mockResolvedValue(nothingStored()); + const { result } = renderHook(() => useDashboardLayoutSync("user-1", vi.fn())); + await waitFor(() => expect(service.fetchLayout).toHaveBeenCalled()); + // Let the first read settle before timing the pushes. + await act(async () => {}); + + vi.useFakeTimers(); + act(() => { + result.current.push(LOCAL); + result.current.push(SERVER); + }); + act(() => { + vi.advanceTimersByTime(1500); + }); + + expect(service.saveLayout).toHaveBeenCalledTimes(1); + expect(service.saveLayout).toHaveBeenCalledWith(LAYOUT_VERSION, SERVER); + }); + + it("forgets the layout on the server on reset", async () => { + service.fetchLayout.mockResolvedValue(nothingStored()); + const { result } = renderHook(() => useDashboardLayoutSync("user-1", vi.fn())); + await waitFor(() => expect(service.fetchLayout).toHaveBeenCalled()); + await act(async () => {}); + + act(() => result.current.reset()); + + expect(service.resetLayout).toHaveBeenCalledTimes(1); + }); + + it("keeps working from local storage when the server cannot be reached", async () => { + storeLayout("user-1", LOCAL); + service.fetchLayout.mockRejectedValue(new Error("offline")); + + renderHook(() => useDashboardLayoutSync("user-1", vi.fn())); + + await waitFor(() => expect(service.fetchLayout).toHaveBeenCalled()); + expect(readStoredLayout("user-1", DASHBOARD_WIDGET_IDS)).toEqual(LOCAL); + }); +}); diff --git a/tests/unit/setup/msw-handlers.ts b/tests/unit/setup/msw-handlers.ts index a3ebe7cee..7ddc66d57 100644 --- a/tests/unit/setup/msw-handlers.ts +++ b/tests/unit/setup/msw-handlers.ts @@ -93,6 +93,23 @@ export const handlers = [ ]), ), + // Nobody has arranged their dashboard yet: the default layout. Tests that care about a stored + // layout override this handler. + http.get("/api/v1/users/me/dashboard/layout", ({ request }) => + HttpResponse.json({ + version: Number(new URL(request.url).searchParams.get("version")), + items: [], + updatedAt: null, + }), + ), + + http.put("/api/v1/users/me/dashboard/layout", async ({ request }) => { + const body = (await request.json()) as { version: number; items: unknown[] }; + return HttpResponse.json({ ...body, updatedAt: new Date().toISOString() }); + }), + + http.delete("/api/v1/users/me/dashboard/layout", () => new HttpResponse(null, { status: 204 })), + http.get("/api/v1/users/me/projects", () => HttpResponse.json([{ id: "project-1", name: "SprintStart Project" }]), ),