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.30.0: the sections editor
5 files changed
+378−4
README.md+6@@ -129,6 +129,12 @@ so renaming a header renames the phase and the change shows up in the legend, th table's phase column and every phase dropdown at once. Dragging a lane reorders `phases[]` itself, which is why the legend and the chart can no longer disagree. +The **Sections** button in the top bar opens the editor: add a section, rename it,+give it an aside, recolour it from a brand-safe palette (or a `#rrggbb` of your+own), reorder it, or remove it. An empty section goes on its own; one with rows in+it makes you say where they land, so nothing is ever orphaned. Every change is one+Ctrl+Z and one changelog line.+ A `lead` is the short aside beside a section name ("the long pole (Adonis)"), shown as secondary text. Older data files that still carry `type: "group"` rows load normally: each is absorbed into its phase, contributing its aside as the `lead`.
docs/BUILD-NOTES.md+52@@ -3,6 +3,58 @@ Decisions taken while building, and the places where the plan and the code disagreed. Newest first. +## Build notes, 2026-08-14 (0.30.0, the sections editor)++0.29.0 made `phases[]` the truth about sections; this puts a door on it. A+Sections button in the actions row of both lenses opens a modal listing the phases+in order with a colour swatch, the name, its aside and its row count, and every+button in it calls one of the mutation functions from 0.29.0 and then rebuilds the+list from `phases[]`. There is no second copy of the section list to fall out of+step, which is the entire dividend of doing unification first.++**One snapshot type for create, delete, recolour and lead.** All four are "the+section list looked like this and every row pointed where it pointed", so+`phaseSnapshot()` captures both halves together. That is what makes undoing a+DELETE put the rows back in the section they came from, instead of leaving them+where they were reassigned to, which is the failure a per-field undo would have+shipped with.++**A populated section cannot just go.** Empty deletes freely; a section with rows+in it makes you pick where they land, in a dropdown that cannot offer "nowhere",+and the rows move before the section is removed. One changelog line names the+count and the destination. Every index above the removed one shifts down by one,+so the item phases, the collapse set and the legend visibility are all remapped in+that same function, exactly as they are on a reorder.++**A new section gets its id from the server**, like every row: `POST /uid`, never+minted in the browser, and the create is refused with the reason in the modal if+the call cannot be made.++**Colour: the palette is eight swatches and the first one is "Theme".** Theme is+the ABSENCE of a colour, handing the lane back to the `--ph` ramp, which is the+only setting that adapts between light and dark. Anything explicit is the colour+the person picked and stays that colour in both themes and in print, which is the+model 0.29.0 stated and this makes reachable. A constrained hex box takes anything+else and refuses what is not `#rrggbb`.++**Three DOM bugs, all from the same root: derived rows still need real DOM.**+`buildDerivedHeaders` rebuilt every header object from scratch, so at runtime each+one lost its `_lDiv` and the next reflow called `appendChild(undefined)`; headers+now keep their object and their DOM across a rebuild, matched by phase uid.+Building that DOM at init as well as in `buildRows` left an orphaned node per+header in the label column, so the chart grew a second set of headers, fifteen+where there should have been eight; the build is now guarded on whether the+initial pass has run. And `applyPhaseOrder` did not re-point the header objects at+their new phase indices, so a reorder moved the legend and left the chart alone.+All three were caught by the suite rather than by reading, which is the argument+for the suite.++Ten suites green, zero JS errors: keyboard, columns, dependencies, uid,+milestone-selection, New-panel memory, select-list, milestone caps, unification+and sections, plus the new 39-check modal sweep covering the button in both+lenses, create, recolour, rename, lead, reorder, delete-empty, delete-with-+reassign, undo and Escape.+ ## Build notes, 2026-08-14 (0.29.0, sections and phases are one thing) The user found the seam himself: group header rows were seeded display items in
gantt.html+317−1@@ -511,6 +511,32 @@ a.prov-chip:hover { border-color: var(--accent); background: rgba(0,184,177,0.08 .label-row.row-collapsed, .bar-row.row-collapsed, .ms-line.row-collapsed { display: none !important; } .label-row.group { position: relative; padding-right: 4px; } .label-row.group .lane-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }+.sec-back { position: fixed; inset: 0; background: rgba(0,0,0,0.5); z-index: 99990; display: flex; align-items: center; justify-content: center; }+.sec-modal { width: min(620px, 94vw); max-height: 84vh; display: flex; flex-direction: column; background: var(--overlay); border: 1px solid var(--border); border-radius: 12px; box-shadow: 0 18px 48px rgba(0,0,0,0.5); }+.sec-head { display: flex; align-items: baseline; gap: 10px; padding: 14px 16px 10px; border-bottom: 1px solid var(--border-muted); }+.sec-head h2 { margin: 0; font-family: 'Familjen Grotesk', sans-serif; font-size: 14px; font-weight: 600; letter-spacing: -0.01em; }+.sec-head .sec-hint { font-size: 11px; color: var(--text2); }+.sec-list { overflow-y: auto; padding: 8px 10px; }+.sec-row { display: flex; align-items: center; gap: 8px; padding: 7px 6px; border-radius: 7px; border: 1px solid transparent; }+.sec-row:hover { background: var(--row-hover); }+.sec-row.sec-editing { border-color: var(--border); background: var(--surface); }+.sec-sw { width: 15px; height: 15px; border-radius: 4px; border: 1px solid var(--border); flex-shrink: 0; cursor: pointer; padding: 0; }+.sec-name { flex: 1; min-width: 0; font-size: 12px; color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }+.sec-name .sec-lead { color: var(--text3); font-size: 0.86em; margin-left: 7px; }+.sec-count { font-family: 'JetBrains Mono', monospace; font-size: 10px; color: var(--text3); flex-shrink: 0; min-width: 44px; text-align: right; }+.sec-acts { display: flex; gap: 2px; flex-shrink: 0; }+.sec-btn { background: none; border: 1px solid transparent; color: var(--text2); cursor: pointer; border-radius: 5px; padding: 3px 6px; font-size: 10px; font-family: inherit; }+.sec-btn:hover { background: var(--elevated); color: var(--text); border-color: var(--border-muted); }+.sec-btn[disabled] { opacity: 0.35; cursor: default; }+.sec-btn.danger:hover { color: #f85149; }+.sec-foot { padding: 10px 16px 14px; border-top: 1px solid var(--border-muted); display: flex; gap: 8px; align-items: center; }+.sec-foot input[type="text"] { flex: 1; min-width: 0; }+.sec-modal input[type="text"] { background: var(--bg); border: 1px solid var(--border); color: var(--text); border-radius: 6px; padding: 5px 8px; font-family: inherit; font-size: 12px; }+.sec-modal select { background: var(--bg); border: 1px solid var(--border); color: var(--text); border-radius: 6px; padding: 5px 8px; font-family: inherit; font-size: 12px; }+.sec-pal { display: flex; gap: 5px; flex-wrap: wrap; padding: 6px 2px 2px; }+.sec-chip { width: 18px; height: 18px; border-radius: 5px; border: 2px solid transparent; cursor: pointer; padding: 0; }+.sec-chip.on { border-color: var(--text); }+.sec-err { color: #f85149; font-size: 11px; padding: 0 16px 8px; } .lane-lead { color: var(--text3); font-weight: 400; font-style: normal; margin-left: 7px; font-size: 0.86em; } .lane-toggle { display: flex; align-items: center; gap: 5px; background: none; border: none; color: var(--text2); cursor: pointer; padding: 2px 4px; border-radius: 4px; font-family: 'JetBrains Mono', monospace; font-size: calc(9px * var(--fs)); flex-shrink: 0; } .lane-toggle:hover { background: var(--row-hover); color: var(--text); }@@ -1121,6 +1147,7 @@ $('hdr').innerHTML = ` <button class="act-btn" id="btnMsStyle" title="Milestone style: diamond or dashed line"> <span id="msStyleIcon"></span><span id="msStyleLabel"></span></button> <button class="act-btn" id="btnTheme" title="Toggle light and dark"><span id="themeLabel"></span></button>+ <button class="act-btn" id="btnSections" title="Sections: add, rename, recolour, reorder or remove the plan's lanes"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M4 6h16M4 12h10M4 18h13"/></svg><span class="btn-label">Sections</span></button> <button class="act-btn" id="btnImport" title="Import a plan: JSON, CSV, MS Project XML or Jira CSV"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 3v12m0 0l-4-4m4 4l4-4M4 17v2a2 2 0 002 2h12a2 2 0 002-2v-2"/></svg><span class="btn-label">Import</span></button> <button class="act-btn" id="btnExport" title="Export this plan: JSON, CSV, SVG or a self-contained HTML snapshot"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 21V9m0 0l4 4m-4-4l-4 4M4 7V5a2 2 0 012-2h12a2 2 0 012 2v2"/></svg><span class="btn-label">Export</span></button> <button class="act-btn" id="btnHelp" style="font-family:'Familjen Grotesk',sans-serif;font-size:14px;font-weight:600;padding:4px 9px">?</button>@@ -1208,10 +1235,30 @@ function absorbLegacyGroups(){ // section that shows its own header with a count of 0 is what makes a section the // user just made feel real. They are in-memory only and never saved. function buildDerivedHeaders(){+ // Reuse the header object a phase already has, DOM and all. Rebuilding them all+ // from scratch at runtime left every header without a `_lDiv`, and the next+ // reflow tried to appendChild(undefined): headers only ever got their DOM from+ // the initial buildRows pass, so a section created after load had none.+ const old=items.filter(isDerivedGroup);+ const byPhaseUid=new Map();+ old.forEach(h=>{ if(h._phUid)byPhaseUid.set(h._phUid,h); }); for(let i=items.length-1;i>=0;i--) if(isDerivedGroup(items[i])) items.splice(i,1);+ const kept=new Set(); phases.forEach((p,i)=>{- items.push({type:'group',phase:i,label:p.name,lead:p.lead||'',uid:headerUid(i),_derived:true});+ const key=p.uid||('#ph'+i);+ let h=byPhaseUid.get(key);+ if(h){ h.phase=i; h.label=p.name; h.lead=p.lead||''; h.uid=headerUid(i); }+ else h={type:'group',phase:i,label:p.name,lead:p.lead||'',uid:headerUid(i),_derived:true,_phUid:key};+ h._phUid=key;+ kept.add(h);+ items.push(h);+ if(rowsBuilt&&!h._lDiv)buildRowDom(h,items.length-1); });+ // a header whose phase is gone takes its DOM with it+ old.forEach(h=>{ if(!kept.has(h)){+ if(h._lDiv&&h._lDiv.parentNode)h._lDiv.remove();+ if(h._bDiv&&h._bDiv.parentNode)h._bDiv.remove();+ }}); } // Put each header immediately above the first row of its lane, and an empty lane's // header after the previous lane's block, so the chart reads as one section after@@ -1572,6 +1619,7 @@ function applySnapshot(sn){ } // A list of snapshots applied as one. The inverse is the inverses in reverse, so // undoing a batch and redoing it both land exactly where they started.+ if(sn.type==='phase.state')return restorePhaseState(sn); if(sn.type==='phase.rename'){ const p=phases[sn.i]; if(!p)return null; const inv={type:'phase.rename',i:sn.i,name:p.name};@@ -1840,6 +1888,9 @@ document.addEventListener('keydown',e=>{ return; } if(rowDrag||laneDrag){ endRowDrag(false); endLaneDrag(false); return; }+ // The sections modal owns Escape while it is up; its own handler runs first and+ // stops this one, so reaching here means it is already closed.+ if(secModal){ closeSections(); return; } // Innermost first here too: the half-made dependency goes before the mode that // is making it. if(depMode&&depPending){ setDepPending(null); return; }@@ -2409,8 +2460,10 @@ 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); }+let rowsBuilt=false; function buildRows(){ items.forEach((r,idx)=>buildRowDom(r,idx));+rowsBuilt=true; // Appended after the rows so the dashed rules sit above the phase bands. msLineEls.forEach(ln=>bars.appendChild(ln));@@ -2976,15 +3029,277 @@ function applyPhaseOrder(arr,map){ const nv=phases.map(()=>true); map.forEach((ni,oi)=>{ if(vis[oi]!==undefined)nv[ni]=vis[oi]; }); phaseVis.length=0; phaseVis.push(...nv);+ // The header objects still carry the phase indices they had BEFORE the remap, so+ // they are re-pointed at their phases (by phase uid, which did not move) before+ // the layout runs. Without this the legend reordered and the chart did not.+ buildDerivedHeaders(); placeHeaders(); reflowRows(); refreshSections(); applyFilter(); refreshChartGeometry(); }+// One snapshot type covers create, delete, recolour and lead, because all four+// are "the section list looked like this, and every row pointed where it pointed".+// Capturing both halves together is what makes an undo of a DELETE put the rows+// back in the section they came from rather than leaving them where they were+// reassigned to.+function phaseSnapshot(){+ return {type:'phase.state',+ phases:phases.map(p=>Object.assign({},p)),+ at:items.filter(r=>!isDerivedGroup(r)).map(r=>[r.uid,r.phase]),+ collapsed:[...collapsed], vis:phaseVis.slice()};+}+function restorePhaseState(sn){+ const inv=phaseSnapshot();+ phases.length=0; phases.push(...sn.phases.map(p=>Object.assign({},p)));+ const m=new Map(sn.at);+ items.forEach(r=>{ if(!isDerivedGroup(r)&&m.has(r.uid))r.phase=m.get(r.uid); });+ collapsed.clear(); (sn.collapsed||[]).forEach(x=>collapsed.add(x));+ prefs.collapsed=[...collapsed]; savePrefs();+ phaseVis.length=0; phaseVis.push(...(sn.vis&&sn.vis.length===phases.length?sn.vis:phases.map(()=>true)));+ buildDerivedHeaders(); placeHeaders(); reflowRows();+ refreshSections(); applyFilter(); refreshChartGeometry();+ return inv;+}+// A section is a row-shaped thing, so it gets its id the same way a row does:+// from the server, never minted here. If the call cannot be made the section is+// not created and the modal says so, exactly as the New panel does.+async function createPhase(name,color,lead){+ const uid=await allocUid();+ const sn=phaseSnapshot();+ const p={name,start:'',end:'',uid};+ if(color)p.color=color;+ if(lead)p.lead=lead;+ phases.push(p); phaseVis.push(true);+ pushUndo(sn);+ buildDerivedHeaders(); placeHeaders(); reflowRows();+ refreshSections(); applyFilter(); refreshChartGeometry();+ logPhaseEvent({action:'phase.create',phase:name,to:{uid,color:color||'',lead:lead||''}});+ scheduleSave('section-create');+ return phases.length-1;+}+// Deleting a section never orphans a row. An empty one goes on its own; a+// populated one MUST name where its rows are going, and they are moved before the+// section is removed. Every index above the removed one shifts down by one, which+// is why the item phases, the collapse set and the legend visibility are all+// remapped here rather than anywhere else.+function deletePhase(i,reassignTo){+ const p=phases[i]; if(!p||phases.length<2)return false;+ const rows=items.filter(r=>!isDerivedGroup(r)&&r.phase===i);+ if(rows.length&&(reassignTo===undefined||reassignTo===null||reassignTo===i||!phases[reassignTo]))return false;+ const sn=phaseSnapshot();+ const name=p.name, dest=rows.length?phases[reassignTo].name:null;+ rows.forEach(r=>{ r.phase=reassignTo; });+ const map=new Map();+ phases.forEach((_,oi)=>{ if(oi<i)map.set(oi,oi); else if(oi>i)map.set(oi,oi-1); });+ phases.splice(i,1);+ items.forEach(r=>{ if(!isDerivedGroup(r)&&map.has(r.phase))r.phase=map.get(r.phase); });+ const wasC=[...collapsed]; collapsed.clear();+ wasC.forEach(ph=>{ if(map.has(ph))collapsed.add(map.get(ph)); });+ prefs.collapsed=[...collapsed]; savePrefs();+ const vis=phaseVis.slice(); const nv=phases.map(()=>true);+ map.forEach((ni,oi)=>{ if(vis[oi]!==undefined)nv[ni]=vis[oi]; });+ phaseVis.length=0; phaseVis.push(...nv);+ pushUndo(sn);+ buildDerivedHeaders(); placeHeaders(); reflowRows();+ refreshSections(); applyFilter(); refreshChartGeometry();+ logPhaseEvent({action:'phase.delete',phase:name,count:rows.length,+ to:dest?{section:dest}:undefined});+ scheduleSave('section-delete');+ return true;+}+function recolorPhase(i,color){+ const p=phases[i]; if(!p)return false;+ const from=p.color||'';+ if((color||'')===from)return false;+ pushUndo(phaseSnapshot());+ if(color)p.color=color; else delete p.color;+ items.forEach(r=>{+ if(r.phase!==i)return;+ if(r.id&&barEls[r.id])barEls[r.id].bar.style.background=phaseColor(i);+ if(r._msLine)r._msLine.style.color=phaseColor(i);+ });+ refreshSections();+ logPhaseEvent({action:'phase.color',phase:p.name,from:{color:from},to:{color:color||'(theme ramp)'}});+ scheduleSave('section-color');+ return true;+}+function setPhaseLead(i,lead){+ const p=phases[i]; if(!p)return false;+ const from=p.lead||'';+ if((lead||'')===from)return false;+ pushUndo(phaseSnapshot());+ if(lead)p.lead=lead; else delete p.lead;+ refreshSections();+ logPhaseEvent({action:'phase.lead',phase:p.name,from:{lead:from},to:{lead:lead||''}});+ scheduleSave('section-lead');+ return true;+} function logPhaseEvent(e){ if(SNAPSHOT)return; try{ fetch('log',{method:'POST',headers:{'Content-Type':'application/json'}, body:JSON.stringify({actor:'user',events:[Object.assign({via:'sections'},e)]})}).catch(()=>{}); }catch(_){} } +// ── Sections editor ────────────────────────────────────────────────────────+//+// The modal is a view of phases[] and nothing else: every button calls one of the+// mutation functions above, then the list is rebuilt from phases[]. There is no+// second copy of the section list to fall out of step, which is the whole reason+// unification came first.+//+// A brand-safe palette, plus a constrained hex box for anything else. "Theme" is+// the absence of a colour: it hands the lane back to the --ph ramp, which is the+// only setting that adapts between light and dark. Anything explicit is the colour+// the user picked and stays that colour everywhere, on purpose.+const SEC_PALETTE=[['','Theme'],['#00b8b1','Teal'],['#8c6bf7','Purple'],['#64abff','Blue'],+ ['#3fb950','Green'],['#f0883e','Orange'],['#f85149','Red'],['#8b949e','Slate']];+const HEX_RE=/^#[0-9a-fA-F]{6}$/;+let secModal=null;+function closeSections(){+ if(!secModal)return;+ secModal.remove(); secModal=null;+ document.removeEventListener('keydown',secKeys,true);+}+function secKeys(e){+ if(e.key!=='Escape'||!secModal)return;+ // Innermost first, same stack as everywhere else: an open colour picker or an+ // open name field takes the Escape before the modal does.+ const open=secModal.querySelector('.sec-row.sec-editing');+ if(open){ e.stopPropagation(); e.preventDefault(); renderSections(); return; }+ e.stopPropagation(); e.preventDefault(); closeSections();+}+function laneCount(i){ return items.filter(r=>!isDerivedGroup(r)&&r.phase===i).length; }+function openSections(){+ closeMenus(); closeEditor(); closeSections();+ const back=document.createElement('div'); back.className='sec-back';+ back.innerHTML='<div class="sec-modal" role="dialog" aria-modal="true" aria-label="Sections">'++ '<div class="sec-head"><h2>Sections</h2><span class="sec-hint">the lanes of this plan, in order</span></div>'++ '<div class="sec-list"></div><div class="sec-err" style="display:none"></div>'++ '<div class="sec-foot"><input type="text" id="secNew" placeholder="New section name" maxlength="60">'++ '<button class="de-btn primary" id="secAdd">Add section</button>'++ '<button class="de-btn secondary" id="secDone">Done</button></div></div>';+ document.body.appendChild(back);+ secModal=back;+ back.addEventListener('mousedown',e=>{ if(e.target===back)closeSections(); });+ document.addEventListener('keydown',secKeys,true);+ back.querySelector('#secDone').addEventListener('click',closeSections);+ const add=async()=>{+ const inp=back.querySelector('#secNew'); const nm=inp.value.trim();+ const err=back.querySelector('.sec-err');+ if(!nm){ inp.focus(); return; }+ const btn=back.querySelector('#secAdd'); btn.disabled=true;+ try{ await createPhase(nm,'',''); inp.value=''; err.style.display='none'; renderSections(); }+ catch(e){ err.textContent='No id could be allocated, so no section was created ('+e.message+').'; err.style.display=''; }+ btn.disabled=false;+ };+ back.querySelector('#secAdd').addEventListener('click',add);+ back.querySelector('#secNew').addEventListener('keydown',e=>{ e.stopPropagation(); if(e.key==='Enter')add(); });+ renderSections();+ back.querySelector('#secNew').focus();+}+function renderSections(){+ if(!secModal)return;+ const list=secModal.querySelector('.sec-list');+ list.innerHTML='';+ phases.forEach((p,i)=>{+ const n=laneCount(i);+ const row=document.createElement('div'); row.className='sec-row'; row.dataset.i=String(i);+ row.innerHTML=`<button class="sec-sw" title="Colour" style="background:${esc(phaseColor(i))}"></button>`++ `<span class="sec-name">${esc(p.name)}${p.lead?`<span class="sec-lead">${esc(p.lead)}</span>`:''}</span>`++ `<span class="sec-count">${n} row${n===1?'':'s'}</span>`++ `<span class="sec-acts">`++ `<button class="sec-btn" data-a="up" title="Move up"${i===0?' disabled':''}>↑</button>`++ `<button class="sec-btn" data-a="down" title="Move down"${i===phases.length-1?' disabled':''}>↓</button>`++ `<button class="sec-btn" data-a="edit" title="Rename, or set the aside beside the name">Edit</button>`++ `<button class="sec-btn danger" data-a="del" title="Remove this section"${phases.length<2?' disabled':''}>Remove</button>`++ `</span>`;+ list.appendChild(row);+ });+ list.querySelectorAll('.sec-row').forEach(row=>{+ const i=+row.dataset.i;+ row.querySelector('.sec-sw').addEventListener('click',()=>openSwatch(row,i));+ row.querySelectorAll('.sec-btn').forEach(b=>b.addEventListener('click',()=>{+ const a=b.dataset.a;+ if(a==='up'&&i>0){ reorderPhases(i,i-1); renderSections(); }+ else if(a==='down'&&i<phases.length-1){ reorderPhases(i,i+1); renderSections(); }+ else if(a==='edit') openEdit(row,i);+ else if(a==='del') openDelete(row,i);+ }));+ });+}+function openSwatch(row,i){+ row.classList.add('sec-editing');+ const acts=row.querySelector('.sec-acts');+ const cur=phases[i].color||'';+ const wrap=document.createElement('div');+ wrap.className='sec-pal'; wrap.style.cssText='width:100%;order:9';+ wrap.innerHTML=SEC_PALETTE.map(([hex,nm])=>+ `<button class="sec-chip${(cur||'')===hex?' on':''}" data-hex="${hex}" title="${esc(nm)}" style="background:${hex||'var(--ph'+((i%3)+1)+')'}"></button>`).join('')++ `<input type="text" class="sec-hex" placeholder="#rrggbb" maxlength="7" value="${esc(cur)}" style="width:88px;margin-left:6px">`;+ row.appendChild(wrap);+ wrap.querySelectorAll('.sec-chip').forEach(c=>c.addEventListener('click',()=>{+ recolorPhase(i,c.dataset.hex); renderSections();+ }));+ const hex=wrap.querySelector('.sec-hex');+ hex.addEventListener('keydown',e=>{ e.stopPropagation();+ if(e.key==='Enter'){ const v=hex.value.trim();+ if(v&&!HEX_RE.test(v)){ hex.style.borderColor='#f85149'; return; }+ recolorPhase(i,v); renderSections(); }+ else if(e.key==='Escape'){ renderSections(); } });+ hex.focus();+}+function openEdit(row,i){+ row.classList.add('sec-editing');+ const p=phases[i];+ const wrap=document.createElement('div');+ wrap.style.cssText='display:flex;gap:6px;width:100%;order:9;padding-top:6px';+ wrap.innerHTML=`<input type="text" class="sec-nm" value="${esc(p.name)}" maxlength="60" style="flex:1">`++ `<input type="text" class="sec-ld" value="${esc(p.lead||'')}" maxlength="60" placeholder="aside, e.g. the long pole (Adonis)" style="flex:1">`++ `<button class="de-btn primary sec-ok" style="padding:2px 10px">Save</button>`;+ row.appendChild(wrap);+ const nm=wrap.querySelector('.sec-nm'), ld=wrap.querySelector('.sec-ld');+ const save=()=>{+ const v=nm.value.trim();+ if(v&&v!==p.name)renamePhase(i,v);+ setPhaseLead(i,ld.value.trim());+ renderSections();+ };+ wrap.querySelector('.sec-ok').addEventListener('click',save);+ [nm,ld].forEach(el=>el.addEventListener('keydown',e=>{ e.stopPropagation();+ if(e.key==='Enter')save(); else if(e.key==='Escape')renderSections(); }));+ nm.focus(); nm.select();+}+function openDelete(row,i){+ const n=laneCount(i);+ row.classList.add('sec-editing');+ const wrap=document.createElement('div');+ wrap.style.cssText='display:flex;gap:6px;width:100%;order:9;padding-top:6px;align-items:center;flex-wrap:wrap';+ if(!n){+ wrap.innerHTML=`<span style="font-size:11px;color:var(--text2);flex:1">Remove "${esc(phases[i].name)}"? It has no rows.</span>`++ `<button class="de-btn primary sec-go" style="padding:2px 10px">Remove</button>`++ `<button class="de-btn secondary sec-no" style="padding:2px 10px">Cancel</button>`;+ } else {+ // A populated section cannot just go: its rows have to land somewhere, and the+ // user says where. There is no option that discards them.+ const opts=phases.map((p,j)=>j===i?'':`<option value="${j}">${esc(p.name)}</option>`).join('');+ wrap.innerHTML=`<span style="font-size:11px;color:var(--text2);flex:1 1 100%">`++ `"${esc(phases[i].name)}" holds ${n} row${n===1?'':'s'}. Move ${n===1?'it':'them'} to:</span>`++ `<select class="sec-dest">${opts}</select>`++ `<button class="de-btn primary sec-go" style="padding:2px 10px">Move and remove</button>`++ `<button class="de-btn secondary sec-no" style="padding:2px 10px">Cancel</button>`;+ }+ row.appendChild(wrap);+ wrap.querySelector('.sec-no').addEventListener('click',()=>renderSections());+ wrap.querySelector('.sec-go').addEventListener('click',()=>{+ const sel=wrap.querySelector('.sec-dest');+ const to=sel?parseInt(sel.value,10):undefined;+ if(deletePhase(i,to))renderSections();+ });+}+function wireSectionsButton(){+ const b=$('btnSections');+ if(b)b.addEventListener('click',e=>{ e.stopPropagation(); openSections(); });+}+ // ── Milestone editor ── function openMilestoneEditor(uid){ const r=rowByUid(uid);if(!r||r.type!=='milestone')return;@@ -5340,6 +5655,7 @@ async function init(){ wireBarDblclick(); wireTooltipsAndExport(); wireImportAndHelp();+ wireSectionsButton(); wireThemeButton(); wireMsStyleAndGrain(); wireDepMode();
package.json+1−1@@ -2,7 +2,7 @@ "slug": "adom-gantt", "title": "Adom Gantt", "type": "app",- "version": "0.29.0",+ "version": "0.30.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.29.0",+ "version": "0.30.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]" }-}+}