Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
115 changes: 89 additions & 26 deletions src/lib/board-sync.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand All @@ -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;
Expand Down Expand Up @@ -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);

Expand All @@ -102,22 +135,22 @@ export function startBoardSync({ data, getUid, setUid, renderAll, onReady, fallb
}
};

const persistLocal = () => writeStoredBoard(payload());

const doSave = async () => {
if (!boardReady) return;
if (saveInFlight) {
saveQueued = true;
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) {
Expand All @@ -133,25 +166,55 @@ 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;
if (keepLocal) {
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);
};
}
76 changes: 67 additions & 9 deletions tests/board-sync.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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, {
Expand Down Expand Up @@ -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();
Expand All @@ -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));
Expand All @@ -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 });
Expand All @@ -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,
Expand All @@ -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();
});
Expand Down
Loading