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(); });