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.11.1: rev chip off the canvas, editors stop resizing columns, tooltip stops chasing the cursor
The rev chip was fixed to the chart's bottom-left corner and sat on top of the
milestone labels on a tall plan. It is machinery for the AI handoff loop, not
something a reader needs on screen, and it stays fully available at GET
/changelog. The header meta carries the app version instead, in --text3, read
from package.json and substituted into a placeholder when the server serves
gantt.html, so it cannot drift from the released version. Opened straight off
disk the token is left unsubstituted, so it is pattern-checked and omitted.
Inline editors were replacing the cell's content, so the column re-laid-out
around whatever was being typed, and a select is as wide as its widest option:
the table jumped on every click. The editor is now an absolutely positioned
overlay over the cell, out of flow so the column cannot feel it, with the cell's
own content underneath holding the intrinsic width. Verified across all eight
editable columns, header widths identical before, during and after, including
with 80 characters stuffed into a text field.
The tooltip had a mousemove handler repositioning it on every move, and the
reveal used pointer coordinates captured 500ms earlier. It is anchored to the
trigger now, positioned once, flipped above when it would fall off the bottom and
clamped on both axes, and it leaves on mouseout, mousedown, click, scroll
(capture, so inner scrollers count), resize and blur. Fixed at the shared choke
point, so table cells, bars, milestone caps, the today tag and the legend all
inherit it.
5 files changed
+107−19
docs/BUILD-NOTES.md+37@@ -3,6 +3,43 @@ Decisions taken while building, and the places where the plan and the code disagreed. Newest first. +## Build notes, 2026-08-07 (0.11.1)++**The rev chip left the canvas.** It was a fixed-position chip in the chart's+bottom-left corner, and on a tall plan it sat on top of the milestone labels. The+rev is machinery for the AI handoff loop rather than something a reader needs on+screen, and it stays fully available at `GET /changelog` (and `?format=md`). Its+hover text (the event count plus a pointer to the readable log) went with it;+that information is in the endpoint's own response. The header's meta line now+carries the app version instead, in `--text3`, which is legitimate here because it+is chrome for orientation and not text anyone has to read.++The version comes from `package.json`, substituted into a placeholder when the+server hands out `gantt.html`, so the header cannot drift from the released+version the way a hardcoded string would. Opening the file straight off disk+leaves the token unsubstituted, so it is pattern-checked before being shown and+simply omitted otherwise. The HTML snapshot export goes through the same path.++**Inline editors no longer resize their column.** They were replacing the cell's+content, so the column re-laid-out around whatever was being typed, and a `select`+is as wide as its widest option, which made the whole table jump on every click.+The editor is now an absolutely positioned overlay over the cell: out of flow, so+the column cannot feel it, with the cell's own content still underneath holding+the intrinsic width. `min-width` came off the input. Measured across all eight+editable columns: header widths identical before, during and after, including with+80 characters stuffed into a text field.++**The tooltip stopped following the cursor.** There was a `mousemove` handler+re-positioning it on every move while visible, and the reveal itself used pointer+coordinates captured 500ms earlier. It is now anchored to the trigger's rect,+positioned exactly once at reveal, flipped above when it would fall off the+bottom, and clamped on both axes. It leaves on mouseout, mousedown, click, scroll+(capture, so inner scrollers count), resize and blur, and it keeps+`pointer-events: none` so it can never intercept anything. Fixed at the shared+choke point, so table cells, chart bars, milestone caps, the today tag and the+legend all inherit it; all verified, including clamping for the last row of a+scrolled table.+ ## Build notes, 2026-08-07 (0.11.0) **One filter line.** The legend and the table's filters were two stacked bands
gantt.html+51−15@@ -73,7 +73,7 @@ color-scheme: light; } html, body { overflow: visible !important; height: auto !important; } .labels-col, .timeline-col { overflow: visible !important; }- header .actions, .rev-indicator, .save-indicator { display: none !important; }+ header .actions, .save-indicator { display: none !important; } } *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } html, body { font: 13px/1.4 'Satoshi', -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; background: var(--bg); color: var(--text); overflow: hidden; height: 100%; }@@ -331,8 +331,8 @@ body.col-resizing { cursor: col-resize; user-select: none; } @media print { .data-warn { display: none !important; } } .save-indicator { position: fixed; bottom: 16px; right: 16px; font-family: 'JetBrains Mono', monospace; font-size: 10px; color: var(--text2); z-index: 99998; opacity: 0; transition: opacity 0.3s; } .save-indicator.show { opacity: 1; }-.rev-indicator { position: fixed; bottom: 16px; left: 16px; font-family: 'JetBrains Mono', monospace; font-size: 10px; color: var(--text2); z-index: 99998; cursor: default; }-.rev-indicator:hover { color: var(--text2); }+/* The version is chrome: present for orientation, never competing with the plan. */+header .subject-meta .app-ver { color: var(--text3); } .sel-count { position: fixed; bottom: 16px; left: 16px; font-family: 'JetBrains Mono', monospace; font-size: 10px; color: var(--accent); z-index: 99998; opacity: 0; transition: opacity 0.15s; } .sel-count.show { opacity: 1; } @@ -402,7 +402,14 @@ table.data-table { border-collapse: separate; border-spacing: 0; width: 100%; mi .data-table td.c-notes .trunc { max-width: 300px; color: var(--text-body); } .data-table td.c-notes.empty .trunc { color: var(--text2); } .data-table .phase-dot { display: inline-block; width: 8px; height: 8px; border-radius: 2px; margin-right: 7px; vertical-align: middle; }-.data-table .cell-input { width: 100%; min-width: 90px; background: var(--bg); border: 1px solid var(--accent); border-radius: 4px; color: var(--text); font-family: 'JetBrains Mono', monospace; font-size: 11px; padding: 3px 6px; outline: none; }+/* The editor is laid OVER the cell, not put inside it. Replacing the cell's+ content made the column resize to whatever was being typed, and a select is as+ wide as its widest option, so the table jumped on every click. An absolutely+ positioned overlay is out of flow, so the column cannot feel it; min-width:0+ stops the control having opinions of its own. */+.data-table td { position: relative; }+.data-table .cell-edit { position: absolute; inset: 1px 3px; z-index: 6; display: flex; align-items: stretch; }+.data-table .cell-input { width: 100%; min-width: 0; max-width: 100%; box-sizing: border-box; background: var(--bg); border: 1px solid var(--accent); border-radius: 4px; color: var(--text); font-family: 'JetBrains Mono', monospace; font-size: 11px; padding: 3px 6px; outline: none; text-overflow: ellipsis; } .data-table select.cell-input { font-family: 'Satoshi', sans-serif; } .data-table .table-empty { padding: 22px 12px; color: var(--text2); font-family: 'Satoshi', sans-serif; font-size: 12px; } /* Provenance: a short chip, because the useful part is usually one token ("POR",@@ -547,7 +554,6 @@ table.data-table { font-size: calc(12px * var(--fs)); min-width: calc(900px * va <div class="undo-toast" id="undoToast">Undid change · <kbd>Ctrl+Z</kbd></div> <div class="data-warn" id="dataWarn"></div> <div class="save-indicator" id="saveInd">saved</div>-<div class="rev-indicator" id="revInd">rev 0</div> <div class="datepop" id="datePop"></div> <div class="sel-count" id="selCount"></div> <input type="file" id="importFile" accept=".json,.csv,.xml">@@ -569,7 +575,6 @@ const $ = id => document.getElementById(id); const labelsEl = $('labels'), timelineEl = $('timeline'), tipEl = $('tip'); const editorEl = $('editor'), undoToast = $('undoToast'), saveInd = $('saveInd'); const selCountEl = $('selCount'), exportMenu = $('exportMenu'), importMenu = $('importMenu');-const revEl = $('revInd'); const importFileEl = $('importFile'); // ── Load data ──@@ -652,6 +657,11 @@ const PREF_KEY = 'adom-gantt:prefs'; // A snapshot opens as its author curated it, so it ignores whatever the viewer happens // to have in localStorage from another chart. The export flips this to true. const SNAPSHOT = false;+// Substituted by the server from package.json, so the header cannot drift from+// the released version. Opening this file straight off disk leaves the token in+// place, which is why it is checked before being shown.+const RAW_VERSION = '__APP_VERSION__';+const APP_VERSION = /^[0-9]/.test(RAW_VERSION) ? RAW_VERSION : ''; // project.view is the author's intended default view. It applies everywhere the chart // is served fresh: the HTML export, the server render, and any browser with no saved@@ -825,7 +835,7 @@ $('hdr').innerHTML = ` <div class="vsep"></div> <!-- One title, one subtitle. The brand used to repeat the project as an app-name plus a second subtitle, so a single plan was named three times. -->- <div class="subject" title="${esc(project.title)}${project.subtitle?', '+esc(project.subtitle):''}"><span class="subject-name">${esc(project.title)}</span>${project.subtitle?`<span class="subject-sub">${esc(project.subtitle)}</span>`:''}<span class="subject-meta"><span class="sep">·</span><span id="unitCount">${UNITS} months</span></span></div>+ <div class="subject" title="${esc(project.title)}${project.subtitle?', '+esc(project.subtitle):''}"><span class="subject-name">${esc(project.title)}</span>${project.subtitle?`<span class="subject-sub">${esc(project.subtitle)}</span>`:''}<span class="subject-meta"><span class="sep">·</span><span id="unitCount">${UNITS} months</span>${APP_VERSION?`<span class="sep">·</span><span class="app-ver" title="adom-gantt ${esc(APP_VERSION)}">v${esc(APP_VERSION)}</span>`:''}</span></div> <div class="start-ctl" title="Project start. Changing it moves the whole plan."> <label>starts</label> <button class="date-btn" id="startBtn"></button>@@ -1244,7 +1254,7 @@ function diffAndLog(via){ if(SNAPSHOT)return; // a snapshot has no server to record against fetch('log',{method:'POST',headers:{'Content-Type':'application/json'}, body:JSON.stringify({actor:'user',events:evs.map(e=>({...e,via}))})})- .then(r=>r.json()).then(d=>{if(d&&d.rev)revEl.textContent='rev '+d.rev;}).catch(()=>{});+ .catch(()=>{}); } function scheduleSave(via){diffAndLog(via||'edit');clearTimeout(saveTimer);saveTimer=setTimeout(()=>{saveTimer=null;saveToFile();},800);} // The changelog entry goes out immediately but the sidecar write waits 800ms, so@@ -1944,7 +1954,8 @@ let tipTimer=null; // decodes the escaping that put them there, so an innerHTML here executed whatever // the last person to edit the plan wrote, for everyone who opened it afterwards. function tipLine(cls,text){const d=document.createElement('div');d.className=cls;d.textContent=text;return d;}-function showTip(el,e){if(dragState)return;clearTimeout(tipTimer);tipTimer=setTimeout(()=>{+function showTip(el){if(dragState)return;clearTimeout(tipTimer);tipTimer=setTimeout(()=>{+ if(!el.isConnected){hideTip();return;} // a re-render or a delete beat the delay const t=el.dataset.title,d=el.dataset.dates,b=el.dataset.body; if(!t)return; const pv=el.dataset.prov;@@ -1953,12 +1964,29 @@ function showTip(el,e){if(dragState)return;clearTimeout(tipTimer);tipTimer=setTi if(d)tipEl.appendChild(tipLine('tip-dates',d)); if(b)tipEl.appendChild(tipLine('tip-body',b)); if(pv)tipEl.appendChild(tipLine('tip-prov','source: '+pv));- tipEl.classList.add('show');posTip(e);},500);}-function posTip(e){tipEl.style.left=Math.min(e.clientX+14,window.innerWidth-360)+'px';tipEl.style.top=Math.min(e.clientY+18,window.innerHeight-120)+'px';}+ tipEl.classList.add('show');posTipFor(el);},500);}+// Anchored to the trigger, positioned ONCE, clamped on both axes. It used to be+// placed from the pointer coordinates and re-placed on every mousemove, so it+// trailed the cursor instead of sitting beside the thing it describes.+function posTipFor(el){+ const r=el.getBoundingClientRect();+ const tw=tipEl.offsetWidth, th=tipEl.offsetHeight, M=8;+ let y=r.bottom+M;+ if(y+th>window.innerHeight-M) y=r.top-th-M; // flip above rather than fall off+ y=Math.max(M,Math.min(y,window.innerHeight-th-M));+ const x=Math.max(M,Math.min(r.left,window.innerWidth-tw-M));+ tipEl.style.left=x+'px';tipEl.style.top=y+'px';+} function hideTip(){clearTimeout(tipTimer);tipEl.classList.remove('show');}-document.addEventListener('mouseover',e=>{const el=e.target.closest('[data-title]');if(el)showTip(el,e);});+document.addEventListener('mouseover',e=>{const el=e.target.closest('[data-title]');if(el)showTip(el);}); document.addEventListener('mouseout',e=>{if(e.target.closest('[data-title]'))hideTip();});-document.addEventListener('mousemove',e=>{if(tipEl.classList.contains('show'))posTip(e);});+// No mousemove handler on purpose: a tooltip that follows the pointer is the bug.+// It leaves on anything that could move or invalidate what it points at.+document.addEventListener('mousedown',hideTip,true);+document.addEventListener('click',hideTip,true);+window.addEventListener('scroll',hideTip,true); // capture, so inner scrollers count+window.addEventListener('resize',hideTip);+window.addEventListener('blur',hideTip); // ── Export menu ── $('btnExport').addEventListener('click',e=>{@@ -2611,7 +2639,11 @@ function startCellEdit(td){ el=document.createElement('input');el.type='text';el.value=r.description||''; } el.className='cell-input';- td.innerHTML='';td.appendChild(el);+ // Overlaid, not swapped in: the cell keeps its own content underneath so its+ // intrinsic width never changes, and the overlay is out of flow so the column+ // 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}; let done=false;@@ -2759,7 +2791,11 @@ items.forEach(r=>{ if(r.id)refreshLabelRow(r); else if(r.type==='milestone')refr // Re-baseline AFTER restoring the sidecar, so replaying saved edits on load is // not mistaken for a fresh round of user changes. lastSnap=snapSemantic();-try{const r=await fetch('changelog');if(r.ok){const cl=await r.json();revEl.textContent='rev '+(cl.rev||0);revEl.title=(cl.count||0)+' recorded change(s). GET /changelog?format=md for the readable log.';}}catch(e){}+// The rev used to sit in a floating chip over the chart, where it collided with+// the milestone labels at the bottom of a tall plan. It is machinery for the AI+// handoff loop, not something a reader needs on the canvas, and it stays fully+// available at GET /changelog (and ?format=md). The header carries the app+// version instead, which is the thing a person actually wants to glance at. // The sidecar may carry shifted dates, so reshape the axis before the first paint. rebuildTimeline(); sizeTailroom();
package.json+1−1@@ -2,7 +2,7 @@ "slug": "adom-gantt", "title": "Adom Gantt", "type": "app",- "version": "0.11.0",+ "version": "0.11.1", "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.11.0",+ "version": "0.11.1", "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+16−1@@ -22,6 +22,15 @@ if (!fs.existsSync(DATA_FILE)) { const STATE_FILE = DATA_FILE.replace(/\.json$/, '-state.json'); const LOG_FILE = DATA_FILE.replace(/\.json$/, '-changelog.jsonl'); const APP_DIR = __dirname;+// Read once from our own manifest so the header cannot drift from the released+// version. gantt.html ships a placeholder; it is substituted on the way out.+const APP_VERSION = (() => {+ try { return JSON.parse(fs.readFileSync(path.join(APP_DIR, 'package.json'), 'utf8')).version || ''; }+ catch { return ''; }+})();+function serveApp() {+ return fs.readFileSync(path.join(APP_DIR, 'gantt.html'), 'utf8').split('__APP_VERSION__').join(APP_VERSION);+} const DATA_DIR = path.dirname(DATA_FILE); const MIME = {@@ -1013,7 +1022,7 @@ function generateSVG() { // ── Export: HTML snapshot ── function generateHTMLSnapshot() {- const ganttHtml = fs.readFileSync(path.join(APP_DIR, 'gantt.html'), 'utf8');+ const ganttHtml = serveApp(); const data = mergedData(); // Replace the fetch('data') call with inline data.@@ -1274,6 +1283,12 @@ const server = http.createServer(async (req, res) => { // ── Static file serving ── + if (urlPath === '/' || urlPath === '/gantt.html') {+ res.writeHead(200, { 'Content-Type': 'text/html' });+ res.end(serveApp());+ return;+ }+ let filePath = path.join(APP_DIR, urlPath === '/' ? 'gantt.html' : urlPath); const ext = path.extname(filePath); if (!fs.existsSync(filePath)) { res.writeHead(404); res.end('Not found'); return; }