app
Adom Gantt
Public Made by Adomby adom
Interactive Gantt chart viewer for project timelines, with a table view and an analytics page over the same data. Data-driven, dark by default with a print-friendly light mode, and fully editable in the browser: drag bars to move or resize, multi-select and batch-shift dates, manage dependency arrows inline, reorder rows, filter by phase, and undo. One click auto-schedules the whole plan so every dependency lines up finish-to-start on working days, skipping weekends and US federal holidays, push
← Commit history
0.13.0: one-click-ready editors, an Escape stack, and POST /compact
One click now leaves a usable editor rather than a rendered one. Selects and the
owner datalist call showPicker() synchronously inside the click that opened them,
feature-detected and wrapped so a browser without it degrades to a focused closed
control with nothing on the console.
Caret choices: short identifying values (name, owner, provenance) select all,
because they are usually replaced wholesale. Notes put the caret at the end,
because notes are prose you add to and select-all would destroy them on the first
keypress. Date cells focus but deliberately do not force the native calendar
open: it covers the rows below, takes the keyboard, and a date edit is usually a
small nudge that is faster typed.
Escape is a stack, innermost first: an armed drag, then an open cell editor, then
the create panel, then selection and menus. A native select popup swallows its
own Escape before any of it runs, which is the first rung for free. Cancelling
blurs before re-rendering so focus is released rather than orphaned.
POST /compact bakes the merged plan into the data file and empties the sidecar.
The sidecar keys rows by their ORIGINAL index in the data file, which makes
editing that file underneath a live sidecar unsafe: removing a row renumbers
every key below it and re-points every override. Compacting resolves that once,
after which a structural edit is just an edit. The changelog is untouched: it is
append-only history and earlier events describe the plan as it was. Proven
lossless on a copy of live data before being used on it.
6 files changed
+122−7
README.md+1@@ -149,6 +149,7 @@ Omit `color` on a phase and it follows the Adom brand ramp, which adapts between | `/import/csv` | POST | Import CSV | | `/import/msproject-xml` | POST | Import MS Project XML | | `/import/jira-csv` | POST | Import Jira CSV |+| `/compact` | POST | Bake saved edits into the data file and empty the sidecar | | `/export/json` | GET | Export merged JSON | | `/export/csv` | GET | Export CSV | | `/export/svg` | GET | Export SVG |
docs/BUILD-NOTES.md+35@@ -3,6 +3,41 @@ Decisions taken while building, and the places where the plan and the code disagreed. Newest first. +## Build notes, 2026-08-07 (0.13.0)++**One click leaves a usable editor, not just a rendered one.** Selects and the+owner datalist now call `showPicker()` synchronously inside the click that opened+them, feature-detected and wrapped, so a browser without it (or a call the browser+declines) degrades to a focused closed control with nothing on the console.++Caret choices, stated: short identifying values (name, owner, provenance) select+all, because they are usually replaced wholesale and typing should overwrite.+Notes put the caret at the end, because notes are prose you add to and select-all+would destroy them on the first keypress. Date cells focus but deliberately do NOT+force the native calendar open: it covers the rows below, takes the keyboard, and a+date edit is usually a small nudge that is faster typed. The picker icon is one+click away for anyone who wants it.++**Escape is a stack now**, innermost first: an armed drag, then an open cell+editor, then the create panel, then selection and menus. A native select or+datalist popup swallows its own Escape before any of this runs, which is the first+rung for free. Cancelling blurs before re-rendering so focus is released rather+than orphaned on a node about to be discarded, and the editor unmounts completely,+leaving no editing flag behind.++**Compact: bake live edits into the base.** `POST /compact` writes the merged plan+(renames, phase moves, created rows, deletions, statuses, order) into the data file+and empties the sidecar, because the sidecar is now saying the same thing the file+says. One loud changelog event marks the line.++This exists because the sidecar keys rows by their ORIGINAL index in the data file,+which makes editing that file underneath a live sidecar genuinely unsafe: removing+a row renumbers every key below it and silently re-points every override.+Compacting resolves that once, after which a structural edit is just an edit. The+changelog is append-only history and is NOT touched: earlier events describe the+plan as it was, which is what history means. Proven lossless before use, on a copy+of the live data: merged-before and merged-after were byte-identical.+ ## Build notes, 2026-08-07 (0.12.0) **Drag to reorder.** Arming is a 350ms click-and-hold, which is what let every
gantt.html+37−3@@ -1072,7 +1072,16 @@ function undo(){ if(prefs.view==='table')renderTable(); clearTimeout(undoTimer);undoToast.classList.add('show');undoTimer=setTimeout(()=>undoToast.classList.remove('show'),1800);scheduleSave('undo'); }-document.addEventListener('keydown',e=>{if((e.ctrlKey||e.metaKey)&&e.key==='z'){e.preventDefault();undo();}if(e.key==='Escape'){clearSelection();closeEditor();closeMenus();}});+document.addEventListener('keydown',e=>{+ if((e.ctrlKey||e.metaKey)&&e.key==='z'){e.preventDefault();undo();}+ if(e.key!=='Escape')return;+ // Innermost first. A native select or datalist popup swallows its own Escape+ // before this ever runs, which is exactly the first rung of the stack.+ if(rowDrag||laneDrag){ endRowDrag(false); endLaneDrag(false); return; }+ if(tblEditing){ tblEditing.cancel(); return; }+ if(editorEl.classList.contains('show')){ closeEditor(); return; }+ clearSelection(); closeMenus();+}); // ── Persistence ── let saveTimer=null;@@ -2904,14 +2913,39 @@ function startCellEdit(td){ // cannot resize around the editor. const wrap=document.createElement('div');wrap.className='cell-edit';wrap.appendChild(el); td.appendChild(wrap);- el.focus(); if(el.tagName==='INPUT'&&el.type==='text')el.select();- tblEditing={td,el,r,field};++ // One click has to leave a usable editor, not just a rendered one. This runs+ // synchronously inside the click, which is the only place showPicker() is+ // allowed to open anything.+ el.focus();+ if(el.tagName==='SELECT'||el.hasAttribute('list')){+ // A select or a datalist is a list of choices: the click that opens it should+ // show the choices. Feature-detected and swallowed, so a browser without+ // showPicker just gets a focused control instead of a console error.+ if(typeof el.showPicker==='function'){ try{ el.showPicker(); }catch(e){} }+ }+ if(el.tagName==='INPUT'&&el.type==='text'){+ // Short identifying values are usually replaced wholesale, so select all and+ // let typing overwrite. Notes are prose you almost always add to, so the+ // caret goes to the end instead of destroying what is there on first keypress.+ if(field==='notes'){ const n=el.value.length; el.setSelectionRange(n,n); }+ else el.select();+ }+ // Date cells get focus so the keyboard works at once, but the native calendar+ // is deliberately NOT forced open: it covers the rows below, steals the+ // keyboard, and the field is usually a small nudge that is faster to type. The+ // picker icon is right there for anyone who wants it.+ let done=false; const finish=save=>{ if(done)return;done=true;tblEditing=null;+ // Blur before the re-render so focus lands on the document rather than being+ // orphaned on a node that is about to be thrown away.+ try{ el.blur(); }catch(e){} if(save)commitCell(r,field,el.value); renderTable(); };+ tblEditing={td,el,r,field,cancel:()=>finish(false)}; // Ctrl+Z inside a field is the browser's own text undo, and Esc means "cancel // this cell", not "clear the chart selection", so the app's keys stop here. el.addEventListener('keydown',e=>{e.stopPropagation();
package.json+1−1@@ -2,7 +2,7 @@ "slug": "adom-gantt", "title": "Adom Gantt", "type": "app",- "version": "0.12.0",+ "version": "0.13.0", "description": "Interactive Gantt chart viewer for project timelines, with a table view over the same data. Data-driven, dark by default with a print-friendly light mode, and fully editable in the browser: drag bars to move or resize, multi-select and batch-shift dates, manage dependency arrows inline, reorder rows, filter by phase, and undo. Toggle to the table for every task and milestone as a row with owner, deadline and status, filtered per owner and edited in place. Every edit is recorded in an append-only changelog so an AI can read what a human changed and build on it. Imports JSON, CSV, MS Project XML and Jira CSV; exports JSON, CSV, SVG and a self-contained HTML snapshot.", "main": "server.js", "bin": {
page.json+2−2@@ -2,7 +2,7 @@ "slug": "adom-gantt", "title": "Adom Gantt", "type": "app",- "version": "0.12.0",+ "version": "0.13.0", "description": "Interactive Gantt chart viewer for project timelines, with a table view over the same data. Data-driven, dark by default with a print-friendly light mode, and fully editable in the browser: drag bars to move or resize, multi-select and batch-shift dates, manage dependency arrows inline, reorder rows, filter by phase, and undo. Toggle to the table for every task and milestone as a row with owner, deadline and status, filtered per owner and edited in place. Every edit is recorded in an append-only changelog so an AI can read what a human changed and build on it. Imports JSON, CSV, MS Project XML and Jira CSV; exports JSON, CSV, SVG and a self-contained HTML snapshot.", "main": "server.js", "bin": {@@ -63,4 +63,4 @@ "name": "Drew Owens", "email": "[email protected]" }-}+}
server.js+46−1@@ -150,6 +150,24 @@ function assertDates(items) { } } +// Bake the merged plan into the data file and empty the sidecar.+//+// The sidecar is a delta over the data file, keyed by each row's ORIGINAL index+// (#row<n>). That makes editing the data file underneath a live sidecar unsafe:+// removing a row renumbers every key below it and silently re-points every+// override. Compacting resolves that once. After it, the file IS the plan, the+// sidecar has nothing left to say, and a structural edit is just an edit.+//+// The changelog is append-only history and is NOT touched: earlier events refer+// to the plan as it was, which is exactly what history means.+function logCompact(info) {+ try {+ appendLog([{ action: 'compact', items: info.items, adds: info.adds, deletes: info.deletes, via: 'compact' }], 'user');+ } catch (e) {+ console.error('adom-gantt: could not record the compact in the changelog:', e.message);+ }+}+ // An import replaces the data file wholesale and clears the sidecar, which is the // single most destructive thing the app can do, and it used to leave no trace at // all: an agent reading the changelog could not tell that every event above it@@ -227,6 +245,14 @@ function describeEvent(e) { if (action === 'add') { return `${who} added a new ${e.kind || 'item'} to the plan: ${name}${via}`; }+ if (action === 'compact') {+ return `${who} FLATTENED live edits into the data file: ${e.items || 0} item(s) are now the base and the sidecar is empty. Events above this line describe the plan before the flatten${via}`;+ }+ if (action === 'lane.remove') {+ const kept = Array.isArray(e.rehomed) && e.rehomed.length ? ` Kept and re-homed to ${e.into || 'another lane'}: ${e.rehomed.join('; ')}.` : '';+ const gone = Array.isArray(e.removed) && e.removed.length ? ` Removed: ${e.removed.join('; ')}.` : ' Nothing else was in it.';+ return `${who} removed the "${e.lane}" lane.${gone}${kept}${via}`;+ } if (action === 'restore') { return `${who} put ${name} back into the plan${via}`; }@@ -1204,6 +1230,25 @@ const server = http.createServer(async (req, res) => { // ── Import endpoints ── + if (req.method === 'POST' && urlPath === '/compact') {+ try {+ const state = loadState();+ const merged = mergedData();+ const info = {+ items: merged.items.length,+ adds: (state && state.added || []).length,+ deletes: (state && state.deleted || []).length,+ };+ fs.writeFileSync(DATA_FILE, JSON.stringify(merged, null, 2) + '\n', 'utf8');+ if (fs.existsSync(STATE_FILE)) fs.unlinkSync(STATE_FILE);+ logCompact(info);+ jsonResp(res, 200, Object.assign({ ok: true, file: path.basename(DATA_FILE) }, info));+ } catch (e) {+ jsonResp(res, 400, { ok: false, error: e.message });+ }+ return;+ }+ if (req.method === 'POST' && urlPath === '/import/json') { const body = await readBody(req); try {@@ -1328,5 +1373,5 @@ server.listen(PORT, '127.0.0.1', () => { console.log(`adom-gantt serving ${path.basename(DATA_FILE)} on http://127.0.0.1:${PORT}`); console.log(`State file: ${STATE_FILE}`); console.log(`Data dir: ${DATA_DIR}`);- console.log(`Endpoints: /data /state /save /import/{json,csv,msproject-xml,jira-csv} /export/{json,csv,svg,html}`);+ console.log(`Endpoints: /data /state /save /compact /import/{json,csv,msproject-xml,jira-csv} /export/{json,csv,svg,html}`); });