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.10.0: edit names, move rows between phases, and delete rows
Names are editable for tasks and milestones. My earlier 'names are not editable'
amendment was right when it was written and is now obsolete: milestones were
keyed by name, so a rename orphaned their state, and stable row keys retired that
reason in 0.8.1. A rename restates every surface repeating the name: the chart
label, the bar tooltip title, the milestone label row, hit strip and dashed-rule
cap, and the cap collision layout, since a longer name can need another row. An
empty name is refused.
Phase is a dropdown of lane names, and the important half is not the field. A
group header is a row like any other, so changing only item.phase would leave the
row sitting visually under the lane it came from. moveRowToPhase re-homes it under
the target group, recolours the bar or the dashed rule, and re-runs the filter so
the legend still governs it in its new lane. One undo step covers both halves, and
the reorder event is suppressed when a phase event already explains it.
Delete is one click with an undo toast naming what went. No confirm dialog: a
speed bump in front of something Ctrl+Z already covers is not a safety net. It
drops the row, its DOM, its bar entry and every dependency pointing at it, so the
arrows, pickers, exports and SVG stop naming something that is gone.
The subtle part is the sidecar. applyOrder is all-or-nothing precisely so a stale
key cannot silently shrink the plan, which means 'deliberately removed' and 'key
no longer resolves' must not look alike. Deletion is an explicit deleted:[key,...]
list applied BEFORE the order, with the order length compared against what is
left. On the server the keys are computed before anything is removed, because
filtering first would renumber every surviving row. An older client ignores the
field and shows the rows, verified against the published 0.9.0 build.
One bug this surfaced: setView re-laid-out the chart from a raw rAF, which is not
reliable and had been getting away with it until a milestone renamed in the table
came back with overlapping caps. It now uses the app's debounced path with the
echo guard invalidated, the same fix the text scale needed in 0.8.6.
7 files changed
+273−28
README.md+2@@ -6,6 +6,8 @@ Interactive Gantt chart viewer for project timelines. Data-driven, dark by defau **Two lenses over one plan.** A **Gantt / Table** toggle in the header. The chart answers "when". The table answers "whose, and by when": every task and every milestone as a row with Owner, Start, Deadline, Status and Notes, sortable on any column, filtered to one person with the owner dropdown, and editable in place. An edit made in the table is already true on the chart when you toggle back, because there is only one set of items underneath. The choice of lens persists, and `project.view.mode` can pin it for a plan that is really an assignment list. +**Edit the plan from the table.** Names, phase, owner, dates, status, notes and provenance are all editable in place, and a row can be deleted with one click and an undo toast. Moving a row to another phase re-homes it under that lane's group header on the chart, not just in the data. Deletions are recorded explicitly in the sidecar, so restoring a saved plan can never confuse "removed on purpose" with "row lost".+ **Provenance.** Every item can say where it came from: `"provenance": {"label": "POR", "url": "https://..."}`, or just `"Drew added this"`. The table shows it as a chip, linked when the url is http or https and as plain text otherwise, so a hostile url can never become a link. **Owner is a first-class field.** Put `"owner": "Adonis"` on a task or a milestone: one name, never a team. It drives the table's owner filter, it round-trips through CSV and JSON export, and changing it is recorded in the changelog like any other edit.
SKILL.md+1−1@@ -236,7 +236,7 @@ status dropdown and a free-text filter over name, owner, phase, notes and status legend's phase and milestone toggles apply to the table as well, so the two lenses never disagree about what is in scope. The owner filter and the sort survive a reload. -**Editing.** Click a cell to edit Owner, Start, Deadline, Status or Notes in place.+**Editing.** Click a cell to edit Name, Phase, Owner, Start, Deadline, Status, Notes or Provenance in place, and the button at the end of a row deletes it (one click, undoable, with a toast). Moving a row to another phase re-homes it under that lane's group header on the chart. Deleted rows are recorded in the sidecar as `deleted: [key, ...]` and applied before the row order, so a deliberate removal is never mistaken for a lost key. Enter commits, Escape cancels, clicking away commits. Every edit mutates the item, lands in the sidecar, appends to the changelog with `via: "table-edit"`, shows on the chart the moment you toggle back, and is one Ctrl+Z. Pulling a deadline in front of a
docs/BUILD-NOTES.md+51@@ -3,6 +3,57 @@ Decisions taken while building, and the places where the plan and the code disagreed. Newest first. +## Build notes, 2026-08-07 (0.10.0, editing the plan itself)++Names, phase and deletion are editable from the table, so the table stops being a+read-mostly lens and becomes a way to reshape the plan.++**Names.** My earlier "names are not editable" amendment was correct when it was+written and is now obsolete: milestones were keyed by name, so a rename orphaned+their state. Stable `#row<n>` keys retired that reason in 0.8.1, so names are+editable for tasks and milestones. A rename restates every surface that repeats+the name: the chart label row, the bar's tooltip title, the milestone's label row,+hit strip and dashed-rule cap, and the cap collision layout, because a longer name+can need another row. An empty name is refused rather than accepted, since a row+with no name cannot be identified in either lens.++**Phase.** A dropdown of lane names. The important half is not the field: a group+header is a row like any other, so changing only `item.phase` would leave the row+sitting visually under the lane it came from. `moveRowToPhase` re-homes it under+the target group's header, recolours the bar or the dashed rule, and re-runs the+filter so the legend still governs it in its new lane. One undo step covers the+field and the move together. A phase move necessarily reorders rows, so the+reorder event is suppressed when a phase event already explains it.++**Delete.** One click, undoable, with a toast naming what went. No confirm dialog:+a speed bump in front of something Ctrl+Z already covers is not a safety net.+Deleting drops the row from `items`, its DOM, its bar entry, and every dependency+pointing at it, so the arrows, the pickers, the exports and the SVG all stop+naming something that is no longer in the plan.++The subtle part is the sidecar. `applyOrder` is all-or-nothing precisely so a+stale key can never silently shrink the plan, which means "deliberately removed"+and "key no longer resolves" must not look alike. So deletion is an explicit+`deleted: [key, ...]` list, applied BEFORE the order, with the order length+compared against what is left. Verified both directions: a sidecar with deletions+loads to exactly 71 of 73 rows, and a sidecar with a genuinely stale key in its+order still has its deletions applied while the order is safely ignored and+nothing is lost. A bogus key in `deleted` is a no-op. On the server the row keys+are computed BEFORE anything is removed, because filtering first would renumber+every surviving row and invalidate every saved key.++An older client reading a sidecar with `deleted` keys ignores the field and shows+the rows, which is the documented acceptable degradation. Verified against the+published 0.9.0 build: no errors, all 67 rows.++**One bug this surfaced.** `setView('gantt')` re-laid-out the chart from a raw+`requestAnimationFrame`, which is not reliable, and it had been getting away with+it. Renaming or deleting a milestone from the table exposed it: the caps came back+overlapping because the relayout never ran. It now goes through the app's own+debounced path with the echo guard invalidated, which is the same fix 0.8.6 needed+for the text scale. That is twice now, so the rule is: never re-measure from a raw+rAF in this app.+ ## Build notes, 2026-08-07 (0.9.0, provenance) A new optional per-item field, `provenance`, and a Provenance column in the table
gantt.html+177−20@@ -387,6 +387,15 @@ table.data-table { border-collapse: separate; border-spacing: 0; width: 100%; mi a.prov-chip { border-color: rgba(0,184,177,0.35); color: var(--accent); } a.prov-chip:hover { border-color: var(--accent); background: rgba(0,184,177,0.08); text-decoration: underline; } .data-table td.c-prov.editable:hover { box-shadow: inset 0 0 0 1px var(--border); border-radius: 4px; }+/* Delete: quiet until you are on the row, destructive-red only on the button+ itself, and never wider than the glyph so it cannot push the other columns. */+.data-table th.th-del { width: 30px; }+.data-table td.c-del { width: 30px; padding: 0 6px 0 0; text-align: right; }+.data-table .row-del { background: none; border: none; color: var(--text3); font-size: 15px; line-height: 1; padding: 2px 4px; border-radius: 4px; cursor: pointer; opacity: 0; transition: opacity 0.12s, color 0.12s, background 0.12s; }+.data-table tbody tr:hover .row-del { opacity: 1; }+.data-table .row-del:hover { color: #f85149; background: rgba(248,81,73,0.10); }+.data-table .row-del:focus-visible { opacity: 1; outline: 1px solid var(--accent); }+.undo-toast kbd { white-space: nowrap; } @media print { .table-scroll { overflow: visible !important; } .table-tools { display: none !important; }@@ -496,6 +505,7 @@ table.data-table { font-size: calc(12px * var(--fs)); min-width: calc(900px * va <th class="sortable" data-sort="status">Status</th> <th class="sortable" data-sort="notes">Notes</th> <th class="sortable" data-sort="prov" title="Where this item came from">Provenance</th>+ <th class="th-del" aria-label="Delete"></th> </tr></thead> <tbody id="tableBody"></tbody> </table>@@ -551,6 +561,11 @@ function rowOrderKey(r){ return r.id || r._key; } // as `r.id || '#row' + dataFileIndex`, so the two sides must agree exactly or an // export resolves a sidecar entry onto the wrong milestone. function msKey(r){ return rowOrderKey(r); }+// Rows the user has deleted, by stable key. Deletion has to be an explicit+// signal rather than an absence: applyOrder is all-or-nothing precisely so a+// stale key can never silently shrink the plan, so "this row is gone on purpose"+// and "this key no longer resolves" must not look the same.+const deletedKeys = new Set(); // A row with no usable date used to take the whole chart down with it: parseISO // split an undefined during the build, the exception escaped, and one bad row@@ -797,7 +812,7 @@ const undoStack = []; let undoTimer = null; // The snapshot carries owner, status and description too, so a table edit is one // Ctrl+Z like every other edit rather than a second class of change.-function snapshotMany(ids){ids.forEach(id=>{const r=items.find(d=>d.id===id);if(r)undoStack.push({type:'edit',id,s:r.start,e:r.end,deps:r.deps?[...r.deps]:[],note:r.note||'',owner:r.owner,status:r.status,desc:r.description,prov:r.provenance});});if(undoStack.length>100)undoStack.splice(0,undoStack.length-100);}+function snapshotMany(ids){ids.forEach(id=>{const r=items.find(d=>d.id===id);if(r)undoStack.push({type:'edit',id,s:r.start,e:r.end,deps:r.deps?[...r.deps]:[],note:r.note||'',owner:r.owner,status:r.status,desc:r.description,prov:r.provenance,name:r.name});});if(undoStack.length>100)undoStack.splice(0,undoStack.length-100);} function snapshot(id){snapshotMany([id]);} // One undo entry covering many rows, for bulk operations like a plan shift. function snapshotGroup(ids){@@ -818,21 +833,87 @@ function orderKeyMap(){ } // All or nothing. This used to push whatever resolved and drop the rest, so one // stale key shrank the plan and the shrunken plan was then saved over the real one.+// Re-append every row in items order. The dashed milestone rules are painted+// above the rows, so they go back on top afterwards or a reorder buries them.+function reflowRows(){+ items.forEach((r,i)=>{r._itemIdx=i;labelsEl.appendChild(r._lDiv);bars.appendChild(r._bDiv);if(r.id&&barEls[r.id])barEls[r.id].idx=i;});+ items.filter(r=>r.type==='milestone'&&r._msLine).forEach(r=>bars.appendChild(r._msLine));+ keepTailLast();+}+function refreshChartGeometry(){+ if(prefs.view==='table')return;+ layoutMsCaps(); sizeTailroom(); drawDeps();+} function applyOrder(order){ if(!Array.isArray(order))return false; const byKey=orderKeyMap(); const next=order.map(k=>byKey.get(k)).filter(Boolean); if(next.length!==items.length||new Set(next).size!==items.length)return false; items.length=0;items.push(...next);- items.forEach((r,i)=>{r._itemIdx=i;labelsEl.appendChild(r._lDiv);bars.appendChild(r._bDiv);if(r.id&&barEls[r.id])barEls[r.id].idx=i;});- keepTailLast();+ reflowRows(); drawDeps(); return true; }++// Deleting is a data change, not a hide: the row leaves items, its DOM goes, its+// bar entry goes, and every dependency pointing at it goes too, or the arrows,+// the pickers and the exports would all keep naming something that is no longer+// in the plan. Everything needed to put it back is returned.+function removeRow(r,silent){+ const idx=items.indexOf(r);+ if(idx<0)return null;+ const droppedDeps=[];+ if(r.id)items.forEach(o=>{+ if(o!==r&&Array.isArray(o.deps)&&o.deps.includes(r.id)){+ droppedDeps.push({id:o.id,dep:r.id});+ o.deps=o.deps.filter(d=>d!==r.id);+ }+ });+ items.splice(idx,1);+ if(r._lDiv&&r._lDiv.parentNode)r._lDiv.remove();+ if(r._bDiv&&r._bDiv.parentNode)r._bDiv.remove();+ if(r._msLine&&r._msLine.parentNode)r._msLine.remove();+ if(r.id)delete barEls[r.id];+ deletedKeys.add(rowOrderKey(r));+ items.forEach((x,i)=>{x._itemIdx=i;if(x.id&&barEls[x.id])barEls[x.id].idx=i;});+ if(!silent){keepTailLast();applyFilter();refreshChartGeometry();}+ return {idx,droppedDeps};+}+function restoreRow(r,idx,droppedDeps){+ deletedKeys.delete(rowOrderKey(r));+ items.splice(Math.max(0,Math.min(idx,items.length)),0,r);+ (droppedDeps||[]).forEach(d=>{const o=items.find(x=>x.id===d.id);+ if(o){o.deps=o.deps||[];if(!o.deps.includes(d.dep))o.deps.push(d.dep);}});+ if(r.id&&r._bDiv){const b=r._bDiv.querySelector('.bar[data-id]');if(b)barEls[r.id]={bar:b,idx,row:r._bDiv};}+ reflowRows();applyFilter();refreshChartGeometry();+}+// A group header is a row like any other, so moving a task to another phase has+// to move it under that phase's header. Changing only item.phase would leave it+// sitting visually in the lane it came from, which is worse than not moving it.+function moveRowToPhase(r,np){+ r.phase=np;+ const gi=items.findIndex(g=>g.type==='group'&&g.phase===np);+ if(gi>=0){+ let end=gi+1;+ while(end<items.length&&items[end].type!=='group')end++;+ const cur=items.indexOf(r);+ if(cur>=0){items.splice(cur,1);items.splice(cur<end?end-1:end,0,r);}+ }+ if(r.id&&barEls[r.id])barEls[r.id].bar.style.background=phaseColor(np);+ if(r._msLine)r._msLine.style.color=phaseColor(np);+ reflowRows();applyFilter();refreshChartGeometry();+} function undo(){ if(!undoStack.length)return; const sn=undoStack.pop(); if(sn.type==='order'){applyOrder(sn.order);}+ else if(sn.type==='delete'){restoreRow(sn.r,sn.idx,sn.droppedDeps);}+ else if(sn.type==='phase'){+ sn.r.phase=sn.phase;+ if(sn.r.id&&barEls[sn.r.id])barEls[sn.r.id].bar.style.background=phaseColor(sn.phase);+ if(sn.r._msLine)sn.r._msLine.style.color=phaseColor(sn.phase);+ applyOrder(sn.order);applyFilter();refreshChartGeometry();+ } else if(sn.type==='ms'){ sn.r.date=sn.date; if(sn.name!==undefined)sn.r.name=sn.name;@@ -852,7 +933,8 @@ function undo(){ if('status' in sn)setOpt(r,'status',sn.status); if('desc' in sn)setOpt(r,'description',sn.desc); if('prov' in sn)setProv(r,sn.prov);- updateBar(sn.id);refreshLabelRow(r);}+ if(sn.name!==undefined)r.name=sn.name;+ updateBar(sn.id);refreshLabelRow(r);refreshChartGeometry();} if(prefs.view==='table')renderTable(); clearTimeout(undoTimer);undoToast.classList.add('show');undoTimer=setTimeout(()=>undoToast.classList.remove('show'),1800);scheduleSave('undo'); }@@ -874,10 +956,11 @@ function setProv(r,v){ if(v===undefined||v===null||v===''||(typeof v==='object'& function getState(){ return { v: 3,- tasks: items.filter(r=>r.id).map(r=>({id:r.id,s:r.start,e:r.end,deps:r.deps||[],note:r.note||'',owner:r.owner||'',status:r.status||'',desc:r.description||'',prov:provValue(r)})),- milestones: items.filter(r=>r.type==='milestone').map((r,i)=>({i,key:msKey(r),name:r.name,date:r.date,owner:r.owner||'',status:r.status||'',desc:r.description||'',prov:provValue(r)})),+ tasks: items.filter(r=>r.id).map(r=>({id:r.id,s:r.start,e:r.end,deps:r.deps||[],note:r.note||'',owner:r.owner||'',status:r.status||'',desc:r.description||'',prov:provValue(r),name:r.name,phase:r.phase})),+ milestones: items.filter(r=>r.type==='milestone').map((r,i)=>({i,key:msKey(r),name:r.name,date:r.date,owner:r.owner||'',status:r.status||'',desc:r.description||'',prov:provValue(r),phase:r.phase})), phases: phases.map((p,i)=>({i,start:p.start,end:p.end})), order: items.map(rowOrderKey),+ deleted: [...deletedKeys], }; } // A milestone entry carries a stable key, an index and a name, and all but the@@ -914,6 +997,8 @@ function applyState(state){ if(sv.status!==undefined)setOpt(r,'status',sv.status); if(sv.desc!==undefined)setOpt(r,'description',sv.desc); if(sv.prov!==undefined)setProv(r,sv.prov);+ if(sv.name)r.name=sv.name;+ if(typeof sv.phase==='number'&&phases[sv.phase])r.phase=sv.phase; }); if(Array.isArray(state))return; const ms=items.filter(r=>r.type==='milestone');@@ -932,8 +1017,15 @@ function applyState(state){ if(sv.status!==undefined)setOpt(r,'status',sv.status); if(sv.desc!==undefined)setOpt(r,'description',sv.desc); if(sv.prov!==undefined)setProv(r,sv.prov);+ if(typeof sv.phase==='number'&&phases[sv.phase])r.phase=sv.phase; }); (state.phases||[]).forEach(sv=>{ const p=phases[sv.i]; if(p){ if(sv.start)p.start=sv.start; if(sv.end)p.end=sv.end; } });+ // Before the order, always: the saved order was written after these rows were+ // removed, so applying it first would compare against the wrong length and be+ // rejected by the anti-shrink guard.+ if(Array.isArray(state.deleted)){+ state.deleted.forEach(k=>{ const r=items.find(x=>rowOrderKey(x)===k); if(r)removeRow(r,true); });+ } if(Array.isArray(state.order)&&state.order.length===items.length)applyOrder(state.order); } // ── Changelog ──@@ -951,15 +1043,16 @@ const GROUP_DATE_RE = /\s*\((?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[ function groupLabel(t){ return (t||'').replace(GROUP_DATE_RE,''); } function nameOf(id){const r=items.find(d=>d.id===id);return r?r.name:id;}+function phaseNameAt(i){const p=phases[i];return p?p.name:String(i);} function planStartOf(snap){let m=null;Object.values(snap.byId).forEach(v=>{if(v.start&&(!m||v.start<m))m=v.start;});return m;} function snapSemantic(){ const byId={};- items.forEach(r=>{if(r.id)byId[r.id]={start:r.start,end:r.end,deps:(r.deps||[]).slice(),note:r.note||'',owner:r.owner||'',status:r.status||'',desc:r.description||'',prov:provSig(r),provL:provLabel(r)};});+ items.forEach(r=>{if(r.id)byId[r.id]={start:r.start,end:r.end,deps:(r.deps||[]).slice(),note:r.note||'',owner:r.owner||'',status:r.status||'',desc:r.description||'',prov:provSig(r),provL:provLabel(r),name:r.name||'',phase:r.phase};}); // Keyed by the stable row key, not by the name. Keying by name made a rename // look like one milestone vanishing and another appearing, so it logged nothing // (both sides early-return on an unseen name) and instead surfaced as a phantom // "reorder". It also made one of any same-named pair invisible to the diff.- const ms={};items.filter(r=>r.type==='milestone').forEach(r=>{ms[msKey(r)]={name:r.name,date:r.date,owner:r.owner||'',status:r.status||'',desc:r.description||'',prov:provSig(r),provL:provLabel(r)};});+ const ms={};items.filter(r=>r.type==='milestone').forEach(r=>{ms[msKey(r)]={name:r.name,date:r.date,owner:r.owner||'',status:r.status||'',desc:r.description||'',prov:provSig(r),provL:provLabel(r),phase:r.phase};}); return {byId,ms,order:items.map(rowOrderKey)}; } let lastSnap=snapSemantic();@@ -977,9 +1070,22 @@ function diffAndLog(via){ if((a.status||'')!==(b.status||'')) evs.push({action:'status',id,name:nameOf(id),from:{status:a.status||''},to:{status:b.status||''}}); if((a.desc||'')!==(b.desc||'')) evs.push({action:'desc',id,name:nameOf(id),from:{desc:a.desc||''},to:{desc:b.desc||''}}); if((a.prov||'')!==(b.prov||'')) evs.push({action:'provenance',id,name:nameOf(id),from:{label:a.provL||''},to:{label:b.provL||''}});+ if((a.name||'')!==(b.name||'')) evs.push({action:'rename',id,name:b.name||'',from:{name:a.name||''},to:{name:b.name||''}});+ if(a.phase!==b.phase) evs.push({action:'phase',id,name:nameOf(id),from:{phase:phaseNameAt(a.phase)},to:{phase:phaseNameAt(b.phase)}}); b.deps.filter(d=>!a.deps.includes(d)).forEach(d=>evs.push({action:'dep.add',id,name:nameOf(id),dep:d,depName:nameOf(d)})); a.deps.filter(d=>!b.deps.includes(d)).forEach(d=>evs.push({action:'dep.remove',id,name:nameOf(id),dep:d,depName:nameOf(d)})); });+ // A row that was in the last snapshot and is not in this one was deleted, and+ // the reverse is an undo putting it back. The field-by-field loop above cannot+ // see either, because it only walks rows that exist in both.+ Object.keys(lastSnap.byId).forEach(id=>{+ if(cur.byId[id])return;+ evs.push({action:'delete',id,name:lastSnap.byId[id].name||id});+ });+ Object.keys(cur.byId).forEach(id=>{+ if(lastSnap.byId[id])return;+ evs.push({action:'restore',id,name:cur.byId[id].name||id});+ }); // A plan shift moves every task by the same delta. Logging 26 identical rows // buries the one fact a reader wants, so collapse it into a single event. const dateEvs=evs.filter(e=>e.action==='dates');@@ -994,6 +1100,8 @@ function diffAndLog(via){ } // milestones: keyed by the stable row key, since they carry no id const curMs=cur.ms,oldMs=lastSnap.ms||{};+ Object.keys(oldMs).forEach(k=>{ if(!curMs[k])evs.push({action:'delete',ms:true,key:k,name:oldMs[k].name||k}); });+ Object.keys(curMs).forEach(k=>{ if(!oldMs[k])evs.push({action:'restore',ms:true,key:k,name:curMs[k].name||k}); }); Object.keys(curMs).forEach(k=>{ const a=oldMs[k],b=curMs[k]; if(!a)return;@@ -1007,9 +1115,13 @@ function diffAndLog(via){ if((a.status||'')!==(b.status||'')) evs.push({action:'status',ms:true,key:k,name:nm,from:{status:a.status||''},to:{status:b.status||''}}); if((a.desc||'')!==(b.desc||'')) evs.push({action:'desc',ms:true,key:k,name:nm,from:{desc:a.desc||''},to:{desc:b.desc||''}}); if((a.prov||'')!==(b.prov||'')) evs.push({action:'provenance',ms:true,key:k,name:nm,from:{label:a.provL||''},to:{label:b.provL||''}});+ if(a.phase!==b.phase) evs.push({action:'phase',ms:true,key:k,name:nm,from:{phase:phaseNameAt(a.phase)},to:{phase:phaseNameAt(b.phase)}}); }); const before=lastSnap.order,after=cur.order;- if(JSON.stringify(before)!==JSON.stringify(after)){+ // Only a real reorder. Adding or removing a row changes this list, and so does+ // moving one to another lane, and both of those already say so themselves.+ const explained=evs.some(e=>e.action==='phase');+ if(!explained&&before.length===after.length&&JSON.stringify(before)!==JSON.stringify(after)){ const moved=after.filter((v,i)=>before[i]!==v); evs.push({action:'reorder',order:after,moved:moved.slice(0,8)}); }@@ -1668,8 +1780,7 @@ document.addEventListener('mouseup',()=>{ const finalInsert=reorderState.insertBefore?actualInsert:actualInsert+1; remainingItems.splice(finalInsert,0,...moveItems); items.length=0;items.push(...remainingItems);- items.forEach((r,i)=>{r._itemIdx=i;labelsEl.appendChild(r._lDiv);bars.appendChild(r._bDiv);if(r.id&&barEls[r.id])barEls[r.id].idx=i;});- keepTailLast();+ reflowRows(); drawDeps();scheduleSave('drag-reorder');didDrag=true; } reorderState=null;@@ -2248,7 +2359,7 @@ function renderTable(){ const rows=tableRows(); const total=items.filter(r=>!isGroup(r)).length; countEl.textContent = rows.length===total ? `${total} items` : `${rows.length} of ${total} items`;- if(!rows.length){tableBody.innerHTML='<tr><td colspan="8" class="table-empty">No items match these filters.</td></tr>';return;}+ if(!rows.length){tableBody.innerHTML='<tr><td colspan="9" class="table-empty">No items match these filters.</td></tr>';return;} tableBody.innerHTML=rows.map(r=>{ const ms=isMs(r), editable=ms||!!r.id; const o=ownerOf(r), st=r.status||'', dl=deadlineOf(r), sd=ms?'':(r.start||'');@@ -2256,9 +2367,9 @@ function renderTable(){ const dates=ms?(dl?fmt(dl):''):((sd&&dl)?fmt(sd)+' → '+fmt(dl):''); const ed=editable?' editable':''; return `<tr data-key="${esc(rowKey(r))}"${ms?' class="is-ms"':''}>`- +`<td class="c-name"><span class="trunc" data-title="${esc(r.name)}" data-dates="${esc(dates)}" data-body="${esc(r.description||'')}">`+ +`<td class="c-name${ed}" data-field="name"><span class="trunc" data-title="${esc(r.name)}" data-dates="${esc(dates)}" data-body="${esc(r.description||'')}">` +`${ms?'<span class="ms-mark">◆</span>':''}${esc(r.name)}</span></td>`- +`<td class="c-phase">${dot}${esc(phaseNameOf(r))}</td>`+ +`<td class="c-phase${ed}" data-field="phase">${dot}${esc(phaseNameOf(r))}</td>` +`<td class="c-owner${ed}${o?'':' empty'}" data-field="owner">${o?esc(o):'unassigned'}</td>` +(ms?'<td class="c-date empty"></td>' :`<td class="c-date${ed}${sd?'':' empty'}" data-field="start">${sd?esc(sd):'not set'}</td>`)@@ -2266,6 +2377,7 @@ function renderTable(){ +`<td class="c-status${ed}" data-field="status">${st?`<span class="tag ${statusTag[st]||'tag-not-started'}">${esc(statusLabel[st]||st)}</span>`:'<span class="tag tag-not-started">none</span>'}</td>` +`<td class="c-notes${ed}${r.description?'':' empty'}" data-field="notes"><span class="trunc">${r.description?esc(r.description):'none'}</span></td>` +`<td class="c-prov${ed}" data-field="prov">${provCell(r)}</td>`+ +`<td class="c-del">${editable?`<button class="row-del" type="button" title="Delete this row from the plan">×</button>`:''}</td>` +'</tr>'; }).join(''); }@@ -2285,6 +2397,12 @@ function startCellEdit(td){ } else if(field==='start'||field==='deadline'){ el=document.createElement('input');el.type='date'; el.value=(field==='start'?r.start:deadlineOf(r))||'';+ } else if(field==='name'){+ el=document.createElement('input');el.type='text';el.value=r.name||'';+ } else if(field==='phase'){+ el=document.createElement('select');+ el.innerHTML=phases.map((p,i)=>`<option value="${i}">${esc(p.name)}</option>`).join('');+ el.value=String(r.phase); } else if(field==='prov'){ // The label only. A url is a thing you paste from somewhere, not something // you retype in a grid cell, so it is preserved as-is underneath.@@ -2325,6 +2443,20 @@ function commitCell(r,field,v){ } else if(field==='status'){ if((r.status||'')===v)return; snap();setOpt(r,'status',STATUS_OPTS.includes(v)?v:'');+ } else if(field==='name'){+ v=v.trim();+ // A row with no name cannot be identified in either lens, so refuse it.+ if(!v||v===r.name)return;+ snap();r.name=v;+ } else if(field==='phase'){+ const np=parseInt(v,10);+ if(!phases[np]||np===r.phase)return;+ // One undo step covers both halves: the field and the re-homing it causes.+ undoStack.push({type:'phase',r,phase:r.phase,order:items.map(rowOrderKey)});+ if(undoStack.length>100)undoStack.splice(0,undoStack.length-100);+ moveRowToPhase(r,np);+ scheduleSave('table-edit');+ return; } else if(field==='prov'){ v=v.trim(); const cur=provOf(r);@@ -2352,6 +2484,7 @@ function commitCell(r,field,v){ // already telling the truth by the time we toggle back to it. if(ms){refreshMsChrome(r);updateMilestone(r);} else{updateBar(r.id);refreshLabelRow(r);}+ if(field==='name')refreshChartGeometry(); // a longer cap can need another row scheduleSave('table-edit'); } @@ -2363,7 +2496,30 @@ tableHead.addEventListener('click',e=>{ prefs.tableSort={key:sortKey,dir:sortDir};savePrefs(); renderTable(); });-tableBody.addEventListener('click',e=>{const td=e.target.closest('td.editable');if(td)startCellEdit(td);});+tableBody.addEventListener('click',e=>{+ const del=e.target.closest('.row-del');+ if(del){+ const tr=del.closest('tr'); const r=rowItem(tr&&tr.dataset.key);+ if(r)deleteRow(r);+ return;+ }+ const td=e.target.closest('td.editable');if(td)startCellEdit(td);+});+// One click, undoable. A confirm dialog for something Ctrl+Z already covers is+// a speed bump, not a safety net.+function deleteRow(r){+ const label=r.name||r.label||'row';+ const info=removeRow(r);+ if(!info)return;+ undoStack.push({type:'delete',r,idx:info.idx,droppedDeps:info.droppedDeps});+ if(undoStack.length>100)undoStack.splice(0,undoStack.length-100);+ renderTable();+ clearTimeout(undoTimer);+ undoToast.innerHTML='Deleted '+esc(label)+' · <kbd>Ctrl+Z</kbd>';+ undoToast.classList.add('show');+ undoTimer=setTimeout(()=>{undoToast.classList.remove('show');undoToast.innerHTML='Undid change · <kbd>Ctrl+Z</kbd>';},3200);+ scheduleSave('table-delete');+} ownerSel.addEventListener('change',()=>{prefs.tableOwner=ownerSel.value;savePrefs();renderTable();}); statusSel.addEventListener('change',renderTable); searchInp.addEventListener('keydown',e=>e.stopPropagation());@@ -2390,11 +2546,12 @@ function setView(v){ prefs.view=v;savePrefs();applyView(); if(v==='table'){renderTable();return;} // The chart measured nothing while it was hidden, so lay it out from scratch- // once it is back on screen.- requestAnimationFrame(()=>{- lastPaneW=timelineEl.clientWidth;lastPaneH=timelineEl.clientHeight;lastRelayoutW=lastPaneW;- relayout();sizeTailroom();drawDeps();- });+ // once it is back on screen. Through the app's own debounced path with the echo+ // guard invalidated, not a raw rAF: the pane reports the same size it did+ // before, and a rAF here proved unreliable, which is how milestone caps+ // renamed or deleted in the table came back overlapping.+ lastPaneW=lastPaneH=lastRelayoutW=-1;+ scheduleRelayout(); } $('viewToggle').addEventListener('click',e=>{const b=e.target.closest('.vt-btn');if(b)setView(b.dataset.view);});
package.json+1−1@@ -2,7 +2,7 @@ "slug": "adom-gantt", "title": "Adom Gantt", "type": "app",- "version": "0.9.0",+ "version": "0.10.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.9.0",+ "version": "0.10.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+39−4@@ -209,6 +209,18 @@ function describeEvent(e) { if (!t) return `${who} cleared the notes on ${name}${via}`; return `${who} rewrote the notes on ${name}: "${t}"${via}`; }+ if (action === 'rename') {+ return `${who} renamed "${fld('from', 'name', '?')}" to "${fld('to', 'name', '?')}"${via}`;+ }+ if (action === 'phase') {+ return `${who} moved ${name} from the ${fld('from', 'phase', '?')} lane to ${fld('to', 'phase', '?')}${via}`;+ }+ if (action === 'restore') {+ return `${who} put ${name} back into the plan${via}`;+ }+ if (action === 'delete') {+ return `${who} DELETED ${name} from the plan${e.deps ? ` (and ${e.deps} dependency link(s) on it)` : ''}${via}`;+ } if (action === 'provenance') { const f = fld('from', 'label', ''), t = fld('to', 'label', ''); if (!f) return `${who} recorded where ${name} came from: "${t}"${via}`;@@ -305,9 +317,13 @@ function resolveMilestone(ms, msKeys, sv, claimed) { // and the row silently vanished from the plan. Index into the data file instead: // that never changes when the text does. Legacy label and name keys are still // accepted so a sidecar written before this change still restores its order.-function orderKeyMap(items) {+// Keys are the row's ORIGINAL index in the data file, so they must be computed+// before anything is removed. Filtering first and then numbering would renumber+// every surviving row and break every saved key.+function rowKeysOf(items) { return items.map((r, i) => r.id || ('#row' + i)); }+function orderKeyMap(items, keys) { const m = new Map();- items.forEach((r, i) => { m.set(r.id || ('#row' + i), r); });+ items.forEach((r, i) => { m.set(keys[i], r); }); items.forEach(r => { const k = r.label || r.name; if (k && !m.has(k)) m.set(k, r); }); return m; }@@ -317,6 +333,22 @@ function mergedData() { const state = loadState(); if (!state) return data; // v1 was a bare array of tasks; v2 also carries milestones, phases and row order.+ // Keys first, deletions second, everything else after: the saved order was+ // written against the post-deletion plan, so the order guard has to compare+ // against that. A key that no longer resolves still trips the guard; a key+ // listed in `deleted` is a deliberate removal and does not.+ let keys = rowKeysOf(data.items);+ if (!Array.isArray(state) && Array.isArray(state.deleted) && state.deleted.length) {+ const gone = new Set(state.deleted);+ const keptItems = [], keptKeys = [];+ data.items.forEach((r, i) => { if (!gone.has(keys[i])) { keptItems.push(r); keptKeys.push(keys[i]); } });+ const removedIds = new Set(data.items.filter((r, i) => gone.has(keys[i])).map(r => r.id).filter(Boolean));+ data.items = keptItems; keys = keptKeys;+ // dependencies on a deleted row go with it, everywhere the data is read+ if (removedIds.size) data.items.forEach(r => {+ if (Array.isArray(r.deps)) r.deps = r.deps.filter(d => !removedIds.has(d));+ });+ } const tasks = Array.isArray(state) ? state : state.tasks; (tasks || []).forEach(sv => { // An entry with no id would match the first id-less row in the file, which is@@ -333,10 +365,12 @@ function mergedData() { setOpt(r, 'status', sv.status); setOpt(r, 'description', sv.desc); setProv(r, sv.prov);+ if (sv.name) r.name = sv.name;+ if (typeof sv.phase === 'number' && data.phases[sv.phase]) r.phase = sv.phase; }); if (Array.isArray(state)) return data; const ms = [], msKeys = [];- data.items.forEach((r, i) => { if (r.type === 'milestone') { ms.push(r); msKeys.push(r.id || ('#row' + i)); } });+ data.items.forEach((r, i) => { if (r.type === 'milestone') { ms.push(r); msKeys.push(keys[i]); } }); const claimed = new Set(); (state.milestones || []).forEach(sv => { if (!sv) return;@@ -351,13 +385,14 @@ function mergedData() { setOpt(r, 'status', sv.status); setOpt(r, 'description', sv.desc); setProv(r, sv.prov);+ if (typeof sv.phase === 'number' && data.phases[sv.phase]) r.phase = sv.phase; }); (state.phases || []).forEach(sv => { const p = data.phases[sv.i]; if (p) { if (sv.start) p.start = sv.start; if (sv.end) p.end = sv.end; } }); if (Array.isArray(state.order) && state.order.length === data.items.length) {- const byKey = orderKeyMap(data.items);+ const byKey = orderKeyMap(data.items, keys); const reordered = state.order.map(k => byKey.get(k)).filter(Boolean); // All or nothing. A single unresolved key would otherwise drop a row. if (reordered.length === data.items.length && new Set(reordered).size === data.items.length) {