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.29.0: sections and phases unified, headers derived from phases[]
7 files changed
+379−44
README.md+14−2@@ -107,10 +107,9 @@ Create a JSON file with three sections: "subtitle": "Short description" }, "phases": [- { "name": "Phase 1 — Build", "start": "2026-01", "end": "2026-06" }+ { "name": "Phase 1 — Build", "lead": "the long pole (Adonis)", "start": "2026-01", "end": "2026-06" } ], "items": [- { "type": "group", "label": "Phase 1 — Build", "phase": 0 }, { "type": "task", "id": "t1", "name": "Task name", "start": "2026-01-15", "end": "2026-03-01", "phase": 0, "owner": "Adonis", "status": "planned", "description": "Details.", "deps": ["other-id"] }, { "type": "milestone", "name": "M1: Goal reached", "date": "2026-06-01", "phase": 0, "description": "What this means." } ]@@ -121,6 +120,19 @@ Create a JSON file with three sections: Omit `color` on a phase and it follows the Adom brand ramp, which adapts between dark and print. See [SKILL.md](SKILL.md) for the full field reference and the ramp values. +## Sections are phases++`phases[]` is the whole truth about the sections a plan has. Each one renders its+own header row, in phases order, with its item count, **including a section with+nothing in it yet**. There is no `"group"` item type any more: headers are derived,+so renaming a header renames the phase and the change shows up in the legend, the+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.++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`.+ ## Item ids that do not move Every row carries a `uid`: a zero-padded number, assigned once, that nothing can
SKILL.md+11−2@@ -118,13 +118,22 @@ hero image that invited the click. <!-- prose-lint-disable (literal legend label from the shipped example data) --> | name | string | yes | Legend label (e.g., "Phase 1 — Build") | <!-- prose-lint-enable -->-| color | hex string | no | Overrides the brand ramp for this phase. Omit it to stay theme-aware, see Phase colors below |+| uid | string | assigned | The phase's permanent id, from the same counter rows use. The server writes it |+| lead | string | no | A short aside shown beside the section name, e.g. "the long pole (Adonis)". Secondary text, never the name |+| color | hex string | no | Overrides the brand ramp for this phase. An explicit colour is theme-INVARIANT by design: it is the colour someone chose, so it is the same in light, dark and print. Omit it to stay theme-aware, see Phase colors below | | start | YYYY-MM | yes | Phase start month | | end | YYYY-MM | yes | Phase end month | **items[]** (required). An ordered array, rendered top-to-bottom -Each item has a `type` field: `"group"`, `"task"`, or `"milestone"`.+Each item has a `type` field: `"task"` or `"milestone"`.++**`"group"` is no longer an item type.** Section headers are DERIVED from+`phases[]`, one per phase, in phases order, and are never stored. A data file that+still contains `type: "group"` rows loads fine: each one is absorbed into the phase+it was describing (its aside becomes that phase's `lead`) and dropped as a row. Do+not write group rows; add a phase instead. Exports carry `phases` and no group+items. **type: "group"**: section header row
docs/BUILD-NOTES.md+53@@ -3,6 +3,59 @@ Decisions taken while building, and the places where the plan and the code disagreed. Newest first. +## 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+the data file with no connection to `phases[]` at all, so the four sections added+in August had no header, renaming a header renamed a string nobody read, and the+legend could disagree with the chart (the workaround 0.12.0 wrote down).++**`phases[]` is the single source of truth for sections. A header is a derived+render artifact**, exactly one per phase, in phases order, synthesised at load and+never stored. Phases gained `lead`, the aside the old labels carried, so+"Workcells, the long pole (Adonis)" is now the phase `Workcells` with lead `the+long pole (Adonis)`, rendered as secondary text beside the name.++**Phase identity is `phase.uid`**, from the same counter every row uses.+`item.phase` stays an INDEX in stored form, which keeps the data file and every+export the shape they were, but the index is a storage ENCODING and not the+identity: exactly one function reorders phases and it remaps every `item.phase`,+the collapse set and the legend visibility in one pass, asserting the map is a+bijection before applying any of it. That is what stops the index becoming the+next `#row`.++**Chart order IS phases order, by construction.** Headers used to be placed where+their rows happened to sit, so a phase reorder showed in the legend and not in the+chart. `placeHeaders` now lays the plan out section by section in phases order,+with the saved row order kept WITHIN each section. Lane drag reorders `phases[]`+itself, one undo, one changelog line, and the legend cannot drift because it is a+view of the same array.++**Empty sections show their header with a count of 0**, always. A section you just+made that renders nothing does not feel made.++**Three bugs the tests found, all worth writing down.** Header rows were excluded+from the label-column click handler (`.label-row[data-id], .label-row.ms`), so+click-to-rename did nothing at all. `placeHeaders` derived header ORDER from row+order, which made lane drag look like a scramble. And `applyState`'s order guard+compared the saved order against `items.length` WITH the derived headers counted,+so it rejected every saved order and left the plan silently in data-file order:+the loudest kind of quiet bug, and the reason the order-undo checks failed before+they passed.++**Migration of the live plan.** Three group rows retired (their uids `00001`,+`00115`, `00136` are retired for good, per the uid contract), seven phases given+uids `00165`-`00171` and their leads, the sidecar order dropped from 156 to 153+entries. Every surviving row compared byte-identical, the task and milestone set is+the same set, and the phase names are unchanged.++**Export shape changed on purpose.** JSON carries `phases` with `uid`, `lead` and+`color`, and NO group items; CSV is unchanged in columns since a row's section has+always travelled as `phase` by name. A legacy file with group rows still loads: the+rows are absorbed into the phases they were describing and dropped as rows, on both+the client and the server, so an old data file opens with real headers and its+asides intact.+ ## Build notes, 2026-08-14 (0.28.4, the cap you grab is the milestone that moves) Dragging the "Full dry run done, safety docs complete" cap moved "NSF sign-off on
gantt.html+246−30@@ -511,6 +511,7 @@ 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; }+.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); } .lane-toggle svg { width: 11px; height: 11px; transition: transform 0.15s; }@@ -1154,8 +1155,82 @@ function avatarHtml(u,cls){ } function phaseColor(i){ const p = phases[i];+ // An explicit colour is theme-INVARIANT by design: a lane someone deliberately+ // painted keeps that exact colour in light, dark and print, because they chose+ // a colour and not a slot in a ramp. A lane with no colour of its own follows+ // the theme ramp, which is what makes the default palette adapt for free. return (p && p.color) ? p.color : `var(--ph${(i % 3) + 1})`; }+// ── Sections ───────────────────────────────────────────────────────────────+//+// `phases` is the single source of truth for what sections the plan has. A group+// header row is a DERIVED render artifact: exactly one per phase, in phases[]+// order, synthesised at load and never stored. Before this, headers were seeded+// display items in the data file with no connection to phases at all, so the four+// sections added in August had no header, renaming a header renamed nothing, and+// legend order and chart order could disagree (the 0.12.0 note).+//+// Phase IDENTITY is `phase.uid`, from the same counter every row uses. `item.phase`+// stays an INDEX in stored form, which keeps the data file and every export+// readable and unchanged in shape, but the index is a storage encoding and not the+// identity: exactly one function reorders phases, and it remaps every item.phase+// atomically. That is what keeps the index from becoming the next `#row`.+function phaseUid(i){ const p=phases[i]; return p&&p.uid?p.uid:('#ph'+i); }+function headerUid(i){ return '#hdr-'+phaseUid(i); }+function isDerivedGroup(r){ return !!(r&&r.type==='group'&&r._derived); }+// Lift a legacy data file's group rows into the phases they were describing, then+// drop them as rows. A label like "Phase 1 - Build (Jan-Apr 2026)" carries a name+// and often an aside; the aside becomes `lead`, which renders as secondary text.+function leadFromLabel(raw,name){+ const r=String(raw||'').trim(), nm=String(name||'').trim();+ if(r.toLowerCase().startsWith(nm.toLowerCase())){+ const rest=r.slice(nm.length).trim();+ if(rest.startsWith(','))return rest.slice(1).trim();+ const m=rest.match(/^\((.*)\)$/);+ return m?m[1].trim():rest;+ }+ return r.toLowerCase()===nm.toLowerCase()?'':r;+}+function absorbLegacyGroups(){+ const groups=items.filter(r=>r.type==='group'&&!r._derived);+ groups.forEach(g=>{+ const p=phases[g.phase];+ if(!p)return;+ const raw=groupLabel(g.label||'');+ // "Workcells, the long pole (Adonis)" -> name "Workcells", lead "the long pole (Adonis)"+ // "Logistics (Dan)" -> name "Logistics", lead "Dan"+ if(!p.lead)p.lead=leadFromLabel(raw,p.name);+ });+ for(let i=items.length-1;i>=0;i--) if(items[i].type==='group'&&!items[i]._derived) items.splice(i,1);+ return groups.length;+}+// One header per phase, always, including a phase with nothing in it: an empty+// 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(){+ for(let i=items.length-1;i>=0;i--) if(isDerivedGroup(items[i])) items.splice(i,1);+ phases.forEach((p,i)=>{+ items.push({type:'group',phase:i,label:p.name,lead:p.lead||'',uid:headerUid(i),_derived:true});+ });+}+// 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+// another whatever order the rows themselves are in.+function placeHeaders(){+ const hdrs=items.filter(isDerivedGroup);+ const rest=items.filter(r=>!isDerivedGroup(r));+ const hdrOf=i=>hdrs.find(h=>h.phase===i);+ const out=[];+ phases.forEach((p,i)=>{+ const h=hdrOf(i); if(h)out.push(h);+ rest.forEach(r=>{ if(r.phase===i)out.push(r); }); // saved row order stands within a section+ });+ // a row whose phase index is out of range would otherwise vanish, which is the+ // one way this could lose work: keep it at the end rather than drop it+ const seen=new Set(out);+ rest.forEach(r=>{ if(!seen.has(r))out.push(r); });+ items.length=0; items.push(...out);+} // ── State ── const phaseVis = phases.map(()=>true);@@ -1211,12 +1286,18 @@ function refreshChartGeometry(){ if(prefs.view==='table')return; layoutMsCaps(); sizeTailroom(); drawDeps(); }+// The saved order lists the plan's ROWS. Headers are derived, so they are not in+// it and are re-placed from the phases afterwards; that is what guarantees legend+// order and chart order can never disagree again. function applyOrder(order){ if(!Array.isArray(order))return false; const byKey=orderKeyMap();- const next=order.map(k=>byKey.get(k)).filter(Boolean);- if(next.length!==items.length||new Set(next).size!==items.length)return false;- items.length=0;items.push(...next);+ const real=items.filter(r=>!isDerivedGroup(r));+ const next=order.map(k=>byKey.get(k)).filter(r=>r&&!isDerivedGroup(r));+ if(next.length!==real.length||new Set(next).size!==real.length)return false;+ const hdrs=items.filter(isDerivedGroup);+ items.length=0;items.push(...next,...hdrs);+ placeHeaders(); reflowRows(); drawDeps(); return true;@@ -1491,6 +1572,23 @@ 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.rename'){+ const p=phases[sn.i]; if(!p)return null;+ const inv={type:'phase.rename',i:sn.i,name:p.name};+ p.name=sn.name; refreshSections();+ return inv;+ }+ if(sn.type==='phase.order'){+ const inv={type:'phase.order',order:phases.map(p=>p.uid||p.name)};+ const key=p=>p.uid||p.name;+ const want=sn.order;+ const arr=want.map(k=>phases.find(p=>key(p)===k)).filter(Boolean);+ if(arr.length!==phases.length)return null;+ const map=new Map();+ arr.forEach((p,ni)=>map.set(phases.indexOf(p),ni));+ applyPhaseOrder(arr,map);+ return inv;+ } if(sn.type==='multi'){ const inv=[]; (sn.steps||[]).forEach(st=>{ const i=applySnapshot(st); if(i)inv.unshift(i); });@@ -1790,14 +1888,19 @@ function setProv(r,v){ if(v===undefined||v===null||v===''||(typeof v==='object'& function getState(){ return { v: 4,- items: items.filter(r=>r.uid).map(r=>{+ items: items.filter(r=>r.uid&&!isDerivedGroup(r)).map(r=>{ const o={uid:r.uid,owner:r.owner||'',status:r.status||'',desc:r.description||'',prov:provValue(r),name:r.name,phase:r.phase}; if(r.type==='milestone')o.date=r.date; else { o.s=r.start; o.e=r.end; o.deps=r.deps||[]; o.note=r.note||''; } return o; }),- phases: phases.map((p,i)=>({i,start:p.start,end:p.end})),- order: items.map(rowOrderKey),+ // The whole section list, not just date patches by index. Sections are data+ // the user edits now (rename, reorder, and soon colour and lead), so the+ // sidecar has to carry what they are, not only when they run. `i` stays for a+ // reader written against the older shape.+ phases: phases.map((p,i)=>{const o={i,name:p.name,start:p.start,end:p.end};+ if(p.uid)o.uid=p.uid; if(p.lead)o.lead=p.lead; if(p.color)o.color=p.color; return o;}),+ order: items.filter(r=>!isDerivedGroup(r)).map(rowOrderKey), deleted: [...deletedKeys], added: addedItems.map(r=>{const o={}; // `uid` is in this list because it is not optional. A created row lives only@@ -1850,7 +1953,11 @@ function applyState(state){ if(Array.isArray(state.deleted)){ state.deleted.forEach(k=>{ const r=items.find(x=>rowOrderKey(x)===k); if(r)removeRow(r,true); }); }- if(Array.isArray(state.order)&&state.order.length===items.length)applyOrder(state.order);+ // Against the ROW count, not items.length: headers are derived and are not in+ // the saved order, so comparing with them included made this guard reject every+ // saved order outright and silently leave the plan in data-file order.+ const rowCount=items.filter(r=>!isDerivedGroup(r)).length;+ if(Array.isArray(state.order)&&state.order.length===rowCount)applyOrder(state.order); } // ── Changelog ── //@@ -2102,8 +2209,25 @@ function flash(el,text,ms){ el.textContent=text; showToast(el,ms); } // ── Legend ── const legendEl = $('legend');+function phaseChip(ph,i){+ const el=document.createElement('div');el.className='legend-item phase-chip';+ el.title=(ph.name||'')+(ph.lead?', '+ph.lead:'');+ el.innerHTML=`<div class="legend-dot" style="background:${esc(phaseColor(i))}"></div><span class="chip-label">${esc(ph.name)}</span>`;+ if(!phaseVis[i])el.classList.add('off');+ el.addEventListener('click',()=>{phaseVis[i]=!phaseVis[i];el.classList.toggle('off',!phaseVis[i]);applyFilter();});+ return el;+} 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);});+phases.forEach((ph,i)=>legendEl.appendChild(phaseChip(ph,i)));+}+// The legend is a view of phases[], so it is rebuilt from phases[] rather than+// patched: a rename, a recolour, a new section and a reorder are all the same+// operation to it. The chips sit at the FRONT, ahead of the milestone, deps and+// today chips and the table tools, which is where they were built.+function rebuildLegend(){+ legendEl.querySelectorAll('.phase-chip').forEach(el=>el.remove());+ const first=legendEl.firstChild;+ phases.forEach((ph,i)=>legendEl.insertBefore(phaseChip(ph,i),first)); } const msL=document.createElement('div'); function buildLegendMilestone(){@@ -2241,7 +2365,10 @@ function buildRowDom(r,idx){ const lDiv=document.createElement('div'),bDiv=document.createElement('div'); if(r.type==='group'){ lDiv.className='label-row group';- const nm=document.createElement('span');nm.className='lane-name';nm.textContent=groupLabel(r.label);+ const nm=document.createElement('span');nm.className='lane-name';+ nm.textContent=isDerivedGroup(r)?(phases[r.phase]?phases[r.phase].name:r.label):groupLabel(r.label);+ const ld=isDerivedGroup(r)?((phases[r.phase]&&phases[r.phase].lead)||''):'';+ if(ld){ const sp=document.createElement('span'); sp.className='lane-lead'; sp.textContent=ld; nm.appendChild(sp); } const btn=document.createElement('button');btn.className='lane-toggle';btn.type='button'; btn.innerHTML='<span class="lane-count"></span><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 9l6 6 6-6"/></svg>'; lDiv.appendChild(nm);lDiv.appendChild(btn);@@ -2609,7 +2736,7 @@ labelsEl.addEventListener('click',e=>{ // the next real click. if(Date.now()-dragEndedAt<150)return; if(e.target.closest('.lane-toggle')||e.target.closest('.row-edit'))return;- const row=e.target.closest('.label-row[data-id], .label-row.ms');+ const row=e.target.closest('.label-row[data-id], .label-row.ms, .label-row.group'); // Empty space in the label column is a click on nothing, which is how every list // in the world drops a selection. if(!row){ if(!e.ctrlKey&&!e.metaKey&&!e.shiftKey){ closeLabelEditors(true); clearSelection(); } return; }@@ -2669,16 +2796,26 @@ function editLabelInline(r){ if(!lDiv||lDiv.querySelector('.label-edit'))return; closeLabelEditors(true); // commit whatever else was open, never orphan it const inp=document.createElement('input');- inp.type='text';inp.className='label-edit';inp.value=r.name||'';+ // A header IS its phase now, so the field edits the phase name and the rename+ // flows to the legend, the table's phase column and every phase dropdown.+ const isHdr=isDerivedGroup(r);+ inp.type='text';inp.className='label-edit';+ inp.value=isHdr?((phases[r.phase]&&phases[r.phase].name)||''):(r.name||''); let done=false; const restore=()=>{- if(r.type==='milestone')refreshMsChrome(r); else refreshLabelRow(r);+ if(isDerivedGroup(r))refreshHeaderRow(r);+ else if(r.type==='milestone')refreshMsChrome(r); else refreshLabelRow(r); applyCollapse(); }; const finish=save=>{ if(done)return;done=true; const v=inp.value.trim(); try{inp.blur();}catch(e){}+ if(isHdr){+ const p=phases[r.phase];+ if(save&&v&&p&&v!==p.name){ renamePhase(r.phase,v); } else restore();+ return;+ } if(save&&v&&v!==r.name){ if(r.type==='milestone')snapshotMs(r); else snapshot(r.id); r.name=v;@@ -2774,6 +2911,80 @@ bars.addEventListener('click',e=>{ 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();}); } +// ── Sections: the only places phases[] is mutated ──────────────────────────+//+// Every one of these rebuilds the header row, the legend and the table, so a+// section change lands in both lenses at once and there is no second copy of the+// truth to drift.+function refreshHeaderRow(r){+ if(!r||!r._lDiv)return;+ const p=phases[r.phase]; if(!p)return;+ const nm=r._lDiv.querySelector('.lane-name');+ if(nm){+ nm.textContent=p.name||'';+ if(p.lead){ const sp=document.createElement('span'); sp.className='lane-lead'; sp.textContent=p.lead; nm.appendChild(sp); }+ }+ r.label=p.name; r.lead=p.lead||'';+}+function refreshSections(){+ items.filter(isDerivedGroup).forEach(refreshHeaderRow);+ rebuildLegend();+ applyCollapse();+ // Every phase dropdown (the New panel, the table's phase cell) is built from+ // phases[] at the moment it opens, so there is nothing else to keep in step.+ if(prefs.view==='table')renderTable();+}+function renamePhase(i,name){+ const p=phases[i]; if(!p||!name||name===p.name)return false;+ const from=p.name;+ pushUndo({type:'phase.rename',i,name:from});+ p.name=name;+ refreshSections();+ logPhaseEvent({action:'phase.rename',phase:name,from:{name:from},to:{name}});+ scheduleSave('section-rename');+ return true;+}+// A phases[] reorder is the one operation that moves every item's stored phase+// index, so it is written once, here, and it remaps in a single pass: build the+// new array, build old-index -> new-index from it, then rewrite every row. The+// mapping is asserted to be a bijection before anything is applied, because a+// half-applied phase remap is the restructure hazard all over again.+function reorderPhases(from,to){+ if(from===to||!phases[from]||to<0||to>=phases.length)return false;+ const before=phases.map(p=>p.uid||p.name);+ const arr=phases.slice();+ const [moved]=arr.splice(from,1);+ arr.splice(to,0,moved);+ const map=new Map();+ arr.forEach((p,ni)=>{ const oi=phases.indexOf(p); map.set(oi,ni); });+ if(map.size!==phases.length||new Set(map.values()).size!==phases.length)return false;+ pushUndo({type:'phase.order',order:before});+ applyPhaseOrder(arr,map);+ logPhaseEvent({action:'phase.reorder',phase:moved.name,from:{i:from},to:{i:to}});+ scheduleSave('section-reorder');+ return true;+}+function applyPhaseOrder(arr,map){+ phases.length=0; phases.push(...arr);+ items.forEach(r=>{ if(r.phase!==undefined&&map.has(r.phase))r.phase=map.get(r.phase); });+ // the collapse set is keyed by phase index, so it travels with the remap+ const wasCollapsed=[...collapsed];+ collapsed.clear();+ wasCollapsed.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);+ placeHeaders();+ reflowRows(); refreshSections(); applyFilter(); refreshChartGeometry();+}+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(_){}+}+ // ── Milestone editor ── function openMilestoneEditor(uid){ const r=rowByUid(uid);if(!r||r.type!=='milestone')return;@@ -3334,8 +3545,13 @@ function clientMergedData(){ const clean=r=>{const o={};FIELDS.forEach(k=>{if(r[k]!==undefined)o[k]=r[k];}); if(o.status)o.status=statusKey(o.status); // the canonical vocabulary, as the server normalises on the way out return o;};- const phs=phases.map(p=>{const o={name:p.name,start:p.start,end:p.end};if(p.color)o.color=p.color;return o;});- return {project:Object.assign({},project),phases:phs,items:items.map(clean)};+ // Sections travel in `phases`, which now carries name, lead and colour. Group+ // ROWS are gone from the export shape: they were never data, and a reader that+ // wants the section a row belongs to has always used `phase`.+ const phs=phases.map(p=>{const o={name:p.name,start:p.start,end:p.end};+ if(p.uid)o.uid=p.uid; if(p.lead)o.lead=p.lead; if(p.color)o.color=p.color; return o;});+ return {project:Object.assign({},project),phases:phs,+ items:items.filter(r=>!isDerivedGroup(r)).map(clean)}; } // Byte-for-byte the server's itemsToCSV, because a CSV that round-trips has to // re-import cleanly and the two must not drift. Kept client-side so the CSV export@@ -4269,22 +4485,17 @@ function endLaneDrag(commit){ hideDropIndicators(); (d.block?d.block.rows:[]).forEach(r=>{if(r._lDiv)r._lDiv.classList.remove('dragging');if(r._bDiv)r._bDiv.classList.remove('dragging');}); if(!commit||!d.armed||d.targetGroup==null||d.targetGroup===d.g)return;- const blk=laneBlock(d.g); if(!blk)return;- snapshotOrder(); // one undo for the whole lane- const rows=blk.rows;- const rest=items.filter(r=>!rows.includes(r));- const tb=laneBlock(d.targetGroup);- let at=rest.indexOf(d.targetGroup);- if(at<0)at=rest.length;- else if(!d.before){ // after the target lane's whole block- const tRows=tb?tb.rows:[d.targetGroup];- at=rest.indexOf(tRows[tRows.length-1])+1;- }- rest.splice(at,0,...rows);- items.length=0;items.push(...rest);- reflowRows();applyFilter();refreshChartGeometry();renderTable();- moveHint='the '+groupLabel(d.g.label)+' lane';- scheduleSave('drag-lane');+ // Dragging a lane now moves the SECTION, not just its rows: phases[] is+ // reordered and every item.phase is remapped in the same pass, so the legend+ // and the chart cannot disagree about section order. That retires the+ // disagreement 0.12.0 wrote down and worked around.+ const from=d.g.phase, tgt=d.targetGroup.phase;+ let to=tgt;+ if(!d.before&&from>tgt)to=tgt+1;+ else if(d.before&&from<tgt)to=tgt-1;+ if(to<0)to=0; if(to>=phases.length)to=phases.length-1;+ const name=phases[from]?phases[from].name:'';+ if(reorderPhases(from,to)) moveHint='the '+name+' section'; } function wireLaneDrag(){ labelsEl.addEventListener('mousedown',e=>{@@ -5088,6 +5299,11 @@ if(UNDATED.length){ // 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(){+ // Sections first: a legacy file's group rows become phase annotations and stop+ // being rows, then exactly one derived header per phase takes their place.+ absorbLegacyGroups();+ buildDerivedHeaders();+ placeHeaders(); assignRowKeys(); dropUndatedRows(); clampFontScalePref();
package.json+1−1@@ -2,7 +2,7 @@ "slug": "adom-gantt", "title": "Adom Gantt", "type": "app",- "version": "0.28.4",+ "version": "0.29.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.28.4",+ "version": "0.29.0", "description": "Interactive Gantt chart viewer for project timelines, with a table view over the same data. Data-driven, dark by default with a print-friendly light mode, and fully editable in the browser: drag bars to move or resize, multi-select and batch-shift dates, manage dependency arrows inline, reorder rows, filter by phase, and undo. Toggle to the table for every task and milestone as a row with owner, deadline and status, filtered per owner and edited in place. Every edit is recorded in an append-only changelog so an AI can read what a human changed and build on it. Imports JSON, CSV, MS Project XML and Jira CSV; exports JSON, CSV, SVG and a self-contained HTML snapshot.", "main": "server.js", "bin": {@@ -63,4 +63,4 @@ "name": "Drew Owens", "email": "[email protected]" }-}+}
server.js+52−7@@ -982,10 +982,40 @@ function ensureStateV4() { return { ok: true, upgraded: true, from: next.upgradedFrom, stateRev: next.stateRev }; } +const GROUP_DATE_RE_S = /\s*\((?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[^)]*\d{4}[^)]*\)\s*$/;+// What every reader of the merged plan sees, whether or not there is a sidecar.+// This used to live at the bottom of mergedData, which has an early return for the+// no-sidecar case, so a plain data file skipped the lot.+function finaliseMerged(data) {+ // The merged plan is the app's canonical output, so it speaks the canonical+ // vocabulary. Stored data is never touched: this normalises on the way OUT.+ data.items.forEach(r => { if (r.status) r.status = statusKey(r.status); });+ // Sections live in `phases`. A group ROW is a derived header now, not data, so a+ // legacy file's group rows are lifted into the phase they were describing and+ // dropped from the item list. Every export therefore carries phases (with their+ // lead text) and no group items: a deliberate change to the export shape, since+ // a reader that wants a row's section has always used `phase`.+ const groups = data.items.filter(r => r && r.type === 'group');+ if (groups.length) {+ groups.forEach(g => {+ const p = data.phases[g.phase];+ if (!p || p.lead) return;+ const raw = String(g.label || '').replace(GROUP_DATE_RE_S, '').trim();+ const nm = String(p.name || '').trim();+ if (raw.toLowerCase().startsWith(nm.toLowerCase())) {+ const rest = raw.slice(nm.length).trim();+ if (rest.startsWith(',')) p.lead = rest.slice(1).trim();+ else { const m2 = rest.match(/^\((.*)\)$/); p.lead = m2 ? m2[1].trim() : rest; }+ } else if (raw.toLowerCase() !== nm.toLowerCase()) p.lead = raw;+ });+ data.items = data.items.filter(r => !(r && r.type === 'group'));+ }+ return data;+} function mergedData() { const data = loadData(); const state = upgradeState(loadState(), data);- if (!state) return data;+ if (!state) return finaliseMerged(data); // Rows created in the app live only in the sidecar, so every reader of the // merged plan (json, csv, svg, html) has to fold them back in. Appended; the // saved order puts them where they belong.@@ -1030,10 +1060,26 @@ function mergedData() { if (sv.name) r.name = sv.name; if (typeof sv.phase === 'number' && data.phases[sv.phase]) r.phase = sv.phase; });- (state.phases || []).forEach(sv => {- const p = data.phases[sv.i];- if (p) { if (sv.start) p.start = sv.start; if (sv.end) p.end = sv.end; }- });+ // Two shapes. A sidecar written since sections became editable carries the FULL+ // list, every entry with a name, and it owns the sections outright: that is what+ // lets a rename, a recolour and a reorder persist without ever rewriting the data+ // file. An older sidecar carries only {i, start, end} date patches by index and+ // is applied against the data file's phases exactly as before.+ const sph = state.phases;+ if (Array.isArray(sph) && sph.length && sph.some(p => p && p.name !== undefined)) {+ data.phases = sph.map(p => {+ const o = { name: p.name, start: p.start || '', end: p.end || '' };+ if (p.uid) o.uid = p.uid;+ if (p.lead) o.lead = p.lead;+ if (p.color) o.color = p.color;+ return o;+ });+ } else {+ (sph || []).forEach(sv => {+ const p = data.phases[sv.i];+ if (p) { if (sv.start) p.start = sv.start; if (sv.end) p.end = sv.end; }+ });+ } if (Array.isArray(state.order) && state.order.length === data.items.length) { const reordered = state.order.map(u => byUid.get(u)).filter(r => r && data.items.includes(r)); // All or nothing. A single unresolved uid would otherwise drop a row.@@ -1045,8 +1091,7 @@ function mergedData() { // vocabulary. Stored data is never touched: this normalises on the way OUT, so // /export/json, /export/csv, the SVG and the HTML snapshot all agree, and a // sidecar still saying "live" keeps saying it until the user next saves.- data.items.forEach(r => { if (r.status) r.status = statusKey(r.status); });- return data;+ return finaliseMerged(data); } // ── CSV Parsing (RFC 4180 compliant) ──