← Commit history

0.54.0: a save-blocked tab is loud

Drew Owens ·d38de37402 · ·parent a259bf7
The conflict message is its own persistent banner instead of a toast, so an undo, a delete, an auto-schedule or a hover can no longer take it down; the save indicator has an error state that stays up; a blocked tab keeps trying so a save that starts working again clears it; and the changelog is chained to the save that carries it, so a tab that is not saving is not writing anywhere.
6 files changed +423−56
README.md+28−5
@@ -277,11 +277,34 @@ 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. 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.+is refused with a 409, and **you are told, loudly, and you keep being told.**++A red banner comes up at the bottom of the window on the FIRST refused save, autosave+included, and it stays up until the situation is resolved. It reads *"Your changes are+not saving. This plan changed somewhere else."* and it counts how many of your changes+have not been saved. The save indicator in the corner goes into an error state and stays+there, so the tab never looks idle or saved while it is not saving. Nothing you do in the+app takes either of them down: an undo, a delete, a dependency change, an auto-schedule,+even pointing at the banner and looking away, all leave it exactly where it is.++Your own edits stay on screen until you decide, and the banner gives you three ways out:++- **Export 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.+- **Reload latest** takes the server's version. If you have unsaved changes it warns you+  first, names how many are about to go, and offers the export in the same row.++There is a fourth way out that costs you nothing: if a save simply starts working again,+the banner clears itself.++**The changelog cannot run ahead of the plan.** Changelog lines are chained to the save+that carries them: while your saves are being refused, your lines queue in the tab rather+than posting, and they go out in order behind the first save that lands, or are dropped+along with the edits if you choose to discard them. A tab that is not saving is not+writing anything anywhere, which is the whole point. Before 0.54.0 the two were+independent and a blocked tab kept filling the changelog with edits the plan never got.+ The practical advice is unchanged: for now, edit one at a time, or reload before a long editing run. 
SKILL.md+7−1
@@ -230,7 +230,7 @@ rather than a category. - **The changelog actor is the signed-in identity**, taken from the session and never from the request body, so a shared plan's history reads "Drew Owens moved X" - **New items default their provenance to "Added by <your display name>"**, still an ordinary editable field - **Delete** removes the selected table row (undoable, redoable). Only when a row is selected and no editor, panel, search box or drag is live, so Delete inside a field only edits text. Backspace is not bound. Inert in the chart-- **Saving is whole-state and last-write-wins.** Two people editing at once WILL overwrite each other. Each save carries the revision it was based on and a stale save is refused with 409, at which point that tab stops autosaving and offers a reload rather than clobbering the other person+- **Saving is whole-state and last-write-wins.** Two people editing at once WILL overwrite each other. Each save carries the revision it was based on and a stale save is refused with 409, so the second writer is told rather than the first writer being erased. **Since 0.54.0 a refused tab is impossible to miss.** The FIRST 409 from any save path, autosave included, raises a red banner of its own at the bottom of the window that stays up until the situation is resolved ("Your changes are not saving. This plan changed somewhere else.", with a running count of the changes that have not been saved), and the save indicator goes into an error state and stays in it. Nothing an ordinary session does takes either down: an undo, a delete, a dependency change, an auto-schedule, a hover. Four ways out: **Export my version** (the in-memory plan as JSON, so nothing is ever trapped behind the banner), **Save mine anyway** (force-overwrite, behind a confirm click), **Reload latest** (which warns first, names how many changes it is about to discard, and offers the export in the same row), and simply a save that starts working again, which clears the banner on its own. **The changelog is chained to the save that carries it, also since 0.54.0:** a tab whose saves are being refused queues its `/log` lines instead of posting them, so the changelog can never run ahead of the plan, and the queue goes out behind the first save that lands or is dropped along with the edits a reload discards. Through 0.53.0 the two were independent, and a blocked tab kept filling the changelog with edits the sidecar never received, which is precisely how a real seven-minute editing run was lost on 2026-08-21 - **Dependency mode** (the Deps button with the chart controls, or **Shift+D**): click the task that comes FIRST, then the task that waits on it, and the dependency is drawn. **The Deps button glows** in the accent, breathing on the same 2.6s the critical path does, for exactly as long as the mode is on, and its tooltip carries the instruction in and out of the mode: "Adding dependencies. Click the task that comes first, then the one that waits on it. Click an arrow to pick it. Esc leaves the mode." There is no info bar under the legend any more, so entering and leaving the mode moves nothing on the page. **Creating a row leaves the mode first**: Shift+N and the New button both exit it (armed source dropped, wash cleared, picked arrow released, glow off) and then open the create panel, so you never end up typing a name over a chart that is still armed for a pairing. **The row under the pointer lights up**, name cell and chart band together, washed in that row's own phase colour, so a 20px bar and the name a pane away read as one row and you can see which task you are about to click. One row at a time, and it goes the instant the mode does. The armed first task wears the normal selection outline so you can see what the pair is waiting on. The pair resets and the mode stays on, so a run of dependencies is one sitting. Clicking a pair that ALREADY has a dependency REMOVES it, which is the same removal as the editor's ×: one undo entry, one changelog event. Refuses anything that would make a cycle, naming the shortest loop path. Milestones are not endpoints: a dep is stored as a task id and a milestone in the data file has none. **Clicking an arrow picks it**, in this mode only, and **Delete** twice removes the dependency it stands for (the first press arms and says so, the second commits; any other key, a click or Esc withdraws the question). Outside the mode the arrows take no clicks at all. Esc lets go of a picked arrow, then a half-made pick, then leaves the mode. While the mode is on, bar clicks mean dependencies: no editor, no drag, no reorder, and **no resize edges**. The two 8px drag handles are hidden outright in the mode, out of paint and out of hit-testing together, so the whole face of a bar is one clean pick target: on a one-day bar, which the app draws at a 6px floor, the handles covered the entire bar twice over and every aim at it returned a resize cursor instead of the bar. Leaving the mode restores them exactly, by any of the ways out. Milestones never had an edge handle, so the mode leaves their hit strip and its pointer cursor untouched. Ctrl+Z undoes the last add or removal without leaving the mode - **The batch panel's Delete** removes the WHOLE selection, milestones included, in one press. It is the compact panels' danger button in the compact panels' place: a muted ghost, right-aligned by `margin-left:auto` so it is clear of Apply and Cancel, red on hover, last in the panel's Tab walk. It lands on the same `deleteRows` the chart's armed Delete key and the table's Delete use, so it is one `multi` undo entry restoring every row where it stood, one changelog batch, dependencies on the departed rows dropped and their uids retired, whichever hand did it; only the `via` differs (`batch-delete` against `chart-delete`). One press and no arming, unlike the key: the key acts on a selection that may be entirely off screen and asks twice, where a button inside the panel that IS the selection made visible has already been aimed. The panel's headline counts the TASKS the date fields will move and says how many milestones it is leaving alone; the Delete's tooltip names the total it will actually remove, so the two counts cannot be read as one - **Copy IDs**: with rows selected, the table's tools row and the chart's selection chip (and the batch panel) copy the selected items' stable ids, one per line as `id<TAB>name`, for pasting into an AI chat to request batch edits. Ctrl+C does the same when nothing is focused and no text is selected. If the clipboard is refused the ids appear in a selectable box rather than failing silently@@ -541,6 +541,12 @@ read yet. Send `baseRev` on `/save` so a plan that moved under you comes back 40 of erasing somebody. Then re-read `/export/json` and confirm both that your change landed and that nothing else moved. +**Your write bumps `stateRev`, and every open tab is working from the old one.** Since+0.54.0 those tabs put up a banner and stop pretending to save, so nobody loses seven+minutes of work to your update without being told. That is a safety net and not a licence:+a plan somebody is editing right now is still a plan you should write to once, in one+batch, rather than a dozen times across a long pass.+ ## Dates are required  A task needs a `start` and an `end`; a milestone needs a `date`, each looking like
docs/BUILD-NOTES.md+139
@@ -3,6 +3,145 @@ Decisions taken while building, and the places where the plan and the code disagreed. Newest first. +## Build notes, 2026-08-24 (0.54.0, the silent save-blocked tab)++One change, and it is a data-loss fix rather than a feature. `gantt.html` only; `server.js`+is untouched, because nothing about the server was wrong. The 409 guard did exactly what it+was built to do. What failed was the client's account of it.++### The incident++2026-08-21. Another writer, the agent service-token path, saved the plan while Drew had it+open in a tab. That bumped `stateRev`. From that moment every autosave from his tab carried+a stale `baseRev` and came back 409, and every one of his `/log` posts kept succeeding, so+the changelog kept advancing and the app looked alive. He worked for about seven minutes.+His refresh loaded the newer revision and the work was gone. It was recovered afterwards,+out of the client-state Export, which is the one thing in this story that worked.++### Why the 0.28.0 banner did not save him++The banner exists, and it is not dead code: it fires on the autosave path, and on the first+409 it really was on screen. Four things then took it away, and it takes only the first one+to lose it.++**D1. It was rendered into `#undoToast`, which is a toast.** `onSaveConflict` set+`undoToast.innerHTML` and added `.show` by hand. Six other places in the app set that same+element's `innerHTML` and call `showToast` on it: undo, redo, a row delete, a batch delete,+a dependency delete and Auto-schedule. Any one of them overwrote the banner's text, armed+`showToast`'s hide timer on top of it, and left an `onDone` that resets the element to+"Undid change · Ctrl+Z". A user who presses Ctrl+Z once after a conflict has, three seconds+later, an empty screen.++**D2. `showToast`'s hover pause was wired to the same element, permanently.** The wiring+happens once, on the first toast of the session, and it outlives that toast. Because the+banner was shown by hand rather than through `showToast`, `__ms` still belonged to whichever+toast came before it, so `mouseenter` then `mouseleave` set `__t = setTimeout(__hide, 3000)`.+Pointing at the banner and then thinking better of it, which is the exact gesture of+reaching for its own buttons, scheduled its disappearance.++**D3. `saveToFile` began `if(saveBlocked)return;`.** The first 409 was therefore the last+request the tab ever sent. No later 409 could re-raise a banner that had been clobbered,+and there was no path at all by which a conflict could resolve on its own.++**D4. The save indicator is a toast too.** `flash(saveInd,'not saved, plan changed+elsewhere',5000)` shows for five seconds and hides. Five seconds after the conflict the tab+was, in every visible respect, a tab that was saving normally.++`repro53.mjs` in the scratch bench asserts all four against 0.53.0 and goes green there.++### The fix++**The banner is its own element and it has no timer anywhere.** `#conflictBar`, its own+`.conflict-bar` rules, `role="alert"`. It goes up on the FIRST rejection from any save path,+autosave included, and only a resolution takes it down. It cannot be overwritten by a toast+because it is not a toast, it cannot be hidden by a hover for the same reason, and the+rebuild is guarded so a refused autosave underneath it does not disarm the confirm button or+throw away the reload warning the user is reading. Only the count moves.++It sits where the old one sat, bottom centre, and `:root[data-save="blocked"]` lifts the+undo toast and the save indicator to `bottom: 74px` so the three never stack on one another.+One line tall, clear of the header and the legend, and it takes a new `--alarm` token+(`#f85149` on screen, `#b91c1c` on paper and in light mode, matching `.an-late`) rather than+reusing the delete red, which is about a thing you are pointing at, or `--warn`, which is a+status a row can hold.++**The save indicator has a STATE now, not just messages.** `.save-indicator.err`, pinned up+in the alarm colour for as long as saves are refused. `showToast` grew an `__transient` flag+so a hover cannot arm a hide on it, and `__hide` hands the error state back after anything+that flashes over it. Not idle, not saved: not saving.++**A blocked tab keeps trying.** The `saveBlocked` short-circuit is gone. One refused POST per+800ms of editing, answered by the server out of a single `readFileSync`, buys a banner that+is telling live truth and a fourth way out that costs the user nothing: a save that starts+working again clears everything by itself.++**Three deliberate ways out, and the safe one is first.** *Export my version* is the 0.28.0+client-state export, unchanged, so nothing is ever trapped behind the banner. *Save mine+anyway* is the existing armed force-save, unchanged in semantics. *Reload latest* warns+FIRST when there is anything to lose, names how many changes are about to go, and puts the+export in the same row as the discard, so keeping the work is never more steps than losing+it. A tab with nothing unsaved reloads on one click, which is the 409-then-reload direction+that was already right for a stale-but-clean tab.++**`flushSave` no longer force-writes on unload while blocked.** That send deliberately+carries no `baseRev` and so bypasses the guard, which is correct for a tab that was saving+cleanly a second ago and exactly wrong once the banner is up: closing the tab would have+silently overwritten the other writer with edits the user had just been told were not being+saved. In practice the path was already unreachable (the debounce nulls `saveTimer` before+`saveToFile` runs), so this is the intent made explicit rather than a behaviour change.++### The log-write decision, and why it went further than asked++The split was real and it mattered: `/log` posts succeeding while `/save` was refused is+what made the app look alive. The obvious fix is to queue the log lines while `saveBlocked`+is set. That is not enough, and the harness caught it. The conflict is DISCOVERED by the+save that the first edit schedules, 800ms after that edit, and the log line for that edit+has already gone out by then. Queue-only-while-blocked therefore leaks exactly one line per+conflict, and one is enough to make the changelog wrong.++**So the `/log` post is no longer a side channel at all. It is chained to the save.** Every+line queues in `logQueue` and goes out, in order, behind a save that LANDS. Three consequences,+all of them wanted:++* A tab that is not saving is not writing anything anywhere. There is no half-persisted state.+* The changelog can never run ahead of the sidecar. It can lag it by one round trip, which is+  the harmless direction: a reader who sees a line knows the state behind it is on disk.+* A reload that discards local edits drops their queued lines with them, because at that+  point those edits really did not happen.++Dropping the queue instead of holding it was the other coherent answer and it is the wrong+one: while the banner is up the edits are still on screen and still savable, so their lines+are pending, not void.++The cost is that `flushSave` now has a second beacon behind the first. The 0.30.0-era fix it+was built for (a tab closed inside the 800ms window used to leave the log claiming an edit+the sidecar never received) still holds, and now holds in both directions: state first, then+its lines, both by `sendBeacon` with a keepalive fetch as the fallback. Note that the log+beacon fires only when the state beacon does, which is deliberate. A tab closed with a save+already IN FLIGHT drops its queued lines rather than sending them, because that save may+still come back 409 with nobody left to see the banner. Losing the lines is the lagging+direction and it is the safe one; sending them would be the leading direction, which is the+thing this release exists to make impossible. The queue is capped at 2000 events, because a+blocked tab can be left open all day.++### Verification++`repro53.mjs` reproduces the incident on 0.53.0 (21 assertions, all four mechanisms) and goes+RED on 0.54.0, every failure being the bug's absence. `v54.mjs` is the new standing suite, 74+assertions on the same bench and the same sequence: banner on the first refused autosave, the+copy, the count, the error indicator, the root attribute, undo and redo and hover and further+editing all leaving it up, keyboard shortcuts still working with it on screen, the export+carrying THIS tab's model rather than the server's, the reload warning and its Keep editing+return, the armed force-save surviving a refused autosave underneath it, the queue flushing+behind the force-save, autosave live again afterwards, the other writer getting the 409 next+(the safe direction), the discard-and-reload dropping its lines, and the banner's colours,+height and clearances in both themes. It FATALs against 0.53.0, which has no `#conflictBar`+at all, so it is fixture-coupled the way `mssel` and `msphase` are.++No assertion anywhere in the standing battery had to be relaxed or rewritten: nothing in it+had ever asserted the silent-409 behaviour, because nothing in it had ever produced a 409.+The only battery change is the new suite's entry.+ ## Build notes, 2026-08-24 (0.53.0, two scheduling semantics Drew decided)  Two changes, one round, both of them changes to what the app MEANS rather than to what it
gantt.html+246−47
@@ -42,6 +42,13 @@      so today reads as neutral grey and stops competing with both. */   --today: var(--text2);   --ok: #3fb950; --warn: #d29922;+  /* ALARM, and it means one thing only: this tab is not saving. It is deliberately not+     --warn, which is a status a row can hold, and not the delete red, which is about a+     thing you are pointing at. It is the colour of the one banner in the app that stays+     up until somebody deals with it. The edge and the halo are the same paired shape+     --sig and --crit use, so the banner can be ringed without hard-coding an rgba the+     light theme would have to override in three places. */+  --alarm: #f85149; --alarm-edge: rgba(248,81,73,0.45); --alarm-halo: rgba(248,81,73,0.16);   /* The dependency-mode signal, for ONE thing: a picked dependency ARROW. In 0.39.0 it      also ringed the armed first task of a pair; 0.40.0 took that back, because an armed      bar is a selected bar and the app already has one way to say a bar is selected. So@@ -122,6 +129,10 @@   --text: #1f2328; --text-body: #24292f; --text2: #59636e; --text3: #8c959f;   --accent: #00857f; --accent-hover: #00a49d; --accent-bright: #00a49d; --today: var(--text2);   --ok: #1a7f37; --warn: #9a6700;+  /* The alarm on paper. #f85149 is a screen red and washes out on white, so the banner+     takes the deeper one the analytics lens already uses for a late row, which clears+     4.5:1 against the page. */+  --alarm: #b91c1c; --alarm-edge: rgba(185,28,28,0.40); --alarm-halo: rgba(185,28,28,0.12);   /* Same signal, both bands flipped for paper: the halo is the page white, which clears      3:1 against all three light fills, and the stroke is the deep amber, which clears      5:1 against the page. */@@ -152,6 +163,7 @@     --border:#d0d7de; --border-muted:#d8dee4;     --text:#1f2328; --text-body:#24292f; --text2:#59636e; --text3:#8c959f;     --accent:#00857f; --today:var(--text2); --grid-line:rgba(31,35,40,0.10); --weekend:rgba(31,35,40,0.04); --holiday:rgba(199,105,10,0.105);+    --alarm:#b91c1c; --alarm-edge:rgba(185,28,28,0.40); --alarm-halo:rgba(185,28,28,0.12);     --ph1:#00a49d; --ph2:#8c6bf7; --ph3:#3b95ff;     --sig:#b45309; --sig-edge:#ffffff;     --crit:#4c1d95; --crit-glow:rgba(76,29,149,0.95); --crit-halo:rgba(76,29,149,0.40);@@ -159,7 +171,7 @@     color-scheme: light; }   html, body { overflow: visible !important; height: auto !important; }   .labels-col, .timeline-col { overflow: visible !important; }-  header .actions, .save-indicator { display: none !important; }+  header .actions, .save-indicator, .conflict-bar { display: none !important; } } *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } html, body { font: 13px/1.4 'Satoshi', -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; background: var(--bg); color: var(--text); overflow: hidden; height: 100%; }@@ -834,6 +846,35 @@ body.ed-dragging { user-select: none; -webkit-user-select: none; }    unnoticed. It is a pill now, with the same teal left edge as the toast. */ .save-indicator { position: fixed; bottom: 16px; right: 16px; font-family: 'JetBrains Mono', monospace; font-size: 10px; color: var(--text); background: var(--elevated); border: 1px solid var(--border); border-left: 3px solid var(--accent); border-radius: 7px; padding: 6px 11px; box-shadow: 0 6px 18px rgba(0,0,0,0.42), 0 0 0 1px rgba(0,184,177,0.14); max-width: 44vw; z-index: 99998; opacity: 0; transform: translateY(8px); transition: opacity 0.18s ease-out, transform 0.18s ease-out; pointer-events: none; } .save-indicator.show { opacity: 1; transform: translateY(0); pointer-events: auto; }+/* Not idle, not saved: NOT SAVING. The indicator is a toast for everything else and a+   STATE for this one thing, so while the banner is up it is pinned in the alarm colour+   and anything that flashes over it hands it straight back when it fades. */+.save-indicator.err { color: var(--alarm); border-color: var(--alarm-edge); border-left-color: var(--alarm); box-shadow: 0 6px 18px rgba(0,0,0,0.42), 0 0 0 1px var(--alarm-halo); }++/* ── The save-blocked banner ── */+/* THIS IS NOT A TOAST, and that is the whole of 0.54.0. 0.28.0 rendered the conflict+   message into #undoToast, which meant the next undo, row delete, dependency delete or+   auto-schedule overwrote its text and armed showToast's hide timer on top of it, and+   saveBlocked stopped any later 409 from putting it back. A tab could therefore sit for+   seven minutes refusing to save and saying nothing, which is exactly what happened to a+   real plan on 2026-08-21. Its own element, no timer on it anywhere, and only a+   resolution takes it down.++   Same place the old one occupied, bottom centre, so nothing has moved for anyone who+   has seen it before. The toast and the indicator both step up out of its way while it is+   there, since two things stacked at bottom:16px would sit on top of each other, and it+   is one line tall so the chart underneath it is as readable as it is under a toast. */+.conflict-bar { position: fixed; bottom: 16px; left: 50%; transform: translate(-50%, 8px); display: none; align-items: center; gap: 8px; max-width: min(900px, calc(100vw - 32px)); background: var(--elevated); border: 1px solid var(--alarm-edge); border-left: 3px solid var(--alarm); border-radius: 8px; padding: 9px 14px; font-family: 'Satoshi', sans-serif; font-size: 12px; color: var(--text); box-shadow: 0 8px 24px rgba(0,0,0,0.5), 0 0 0 1px var(--alarm-halo), 0 2px 14px var(--alarm-halo); z-index: 100000; opacity: 0; transition: opacity 0.18s ease-out, transform 0.18s ease-out; }+.conflict-bar.show { display: flex; opacity: 1; transform: translate(-50%, 0); }+.conflict-bar .cb-msg { flex: 1 1 auto; min-width: 0; color: var(--text-body); }+.conflict-bar .cb-lead { color: var(--alarm); font-weight: 600; }+.conflict-bar .de-btn { flex: 0 0 auto; padding: 4px 11px; }+/* Everything else that lives along the bottom edge steps up while it is there, and it+   outranks all of them, because a banner about work not being saved is the most important+   thing on the screen for as long as it is true. */+:root[data-save="blocked"] .undo-toast,+:root[data-save="blocked"] .save-indicator,+:root[data-save="blocked"] .data-warn { bottom: 74px; } /* The version is chrome: present for orientation, never competing with the plan. */ header .subject-meta .app-ver { color: var(--text3); } .sel-count { position: fixed; bottom: 16px; left: 16px; font-family: 'JetBrains Mono', monospace; font-size: 10px; color: var(--accent); background: var(--elevated); border: 1px solid var(--border); border-left: 3px solid var(--accent); border-radius: 7px; padding: 6px 11px; box-shadow: 0 6px 18px rgba(0,0,0,0.42); z-index: 99998; opacity: 0; transform: translateY(8px); transition: opacity 0.18s ease-out, transform 0.18s ease-out; pointer-events: none; cursor: pointer; }@@ -1321,6 +1362,7 @@ table.data-table { font-size: calc(12px * var(--fs)); min-width: calc(900px * va <div class="dropdown" id="exportMenu"></div> <div class="dropdown" id="importMenu"></div> <div class="undo-toast" id="undoToast">Undid change &middot; <kbd>Ctrl+Z</kbd></div>+<div class="conflict-bar" id="conflictBar" role="alert" aria-live="assertive"></div> <div class="data-warn" id="dataWarn"></div> <div class="save-indicator" id="saveInd">saved</div> <div class="datepop" id="datePop"></div>@@ -1343,6 +1385,7 @@ let COL = 66;         // month column width, recomputed from the viewport in fit const $ = id => document.getElementById(id); const labelsEl = $('labels'), timelineEl = $('timeline'), tipEl = $('tip'); const editorEl = $('editor'), undoToast = $('undoToast'), saveInd = $('saveInd');+const conflictBar = $('conflictBar'); const selCountEl = $('selCount'), exportMenu = $('exportMenu'), importMenu = $('importMenu'); const importFileEl = $('importFile'); @@ -2925,6 +2968,33 @@ let saveTimer=null; // the check is skipped, which is exactly how an older build behaves. let baseRev=null; let saveBlocked=false;+// How many changelog-worthy edits have been made since the last save that LANDED. It is+// the number the banner quotes back before a reload throws them away, so a user is never+// asked to discard "your changes" without being told how many of them there are.+let unsavedCount=0;+// Changelog lines waiting for the save they belong to.+//+// THE LOG AND THE SIDECAR HAVE TO TELL THE SAME STORY. Through 0.53.0 the /log post went+// out the instant an edit was made and was completely independent of whether /save was+// working, so a tab whose saves were being refused kept writing "moved X to Y" lines for+// edits the sidecar never took. That split is not a cosmetic problem: the changelog+// advancing is precisely what made the app look alive on 2026-08-21 while nothing was+// landing, and it left the only durable record of seven minutes of work as a list of+// things that had not happened.+//+// The /log post is therefore no longer a side channel. It is CHAINED to the save: lines+// queue here and go out, in order, behind the save that carries them, so the changelog+// can never run ahead of the state it describes. Only queueing while already blocked+// would have left exactly one edit leaking, the one whose 800ms debounce is what+// discovers the conflict in the first place, and one is enough to make the log wrong.+//+// Two things drain the queue and one drops it. A save that lands flushes it. A tab being+// closed flushes it alongside the beacon that saves the state, so the 0.30.0-era fix for+// the other direction (a tab closed inside the debounce window) still holds in both. And+// a reload that discards the edits drops their lines with them, because at that point the+// edits really did not happen.+let logQueue=[];+const LOG_QUEUE_MAX=2000;    // bounded, because a blocked tab can be left open all day // Milestones carry no id, so an array of tasks alone loses their dates on reload and // they snap back to the data file while the tasks stay shifted. Persist both. // An empty value means "no value", not the empty string, so clearing an owner in@@ -3198,16 +3268,31 @@ function diffAndLog(via){     }     return o;   };-  fetch('log',{method:'POST',headers:{'Content-Type':'application/json'},-    body:JSON.stringify({actor:'user',events:evs.map(stamp)})})+  postLog(evs.map(stamp));+}+// The one door to the changelog, so the chaining rule cannot be bypassed by a caller that+// did not know about it. See logQueue above for why nothing goes out ahead of its save.+function postLog(events){+  if(!events.length)return;+  unsavedCount+=events.length;+  logQueue.push(...events);+  if(logQueue.length>LOG_QUEUE_MAX)logQueue.splice(0,logQueue.length-LOG_QUEUE_MAX);+  refreshConflictCount();+}+function logBody(events){ return JSON.stringify({actor:'user',events}); }+// Called only after a save has landed, so the lines arrive behind the state they describe.+function flushLogQueue(){+  if(!logQueue.length)return;+  const q=logQueue; logQueue=[];+  fetch('log',{method:'POST',headers:{'Content-Type':'application/json'},body:logBody(q)})     .catch(()=>{}); } function scheduleSave(via){diffAndLog(via||'edit');if(critOn())refreshCritical();syncPlanCtl();if(prefs.view==='analytics')renderAnalytics();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.+// Both writes wait: the sidecar for its 800ms debounce and, since 0.54.0, the changelog+// line for the save it belongs to. A tab closed inside that window would leave the edit in+// neither, so anything still pending goes out synchronously when the page is being put+// away, the state first and its lines behind it. sendBeacon is the only send guaranteed to+// outlive the document; keepalive fetch is the fallback. function flushSave(){   // A burst of Alt+Arrow presses holds its changelog line back for 600ms. If the   // tab is put away inside that window the line still has to go, or the log and@@ -3216,11 +3301,32 @@ function flushSave(){   if(!saveTimer)return;   clearTimeout(saveTimer);saveTimer=null;   if(SNAPSHOT)return;+  // A blocked tab must not win the conflict by being CLOSED. This send carries no baseRev+  // and therefore bypasses the guard, which is right for the ordinary case (a tab that was+  // saving cleanly a second ago) and exactly wrong once the banner is up: closing the tab+  // would silently overwrite the other writer with edits the user was told were not being+  // saved. They keep the banner's three ways out instead, and none of them is an accident.+  if(saveBlocked)return;   const body=JSON.stringify(getState());+  let sent=false;+  try{+    if(navigator.sendBeacon)sent=navigator.sendBeacon('save',new Blob([body],{type:'application/json'}));+  }catch(e){}+  if(!sent){+    try{fetch('save',{method:'POST',headers:{'Content-Type':'application/json'},body,keepalive:true});}catch(e){}+  }+  flushLogBeacon();+}+// The queue, on the way out of the document. Same two mechanisms as the state above, and+// second in line behind it, so the log still never precedes the state it describes.+function flushLogBeacon(){+  if(!logQueue.length)return;+  const q=logQueue; logQueue=[];+  const body=logBody(q);   try{-    if(navigator.sendBeacon&&navigator.sendBeacon('save',new Blob([body],{type:'application/json'})))return;+    if(navigator.sendBeacon&&navigator.sendBeacon('log',new Blob([body],{type:'application/json'})))return;   }catch(e){}-  try{fetch('save',{method:'POST',headers:{'Content-Type':'application/json'},body,keepalive:true});}catch(e){}+  try{fetch('log',{method:'POST',headers:{'Content-Type':'application/json'},body,keepalive:true});}catch(e){} } function wireSaveFlush(){ window.addEventListener('pagehide',flushSave);@@ -3231,54 +3337,141 @@ document.addEventListener('visibilitychange',()=>{if(document.visibilityState=== // 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.+// A BLOCKED TAB KEEPS TRYING. Through 0.53.0 this returned early on saveBlocked, which+// meant the first 409 was the last request the tab ever sent: the banner could never be+// re-raised, and there was no path by which a conflict could resolve on its own. The+// attempt costs one refused POST per 800ms of editing, the server answers it out of a+// single readFileSync, and in exchange the banner is telling the live truth and a save+// that starts working again clears it without the user doing anything. function saveToFile(){   if(SNAPSHOT){flash(saveInd,'snapshot mode');return;}-  if(saveBlocked)return;   const payload=getState();   if(typeof baseRev==='number')payload.baseRev=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.status===409){ onSaveConflict(d); return; }+      if(r.status===409){ onSaveBlocked('conflict'); return; }+      if(!r.ok){ onSaveBlocked('The server refused the save ('+r.status+').'); return; }       if(typeof d.stateRev==='number')baseRev=d.stateRev;-      flash(saveInd,'saved');+      onSaveOk('saved');     })-    .catch(()=>{flash(saveInd,'save failed',2500);});-}-// Someone else wrote to this plan while we were editing it. We do not merge and we-// do not overwrite them: autosave stops so this tab cannot clobber anything, and the-// user is told plainly, with the way out one click away. Their own edits are still-// on screen, so nothing is taken from them without their say-so.-function onSaveConflict(d){+    .catch(()=>{ onSaveBlocked('The server could not be reached.'); });+}+// One save landed. Anything the banner was waiting for is now settled, and the queued+// changelog lines go out BEHIND the state they describe, never in front of it.+function onSaveOk(msg){+  unsavedCount=0;+  if(saveBlocked)clearConflict('saved, the plan is back in sync');+  else flash(saveInd,msg);+  flushLogQueue();+}+// This tab's writes are being refused. Usually because someone else wrote to the plan+// while we were editing it; occasionally because the server said no for another reason.+// We do not merge and we do not overwrite anyone silently. What changed in 0.54.0 is not+// the policy, it is the VISIBILITY of it: the message is now a banner of its own that+// stays up until the situation is resolved, and the save indicator goes into an error+// state and stays there. A user must never again be able to keep working for seven+// minutes in a tab that is quietly saving nothing.+//+// `why` is either the string 'conflict' or a sentence about what else went wrong.+let blockedWhy='conflict';+function onSaveBlocked(why){   saveBlocked=true;-  clearTimeout(undoTimer); clearTimeout(undoToast.__t);   // this one waits for a decision-  // 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 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());+  blockedWhy=why;+  clearTimeout(undoTimer);+  showConflictBar();+}+// The count is quoted back before a reload discards the work, so it is worth being exact.+function unsavedNote(){+  if(!unsavedCount)return 'Nothing is waiting to be saved right now.';+  return unsavedCount===1?'1 change in this tab has not been saved.'+                         :unsavedCount+' changes in this tab have not been saved.';+}+function refreshConflictCount(){+  const n=$('cbCount');+  if(n)n.textContent=unsavedNote();+}+function showConflictBar(){+  document.documentElement.setAttribute('data-save','blocked');+  showSaveErr();+  // Already up. Do NOT rebuild it: a rebuild every 800ms would disarm the confirm button+  // under the user's second click and would throw away the reload warning they are+  // reading. Only the count moves.+  if(conflictBar.classList.contains('show')){ refreshConflictCount(); return; }+  renderConflictMain();+  conflictBar.classList.add('show');+}+// Three ways out, and the safe one is always available. Export my version writes the+// IN-MEMORY plan to a file, so nothing is ever trapped behind the banner; that is the+// 0.28.0 client-state export and it is unchanged. Save mine anyway force-writes over the+// other change, behind a confirm click. Reload latest takes the server's version, and+// says what that costs before it does it.+function renderConflictMain(){+  const lead=blockedWhy==='conflict'?'This plan changed somewhere else.':esc(blockedWhy);+  conflictBar.innerHTML='<span class="cb-msg"><span class="cb-lead">Your changes are not saving.</span> '++    lead+' <span id="cbCount">'+esc(unsavedNote())+'</span></span>'++    '<button type="button" id="cbExport" class="de-btn secondary">Export my version</button>'++    '<button type="button" id="cbForce" class="de-btn secondary">Save mine anyway</button>'++    '<button type="button" id="cbReload" class="de-btn primary">Reload latest</button>';+  $('cbExport').addEventListener('click',exportMine);+  // Warn first, always, when there is something to lose. A tab with nothing unsaved is+  // stale rather than dirty and reloading it costs nothing, so it just reloads.+  $('cbReload').addEventListener('click',()=>{+    if(unsavedCount>0)renderConflictReloadWarning(); else 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',()=>{+  const fm=$('cbForce');+  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);+      setTimeout(()=>{ if(fm.isConnected&&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);+}+// What a reload actually costs, said before it happens, with the export offered in the+// same row so keeping the work is never more steps than losing it.+function renderConflictReloadWarning(){+  conflictBar.innerHTML='<span class="cb-msg"><span class="cb-lead">Reloading discards your work.</span> '++    'Reload latest replaces what is on your screen with the version on the server. '++    esc(unsavedNote())+' Export first if you want to keep it.</span>'++    '<button type="button" id="cbExport" class="de-btn secondary">Export my version</button>'++    '<button type="button" id="cbBack" class="de-btn secondary">Keep editing</button>'++    '<button type="button" id="cbGo" class="de-btn primary">Discard and reload</button>';+  $('cbExport').addEventListener('click',exportMine);+  $('cbBack').addEventListener('click',renderConflictMain);+  $('cbGo').addEventListener('click',()=>{+    // The queued changelog lines describe the very edits about to be thrown away, so they+    // go with them. Posting them on the way out would leave the log claiming edits that+    // no sidecar ever held, which is the split this release exists to close.+    logQueue.length=0; unsavedCount=0;+    location.reload();+  });+}+function exportMine(){ doExport('json'); flash(saveInd,'exported your version',4000); }+// Any of the three ways out, plus the fourth: a save that simply started working again.+function clearConflict(msg){+  saveBlocked=false;+  blockedWhy='conflict';+  conflictBar.classList.remove('show');+  conflictBar.innerHTML='';+  document.documentElement.removeAttribute('data-save');+  unpinSaveErr();+  if(msg)flash(saveInd,msg,4000);+}+// Not idle, not saved: NOT SAVING, and it stays that way. See the .save-indicator.err+// rule for why this one message is a state rather than a toast.+function showSaveErr(){+  clearTimeout(saveInd.__t);+  saveInd.__transient=false;+  saveInd.textContent='not saving, this plan changed elsewhere';+  saveInd.classList.add('show','err');+}+function unpinSaveErr(){+  saveInd.__transient=true;+  saveInd.classList.remove('err'); } // 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@@ -3292,11 +3485,11 @@ function forceSaveMine(){       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);+      unsavedCount=0;+      clearConflict('saved your version, overwriting the other change');+      flushLogQueue();     })-    .catch(()=>flash(saveInd,'could not force-save, try Download my version',4000));+    .catch(()=>flash(saveInd,'could not force-save, try Export 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@@ -3305,12 +3498,18 @@ function forceSaveMine(){ const TOAST_MS=2800;          // general confirmations, was a 1500ms default function showToast(el,ms,onDone){   clearTimeout(el.__t);-  el.__ms=ms||TOAST_MS; el.__onDone=onDone||null; el.__paused=false;+  el.__ms=ms||TOAST_MS; el.__onDone=onDone||null; el.__paused=false; el.__transient=true;   if(!el.__hoverWired){     el.__hoverWired=true;     el.addEventListener('mouseenter',()=>{ if(el.classList.contains('show')){ clearTimeout(el.__t); el.__paused=true; } });-    el.addEventListener('mouseleave',()=>{ if(el.__paused&&el.classList.contains('show')){ el.__paused=false; el.__t=setTimeout(el.__hide,el.__ms); } });-    el.__hide=()=>{ el.classList.remove('show'); if(el.__onDone)el.__onDone(); };+    // A pinned message is not a toast and must not be given a hide timer by the pointer+    // merely passing over it. Through 0.53.0 the conflict banner lived in this element and+    // was shown by hand rather than through showToast, so __ms still belonged to whatever+    // toast came before it and one hover was enough to schedule its disappearance.+    el.addEventListener('mouseleave',()=>{ if(el.__paused&&el.classList.contains('show')){ el.__paused=false; if(el.__transient!==false)el.__t=setTimeout(el.__hide,el.__ms); } });+    // Whatever flashed over the save indicator, the not-saving state is what it goes back+    // to while saves are still being refused.+    el.__hide=()=>{ el.classList.remove('show'); if(el.__onDone)el.__onDone(); if(el===saveInd&&saveBlocked)showSaveErr(); };   }   el.classList.add('show');   el.__t=setTimeout(el.__hide,el.__ms);
package.json+1−1
@@ -2,7 +2,7 @@   "slug": "adom-gantt",   "title": "Adom Gantt",   "type": "app",-  "version": "0.53.0",+  "version": "0.54.0",   "description": "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, pushing work LATER only so a lead-time gap you set on purpose is never closed, and one more lights up the critical path, meaning the longest run of work inside every section at once. Cancelled work is left out of both: it never moves, it constrains nothing and it can never glow. Dependency arrows are coloured by the phase of the task they come out of, so where a chain starts reads at a glance. Toggle to the table for every task and milestone as a row with owner, deadline and status, filtered per owner and edited in place, or to analytics for total, remaining and average hours, one row per person carrying both their hours and their status counts, per-section totals, milestones, dependency stats and what is overdue. Five statuses, including cancelled, which keeps the row and its history on the plan while taking its hours out of every total. 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.53.0",+  "version": "0.54.0",   "description": "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, pushing work LATER only so a lead-time gap you set on purpose is never closed, and one more lights up the critical path, meaning the longest run of work inside every section at once. Cancelled work is left out of both: it never moves, it constrains nothing and it can never glow. Dependency arrows are coloured by the phase of the task they come out of, so where a chain starts reads at a glance. Toggle to the table for every task and milestone as a row with owner, deadline and status, filtered per owner and edited in place, or to analytics for total, remaining and average hours, one row per person carrying both their hours and their status counts, per-section totals, milestones, dependency stats and what is overdue. Five statuses, including cancelled, which keeps the row and its history on the plan while taking its hours out of every total. 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": {@@ -86,4 +86,4 @@     "name": "Drew Owens",     "email": "[email protected]"   }-}\ No newline at end of file+}