← Commit history

0.8.2: stale chart tooltip after reload, and a lost edit when a tab closes mid-debounce

Drew Owens ·80b3e49aa5 · ·parent 87635bc
updateBar patched the bar's geometry, dates and note but never its data-title or data-body, which is where the hover tooltip reads from. A description edited in the table and restored from the sidecar on the next load left the bar hovering the version from the data file, while /state and /export/json were both right. Fixed at the choke point, so it covers the initial state load and every live edit at once. updateMilestone had the same gap on the hit strip, which is what diamond style hovers, and now restates it alongside the cap. The changelog entry goes out immediately but the sidecar write is debounced 800ms, so a tab closed inside that window left the log claiming an edit the sidecar never got, which is exactly the disagreement the changelog exists to prevent. flushSave now sends anything pending on pagehide and on the visibilitychange to hidden, via sendBeacon with a keepalive fetch fallback. The debounce is unchanged for normal typing. Also: POST /log with valid JSON of the wrong shape returned 200 and silently dropped the events. The log is append-only, so a non-array events, a non-object body, or an event with no string action is now a 400. And a CSV import left no trace in the changelog even though it replaces the data file and clears the sidecar; all four import endpoints now record one import event that says so, so an agent cannot mistake the history above it for history of this plan.
7 files changed +111−21
README.md+1−1
@@ -26,7 +26,7 @@ Interactive Gantt chart viewer for project timelines. Data-driven, dark by defau  **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. -**Auto-save.** Edits persist to a sidecar JSON file next to the data file. Delete the sidecar to reset. The format is additive: a sidecar written by an older version still loads. Rows are keyed by a stable index rather than by their own text, so renaming a milestone keeps it in the plan, and the saved row order is applied all-or-nothing so a stale key can never drop a row.+**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. The format is additive: a sidecar written by an older version still loads. Rows are keyed by a stable index rather than by their own text, so renaming a milestone keeps it in the plan, and the saved row order is applied all-or-nothing so a stale key can never drop a row.  **Fills the window.** Cells stretch to the width of the pane, and stop at each grain's minimum so a long plan scrolls horizontally rather than squashing into unreadability. Resizing reflows without a reload. 
SKILL.md+8−4
@@ -191,7 +191,7 @@ rather than a category. - **Click a bar** to open the date editor with start/end pickers and dependency management - **Drag bar edges** to resize (changes start or end date) - **Ctrl+Z** to undo (100-level stack)-- **Auto-save** to `<datafile>-state.json` sidecar (800ms debounce)+- **Auto-save** to `<datafile>-state.json` sidecar (800ms debounce, flushed immediately if the page is closed or hidden, so the changelog and the sidecar cannot disagree) - **Auto-load** saved state on page refresh - **Dynamic date range** computed from the data every render - **Project start** in the header. Changing it moves the WHOLE plan: every task, milestone and phase shifts by the same number of days, so durations and gaps are preserved. Revert puts it back to the data file's own start. This is a real edit, recorded and undoable, not a view setting@@ -264,9 +264,13 @@ Each event records the action, the item, the before and after dates, the day shi Actions are `dates`, `plan.shift`, `milestone`, `milestone.rename`, `note`, `owner`, `status`, `desc`, `dep.add`, `dep.remove` and `reorder`. -Every event must carry a string `action`; `POST /log` rejects one that does not. A-malformed event never breaks a reader: `?format=md` renders whatever it can and says-`?` where a field is missing.+Every event must carry a string `action`, and `events` must be an array; `POST /log`+returns 400 otherwise, because the log is append-only and anything accepted is+permanent. A malformed event never breaks a reader: `?format=md` renders whatever it+can and says `?` where a field is missing.++An import records a single `import` event. It replaces the data file and clears the+sidecar, so treat everything logged above that event as history of a different plan.  Moving the project start collapses into a single `plan.shift` event rather than one row per task, so the log says what happened instead of burying it in 26 identical lines.
docs/BUILD-NOTES.md+31
@@ -3,6 +3,37 @@ Decisions taken while building, and the places where the plan and the code disagreed. Newest first. +## Build notes, 2026-08-07 (0.8.2, behavioural verification pass)++A verifier drove the app as a user and found two bugs that only show up in use.++**The chart's tooltip went stale after a reload.** `updateBar` patched the bar's+geometry, dates and note but never its `data-title`/`data-body`, which is where the+hover tooltip reads from. So a description edited in the table and restored from+the sidecar on the next load left the bar hovering the version from the data file,+while `/state` and `/export/json` were both correct. Fixed at the choke point:+`updateBar` now restates the tooltip dataset, which covers the initial state load+and every live edit at once, and `updateMilestone` does the same for the hit strip+(diamond style) as well as the cap (line style), which had the same gap.++**A tab closed inside the save debounce lost the edit.** The changelog entry goes+out immediately but the sidecar write is debounced 800ms, so closing the tab inside+that window left the log claiming an edit the sidecar never received, which is+exactly the disagreement the changelog exists to prevent. `flushSave` now sends any+pending state synchronously on `pagehide` and on the `visibilitychange` to hidden,+via `sendBeacon` with a `keepalive` fetch fallback. The debounce is untouched for+normal typing.++Two smaller ones taken as well. `POST /log` with valid JSON of the wrong shape+(`{"events":"boom"}`) used to return 200 and silently discard the events; the log+is append-only and permanent, so it now returns 400 for a non-array `events`, a+non-object body, or any event without a string `action`. And a CSV import used to+leave no trace in the changelog at all, even though it replaces the data file+wholesale and clears the sidecar: all four import endpoints now record a single+`import` event, which reads as "REPLACED the whole plan ... Everything before this+point describes a different plan", so an agent reading the log cannot mistake the+history above it for history of the current plan.+ ## Build notes, 2026-08-07 (0.8.1, adversarial review pass)  A fresh-context reviewer went at 0.8.0 and found real defects. All of them were
gantt.html+31−7
@@ -822,7 +822,26 @@ function diffAndLog(via){     body:JSON.stringify({actor:'user',events:evs.map(e=>({...e,via}))})})     .then(r=>r.json()).then(d=>{if(d&&d.rev)revEl.textContent='rev '+d.rev;}).catch(()=>{}); }-function scheduleSave(via){diffAndLog(via||'edit');clearTimeout(saveTimer);saveTimer=setTimeout(saveToFile,800);}+function scheduleSave(via){diffAndLog(via||'edit');clearTimeout(saveTimer);saveTimer=setTimeout(()=>{saveTimer=null;saveToFile();},800);}+// The changelog entry goes out immediately but the sidecar write waits 800ms, so+// a tab closed inside that window left the log claiming an edit the sidecar never+// received. Anything still pending goes out synchronously when the page is being+// put away. sendBeacon is the only send guaranteed to outlive the document;+// keepalive fetch is the fallback.+function flushSave(){+  if(!saveTimer)return;+  clearTimeout(saveTimer);saveTimer=null;+  if(SNAPSHOT)return;+  const body=JSON.stringify(getState());+  try{+    if(navigator.sendBeacon&&navigator.sendBeacon('save',new Blob([body],{type:'application/json'})))return;+  }catch(e){}+  try{fetch('save',{method:'POST',headers:{'Content-Type':'application/json'},body,keepalive:true});}catch(e){}+}+window.addEventListener('pagehide',flushSave);+// pagehide does not fire when a mobile browser backgrounds the tab and later+// discards it, so the hidden transition flushes too.+document.addEventListener('visibilitychange',()=>{if(document.visibilityState==='hidden')flushSave();}); // A snapshot is a file, not a server. The export used to try to rewrite this // function with a regex that could never match its own source; the flag it // already sets is enough.@@ -1273,7 +1292,9 @@ function updateMilestone(r){   if(!r||!r._msEl)return;   const x=toX(r.date);   r._msEl.style.left=x+'px';-  if(r._msHit){r._msHit.style.left=x+'px';r._msHit.dataset.dates=fmt(r.date);}+  // Diamond style hovers the hit strip and line style hovers the cap, so both+  // carry the tooltip and both have to be restated, not just the cap.+  if(r._msHit){r._msHit.style.left=x+'px';r._msHit.dataset.dates=fmt(r.date);r._msHit.dataset.title=r.name;r._msHit.dataset.body=r.description||'';}   if(r._msLine){     r._msLine.style.left=x+'px';     const cap=r._msLine.querySelector('.ms-cap');@@ -1295,7 +1316,11 @@ function refreshMsChrome(r){     if(cap){cap.textContent=(r.name||'').replace(/^M(\d+):\s*/,'M$1 ');cap.dataset.title=r.name;cap.dataset.body=r.description||'';}} } -function updateBar(id){const r=items.find(d=>d.id===id),be=barEls[id];if(!r||!be)return;if(r._noteEl)r._noteEl.textContent=r.note||'';const x1=toX(r.start),x2=toX(r.end);be.bar.style.left=x1+'px';be.bar.style.width=Math.max(x2-x1,6)+'px';be.bar.dataset.dates=fmt(r.start)+' → '+fmt(r.end);drawDeps();}+// The bar's tooltip lives in its data-* attributes, so restating the item means+// restating those too. This used to patch only the geometry and the dates, so a+// description restored from the sidecar (or edited in the table) left the bar+// hovering the version that was in the data file.+function updateBar(id){const r=items.find(d=>d.id===id),be=barEls[id];if(!r||!be)return;if(r._noteEl)r._noteEl.textContent=r.note||'';const x1=toX(r.start),x2=toX(r.end);be.bar.style.left=x1+'px';be.bar.style.width=Math.max(x2-x1,6)+'px';be.bar.dataset.dates=fmt(r.start)+' → '+fmt(r.end);be.bar.dataset.title=r.name;be.bar.dataset.body=r.description||'';drawDeps();} function closeEditor(){editorEl.classList.remove('show');editorEl.innerHTML='';} function closeMenus(){exportMenu.classList.remove('show');importMenu.classList.remove('show');} @@ -1983,11 +2008,10 @@ function commitCell(r,field,v){     if(r.end&&r.end<v)r.end=v;   } else return; +  // updateBar and refreshMsChrome both restate the tooltip, so the chart is+  // already telling the truth by the time we toggle back to it.   if(ms){refreshMsChrome(r);updateMilestone(r);}-  else{-    updateBar(r.id);refreshLabelRow(r);-    const be=barEls[r.id];if(be)be.bar.dataset.body=r.description||'';-  }+  else{updateBar(r.id);refreshLabelRow(r);}   scheduleSave('table-edit'); } 
package.json+1−1
@@ -2,7 +2,7 @@   "slug": "adom-gantt",   "title": "Adom Gantt",   "type": "app",-  "version": "0.8.1",+  "version": "0.8.2",   "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+1−1
@@ -2,7 +2,7 @@   "slug": "adom-gantt",   "title": "Adom Gantt",   "type": "app",-  "version": "0.8.1",+  "version": "0.8.2",   "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": {
server.js+38−7
@@ -111,6 +111,18 @@ function appendLog(events, actor) {   return { rev, added: rows.length, rows }; } +// An import replaces the data file wholesale and clears the sidecar, which is the+// single most destructive thing the app can do, and it used to leave no trace at+// all: an agent reading the changelog could not tell that every event above it+// referred to a plan that no longer exists. Never let a failed log kill the import.+function logImport(format, result) {+  try {+    appendLog([{ action: 'import', format, items: result.items, phases: result.phases, via: 'import' }], 'user');+  } catch (e) {+    console.error('adom-gantt: could not record the import in the changelog:', e.message);+  }+}+ // Total by construction. The log is append-only and anyone can POST to /log, so a // single event missing a field it "should" have used to throw here, and the throw // surfaced as a dead server (see the /changelog handler). Every field access goes@@ -170,6 +182,9 @@ function describeEvent(e) {   if (action === 'dep.add') return `${who} made ${name} depend on "${e.depName || e.dep || '?'}"${via}`;   if (action === 'dep.remove') return `${who} removed ${name}'s dependency on "${e.depName || e.dep || '?'}"${via}`;   if (action === 'reorder') return `${who} reordered rows: ${(Array.isArray(e.moved) ? e.moved : []).join(', ') || 'row order changed'}${via}`;+  if (action === 'import') {+    return `${who} REPLACED the whole plan by importing ${e.items || 0} item(s) in ${e.phases || 0} phase(s) from ${e.format || 'a file'}. Everything before this point describes a different plan${via}`;+  }   return `${who} ${action} ${name}${via}`; } @@ -923,16 +938,28 @@ const server = http.createServer(async (req, res) => {       const body = await readBody(req);       try {         const payload = JSON.parse(body);-        const raw = Array.isArray(payload) ? payload : (payload.events || []);-        // describeEvent copes with anything, but a row with no action says nothing-        // at all, so refuse it at the door rather than storing noise forever.-        const events = (Array.isArray(raw) ? raw : []).filter(e => e && typeof e === 'object' && typeof e.action === 'string' && e.action);-        if (Array.isArray(raw) && raw.length && !events.length) {-          jsonResp(res, 400, { ok: false, error: 'every event needs a string "action"', rev: currentRev() });+        // The log is append-only, so anything accepted here is permanent. Say no+        // to a shape that cannot mean anything rather than storing it forever:+        // valid JSON of the wrong shape used to come back 200 and silently vanish.+        let events;+        if (Array.isArray(payload)) events = payload;+        else if (payload && typeof payload === 'object') {+          if (payload.events === undefined) events = [];+          else if (!Array.isArray(payload.events)) {+            jsonResp(res, 400, { ok: false, error: '"events" must be an array', rev: currentRev() });+            return;+          } else events = payload.events;+        } else {+          jsonResp(res, 400, { ok: false, error: 'body must be an events array or an object with one', rev: currentRev() });+          return;+        }+        const bad = events.filter(e => !e || typeof e !== 'object' || Array.isArray(e) || typeof e.action !== 'string' || !e.action);+        if (bad.length) {+          jsonResp(res, 400, { ok: false, error: 'every event needs a string "action"', rejected: bad.length, rev: currentRev() });           return;         }         if (!events.length) { jsonResp(res, 200, { ok: true, added: 0, rev: currentRev() }); return; }-        const out = appendLog(events, payload.actor);+        const out = appendLog(events, payload && payload.actor);         jsonResp(res, 200, { ok: true, added: out.added, rev: out.rev, file: path.basename(LOG_FILE) });       } catch (e) {         jsonResp(res, 400, { ok: false, error: e.message });@@ -986,6 +1013,7 @@ const server = http.createServer(async (req, res) => {       const body = await readBody(req);       try {         const result = importJSON(body);+        logImport('json', result);         jsonResp(res, 200, result);       } catch (e) {         jsonResp(res, 400, { ok: false, error: e.message });@@ -997,6 +1025,7 @@ const server = http.createServer(async (req, res) => {       const body = await readBody(req);       try {         const result = importCSV(body, query);+        logImport('csv', result);         jsonResp(res, 200, result);       } catch (e) {         jsonResp(res, 400, { ok: false, error: e.message });@@ -1008,6 +1037,7 @@ const server = http.createServer(async (req, res) => {       const body = await readBody(req);       try {         const result = importMSProjectXML(body);+        logImport('msproject-xml', result);         jsonResp(res, 200, result);       } catch (e) {         jsonResp(res, 400, { ok: false, error: e.message });@@ -1019,6 +1049,7 @@ const server = http.createServer(async (req, res) => {       const body = await readBody(req);       try {         const result = importJiraCSV(body);+        logImport('jira-csv', result);         jsonResp(res, 200, result);       } catch (e) {         jsonResp(res, 400, { ok: false, error: e.message });