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.22.0: dependency mode
Turn it on, click the task that comes FIRST, click the one that waits on it, and
keep going. Chaining is the point, so the mode survives every pair and only Esc or
the button turns it off. Shift+D toggles it on the same idle gate as Shift+N.
The undo work was already done. A dependency lives in the dependent row's deps
array and the edit snapshot has carried deps since 0.8.0, so snapshot(depId) before
the push makes an add exactly one undo step and 0.20.0's inverse machinery gives
redo for nothing. diffAndLog has emitted dep.add and dep.remove for as long as it
has existed and the server already renders them. No new snapshot type, no new
event, no server change.
A mode that changes what a click means must be impossible to be in by accident:
crosshair cursor over bars, the button lit, and a caption under the legend for as
long as the mode is on saying what the NEXT click will do, which changes once a
first pick exists. The pending bar carries an accent ring. Bar mousedown returns
immediately while the mode is on, so no edge resize, no move, no reorder arming,
and no note editor on double-click.
Cycle prevention is mandatory and names the loop. Deps are needs-edges, so a loop
is exactly the case where the master already needs the dependent through some
chain. Breadth-first, so the path in the message is the shortest one, which is the
one a person can check. Verified for two-row and three-row loops.
Milestones are excluded, and the model made that call: a dep is stored as an id,
barEls is keyed by id, and a milestone in the data file has none, so a dep pointing
at one could not be drawn, listed or exported. Clicking one says so.
Esc keeps the innermost-first rule: the half-made pick goes before the mode making
it. Switching to the table leaves the mode, because a crosshair over a table is a
lie.
The table gains a narrow Deps column, 83px of a 1685px table: counts each way, the
names on hover, blank when there are none, not inline-editable because a dependency
is a relationship between two rows and one cell is the wrong shape for it. Clicking
it opens the editor that already edits them. Joins the fixed-layout and resizable
machinery and sorts by total count with blanks last.
One bug surfaced: clicking the Deps cell opened the editor and the document-level
outside-click handler immediately closed it, seeing the very click that opened it.
Any new surface that opens the editor has to answer that handler.
Also noted for next time: a migration should bump stateRev, so a tab that predates
it gets a 409 instead of writing its old copy back. That is how 0.21.0's provenance
migration was lost.
6 files changed
+250−7
README.md+1@@ -166,6 +166,7 @@ long editing run. | 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 | | Delete | Delete the selected row, undoable. Only when a row is selected and no field is open, so Delete inside an editor just edits text. Backspace is deliberately not bound |+| Shift+D | Dependency mode: click the task that comes first, then the one that waits on it. Stays on so you can chain them. Esc clears the pick, Esc again leaves | | 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 | | Alt+Up / Alt+Down | Move a row one slot: the focused row in the table, the selected task in the chart. Crossing a lane boundary moves the row into that lane. Under a column sort the row stays inside the run of rows the sort considers tied with it | | Tab / Shift+Tab | In the table, commit the open cell editor and open the next one, ready to type. Wraps across rows and around the ends, and follows the visible order under a sort |
SKILL.md+2@@ -200,6 +200,8 @@ rather than a category. - **New items default their provenance to "Added by <your display name>"**, still an ordinary editable field - **Delete** removes the selected table row (undoable, redoable). Only when a row is selected and no editor, panel, search box or drag is live, so Delete inside a field only edits text. Backspace is not bound. Inert in the chart - **Saving is whole-state and last-write-wins.** Two people editing at once WILL overwrite each other. Each save carries the revision it was based on and a stale save is refused with 409, at which point that tab stops autosaving and offers a reload rather than clobbering the other person+- **Dependency mode** (the Deps button with the chart controls, or **Shift+D**): click the task that comes FIRST, then the task that waits on it, and the dependency is drawn. The pair resets and the mode stays on, so a run of dependencies is one sitting. Refuses duplicates and refuses anything that would make a cycle, naming the shortest loop path. Milestones are not endpoints: a dep is stored as a task id and a milestone in the data file has none. Esc clears a half-made pick, Esc again leaves the mode. While the mode is on, bar clicks mean dependencies: no editor, no drag, no reorder. Ctrl+Z removes the last one without leaving the mode+- **Deps column** in the table: a narrow read-only indicator, needs and blocks counts with the names on hover, blank when there are none. Clicking it opens the editor where dependencies are changed. Sortable by total count - **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
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.22.0, dependency mode)++Drawing a dependency used to mean opening one bar's editor and picking from a+dropdown of every task in the plan, one at a time. Dependency mode is the other way+round: turn it on, click the task that comes FIRST, click the one that waits on it,+and keep going. Chaining is the whole point, so the mode survives every pair and only+Esc or the button turns it off.++**The undo work was already done.** A dependency lives in the dependent row's `deps`+array, and the `edit` snapshot has captured `deps` since 0.8.0, so `snapshot(depId)`+before the push makes an add exactly one undo step, and 0.20.0's inverse machinery+gives redo for nothing. The changelog was already there too: `diffAndLog` has emitted+`dep.add` and `dep.remove` for as long as it has existed, and the server already+renders them. The whole feature needed no new snapshot type, no new event and no+server change. That is what the last few rounds were for.++**A mode that changes what a click means has to be impossible to be in by accident.**+Three signals at once: the cursor becomes a crosshair over bars, the button lights,+and a caption sits under the legend for as long as the mode is on saying what the+NEXT click will do, changing from "click the task that comes first" to "now click the+task that waits on <name>" once a first pick exists. The pending bar carries an accent+ring. While the mode is on, bar mousedown returns immediately, so there is no edge+resize, no move and no reorder arming, and double-click does not open the note editor.++**Cycle prevention is mandatory and it names the loop.** Deps are "needs" edges, so+adding master to dep's list closes a loop exactly when master already needs dep+through any chain. The search is breadth-first so the path in the message is the+SHORTEST one, which is the one a person can actually check: "that would make a loop:+Mpins inserted into all blanks needs Assemble all 200 nameplate blanks w/ Puma needs+Order SMD components for 200 boards needs Mpins inserted into all blanks". Verified+for a two-row loop and a three-row loop.++**Milestones are excluded, and it was the model's call rather than a preference.** A+dependency is stored as an id, `barEls` is keyed by id, and a milestone in the base+data file has no id at all, so a dep pointing at one could not be drawn, could not be+listed by `renderDepList` and would not survive an export. Inventing a milestone id+namespace inside this feature would have been a data-model change smuggled in behind+a UI feature. Clicking one says so instead.++**Esc keeps the innermost-first rule** the app has had since 0.13.0: a half-made+dependency goes before the mode that is making it, so the first Esc drops the pick and+the second leaves the mode. Switching to the table leaves it too, because a crosshair+cursor over a table would be a lie.++**The table's Deps column is an indicator, not an editor.** A dependency is a+relationship between two rows, and a one-cell editor is the wrong shape for it, so the+cell shows the shape of the relationships and opens the surface that already edits+them properly. Narrow (83px of a 1685px table), a count each way, and the actual names+on hover. Blank when there are none. It joins the fixed-layout and resizable-column+machinery like any other column and is sortable by total count with blanks last.++**One bug this surfaced, and it is an old friend.** Clicking the Deps cell opened the+bar editor and then immediately closed it: the document-level "a click outside closes+the editor" handler saw the very click that opened it. The bar path escapes that by+being in the handler's allowlist; this one stops propagation instead. Worth+remembering that any NEW surface that opens the editor has to answer that handler.++**Migrations should bump `stateRev`.** The 0.21.0 provenance migration was clobbered+by a tab that predated it doing a whole-state save, which is precisely the+last-write-wins race documented in the same release. The fix is one line and it is now+in the migration script: bump the sidecar's `stateRev` when a migration writes, so any+tab holding a pre-migration `baseRev` gets a 409 on its next save and is told to+reload instead of quietly writing the old data back. Post-0.21.0 clients send+`baseRev`, so this class dies as tabs refresh, but a migration should not depend on+that.++**Evidence.** Mode enters by button and by Shift+D and leaves by both; normal+interactions suspended in the mode and working again after it; a pair creates the dep+with the arrow drawn, the changelog line written and the mode still on; three pairs+chained without leaving; the same bar clicked twice clears the pick; a duplicate is+refused by name; two-row and three-row cycles refused with the path named; milestones+refused with a reason; Esc clears the pick then the mode; Ctrl+Z mid-mode removes the+last dep with the mode still on and Ctrl+Shift+Z puts it back; the Deps column counts,+tooltips, stays blank when empty, is not editable, opens the editor on the dep list,+and sorts with blanks last; six `dep.add` events in the changelog reading "Drew Owens+made X depend on Y (dep-mode)". The 35-step fixed-column battery passes unchanged with+ten columns. Zero JS errors.+ ## Build notes, 2026-08-11 (0.21.0, sign in with Adom, and who did what) The plan is about to be shared with the team, so it needed a door and it needed
gantt.html+166−4@@ -599,6 +599,7 @@ table.data-table { font-size: calc(12px * var(--fs)); min-width: calc(900px * va <div class="app"> <header id="hdr"></header> <div class="legend" id="legend"></div>+<div class="dep-caption" id="depCaption"></div> <div class="chart-wrap"> <div class="labels-col" id="labels"></div> <div class="col-resizer" id="colResizer" title="Drag to resize the name column. Double-click to fit the longest name."></div>@@ -631,6 +632,7 @@ table.data-table { font-size: calc(12px * var(--fs)); min-width: calc(900px * va <th class="sortable" data-sort="start">Start</th> <th class="sortable" data-sort="deadline">Deadline</th> <th class="sortable" data-sort="status">Status</th>+ <th class="sortable" data-sort="deps" title="Dependencies: what this row needs, and what waits on it. Click a cell to edit them">Deps</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>@@ -1032,6 +1034,8 @@ $('hdr').innerHTML = ` </select> <button class="act-btn" id="btnFit" title="Fit the whole plan into the pane: the finest cell size that shows every date without scrolling sideways"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 5v14M21 5v14"/><path d="M7 12h10"/><path d="M10 9l-3 3 3 3M14 9l3 3-3 3"/></svg>Fit</button>+ <button class="act-btn" id="btnDeps" title="Add dependencies · Shift+D. Click the task that comes first, then the one that waits on it. Esc clears the pick, Esc again leaves the mode">+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="5" cy="6" r="2"/><circle cx="19" cy="18" r="2"/><path d="M7 6h6a3 3 0 0 1 3 3v7"/><path d="M14 14l2 3 2-3"/></svg><span class="btn-label">Deps</span></button> </div> <div class="actions"> <button class="act-btn" id="btnMsStyle" title="Milestone style: diamond or dashed line">@@ -1507,6 +1511,14 @@ document.addEventListener('keydown',e=>{ startCellEdit(td); return; }+ // Shift+D turns dependency mode on and off, on the same gate as Shift+N: a+ // capital D typed into a task name is a capital D.+ if(e.shiftKey&&!e.ctrlKey&&!e.metaKey&&!e.altKey&&(e.key==='D'||e.key==='d')){+ if(!appIsIdle())return;+ if(prefs.view!=='gantt'){ flash(saveInd,'dependencies are drawn on the chart',2400); return; }+ e.preventDefault();toggleDepMode();+ return;+ } // Shift+N opens the create panel, and only when the app is genuinely idle: a // capital N typed into a task name is a capital N and nothing else. if(e.shiftKey&&!e.ctrlKey&&!e.metaKey&&!e.altKey&&(e.key==='N'||e.key==='n')){@@ -1524,6 +1536,10 @@ document.addEventListener('keydown',e=>{ return; } if(rowDrag||laneDrag){ endRowDrag(false); endLaneDrag(false); return; }+ // Innermost first here too: the half-made dependency goes before the mode that+ // is making it.+ if(depMode&&depPending){ setDepPending(null); return; }+ if(depMode){ setDepMode(false); return; } if(tblEditing){ tblEditing.cancel(); return; } if(editorEl.classList.contains('show')){ closeEditor(); return; } // The row focus is the outermost thing the table owns, so it goes after the@@ -2333,6 +2349,16 @@ let didDrag = false; function wireBarClicks(){ bars.addEventListener('click',e=>{ if(didDrag){didDrag=false;return;}+ // While the mode is on, a click on the chart is a dependency click. No editor+ // opens, nothing is selected, and a click on a milestone says why rather than+ // quietly doing nothing.+ if(depMode){+ e.stopPropagation();+ const b=e.target.closest('.bar[data-id]');+ if(b){ depClickBar(b.dataset.id); return; }+ if(e.target.closest('.ms-hit, .ms-cap')){ flash(saveInd,'milestones cannot be dependencies here',2600); return; }+ return;+ } const msHit=e.target.closest('.ms-hit, .ms-cap'); if(msHit){ clearSelection(); openMilestoneEditor(+msHit.dataset.msi); e.stopPropagation(); return; } const bar=e.target.closest('.bar[data-id]');@@ -2379,12 +2405,14 @@ function openMilestoneEditor(i){ } // ── Single editor ──-function openSingleEditor(id){+function openSingleEditor(id,anchor){ const r=items.find(d=>d.id===id);if(!r)return; const allTasks=items.filter(d=>d.id&&d.id!==id),deps=r.deps||[]; editorEl.innerHTML=`<div class="drag-bar"></div><div class="de-row"><div><label>start</label><input type="date" id="deStart" value="${r.start}"></div><div><label>end</label><input type="date" id="deEnd" value="${r.end}"></div></div><div class="de-note-row"><label>note <span class="de-hint">shown on the bar</span></label><input type="text" id="deNote" maxlength="24" value="${esc(r.note||'')}" placeholder="e.g. 6m"></div><div class="de-dep-section"><label>dependencies</label><ul class="dep-list" id="deDepList"></ul><select id="deAddDep" style="margin-top:6px;"><option value="">+ add dependency...</option>${allTasks.filter(t=>!deps.includes(t.id)).map(t=>`<option value="${esc(t.id)}">${esc(t.name)}</option>`).join('')}</select></div><div class="de-actions"><button class="de-btn primary" id="deApply">Apply</button><button class="de-btn secondary" id="deClose">Cancel</button></div>`; renderDepList(id);- const rect=barEls[id].bar.getBoundingClientRect();+ // Anchored to the bar in the chart. In the table the chart is display:none and+ // every bar rect is zero, so the caller passes the cell it came from instead.+ const rect=anchor||barEls[id].bar.getBoundingClientRect(); const edW=300, edH=300; let edX=rect.right+8, edY=rect.top; if(edX+edW>window.innerWidth) edX=rect.left-edW-8;@@ -2495,6 +2523,7 @@ let dragState=null; function wireBarDrags(){ bars.addEventListener('mousedown',e=>{+ if(depMode)return; // no edge resize, no move, no reorder arming const handle=e.target.closest('.drag-handle'); const bar=handle?handle.closest('.bar[data-id]'):e.target.closest('.bar[data-id]'); if(!bar)return;@@ -2516,6 +2545,7 @@ bars.addEventListener('mousedown',e=>{ // Milestones carry no id, so they sat outside the bar drag path entirely and could // never be moved or edited. They get their own handler. bars.addEventListener('mousedown',e=>{+ if(depMode)return; const hit=e.target.closest('.ms-hit, .ms-cap'); if(!hit)return; e.preventDefault();hideTip();@@ -2692,6 +2722,100 @@ document.addEventListener('mouseup',()=>{ }); } +// ── Dependency mode ──+//+// Drawing dependencies used to mean opening one bar's editor and picking from a+// dropdown of every task in the plan, one at a time. This is the other way round:+// turn the mode on, then click the task that comes FIRST and the task that waits on+// it, and keep going. Chaining is the whole point, so the mode survives each pair+// and only Esc or the button turns it off.+//+// Milestones are deliberately not endpoints. A dependency is stored as an id in the+// dependent row's `deps`, `barEls` is keyed by id, and a milestone in the base data+// file has no id at all, so a dep pointing at one could not be drawn, could not be+// listed and would not survive an export. Rather than invent an id namespace for+// milestones inside this feature, the mode says so and leaves them alone.+let depMode=false, depPending=null;+function depIsEligible(r){ return !!(r && r.id && r.type!=='milestone'); }+// Deps are "needs" edges: r.deps lists what r waits on. Adding master to dep.deps+// makes dep wait on master, which closes a loop if master already waits on dep,+// directly or through any chain. Breadth-first so the path in the hint is the+// shortest one, which is the one a person can actually check.+function depPathTo(fromId,targetId){+ const seen=new Set([fromId]); const q=[[fromId]];+ while(q.length){+ const path=q.shift(), cur=path[path.length-1];+ const r=items.find(d=>d.id===cur);+ for(const nx of (r&&r.deps)||[]){+ if(nx===targetId)return path.concat(nx);+ if(seen.has(nx))continue;+ seen.add(nx); q.push(path.concat(nx));+ }+ }+ return null;+}+function depName(id){ const r=items.find(d=>d.id===id); return r?(r.name||id):id; }+function setDepPending(id){+ if(depPending){const b=barEls[depPending];if(b)b.bar.classList.remove('dep-pending');}+ depPending=id||null;+ if(depPending){const b=barEls[depPending];if(b)b.bar.classList.add('dep-pending');}+ syncDepCaption();+}+function syncDepCaption(){+ const cap=$('depCaption'); if(!cap)return;+ document.documentElement.setAttribute('data-dep',depMode?'on':'off');+ const btn=$('btnDeps'); if(btn)btn.classList.toggle('on',depMode);+ if(!depMode){cap.innerHTML='';return;}+ cap.innerHTML=depPending+ ? `Now click the task that waits on <b>${esc(depName(depPending))}</b>. <kbd>Esc</kbd> clears the pick, <kbd>Esc</kbd> again leaves.`+ : `Adding dependencies. Click the task that comes <b>first</b>, then the one that waits on it. <kbd>Esc</kbd> leaves the mode.`;+}+function setDepMode(on){+ depMode=!!on;+ if(!depMode)setDepPending(null);+ document.body.classList.toggle('dep-mode',depMode);+ if(depMode){closeEditor();closeMenus();clearSelection();}+ syncDepCaption();+}+function toggleDepMode(){ setDepMode(!depMode); }+// One click in the mode. Returns nothing; every refusal says why in the same place+// every other refusal in this app says why.+function depClickBar(id){+ const r=items.find(d=>d.id===id);+ if(!depIsEligible(r)){ flash(saveInd,'milestones cannot be dependencies here',2600); return; }+ if(!depPending){ setDepPending(id); return; }+ if(depPending===id){ setDepPending(null); flash(saveInd,'pick cleared'); return; }+ const masterId=depPending, depId=id;+ const dep=items.find(d=>d.id===depId);+ if((dep.deps||[]).includes(masterId)){+ flash(saveInd,'"'+depName(depId)+'" already needs "'+depName(masterId)+'"',2800);+ setDepPending(null); return;+ }+ // The data model is a DAG and everything downstream assumes it, so a loop is+ // refused rather than stored and discovered later by something that hangs.+ const loop=depPathTo(masterId,depId);+ if(loop){+ const path=loop.map(depName).concat(depName(masterId)).join(' needs ');+ flash(saveInd,'that would make a loop: '+path,6000);+ setDepPending(null); return;+ }+ // One edit snapshot, which already carries deps, so this is one undo step and the+ // 0.20.0 inverse machinery gives redo for nothing.+ snapshot(depId);+ if(!dep.deps)dep.deps=[];+ dep.deps.push(masterId);+ updateBar(depId);+ drawDeps();+ if(prefs.view==='table')renderTable();+ flash(saveInd,'"'+depName(depId)+'" now needs "'+depName(masterId)+'"',2200);+ scheduleSave('dep-mode');+ // The pair resets and the mode stays on: the next click starts the next pair.+ setDepPending(null);+}+function wireDepMode(){+$('btnDeps').addEventListener('click',e=>{e.stopPropagation();toggleDepMode();});+}+ // ── Bar notes ── // Double-click the bar body to type a short label straight onto it, the way the // NSF chart shows durations. Single-click still opens the full editor.@@ -2716,6 +2840,7 @@ function editNoteInline(id){ } function wireBarDblclick(){ document.addEventListener('dblclick',e=>{+ if(depMode)return; const bar=e.target.closest('.bar'); if(!bar||!bar.dataset.id)return; e.preventDefault();e.stopPropagation();@@ -2842,6 +2967,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">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">Delete</span>Delete the selected row. Undoable</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+D</span>Add dependencies: click the task that comes first, then the one that waits on 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">Shift+N</span>New task or milestone</div> <div class="dd-item" style="cursor:default"><span style="color:var(--accent);min-width:90px;font-family:'JetBrains Mono',monospace;font-size:10px">Alt+Up/Down</span>Move the row one slot. Under a sort it stays inside its tied group</div> <div class="dd-item" style="cursor:default"><span style="color:var(--accent);min-width:90px;font-family:'JetBrains Mono',monospace;font-size:10px">Tab</span>Commit and open the next field. Shift+Tab goes back</div>@@ -3462,6 +3588,7 @@ function sortValueOf(r,key){ if(key==='owner')return ownerOf(r).toLowerCase(); if(key==='start')return isMs(r)?'':(r.start||''); if(key==='status'){const k=statusOf(r);const i=STATUS_ORDER.indexOf(k);return i<0?(k?'z'+k:''):String(i);}+ if(key==='deps'){const d=depsOf(r);const n=d.needs.length+d.blocks.length;return n?String(1000+n):'';} if(key==='notes')return (r.description||'').toLowerCase(); if(key==='prov')return provLabel(r).toLowerCase(); return deadlineOf(r);@@ -3519,6 +3646,27 @@ function provCell(r){ return `<span class="prov-chip" title="${tip}">${esc(p.label)}</span>`; } +// Read-mostly indicator, not an editor: how many rows this one needs, how many wait+// on it, and the actual names on hover. Deliberately NOT inline-editable, because a+// dependency is a relationship between two rows and a one-cell editor is the wrong+// shape for it; the cell opens the editor that already does the job properly.+function depsOf(r){+ if(!r||!r.id)return {needs:[],blocks:[]};+ const needs=(r.deps||[]).map(d=>({id:d,name:depName(d)}));+ const blocks=items.filter(d=>d.deps&&d.deps.includes(r.id)).map(d=>({id:d.id,name:d.name||d.id}));+ return {needs,blocks};+}+function depCell(r){+ const {needs,blocks}=depsOf(r);+ if(!needs.length&&!blocks.length)return '';+ const tip=[needs.length?('needs: '+needs.map(n=>n.name).join(', ')):'',+ blocks.length?('blocks: '+blocks.map(n=>n.name).join(', ')):'']+ .filter(Boolean).join(' / ');+ return `<span class="dep-count" data-title="Dependencies" data-body="${esc(tip)}">`++ (needs.length?`<span class="in">←${needs.length}</span>`:'')++ (needs.length&&blocks.length?' ':'')++ (blocks.length?`→${blocks.length}`:'')+'</span>';+} function renderTable(){ if(!tableBody)return; syncOwnerControls();@@ -3528,7 +3676,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="9" class="table-empty">No items match these filters.</td></tr>';return;}+ if(!rows.length){tableBody.innerHTML='<tr><td colspan="10" 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=statusOf(r), dl=deadlineOf(r), sd=ms?'':(r.start||'');@@ -3544,6 +3692,7 @@ function renderTable(){ :`<td class="c-date${ed}${sd?'':' empty'}" data-field="start">${sd?esc(fmt(sd)):'not set'}</td>`) +`<td class="c-date${ed}${dl?'':' empty'}" data-field="deadline">${dl?esc(fmt(dl)):'not set'}</td>` +`<td class="c-status${ed}" data-field="status">${st?`<span class="tag ${statusTagFor(st)}">${esc(statusTextFor(st))}</span>`:'<span class="tag tag-not-started">none</span>'}</td>`+ +`<td class="c-deps${(()=>{const d=depsOf(r);return (d.needs.length||d.blocks.length)?'':' empty';})()}">${depCell(r)}</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>`@@ -3691,7 +3840,7 @@ function fixedCols(){ return prefs.tableCols&&Object.keys(prefs.tableCols).lengt // width, and the table is pixel-driven from then on. That is also what Excel does: // before you touch anything the columns are whatever the sheet says, and the moment // you drag one they are numbers you own.-const COL_DEFAULTS={name:'23%',phase:'10%',owner:'8%',start:'7.5%',deadline:'7.5%',status:'8%',notes:'22%',prov:'14%',del:'30px'};+const COL_DEFAULTS={name:'22%',phase:'9%',owner:'8%',start:'7%',deadline:'7%',status:'8%',deps:'5%',notes:'20%',prov:'14%',del:'30px'}; function applyColWidths(){ const table=tableHead.closest('table'); if(!table)return;@@ -4144,6 +4293,17 @@ tableBody.addEventListener('click',e=>{ if(r)deleteRow(r); return; }+ const dc=e.target.closest('td.c-deps');+ if(dc){+ // The document-level "click outside closes the editor" handler would otherwise+ // see this very click and shut the editor we are about to open. The bar path+ // escapes it by being in that handler's allowlist; this one stops here instead.+ e.stopPropagation();+ const tr=dc.closest('tr'); const r=rowItem(tr&&tr.dataset.key);+ if(r&&r.id&&barEls[r.id]){ setFocusedRow(tr.dataset.key); openSingleEditor(r.id,dc.getBoundingClientRect()); }+ else if(r) flash(saveInd,'only tasks carry dependencies',2400);+ return;+ } const td=e.target.closest('td.editable');if(td)startCellEdit(td); }); }@@ -4187,6 +4347,7 @@ function applyView(){ function setView(v){ if(v!=='table'&&v!=='gantt')return; if(prefs.view===v)return;+ if(v==='table'&&depMode)setDepMode(false); // the mode is a chart mode prefs.view=v;savePrefs();applyView(); if(v==='table'){renderTable();return;} // The chart measured nothing while it was hidden, so lay it out from scratch@@ -4289,6 +4450,7 @@ async function init(){ wireImportAndHelp(); wireThemeButton(); wireMsStyleAndGrain();+ wireDepMode(); wireWhoChip(); wireNewButton(); wireFontScale();
package.json+1−1@@ -2,7 +2,7 @@ "slug": "adom-gantt", "title": "Adom Gantt", "type": "app",- "version": "0.21.0",+ "version": "0.22.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.21.0",+ "version": "0.22.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]" }-}+}