diff --git a/README.md b/README.md index 1695543..f6dec80 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,88 @@ -# Checklists_Tools -Contains various tools used in specific work flows for Civil Engineering. Very niche +# GDOT Plan Tools + +**Browser-based productivity tools built for civil engineers working on Georgia DOT projects.** +No install. No login. Open the file, get to work. + +--- + +## What's Inside + +| Tool | Purpose | +|---|---| +| **FlowBoard** | Kanban project board with real-time cloud sync | +| **Section 24 Checklist** | Utility Plans — PPG compliance checklist | +| **Section 50 Checklist** | Erosion, Sedimentation & Pollution Control — PPG compliance checklist | +| **Section 60 Checklist** | Right of Way Plans — PPG compliance checklist | + +--- + +## FlowBoard + +A fast, focused Kanban board designed for managing the moving parts of GDOT project deliverables — submittals, reviews, revisions, coordination tasks, and everything in between. + +| Light theme | Dark theme | +|---|---| +|  |  | + +**Core features** + +- **Groups & columns** — Organize by project, phase, or discipline. Columns represent workflow stages (To Do → In Progress → Review → Done) +- **Cards with depth** — Each task carries a title, description, color label, priority flag, tags, due date, and a sub-item checklist +- **WIP limits** — Cap work-in-progress per column to surface bottlenecks before they become delays +- **Drag & drop** — Reorder cards and columns with fluid animations; collapsed columns pin as vertical pills to save screen space +- **Deadline tracking** — Overdue cards pulse red so nothing slips past a submission date +- **Search** — Filter cards across all groups instantly +- **Undo / Redo** — Full history stack; mistakes are reversible +- **15 themes** — Dark and light palettes including Midnight, Navy, Pearl, and more +- **Cloud sync** — Firebase Realtime Database keeps your board identical across devices; a safety gate blocks accidental overwrites from stale data +- **Backups** — Manual snapshots, auto-snapshots on idle, export/import JSON; up to 50 snapshots stored +- **Archive** — Move completed columns out of view without deleting them + +**Works entirely in the browser** — save the HTML file locally or host it on any static server. + +--- + +## PPG Compliance Checklists + +The GDOT [Plan Presentation Guide](https://gdot.ga.gov) defines exactly what must appear on every plan sheet before a set is accepted. Missing an item means a rejection, a resubmittal, and lost time. + +These checklists put every PPG requirement in front of you as an interactive list — organized by section, with the original wording preserved verbatim. Check items off as you review the plans; the progress bar tracks where you stand. + +**Section 24 — Utility Plans** +**Section 50 — Erosion, Sedimentation and Pollution Control Cover Drawing** +**Section 60 — Right of Way Plans** (Cover Drawing + Plan Drawings General) + +**Checklist features** + +- **Filter view** — Show All, Remaining only, or Done — focus on what's left without losing context +- **Progress tracking** — Live counter and progress bar per section and overall; sections badge green when complete +- **Collapsible sections** — Collapse sections you've finished to reduce visual noise +- **Auto-save** — Every check is written to localStorage instantly; reopen the file and pick up exactly where you left off +- **Snapshots** — Save named backups of your progress at any point; restore with one click +- **Export / Import** — Transfer progress between machines via JSON; useful when plans move between workstations or team members +- **Save as PNG / PDF** — Export a clean image or print-ready PDF of your completed checklist for the project file + +--- + +## How They Work Together + +1. **Open FlowBoard** and create a column for each phase of your submittal — *In Progress*, *Internal QC*, *Checking PPG*, *Ready to Submit* +2. When a plan set reaches *Checking PPG*, **open the relevant checklist** and work through it item by item +3. Once the checklist is complete, drag the card to *Ready to Submit* and export a PDF of the checklist as documentation +4. Reopen both files for the next review cycle — progress is remembered + +--- + +## Quick Start + +All tools are single HTML files. No build step, no dependencies to install. + +1. Clone or download this repository +2. Open any `.html` file directly in Chrome, Edge, or Firefox +3. For FlowBoard cloud sync, add your own Firebase Realtime Database credentials in the config block at the top of `flowboard.html` + +--- + +## Who This Is For + +Civil engineers, designers, and project managers at GDOT and consulting firms preparing construction plan sets for Georgia DOT submission. The checklists are keyed directly to the PPG — if you work on GDOT projects, these sections apply to your plans. diff --git a/Section 01 Checklist.html b/Section 01 Checklist.html new file mode 100644 index 0000000..ceea1b4 --- /dev/null +++ b/Section 01 Checklist.html @@ -0,0 +1,847 @@ + + +
+ + +DSK_CONFIG.firebaseConfig and set
+ DSK_CONFIG.firebaseEnabled = true in the source to enable it — every subsystem (undo,
+ snapshots, export/import, the modal below) already runs fully local-only with no cloud dependency.
+ A grouped inventory of FlowBoard features. Use this as the roadmap for creating focused snippet pages that can be reused across other browser-based apps.
+State & Persistence. It is the best starting point because undo, snapshots, import/export, archive, and sync all depend on a clean state shape plus reliable save/load helpers.
+ The app foundation: state object shape, local persistence, state replacement, and schema checks.
+SlocalStoragelocalStorageloadState() and fallback defaultssaveState() with lastModifiedvalidateState() for app-specific schemasreplaceState() for imports, restores, and remote syncFeatures that let users recover from mistakes or return to known-good states.
+_pushUndo(), doUndo(), and doRedo()createSnapshot() and restoreSnapshot()createAutoSnapshot() trigger helpersThe reusable Kanban structure: groups contain columns, columns contain cards, and cards contain nested details.
+uid()Common UI operations for creating, editing, duplicating, deleting, and restoring user content.
+Pointer-based movement systems for cards and columns, plus visual drop indicators.
+elementFromPoint()Card-level attributes that make a task system useful beyond simple titles.
+Features for keeping large boards readable and controlling work in progress.
+Fast filtering helpers for finding cards and narrowing visible work.
+Reusable styling infrastructure for apps that need multiple visual modes without a build step.
+Overlay and floating-panel systems used for focused editing, settings, and secondary workflows.
+setModal() and closeModal()Optional Firebase Realtime Database sync for keeping the same board state across devices.
+Features for moving board data or documentation outside the live app.
+Defensive behaviors that prevent data loss and keep the app resilient.
+Reusable implementation notes for app features that are useful beyond FlowBoard: undo/redo, snapshots, auto-backups, import/export, and archive behavior.
+These globals hold undo history, redo history, and auto-snapshot timing state. Keep them outside the saved app state unless you specifically want these mechanics to sync across devices.
+// -- Undo / Redo --
+var _undoStack = []; // array of JSON strings, full state snapshots, max 50
+var _redoStack = []; // same shape as _undoStack, max 50
+var _undoInProgress = false; // prevents re-entrant pushes during doUndo/doRedo
+
+// -- Auto-snapshot trigger counter --
+var _savesSinceAutoSnap = 0;
+var _idleSnapTimer = null; // fires after 20 minutes of inactivity
+ The core pattern is simple: snapshot the full state before each user-level mutation. Undo replaces the live state with the last snapshot and pushes the replaced state onto the redo stack.
+ +// Call _pushUndo() at the start of every mutation, before changing state.
+function _pushUndo() {
+ if (_undoInProgress) return;
+ _undoStack.push(JSON.stringify(S));
+ if (_undoStack.length > 50) _undoStack.shift();
+ _redoStack = [];
+ _persistUndoSession();
+ _updateUndoUI();
+}
+
+function doUndo() {
+ if (_undoStack.length === 0) return;
+ _redoStack.push(JSON.stringify(S));
+ if (_redoStack.length > 50) _redoStack.shift();
+ _undoInProgress = true;
+ S = JSON.parse(_undoStack.pop());
+ save();
+ render();
+ _undoInProgress = false;
+ _persistUndoSession();
+ _updateUndoUI();
+ notify('Undone' + ((_undoStack.length > 0) ? ' (' + _undoStack.length + ' more)' : ''));
+}
+
+function doRedo() {
+ if (_redoStack.length === 0) return;
+ _undoStack.push(JSON.stringify(S));
+ if (_undoStack.length > 50) _undoStack.shift();
+ _undoInProgress = true;
+ S = JSON.parse(_redoStack.pop());
+ save();
+ render();
+ _undoInProgress = false;
+ _persistUndoSession();
+ _updateUndoUI();
+ notify('Redone' + ((_redoStack.length > 0) ? ' (' + _redoStack.length + ' more)' : ''));
+}
+
+ function _updateUndoUI() {
+ var ub = document.getElementById('undo-btn');
+ var rb = document.getElementById('redo-btn');
+
+ if (ub) {
+ var ul = _undoStack.length;
+ ub.disabled = ul === 0;
+ ub.title = ul === 0
+ ? 'Nothing to undo'
+ : 'Undo (' + ul + ' step' + (ul !== 1 ? 's' : '') + ') - Ctrl+Z';
+ }
+
+ if (rb) {
+ var rl = _redoStack.length;
+ rb.disabled = rl === 0;
+ rb.title = rl === 0
+ ? 'Nothing to redo'
+ : 'Redo (' + rl + ' step' + (rl !== 1 ? 's' : '') + ') - Ctrl+Y';
+ }
+}
+
+ This mirrors the last 10 undo snapshots into sessionStorage. It can survive a page refresh, but clears when the browser tab is closed.
function _persistUndoSession() {
+ try {
+ var slice = _undoStack.slice(-10);
+ sessionStorage.setItem('fb4_undo', JSON.stringify(slice));
+ } catch (e) {}
+}
+
+function _restoreUndoSession() {
+ try {
+ var stored = sessionStorage.getItem('fb4_undo');
+ if (!stored) return;
+
+ var arr = JSON.parse(stored);
+ if (!Array.isArray(arr) || arr.length === 0) return;
+
+ _undoStack = arr;
+ _updateUndoUI();
+ notify('Undo history restored from last session (' + arr.length + ' step' + (arr.length !== 1 ? 's' : '') + ')');
+ } catch (e) {}
+}
+
+ document.addEventListener('keydown', function(e) {
+ if ((e.ctrlKey || e.metaKey) && e.key === 'z') {
+ e.preventDefault();
+ doUndo();
+ }
+
+ if ((e.ctrlKey || e.metaKey) && e.key === 'y') {
+ e.preventDefault();
+ doRedo();
+ }
+});
+
+ function addItem(data) {
+ _pushUndo();
+ S.items.push(data);
+ save();
+ render();
+}
+
+function deleteItem(id) {
+ _pushUndo();
+ S.items = S.items.filter(function(x) { return x.id !== id; });
+ save();
+ render();
+}
+ Manual snapshots are durable user-created save points stored in localStorage. They are intentionally separate from undo history.
function getBaks() {
+ try {
+ return JSON.parse(localStorage.getItem('fb4_baks') || '[]');
+ } catch (e) {
+ return [];
+ }
+}
+
+function setBaks(a) {
+ localStorage.setItem('fb4_baks', JSON.stringify(a));
+}
+
+ function createBak(name) {
+ var a = getBaks();
+ var tc = S.cols.reduce(function(n, c) { return n + c.cards.length; }, 0);
+
+ a.unshift({
+ id: uid(),
+ name: name || 'Snapshot ' + new Date().toLocaleString(),
+ ts: Date.now(),
+ cards: tc,
+ data: JSON.stringify(S)
+ });
+
+ if (a.length > 25) a.splice(25);
+ setBaks(a);
+ notify('Snapshot saved.');
+}
+
+function renameBak(id) {
+ var baks = getBaks();
+ var b = baks.find(function(x) { return x.id === id; });
+ if (!b) return;
+
+ var orig = b.name;
+ var inp = document.createElement('input');
+ inp.value = orig;
+
+ inp.onblur = function() {
+ var v = inp.value.trim() || orig;
+ baks.forEach(function(x) {
+ if (x.id === id) x.name = v;
+ });
+ setBaks(baks);
+ };
+
+ inp.onkeydown = function(e) {
+ if (e.key === 'Enter') inp.blur();
+ else if (e.key === 'Escape') {
+ inp.value = orig;
+ inp.blur();
+ }
+ };
+
+ // Replace the name element with the input in your UI.
+}
+
+function deleteBak(id) {
+ setBaks(getBaks().filter(function(x) { return x.id !== id; }));
+}
+
+function restoreBak(id) {
+ var b = getBaks().find(function(x) { return x.id === id; });
+ if (!b || !confirm('Restore snapshot? Current board will be replaced.')) return;
+
+ _autoSnapBefore('Before: Restore snapshot - ' + new Date().toLocaleTimeString());
+ _undoStack = [];
+ _redoStack = [];
+ _persistUndoSession();
+ _updateUndoUI();
+
+ S = JSON.parse(b.data);
+ save();
+ render();
+ notify('Board restored.');
+}
+ Auto-snapshots protect users before risky operations and preserve useful recovery points without requiring manual action.
+function getAutoBaks() {
+ try {
+ return JSON.parse(localStorage.getItem('fb4_auto') || '[]');
+ } catch (e) {
+ return [];
+ }
+}
+
+function setAutoBaks(a) {
+ try {
+ localStorage.setItem('fb4_auto', JSON.stringify(a));
+ } catch (e) {
+ console.warn('Storage full - auto-snapshot skipped.');
+ }
+}
+
+function _createAutoSnap(name, trigger) {
+ var autos = getAutoBaks();
+ autos.unshift({
+ id: uid(),
+ name: name,
+ ts: Date.now(),
+ trigger: trigger || 'auto',
+ data: JSON.stringify(S)
+ });
+
+ if (autos.length > 15) autos.splice(15);
+ setAutoBaks(autos);
+}
+
+function _autoSnapBefore(label) {
+ _createAutoSnap(label, 'destructive');
+}
+
+function _sessionStartSnap() {
+ var boardTs = S.lastModified || 0;
+ if (boardTs === 0) return;
+
+ var autos = getAutoBaks();
+ var lastAutoTs = autos.length > 0 ? autos[0].ts : 0;
+ if (boardTs <= lastAutoTs) return;
+
+ _createAutoSnap('Opened - ' + new Date().toLocaleString(), 'session');
+}
+
+function restoreAutoBak(id) {
+ var b = getAutoBaks().find(function(x) { return x.id === id; });
+ if (!b || !confirm('Restore this auto-snapshot? Current board will be replaced.')) return;
+
+ _autoSnapBefore('Before: Restore auto-snapshot - ' + new Date().toLocaleTimeString());
+ _undoStack = [];
+ _redoStack = [];
+ _persistUndoSession();
+ _updateUndoUI();
+
+ S = JSON.parse(b.data);
+ save();
+ render();
+ notify('Board restored from auto-snapshot.');
+}
+
+function deleteAutoBak(id) {
+ setAutoBaks(getAutoBaks().filter(function(x) { return x.id !== id; }));
+}
+ Export writes either the live board state or a named snapshot to a JSON file. Import validates the JSON, creates a protective auto-snapshot, then replaces the live state.
+function exportBak(id) {
+ var data, name;
+
+ if (id) {
+ var b = getBaks().find(function(x) { return x.id === id; });
+ if (!b) return;
+ data = b.data;
+ name = b.name;
+ } else {
+ data = JSON.stringify(S);
+ name = 'flowboard_export';
+ }
+
+ var a = document.createElement('a');
+ a.href = URL.createObjectURL(new Blob([data], { type: 'application/json' }));
+ a.download = name.replace(/[^\w\s-]/g, '').trim().replace(/\s+/g, '_') + '_' + Date.now() + '.json';
+ a.click();
+}
+
+function importBak() {
+ var inp = document.createElement('input');
+ inp.type = 'file';
+ inp.accept = '.json';
+
+ inp.onchange = function(e) {
+ var f = e.target.files[0];
+ if (!f) return;
+
+ var r = new FileReader();
+ r.onload = function(ev) {
+ try {
+ var d = JSON.parse(ev.target.result);
+ if (!d.cols) throw 0;
+ if (!confirm('Import? Current board will be replaced.')) return;
+
+ _autoSnapBefore('Before: Import - ' + new Date().toLocaleTimeString());
+ _undoStack = [];
+ _redoStack = [];
+ _persistUndoSession();
+ _updateUndoUI();
+
+ S = d;
+ save();
+ render();
+ notify('Imported.');
+ } catch (e2) {
+ alert('Invalid file.');
+ }
+ };
+
+ r.readAsText(f);
+ };
+
+ inp.click();
+}
+ Archive is non-destructive. Items stay in the data model with flags and timestamps, while the active board render filters them out.
+ +function archiveCard(cid) {
+ _pushUndo();
+
+ for (var i = 0; i < S.cols.length; i++) {
+ var idx = S.cols[i].cards.findIndex(function(x) { return x.id === cid; });
+ if (idx >= 0) {
+ var card = S.cols[i].cards[idx];
+ card.archived = true;
+ card.archivedAt = Date.now();
+ card.archivedFromCol = S.cols[i].id;
+ break;
+ }
+ }
+
+ save();
+ render();
+ notify('Card archived.');
+}
+
+function archiveCol(cid) {
+ var col = S.cols.find(function(x) { return x.id === cid; });
+ if (!col) return;
+
+ _pushUndo();
+ col.archived = true;
+ col.archivedAt = Date.now();
+ save();
+ render();
+ notify('Column archived.');
+}
+
+ function restoreCard(cid) {
+ _pushUndo();
+
+ for (var i = 0; i < S.cols.length; i++) {
+ var idx = S.cols[i].cards.findIndex(function(x) { return x.id === cid; });
+ if (idx >= 0) {
+ var card = S.cols[i].cards[idx];
+ delete card.archived;
+ delete card.archivedAt;
+ delete card.archivedFromCol;
+ break;
+ }
+ }
+
+ save();
+ render();
+ notify('Card restored.');
+}
+
+function restoreCol(cid) {
+ var col = S.cols.find(function(x) { return x.id === cid; });
+ if (!col) return;
+
+ _pushUndo();
+ delete col.archived;
+ delete col.archivedAt;
+
+ col.cards.forEach(function(card) {
+ delete card.archived;
+ delete card.archivedAt;
+ delete card.archivedFromCol;
+ });
+
+ save();
+ render();
+ notify('Column restored.');
+}
+
+ function deleteArchivedCard(cid) {
+ if (!confirm('Permanently delete this card?')) return;
+ _pushUndo();
+
+ for (var i = 0; i < S.cols.length; i++) {
+ var idx = S.cols[i].cards.findIndex(function(x) { return x.id === cid; });
+ if (idx >= 0) {
+ S.cols[i].cards.splice(idx, 1);
+ break;
+ }
+ }
+
+ save();
+ render();
+}
+
+function deleteArchivedCol(cid) {
+ if (!confirm('Permanently delete this column and all its cards?')) return;
+ _pushUndo();
+ S.cols = S.cols.filter(function(x) { return x.id !== cid; });
+ save();
+ render();
+}
+
+ var activeCols = S.cols.filter(function(c) { return !c.archived; });
+var activeCards = col.cards.filter(function(k) { return !k.archived; });
+ | Pattern | +How it works | +
|---|---|
| Undo granularity | +Use one _pushUndo() call per user action, not per keystroke. |
+
| Redo invalidation | +Any new action clears _redoStack, matching standard editor behavior. |
+
| Auto-snap triggers | +Create snapshots at session start, after idle timeout, and before destructive deletes or full-state replacement. | +
| Archive vs delete | +Archive sets flags and preserves the item in state. Delete removes it from the array. | +
| Import/restore clears undo | +Replacing the entire state makes prior undo steps unreliable, so both stacks are wiped. | +
d.cols with validation for that app's state model.Implementation notes and reusable code patterns for FlowBoard features. These docs focus on snippets that can be copied into similar browser-based apps.
+Grouped inventory of FlowBoard features and the recommended order for building reusable snippet pages.
+ + +Undo/redo, manual backups, auto-snapshots, import/export, and archive patterns.
+ +State shape, localStorage load/save, validation, and full-state replacement.
+Undo, redo, manual snapshots, auto-snapshots, and restore behavior.
+Groups, columns, cards, checklist items, IDs, selectors, and move helpers.
+Add, rename, delete, duplicate, and checklist editing patterns.
+Pointer drag, ghost elements, drop targets, and array movement.
+Priority, tags, due dates, color labels, and checklist progress.
+Collapse states, WIP limits, priority sorting, and board stats.
+Query matching, tag search, archive filtering, and checklist filters.
+CSS theme tokens, theme switching, palette helpers, and notifications.
+Modal root, dirty-close guard, dropdowns, and click-outside panels.
+Sync status, board keys, debounced writes, and safe remote write checks.
+JSON download, import pipeline, filename cleanup, and print hooks.
+Destructive action wrappers, undo clearing, storage checks, and safe import.
+flowboard_docs is only for FlowBoard documentation, so it stays separate from checklist docs and other tools in the repository.
+ A reusable Kanban state structure with groups, columns, cards, nested checklist items, and archived flags.
var S = {
+ prefs: { theme: 'midnight', boardName: 'My Board' },
+ lastModified: 0,
+ groups: [
+ { id: 'g1', title: 'Work', pi: 0, collapsed: false, limit: 0 }
+ ],
+ cols: [
+ {
+ id: 'c1', gid: 'g1', title: 'To Do', pi: 0,
+ collapsed: false, sort: false, archived: false,
+ cards: [
+ { id: 'k1', title: 'Task', desc: '', pri: 1, color: null, tags: [], due: '', items: [], done: [] }
+ ]
+ }
+ ]
+};function findGroup(gid) {
+ return S.groups.find(function(g) { return g.id === gid; });
+}
+
+function findColumn(cid) {
+ return S.cols.find(function(c) { return c.id === cid; });
+}
+
+function findCard(cid) {
+ for (var i = 0; i < S.cols.length; i++) {
+ var card = S.cols[i].cards.find(function(k) { return k.id === cid; });
+ if (card) return { card: card, col: S.cols[i] };
+ }
+ return null;
+}function activeColumns() {
+ return S.cols.filter(function(c) { return !c.archived; });
+}
+
+function activeCards(col) {
+ return col.cards.filter(function(k) { return !k.archived; });
+}
+
+function boardCounts() {
+ var cols = activeColumns();
+ var cards = cols.reduce(function(n, c) { return n + activeCards(c).length; }, 0);
+ return { groups: S.groups.length, cols: cols.length, cards: cards };
+}function moveCard(cardId, targetColId, targetIndex) {
+ var found = findCard(cardId);
+ var target = findColumn(targetColId);
+ if (!found || !target) return;
+
+ pushUndo();
+ found.col.cards = found.col.cards.filter(function(k) { return k.id !== cardId; });
+ target.cards.splice(targetIndex, 0, found.card);
+ saveState();
+ render();
+}Optional Firebase-style sync patterns: status, board keys, debounced writes, and safety checks.
var sync = { status: 'local', key: '', ref: null, timer: null };
+
+function setSyncStatus(status) {
+ sync.status = status; // local | syncing | synced | error | offline
+ var el = document.getElementById('sync-status');
+ if (el) el.textContent = status;
+}function newBoardKey() {
+ var parts = [];
+ for (var i = 0; i < 5; i++) parts.push(Math.random().toString(16).slice(2, 6));
+ return parts.join('-');
+}
+
+function saveActiveKey(key) {
+ localStorage.setItem('fb4_active_key', key);
+ sync.key = key;
+}function queueRemoteWrite() {
+ if (!sync.ref || sync.status === 'local') return;
+ clearTimeout(sync.timer);
+ sync.timer = setTimeout(function() {
+ if (!isSafeRemoteWrite(S)) return;
+ setSyncStatus('syncing');
+ sync.ref.set(S).then(function() { setSyncStatus('synced'); })
+ .catch(function() { setSyncStatus('error'); });
+ }, 600);
+}function isSafeRemoteWrite(next) {
+ if (!next || !Array.isArray(next.cols)) return false;
+ var cardCount = next.cols.reduce(function(n, c) { return n + (c.cards || []).length; }, 0);
+ if (S.cols.length && next.cols.length === 0) return false;
+ if (boardCounts().cards > 5 && cardCount === 0) return false;
+ return true;
+}Pointer-based drag behavior for cards and columns, including ghosts, drop targets, and array movement.
var drag = null;
+
+function startDrag(e, data) {
+ drag = { data: data, startX: e.clientX, startY: e.clientY, active: false, ghost: null };
+ document.addEventListener('pointermove', onDragMove);
+ document.addEventListener('pointerup', endDrag, { once: true });
+}
+
+function onDragMove(e) {
+ if (!drag) return;
+ var dx = e.clientX - drag.startX;
+ var dy = e.clientY - drag.startY;
+ if (!drag.active && Math.hypot(dx, dy) > 5) activateDrag(e);
+ if (drag.active) positionGhost(e);
+}function activateDrag(e) {
+ drag.active = true;
+ drag.ghost = drag.data.el.cloneNode(true);
+ drag.ghost.style.position = 'fixed';
+ drag.ghost.style.pointerEvents = 'none';
+ drag.ghost.style.opacity = '0.88';
+ drag.ghost.style.transform = 'rotate(2deg) scale(1.04)';
+ document.body.appendChild(drag.ghost);
+ positionGhost(e);
+}
+
+function positionGhost(e) {
+ drag.ghost.style.left = e.clientX + 12 + 'px';
+ drag.ghost.style.top = e.clientY + 12 + 'px';
+}function getDropTarget(x, y) {
+ if (drag.ghost) drag.ghost.style.display = 'none';
+ var el = document.elementFromPoint(x, y);
+ if (drag.ghost) drag.ghost.style.display = '';
+ return el ? el.closest('[data-drop-target]') : null;
+}function moveBetweenArrays(source, target, itemId, targetIndex) {
+ var idx = source.findIndex(function(x) { return x.id === itemId; });
+ if (idx < 0) return;
+ var item = source.splice(idx, 1)[0];
+ target.splice(targetIndex, 0, item);
+}Reusable patterns for adding, renaming, deleting, duplicating, and editing board data.
function addGroup(title) {
+ pushUndo();
+ S.groups.push({ id: uid(), title: title || 'New Group', pi: 0, collapsed: false, limit: 0 });
+ saveState(); render();
+}
+
+function addColumn(title, gid) {
+ pushUndo();
+ S.cols.push({ id: uid(), gid: gid || null, title: title || 'New Column', pi: 0, collapsed: false, sort: false, cards: [] });
+ saveState(); render();
+}
+
+function addCard(colId, title) {
+ var col = findColumn(colId);
+ if (!col) return;
+ pushUndo();
+ col.cards.push({ id: uid(), title: title || 'New Card', desc: '', pri: 0, tags: [], items: [], done: [], due: '', color: null });
+ saveState(); render();
+}function startInlineRename(labelEl, currentValue, onSave) {
+ var input = document.createElement('input');
+ input.value = currentValue;
+ labelEl.replaceWith(input);
+ input.focus();
+ input.select();
+
+ input.onblur = function() {
+ var next = input.value.trim() || currentValue;
+ onSave(next);
+ };
+
+ input.onkeydown = function(e) {
+ if (e.key === 'Enter') input.blur();
+ if (e.key === 'Escape') {
+ input.value = currentValue;
+ input.blur();
+ }
+ };
+}function deleteColumn(colId) {
+ if (!confirm('Delete this column and all cards?')) return;
+ createAutoSnapshot('Before deleting column', 'destructive');
+ pushUndo();
+ S.cols = S.cols.filter(function(c) { return c.id !== colId; });
+ saveState(); render();
+}function addChecklistItem(card, text) {
+ pushUndo();
+ card.items.unshift(text);
+ saveState(); render();
+}
+
+function toggleChecklistItem(card, text) {
+ pushUndo();
+ var i = card.done.indexOf(text);
+ if (i >= 0) card.done.splice(i, 1);
+ else card.done.push(text);
+ saveState(); render();
+}JSON download, import pipeline, filename cleanup, and print/export helpers.
function safeFileName(name) {
+ return (name || 'export').replace(/[^\w\s-]/g, '').trim().replace(/\s+/g, '_');
+}
+
+function downloadJson(data, name) {
+ var blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
+ var a = document.createElement('a');
+ a.href = URL.createObjectURL(blob);
+ a.download = safeFileName(name) + '_' + Date.now() + '.json';
+ a.click();
+ URL.revokeObjectURL(a.href);
+}function exportCurrentBoard() {
+ downloadJson(S, 'flowboard_export');
+}function importJsonFile() {
+ var input = document.createElement('input');
+ input.type = 'file';
+ input.accept = '.json,application/json';
+ input.onchange = function(e) {
+ var file = e.target.files[0];
+ if (!file) return;
+ var reader = new FileReader();
+ reader.onload = function(ev) {
+ var next = JSON.parse(ev.target.result);
+ if (!validateState(next)) return alert('Invalid file.');
+ if (!confirm('Import? Current board will be replaced.')) return;
+ createAutoSnapshot('Before import', 'destructive');
+ replaceState(next);
+ };
+ reader.readAsText(file);
+ };
+ input.click();
+}function printCurrentView() {
+ document.body.classList.add('print-mode');
+ window.print();
+ setTimeout(function() { document.body.classList.remove('print-mode'); }, 0);
+}Generic overlay, floating panel, dirty-close, and dropdown patterns for single-page tools.
function setModal(html, width) {
+ var root = document.getElementById('modal-root');
+ if (!root) {
+ root = document.createElement('div');
+ root.id = 'modal-root';
+ document.body.appendChild(root);
+ }
+
+ root.innerHTML = '';
+}
+
+function closeModal() {
+ var root = document.getElementById('modal-root');
+ if (root) root.innerHTML = '';
+}var modalDirty = false;
+
+function guardedCloseModal() {
+ if (modalDirty && !confirm('Discard unsaved changes?')) return;
+ modalDirty = false;
+ closeModal();
+}function openPanel(panel, anchor) {
+ panel.classList.add('open');
+
+ setTimeout(function() {
+ document.addEventListener('pointerdown', function handler(e) {
+ if (!panel.contains(e.target) && !anchor.contains(e.target)) {
+ panel.classList.remove('open');
+ document.removeEventListener('pointerdown', handler);
+ }
+ });
+ }, 0);
+}function selectFromDropdown(value, onSelect) {
+ pushUndo();
+ onSelect(value);
+ saveState();
+ render();
+}Patterns for collapse states, WIP limits, active selectors, sorting, and board statistics.
function toggleGroup(gid) {
+ var group = findGroup(gid);
+ if (!group) return;
+ pushUndo();
+ group.collapsed = !group.collapsed;
+ saveState(); render();
+}
+
+function toggleColumn(cid) {
+ var col = findColumn(cid);
+ if (!col) return;
+ pushUndo();
+ col.collapsed = !col.collapsed;
+ saveState(); render();
+}var CARD_SLOT_H = 86;
+
+function columnBodyHeight(group, cardCount) {
+ if (!group || !group.limit) return '';
+ return Math.max(1, group.limit) * CARD_SLOT_H + 'px';
+}function visibleCards(col) {
+ var cards = activeCards(col).slice();
+ if (col.sort) {
+ cards.sort(function(a, b) { return (b.pri || 0) - (a.pri || 0); });
+ }
+ return cards;
+}function statsText() {
+ var counts = boardCounts();
+ return counts.groups + ' groups · ' + counts.cols + ' cols · ' + counts.cards + ' cards';
+}Defensive wrappers and checks that reduce accidental data loss and make browser tools more resilient.
function runDestructive(label, confirmText, fn) {
+ if (confirmText && !confirm(confirmText)) return false;
+ createAutoSnapshot(label, 'destructive');
+ pushUndo();
+ fn();
+ saveState();
+ render();
+ return true;
+}function clearUndoRedo() {
+ _undoStack = [];
+ _redoStack = [];
+ persistUndoSession();
+ updateUndoButtons();
+}function estimateStorageBytes() {
+ var total = 0;
+ for (var i = 0; i < localStorage.length; i++) {
+ var key = localStorage.key(i);
+ total += key.length + (localStorage.getItem(key) || '').length;
+ }
+ return total * 2;
+}
+
+function warnIfStorageHigh() {
+ if (estimateStorageBytes() > 4 * 1024 * 1024) notify('Storage is getting full. Export a backup soon.');
+}function safeImportState(next) {
+ if (!validateState(next)) return alert('Invalid state file.');
+ runDestructive('Before full-state import', 'Replace current board?', function() {
+ clearUndoRedo();
+ S = next;
+ });
+}Undo, redo, manual snapshots, and auto-snapshots for preventing data loss in single-page browser apps.
var _undoStack = [];
+var _redoStack = [];
+var _undoInProgress = false;
+
+function pushUndo() {
+ if (_undoInProgress) return;
+ _undoStack.push(JSON.stringify(S));
+ if (_undoStack.length > 50) _undoStack.shift();
+ _redoStack = [];
+ persistUndoSession();
+ updateUndoButtons();
+}
+
+function doUndo() {
+ if (!_undoStack.length) return;
+ _redoStack.push(JSON.stringify(S));
+ _undoInProgress = true;
+ replaceState(JSON.parse(_undoStack.pop()), { touch: false });
+ _undoInProgress = false;
+ persistUndoSession();
+ updateUndoButtons();
+}
+
+function doRedo() {
+ if (!_redoStack.length) return;
+ _undoStack.push(JSON.stringify(S));
+ _undoInProgress = true;
+ replaceState(JSON.parse(_redoStack.pop()), { touch: false });
+ _undoInProgress = false;
+ persistUndoSession();
+ updateUndoButtons();
+}function persistUndoSession() {
+ try { sessionStorage.setItem('fb4_undo', JSON.stringify(_undoStack.slice(-10))); }
+ catch (e) {}
+}
+
+function restoreUndoSession() {
+ try {
+ var arr = JSON.parse(sessionStorage.getItem('fb4_undo') || '[]');
+ if (Array.isArray(arr)) _undoStack = arr;
+ updateUndoButtons();
+ } catch (e) {}
+}function getSnapshots() {
+ try { return JSON.parse(localStorage.getItem('fb4_baks') || '[]'); }
+ catch (e) { return []; }
+}
+
+function setSnapshots(items) {
+ localStorage.setItem('fb4_baks', JSON.stringify(items));
+}
+
+function createSnapshot(name) {
+ var items = getSnapshots();
+ items.unshift({ id: uid(), name: name || 'Snapshot', ts: Date.now(), data: JSON.stringify(S) });
+ if (items.length > 25) items.splice(25);
+ setSnapshots(items);
+}function createAutoSnapshot(label, trigger) {
+ var items = JSON.parse(localStorage.getItem('fb4_auto') || '[]');
+ items.unshift({ id: uid(), name: label, trigger: trigger || 'auto', ts: Date.now(), data: JSON.stringify(S) });
+ if (items.length > 15) items.splice(15);
+ localStorage.setItem('fb4_auto', JSON.stringify(items));
+}
+
+function withDestructiveSnapshot(label, fn) {
+ createAutoSnapshot(label, 'destructive');
+ pushUndo();
+ fn();
+ saveState();
+ render();
+}Small helpers for filtering cards by title, tags, archive state, and checklist status.
var filters = {
+ query: '',
+ showArchived: false,
+ checklistMode: 'all' // all | remaining | done
+};function cardMatchesQuery(card, query) {
+ query = (query || '').trim().toLowerCase();
+ if (!query) return true;
+
+ var title = (card.title || '').toLowerCase();
+ var tags = (card.tags || []).join(' ').toLowerCase();
+ return title.indexOf(query) >= 0 || tags.indexOf(query) >= 0;
+}function filteredCards(col) {
+ return col.cards.filter(function(card) {
+ if (!filters.showArchived && card.archived) return false;
+ if (!cardMatchesQuery(card, filters.query)) return false;
+ return true;
+ });
+}function checklistItemsForMode(card, mode) {
+ return card.items.filter(function(item) {
+ var done = card.done.indexOf(item) >= 0;
+ if (mode === 'done') return done;
+ if (mode === 'remaining') return !done;
+ return true;
+ });
+}Foundation helpers for browser apps that store their full state locally and replace that state during imports, restores, or sync events.
var STORE_KEY = 'fb4';
+
+function uid() {
+ return Math.random().toString(36).slice(2, 10) + Date.now().toString(36);
+}
+
+function defaultState() {
+ return {
+ prefs: { theme: 'midnight', modalW: 510, modalH: null, boardName: 'My Board' },
+ lastModified: 0,
+ groups: [],
+ cols: []
+ };
+}function validateState(data) {
+ if (!data || typeof data !== 'object') return false;
+ if (!Array.isArray(data.groups)) return false;
+ if (!Array.isArray(data.cols)) return false;
+ if (!data.prefs || typeof data.prefs !== 'object') data.prefs = {};
+
+ data.cols.forEach(function(col) {
+ if (!Array.isArray(col.cards)) col.cards = [];
+ });
+
+ return true;
+}var S = defaultState();
+
+function loadState() {
+ try {
+ var raw = localStorage.getItem(STORE_KEY);
+ if (!raw) {
+ S = defaultState();
+ return;
+ }
+
+ var parsed = JSON.parse(raw);
+ S = validateState(parsed) ? parsed : defaultState();
+ } catch (e) {
+ console.warn('State failed to load. Starting from defaults.', e);
+ S = defaultState();
+ }
+}
+
+function saveState() {
+ S.lastModified = Date.now();
+ localStorage.setItem(STORE_KEY, JSON.stringify(S));
+}function replaceState(nextState, options) {
+ options = options || {};
+ if (!validateState(nextState)) throw new Error('Invalid state object.');
+
+ S = nextState;
+ if (options.touch !== false) S.lastModified = Date.now();
+
+ localStorage.setItem(STORE_KEY, JSON.stringify(S));
+ render();
+}Use replaceState() for imports, snapshot restores, undo/redo, and remote sync so all full-state replacement paths behave consistently.
Reusable helpers for priority, tags, due dates, color labels, and checklist progress.
var PRIORITIES = [
+ { label: 'Low', className: 'pri-low' },
+ { label: 'Med', className: 'pri-med' },
+ { label: 'High', className: 'pri-high' }
+];
+
+function priorityInfo(value) {
+ return PRIORITIES[value] || PRIORITIES[0];
+}function addTag(card, tag) {
+ tag = tag.trim();
+ if (!tag || card.tags.indexOf(tag) >= 0) return;
+ pushUndo();
+ card.tags.push(tag);
+ saveState(); render();
+}
+
+function removeTag(card, tag) {
+ pushUndo();
+ card.tags = card.tags.filter(function(x) { return x !== tag; });
+ saveState(); render();
+}function dueInfo(isoDate) {
+ if (!isoDate) return { label: '', className: '' };
+ var today = new Date();
+ today.setHours(0, 0, 0, 0);
+ var due = new Date(isoDate + 'T00:00:00');
+ var diff = Math.round((due - today) / 86400000);
+
+ if (diff < 0) return { label: Math.abs(diff) + 'd overdue', className: 'due-over' };
+ if (diff === 0) return { label: 'Today', className: 'due-soon' };
+ if (diff === 1) return { label: 'Tomorrow', className: 'due-soon' };
+ if (diff <= 2) return { label: diff + 'd', className: 'due-soon' };
+ return { label: due.toLocaleDateString(), className: 'due-ok' };
+}function checklistProgress(card) {
+ var total = card.items.length;
+ var done = card.done.length;
+ return {
+ done: done,
+ total: total,
+ pct: total ? Math.round((done / total) * 100) : 0
+ };
+}CSS token themes, theme switching, color pickers, toasts, and small UI primitives.
:root {
+ --bg: #060d18;
+ --panel: #1a2538;
+ --text: #e2e8f0;
+ --muted: #94a3b8;
+ --border: rgba(255,255,255,.1);
+}
+
+[data-theme="pearl"] {
+ --bg: #f5f5f7;
+ --panel: #ffffff;
+ --text: #1d1d1f;
+ --muted: #6e6e73;
+ --border: rgba(0,0,0,.12);
+}function applyTheme(id, persist) {
+ document.documentElement.dataset.theme = id;
+ if (persist) {
+ S.prefs.theme = id;
+ saveState();
+ }
+}
+
+function loadTheme() {
+ applyTheme((S.prefs && S.prefs.theme) || 'midnight', false);
+}var PAL = ['#2563eb', '#7c3aed', '#e11d48', '#059669', '#d97706'];
+
+function setPaletteIndex(item, pi) {
+ pushUndo();
+ item.pi = pi;
+ saveState();
+ render();
+}function notify(message) {
+ var el = document.getElementById('notif') || document.createElement('div');
+ el.id = 'notif';
+ el.textContent = message;
+ el.className = 'toast show';
+ document.body.appendChild(el);
+ clearTimeout(notify.timer);
+ notify.timer = setTimeout(function() { el.classList.remove('show'); }, 2600);
+}