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.16.0: the init() refactor
The module body is declarations and function definitions only now, plus one
await init() on the last line. Every imperative block was wrapped in a named
function where it already sat, and the 51 wrappers are called from a single
ordered list in init(). Function declarations hoist, so file position stops
mattering: by the time any step runs, every declaration in the module has been
initialised.
That kills the bug class behind three shipped-or-caught failures (STATUS_ORDER
in 0.11.0, collapsed and prefs in 0.14.0), where a prologue statement ran inside
a later const's temporal dead zone and the module threw part-way through the
build, leaving labels in the DOM and no bars.
Behaviour-neutral by construction and by measurement: a 52-key DOM, geometry,
style and endpoint fingerprint is identical before and after, and a behavioural
sweep over a copy of the live plan matches on 28 of 30 checks (the two that move
are the HTML snapshot's length and the fact that it now contains await init()).
Nothing was reindented or reordered, which also keeps the snapshot exporter's
three exact-text anchors intact.
4 files changed
+282−18
docs/BUILD-NOTES.md+64@@ -3,6 +3,70 @@ Decisions taken while building, and the places where the plan and the code disagreed. Newest first. +## Build notes, 2026-08-10 (0.16.0, the init() refactor)++**The rule this file kept restating is now enforced by the shape of the code.**+Three times a statement in the module-level prologue ran before a `const` further+down the file existed and the whole app died in its temporal dead zone:+`STATUS_ORDER` in 0.11.0, then `collapsed` and `prefs` in 0.14.0. The advice that+came out of those ("anything the prologue touches must be declared above it") was+correct and useless, because it asks every future edit to hold a 200-statement+execution order in its head, and the failure mode does not look like an ordering+bug: the module throws part-way through the build, so the labels are in the DOM+and the bars are not, and the symptom reads as a rendering problem.++The module body now contains **declarations and function definitions only**, plus+one `await init()` on the last line. Every imperative block was wrapped in a named+function **where it already sat**, and the 51 wrappers are called from a single+ordered list in `init()`. Function declarations hoist, so file position stops+mattering: by the time any step runs, every declaration in the module has been+initialised. The bug class is gone rather than documented.++**Four initialisers were genuinely order-dependent and are now declare-then-assign.**+`W` needs `computeRange()` to have set `UNITS`; `lastSnap` needs the plan loaded+and its undateable rows dropped; `lastPaneW`/`lastPaneH` and `lastRelayoutW` are+pane measurements, and a measurement taken before the chart is in the pane is a+measurement of nothing. Each is declared bare and assigned inside its own step, at+the point in the sequence where it used to run. That is the pattern for anything+order-dependent from here: the declaration is a binding, the ordering lives in+`init()`.++**Nothing was reindented and nothing was reordered.** The wrappers are pure+insertions, which keeps the diff to what it is (201 added lines, and the only 15+removed lines are one-liners that had a declaration and a statement sharing them,+split across two lines). It also matters for correctness beyond taste: the HTML+snapshot exporter finds three pieces of this file by exact-text regex, including+the whole `try{const r=await fetch('state')...}catch(e){}` block on one line, and+a reformat would have silently produced a snapshot that still tried to fetch from+a server it does not have. All three anchors were asserted present after the+transform, and the exported snapshot was rendered from `file://` to prove it.++**The acceptance bar was a zero-diff fingerprint, and it was met.** A 52-key+capture (76 label rows, 62 bars, 9 caps and their tops, 56 arrows, 71 edit buttons+and table rows, control positions, bar geometry to the sub-pixel, axis labels,+computed styles, table headers and widths, the first six rows, and the+`/state`, `/data`, `/export/{json,csv,svg}` responses) was taken before the+refactor and again after: identical on all 52 keys. The harness was validated by+re-running it against the pre-refactor file through the same pipeline first, which+reproduced the baseline exactly, so the comparison is measuring the code and not+the method.++On top of that, a behavioural sweep against a copy of the live DC plan compared+before and after on 30 checks and found them identical on 28: the two that differ+are the snapshot's byte length and the fact that it now contains `await init();`.+The sweep covers the view toggle, an owner edit round trip with Ctrl+Z, drag+arming and its Escape, collapse and expand with the cap collision layout measured+at zero overlaps throughout, two font-scale steps and the reset, Fit, the today+tooltip, and the standalone snapshot rendering from disk. Zero JS errors, with+`unhandledrejection` captured as well as `error`, and `bars > 0` asserted+explicitly because that is the shape the TDZ failure took.++**A static invariant now exists for this.** Two properties are checkable from the+AST and were run against the result: every top-level statement is a declaration+except the final `await init()`, and no top-level declaration references a+top-level binding declared later in the file. If a future round wants a guard+rather than a habit, that is the check to wire up.+ ## Build notes, 2026-08-07 (0.15.1) Owner and status moved above the dates in the create panel, and the SAME move in
gantt.html+215−15@@ -650,7 +650,9 @@ const { project, phases, items } = projData; // changed its key, the key no longer resolved on the next load, the row was // dropped, and the following save wrote the shortened plan back to the sidecar // permanently. An index cannot go stale when the text changes.+function assignRowKeys(){ items.forEach((r,i)=>{ if(!r.id) r._key='#row'+i; });+} function rowOrderKey(r){ return r.id || r._key; } // Milestones use the same stable key as every other row. The server recomputes it // as `r.id || '#row' + dataFileIndex`, so the two sides must agree exactly or an@@ -713,8 +715,10 @@ function rowDatesOk(r){ // Keys are assigned above, over the FULL data-file array, before anything is // dropped, so the surviving rows keep the keys the server computes for them. const UNDATED=items.filter(r=>!rowDatesOk(r));+function dropUndatedRows(){ if(UNDATED.length){ for(let i=items.length-1;i>=0;i--){ if(!rowDatesOk(items[i]))items.splice(i,1); } } document.title = `${project.title} · adom-gantt`;+} // ── View preferences (theme, milestone style, start-date override) ── const PREF_KEY = 'adom-gantt:prefs';@@ -756,9 +760,12 @@ const prefs = Object.assign( savedPrefs() ); // A hand-edited or stale localStorage value must not put the app at 40%.+function clampFontScalePref(){ if (FS_STEPS.indexOf(prefs.fs) < 0) prefs.fs = 1;+} function savePrefs(){ try { localStorage.setItem(PREF_KEY, JSON.stringify(prefs)); } catch {} } function applyTheme(){ document.documentElement.setAttribute('data-theme', prefs.theme); }+function applyStartupPrefs(){ applyTheme(); // Both set before the chart is built: the view so a plan that opens as a table // never measures a hidden timeline, the scale so the first paint is already at@@ -766,6 +773,7 @@ applyTheme(); // not exist yet on either of these first calls. applyView(); applyFontScale();+} // ── Collapse a lane ── //@@ -803,6 +811,7 @@ function setCollapsed(ph,on,persist){ applyCollapse(); layoutMsCaps();sizeTailroom();drawDeps(); }+function wireLaneToggle(){ labelsEl.addEventListener('click',e=>{ const btn=e.target.closest('.lane-toggle'); if(!btn)return;@@ -810,6 +819,7 @@ labelsEl.addEventListener('click',e=>{ const g=items.find(r=>r._laneBtn===btn); if(g)setCollapsed(g.phase,!isCollapsed(g.phase)); });+} // ── Date range ── // The timeline is derived from the data every time, so shifting the plan reshapes@@ -859,8 +869,15 @@ function rangeFor(g){ return {start:s, units:Math.max(2, (b.getFullYear()-s.getFullYear())+2)}; } function computeRange(){ const r=rangeFor(G()); START=r.start; UNITS=r.units; }+// W is the timeline's pixel width and cannot be computed until computeRange() has+// set UNITS, so it is declared here and assigned inside the init step. That+// declare-then-assign shape is the rule for anything order-dependent now: the+// declaration is a binding, the ordering lives in init().+let W = 0;+function initTimeRange(){ computeRange();-let W = UNITS*COL;+W = UNITS*COL;+} // Fit the timeline to whatever width the pane actually has. Wide window: months // stretch so the chart fills it. Narrow window or a very long plan: months stop@@ -937,6 +954,7 @@ function fmt(ds){ function addDays(ds, n){const[y,mo,d]=ds.split('-').map(Number);const dt=new Date(y,mo-1,d);dt.setDate(dt.getDate()+n);return`${dt.getFullYear()}-${String(dt.getMonth()+1).padStart(2,'0')}-${String(dt.getDate()).padStart(2,'0')}`;} // ── Header with import/export buttons ──+function buildHeader(){ $('hdr').innerHTML = ` <div class="brand" title="adom-gantt${project.appName?', '+esc(project.appName):''}"><svg class="logo" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><rect x="3" y="4" width="18" height="2" rx="1" opacity="0.5"/><rect x="5" y="9" width="12" height="2" rx="1"/><rect x="3" y="14" width="16" height="2" rx="1" opacity="0.7"/><rect x="7" y="19" width="10" height="2" rx="1" opacity="0.5"/></svg> <span class="app-name">${esc(project.appName||'adom-gantt')}</span></div>@@ -983,6 +1001,7 @@ $('hdr').innerHTML = ` </div> </div>`; applyView(); // again, now that the toggle buttons exist+} // The brand has three hues, so the ramp cycles them rather than inventing lighter // or darker fourths. On a dark ground a second teal has to be lighter to be told@@ -1166,6 +1185,7 @@ function undo(){ if(prefs.view==='table')renderTable(); clearTimeout(undoTimer);undoToast.classList.add('show');undoTimer=setTimeout(()=>undoToast.classList.remove('show'),1800);scheduleSave('undo'); }+function wireGlobalKeys(){ document.addEventListener('keydown',e=>{ if((e.ctrlKey||e.metaKey)&&e.key==='z'){e.preventDefault();undo();} if(e.key!=='Escape')return;@@ -1182,6 +1202,7 @@ document.addEventListener('keydown',e=>{ if(editorEl.classList.contains('show')){ closeEditor(); return; } clearSelection(); closeMenus(); });+} // ── Persistence ── let saveTimer=null;@@ -1317,7 +1338,13 @@ function snapSemantic(){ 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();+// The baseline the changelog diffs against. It has to be taken after the plan is+// loaded and normalised, which is an ordering fact, so it is an init step rather+// than a declaration initialiser.+let lastSnap=null;+function captureBaselineSnapshot(){+lastSnap=snapSemantic();+} function diffAndLog(via){ const cur=snapSemantic(),evs=[]; Object.keys(cur.byId).forEach(id=>{@@ -1418,10 +1445,12 @@ function flushSave(){ }catch(e){} try{fetch('save',{method:'POST',headers:{'Content-Type':'application/json'},body,keepalive:true});}catch(e){} }+function wireSaveFlush(){ window.addEventListener('pagehide',flushSave); // pagehide does not fire when a mobile browser backgrounds the tab and later // discards it, so the hidden transition flushes too. document.addEventListener('visibilitychange',()=>{if(document.visibilityState==='hidden')flushSave();});+} // A snapshot is a file, not a server. The export used to try to rewrite this // function with a regex that could never match its own source; the flag it // already sets is enough.@@ -1430,13 +1459,23 @@ function flash(el,text,ms){el.textContent=text;el.classList.add('show');setTimeo // ── Legend ── const legendEl = $('legend');+function buildLegendPhases(){ phases.forEach((ph,i)=>{const el=document.createElement('div');el.className='legend-item phase-chip';el.title=ph.name;el.innerHTML=`<div class="legend-dot" style="background:${esc(phaseColor(i))}"></div><span class="chip-label">${esc(ph.name)}</span>`;el.addEventListener('click',()=>{phaseVis[i]=!phaseVis[i];el.classList.toggle('off',!phaseVis[i]);applyFilter();});legendEl.appendChild(el);});-const msL=document.createElement('div');msL.className='legend-item';msL.innerHTML='<div class="legend-diamond"></div>Milestone';msL.addEventListener('click',()=>{msVis=!msVis;msL.classList.toggle('off',!msVis);applyFilter();});legendEl.appendChild(msL);-const depL=document.createElement('div');depL.className='legend-item gantt-only';depL.innerHTML='<svg width="14" height="10" style="flex-shrink:0"><path d="M0,5C4,5 10,5 14,5" stroke="var(--accent)" stroke-width="1.5" fill="none" opacity="0.6"/><polygon points="14,5 10,3 10,7" fill="var(--accent)" opacity="0.6"/></svg>Dependencies';+}+const msL=document.createElement('div');+function buildLegendMilestone(){+msL.className='legend-item';msL.innerHTML='<div class="legend-diamond"></div>Milestone';msL.addEventListener('click',()=>{msVis=!msVis;msL.classList.toggle('off',!msVis);applyFilter();});legendEl.appendChild(msL);+}+const depL=document.createElement('div');+function buildLegendDeps(){+depL.className='legend-item gantt-only';depL.innerHTML='<svg width="14" height="10" style="flex-shrink:0"><path d="M0,5C4,5 10,5 14,5" stroke="var(--accent)" stroke-width="1.5" fill="none" opacity="0.6"/><polygon points="14,5 10,3 10,7" fill="var(--accent)" opacity="0.6"/></svg>Dependencies'; depL.addEventListener('click',()=>{depsVis=!depsVis;depL.classList.toggle('off',!depsVis);depSvg.classList.toggle('hidden',!depsVis);});legendEl.appendChild(depL);+} // Today toggles from the legend like Milestone and Dependencies, rather than adding // separate chrome: the legend is already where this app hides and shows chart elements.-const todayL=document.createElement('div');todayL.className='legend-item gantt-only';+const todayL=document.createElement('div');+function buildLegendToday(){+todayL.className='legend-item gantt-only'; todayL.innerHTML='<div class="legend-line"></div>Today'; refreshTodayTip(todayL); todayL.addEventListener('mouseover',()=>refreshTodayTip(todayL));@@ -1449,22 +1488,39 @@ legendEl.appendChild(todayL); // The table's filters join the legend line rather than owning a second band. // Done here, after the chips, so they sit to the right of them. legendEl.appendChild($('tableTools'));+} // ── Build chart ──+function fitTimelineToPane(){ fitCOL();+} const gridEls=[], msLineEls=[]; let todayLineEl=null, todayTagEl=null;+function mountQHeaderSpacer(){ labelsEl.appendChild(Object.assign(document.createElement('div'),{className:'label-h-q'}));-const qRow=document.createElement('div');qRow.className='q-row';+}+const qRow=document.createElement('div');+function mountQRow(){+qRow.className='q-row'; labelsEl.appendChild(Object.assign(document.createElement('div'),{className:'label-h-m'}));-const mRow=document.createElement('div');mRow.className='m-row';+}+const mRow=document.createElement('div');+function mountMRow(){+mRow.className='m-row';+} const mNames=['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']; let qs=[]; -const bars=document.createElement('div');bars.style.cssText=`position:relative;width:${W}px;`;+const bars=document.createElement('div');+function sizeBarsPane(){+bars.style.cssText=`position:relative;width:${W}px;`;+} // The grid lives in its own layer so changing the start date can rebuild it without // disturbing the rows (and their bound handlers). Phases are carried by the bar colour // and the group header, so there is no background band.-const gridLayer=document.createElement('div');gridLayer.style.cssText='position:absolute;inset:0;pointer-events:none;';bars.appendChild(gridLayer);+const gridLayer=document.createElement('div');+function mountGridLayer(){+gridLayer.style.cssText='position:absolute;inset:0;pointer-events:none;';bars.appendChild(gridLayer);+} // Quarter and month header cells, rebuilt whenever START or UNITS changes. function buildTimeHeaders(){@@ -1512,8 +1568,10 @@ function buildGridLines(){ gridLayer.appendChild(l);gridEls.push(l); } }+function buildTimeAxis(){ buildTimeHeaders();buildGridLines(); depSvg=document.createElementNS('http://www.w3.org/2000/svg','svg');depSvg.classList.add('dep-svg');depSvg.setAttribute('width',W);depSvg.setAttribute('height','1');bars.appendChild(depSvg);+} @@ -1581,6 +1639,7 @@ function buildRowDom(r,idx){ b.appendChild(Object.assign(document.createElement('div'),{className:'drag-handle left'}));b.appendChild(Object.assign(document.createElement('div'),{className:'drag-handle right'}));bDiv.appendChild(b);barEls[r.id]={bar:b,idx,row:bDiv};} r._lDiv=lDiv;r._bDiv=bDiv;r._itemIdx=idx;labelsEl.appendChild(lDiv);bars.appendChild(bDiv); }+function buildRows(){ items.forEach((r,idx)=>buildRowDom(r,idx)); // Appended after the rows so the dashed rules sit above the phase bands.@@ -1588,6 +1647,7 @@ msLineEls.forEach(ln=>bars.appendChild(ln)); applyCollapse(); document.documentElement.setAttribute('data-ms', prefs.msStyle); if(prefs.showToday===false)todayL.classList.add('off');+} const today=new Date(); function todayX(){return toX(isoDay(today));}@@ -1598,6 +1658,7 @@ function positionToday(){ if(todayLineEl){todayLineEl.style.left=tx+'px';todayLineEl.style.display=on?'':'none';} if(todayTagEl){todayTagEl.style.left=tx+'px';todayTagEl.style.display=on?'':'none';} }+function mountTodayLine(){ {const tl=document.createElement('div');tl.className='today-line';bars.appendChild(tl);todayLineEl=tl; const tt=document.createElement('div');tt.className='today-tag';tt.textContent='today';bars.appendChild(tt);todayTagEl=tt; // Recomputed on hover, not at build: a plan left open overnight would otherwise@@ -1607,9 +1668,16 @@ function positionToday(){ tt.addEventListener('mouseover',()=>refreshTodayTip(tt)); positionToday();} timelineEl.appendChild(qRow);timelineEl.appendChild(mRow);timelineEl.appendChild(bars);-const barTail=document.createElement('div');barTail.className='tailroom';timelineEl.appendChild(barTail);-const labelTail=document.createElement('div');labelTail.className='tailroom';labelsEl.appendChild(labelTail);+}+const barTail=document.createElement('div');+function mountBarTailroom(){+barTail.className='tailroom';timelineEl.appendChild(barTail);+}+const labelTail=document.createElement('div');+function mountLabelTailroom(){+labelTail.className='tailroom';labelsEl.appendChild(labelTail); timelineEl.addEventListener('scroll',()=>{labelsEl.scrollTop=timelineEl.scrollTop;});+} // ── Responsive relayout ── // Everything on the timeline is positioned in px derived from COL, so a resize@@ -1700,7 +1768,10 @@ function sizeTailroom(){ barTail.style.height=pad+'px'; labelTail.style.height=pad+'px'; }-let rlTimer=null, lastPaneW=timelineEl.clientWidth, lastPaneH=timelineEl.clientHeight;+// The echo guard's baseline. A measurement is order-dependent by definition, so it+// is taken in wireRelayoutObservers() rather than here, where it would now read a+// pane the chart had not been put into yet.+let rlTimer=null, lastPaneW=0, lastPaneH=0; function scheduleRelayout(){clearTimeout(rlTimer);rlTimer=setTimeout(()=>{ // A hidden chart measures zero, which would collapse every column and the // milestone cap layout. Switching back to the chart relays out explicitly.@@ -1716,12 +1787,16 @@ function scheduleRelayout(){clearTimeout(rlTimer);rlTimer=setTimeout(()=>{ // the tail would otherwise stay stale until the next resize. requestAnimationFrame(()=>{ lastPaneH=timelineEl.clientHeight; sizeTailroom(); }); },80);}-let lastRelayoutW=timelineEl.clientWidth;+let lastRelayoutW=0;+function wireRelayoutObservers(){+lastPaneW=timelineEl.clientWidth; lastPaneH=timelineEl.clientHeight;+lastRelayoutW=timelineEl.clientWidth; window.addEventListener('resize',scheduleRelayout); if(window.ResizeObserver)new ResizeObserver(scheduleRelayout).observe(timelineEl); // Web-font load changes tag widths and heights after the first measure, so re-run the // milestone cap collision layout once fonts settle, or tags can overlap on a cold load. if(document.fonts&&document.fonts.ready)document.fonts.ready.then(()=>{if(prefs.msStyle==='line'&&prefs.view!=='table')layoutMsCaps();});+} // ── Filter ──@@ -1750,7 +1825,9 @@ function drawDeps(){ maxY=Math.max(maxY,y1,y2);});}); depSvg.setAttribute('height',Math.max(maxY+40,bars.scrollHeight)); }+function wireDepRedraw(){ setTimeout(drawDeps,100);new ResizeObserver(drawDeps).observe(bars);+} // ── Selection (Ctrl+click toggle, Shift+click range, click single) ── let selAnchor = null;@@ -1788,6 +1865,7 @@ function updateSelCount(){if(selected.size>1){selCountEl.textContent=`${selected // label is the thing's name, the bar is its time. // // Multi-select modifiers keep their old meaning and never start an edit.+function wireLabelClicks(){ labelsEl.addEventListener('click',e=>{ if(reorderState||laneDrag)return; if(e.target.closest('.lane-toggle')||e.target.closest('.row-edit'))return;@@ -1817,6 +1895,7 @@ labelsEl.addEventListener('dblclick',e=>{ if(live)live.__cancel&&live.__cancel(); openItemModal(r,carried); });+} function editLabelInline(r){ const lDiv=r._lDiv;@@ -1857,8 +1936,14 @@ let labelEditing=null; // ── Unified pending gesture (vertical=reorder, horizontal=move) ── let reorderState=null, gesture=null;-const labelDropInd=document.createElement('div');labelDropInd.className='drop-indicator';labelsEl.appendChild(labelDropInd);-const barDropInd=document.createElement('div');barDropInd.className='drop-indicator';bars.appendChild(barDropInd);+const labelDropInd=document.createElement('div');+function mountLabelDropIndicator(){+labelDropInd.className='drop-indicator';labelsEl.appendChild(labelDropInd);+}+const barDropInd=document.createElement('div');+function mountBarDropIndicator(){+barDropInd.className='drop-indicator';bars.appendChild(barDropInd);+} function rowYCenter(el){return el.offsetTop+el.offsetHeight/2;} // One place to put the indicators away, called from every exit: drop, Escape, // a drag that ends outside any list, and the safety net on mouseup. An indicator@@ -1868,15 +1953,18 @@ function hideDropIndicators(){ barDropInd.classList.remove('show'); } +function wireLabelDragArm(){ labelsEl.addEventListener('mousedown',e=>{ const row=e.target.closest('.label-row[data-id]'); if(!row)return; gesture={id:row.dataset.id,startX:e.clientX,startY:e.clientY,src:'label'}; });+} // (bar body mousedown is handled below in the drag section) let didDrag = false;+function wireBarClicks(){ bars.addEventListener('click',e=>{ if(didDrag){didDrag=false;return;} const msHit=e.target.closest('.ms-hit, .ms-cap');@@ -1892,6 +1980,7 @@ bars.addEventListener('click',e=>{ }else if(!e.target.closest('.editor')&&!e.target.closest('.dropdown')){clearSelection();closeEditor();} }); document.addEventListener('click',e=>{if(!e.target.closest('.editor')&&!e.target.closest('.bar[data-id]')&&!e.target.closest('.dropdown')&&!e.target.closest('.act-btn'))closeEditor();});+} // ── Milestone editor ── function openMilestoneEditor(i){@@ -2039,6 +2128,7 @@ function closeMenus(){exportMenu.classList.remove('show');importMenu.classList.r // ── Drag (edge-resize, body-move horizontal, reorder vertical) ── let dragState=null; +function wireBarDrags(){ bars.addEventListener('mousedown',e=>{ const handle=e.target.closest('.drag-handle'); const bar=handle?handle.closest('.bar[data-id]'):e.target.closest('.bar[data-id]');@@ -2205,6 +2295,7 @@ document.addEventListener('mouseup',()=>{ reorderState=null; } });+} // ── Bar notes ── // Double-click the bar body to type a short label straight onto it, the way the@@ -2228,6 +2319,7 @@ function editNoteInline(id){ inp.addEventListener('blur',()=>commit(true)); be.bar.appendChild(inp);inp.focus();inp.select(); }+function wireBarDblclick(){ document.addEventListener('dblclick',e=>{ const bar=e.target.closest('.bar'); if(!bar||!bar.dataset.id)return;@@ -2235,6 +2327,7 @@ document.addEventListener('dblclick',e=>{ closeEditor(); editNoteInline(bar.dataset.id); });+} // ── Tooltip ── let tipTimer=null;@@ -2267,6 +2360,7 @@ function posTipFor(el){ tipEl.style.left=x+'px';tipEl.style.top=y+'px'; } function hideTip(){clearTimeout(tipTimer);tipEl.classList.remove('show');}+function wireTooltipsAndExport(){ document.addEventListener('mouseover',e=>{const el=e.target.closest('[data-title]');if(el)showTip(el);}); document.addEventListener('mouseout',e=>{if(e.target.closest('[data-title]'))hideTip();}); // No mousemove handler on purpose: a tooltip that follows the pointer is the bug.@@ -2290,6 +2384,7 @@ $('btnExport').addEventListener('click',e=>{ exportMenu.querySelectorAll('.dd-item').forEach(item=>{item.addEventListener('click',()=>{doExport(item.dataset.fmt);closeMenus();});}); e.stopPropagation(); });+} function doExport(fmt){ const url = `export/${fmt}`;@@ -2301,6 +2396,7 @@ function doExport(fmt){ // ── Import menu ── let importFormat = null;+function wireImportAndHelp(){ $('btnImport').addEventListener('click',e=>{ closeMenus();const rect=e.currentTarget.getBoundingClientRect(); importMenu.innerHTML=`@@ -2358,6 +2454,7 @@ $('btnHelp').addEventListener('click',e=>{ 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(); });+} // ── View controls: theme, milestone style, granularity, project start ── @@ -2405,17 +2502,20 @@ function shiftPlanTo(newStartISO, via){ } function syncThemeBtn(){ $('themeLabel').textContent = prefs.theme==='dark' ? 'Light' : 'Dark'; }+function wireThemeButton(){ $('btnTheme').addEventListener('click',()=>{ prefs.theme = prefs.theme==='dark' ? 'light' : 'dark'; applyTheme();savePrefs();syncThemeBtn(); }); syncThemeBtn();+} function syncMsBtn(){ const line = prefs.msStyle==='line'; $('msStyleIcon').textContent = line ? '¦' : '◆'; $('msStyleLabel').textContent = line ? 'Lines' : 'Diamonds'; }+function wireMsStyleAndGrain(){ $('btnMsStyle').addEventListener('click',()=>{ prefs.msStyle = prefs.msStyle==='line' ? 'diamond' : 'line'; document.documentElement.setAttribute('data-ms',prefs.msStyle);@@ -2429,6 +2529,7 @@ $('grainSel').addEventListener('change',function(){ savePrefs(); rebuildTimeline(); });+} // ── The full item editor ── //@@ -2575,7 +2676,9 @@ function openCreateEditor(){ flash(saveInd,'added '+(isMs?'milestone':'task')); }); }+function wireNewButton(){ $('btnNew').addEventListener('click',e=>{e.stopPropagation();closeMenus();openCreateEditor();});+} // ── Text scale ── //@@ -2614,10 +2717,12 @@ function stepFontScale(d){ const i = FS_STEPS.indexOf(prefs.fs); setFontScale(FS_STEPS[Math.max(0, Math.min(FS_STEPS.length-1, i+d))]); }+function wireFontScale(){ $('fsDown').addEventListener('click',()=>stepFontScale(-1)); $('fsUp').addEventListener('click',()=>stepFontScale(1)); $('fsPct').addEventListener('click',()=>setFontScale(1)); syncFsCtl();+} // ── Fit the plan to the pane ── //@@ -2648,7 +2753,9 @@ function fitToWidth(){ rebuildTimeline(); flash(saveInd, changed ? `fit to ${GRAINS[g].label.toLowerCase()}` : 'fit to width'); }+function wireFitButton(){ $('btnFit').addEventListener('click',fitToWidth);+} // ── Date picker ── const datePop=$('datePop');@@ -2703,6 +2810,7 @@ function renderDatePicker(){ </div>`; } +function wireDatePicker(){ datePop.addEventListener('click',e=>{ const nav=e.target.closest('[data-nav]'); if(nav){ const d=+nav.dataset.nav;@@ -2728,6 +2836,7 @@ $('startBtn').addEventListener('click',e=>{ datePop.classList.contains('show') ? closeDatePicker() : openDatePicker(); }); document.addEventListener('click',e=>{ if(!e.target.closest('.datepop')&&!e.target.closest('#startBtn')) closeDatePicker(); });+} // ── Resizable name column ── // Long task names were simply cut off with no way to see them. The width persists,@@ -2738,6 +2847,7 @@ function applyLabelWidth(w){ else document.documentElement.style.removeProperty('--label-w'); scheduleRelayout(); }+function wireLabelColumnResize(){ if(prefs.labelW) applyLabelWidth(prefs.labelW); (()=>{@@ -2776,9 +2886,11 @@ if(prefs.labelW) applyLabelWidth(prefs.labelW); // Close menus on outside click document.addEventListener('click',e=>{if(!e.target.closest('.dropdown')&&!e.target.closest('.act-btn'))closeMenus();});+} // ── Draggable editor ── let edDrag=null;+function wireEditorDrag(){ editorEl.addEventListener('mousedown',e=>{ if(!e.target.closest('.drag-bar'))return; e.preventDefault();@@ -2791,6 +2903,7 @@ document.addEventListener('mousemove',e=>{ editorEl.style.top=Math.max(0,e.clientY-edDrag.sy)+'px'; }); document.addEventListener('mouseup',()=>{edDrag=null;});+} // ── Table view ── //@@ -2807,10 +2920,12 @@ const UNASSIGNED='__unassigned__'; // sentinel, so a real owner can never coll // null means plan order: the order the rows are actually in, which is the only // order it makes sense to drag rows around in. A column sort is a lens over that. let sortKey='deadline', sortDir=1, tblSearchTimer=null;+function restoreTableSort(){ if(prefs.tableSort){ sortKey=prefs.tableSort.key||null; sortDir=prefs.tableSort.dir===-1?-1:1; }+} function manualOrder(){ return !sortKey; } function esc(s){return String(s==null?'':s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');}@@ -3032,6 +3147,7 @@ function endLaneDrag(commit){ moveHint='the '+groupLabel(d.g.label)+' lane'; scheduleSave('drag-lane'); }+function wireLaneDrag(){ labelsEl.addEventListener('mousedown',e=>{ if(e.button!==0)return; const row=e.target.closest('.label-row.group');@@ -3085,6 +3201,7 @@ document.addEventListener('mousemove',e=>{ }); document.addEventListener('mouseup',()=>endLaneDrag(true)); document.addEventListener('keydown',e=>{if(e.key==='Escape'&&laneDrag)endLaneDrag(false);},true);+} // ── Resizable table columns ── //@@ -3142,6 +3259,7 @@ function fitColumn(key){ prefs.tableCols[key]=Math.max(COL_MIN,Math.min(720,Math.round(max)+28)); savePrefs();applyColWidths(); }+function wireTableColumnResize(){ (function(){ let grip=null; tableHead.querySelectorAll('th').forEach((th,i,all)=>{@@ -3170,6 +3288,7 @@ function fitColumn(key){ grip=null;document.body.classList.remove('col-sizing');savePrefs(); }); })();+} // ── Drag a row to reorder it ── //@@ -3265,6 +3384,7 @@ function rowDragMove(e){ if(rowDrag.targetItem&&rowDrag.targetItem.phase!==rowDrag.r.phase)target.classList.add('lane-target'); } +function wireTableRowDrag(){ tableBody.addEventListener('mousedown',e=>{ if(e.button!==0)return; if(e.target.closest('.row-del')||e.target.closest('.cell-edit'))return;@@ -3285,13 +3405,16 @@ document.addEventListener('mouseup',()=>{const wasArmed=rowDrag&&rowDrag.armed;e if(wasArmed)dragEndedAt=Date.now();}); // Esc abandons the drag and leaves the plan exactly as it was. document.addEventListener('keydown',e=>{if(e.key==='Escape'&&rowDrag)endRowDrag(false);},true);+} // A drag must not also count as a click on the cell it finished over. Timestamped // rather than a sticky flag: a drag that ends outside the table never produces a // click here, and a flag left armed would then eat the next real one. let dragEndedAt=0;+function wireRowDragClickGuard(){ tableBody.addEventListener('click',e=>{ if(Date.now()-dragEndedAt<150){e.stopPropagation();e.preventDefault();} },true);+} // ── Inline cell editing ── let tblEditing=null;@@ -3434,6 +3557,7 @@ function commitCell(r,field,v){ // Three states per column: ascending, descending, then back to plan order. That // last click is the way back to a draggable table, so it is the same control the // user already reached for rather than a separate "clear sort" affordance.+function wireTableHeadAndDelete(){ tableHead.addEventListener('click',e=>{ const th=e.target.closest('th[data-sort]');if(!th)return; const k=th.dataset.sort;@@ -3452,6 +3576,7 @@ tableBody.addEventListener('click',e=>{ } 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){@@ -3467,6 +3592,7 @@ function deleteRow(r){ undoTimer=setTimeout(()=>{undoToast.classList.remove('show');undoToast.innerHTML='Undid change · <kbd>Ctrl+Z</kbd>';},3200); scheduleSave('table-delete'); }+function wireTableFilters(){ ownerSel.addEventListener('change',()=>{prefs.tableOwner=ownerSel.value;savePrefs();renderTable();}); statusSel.addEventListener('change',renderTable); searchInp.addEventListener('keydown',e=>e.stopPropagation());@@ -3475,6 +3601,7 @@ $('tblClear').addEventListener('click',()=>{ ownerSel.value='';statusSel.value='';searchInp.value=''; prefs.tableOwner='';savePrefs();renderTable(); });+} // ── View switching ── function applyView(){@@ -3500,6 +3627,7 @@ function setView(v){ lastPaneW=lastPaneH=lastRelayoutW=-1; scheduleRelayout(); }+async function startApp(){ $('viewToggle').addEventListener('click',e=>{const b=e.target.closest('.vt-btn');if(b)setView(b.dataset.view);}); // ── Init ──@@ -3534,7 +3662,79 @@ if(UNDATED.length){ dw.classList.add('show'); dw.querySelector('.dw-close').addEventListener('click',()=>dw.classList.remove('show')); }+} ++// ── Startup ──+//+// Everything above this line declares or defines. Nothing above it runs on its own,+// and this is the only ordered sequence in the module: it is the whole of startup.+//+// The file used to open with a two-hundred-statement imperative prologue interleaved+// with the declarations it depended on, which put three separate shipped-or-caught+// bugs in the same temporal dead zone (STATUS_ORDER in 0.11.0, collapsed and prefs+// in 0.14.0): a statement near the top ran before a const further down existed, the+// module threw part-way through the build, and the symptom was a chart with labels+// and no bars rather than anything that named the real cause. Function declarations+// hoist, so wrapping each block where it already sat takes file position out of the+// question entirely: by the time any of these run, every declaration in the module+// has been initialised. Anything genuinely order-dependent is declared bare and+// assigned inside its step (W, lastSnap, the relayout echo guard's baseline).+async function init(){+ assignRowKeys();+ dropUndatedRows();+ clampFontScalePref();+ applyStartupPrefs();+ wireLaneToggle();+ initTimeRange();+ buildHeader();+ wireGlobalKeys();+ captureBaselineSnapshot();+ wireSaveFlush();+ buildLegendPhases();+ buildLegendMilestone();+ buildLegendDeps();+ buildLegendToday();+ fitTimelineToPane();+ mountQHeaderSpacer();+ mountQRow();+ mountMRow();+ sizeBarsPane();+ mountGridLayer();+ buildTimeAxis();+ buildRows();+ mountTodayLine();+ mountBarTailroom();+ mountLabelTailroom();+ wireRelayoutObservers();+ wireDepRedraw();+ wireLabelClicks();+ mountLabelDropIndicator();+ mountBarDropIndicator();+ wireLabelDragArm();+ wireBarClicks();+ wireBarDrags();+ wireBarDblclick();+ wireTooltipsAndExport();+ wireImportAndHelp();+ wireThemeButton();+ wireMsStyleAndGrain();+ wireNewButton();+ wireFontScale();+ wireFitButton();+ wireDatePicker();+ wireLabelColumnResize();+ wireEditorDrag();+ restoreTableSort();+ wireLaneDrag();+ wireTableColumnResize();+ wireTableRowDrag();+ wireRowDragClickGuard();+ wireTableHeadAndDelete();+ wireTableFilters();+ await startApp();+}+await init(); })(); </script> </body>
package.json+1−1@@ -2,7 +2,7 @@ "slug": "adom-gantt", "title": "Adom Gantt", "type": "app",- "version": "0.15.1",+ "version": "0.16.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.15.1",+ "version": "0.16.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]" }-}+}