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.33.0: client applyState now rebuilds the section list from the sidecar (mirrors server mergedData), so section rename/recolour/reorder/create/delete survive a reload; client lanes + item placement now equal /export/json. Fixes the DC plan scramble; reconciled + compacted live. describeEvent gains plan.reconcile.
5 files changed
+90−4
docs/BUILD-NOTES.md+44@@ -3,6 +3,50 @@ Decisions taken while building, and the places where the plan and the code disagreed. Newest first. +## Build notes, 2026-08-17 (0.33.0, the client persists the section list on load)++**The bug.** The client's `applyState` never applied the sidecar's full section+list. It only patched each phase's start/end by index (`state.phases.forEach(sv =>+phases[sv.i]…)`), so a section rename, recolour, reorder, create or delete — all of+which the section editor writes into the sidecar as a full, named `phases` list —+was silently dropped on reload. The chart was rebuilt from the DATA FILE's phases+every time. The server's `mergedData()` had always done the right thing (a sidecar+carrying a named phase list OWNS the sections), so `/export/json` and the client+disagreed. On the DC plan this was stark: Drew had reorganised a "Workcells"+catch-all into four topical lanes and deleted Workcells via the section editor. The+sidecar held the correct six lanes with every item's lane override; the stale data+file still held seven. The client rendered the data file's seven names but applied+the sidecar's 0–5 item indices against them — every item in the wrong lane, a+phantom empty lane, Workcells still showing.++**The fix mirrors the server exactly.** `applyState` now branches the same way+`mergedData()` does: a sidecar whose `phases` entries carry a `name` OWNS the list,+so `phases[]` is rebuilt in place (same field set — name, uid, lead, color, start,+end) and the derived headers, the legend, the row grouping and the lane colours are+re-derived from it (`buildDerivedHeaders` → `placeHeaders` → `reflowRows` →+`refreshSections` → `applyFilter`, the same tail every section mutation and+`restorePhaseState` already run). `phaseVis` is resized to the new lane count. An+older, date-only sidecar (`{i,start,end}` by index, no name) still applies against+the data file's phases exactly as before, so the example files and any+pre-editable-sections sidecar load unchanged; a sidecar with no `phases` key at all+hits the same no-op path. After the fix the client's lane list and every item's lane+equal `/export/json`, and a rename/recolour/reorder/create/delete of a lane survives+a reload — the regression the bug had been hiding.++**`refreshSections` in the tail was not optional.** `buildDerivedHeaders` reuses a+header's DOM by phase uid and only updates the header OBJECT's label; the visible+`.lane-name` text and the lead span are written by `refreshHeaderRow`. Without+`refreshSections` a reloaded rename showed the new name in the legend (rebuilt from+scratch) but the OLD name in the chart header. Caught by the reload test.++**Then the plan was made canonical.** With the client fixed, the latent data-file/+sidecar divergence was compacted away on the live plan: `/compact` wrote+`mergedData()` (the correct six-lane state) as the data file and emptied the sidecar,+after a `plan.reconcile` changelog event (actor Drew Owens) recording that Workcells+was intentionally deleted and every item and its resolved lane are intact. Proven+nothing-lost: identical uid set and identical resolved lane-name per item before and+after, 160 items, 6 lanes. `describeEvent` gained a sentence for `plan.reconcile`.+ ## Build notes, 2026-08-17 (0.32.0, the status pill is editable in the chart) The little status pill next to a task name in the label column ("not started",
gantt.html+40−1@@ -2038,7 +2038,37 @@ function applyState(state){ if(sv.name)r.name=sv.name; if(typeof sv.phase==='number'&&phases[sv.phase])r.phase=sv.phase; });- (state.phases||[]).forEach(sv=>{ const p=phases[sv.i]; if(p){ if(sv.start)p.start=sv.start; if(sv.end)p.end=sv.end; } });+ // The sidecar's phase list, applied EXACTLY as the server's mergedData does, so+ // the client renders what /export/json says. Two shapes. A sidecar written since+ // sections became editable carries the FULL list, every entry named, and it OWNS+ // the section list: it is rebuilt in place so a rename, recolour, reorder, create+ // or delete made in the section editor survives a reload (the render at the end of+ // this function re-derives the headers, the legend and the row grouping from it).+ // An older sidecar (v3/v4 date-only) carries just {i,start,end} patches by index+ // and is applied against the data-file phases, exactly as before — the example+ // files and any pre-editable-sections sidecar keep loading unchanged.+ const sph=state.phases;+ let phasesRebuilt=false;+ if(Array.isArray(sph)&&sph.length&&sph.some(p=>p&&p.name!==undefined)){+ const rebuilt=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;+ });+ phases.length=0; phases.push(...rebuilt);+ // phaseVis is one flag per lane; resize it to the rebuilt list, all visible.+ phaseVis.length=0; phases.forEach(()=>phaseVis.push(true));+ // Relabel/rehome the derived headers to the rebuilt list and drop any whose phase+ // is gone — that DOM removal is what clears the phantom empty lane. Safe here:+ // the chart was built in init() (rowsBuilt), and buildDerivedHeaders reuses a+ // header's DOM by phase uid, so nothing already on screen is orphaned by mistake.+ if(rowsBuilt)buildDerivedHeaders();+ phasesRebuilt=true;+ } else {+ (sph||[]).forEach(sv=>{ const p=phases[sv.i]; if(p){ if(sv.start)p.start=sv.start; if(sv.end)p.end=sv.end; } });+ } // Before the order, always: the saved order was written after these rows were // removed, so applying it first would compare against the wrong length and be // rejected by the anti-shrink guard.@@ -2050,6 +2080,15 @@ function applyState(state){ // 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);+ // A rebuilt phase list changed the sections, their names, leads, order and colours,+ // so re-derive both lenses from phases[], the same tail every section mutation and+ // restorePhaseState run: placeHeaders/reflowRows regroup and recolour the rows (the+ // saved order above may already have, which is idempotent), and refreshSections+ // rewrites each header's name and lead in the DOM (buildDerivedHeaders only reused+ // the header object, not its text) and rebuilds the legend and the table. applyFilter+ // restores lane visibility. Geometry (axis, caps, deps) is left to startApp, which+ // rebuilds the timeline right after this returns.+ if(phasesRebuilt&&rowsBuilt){ placeHeaders(); reflowRows(); refreshSections(); applyFilter(); } } // ── Changelog ── //
package.json+1−1@@ -2,7 +2,7 @@ "slug": "adom-gantt", "title": "Adom Gantt", "type": "app",- "version": "0.32.0",+ "version": "0.33.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.32.0",+ "version": "0.33.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+3@@ -463,6 +463,9 @@ function describeEvent(e) { if (action === 'compact') { return `${who} FLATTENED live edits into the data file: ${e.items || 0} item(s) are now the base and the sidecar is empty. Events above this line describe the plan before the flatten${via}`; }+ if (action === 'plan.reconcile') {+ return `${who} RECONCILED the plan: the section reorganisation is now the data file itself — ${e.phases || 0} lane(s) over ${e.items || 0} item(s). The "Workcells" catch-all was intentionally deleted and its items distributed into their topical lanes; every item and its resolved lane are intact. The client now rebuilds the section list from the sidecar on load, so a rename, recolour, reorder or delete of a lane can no longer drift between what is saved and what is shown${via}`;+ } if (action === 'lane.remove') { const kept = Array.isArray(e.rehomed) && e.rehomed.length ? ` Kept and re-homed to ${e.into || 'another lane'}: ${e.rehomed.join('; ')}.` : ''; const gone = Array.isArray(e.removed) && e.removed.length ? ` Removed: ${e.removed.join('; ')}.` : ' Nothing else was in it.';