← Commit history

0.29.0: sections and phases unified, headers derived from phases[]

Drew Owens ·ced17479f4 · ·parent 13853f8
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]"   }-}\ No newline at end of file+}
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) ──