From 14068edbfbf6375bb69401f8e15dd2480bb568af Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 23 Jun 2026 15:21:11 +0000 Subject: [PATCH] Fix board changes lost on page refresh Reject reload only when tasks have invalid owners, not missing due dates. Flush edits made during load once sync is ready. Persist to localStorage when API is unavailable. Save on page unload. Rebased onto main. Co-authored-by: Sanket Sharma --- CHANGELOG.md | 8 +++ src/lib/board-sync.js | 115 ++++++++++++++++++++++++++++++--------- tests/board-sync.test.js | 76 +++++++++++++++++++++++--- 3 files changed, 164 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1014d57..2058710 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## Fix board persistence on refresh (2026-06-23) + +### Fixed +- Saved boards are no longer rejected on reload when many tasks lack due dates. +- Edits made while the board is still loading are flushed to storage once sync is ready. +- Changes persist in `localStorage` when the `/api/board` backend is unavailable (e.g. GitHub Pages static hosting). +- Page unload triggers a final save so quick refreshes are less likely to lose work. + ## Past gantt views (2026-06-22) ### Added diff --git a/src/lib/board-sync.js b/src/lib/board-sync.js index dc99216..e707857 100644 --- a/src/lib/board-sync.js +++ b/src/lib/board-sync.js @@ -2,6 +2,9 @@ import { CLIENTS, DOMAIN_RULES, HARDWARE_VOCAB, PEOPLE, TODAY } from "../data/co import { todayLocalIso } from "./date-core.js"; import { flat, normalizeTaskTree } from "./tree.js"; +const BOARD_STORAGE_KEY = "taskboard_board_v1"; +const LOAD_TIMEOUT_MS = 10000; + function maxTaskId(nodes) { let m = 0; flat(nodes, (n) => { @@ -22,19 +25,12 @@ export function boardPayload(data, getUid) { }; } -export function applyBoard(board, data, setUid) { - if (!board || typeof board !== "object") return false; - if (!board.tasks?.length || !Object.keys(board.people || {}).length) return false; +function boardShapeOk(board) { + return board && typeof board === "object" && board.tasks?.length && Object.keys(board.people || {}).length; +} - let leaves = 0; - let withDue = 0; - flat(board.tasks, (n) => { - if (!n.children?.length) { - leaves += 1; - if (n.due) withDue += 1; - } - }); - if (!leaves || withDue < leaves * 0.3) return false; +export function applyBoard(board, data, setUid) { + if (!boardShapeOk(board)) return false; const peopleKeys = new Set(Object.keys(board.people || {})); let badOwner = false; @@ -64,25 +60,62 @@ export function applyBoard(board, data, setUid) { return true; } -const LOAD_TIMEOUT_MS = 10000; +function readStoredBoard() { + try { + const raw = localStorage.getItem(BOARD_STORAGE_KEY); + if (!raw) return null; + const board = JSON.parse(raw); + return boardShapeOk(board) ? board : null; + } catch { + return null; + } +} -async function fetchBoard() { +function writeStoredBoard(board) { + try { + localStorage.setItem(BOARD_STORAGE_KEY, JSON.stringify(board)); + return true; + } catch (e) { + console.error("Board local save failed", e); + return false; + } +} + +function responseLooksJson(res) { + const ct = res.headers.get("content-type") || ""; + return ct.includes("application/json") || ct.includes("text/json"); +} + +async function fetchBoardFromApi() { const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(), LOAD_TIMEOUT_MS); try { const res = await fetch("/api/board", { signal: ctrl.signal }); - if (!res.ok) throw new Error("load failed"); - return await res.json(); + if (!res.ok || !responseLooksJson(res)) throw new Error("load failed"); + const board = await res.json(); + if (!boardShapeOk(board)) throw new Error("invalid board"); + return board; } finally { clearTimeout(timer); } } +async function saveBoardToApi(body) { + const res = await fetch("/api/board", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + keepalive: true, + }); + if (!res.ok || !responseLooksJson(res)) throw new Error("save failed"); +} + export function startBoardSync({ data, getUid, setUid, renderAll, onReady, fallback, hasLocalEdits }) { let boardReady = false; let saveTimer = null; let saveInFlight = false; let saveQueued = false; + let apiAvailable = true; const payload = () => boardPayload(data, getUid); @@ -102,6 +135,8 @@ export function startBoardSync({ data, getUid, setUid, renderAll, onReady, fallb } }; + const persistLocal = () => writeStoredBoard(payload()); + const doSave = async () => { if (!boardReady) return; if (saveInFlight) { @@ -109,15 +144,13 @@ export function startBoardSync({ data, getUid, setUid, renderAll, onReady, fallb return; } saveInFlight = true; + const body = payload(); + persistLocal(); try { - const res = await fetch("/api/board", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload()), - }); - if (!res.ok) throw new Error("save failed"); + if (apiAvailable) await saveBoardToApi(body); } catch (e) { - console.error("Board save failed", e); + apiAvailable = false; + console.warn("Board API save unavailable; keeping changes in this browser only.", e); } finally { saveInFlight = false; if (saveQueued) { @@ -133,13 +166,32 @@ export function startBoardSync({ data, getUid, setUid, renderAll, onReady, fallb saveTimer = setTimeout(doSave, 500); } + function flushSave() { + if (!boardReady) return; + clearTimeout(saveTimer); + const body = payload(); + persistLocal(); + if (apiAvailable) { + fetch("/api/board", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + keepalive: true, + }).catch(() => { apiAvailable = false; }); + } + } + + const onPageHide = () => flushSave(); + if (typeof window !== "undefined") window.addEventListener("pagehide", onPageHide); + (async () => { let loaded = false; let board = null; try { - board = await fetchBoard(); + board = await fetchBoardFromApi(); } catch (e) { - console.warn("Board load skipped, using built-in sample data.", e); + console.warn("Board API load skipped.", e); + apiAvailable = false; } const keepLocal = hasLocalEdits?.() ?? false; @@ -147,11 +199,22 @@ export function startBoardSync({ data, getUid, setUid, renderAll, onReady, fallb console.info("Keeping local edits made while the board was loading."); } else if (board) { loaded = applyBoard(board, data, setUid); - if (!loaded) console.warn("Board from server rejected, using fallback data."); + if (!loaded) console.warn("Board from server rejected."); + } + + if (!loaded && !keepLocal) { + const stored = readStoredBoard(); + if (stored) loaded = applyBoard(stored, data, setUid); } if (!loaded && !keepLocal) useFallback(); + boardReady = true; safeRender(); onReady(scheduleSave); + if (keepLocal || hasLocalEdits?.()) scheduleSave(); })(); + + return () => { + if (typeof window !== "undefined") window.removeEventListener("pagehide", onPageHide); + }; } diff --git a/tests/board-sync.test.js b/tests/board-sync.test.js index 6855462..09edca3 100644 --- a/tests/board-sync.test.js +++ b/tests/board-sync.test.js @@ -6,6 +6,22 @@ import { applyBoard, startBoardSync } from "../src/lib/board-sync.js"; describe("board-sync", () => { const { T, setUid } = createTaskFactory(); + const mockApiFetch = (board) => vi.fn().mockResolvedValue({ + ok: true, + headers: { get: (k) => (k === "content-type" ? "application/json" : null) }, + json: async () => board, + }); + + const mockStorage = () => { + const store = {}; + vi.stubGlobal("localStorage", { + getItem: (k) => store[k] ?? null, + setItem: (k, v) => { store[k] = v; }, + removeItem: (k) => { delete store[k]; }, + }); + return store; + }; + beforeEach(() => { setUid(0); Object.assign(PEOPLE, { @@ -48,6 +64,26 @@ describe("board-sync", () => { expect(data[0].title).toBe("Local"); }); + it("applyBoard accepts saved boards even when many leaves lack due dates", () => { + const data = []; + const board = { + people: { fd: { name: "Florian", initials: "FD", color: "#3b6ef6", role: "Lead", al: [] } }, + tasks: [ + T("Project", "fd", { + d: "2026-06-20", + c: [ + T("No due 1", "fd", {}), + T("No due 2", "fd", {}), + T("Has due", "fd", { d: "2026-06-18" }), + ], + }), + ], + uid: 5, + }; + expect(applyBoard(board, data, setUid)).toBe(true); + expect(data[0].children).toHaveLength(3); + }); + it("startBoardSync renders once after loading from the server", async () => { const data = []; const renderAll = vi.fn(); @@ -58,10 +94,7 @@ describe("board-sync", () => { uid: 2, }; - vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ - ok: true, - json: async () => board, - })); + vi.stubGlobal("fetch", mockApiFetch(board)); startBoardSync({ data, getUid: () => 0, setUid, renderAll, onReady, fallback: vi.fn() }); await vi.waitFor(() => expect(renderAll).toHaveBeenCalledTimes(1)); @@ -76,6 +109,7 @@ describe("board-sync", () => { const renderAll = vi.fn(); const fallback = vi.fn(() => { data.push(T("Fallback project", "fd", { d: "2026-06-20" })); }); + mockStorage(); vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("offline"))); startBoardSync({ data, getUid: () => 0, setUid, renderAll, onReady: () => {}, fallback }); @@ -86,21 +120,42 @@ describe("board-sync", () => { vi.unstubAllGlobals(); }); - it("startBoardSync keeps local edits made while the board is loading", async () => { + it("startBoardSync loads from localStorage when the API is unavailable", async () => { + const data = []; + const renderAll = vi.fn(); + const onReady = vi.fn(); + const fallback = vi.fn(); + const stored = { + people: { fd: { name: "Florian", initials: "FD", color: "#3b6ef6", role: "Lead", al: [] } }, + tasks: [T("Stored project", "fd", { d: "2026-06-20", c: [T("Task", "fd", { d: "2026-06-18" })] })], + uid: 2, + }; + const store = mockStorage(); + vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("offline"))); + store.taskboard_board_v1 = JSON.stringify(stored); + + startBoardSync({ data, getUid: () => 0, setUid, renderAll, onReady, fallback }); + await vi.waitFor(() => expect(renderAll).toHaveBeenCalledTimes(1)); + expect(data[0]?.title).toBe("Stored project"); + expect(fallback).not.toHaveBeenCalled(); + expect(onReady).toHaveBeenCalledOnce(); + + vi.unstubAllGlobals(); + }); + + it("startBoardSync saves local edits made while the board is loading", async () => { const data = [T("User project", "fd", { d: "2026-06-20" })]; const renderAll = vi.fn(); const onReady = vi.fn(); const fallback = vi.fn(); + const store = mockStorage(); const board = { people: { fd: { name: "Florian", initials: "FD", color: "#3b6ef6", role: "Lead", al: [] } }, tasks: [T("Server project", "fd", { d: "2026-06-20", c: [T("Task", "fd", { d: "2026-06-18" })] })], uid: 2, }; - vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ - ok: true, - json: async () => board, - })); + vi.stubGlobal("fetch", mockApiFetch(board)); startBoardSync({ data, @@ -116,6 +171,9 @@ describe("board-sync", () => { expect(data[0].title).toBe("User project"); expect(fallback).not.toHaveBeenCalled(); expect(onReady).toHaveBeenCalledOnce(); + await vi.waitFor(() => expect(fetch).toHaveBeenCalledTimes(2)); + expect(fetch.mock.calls[1][0]).toBe("/api/board"); + expect(fetch.mock.calls[1][1]?.method).toBe("PUT"); vi.unstubAllGlobals(); });