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.28.0: export reflects client state (all 4 formats, even while save-blocked); conflict banner gains Download my version + force-save
6 files changed
+216−26
README.md+9−6@@ -44,7 +44,7 @@ Interactive Gantt chart viewer for project timelines. Data-driven, dark by defau **Import.** JSON (native format), CSV, MS Project XML, and Jira CSV. Drag-and-drop or file picker. A file with rows that carry no usable date is rejected whole, naming the rows, rather than having a date invented for it. A row that cannot be dated is left out of the chart with a warning instead of blanking the plan. -**Export.** JSON, CSV, SVG, and self-contained HTML (no server needed to view the snapshot). CSV carries the phase by name, so an export re-imported keeps its phases, and any cell opening with a formula character is apostrophe-guarded so a spreadsheet cannot execute it. Import strips the guard back off.+**Export.** JSON, CSV, SVG, and self-contained HTML (no server needed to view the snapshot). The Export button serialises what is on your screen right now, not what is on disk, so it reflects unsaved edits and keeps working even when a save conflict has stopped autosave. CSV carries the phase by name, so an export re-imported keeps its phases, and any cell opening with a formula character is apostrophe-guarded so a spreadsheet cannot execute it. Import strips the guard back off. **Auto-save.** Edits persist to a sidecar JSON file next to the data file, debounced while you work and flushed immediately if you close or hide the tab, so the changelog never claims an edit the sidecar did not get. Delete the sidecar to reset. Every row in it is keyed by its permanent `uid`, so renaming a row, reordering the plan, or inserting a row into the middle of the data file all leave every saved edit pointing exactly where it was. Sidecars written by older versions keyed rows by position; they are translated on the way in and keep working. The saved row order is applied all-or-nothing, so a key that no longer resolves can never drop a row. @@ -187,7 +187,10 @@ of what was lost is the changelog. There is no merge and no operational transfor What the app does do is refuse to be the one that erases you silently. Every save carries the revision it was based on; if the plan moved since you loaded it, the save is refused with a 409, your tab stops autosaving so it cannot clobber anything, and-you are told, with a Reload button. Your own edits stay on screen until you decide.+you are told. Your own edits stay on screen until you decide, and the banner gives+you three ways out: **Download my version** writes your in-memory plan to a JSON file+so nothing is ever trapped behind the banner, **Save mine anyway** force-overwrites+the other change (behind a confirm click), and **Reload** discards yours for theirs. The practical advice is unchanged: for now, edit one at a time, or reload before a long editing run. @@ -251,10 +254,10 @@ table, the item editor or the batch editor are taken exactly as typed. | `/import/msproject-xml` | POST | Import MS Project XML | | `/import/jira-csv` | POST | Import Jira CSV | | `/compact` | POST | Bake saved edits into the data file and empty the sidecar |-| `/export/json` | GET | Export merged JSON |-| `/export/csv` | GET | Export CSV |-| `/export/svg` | GET | Export SVG |-| `/export/html` | GET | Export self-contained HTML |+| `/export/json` | GET or POST | Export merged JSON. GET renders the plan on disk (the AI/loopback loop); POST renders the client model in the body, which is how the Export button reflects unsaved edits |+| `/export/csv` | GET or POST | Export CSV, same GET/POST split |+| `/export/svg` | GET or POST | Export SVG, same GET/POST split |+| `/export/html` | GET or POST | Export self-contained HTML, same GET/POST split | ## Examples
docs/BUILD-NOTES.md+59@@ -3,6 +3,65 @@ Decisions taken while building, and the places where the plan and the code disagreed. Newest first. +## Build notes, 2026-08-12 (0.28.0, the export is your escape hatch)++Drew hit the save-conflict banner, went to Export to rescue his edits, and the+file that came down did not have them. Two facts lined up into a trap. The banner+sets `saveBlocked` and stops autosave, so his edits lived only in the tab. And+Export was an `<a href="export/json">`, a GET the SERVER answers from disk, so it+returned the plan WITHOUT the edits he was trying to save. Reload would have+thrown them away. The work was one wrong click from gone.++**The export button now serialises the client's own state.** JSON and CSV are+built in the browser from the in-memory model, so they need no server at all and+work offline. SVG and HTML are rendered by POSTing that same client model to the+server, which reflects exactly what is on screen rather than what is on disk. The+`clientMergedData()` that feeds all four is the merged plan (project, phases,+items in display order, real fields only), not `getState()`'s delta: an export is+the whole plan, and this reads the model the app is rendering, conflict or no.++**The server `/export/*` GETs are untouched.** They still answer from disk, which+is what the AI handoff loop reads over loopback, and the fingerprint proves it:+0.27.0 and 0.28.0 fingerprint identical on the same data except the version+string, `csvHead` and `svgLen` byte-for-byte the same. The POST forms are new and+additive; a malformed POST body falls back to disk rather than 400ing away+someone's only copy.++**The conflict banner grew an escape hatch that cannot fail.** Next to Reload it+now offers **Download my version**, which writes the in-memory plan to a JSON file+with one click, so nothing is ever trapped behind the banner. And **Save mine+anyway**, a force-save that posts the client state with NO baseRev, bypassing the+conflict guard the same way `flushSave` already does on unload; it is armed by a+first click that reads "Confirm overwrite" for four seconds, because it overwrites+the other person's change, and on success the tab adopts the new revision so+autosave resumes rather than re-prompting on every keystroke.++**The blocked-export proof, run end to end in a real headless session.** A second+save bumped the server's revision out from under the tab; an in-memory rename was+made and left unsaved; the autosave fired and 409'd, raising the banner. Then:++| Check | Result |+|---|---|+| Export JSON while blocked contains the unsaved edit | yes, all four formats |+| The server's own GET export lacks it | yes (proves client-state, not a round trip) |+| Banner shows Download my version and Save mine anyway | yes |+| Download my version hands over the un-saveable edit | yes |+| Force-save arms ("Confirm overwrite") then writes the edit to the server | yes |+| Unblocked client JSON matches the server's GET export | yes, item-for-item, same 7 phases |++**No behavioural regression.** The 55-check keyboard sweep, 35-step column+battery, 40-key dependency suite and the uid and milestone-selection suites are+unchanged against 0.27.0 on the same plan (the two b2 lane-crossing checks and one+column-class check that read falsy do so identically on the untouched 0.27.0+client with this data: they are the check reacting to a 7-lane plan, not this+change). init-shape invariant holds.++A separate data operation the same day appended four empty lanes to the DC plan+(Probing Workcell, Laser Workcell, Drone Project, Nameplates) and applied one+pending rename, straight into the data file and sidecar with a stateRev bump; no+code involved, and the section EDITOR that will let a person do this from the UI+is the next round, held for a go-ahead.+ ## Build notes, 2026-08-12 (0.27.0, the plan is keyed by uid) 0.26.0 put a uid on every row and used it for one thing, the copy button.
gantt.html+112−9@@ -2035,13 +2035,52 @@ function saveToFile(){ function onSaveConflict(d){ saveBlocked=true; clearTimeout(undoTimer); clearTimeout(undoToast.__t); // this one waits for a decision- undoToast.innerHTML='Someone else changed this plan. Your edits are not being saved. '+- '<button type="button" id="reloadPlan" class="de-btn primary" style="margin-left:8px;padding:2px 10px">Reload</button>';+ // Three ways out, and the safe one is always available. Before this, a blocked+ // user's only choices were Reload (throw my edits away) and Export (which pulled+ // the SERVER's stale state, silently omitting the very edits they were trying to+ // keep). Download my version writes the in-memory plan to a file, so nothing is+ // ever trapped behind the banner. Save mine anyway force-writes over the other+ // change, behind a confirm click. Reload is unchanged.+ undoToast.innerHTML='Someone else changed this plan, so your edits are not being saved. '++ '<button type="button" id="dlMine" class="de-btn secondary" style="margin-left:8px;padding:2px 10px">Download my version</button>'++ '<button type="button" id="forceMine" class="de-btn secondary" style="margin-left:6px;padding:2px 10px">Save mine anyway</button>'++ '<button type="button" id="reloadPlan" class="de-btn primary" style="margin-left:6px;padding:2px 10px">Reload</button>'; undoToast.classList.add('show');- const b=$('reloadPlan');- if(b)b.addEventListener('click',()=>location.reload());+ const dl=$('dlMine');+ if(dl)dl.addEventListener('click',()=>{ doExport('json'); flash(saveInd,'downloaded your version',4000); });+ const rl=$('reloadPlan');+ if(rl)rl.addEventListener('click',()=>location.reload());+ // Two clicks, because it overwrites someone else's change. The first arms it and+ // says so; a second click inside four seconds commits; otherwise it disarms.+ const fm=$('forceMine');+ if(fm)fm.addEventListener('click',()=>{+ if(fm.dataset.armed!=='1'){+ fm.dataset.armed='1'; fm.textContent='Confirm overwrite'; fm.classList.remove('secondary'); fm.classList.add('primary');+ setTimeout(()=>{ if(fm&&fm.dataset.armed==='1'){ fm.dataset.armed='0'; fm.textContent='Save mine anyway'; fm.classList.remove('primary'); fm.classList.add('secondary'); } },4000);+ return;+ }+ forceSaveMine();+ }); flash(saveInd,'not saved, plan changed elsewhere',5000); }+// Overwrite whatever is on the server now with this tab's state. Posting with no+// baseRev bypasses the conflict guard exactly the way flushSave already does on+// unload: the guard only fires when a baseRev is present and stale. On success the+// tab adopts the new revision and autosave resumes, so the user is not stuck+// re-confirming after every keystroke.+function forceSaveMine(){+ const payload=getState(); // deliberately NO baseRev+ fetch('save',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)})+ .then(async r=>{+ const d=await r.json().catch(()=>({}));+ if(!r.ok)throw new Error('server '+r.status);+ if(typeof d.stateRev==='number')baseRev=d.stateRev;+ saveBlocked=false;+ undoToast.classList.remove('show');+ flash(saveInd,'saved your version, overwriting the other change',5000);+ })+ .catch(()=>flash(saveInd,'could not force-save, try Download my version',4000));+} // Every toast's timer lives here so hover can pause it. Gone-before-you-noticed was // half duration and half that a toast you are reading keeps counting down; pointing // at one now stops the clock until you look away, which is what every toast in every@@ -3285,12 +3324,76 @@ $('btnExport').addEventListener('click',e=>{ }); } -function doExport(fmt){- const url = `export/${fmt}`;- const a = document.createElement('a');- a.href = url;- a.download = `${project.title.replace(/[^a-zA-Z0-9]/g,'-').toLowerCase()}.${fmt==='html'?'html':fmt}`;+// The plan as the user sees it RIGHT NOW, in the shape /export/json returns from+// disk: project, phases, items in display order with only their real fields. The+// point is that this reads the in-memory model, not the server, so an export+// reflects unsaved edits and keeps working when a save conflict has blocked the+// sidecar. `getState()` is a delta for the server to merge; this is the whole+// merged plan, which is what an export is.+function clientMergedData(){+ const FIELDS=['type','uid','id','name','label','start','end','date','phase','owner','status','description','deps','note','striped','provenance'];+ 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)};+}+// 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+// works with no server, same as JSON.+const CSV_FORMULA_LEAD=/^[=+\-@\t\r]/;+function csvGuardCell(s){return CSV_FORMULA_LEAD.test(s)?"'"+s:s;}+function clientItemsToCSV(itemsArr,phasesArr){+ const headers=['uid','id','name','start','end','phase','owner','status','description','provenance','provenance_url','deps','type','label','date','striped'];+ const cell=v=>{if(v==null)return '';const s=csvGuardCell(String(v));+ return (s.includes(',')||s.includes('"')||s.includes('\n')||s.includes('\r'))?'"'+s.replace(/"/g,'""')+'"':s;};+ const phaseName=i=>{const p=phasesArr&&phasesArr[i];return p&&p.name?p.name:(i==null?'':i);};+ const lines=[headers.join(',')];+ itemsArr.forEach(item=>{+ lines.push(headers.map(h=>{+ if(h==='deps')return cell((item.deps||[]).join(';'));+ if(h==='phase')return cell(phaseName(item.phase));+ if(h==='status')return cell(statusKey(item.status));+ if(h==='provenance'){const p=item.provenance;return cell(typeof p==='string'?p:(p&&p.label)||'');}+ if(h==='provenance_url'){const p=item.provenance;return cell((p&&typeof p==='object'&&p.url)||'');}+ return cell(item[h]);+ }).join(','));+ });+ return lines.join('\n');+}+function downloadBlob(blob,fname){+ const url=URL.createObjectURL(blob);+ const a=document.createElement('a');a.href=url;a.download=fname; document.body.appendChild(a);a.click();a.remove();+ setTimeout(()=>URL.revokeObjectURL(url),1500);+}+// The export BUTTON serialises the client's current state. JSON and CSV are built+// here and need no server at all. SVG and HTML are rendered by POSTing that same+// client model to the server, so they reflect what is on screen rather than what+// is on disk, which is the whole fix: a user whose save is conflict-blocked can+// still get their in-memory edits out. The server /export GETs are untouched and+// still serve disk state for the AI handoff loop.+async function doExport(fmt){+ const fname=`${project.title.replace(/[^a-zA-Z0-9]/g,'-').toLowerCase()}.${fmt==='html'?'html':fmt}`;+ try{+ let blob;+ if(fmt==='json'){+ blob=new Blob([JSON.stringify(clientMergedData(),null,2)],{type:'application/json'});+ }else if(fmt==='csv'){+ const m=clientMergedData();+ blob=new Blob([clientItemsToCSV(m.items,m.phases)],{type:'text/csv'});+ }else{+ const m=clientMergedData();+ const r=await fetch('export/'+fmt,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(m)});+ if(!r.ok)throw new Error('the server answered '+r.status);+ const text=await r.text();+ blob=new Blob([text],{type:fmt==='svg'?'image/svg+xml':'text/html'});+ }+ downloadBlob(blob,fname);+ flash(saveInd,'exported '+fmt.toUpperCase());+ }catch(err){+ flash(saveInd,'export failed: '+err.message,4500);+ } } // ── Import menu ──
package.json+1−1@@ -2,7 +2,7 @@ "slug": "adom-gantt", "title": "Adom Gantt", "type": "app",- "version": "0.27.0",+ "version": "0.28.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.27.0",+ "version": "0.28.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+33−8@@ -1467,8 +1467,8 @@ function svgPhaseColor(p, i) { return (p && p.color && !/^var\(/.test(p.color)) ? p.color : SVG_PHASE_RAMP[i % SVG_PHASE_RAMP.length]; } -function generateSVG() {- const data = mergedData();+function generateSVG(dataArg) {+ const data = dataArg || mergedData(); const { project, phases } = data; // Imports reject undateable rows, but a hand-edited data file never goes through@@ -1575,6 +1575,10 @@ function generateSVG() { // Phase backgrounds phases.forEach((p, pi) => {+ // An empty lane the user just created has no dated range yet, and a phase with+ // no start used to throw here and 500 the whole export. Skip it: no items, no+ // band. Same guard the phase-band pass above already applies.+ if (!p || !DATE_RE.test(String(p.start || '')) || !DATE_RE.test(String(p.end || ''))) return; const [sy, sm] = p.start.split('-').map(Number); const [ey, em] = p.end.split('-').map(Number); const x1 = LABEL_W + PAD + mDiff(START, new Date(sy, sm - 1, 1)) * COL;@@ -1639,9 +1643,9 @@ function generateSVG() { // ── Export: HTML snapshot ── -function generateHTMLSnapshot(user) {+function generateHTMLSnapshot(user, dataArg) { const ganttHtml = serveApp(user);- const data = mergedData();+ const data = dataArg || mergedData(); // Replace the fetch('data') call with inline data. // The original code: const projData = await fetch('data').then(r => r.json());@@ -2017,8 +2021,29 @@ const server = http.createServer(async (req, res) => { // ── Export endpoints ── + // The four exports render from the merged plan on disk on a GET, which is what+ // the AI handoff loop reads. On a POST they render from the body instead: the+ // browser's Export button sends the LIVE client model, so what downloads is+ // exactly what the user sees, even when their edits are conflict-blocked and+ // not on disk. A malformed body falls back to disk rather than 400ing away+ // someone's only copy of their work.+ async function exportData() {+ if (req.method !== 'POST') return mergedData();+ const body = await readBody(req);+ try {+ const d = JSON.parse(body);+ if (d && Array.isArray(d.items) && Array.isArray(d.phases) && d.project) {+ // Normalise on the way through, exactly as mergedData does on the way+ // out, so a POSTed export speaks the same vocabulary as a GET one.+ d.items.forEach(r => { if (r && r.status) r.status = statusKey(r.status); });+ return d;+ }+ } catch (e) {}+ return mergedData();+ }+ if (urlPath === '/export/json') {- const data = mergedData();+ const data = await exportData(); res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Disposition': 'attachment; filename="adom-gantt-export.json"'@@ -2028,7 +2053,7 @@ const server = http.createServer(async (req, res) => { } if (urlPath === '/export/csv') {- const data = mergedData();+ const data = await exportData(); const csv = itemsToCSV(data.items, data.phases); res.writeHead(200, { 'Content-Type': 'text/csv',@@ -2039,7 +2064,7 @@ const server = http.createServer(async (req, res) => { } if (urlPath === '/export/svg') {- const svg = generateSVG();+ const svg = generateSVG(await exportData()); res.writeHead(200, { 'Content-Type': 'image/svg+xml', 'Content-Disposition': 'attachment; filename="adom-gantt-timeline.svg"'@@ -2049,7 +2074,7 @@ const server = http.createServer(async (req, res) => { } if (urlPath === '/export/html') {- const html = generateHTMLSnapshot(auth.user);+ const html = generateHTMLSnapshot(auth.user, await exportData()); res.writeHead(200, { 'Content-Type': 'text/html', 'Content-Disposition': 'attachment; filename="adom-gantt-snapshot.html"'