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.20.0: redo on Ctrl+Shift+Z, and Ctrl+Y
A minor rather than a patch: a redo stack is a new capability and new module
state, not a fix.
Undo and redo are one implementation pointed in opposite directions. The old
seven-branch undo() is now applySnapshot(sn), which does the same restore but
first captures its own inverse from the current state and returns it;
timeTravel(from,to) pops an entry off one stack, applies it, and pushes the
inverse onto the other. undo() and redo() are one line each. Two hand-written
halves would drift the first time a snapshot type gained a field; this cannot.
Create and delete are each other's inverse and take theirs from what removeRow
returns. They act on the same object, so create, undo, redo hands back a row with
the id and stable key it was born with and everything keyed on them still
resolves.
Any new edit clears the redo stack, because redo replays what undo took back and
must never invent history. That rule lives in pushUndo(), which all nine
edit-recording sites now call, replacing nine copies of push-and-trim. undo() and
redo() deliberately do not go through it.
Units stay symmetric: one Alt+Arrow press is one undo entry and one redo entry;
the 600ms coalescing is a changelog concern and never touched the stack.
No server change for the log: the markdown renderer already appends the via, so a
redo reads 'user reordered rows: ... (redo)' exactly as undo reads '(undo)'.
Guarded by construction: redo sits in the same keydown handler as undo, and every
text surface already stops its own keydown, so inside a field the field's native
undo and redo win. Ctrl+Y is taken as the Windows alias; nothing in the app or the
browser claims it.
Verified: fifteen action types driven do, undo, redo, undo, redo, each compared by
deep equality of the merged plan read after the save debounce. Owner, status,
notes, deadline, rename, provenance, phase move, Alt+Arrow move, delete, create,
milestone rename, snapped bar drag, chart row reorder, lane drag and whole-plan
shift all round trip both ways, twice. A new edit after an undo leaves redo a
no-op. Four Alt+Arrow presses take four undos and four redos. The changelog
carries 52 undo and 31 redo events interleaved and renders (redo) in the markdown.
The 52-key fingerprint is unchanged: the three keys that appeared to move were the
date rolling past midnight, proven by pulling the shipped 0.19.0 gantt.html back
out of the page repo and fingerprinting it against 0.20.0 under the same clock,
which is a zero diff.
6 files changed
+201−44
README.md+1@@ -126,6 +126,7 @@ Omit `color` on a phase and it follows the Adom brand ramp, which adapts between | Key | Action | |-----|--------| | Ctrl+Z | Undo |+| Ctrl+Shift+Z | Redo, also Ctrl+Y. Replays only what undo took back, so any new edit clears it | | Up / Down | Move the selected row in the table, following the visible order under sorts and filters. They never scroll the pane by themselves; it moves only enough to keep the selected row in view | | Enter | Edit the selected row's name. Inside a cell, commit and step down one row | | Shift+N | Open the New panel. Fires only when nothing else is going on, so a capital N typed into a field stays a capital N |
SKILL.md+1@@ -195,6 +195,7 @@ rather than a category. - **Click a bar** to open the date editor with start/end pickers and dependency management - **Drag bar edges** to resize (changes start or end date). Every timeline drag snaps to the grid the axis is currently drawing: day boundaries at the day cell size, week starts at weeks, month, quarter and year firsts at the coarser ones. Resizing the start leaves the end where it is and the mirror for the end; dragging the bar body snaps the START and preserves the DURATION, so the end lands at start plus the same number of days. The preview snaps live, line to line, and is exactly what gets committed. The shortest bar a resize can produce is the first grid line past the fixed edge, so it is never zero or negative. Milestones snap the same way. Dates set explicitly (table cells, the item modal, the batch editor, the project-start shift) are taken exactly as given and are never rounded - **Ctrl+Z** to undo (100-level stack)+- **Ctrl+Shift+Z** (or **Ctrl+Y**) to redo. Redo only ever replays what undo just took back, so any new edit clears it. It covers every undoable action symmetrically: field edits, snapped drags, row and lane moves, deletes, creates (the row comes back with the same id and stable key), milestone edits and the whole-plan shift. Inside a text field the field's own undo and redo win, exactly as Ctrl+Z already does. Redo is logged to the changelog as its own event, tagged `redo` - **Up / Down** in the table move the selected row, following the visible order under any sort or filter. They never scroll the pane on their own: the browser's default is suppressed and the pane moves only enough to keep the selected row in view. The first press selects the first visible row. In the chart the arrows are left alone - **Enter** on the selected row opens its NAME editor, ready to type with the text selected. Inside a cell editor Enter commits and steps down one row, the spreadsheet convention, so a column of values is Enter, type, Enter, type; it does not open the next editor, and a second Enter does - **Shift+N** opens the New panel, gated on the app being idle: no editor, panel, menu or drag in progress, and nothing focused that a person could be typing into
docs/BUILD-NOTES.md+78@@ -3,6 +3,84 @@ Decisions taken while building, and the places where the plan and the code disagreed. Newest first. +## Build notes, 2026-08-11 (0.20.0, redo)++Ctrl+Shift+Z, and Ctrl+Y as its Windows alias. A minor rather than a patch: a redo+stack is a new capability and a new piece of module state, not a fix.++**Undo and redo are one implementation pointed in opposite directions.** The old+`undo()` was a seven-branch restore that wrote state and returned. It is now+`applySnapshot(sn)`, which does the same restore but first captures its own inverse+from the CURRENT state and returns it. `timeTravel(from,to,...)` pops an entry off+one stack, applies it, and pushes the inverse onto the other. `undo()` and `redo()`+are one line each and differ only in which stack is which. Two hand-written halves+would have drifted the first time a snapshot type gained a field; this cannot,+because there is only one half.++Create and delete are the one pair that cannot capture their inverse beforehand:+applying a `delete` entry restores the row, and the thing that undoes THAT is a+`create` entry, so each returns the other's shape. They act on the same object, so+a create, an undo and a redo hand back a row with the id and the stable key it was+born with, and everything keyed on them still resolves. Measured: the created row+comes back as `identity-probe`, a later owner edit lands on it, and it is in the+sidecar's `added` list.++**Any new edit clears the redo stack**, because redo replays what undo took back+and must never invent history. That rule lives in one place: `pushUndo()`, which+every one of the nine edit-recording sites now calls, replacing nine copies of the+same push-and-trim. The tenth site, added later by someone else, gets the rule for+free. `undo()` and `redo()` deliberately do NOT go through it: time travel is the+one thing that must not clear the other stack.++**The units stay symmetric.** One press of Alt+Arrow is one undo entry and one redo+entry; the 600ms coalescing from 0.17.0 is a changelog concern and never touched+the stack. Measured: four presses moved a row four slots, took four undos to put+back and four redos to reapply.++**The changelog needed no server change.** The markdown renderer already appends+the `via` in brackets to every line, so a redo reads as+`user reordered rows: hw-pcb-reflow, hw-wc-power (redo)` exactly as an undo reads+`(undo)` today. An agent reading the log sees both directions in the order they+happened.++**Guarded by construction rather than by a second rule.** Redo sits in the same+document keydown handler as undo, so whatever stops Ctrl+Z from firing stops+Ctrl+Shift+Z too. Every text surface in this app already stops its own keydown, so+inside a cell editor, the create panel or the filter box the FIELD's native undo and+redo win and the app never sees the chord. Verified: with a notes editor open and+text typed into it, Ctrl+Shift+Z and Ctrl+Z leave the editor open and the plan+untouched, and the app's own undo works again the moment the field is closed.++**Ctrl+Y is taken as an alias.** Nothing in this app claims it and no browser binds+it, and inside a field the field's handler stops it before it arrives, so it costs+one clause for a keystroke half the users will reach for. Verified end to end.++**Evidence.** Fifteen action types driven do, undo, redo, undo, redo, each compared+by deep equality of the merged plan (`/export/json`, read after the save debounce so+it is committed truth and not a screen reading): owner, status, notes, deadline,+rename, provenance, phase move, Alt+Arrow move, delete, create, milestone rename,+snapped bar drag, chart row reorder, lane drag, whole-plan shift. Every one restores+to the pre-edit state on undo, returns to the post-edit state on redo, and does it+again on the second round trip. Plus: a new edit made after an undo leaves Ctrl+Z's+redo a no-op; the lane drag moves the whole Hardware block to the end of the plan+and comes back exactly; the changelog carries 52 undo and 31 redo events+interleaved in the order they happened and renders `(redo)` in the markdown.++**One measurement note.** The 52-key fingerprint appeared to move in three keys+beyond the version, `svgLen` and the today tag's position and date. It was the clock:+this session ran past midnight and the date rolled from 10-Aug to 11-Aug, which moves+the today marker. Proven rather than assumed, by pulling the shipped 0.19.0+`gantt.html` back out of the page repo with `adom-wiki repo show --ref` and+fingerprinting it against 0.20.0 under the same clock and the same version string:+zero diff on all 52 keys. Worth keeping: a fingerprint over a plan with a today+marker is only stable within a day, and the control run is how you tell the clock+from the code.++**Also worth keeping.** A scratch instance was briefly pointed at a port already+held by an unrelated app in this container, and the harness happily scraped that+app's page and reported nothing. The runner now checks the log for `EADDRINUSE` and+confirms the served page is ours before it trusts a single number.+ ## Build notes, 2026-08-10 (0.19.0, arrow keys walk the table) "Up down arrow keys should move which row is selected. Hitting Enter on a row
gantt.html+118−41@@ -1044,20 +1044,30 @@ let depSvg; // ── Undo ── const undoStack = [];+// Redo only ever replays what undo just took back. It never invents history, so+// any new edit empties it: that is the whole contract, and it lives in pushUndo+// rather than at each of the nine places that record an edit, because the tenth+// one added later would have forgotten.+const redoStack = [];+function pushUndo(sn){+ undoStack.push(sn);+ if(undoStack.length>100)undoStack.splice(0,undoStack.length-100);+ redoStack.length=0;+}+const UNDO_TOAST_DEFAULT='Undid change · <kbd>Ctrl+Z</kbd>'; 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,name:r.name});});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)pushUndo({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});});} function snapshot(id){snapshotMany([id]);} // One undo entry covering many rows, for bulk operations like a plan shift. function snapshotGroup(ids){ const entries=[]; ids.forEach(id=>{const r=items.find(d=>d.id===id);if(r)entries.push({id,s:r.start,e:r.end,deps:r.deps?[...r.deps]:[],note:r.note||''});}); const ms=items.filter(r=>r.type==='milestone');- undoStack.push({type:'group',entries,ms:ms.map(r=>({r,date:r.date})),phases:phases.map(p=>({p,start:p.start,end:p.end}))});- if(undoStack.length>100)undoStack.splice(0,undoStack.length-100);+ pushUndo({type:'group',entries,ms:ms.map(r=>({r,date:r.date})),phases:phases.map(p=>({p,start:p.start,end:p.end}))}); }-function snapshotOrder(){undoStack.push({type:'order',order:items.map(rowOrderKey)});if(undoStack.length>100)undoStack.splice(0,undoStack.length-100);}+function snapshotOrder(){pushUndo({type:'order',order:items.map(rowOrderKey)});} // Legacy label and name keys are still accepted, so an order saved before row // keys became stable still restores. function orderKeyMap(){@@ -1257,8 +1267,7 @@ function moveFocusedRow(dir){ else if(res==='edge')flash(saveInd,sorted?'the end of the tied group':'the end of the plan'); return; }- undoStack.push({type:'phase',r,phase:fromPhase,order});- if(undoStack.length>100)undoStack.splice(0,undoStack.length-100);+ pushUndo({type:'phase',r,phase:fromPhase,order}); const np=phaseAtPosition(r); if(np!==undefined&&np!==r.phase){ r.phase=np;@@ -1272,10 +1281,18 @@ function moveFocusedRow(dir){ moveHint='"'+(r.name||'a row')+'"'; scheduleKeyMoveSave(); }-function undo(){- if(!undoStack.length)return;- const sn=undoStack.pop();+// Apply a snapshot, and return the snapshot that puts things back. Undo and redo+// are the same operation pointed in opposite directions, so there is exactly one+// implementation of "restore this state" and the two directions cannot drift+// apart the way two hand-written halves would. Every branch captures its own+// inverse from the CURRENT state before it overwrites it, except create and+// delete, which are each other's inverse and take it from what removeRow returns.+// Returns null when the snapshot no longer resolves, which is the old early+// return: a stale entry does nothing rather than half-applying.+function applySnapshot(sn){ if(sn.type==='order'){+ const inv={type:'order',order:items.map(rowOrderKey),+ phases:(sn.phases||[]).map(pc=>({r:pc.r,from:pc.r.phase}))}; (sn.phases||[]).forEach(pc=>{ pc.r.phase=pc.from; if(pc.r.id&&barEls[pc.r.id])barEls[pc.r.id].bar.style.background=phaseColor(pc.from);@@ -1283,39 +1300,89 @@ function undo(){ }); applyOrder(sn.order); if(sn.phases&&sn.phases.length){applyCollapse();applyFilter();refreshChartGeometry();}+ return inv;+ }+ if(sn.type==='delete'){+ restoreRow(sn.r,sn.idx,sn.droppedDeps);+ // Putting the row back is undone by taking it out again, and it is the SAME+ // object, so its id and its stable key survive the round trip and everything+ // keyed on them still resolves.+ return {type:'create',r:sn.r}; }- else if(sn.type==='delete'){restoreRow(sn.r,sn.idx,sn.droppedDeps);}- else if(sn.type==='create'){removeRow(sn.r);renderTable();}- else if(sn.type==='phase'){+ if(sn.type==='create'){+ const info=removeRow(sn.r); renderTable();+ return {type:'delete',r:sn.r,idx:info?info.idx:items.length,droppedDeps:info?info.droppedDeps:[]};+ }+ if(sn.type==='phase'){+ const inv={type:'phase',r:sn.r,phase:sn.r.phase,order:items.map(rowOrderKey)}; 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();+ return inv; }- else if(sn.type==='ms'){- sn.r.date=sn.date;- if(sn.name!==undefined)sn.r.name=sn.name;- setOpt(sn.r,'owner',sn.owner);setOpt(sn.r,'status',sn.status);setOpt(sn.r,'description',sn.desc);- if('prov' in sn)setProv(sn.r,sn.prov);- refreshMsChrome(sn.r);updateMilestone(sn.r);+ if(sn.type==='ms'){+ const r=sn.r;+ const inv={type:'ms',r,date:r.date,name:r.name,owner:r.owner,status:r.status,desc:r.description,prov:r.provenance};+ r.date=sn.date;+ if(sn.name!==undefined)r.name=sn.name;+ setOpt(r,'owner',sn.owner);setOpt(r,'status',sn.status);setOpt(r,'description',sn.desc);+ if('prov' in sn)setProv(r,sn.prov);+ refreshMsChrome(r);updateMilestone(r);+ return inv; }- else if(sn.type==='group'){- // one Ctrl+Z undoes the whole shift, not 26 tasks one at a time+ if(sn.type==='group'){+ // one Ctrl+Z undoes the whole shift, not 26 tasks one at a time, and one+ // Ctrl+Shift+Z puts the whole shift back+ const inv={type:'group',+ entries:sn.entries.map(en=>{const r=items.find(d=>d.id===en.id);+ return r?{id:en.id,s:r.start,e:r.end,deps:r.deps?[...r.deps]:[],note:r.note||''}:Object.assign({},en);}),+ ms:(sn.ms||[]).map(m=>({r:m.r,date:m.r.date})),+ phases:(sn.phases||[]).map(x=>({p:x.p,start:x.p.start,end:x.p.end}))}; sn.entries.forEach(en=>{const r=items.find(d=>d.id===en.id);if(!r)return;r.start=en.s;r.end=en.e;r.deps=en.deps;r.note=en.note;}); (sn.ms||[]).forEach(m=>{m.r.date=m.date;}); (sn.phases||[]).forEach(x=>{x.p.start=x.start;x.p.end=x.end;}); rebuildTimeline();Object.keys(barEls).forEach(id=>updateBar(id));+ return inv; }- else{const r=items.find(d=>d.id===sn.id);if(!r)return;r.start=sn.s;r.end=sn.e;r.deps=sn.deps;if(sn.note!==undefined)r.note=sn.note;- if('owner' in sn)setOpt(r,'owner',sn.owner);- 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);- if(sn.name!==undefined)r.name=sn.name;- updateBar(sn.id);refreshLabelRow(r);refreshChartGeometry();}+ const r=items.find(d=>d.id===sn.id);+ if(!r)return null;+ const inv={type:'edit',id:sn.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};+ r.start=sn.s;r.end=sn.e;r.deps=Array.isArray(sn.deps)?[...sn.deps]:sn.deps;+ if(sn.note!==undefined)r.note=sn.note;+ if('owner' in sn)setOpt(r,'owner',sn.owner);+ 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);+ if(sn.name!==undefined)r.name=sn.name;+ updateBar(sn.id);refreshLabelRow(r);refreshChartGeometry();+ return inv;+}+// One step in either direction. The entry comes off one stack and its inverse+// goes onto the other, which is what makes undo, redo, undo land exactly where+// undo did. Note that neither of these goes through pushUndo: time travel is the+// one thing that must NOT clear the other stack.+function timeTravel(from,to,via,label,chord){+ if(!from.length)return false;+ const sn=from.pop();+ const inv=applySnapshot(sn);+ if(inv===null)return false;+ to.push(inv);+ if(to.length>100)to.splice(0,to.length-100); if(prefs.view==='table')renderTable();- clearTimeout(undoTimer);undoToast.classList.add('show');undoTimer=setTimeout(()=>undoToast.classList.remove('show'),1800);scheduleSave('undo');+ clearTimeout(undoTimer);+ undoToast.innerHTML=label+' · <kbd>'+chord+'</kbd>';+ undoToast.classList.add('show');+ undoTimer=setTimeout(()=>{undoToast.classList.remove('show');undoToast.innerHTML=UNDO_TOAST_DEFAULT;},1800);+ // Tagged so the changelog says which direction it was. The markdown renderer+ // already puts the via in brackets on every line, so this reads as+ // "user reassigned X from A to B (redo)" with no server change.+ scheduleSave(via);+ return true; }+function undo(){ return timeTravel(undoStack,redoStack,'undo','Undid change','Ctrl+Z'); }+function redo(){ return timeTravel(redoStack,undoStack,'redo','Redid change','Ctrl+Shift+Z'); } // Is anything at all in progress? A chord that opens a panel must never fire in // the middle of something else, and "something else" includes any field anywhere, // not just this app's own editors.@@ -1334,7 +1401,21 @@ function appIsIdle(){ } function wireGlobalKeys(){ document.addEventListener('keydown',e=>{- if((e.ctrlKey||e.metaKey)&&e.key==='z'){e.preventDefault();undo();}+ // Undo and redo live in the same handler and are therefore guarded identically,+ // which is the point: whatever stops Ctrl+Z from firing stops Ctrl+Shift+Z too.+ // Every text surface in this app stops its own keydown, so inside a cell editor,+ // the create panel or the filter box the FIELD's native undo and redo win and+ // this never runs.+ if((e.ctrlKey||e.metaKey)&&(e.key==='z'||e.key==='Z')){+ e.preventDefault();+ if(e.shiftKey)redo(); else undo();+ return;+ }+ // Ctrl+Y is the Windows redo chord. Nothing in this app claims it and no browser+ // binds it, so it is one clause for a keystroke half our users will reach for.+ if((e.ctrlKey||e.metaKey)&&!e.shiftKey&&(e.key==='y'||e.key==='Y')){+ e.preventDefault(); redo(); return;+ } // Alt+Up and Alt+Down MOVE a row. Checked first, so the two arrow meanings never // race: with Alt the plan changes, without it only the selection does. if(e.altKey&&!e.ctrlKey&&!e.metaKey&&(e.key==='ArrowUp'||e.key==='ArrowDown')){@@ -2295,8 +2376,7 @@ function updateMilestone(r){ drawDeps(); } function snapshotMs(r){- undoStack.push({type:'ms',r,date:r.date,name:r.name,owner:r.owner,status:r.status,desc:r.description,prov:r.provenance});- if(undoStack.length>100)undoStack.splice(0,undoStack.length-100);+ pushUndo({type:'ms',r,date:r.date,name:r.name,owner:r.owner,status:r.status,desc:r.description,prov:r.provenance}); } // Milestone chrome that is not the date: the label row, the tooltip body and the // dashed rule's tag all restate the milestone, so they move together.@@ -2665,6 +2745,7 @@ $('btnHelp').addEventListener('click',e=>{ <div class="dd-sep"></div> <div class="dd-label">keyboard</div> <div class="dd-item" style="cursor:default"><span style="color:var(--accent);min-width:90px;font-family:'JetBrains Mono',monospace;font-size:10px">Ctrl+Z</span>Undo</div>+ <div class="dd-item" style="cursor:default"><span style="color:var(--accent);min-width:90px;font-family:'JetBrains Mono',monospace;font-size:10px">Ctrl+Shift+Z</span>Redo, also Ctrl+Y. Any new edit clears it</div> <div class="dd-item" style="cursor:default"><span style="color:var(--accent);min-width:90px;font-family:'JetBrains Mono',monospace;font-size:10px">Up/Down</span>Move the selected row in the table. The pane only scrolls to keep it in view</div> <div class="dd-item" style="cursor:default"><span style="color:var(--accent);min-width:90px;font-family:'JetBrains Mono',monospace;font-size:10px">Enter</span>Edit the selected row's name. In a cell, commit and step down a row</div> <div class="dd-item" style="cursor:default"><span style="color:var(--accent);min-width:90px;font-family:'JetBrains Mono',monospace;font-size:10px">Shift+N</span>New task or milestone</div>@@ -2677,7 +2758,7 @@ $('btnHelp').addEventListener('click',e=>{ <div class="dd-item" style="cursor:default"><span style="color:var(--accent);min-width:90px;font-family:'JetBrains Mono',monospace;font-size:10px">click deps</span>Toggle dependency arrows</div> <div class="dd-sep"></div> <div class="dd-label">data</div>- <div class="dd-item" style="cursor:default"><span style="color:var(--text-body);font-size:10px">Edits auto-save to sidecar JSON. Ctrl+Z undoes up to 100 steps.</span></div>`;+ <div class="dd-item" style="cursor:default"><span style="color:var(--text-body);font-size:10px">Edits auto-save to sidecar JSON. Ctrl+Z undoes up to 100 steps, Ctrl+Shift+Z puts them back.</span></div>`; exportMenu.style.left=Math.min(rect.right-220,window.innerWidth-240)+'px';exportMenu.style.top=(rect.bottom+4)+'px';exportMenu.classList.add('show'); e.stopPropagation(); });@@ -2914,8 +2995,7 @@ function openCreateEditor(){ lastCreated={owner:owner||'', start, end}; addedItems.push(r); insertRow(r);- undoStack.push({type:'create',r});- if(undoStack.length>100)undoStack.splice(0,undoStack.length-100);+ pushUndo({type:'create',r}); closeEditor(); renderTable(); rebuildTimeline(); // a new date can widen the axis@@ -3601,8 +3681,7 @@ function endRowDrag(commit){ const fromPhase=r.phase; const toPhase=target.phase; // One undo for the whole gesture, position and lane together.- undoStack.push({type:'phase',r,phase:fromPhase,order:items.map(rowOrderKey)});- if(undoStack.length>100)undoStack.splice(0,undoStack.length-100);+ pushUndo({type:'phase',r,phase:fromPhase,order:items.map(rowOrderKey)}); const cur=items.indexOf(r); if(cur<0)return; items.splice(cur,1);@@ -3884,8 +3963,7 @@ function commitCell(r,field,v){ 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);+ pushUndo({type:'phase',r,phase:r.phase,order:items.map(rowOrderKey)}); moveRowToPhase(r,np); scheduleSave('table-edit'); return;@@ -3951,13 +4029,12 @@ function deleteRow(r){ if(focusedKey===rowKey(r))focusedKey=null; 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);+ pushUndo({type:'delete',r,idx:info.idx,droppedDeps:info.droppedDeps}); 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);+ undoTimer=setTimeout(()=>{undoToast.classList.remove('show');undoToast.innerHTML=UNDO_TOAST_DEFAULT;},3200); scheduleSave('table-delete'); } function wireTableFilters(){
package.json+1−1@@ -2,7 +2,7 @@ "slug": "adom-gantt", "title": "Adom Gantt", "type": "app",- "version": "0.19.0",+ "version": "0.20.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.19.0",+ "version": "0.20.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]" }-}+}