// Cairn's app: the board, kept in this browser and synced with the Pi when it can reach it. // // Every change is an op ({opid, op, ...}), the same ops board.py applies. The app applies it here at once, keeps it in // an outbox in localStorage, and sends the outbox to /api/sync when the Pi answers. The Pi's reply is the whole board; // any op still waiting is applied again on top of it. So the phone works with no signal, and nothing is lost when a // reply is: an opid the Pi has seen is skipped. apply() below must do what board.py's apply() does. "use strict"; const COLUMNS = ["Doing", "Todo", "Later", "Done"]; const EMPTY = {Doing: "Drag a card here, or Start one", Todo: "Nothing waiting", Later: "Nothing put off", Done: "Nothing done yet"}; const ID_CHARS = "abcdefghijkmnpqrstuvwxyz23456789"; const STORES = {menards: "Menards", harborfreight: "Harbor Freight", fleetfarm: "Fleet Farm", farmstore: "Farm store", dealer: "Dealer", online: "Online", fb: "FB Marketplace"}; // board.py's STORES const KEY = "cairn.v1"; const $ = s => document.querySelector(s); let S = read() || {board: null, version: "", outbox: [], synced: 0, error: "", filter: "", lastCat: "", closed: {Later: true, Done: true}}; let syncing = false, syncTimer = null, drag = null, undo = null, toastTimer = null, editing = null; function read() { try { return JSON.parse(localStorage.getItem(KEY)); } catch (e) { return null; } } function save() { try { localStorage.setItem(KEY, JSON.stringify(S)); } catch (e) { /* private window: memory only */ } } // ------------------------------------------------------------------ dates (local time, like the Pi) const pad = n => String(n).padStart(2, "0"); const iso = d => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; const today = () => iso(new Date()); function addDays(n) { const d = new Date(); d.setDate(d.getDate() + n); return iso(d); } function nextMonth() { const d = new Date(); d.setMonth(d.getMonth() + 1); return iso(d); } const weekday = s => new Date(s + "T12:00").toLocaleDateString("en-US", {weekday: "short"}); function dayLabel(s) { if (s === today()) return "today"; if (s === addDays(1)) return "tomorrow"; return `${weekday(s)} ${s.slice(5)}`; } // ------------------------------------------------------------------ the board (board.py's apply, in JavaScript) function find(b, id) { for (const col of COLUMNS) { const i = b.columns[col].findIndex(c => c.id === id); if (i >= 0) return [col, i]; } return null; } function take(b, id) { const f = find(b, id); if (!f) throw new Error("gone"); return b.columns[f[0]].splice(f[1], 1)[0]; } function place(list, card, where) { if (where === "top") list.unshift(card); else if (!where || where === "bottom") list.push(card); else { const i = list.findIndex(c => c.id === where); list.splice(i < 0 ? 0 : i + 1, 0, card); } } function laterSlot(list, card) { if (!card.until) return "bottom"; const before = list.filter(c => c.until && c.until <= card.until); return before.length ? before[before.length - 1].id : "top"; } function apply(b, op) { const t = today(); if (op.op === "add") { const col = op.column || "Todo"; const card = {id: op.id, title: op.title, cat: op.cat || "", due: op.due || "", until: col === "Later" ? op.until || "" : "", done: col === "Done" ? t : "", notes: [...(op.notes || [])], every: op.every || "", store: op.store || ""}; place(b.columns[col], card, col === "Later" ? laterSlot(b.columns[col], card) : (op.where || "bottom")); return card; } const f = find(b, op.id); if (!f) throw new Error("gone"); const card = b.columns[f[0]][f[1]]; if (op.op === "edit") { for (const k of ["title", "cat", "due", "every", "store"]) if (k in op) card[k] = op[k]; if ("note" in op) card.notes.push(op.note); if ("notes" in op) card.notes = [...op.notes]; return card; } if (op.op === "delete") return take(b, card.id); let to, where; if (op.op === "move") { to = op.column; where = "where" in op ? op.where : "top"; } else if (op.op === "done") { to = "Done"; where = "top"; } else if (op.op === "later") { to = "Later"; where = null; card.until = op.until || ""; } else throw new Error("unknown op"); take(b, card.id); card.done = to === "Done" ? (card.done || t) : ""; if (to !== "Later") card.until = ""; place(b.columns[to], card, to === "Later" ? laterSlot(b.columns[to], card) : where); return card; } function newId() { const used = new Set(COLUMNS.flatMap(c => S.board.columns[c].map(x => x.id))); for (;;) { const id = Array.from(crypto.getRandomValues(new Uint8Array(4)), n => ID_CHARS[n % 32]).join(""); if (!used.has(id)) return id; } } // where a card sits now, so an undo can put it back function position(id) { const f = find(S.board, id); if (!f) return null; const list = S.board.columns[f[0]]; return {column: f[0], where: f[1] ? list[f[1] - 1].id : "top", card: JSON.parse(JSON.stringify(list[f[1]]))}; } function change(op) { op.opid = Date.now().toString(36) + Math.random().toString(36).slice(2, 8); try { apply(S.board, op); } catch (e) { return false; } S.outbox.push(op); save(); render(); syncSoon(); return true; } function restore(was) { // the undo of done, later, delete or a move if (!was) return; if (!find(S.board, was.card.id)) { const c = was.card; change({op: "add", id: c.id, title: c.title, cat: c.cat, due: c.due, notes: c.notes, every: c.every || "", store: c.store || "", column: was.column, until: c.until, where: was.where}); } else if (was.column === "Later") { change({op: "later", id: was.card.id, until: was.card.until}); } else { change({op: "move", id: was.card.id, column: was.column, where: was.where}); } } // ------------------------------------------------------------------ sync function who() { const ua = navigator.userAgent; return /Android/.test(ua) ? "phone" : /Windows/.test(ua) ? "msi" : /Linux/.test(ua) ? "linux" : "app"; } function syncSoon(ms = 400) { clearTimeout(syncTimer); syncTimer = setTimeout(sync, ms); } async function sync() { if (syncing) return syncSoon(1000); syncing = true; status(); const sent = S.outbox.slice(); try { const r = sent.length ? await fetch("api/sync", {method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify({ops: sent, who: who()})}) : await fetch("api/board", {cache: "no-store"}); const d = await r.json(); if (!r.ok) throw new Error(d.error || `the Pi said ${r.status}`); const gone = new Set(sent.map(o => o.opid)); S.outbox = S.outbox.filter(o => !gone.has(o.opid)); S.board = d.board; for (const op of S.outbox) { try { apply(S.board, op); } catch (e) { /* its card went meanwhile */ } } S.version = d.version; S.synced = Date.now(); S.error = ""; } catch (e) { S.error = !navigator.onLine ? "offline" : e instanceof TypeError ? "Pi unreachable" : String(e.message || e); } finally { syncing = false; save(); if (!drag) render(); else status(); } } function status() { const b = $("#status"); const n = S.outbox.length; const at = S.synced ? new Date(S.synced) : null; let text = syncing ? "syncing…" : at ? `synced ${pad(at.getHours())}:${pad(at.getMinutes())}` : "not synced"; if (S.error) text = S.error; if (n) text = `${n} waiting${S.error ? " · " + S.error : ""}`; b.textContent = text; b.title = S.error ? "Can't reach the Pi. Changes are kept here and sent when it answers." : "Sync now"; b.classList.toggle("waiting", n > 0); } // ------------------------------------------------------------------ drawing function el(tag, props = {}, ...kids) { const e = document.createElement(tag); for (const [k, v] of Object.entries(props)) { if (k === "style") e.style.cssText = v; else if (k.startsWith("data-") || k.startsWith("aria-")) e.setAttribute(k, v); else e[k] = v; } for (const k of kids) if (k != null) e.append(k); return e; } const cats = () => Object.fromEntries((S.board?.categories || []).map(c => [c.tag, c])); const shown = c => !S.filter || c.cat === S.filter; function render() { status(); const main = $("#board"); if (!S.board) { main.replaceChildren(el("p", {}, S.error ? `Can't reach the Pi yet (${S.error}). Open this once while it answers.` : "Loading…")); return; } chips(); main.replaceChildren(...COLUMNS.map(section)); } function chips() { const open = S.board.columns.Doing.concat(S.board.columns.Todo); const chip = (tag, name, colour) => { const n = open.filter(c => !tag || c.cat === tag).length; const b = el("button", {className: "chip", "aria-pressed": String(S.filter === tag), style: colour ? `--c:${colour}` : ""}, colour ? el("i") : null, `${name} ${n}`); b.onclick = () => { S.filter = S.filter === tag ? "" : tag; save(); render(); }; return b; }; $("#chips").replaceChildren(chip("", "All"), ...S.board.categories.map(c => chip(c.tag, c.name.replace(/ \(.*$/, ""), c.colour))); } function section(col) { const list = S.board.columns[col].filter(shown); const foldable = col === "Later" || col === "Done"; const head = foldable ? el("button", {type: "button", onclick: () => { S.closed[col] = !S.closed[col]; save(); render(); }}, el("span", {className: "arrow"}, S.closed[col] ? "▸ " : "▾ "), col.toUpperCase()) : col.toUpperCase(); const box = el("div", {className: "list" + (list.length ? "" : " empty"), "data-empty": EMPTY[col], "data-col": col}); const max = col === "Done" ? 30 : Infinity; box.append(...list.slice(0, max).map(c => card(c, col))); return el("section", {className: col + (foldable && S.closed[col] ? " closed" : ""), "data-col": col}, el("h2", {}, head, el("span", {className: "n"}, String(list.length))), col === "Todo" ? addForm() : null, box); } function card(c, col) { const cat = cats()[c.cat]; const meta = []; if (c.due) { const d = c.due.slice(0, 10), at = c.due.slice(11); const late = col !== "Done" && d < today(); meta.push(el("span", {className: late ? "late" : d <= addDays(1) ? "soon" : ""}, (late ? `late ${d.slice(5)}` : dayLabel(d)) + (at ? ` ${at}` : ""))); } if (c.every) meta.push(el("span", {title: "Repeats. Ticking it makes the next one."}, `↻ ${c.every}`)); if (c.store) meta.push(el("span", {}, `@ ${STORES[c.store] || c.store}`)); if (col === "Later") meta.push(el("span", {}, c.until ? `until ${dayLabel(c.until)}` : "someday")); if (col === "Done" && c.done) meta.push(el("span", {}, `done ${c.done.slice(5)}`)); if (cat && !S.filter) meta.push(el("span", {}, cat.name.replace(/ \(.*$/, ""))); if (c.notes.length) meta.push(el("span", {}, "≡ notes")); const tick = el("button", {className: "tick", type: "button", "aria-label": col === "Done" ? "Not done" : "Done"}); tick.onclick = () => { const was = position(c.id); if (col === "Done") change({op: "move", id: c.id, column: "Todo", where: "top"}); else if (change({op: "done", id: c.id})) toast(`Done: ${c.title}`, was); }; const body = el("button", {className: "body", type: "button"}, el("div", {className: "title"}, c.title), meta.length ? el("div", {className: "meta"}, ...meta) : null); body.onclick = () => openEditor(c.id); const grip = el("div", {className: "grip", "aria-hidden": "true"}, "⋮⋮"); const e = el("div", {className: `card ${col}`, "data-id": c.id, style: cat ? `--c:${cat.colour}` : ""}, tick, body, grip); grip.addEventListener("pointerdown", ev => startDrag(ev, e, c.id)); return e; } function addForm() { const input = el("input", {placeholder: "Add a task…", title: "End with #home (or any tag) to set its category", autocomplete: "off", enterKeyHint: "done"}); const pick = el("select", {"aria-label": "Category"}, el("option", {value: ""}, "none"), ...S.board.categories.map(c => el("option", {value: c.tag}, c.name.replace(/ \(.*$/, "")))); pick.value = S.filter || S.lastCat || ""; const form = el("form", {className: "add"}, input, pick); form.onsubmit = ev => { ev.preventDefault(); let title = input.value.trim(), cat = pick.value; const m = title.match(/(?:^|\s)#([a-z][a-z0-9-]*)$/); if (m && cats()[m[1]]) { cat = m[1]; title = title.slice(0, m.index).trim(); } if (!title) return; S.lastCat = cat; change({op: "add", id: newId(), title, cat, column: "Todo", where: "bottom"}); const again = document.querySelector("section.Todo .add input"); if (again) again.focus(); }; return form; } // ------------------------------------------------------------------ dragging (by the grip, mouse or finger) function startDrag(ev, cardEl, id) { if (ev.button > 0) return; ev.preventDefault(); const r = cardEl.getBoundingClientRect(); const ghost = cardEl.cloneNode(true); ghost.classList.add("ghost"); Object.assign(ghost.style, {width: `${r.width}px`, left: `${r.left}px`, top: `${r.top}px`}); const slot = el("div", {className: "slot", style: `height:${r.height}px`}); cardEl.before(slot); cardEl.hidden = true; document.body.append(ghost); drag = {id, cardEl, ghost, slot, dx: ev.clientX - r.left, dy: ev.clientY - r.top, y: ev.clientY, over: null, from: position(id)}; drag.scroller = setInterval(() => { if (drag.y < 70) scrollBy(0, -14); else if (drag.y > innerHeight - 70) scrollBy(0, 14); }, 16); addEventListener("pointermove", moveDrag); addEventListener("pointerup", endDrag, {once: true}); addEventListener("pointercancel", cancelDrag, {once: true}); } function moveDrag(ev) { const d = drag; d.y = ev.clientY; d.ghost.style.left = `${ev.clientX - d.dx}px`; d.ghost.style.top = `${ev.clientY - d.dy}px`; const sec = document.elementFromPoint(ev.clientX, ev.clientY)?.closest("section"); document.querySelectorAll(".list.over").forEach(l => l.classList.remove("over")); if (!sec) return; const list = sec.querySelector(".list"); d.over = sec.dataset.col; if (sec.classList.contains("closed")) { d.slot.remove(); return; } // a folded column: it goes in at its top list.classList.add("over"); const cards = [...list.querySelectorAll(".card:not([hidden])")]; const next = cards.find(c => { const b = c.getBoundingClientRect(); return ev.clientY < b.top + b.height / 2; }); if (next) next.before(d.slot); else list.append(d.slot); } function finishDrag() { clearInterval(drag.scroller); removeEventListener("pointermove", moveDrag); removeEventListener("pointercancel", cancelDrag); removeEventListener("pointerup", endDrag); drag.ghost.remove(); const d = drag; drag = null; return d; } function endDrag() { const d = finishDrag(); const col = d.over || d.from.column; let where = "top"; if (d.slot.isConnected) { let prev = d.slot.previousElementSibling; while (prev && (prev.hidden || !prev.classList.contains("card"))) prev = prev.previousElementSibling; where = prev ? prev.dataset.id : "top"; } d.slot.remove(); d.cardEl.hidden = false; if (col === d.from.column && where === d.from.where) return render(); if (col === "Done") { if (change({op: "done", id: d.id})) toast(`Done: ${d.from.card.title}`, d.from); } else if (col === "Later") change({op: "later", id: d.id, until: d.from.card.until || ""}); else change({op: "move", id: d.id, column: col, where}); } function cancelDrag() { const d = finishDrag(); d.slot.remove(); d.cardEl.hidden = false; render(); } // ------------------------------------------------------------------ the editor function openEditor(id) { const f = find(S.board, id); if (!f) return; const c = S.board.columns[f[0]][f[1]]; editing = {id, col: f[0]}; $("#f-title").value = c.title; $("#f-cat").replaceChildren(el("option", {value: ""}, "none"), ...S.board.categories.map(k => el("option", {value: k.tag}, k.name.replace(/ \(.*$/, "")))); $("#f-cat").value = c.cat; $("#f-date").value = c.due.slice(0, 10); $("#f-time").value = c.due.slice(11); $("#f-notes").value = c.notes.join("\n"); $("#f-every").value = c.every || ""; $("#f-store").value = c.store || ""; $("#f-until").value = c.until || addDays(7); $("#b-doing").hidden = f[0] === "Doing"; $("#b-done").textContent = f[0] === "Done" ? "Not done" : "Done"; $("#b-done").value = f[0] === "Done" ? "reopen" : "done"; $("#later").classList.remove("on"); $("#b-delete").textContent = "Delete"; $("#edit").returnValue = ""; $("#edit").showModal(); } $("#b-later").onclick = () => $("#later").classList.toggle("on"); $("#b-delete").onclick = () => { const b = $("#b-delete"); if (b.textContent === "Delete") { b.textContent = "Really delete?"; return; } const was = position(editing.id); $("#edit").close("delete-confirmed"); if (change({op: "delete", id: was.card.id})) toast(`Deleted: ${was.card.title}`, was); }; $("#edit").addEventListener("close", () => { const action = $("#edit").returnValue; if (!editing || !action || action === "cancel" || action === "delete-confirmed") { editing = null; return; } const was = position(editing.id); if (!was) { editing = null; return; } const c = was.card; const date = $("#f-date").value, time = $("#f-time").value; const fields = {title: $("#f-title").value.trim() || c.title, cat: $("#f-cat").value, due: date ? date + (time ? `T${time}` : "") : "", every: $("#f-every").value, store: $("#f-store").value}; const notes = $("#f-notes").value.split("\n").map(s => s.trim()).filter(Boolean); const edit = {op: "edit", id: c.id}; for (const k of ["title", "cat", "due", "every", "store"]) if (fields[k] !== (c[k] || "")) edit[k] = fields[k]; if (JSON.stringify(notes) !== JSON.stringify(c.notes)) edit.notes = notes; if (Object.keys(edit).length > 2) change(edit); if (action === "doing") change({op: "move", id: c.id, column: "Doing", where: "top"}); if (action === "done" && change({op: "done", id: c.id})) toast(`Done: ${fields.title}`, was); if (action === "reopen") change({op: "move", id: c.id, column: "Todo", where: "top"}); if (action.startsWith("later:")) { const pick = action.slice(6); const until = {tomorrow: addDays(1), week: addDays(7), month: nextMonth(), date: $("#f-until").value, "": ""}[pick] ?? ""; if (change({op: "later", id: c.id, until})) toast(`Later${until ? ": " + dayLabel(until) : ": someday"}`, was); } editing = null; }); // ------------------------------------------------------------------ toast with undo function toast(text, was) { undo = was; $("#toast-text").textContent = text; $("#toast").classList.add("on"); clearTimeout(toastTimer); toastTimer = setTimeout(() => { $("#toast").classList.remove("on"); undo = null; }, 6000); } $("#toast-undo").onclick = () => { $("#toast").classList.remove("on"); restore(undo); undo = null; }; // ------------------------------------------------------------------ start $("#status").onclick = () => sync(); addEventListener("online", () => sync()); document.addEventListener("visibilitychange", () => { if (document.visibilityState === "visible") sync(); }); setInterval(() => { if (document.visibilityState === "visible" && !drag) sync(); }, 30000); if ("serviceWorker" in navigator) navigator.serviceWorker.register("sw.js").catch(() => {}); // keep the board and the unsent changes when the phone runs short of space (an installed app is usually granted it) if (navigator.storage && navigator.storage.persist) navigator.storage.persist().catch(() => {}); render(); // a push's link is the app with #: open that card once the board is here addEventListener("hashchange", () => { const id = location.hash.slice(1); if (id && S.board && find(S.board, id)) openEditor(id); }); const wanted = location.hash.slice(1); sync().then(() => { if (wanted && S.board && find(S.board, wanted)) { history.replaceState(null, "", location.pathname); openEditor(wanted); } });