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.28.4: milestone caps bind by uid, not by a cached index into a reordered list
4 files changed
+66−16
docs/BUILD-NOTES.md+46@@ -3,6 +3,52 @@ Decisions taken while building, and the places where the plan and the code disagreed. Newest first. +## Build notes, 2026-08-14 (0.28.4, the cap you grab is the milestone that moves)++Dragging the "Full dry run done, safety docs complete" cap moved "NSF sign-off on+scope" instead. Tasks dragged correctly; only milestones were wrong.++**A position, cached.** A cap and its hit strip were stamped at BUILD time with+`data-msi`, the row's index into `items.filter(type=='milestone')`, and every+reader resolved it at CLICK time against that same filtered list computed from+whatever order `items` was in by then. Between those two moments sits+`applyOrder`, which is how the plan loads: `buildRows()` runs in `init()` against+the data file's order, then `applyState` reorders all 156 rows from the sidecar.+Nothing recomputed the stamps, so the index pointed into a list that no longer+existed. The scheduling pass, which reordered every row in the plan, is what made+a latent off-by-a-neighbour into a reliable one.++Measured before the fix, on the live data shape: **7 of 10 caps resolved to the+wrong milestone, and 7 of 10 drags moved somebody else's date.** The first+mismatch in the report is the user's exact complaint, cap "Full dry run done"+resolving to "NSF sign-off on scope".++**This is the `#row` bug wearing a different hat**, and it gets the same fix.+0.27.0 gave every row a uid precisely so that nothing has to be found by counting+to it. Caps and hit strips now carry `data-msuid`, every reader resolves through+`rowByUid`, and `msByIndex` is deleted rather than left around to be called again.++**Two more of the same kind, found by looking.** The table keyed a milestone row+as `m:<index into the filtered milestone list>`, computed at render and resolved+on read; a re-render between the two would have handed a keystroke to the wrong+row, and `focusedKey` outlives a render. It is `m:<uid>` now, and `msIndexOf` is+gone with it. A bar carried `data-idx`, a position written once at build and never+refreshed; nothing read it, so it was a landmine rather than a bug, and it is+removed. Bars themselves resolve by `data-id`, a task id that never moves, and+`barEls[].idx` IS refreshed on every reorder, so both were already correct;+asserted rather than assumed.++**Per-cap proof.** All ten caps were dragged in a real headless session,+mousedown on the cap centre, three mousemoves, mouseup, then the served plan+diffed to see which milestone moved, with a Ctrl+Z between each so every+cap was tested from the same baseline. Ten of ten moved their own milestone, zero+moved a neighbour. Beyond the drag: zero caps misresolve, zero tooltips belong to+the wrong row, click-to-edit opens the named milestone in both the line and the+diamond style, and it all still holds after a collapse and expand and after a+fresh Alt+Down reorder, with the cap band still laying out at zero overlaps. The+keyboard, column, dependency, uid, milestone-selection, New-panel-memory and+select-list suites are all green.+ ## Build notes, 2026-08-14 (0.28.3, two data events that read like sentences) A full scheduling pass and a section add both went into the DC plan as data
gantt.html+17−13@@ -2257,7 +2257,7 @@ function buildRowDom(r,idx){ bDiv.className='bar-row ms-row'; const x=toX(r.date); const hit=document.createElement('div');hit.className='ms-hit';hit.style.left=x+'px';- hit.dataset.msi=String(items.filter(q=>q.type==='milestone').indexOf(r));+ hit.dataset.msuid=r.uid||''; hit.dataset.title=r.name;hit.dataset.dates=fmt(r.date);hit.dataset.body=r.description||'';hit.dataset.prov=provLabel(r); const d=document.createElement('div');d.className='ms-diamond';d.style.left=x+'px'; bDiv.appendChild(hit);bDiv.appendChild(d);@@ -2266,7 +2266,7 @@ function buildRowDom(r,idx){ const ln=document.createElement('div');ln.className='ms-line';ln.style.left=x+'px'; ln.style.color=phaseColor(r.phase); const cap=document.createElement('div');cap.className='ms-cap';cap.textContent=r.name.replace(/^M(\d+):\s*/,'M$1 ');- cap.dataset.msi=String(items.filter(q=>q.type==='milestone').indexOf(r));+ cap.dataset.msuid=r.uid||''; cap.dataset.title=r.name;cap.dataset.dates=fmt(r.date);cap.dataset.body=r.description||'';cap.dataset.prov=provLabel(r); ln.appendChild(cap);msLineEls.push(ln); r._msEl=d;r._msHit=hit;r._msLine=ln;@@ -2277,7 +2277,7 @@ function buildRowDom(r,idx){ r._nameSpan=lDiv.querySelector('.row-name'); const sk=statusOf(r);const tg=sk?`<span class="tag ${statusTagFor(sk)}">${esc(statusTextFor(sk))}</span>`:''; r._nameSpan.innerHTML=esc(r.name)+tg;- bDiv.className='bar-row';const x1=toX(r.start),x2=toX(r.end),b=document.createElement('div');b.className='bar'+(r.striped?' bar-striped':'');b.style.left=x1+'px';b.style.width=Math.max(x2-x1,6)+'px';b.style.background=phaseColor(r.phase);b.dataset.title=r.name;b.dataset.dates=fmt(r.start)+' → '+fmt(r.end);b.dataset.body=r.description||'';b.dataset.prov=provLabel(r);b.dataset.id=r.id;b.dataset.idx=idx;+ bDiv.className='bar-row';const x1=toX(r.start),x2=toX(r.end),b=document.createElement('div');b.className='bar'+(r.striped?' bar-striped':'');b.style.left=x1+'px';b.style.width=Math.max(x2-x1,6)+'px';b.style.background=phaseColor(r.phase);b.dataset.title=r.name;b.dataset.dates=fmt(r.start)+' → '+fmt(r.end);b.dataset.body=r.description||'';b.dataset.prov=provLabel(r);b.dataset.id=r.id; const nt=document.createElement('div');nt.className='bar-note';nt.textContent=r.note||'';b.appendChild(nt);r._noteEl=nt; b.appendChild(Object.assign(document.createElement('div'),{className:'drag-handle left'}));b.appendChild(Object.assign(document.createElement('div'),{className:'drag-handle right'}));bDiv.appendChild(b);barEls[r.id]={bar:b,idx,row:bDiv};} r._lDiv=lDiv;r._bDiv=bDiv;r._itemIdx=idx;labelsEl.appendChild(lDiv);bars.appendChild(bDiv);@@ -2748,7 +2748,7 @@ bars.addEventListener('click',e=>{ } const msHit=e.target.closest('.ms-hit, .ms-cap'); if(msHit){- const mr=msByIndex(+msHit.dataset.msi);+ const mr=rowByUid(msHit.dataset.msuid); // A modifier on a milestone builds the set, the same gesture as on a bar. A // plain click still opens its editor, because that is what a plain click on a // milestone has always meant and one is not a selection.@@ -2758,7 +2758,7 @@ bars.addEventListener('click',e=>{ if(selected.size>1)openBatchEditor(); else closeEditor(); return; }- clearSelection(); openMilestoneEditor(+msHit.dataset.msi); e.stopPropagation(); return;+ clearSelection(); openMilestoneEditor(msHit.dataset.msuid); e.stopPropagation(); return; } const bar=e.target.closest('.bar[data-id]'); if(bar&&!e.target.classList.contains('drag-handle')){@@ -2775,8 +2775,8 @@ document.addEventListener('click',e=>{if(!e.target.closest('.editor')&&!e.target } // ── Milestone editor ──-function openMilestoneEditor(i){- const r=msByIndex(i);if(!r)return;+function openMilestoneEditor(uid){+ const r=rowByUid(uid);if(!r||r.type!=='milestone')return; closeEditor(); editorEl.innerHTML=`<div class="drag-bar"></div> <div class="de-row"><div style="flex:1"><label>milestone</label>@@ -2890,7 +2890,6 @@ function openBatchEditor(){ wireBatchCopy(); } -function msByIndex(i){return items.filter(r=>r.type==='milestone')[i];} function updateMilestone(r){ if(!r||!r._msEl)return; const x=toX(r.date);@@ -2958,8 +2957,8 @@ bars.addEventListener('mousedown',e=>{ const hit=e.target.closest('.ms-hit, .ms-cap'); if(!hit)return; e.preventDefault();hideTip();- const r=msByIndex(+hit.dataset.msi);- if(!r)return;+ const r=rowByUid(hit.dataset.msuid);+ if(!r||r.type!=='milestone')return; snapshotMs(r); dragState={kind:'ms',r,startX:e.clientX,origLeft:toX(r.date)}; document.body.style.cursor='ew-resize';@@ -4069,12 +4068,17 @@ function provLabel(r){ const p=provOf(r); return p?p.label:''; } // url, so a hand-written provenance stays hand-written. function provValue(r){ const p=provOf(r); return p ? (p.url?{label:p.label,url:p.url}:p.label) : ''; } function provSig(r){ const p=provOf(r); return p ? p.label+'\u0001'+p.url : ''; }-function msIndexOf(r){return items.filter(q=>isMs(q)).indexOf(r);}-function rowKey(r){return isMs(r)?('m:'+msIndexOf(r)):('t:'+r.id);}+// A table row's key. The milestone half used to be `m:<index into the filtered+// milestone list>`, computed when the table rendered and resolved whenever it was+// read: the same stale-position bug the caps had, one row over. `focusedKey`+// outlives a render, so a reorder between the two could hand a keystroke to the+// wrong milestone. Both halves are durable now, and neither is a position.+function rowKey(r){return isMs(r)?('m:'+(r.uid||'')):('t:'+r.id);} function rowItem(key){ if(!key)return null; if(key.charAt(0)==='t')return items.find(r=>r.id===key.slice(2));- return msByIndex(+key.slice(2));+ const r=rowByUid(key.slice(2));+ return (r&&isMs(r))?r:null; } // Re-render the chart's own label row, so a status changed in the table is // already true on the chart when you toggle back.
package.json+1−1@@ -2,7 +2,7 @@ "slug": "adom-gantt", "title": "Adom Gantt", "type": "app",- "version": "0.28.3",+ "version": "0.28.4", "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.28.3",+ "version": "0.28.4", "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]" }-}+}