← Commit history
README.md+51−38
@@ -331,6 +331,19 @@ curl -s -X POST http://127.0.0.1:8901/log \   -d '{"events":[{"action":"note","id":"log-ship","to":{"note":"venue confirmed"}}]}' ``` +To change the plan itself, send ops (0.77.0), the same ones a tab sends, and only the fields+you mean to change:++```bash+curl -s -X POST http://127.0.0.1:8901/ops \+  -H "Authorization: Bearer $TOK" -H 'Content-Type: application/json' \+  -d '{"ops":[{"op":"row","uid":"00120","set":{"owner":"Noah","e":"2026-11-03"}},+             {"op":"row","uid":"00121","add":{"deps":["fab-board"]}}]}'+```++Every open tab shows the change at once, and nobody's other edits to those rows are touched.+Then post the matching changelog line to `/log`, as below.+ What that buys you is an **audit trail instead of a mystery**. The write goes through the same handler, the same append path and the same hash chain as everybody else's, so integrity stays `ok:true`, and every event it produces is recorded under the actor@@ -352,44 +365,44 @@ This is not a secret from anything already running in the container, and does no to be: a local process can read the plan and the token alike. Its job is to make agent edits **auditable** rather than invisible. -### Known limitation: two people editing at once--The save model is whole-state, and it is **last-write-wins**. If two teammates have-the plan open and both edit, the second save overwrites the first, and the only record-of what was lost is the changelog. There is no merge and no operational transform.--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, 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 starts working again, the-banner clears itself.+### Editing together++Since 0.77.0 the plan saves the way Google Docs does. A tab does not send its copy of the+whole plan: it sends what you changed, as small operations ("the owner of row 00147 is now+Noah", "row 00210 goes after row 00198", "add the tag Fab to row 00033"). The server applies+them in the order they arrive and tells every open tab, so:++- **Two people editing different fields of one row both keep their change.** You set the+  owner while a teammate sets the status: the row ends with both.+- **The same field, at the same moment, goes to the later change**, in every tab. If your+  value is replaced a moment after you set it, you are told who replaced it and what you had+  ("Ana changed the owner of "Ship the workcells" to Kai just now (you had Noah)"), so+  nothing is lost without a word.+- **Lists merge item by item.** Two people adding a different dependency, or a different tag,+  to the same row both keep theirs; removing one takes out that one only.+- **Moves merge too.** A move is "this row goes after that one", replayed on whatever order+  the plan has by then, so two people reordering different rows both keep their order.+- **Ctrl+Z is yours.** It takes back what you did and nothing anybody else did since. If a+  teammate changed a field after you, your undo leaves their value and the toast says so+  ("kept Ana's newer owner"); in a list, only the items you added or took out go back; a+  row you created that a teammate has since worked on stays. Redo works the same way.+- **An edit outlives a deletion it did not see.** If a teammate deletes a row while you are+  changing it, your change brings the row back, and you are told.+- **Nothing to refresh, nothing to clobber.** Every tab is current, and a tab that drops off+  (a laptop lid, a flaky connection) catches up on its own when it comes back.++The red banner at the bottom (*"Your changes are not saving"*) is now only for a save the+server could not take at all: the plan stopped being shared with you, your sign-in ended,+or the server could not be reached. Your edits stay on screen; **Try again** sends them+again (safe to repeat, because each change says what a field IS), **Export my version**+writes them to a file, and **Reload latest** takes the server's version after saying how+many changes it would discard. A save that failed for want of a server is retried on its+own every few seconds.  **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.+that carries them: while your saves are failing, 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.  ## Your view and the shared plan: what lives where @@ -501,7 +514,7 @@ node test/ui_test.mjs --keep --plan path/to/plan.json   # also a real plan (copi node test/ui_test.mjs --only "editor: item,text size"   # a subset of flows, for a fast inner loop ``` -**Several people at once** (0.75.1) has a suite of its own, `test/collab_test.mjs`: real Chrome tabs, each signed in as a different person through a stand-in for Adom sign-in, on one plan shaped like a real launch plan (about 490 rows, 50 sections, 190 dependencies). It plays a meeting: people join one after another, edit their own rows, look at rows, scroll and flip their own filters, all at once. It measures what each tab pays (main-thread time, the longest freeze, forced layouts, memory) and checks that every tab ends up holding exactly what the server holds with no edit lost. It also covers a save seen from every lens, presence, idle cost (critical path included), a 150-save soak for leaks, two people on one row, and a tab that drops off and catches up.+**Several people at once** (0.75.1, field-level since 0.77.0) has a suite of its own, `test/collab_test.mjs`: real Chrome tabs, each signed in as a different person through a stand-in for Adom sign-in, on one plan shaped like a real launch plan (about 490 rows, 50 sections, 190 dependencies). It plays a meeting: people join one after another, edit their own rows, look at rows, scroll and flip their own filters, all at once. It measures what each tab pays (main-thread time, the longest freeze, forced layouts, memory) and checks that every tab ends up holding exactly what the server holds with no edit lost. It also covers a save seen from every lens, presence, idle cost (critical path included), a 150-save soak for leaks, two people on one row, and a tab that drops off and catches up. Since 0.77.0 a `docs` scenario checks each Google-Docs-style behaviour above with two signed-in people, and a `fuzz` scenario has up to six people (`--users`) edit the same rows as fast as they can (fields, lists, moves, undo and redo, tabs dropping off and coming back), then checks that every tab holds exactly what the server holds and that no field only one person wrote was lost (`--seed` picks another random sequence).  ``` node test/collab_test.mjs                      # 6 people, every scenario, about 6 minutes@@ -513,7 +526,7 @@ A full run of the browser suite takes about 12 minutes (14 with `--plan`). Since  ## Collaboration -Open the same plan from any number of tabs or people (every reader signs in with Adom). Each tab is live: a save in one lands in the others within a second, merged under whatever that tab has not saved yet, so a refresh never clobbers anybody and two people editing different rows at once both land. The legend shows who else has the plan open. Two people editing the same row within a save of each other still end with that row as the later saver had it (field-level merging is next).+Open the same plan from any number of tabs or people (every reader signs in with Adom). It works the way a Google Doc does (0.77.0): each change goes to the server as the one field it touched, every open tab shows it within about half a second, and two people never lose each other's work by touching the same row. Ctrl+Z takes back only your own changes. The legend shows who else has the plan open and which rows they are on. See [Editing together](#editing-together).  It stays light with a room full of people (0.75.1). Another person's save costs an open tab about a fifth of a second on the chart on a current laptop, and under a second in any lens; someone clicking around costs the others next to nothing; and an idle tab costs nothing, critical path or not. Through 0.75.0 every save anyone made cost every other open tab 15 to 25 seconds of one CPU core: the arrows were redrawn once per bar, about 480 times, and the rows the sidecar added were placed one full layout at a time. Six people in one meeting froze each other's laptops that way. `test/collab_test.mjs` holds the line. Use Export (JSON) to hand a copy around and Import (JSON) to load one; an import replaces the plan and reloads every other open tab. 
SKILL.md+28−12
@@ -253,7 +253,7 @@ rather than a category. - **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 - **Who has it open (0.71.0).** With two or more tabs on the plan, the legend ends with each person's initials and "N tabs"; the tooltip names them, with each person's tab count. A tab checks in with the server once a minute (`POST /live/ping?cid=`) and whenever it comes back into view; a stream not heard from for 150 s (`ADOM_GANTT_LIVE_STALE_MS`) is closed and leaves the count, and a tab that reconnects replaces its old entry. Before this, connections a proxy kept open after their tabs closed were counted (one plan showed "30 here" for one person). A tab the server has forgotten (the laptop slept) reopens its stream on its next check-in. - **The Table's count says what it hides (0.71.0).** "333 of 415 items" has a tooltip listing why the rest are not shown: in collapsed sections, in sections switched off in the legend, by the legend filters (naming them: owner, tags, Current, Overdue), milestones switched off, the status filter, the search. Each hidden row counts once, under the first rule that hides it.-- **Saving: every open tab is live, and two people no longer clobber each other (0.66.0).** Each tab holds a server-sent-events stream (`GET /live`). When a save lands, every other tab is told the new revision and who made it, pulls the state and merges it under its own unsaved work: rows this tab has not touched take the server's values, rows it has keep this tab's, and so do the section list, the row order and the tag registry when this tab changed them. A tab that moved every row (a plan shift, Auto-schedule) keeps all its rows. So a refresh has nothing to clobber, and two people editing different rows in the same second both land. A save is still whole-state and still carries the revision it was based on; a refused save (409) now pulls, merges and saves again on its own, and the red banner ("Your changes are not saving") is for a SECOND refusal in a row, which means the connection and not the plan. The one case this does not yet cover: two people editing the SAME row within a save of each other end with that row as the later saver had it; field-level ops, the next checkpoint, fix that. An import replaces the plan, so every other tab reloads with a toast saying who imported. The legend shows who else has the plan open (initials, the name on hover, "N here"), nothing when you are alone. The exported snapshot has no server and so no stream.+- **Saving: field by field, the way Google Docs does it (0.77.0).** A tab sends what it CHANGED, as ops (`POST /ops`: `{op:'row', uid, set:{owner:'Ana'}, add:{deps:[..]}, del:{tags:[..]}}`, `{op:'add', row}`, `{op:'del', uid}`, `{op:'move', uid, after}`, `{op:'phases'|'reg'|'dates'|'cal', ...}`), one batch at a time, 400 ms after the last edit. The server applies each batch in arrival order onto the sidecar, makes one revision of it and announces it on the live stream (`GET /live`) to every open tab, the sender included, with the ops as stored. Each tab keeps a shadow of the server's plan; what it has not sent is the difference between the page and the shadow, so nothing has to be marked dirty. A remote op moves the shadow and, where the tab has no change of its own to that field, the page; where it has, its own value stays, because it reaches the server later and wins there too. So the last write wins PER FIELD; dependency and tag lists merge item by item; a move ("after that row") is replayed on the order as it stands; and every tab converges on exactly what the server holds. A gap in the revisions, a reconnect, or a save from something that does not send ops (an older tab's whole-state `/save`) makes the tab pull `/state` and apply it through the same rules. Ops are never refused for being stale (no 409); a bad field is skipped and named in `refused`. The same field changed by somebody else within two minutes of you setting it raises a notice naming them and your old value. An edit to a row somebody deleted, from a tab that had not seen the deletion, brings the row back (the 0.66.0 rule, now on the server). The red banner is for a save the server could not take at all (403, 401, unreachable; retried every 5 s), with Try again, Export my version and Reload latest. An import replaces the plan, so every other tab reloads with a toast saying who imported. The legend shows who else has the plan open (initials, the name on hover, "N tabs"), nothing when you are alone. The exported snapshot has no server and so no stream. - **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@@ -261,7 +261,7 @@ rather than a category. - **Arrows are coloured by where they come FROM.** Each dependency arrow, line and head together, takes the phase colour of the task at its TAIL, so the origin of a chain is readable without tracing it back and two arrows into the same row say which lane each came out of. A lane colour is chosen to work as a 20px bar and can vanish as a 1.5px line, so the stroke is derived rather than copied: hue and saturation are held and lightness is walked away from the page in 2% steps until the stroke clears a 3:1 contrast ratio against the background, which is the WCAG minimum for a graphical object. A lane painted navy therefore gets a lighter blue arrow on the dark theme and is left alone on the light one, and it still reads as that lane rather than reverting to a neutral. Recomputed on a theme change, a lane repaint and a row moving lane. The amber of a picked arrow, the red of one armed for deletion and the white of the critical path all still override it while they are active - **Arrows steer clear of the bars.** A dependency leaves the source bar's END sideways, drops, and arrives at the next bar's START sideways again, with rounded elbows, so which end is the cause is always readable. It routes AROUND the bars in between rather than through them: the drop moves to the nearest clear channel, and a long or backward route runs along the empty lane between two rows rather than across a task that has nothing to do with it. Where a bar lies clean across the only gap, the arrow goes round the end of it. Nothing to configure; it is re-decided on every redraw, including live during a drag - **Deps column** in the table: a narrow read-only indicator, needs and blocks counts with the names on hover, blank when there are none. Clicking it opens the editor where dependencies are changed. Sortable by total count-- **Ctrl+Z** to undo (100-level stack)+- **Ctrl+Z** to undo (100-level stack). **It is yours alone (0.77.0):** it takes back what you did and nothing anybody else did since. Each entry remembers the remote-change sequence number when it was made and the row as the edit left it; a field somebody else changed after that is left as it is (and named in the toast, "kept Ana's newer owner", only when your edit had changed it too); in a dependency or tag list only the items you added or removed go back; a row you created that somebody has since changed is not removed; a row move puts back only the rows that move moved, next to their old neighbours, on the order as it is now - **Ctrl+Shift+Z** (or **Ctrl+Y**) to redo. Redo only ever replays what undo just took back, so any new edit clears it. It covers every undoable action symmetrically: field edits, snapped drags, row and lane moves, deletes, creates (the row comes back with the same id and stable key), milestone edits and the whole-plan shift. Inside a text field the field's own undo and redo win, exactly as Ctrl+Z already does. Redo is logged to the changelog as its own event, tagged `redo` - **Every list walks with the arrows** (0.69.0): the status pill's and the owner picker's on the chart, the phase, status, type and "waits on" lists in the item editor and the New panel, the Table's cells, and the Export and Import menus. Down and Up move the mark, Home and End jump to the ends, a letter jumps to the next entry starting with it, Enter takes the marked entry, Escape closes the list changing nothing, and Tab closes it on the way to the next control. A list open from a click (the status pill's) holds no focus, so its keys are read while it is open; the page's own arrow keys never see them. - **Up / Down** in the table move the selected row, following the visible order under any sort or filter. They never scroll the pane on their own: the browser's default is suppressed and the pane moves only enough to keep the selected row in view. The first press selects the first visible row. In the chart the arrows are left alone@@ -800,7 +800,23 @@ curl -s -X POST localhost:8901/log -H "Authorization: Bearer $TOK" \        "to":{"note":"Venue confirmed: Hart SH-216"},"via":"agent"}]}' ``` -`/save`, `/log`, `/uid`, `/uid/backfill`, `/compact` and the four imports all take it.+`/ops`, `/save`, `/log`, `/uid`, `/uid/backfill`, `/compact` and the four imports all take it.++**Change the plan with `/ops` (0.77.0), the same ops a tab sends, carrying only the fields+you mean to change.** It lands on whatever the plan is by now, field by field, never+erases a person's other edits to the same row, and every open tab shows it at once:++```bash+curl -s -X POST localhost:8901/ops -H "Authorization: Bearer $TOK" \+  -H 'Content-Type: application/json' \+  -d '{"ops":[{"op":"row","uid":"00120","set":{"note":"venue confirmed","e":"2026-11-03"}},+             {"op":"row","uid":"00121","add":{"deps":["fab-board"]},"del":{"tags":["later"]}}]}'+```++Fields use the sidecar's names (`s`, `e`, `date`, `deps`, `note`, `owner`, `status`, `desc`,+`prov`, `tags`, `effort`, `name`, `phase`). The answer carries `stateRev`, the ops as stored,+and `refused` naming anything skipped (an unknown uid, a malformed date, the project dates+or calendar from anyone but the owner). Post the matching changelog line to `/log` after it.  Four things to know about it: @@ -819,14 +835,14 @@ Four things to know about it: **The safe write loop.** Capture `/export/json` and `/health` first. Locate items by `uid`, and check the name still matches what you were told before you write, because the user edits the same plan live and a uid whose name has changed is a decision you have not-read yet. Send `baseRev` on `/save` so a plan that moved under you comes back 409 instead-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+read yet. Write with `/ops`, which changes only the fields you name. (A whole-state `/save`+still works for a script that has to replace everything; send `baseRev` with it so a plan+that moved under you comes back 409 instead of erasing somebody.) Then re-read+`/export/json` and confirm both that your change landed and that nothing else moved.++**Your write is a revision every open tab hears about.** Ops land on every tab at once and+leave everyone's own changes alone; a whole-state `/save` makes every tab pull the plan.+Either way, a plan somebody is editing right now is a plan you should write to once, in one batch, rather than a dozen times across a long pass.  ## Dates are required@@ -888,7 +904,7 @@ is why `project.nextUid` can be higher than any uid you can see.  ## Tests -`node test/collab_test.mjs` (0.75.1) is the many-people suite: real Chrome tabs signed in as different people (a fake Carbon, the server on `--no-loopback`), a generated launch-sized plan, and scenarios for joining, a save seen from every lens, a meeting of everyone editing at once, presence, idle cost, a 150-save soak, conflicts and reconnects. It measures main-thread time, long tasks, forced layouts and memory per tab against budgets (`COLLAB_BUDGET_<NAME>` overrides one, `--throttle 4` slows every CPU), and checks every tab converges on the server's plan with no edit lost. `--users`, `--seconds`, `--only`, `--plan`, `--app <dir>` (measure another build), `--report <json>`.+`node test/collab_test.mjs` (0.75.1) is the many-people suite: real Chrome tabs signed in as different people (a fake Carbon, the server on `--no-loopback`), a generated launch-sized plan, and scenarios for joining, a save seen from every lens, a meeting of everyone editing at once, presence, idle cost, a 150-save soak, conflicts and reconnects. It measures main-thread time, long tasks, forced layouts and memory per tab against budgets (`COLLAB_BUDGET_<NAME>` overrides one, `--throttle 4` slows every CPU), and checks every tab converges on the server's plan with no edit lost. Since 0.77.0 the `docs` scenario checks the Google-Docs-style rules one by one (two fields of one row at once, the same field at once, the notice, lists merging, undo that is yours, moves, a created row a teammate changed, an edit outliving an unseen deletion) and `fuzz` has up to six people edit the same rows as fast as they can, with undo, redo, moves and tabs dropping off, then checks every field, list and the row order converge and that no field only one person wrote was lost. `--users`, `--seconds`, `--only`, `--plan`, `--app <dir>` (measure another build), `--seed` (the fuzz's sequence), `--report <json>`.  `node test/ui_test.mjs` (from the package directory) is the browser suite (since 0.66.0 it waits for the page's `html[data-ready]` marker rather than for the network to go idle, which a live stream never lets it; the `collab` flow drives two tabs on one plan), added in 0.57.0 and built to the app-testing skill. It spawns `server.js --no-auth` on scratch ports from a fixed range, 8960-8979 (`UI_TEST_PORTS=<lo>-<hi>` for another; it fails loudly when none is free and never strays outside, so other tools can safely be given ports beyond it), against a SEEDED synthetic plan in a temp dir (20 sections, 123 rows, 21-Sep-2026 to 14-Jan-2027), pins the page's clock to 29-Sep-2026, and drives Chrome from `~/.cache/puppeteer` through the vendored `test/node_modules/puppeteer-core` (`cd test && npm install` once; it does not ship). It asserts effects: the header readouts against an oracle computed from the plan the SERVER saved after each edit (drag, undo, redo, auto-schedule, table edit, delete, paste, create, start shift), the start date at every grain, text size across a refresh in the live app, in a second tab and in an exported snapshot, collapse all in both lenses and across a refresh, and the wrapped legend with the sticky axis, pinned section header and table header measured at 100% and 150% text and across a resize. A coverage manifest fails on any control nobody declared, on any declared control that never renders, and clicks every safe control. 393px, 1440px and 1920px are rendered with no sideways scroll allowed, and a header sweep (every 40px from 360 to 2000 plus 15 named widths; `SWEEP_STEP=20` for denser) asserts both readouts are whole and every header control is on screen, with and without an appName, at 100% and 150% text. Since 0.58.0 the sweep covers all four lenses, and the viewport matrix renders Analytics and Setup too (Setup with 20 sections, plain and with a rename and a remove form open). The `sections` flow drives the Setup page on a plan with rows in every section and no phase uids: after every add, reorder and remove, after each one's undo and redo, and after a reload, it compares the whole section list and every row's section with `/export/json` and checks the page repainted to match; it also covers the one-undo Save, duplicate names refused, Enter pressed five times making one section, an open Remove form surviving Ctrl+Z, the row chords doing nothing on Setup, the chart axis after adding a section, and rename, reorder and remove in an exported snapshot. Since 0.61.0 it drags a row with real mouse events (row 3 to the top, checked against `/export/json` for the order and every row's section, one `phase.reorder` event, undo and redo), checks that a 2px press is a click, Alt+Up/Down on a focused row, and the click-to-edit name and aside: Enter, a click away, Escape, a duplicate and an empty name refused. The `tags` flow (0.59.0) does the same for section tags on a plan whose last section arrives with a messy tag list: every add, comma-add, remove, rename-everywhere, remove-everywhere and prefix pass is checked against `/export/json` and the page, with its undo and redo, the `phase.tags` changelog lines (one batch, `via: setup`, and the undo and redo lines), a reload, the legend's tag chips and fold, the filter's row counts in the chart and the table, its composition with the section chips and Collapse all, its survival across a reload, a removed tag leaving it, the widths 393 and 1440, and tagging and filtering in an exported snapshot; the coverage gate walks a tagged copy of the seeded plan, with a tag registry. Since 0.61.0 it also drives New tag (and its refusal), a colour from the palette and from the hex box, No colour, the colour on a legend chip, a header chip and a Table pill by computed style on both themes (fill and a 3:1 ink), rename and remove everywhere carrying the registry, the `tag.*` changelog lines, and the registry and an unused tag across a reload. The `row tags` flow (0.60.0) drives every row-tag edit path (the item editor, the table's Tags cell, the New panel and its memory, the batch panel's add and remove, a paste, rename and remove everywhere on Setup) against `/export/json` with each one's undo and redo, checks the `tags` changelog lines and their `via`, the effective-tag filter's row counts in the chart and the table, the Analytics per-tag card against hours worked out by hand from its seed, a cleared data-file tag staying cleared across a reload, the Tags column's sort and text filter, CSV out and back in through the Import menu, no row name cut for its tags at 393 and 1440, and showing, filtering and editing row tags in an exported snapshot. `node test/server_test.mjs` is the server's own suite (ports 8940 up). Ports are checked free before use and the server answering /health must be the one the suite spawned. `--keep` leaves screenshots in `test/out/`; `--plan <file>` also runs the header and legend checks against a real plan, COPIED to a temp dir first. It exits non-zero on any failure. 
client-vs-server/README.md+5−4
@@ -25,7 +25,7 @@ Gone when you reload, and not shared even with your other tabs: |---|---| | The section, Milestone and Dependencies chips in the legend | Hiding or dimming a section is a quick look, not a setting | | Your selection, the open editor, your scroll position | Your selection is also shown to others as a highlight (see below), but it is never saved |-| Undo history | Ctrl+Z undoes this tab's own edits only |+| Undo history | Ctrl+Z undoes this tab's own edits only, and never a teammate's later change to the same field | | "See what the team sees" | The owner's preview of the team view lasts until the tab closes |  ## This browser@@ -65,9 +65,10 @@ Saved by the adom-gantt server and shared with everyone the plan is shared with. | Google Chat digest | Owner only | | Sign-in sessions and the agent key | Never shown in the page | -Edits save in about a second after you stop. If two people save at the same moment the-page merges the other person's rows under yours and saves again. If both edited the-same row within a second or two, the later save wins for that row.+Edits save about half a second after you stop, as the fields you changed and nothing else+(0.77.0), the way a Google Doc does. Two people editing different fields of one row both+keep their change; the same field at the same moment goes to the later one, in every tab,+and the person whose value was replaced is told.  ## Live, never saved 
docs/BUILD-NOTES.md+75
@@ -3,6 +3,81 @@ Decisions taken while building, and the places where the plan and the code disagreed. Newest first. +## Build notes, 2026-10-30 (0.77.0, editing together the way Google Docs does)++Drew asked how Google Docs handles several people and undo, then: "build out the gdoc style+collab and thoroughly test for functionality, robustness, and performance". This is+checkpoint 2 of docs/plan-collab.md, as Drew decided it on 2026-10-07 (field-level ops, last+writer wins per field).++What changed:++- **A tab sends what it changed, not the plan.** `POST /ops` takes a batch: `row` (fields+  with their values, and list items added or removed), `add` (a new row, whole), `del`,+  `move` (a row placed after another), `phases`, `reg`, `dates`, `cal`. The server applies a+  batch in arrival order onto the sidecar, as one revision, and announces it on `/live` to+  every tab, the sender included, with the ops as stored. Never a 409; a bad field is skipped+  and named in `refused`. The whole-state `/save` stays for older tabs and scripts.+- **No dirty tracking.** Each tab keeps a shadow of the server's plan; what it has not sent+  is the page minus the shadow (`computeOps`). The old per-row dirty sets, structural flags+  and the row-level merge are gone.+- **Applying a remote op**: the shadow takes the value; the page takes it unless this tab has+  its own change to that field (in a batch on its way, or not sent yet), which reaches the+  server later and wins there too. Lists merge item by item, keeping the sender's order for+  its own items (a renamed tag stays in place). A move is replayed on the order as it stands,+  with this tab's own pending moves on top. A gap in revisions, a reconnect or a whole-state+  save makes the tab pull `/state` and apply it as synthetic ops through the same code.+- **Batches are serial per tab** (one in flight; the next waits for the answer) and the stream+  is applied in revision order (`syncQ`), so a tab's own batch coming back is its+  confirmation, wherever it falls among other people's. Debounce 400 ms (was 800).+- **The same-field notice.** A field this person set within two minutes that somebody else+  then changes raises "Ana changed the owner of X to Kai just now (you had Noah)".+- **Undo is yours.** Each entry records the remote-change sequence number when it was made+  and, once sealed, the row as the edit left it. A field changed by somebody else since is+  left alone (and named in the toast only if this edit had changed it too); in a list, only+  the items this person added or removed go back; a created row somebody else has changed is+  not removed; a row move puts back only the rows it moved (longest increasing subsequence+  of the positions, then "after the old neighbour"). Redo works the same way.+- **An unseen deletion loses to an edit**, now on the server: a row op for a deleted row+  brings it back. Deleted created rows stay in `added` (and in `deleted`) so they can come back+  whole.+- **The red banner** is now only for a save the server could not take (403, 401,+  unreachable, 5xx); "Save mine anyway" became Try again, and a failed save retries every 5 s.+- **Table**: a batch that only changed a few rows' own fields rewrites those rows and builds+  nothing else (`patchTableRows`); a sort, a filter a row now passes or fails, a name, a+  dependency or a section change still renders the table.++Bugs the new tests found while building, all fixed: tags never landed on a row that had none+of its own (the field was skipped as "not carried"); an undo back to "no tags" took the key+off the row and the diff never sent the removal; a rename of a tag moved it to the end of+the row's list; and an item added and then removed while the add was still on its way came+back when the tab caught up by a pull after a drop-off (in-flight items now count as the+tab's own).++Measured (generated 490-row plan, this laptop, one remote change of one row's status and+end, main thread per tab, lab script `opsprof`):++| Lens | 0.76.0 (whole-state save, pull, merge) | 0.77.0 (ops) |+|---|---|---|+| Gantt | 62 to 91 ms | 9 to 20 ms |+| Table | 48 to 65 ms | 8 to 19 ms |+| Setup | 39 to 51 ms | 2 to 6 ms |+| Analytics | 63 to 95 ms | 5 to 10 ms |++Each tab no longer downloads the whole state per save (about 170 KB on the live plan); a batch+announcement is a few hundred bytes. Through the suite (6 people): an edit reaches every+other tab in 0.7 to 1.2 s (was 1.2 to 1.6 s), zero catch-up pulls in the merge scenario, the+meeting at 15% of a core per tab, the longest freeze 0.3 s, 150 merges in the soak at 60 ms+each with the heap up 0.3 MB.++Tests: `collab_test.mjs` gained `docs` (20 checks, one per rule above, two signed-in people)+and `fuzz` (up to six people editing the same rows as fast as they can: fields, lists, moves,+undo, redo, and tabs dropping off; then every field, list and the row order must match the+server in every tab, and every field only one person wrote must end as they left it;+`--seed`). Fuzz passed on seeds 7000, 11, 23, 37, 41, 101 (six people, 746 actions) and 202+at `--throttle 4`. `server_test.mjs` gained 18 checks for `/ops`. The ui `collab` flow's+409 step now reads as "a tab that was not listening saved, and caught up".+ ## Build notes, 2026-10-30 (0.76.0, a working calendar per plan, issue #3)  Issue #3 (Drew, 2026-09-29): every observed federal holiday was a day off, hardcoded, and Adom
docs/plan-collab.md+8−1
@@ -75,7 +75,14 @@ The smallest change that ends the clobbering, built on the current whole-state s This does not need the op model and takes an afternoon. It removes the refresh problem for everyone, because every tab is current before it saves. -### Checkpoint 2 (0.67.0): ops, the real fix+### Checkpoint 2: ops, the real fix. BUILT 2026-10-30 as 0.77.0, see BUILD-NOTES++Built as described below, with three changes found while building: list fields (dependencies,+tags) merge item by item rather than whole; a move is an op of its own ("after that row"),+replayed on the order as it stands; and the whole-state `/save` stays for old tabs and+scripts rather than for Auto-schedule, which sends its rows' fields as ops like any edit.+Undo is per field and personal, as planned. The original text follows.+  Saves become field-level ops (the shape above), applied and broadcast by the server. The whole-state `/save` stays for imports, the project-start shift and Auto-schedule (which
gantt.html+906−295
@@ -3215,10 +3215,73 @@ const redoStack = []; let undoGroup=null, phaseKeySeq=0; function pushUndo(sn){   if(undoGroup){ undoGroup.push(sn); return; }   // collected by withOneUndo+  sealUndo();+  // Where the plan's outside changes stood when this was done, so its undo can tell a field+  // this person set from one somebody else changed after them (0.77.0).+  if(sn.seq===undefined)sn.seq=syncSeq;   undoStack.push(sn);   if(undoStack.length>100)undoStack.splice(0,undoStack.length-100);   redoStack.length=0; }+// A row move's undo puts back only the rows THIS move moved, so it needs the order just+// after the move as well as the one before it. Taken the moment anything else happens: the+// next edit, the next save, the next change from outside.+function sealUndo(){+  const top=undoStack[undoStack.length-1];+  if(!top||top.sealed)return;+  top.sealed=true;+  if(top.type==='order'||top.type==='phase'){ if(!top.after)top.after=liveOrder(); return; }+  // A field edit keeps the row as it left it too, so its undo knows which fields IT changed+  // and does not report a field somebody else changed that it never touched.+  const seal=st=>{ if(st.type==='edit'){ const r=items.find(d=>d.id===st.id); if(r)st.after=rawRow(r); } else if(st.type==='ms'&&isLive(st.r))st.after=rawRow(st.r); };+  if(top.type==='multi')(top.steps||[]).forEach(seal); else seal(top);+}+function rawRow(r){ const o={}; Object.keys(RAW).forEach(f=>{ o[f]=cloneV(RAW[f][0](r)); }); return o; }+// ── Undo is yours alone (0.77.0) ──+// As in Google Docs: Ctrl+Z takes back what this person did, and nothing anybody else did+// since. A field somebody else changed after them is left as it is; in a list (dependencies,+// tags) only the items this person added or took out go back. The toast names what was left.+const undoKept=[];+const RAW={+  s:[r=>r.start,(r,v)=>{r.start=v;}], e:[r=>r.end,(r,v)=>{r.end=v;}], date:[r=>r.date,(r,v)=>{r.date=v;}],+  deps:[r=>r.deps||[],(r,v)=>{r.deps=Array.isArray(v)?v.slice():v;}],+  note:[r=>r.note||'',(r,v)=>{r.note=v;}],+  owner:[r=>r.owner,(r,v)=>setOpt(r,'owner',v)], status:[r=>r.status,(r,v)=>setOpt(r,'status',v)],+  desc:[r=>r.description,(r,v)=>setOpt(r,'description',v)], prov:[r=>r.provenance,(r,v)=>setProv(r,v)],+  name:[r=>r.name,(r,v)=>{r.name=v;}], tags:[r=>tagsSnap(r),(r,v)=>setRowTags(r,v)],+  effort:[r=>effortOf(r),(r,v)=>setEffort(r,v)], phase:[r=>r.phase,(r,v)=>{r.phase=v;}],+};+const rawEq=(f,a,b)=>ROW_SETS[f]?ovEq(f,a,b)&&listOf(a).join('\u0001')===listOf(b).join('\u0001'):ovEq(f,a,b);+// Put one field back to `before`, unless somebody else changed it after `seq`. `after` is the+// row as the edit left it, when known: a field the edit did not change is not reported.+function restoreField(r,f,before,seq,after){+  const [get,set]=RAW[f], cur=get(r);+  if(rawEq(f,cur,before))return false;+  const mine=!after||!(f in after)||!rawEq(f,after[f],before);+  if(ROW_SETS[f]&&r.uid){+    const k=setKey(f), B=listOf(before), C=listOf(cur), Bs=new Set(B.map(k)), Cs=new Set(C.map(k));+    const theirs=x=>(remoteSeq.get(r.uid+'|'+f+'|'+k(x))||0)>seq;+    let kept=0;+    const next=C.filter(x=>{ if(Bs.has(k(x)))return true; if(theirs(x)){ kept++; return true; } return false; });+    B.forEach(x=>{ if(Cs.has(k(x)))return; if(theirs(x)){ kept++; return; } next.push(x); });+    if(kept&&mine)undoKept.push({r,f,who:remoteWho.get(r.uid+'|'+f)||''});+    if(rawEq(f,next,cur))return false;+    set(r,f==='tags'&&before===undefined&&!next.length?undefined:next);+    return true;+  }+  if(r.uid&&(remoteSeq.get(r.uid+'|'+f)||0)>seq){ if(mine)undoKept.push({r,f,who:remoteWho.get(r.uid+'|'+f)||''}); return false; }+  set(r,cloneV(before));+  return true;+}+// The rows one move moved, put back next to the rows they followed, on the order as it is+// now: other people's moves since stay where they put them.+function selectiveOrder(before,after,live){+  const liveSet=new Set(live);+  const B=(before||[]).filter(k=>liveSet.has(k)), A=(after||live).filter(k=>liveSet.has(k));+  const res=live.slice();+  orderMoves(A,B).forEach(m=>moveInOrder(res,m.uid,m.after));+  return res;+} const UNDO_TOAST_DEFAULT='Undid change &middot; <kbd>Ctrl+Z</kbd>'; let undoTimer = null; // The snapshot carries owner, status and description too, so a table edit is one@@ -3357,15 +3420,15 @@ function removeRow(r,silent){   if(ai>=0)addedItems.splice(ai,1);   else deletedKeys.add(rowOrderKey(r));   r._wasAdded=ai>=0;-  // Whose deletion this is (0.66.0): the person's here, to be kept through a merge until-  // it is saved; or the server's, to be reversible if another tab undoes it.-  if(silent)remoteRemoved.set(rowOrderKey(r),{r,idx}); else { dirtyDel.add(rowOrderKey(r)); dirtyGen++; }+  // A deletion on the server's say-so is kept whole, so another tab's undo of it can put+  // it back here without a reload.+  if(silent)remoteRemoved.set(rowOrderKey(r),{r,idx});   items.forEach((x,i)=>{x._itemIdx=i;if(x.id&&barEls[x.id])barEls[x.id].idx=i;});   if(!silent){keepTailLast();applyFilter();refreshChartGeometry();}   return {idx,droppedDeps}; } function restoreRow(r,idx,droppedDeps){-  dirtyDel.delete(rowOrderKey(r)); remoteRemoved.delete(rowOrderKey(r));+  remoteRemoved.delete(rowOrderKey(r));   if(r._wasAdded){ if(!addedItems.includes(r))addedItems.push(r); }   else deletedKeys.delete(rowOrderKey(r));   items.splice(Math.max(0,Math.min(idx,items.length)),0,r);@@ -3796,7 +3859,8 @@ function moveFocusedRow(dir){ // delete, which are each other's inverse and take it from what removeRow returns. // Returns null when the snapshot no longer resolves, which is the old early // return: a stale entry does nothing rather than half-applying.-function applySnapshot(sn){+function applySnapshot(sn,seqIn){+  const seq=sn.seq!==undefined?sn.seq:(seqIn!==undefined?seqIn:Infinity);   // Project dates (0.75.0): the inverse is the dates as they stand.   if(sn.type==='pdates'){     const inv={type:'pdates',start:planDates.start,end:planDates.end};@@ -3810,14 +3874,16 @@ function applySnapshot(sn){     return inv;   }   if(sn.type==='order'){-    const inv={type:'order',order:items.map(rowOrderKey),+    const live=liveOrder();+    const inv={type:'order',order:live,       phases:(sn.phases||[]).map(pc=>({r:pc.r,from:pc.r.phase}))};     (sn.phases||[]).forEach(pc=>{-      pc.r.phase=pc.from;-      if(pc.r.id&&barEls[pc.r.id])barEls[pc.r.id].bar.style.background=phaseColor(pc.from);-      if(pc.r._msLine)pc.r._msLine.style.color=phaseColor(pc.from);+      if(!isLive(pc.r)||!restoreField(pc.r,'phase',pc.from,seq))return;+      if(pc.r.id&&barEls[pc.r.id])barEls[pc.r.id].bar.style.background=phaseColor(pc.r.phase);+      if(pc.r._msLine)pc.r._msLine.style.color=phaseColor(pc.r.phase);     });-    applyOrder(sn.order);+    inv.after=selectiveOrder(sn.order,sn.after,live);+    applyOrder(inv.after);     if(sn.phases&&sn.phases.length){applyCollapse();applyFilter();refreshChartGeometry();}     return inv;   }@@ -3876,10 +3942,12 @@ function applySnapshot(sn){   }   if(sn.type==='multi'){     const inv=[];-    (sn.steps||[]).forEach(st=>{ const i=applySnapshot(st); if(i)inv.unshift(i); });+    (sn.steps||[]).forEach(st=>{ const i=applySnapshot(st,seq); if(i)inv.unshift(i); });     return {type:'multi',steps:inv};   }   if(sn.type==='delete'){+    // Somebody else put it back already: there is nothing to restore.+    if(isLive(sn.r))return {type:'create',r:sn.r};     restoreRow(sn.r,sn.idx,sn.droppedDeps);     // Putting the row back is undone by taking it out again, and it is the SAME     // object, so its id and its stable key survive the round trip and everything@@ -3887,26 +3955,37 @@ function applySnapshot(sn){     return {type:'create',r:sn.r};   }   if(sn.type==='create'){+    if(!isLive(sn.r))return {type:'multi',steps:[]};+    // A row somebody else has worked on since this person made it stays: taking it out+    // would take their work with it.+    if(sn.r.uid&&(remoteRowSeq.get(sn.r.uid)||0)>seq){ undoKept.push({r:sn.r,f:'row',who:remoteWho.get(sn.r.uid+'|row')||''}); return {type:'multi',steps:[]}; }     const info=removeRow(sn.r); renderTable();     return {type:'delete',r:sn.r,idx:info?info.idx:items.length,droppedDeps:info?info.droppedDeps:[]};   }   if(sn.type==='phase'){-    const inv={type:'phase',r:sn.r,phase:sn.r.phase,order:items.map(rowOrderKey)};-    sn.r.phase=sn.phase;-    if(sn.r.id&&barEls[sn.r.id])barEls[sn.r.id].bar.style.background=phaseColor(sn.phase);-    if(sn.r._msLine)sn.r._msLine.style.color=phaseColor(sn.phase);-    applyOrder(sn.order);applyFilter();refreshChartGeometry();+    if(!isLive(sn.r))return null;+    const live=liveOrder();+    const inv={type:'phase',r:sn.r,phase:sn.r.phase,order:live};+    if(restoreField(sn.r,'phase',sn.phase,seq)){+      if(sn.r.id&&barEls[sn.r.id])barEls[sn.r.id].bar.style.background=phaseColor(sn.r.phase);+      if(sn.r._msLine)sn.r._msLine.style.color=phaseColor(sn.r.phase);+    }+    inv.after=selectiveOrder(sn.order,sn.after,live);+    applyOrder(inv.after);applyFilter();refreshChartGeometry();     return inv;   }   if(sn.type==='ms'){     const r=sn.r;+    if(!isLive(r))return null;     const inv={type:'ms',r,date:r.date,name:r.name,owner:r.owner,status:r.status,desc:r.description,prov:r.provenance,tags:tagsSnap(r)};-    r.date=sn.date;-    if(sn.name!==undefined)r.name=sn.name;-    setOpt(r,'owner',sn.owner);setOpt(r,'status',sn.status);setOpt(r,'description',sn.desc);-    if('prov' in sn)setProv(r,sn.prov);-    const tagsMoved='tags' in sn&&!sameTags(ownTags(r),sn.tags);-    if('tags' in sn)setRowTags(r,sn.tags);+    const tagsBefore=ownTags(r).slice();+    const A=sn.after;+    restoreField(r,'date',sn.date,seq,A);+    if(sn.name!==undefined)restoreField(r,'name',sn.name,seq,A);+    restoreField(r,'owner',sn.owner,seq,A); restoreField(r,'status',sn.status,seq,A); restoreField(r,'desc',sn.desc,seq,A);+    if('prov' in sn)restoreField(r,'prov',sn.prov,seq,A);+    if('tags' in sn)restoreField(r,'tags',sn.tags,seq,A);+    const tagsMoved=!sameTags(ownTags(r),tagsBefore);     refreshMsChrome(r);updateMilestone(r);     if(tagsMoved)afterTagsChange();     return inv;@@ -3919,8 +3998,9 @@ function applySnapshot(sn){         return r?{id:en.id,s:r.start,e:r.end,deps:r.deps?[...r.deps]:[],note:r.note||''}:Object.assign({},en);}),       ms:(sn.ms||[]).map(m=>({r:m.r,date:m.r.date})),       phases:(sn.phases||[]).map(x=>({p:x.p,start:x.p.start,end:x.p.end}))};-    sn.entries.forEach(en=>{const r=items.find(d=>d.id===en.id);if(!r)return;r.start=en.s;r.end=en.e;r.deps=en.deps;r.note=en.note;});-    (sn.ms||[]).forEach(m=>{m.r.date=m.date;});+    sn.entries.forEach(en=>{const r=items.find(d=>d.id===en.id);if(!r)return;+      restoreField(r,'s',en.s,seq); restoreField(r,'e',en.e,seq); restoreField(r,'deps',en.deps,seq); restoreField(r,'note',en.note,seq);});+    (sn.ms||[]).forEach(m=>{ if(isLive(m.r))restoreField(m.r,'date',m.date,seq); });     (sn.phases||[]).forEach(x=>{x.p.start=x.start;x.p.end=x.end;});     rebuildTimeline();updateBars();     return inv;@@ -3929,16 +4009,18 @@ function applySnapshot(sn){   if(!r)return null;   const inv={type:'edit',id:sn.id,s:r.start,e:r.end,deps:r.deps?[...r.deps]:[],note:r.note||'',              owner:r.owner,status:r.status,desc:r.description,prov:r.provenance,name:r.name,tags:tagsSnap(r),effort:effortOf(r)};-  const tagsMoved='tags' in sn&&!sameTags(ownTags(r),sn.tags);-  if('tags' in sn)setRowTags(r,sn.tags);-  if('effort' in sn)setEffort(r,sn.effort);-  r.start=sn.s;r.end=sn.e;r.deps=Array.isArray(sn.deps)?[...sn.deps]:sn.deps;-  if(sn.note!==undefined)r.note=sn.note;-  if('owner' in sn)setOpt(r,'owner',sn.owner);-  if('status' in sn)setOpt(r,'status',sn.status);-  if('desc' in sn)setOpt(r,'description',sn.desc);-  if('prov' in sn)setProv(r,sn.prov);-  if(sn.name!==undefined)r.name=sn.name;+  const tagsBefore=ownTags(r).slice();+  const A=sn.after;+  if('tags' in sn)restoreField(r,'tags',sn.tags,seq,A);+  if('effort' in sn)restoreField(r,'effort',sn.effort,seq,A);+  restoreField(r,'s',sn.s,seq,A); restoreField(r,'e',sn.e,seq,A); restoreField(r,'deps',Array.isArray(sn.deps)?sn.deps:[],seq,A);+  if(sn.note!==undefined)restoreField(r,'note',sn.note,seq,A);+  if('owner' in sn)restoreField(r,'owner',sn.owner,seq,A);+  if('status' in sn)restoreField(r,'status',sn.status,seq,A);+  if('desc' in sn)restoreField(r,'desc',sn.desc,seq,A);+  if('prov' in sn)restoreField(r,'prov',sn.prov,seq,A);+  if(sn.name!==undefined)restoreField(r,'name',sn.name,seq,A);+  const tagsMoved=!sameTags(ownTags(r),tagsBefore);   updateBar(sn.id);refreshLabelRow(r);refreshChartGeometry();   if(tagsMoved)afterTagsChange();   return inv;@@ -3953,10 +4035,16 @@ function timeTravel(from,to,via,label,chord){   const sn=from.pop();   const fPre=prefs.tagFilter.slice();   timeTraveling=true;+  undoKept.length=0;   let inv;   try{     inv=applySnapshot(sn);     if(inv===null)return false;+    inv.seq=syncSeq;+    // The row as this step left it, for the step back (see sealUndo).+    const seal=st=>{ if(st.type==='edit'){ const r=items.find(d=>d.id===st.id); if(r)st.after=rawRow(r); } else if(st.type==='ms'&&isLive(st.r))st.after=rawRow(st.r); };+    if(inv.type==='multi')(inv.steps||[]).forEach(seal); else seal(inv);+    inv.sealed=true;     to.push(inv);     // The reader's tag filter as the entry remembered it (a delete that pruned it, say).     if(sn.viewFilter){ prefs.tagFilter=sn.viewFilter.slice(); savePrefs(); afterTagsChange(); }@@ -3967,7 +4055,7 @@ function timeTravel(from,to,via,label,chord){   } finally { timeTraveling=false; }   if(to.length>100)to.splice(0,to.length-100);   if(prefs.view==='table')renderTable();-  undoToast.innerHTML=label+' &middot; <kbd>'+chord+'</kbd>';+  undoToast.innerHTML=label+keptNote()+' &middot; <kbd>'+chord+'</kbd>';   // 3000, was 1800. It names a keystroke, so it needs long enough to read AND act.   showToast(undoToast,3000,()=>{undoToast.innerHTML=UNDO_TOAST_DEFAULT;});   // Tagged so the changelog says which direction it was. The markdown renderer@@ -3976,6 +4064,13 @@ function timeTravel(from,to,via,label,chord){   scheduleSave(via);   return true; }+// What an undo or redo left alone because somebody else changed it after this person.+function keptNote(){+  if(!undoKept.length)return '';+  const k=undoKept[0], who=k.who||'someone else';+  const what=k.f==='row'?`kept "${k.r.name}", which ${who} changed after you made it`:`kept ${who}'s newer ${FIELD_WORD[k.f]||k.f} on "${k.r.name}"`;+  return ' &middot; '+esc(what)+(undoKept.length>1?' and '+(undoKept.length-1)+' more':'');+} function undo(){ return timeTravel(undoStack,redoStack,'undo','Undid change','Ctrl+Z'); } function redo(){ return timeTravel(redoStack,undoStack,'redo','Redid change','Ctrl+Shift+Z'); } // Is anything at all in progress? A chord that opens a panel must never fire in@@ -4287,42 +4382,222 @@ let baseRev=null; let saveBlocked=false; // ── Live tabs (0.66.0) ── //-// This tab's id, so the server can tell it which announcements are its own. What this-// tab has changed and not yet saved: rows by uid, and three structural flags. A merge-// keeps exactly these and takes everything else from the server, which is what stops-// two people in the same plan from overwriting each other on every refresh.+// This tab's id, so the server can tell it which announcements are its own. const CLIENT_ID=Math.random().toString(36).slice(2,10)+Date.now().toString(36).slice(-4);-const dirtyUids=new Set();-// Rows this tab deleted and has not saved, by key; and rows this tab removed on the-// SERVER's say-so (startup or a merge), kept whole so that an undo of that deletion in-// another tab can put them back here without a reload.-const dirtyDel=new Set();+// ── Field-level sync (0.77.0) ──+//+// How Google Docs keeps several people in one document, at the grain of a plan. A tab does+// not send its copy of the whole plan: it sends what it CHANGED (this field of this row,+// this row placed after that one, this tag added to that row), and the server applies+// every change in the order it arrives and announces it, with the revision it made, to+// every open tab including the sender. So the last write wins per FIELD, never per row;+// dependency and tag lists merge item by item; and every tab ends on exactly what the+// server holds.+//+// `shadow` is the server's plan as this tab last heard it. What this tab has changed and+// the server does not have yet is the difference between the page and the shadow, so+// nothing has to be marked and nothing can be forgotten: computeOps() reads it off. A+// change from someone else moves the shadow and, where this tab has not changed the same+// thing itself, the page. Where it has, this tab's value stays: its change reaches the+// server later and wins there too, which is what keeps the two in step.+//+// Rows this tab removed on the SERVER's say-so (startup or a merge), kept whole so that an+// undo of that deletion in another tab can put them back here without a reload. const remoteRemoved=new Map();-const dirtyStruct={phases:false,order:false,reg:false,dates:false,cal:false};-let dirtyAll=false, dirtyGen=0;-function markDirty(evs){-  (evs||[]).forEach(e=>{-    if(!e||!e.action)return;-    dirtyGen++;-    if(e.action==='plan.shift'||e.action==='auto-schedule'||e.action==='import'||e.action==='compact'){ dirtyAll=true; return; }-    if(e.action==='reorder'){ dirtyStruct.order=true; return; }-    if(/^phase\./.test(e.action)){ dirtyStruct.phases=true; return; }-    if(/^tag\./.test(e.action)){ dirtyStruct.reg=true; return; }-    if(e.action==='plan.dates'){ dirtyStruct.dates=true; return; }-    if(e.action==='plan.calendar'){ dirtyStruct.cal=true; return; }-    if(e.action==='phase'||e.action==='add'||e.action==='delete'||e.action==='restore'||e.action==='paste')dirtyStruct.order=true;-    if(e.uid){ dirtyUids.add(e.uid); return; }-    if(e.id){ const r=items.find(x=>x.id===e.id); if(r&&r.uid)dirtyUids.add(r.uid); return; }-    if(e.ms&&e.key){ const r=items.find(x=>x.type==='milestone'&&rowOrderKey(x)===e.key); if(r&&r.uid)dirtyUids.add(r.uid); }-  });-}-function dirtySnapshot(){ return {uids:[...dirtyUids],dels:[...dirtyDel],gen:dirtyGen}; }-function clearDirty(snap){-  snap.uids.forEach(u=>dirtyUids.delete(u));-  snap.dels.forEach(k=>dirtyDel.delete(k));-  // The structural flags and dirtyAll only clear when nothing was marked since the send.-  if(snap.gen===dirtyGen){ dirtyStruct.phases=dirtyStruct.order=dirtyStruct.reg=dirtyStruct.dates=dirtyStruct.cal=false; dirtyAll=false; }+const shadow={rows:new Map(),dead:new Map(),order:[],phases:'',phasesV:null,reg:'',regV:null,dates:'',datesV:null,cal:'',calV:null};+// The one batch on its way to the server: what it carried, field by field.+let inflight=null, batchN=0;+// Which fields somebody else changed in this tab, and when, as a sequence number, so an+// undo can tell a field it set from one somebody changed after it (see restoreField).+let syncSeq=0;+const remoteSeq=new Map(), remoteWho=new Map(), remoteRowSeq=new Map();+// Fields this tab set itself a moment ago, so a person whose value is replaced by somebody+// else's straight after is told: the only true conflict, the same field at the same time.+const myRecent=new Map(), RECENT_MS=120000;+const syncNotices=[];+const ROW_F=['s','e','date','deps','note','owner','status','desc','prov','tags','effort','name','phase'];+const ROW_SETS={deps:true,tags:true};+const FIELD_WORD={s:'start',e:'deadline',date:'date',deps:'dependencies',note:'bar label',owner:'owner',status:'status',desc:'description',prov:'provenance',tags:'tags',effort:'effort',name:'name',phase:'section'};+// Every row this tab has held, by uid, so one that comes back is the same object (its DOM,+// and every undo entry that names it, still resolve).+const allRows=new Map();+// The data file's rows, as opposed to the ones people created: the server keeps the two+// apart (a deleted data row is listed, a deleted created row is dropped).+const dataUids=new Set();+// A row as the sidecar has always stored it. The server keeps exactly this per row.+function rowOv(r){+  const o={uid:r.uid,owner:r.owner||'',status:r.status||'',desc:r.description||'',prov:provValue(r),name:r.name,phase:r.phase};+  if(r.type==='milestone')o.date=r.date;+  else { o.s=r.start; o.e=r.end; o.deps=r.deps||[]; o.note=r.note||''; }+  // A row's own tags (0.60.0 edits them). Written only when the row has a list, so a+  // row that never had one says nothing and the data file's value stands; an edit+  // that clears a row's tags leaves `[]` on it, which is the sidecar's "cleared".+  if(Array.isArray(r.tags))o.tags=r.tags.slice();+  // Effort (0.63.0) on every task, null when it has none: null is the sidecar's+  // "cleared", as '' is for the owner.+  if(r.type!=='milestone')o.effort=effortOf(r);+  return o;+}+const listOf=v=>Array.isArray(v)?v:[];+const setKey=f=>f==='tags'?(x=>String(x).toLowerCase()):(x=>x);+function ovEq(f,a,b){+  if(ROW_SETS[f]){ const A=listOf(a),B=listOf(b); if(A.length!==B.length)return false; const k=setKey(f),h=new Set(A.map(k)); return B.every(x=>h.has(k(x))); }+  if(f==='prov')return JSON.stringify(a===undefined||a===null?'':a)===JSON.stringify(b===undefined||b===null?'':b);+  if(f==='effort')return (a===undefined?null:a)===(b===undefined?null:b);+  return (a===undefined||a===null?'':a)===(b===undefined||b===null?'':b);+}+const cloneV=v=>Array.isArray(v)?v.slice():(v&&typeof v==='object'?Object.assign({},v):v);+// One field written into the page's model, by its sidecar name.+function setOvField(r,f,v){+  if(f==='s')r.start=v; else if(f==='e')r.end=v; else if(f==='date')r.date=v;+  else if(f==='deps')r.deps=listOf(v).slice();+  else if(f==='note')r.note=v||'';+  else if(f==='owner')setOpt(r,'owner',v);+  else if(f==='status')setOpt(r,'status',v);+  else if(f==='desc')setOpt(r,'description',v);+  else if(f==='prov')setProv(r,v);+  else if(f==='tags')setRowTags(r,normalizeTags(listOf(v)));+  else if(f==='effort'){ if(r.type!=='milestone'){ if(v===null||v===undefined)delete r.effort; else { const ef=normalizeEffort(v); if(ef!==undefined)r.effort=ef; } } }+  else if(f==='name'){ if(v)r.name=v; }+  else if(f==='phase'){ if(Number.isInteger(v)&&v>=0&&v<phases.length)r.phase=v; }+}+function isLive(r){ return !!r&&(items[r._itemIdx]===r||items.includes(r)); }+function liveRowOf(uid){ const r=allRows.get(uid); return isLive(r)?r:null; }+function liveOrder(){ return items.filter(r=>!isDerivedGroup(r)).map(rowOrderKey); }+function phasesOut(){+  return phases.map((p,i)=>{const o={i,name:p.name,start:p.start,end:p.end};+    if(p.uid)o.uid=p.uid; if(p.lead)o.lead=p.lead; if(p.color)o.color=p.color;+    o.tags=(p.tags||[]).slice(); return o;});+}+const phasesSigOf=l=>JSON.stringify(listOf(l).map(p=>[p.name,p.start||'',p.end||'',p.uid||'',p.lead||'',p.color||'',listOf(p.tags)]));+const regSigOf=l=>JSON.stringify(listOf(l).map(e=>[e.name,e.color||'']));+const datesSigOf=d=>d?(d.start||'')+'|'+(d.end||''):'';+const calSigOf=c=>c?JSON.stringify([listOf(c.workHolidays),listOf(c.extraDaysOff)]):'';+// The four plan-wide settings as this page has them. One the page never touched reads as+// the shadow's, so it is never sent: absent means the data file's, on both sides.+function curStruct(){+  return {phases:phasesSigOf(phasesOut()),+    reg:regInSidecar?regSigOf(regOut()):shadow.reg,+    dates:datesInSidecar?datesSigOf(planDates):shadow.dates,+    cal:calInSidecar?calSigOf(planCal):shadow.cal};+}+// The baseline, taken once the plan is loaded and merged with the sidecar: at that moment+// the page and the server agree by definition.+function initShadow(){+  shadow.rows.clear(); shadow.dead.clear(); dataUids.clear();+  items.forEach(r=>{ if(!r.uid||isDerivedGroup(r))return; allRows.set(r.uid,r); shadow.rows.set(r.uid,rowOv(r)); if(!addedItems.includes(r))dataUids.add(r.uid); });+  remoteRemoved.forEach((info,k)=>{ allRows.set(k,info.r); shadow.dead.set(k,rowOv(info.r)); if(!info.r._wasAdded)dataUids.add(k); });+  shadow.order=liveOrder();+  const c=curStruct();+  shadow.phases=c.phases; shadow.phasesV=phasesOut();+  shadow.reg=regInSidecar?c.reg:''; shadow.regV=regInSidecar?regOut():null;+  shadow.dates=datesInSidecar?c.dates:''; shadow.datesV=datesInSidecar?{start:planDates.start,end:planDates.end}:null;+  shadow.cal=calInSidecar?c.cal:''; shadow.calV=calInSidecar?JSON.parse(JSON.stringify(planCal)):null;+}+// What changed in one row, as one op: the plain fields with their new values, the two+// lists as what was added and what was taken out.+function rowDiff(ov,sh){+  let set=null,add=null,del=null,ord=null;+  // No tags of its own is the same as an empty list: an undo back to "none" takes the key+  // off the row, and that is still a change to send.+  if(!('tags' in ov))ov=Object.assign({},ov,{tags:[]});+  for(const f of ROW_F){+    if(!(f in ov))continue;+    if(ROW_SETS[f]){+      const k=setKey(f), A=listOf(ov[f]), B=listOf(sh[f]), As=new Set(A.map(k)), Bs=new Set(B.map(k));+      const plus=A.filter(x=>!Bs.has(k(x))), minus=B.filter(x=>!As.has(k(x)));+      if(plus.length)(add||(add={}))[f]=plus;+      if(minus.length)(del||(del={}))[f]=minus;+      // The whole list too, for its order: the server keeps this tab's items where this tab+      // has them (a tag renamed in place stays in place).+      if(plus.length||minus.length)(ord||(ord={}))[f]=A.slice();+      continue;+    }+    if(!ovEq(f,ov[f],sh[f]))(set||(set={}))[f]=cloneV(ov[f]);+  }+  if(!set&&!add&&!del)return null;+  const o={op:'row',uid:ov.uid};+  if(set)o.set=set; if(add)o.add=add; if(del)o.del=del; if(ord)o.ord=ord;+  return o;+}+// A created row, whole, in the sidecar's `added` shape.+function addedOut(r){+  const o={};+  ['type','uid','id','name','start','end','date','phase','owner','status','description','deps','note','provenance','tags','effort'].forEach(k=>{if(r[k]!==undefined)o[k]=cloneV(r[k]);});+  return o;+}+// A created task's id, as the server settled it (two people making a row of the same name+// in the same second). Dependencies name tasks by id, so they follow.+function renameRowId(r,id){+  const old=r.id;+  if(!old||!id||old===id)return;+  items.forEach(o=>{ if(Array.isArray(o.deps)&&o.deps.includes(old))o.deps=o.deps.map(d=>d===old?id:d); });+  if(barEls[old]){ barEls[id]=barEls[old]; delete barEls[old]; if(barEls[id].bar)barEls[id].bar.dataset.id=id; }+  if(r._lDiv)r._lDiv.dataset.id=id;+  if(selected.has(old)){ selected.delete(old); selected.add(id); }+  r.id=id;+}+// Place `uid` straight after `after` (null: at the top), as the server does.+function moveInOrder(order,uid,after){+  const i=order.indexOf(uid);+  if(i<0)return false;+  if(after!==null&&(after===uid||!order.includes(after)))return false;+  order.splice(i,1);+  order.splice(after===null?0:order.indexOf(after)+1,0,uid);+  return true; }+// The fewest rows to move to turn `base` into `target`, each as "after its new neighbour".+// The rows that stay are the longest run already in the right relative order (a longest+// increasing subsequence of their old positions); everything else moved, and so did any+// row `base` does not have yet.+function orderMoves(base,target){+  const pos=new Map(); base.forEach((k,i)=>pos.set(k,i));+  const seq=[]; target.forEach((k,i)=>{ if(pos.has(k))seq.push(i); });+  const tails=[],tailAt=[],prev=new Array(seq.length);+  seq.forEach((ti,j)=>{+    const v=pos.get(target[ti]); let lo=0,hi=tails.length;+    while(lo<hi){ const m=(lo+hi)>>1; if(tails[m]<v)lo=m+1; else hi=m; }+    tails[lo]=v; tailAt[lo]=j; prev[j]=lo>0?tailAt[lo-1]:-1;+  });+  const keep=new Set();+  for(let j=tails.length?tailAt[tails.length-1]:-1;j>=0;j=prev[j])keep.add(target[seq[j]]);+  const moves=[];+  target.forEach((k,i)=>{ if(!keep.has(k))moves.push({op:'move',uid:k,after:i?target[i-1]:null}); });+  return moves;+}+// Everything this tab has that the server does not, as ops, in the order the server needs+// them: new rows first (a field or a move can name them), then deletions, fields, moves,+// and the plan-wide settings.+function computeOps(){+  const adds=[],dels=[],rows=[],tail=[];+  const live=new Set();+  items.forEach(r=>{+    if(!r.uid||isDerivedGroup(r))return;+    live.add(r.uid); allRows.set(r.uid,r);+    const ov=rowOv(r), sh=shadow.rows.get(r.uid);+    if(sh){ const d=rowDiff(ov,sh); if(d)rows.push(d); return; }+    adds.push({op:'add',row:addedOut(r)});+    const dead=shadow.dead.get(r.uid);+    if(dead){ const d=rowDiff(ov,dead); if(d)rows.push(d); }+  });+  shadow.rows.forEach((_,u)=>{ if(!live.has(u))dels.push({op:'del',uid:u}); });+  const target=liveOrder(), base=shadow.order.filter(k=>live.has(k));+  let moves=orderMoves(base,target);+  // The moves are checked by replaying them; if they would not land exactly on this+  // page's order, the whole order goes instead, merged by the server like a move.+  if(moves.length){+    const sim=base.concat(target.filter(k=>!base.includes(k)));+    moves.forEach(m=>moveInOrder(sim,m.uid,m.after));+    if(sim.join('\n')!==target.join('\n'))moves=[{op:'order',order:target}];+  }+  const c=curStruct();+  if(c.phases!==shadow.phases)tail.push({op:'phases',phases:phasesOut()});+  if(regInSidecar&&c.reg!==shadow.reg)tail.push({op:'reg',tags:regOut()});+  if(datesInSidecar&&c.dates!==shadow.dates)tail.push({op:'dates',dates:{start:planDates.start,end:planDates.end}});+  if(calInSidecar&&c.cal!==shadow.cal)tail.push({op:'cal',calendar:JSON.parse(JSON.stringify(planCal))});+  return adds.concat(dels,rows,moves,tail);+}+function pendingCount(){ return computeOps().length; } // 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.@@ -4368,19 +4643,7 @@ function setProv(r,v){ if(v===undefined||v===null||v===''||(typeof v==='object'& function getState(){   return {     v: 4,-    items: items.filter(r=>r.uid&&!isDerivedGroup(r)).map(r=>{-      const o={uid:r.uid,owner:r.owner||'',status:r.status||'',desc:r.description||'',prov:provValue(r),name:r.name,phase:r.phase};-      if(r.type==='milestone')o.date=r.date;-      else { o.s=r.start; o.e=r.end; o.deps=r.deps||[]; o.note=r.note||''; }-      // A row's own tags (0.60.0 edits them). Written only when the row has a list, so a-      // row that never had one says nothing and the data file's value stands; an edit-      // that clears a row's tags leaves `[]` on it, which is the sidecar's "cleared".-      if(Array.isArray(r.tags))o.tags=r.tags.slice();-      // Effort (0.63.0) on every task, null when it has none: this page holds the whole-      // state, and null is the sidecar's "cleared", as '' is for the owner.-      if(r.type!=='milestone')o.effort=effortOf(r);-      return o;-    }),+    items: items.filter(r=>r.uid&&!isDerivedGroup(r)).map(rowOv),     // The whole section list, not just date patches by index. Sections are data     // the user edits now (rename, reorder, and soon colour and lead), so the     // sidecar has to carry what they are, not only when they run. `i` stays for a@@ -4415,6 +4678,70 @@ function getState(){ // here, which is why the four-rung "find the milestone by its key, or its name, // or its index, or its name again" ladder that used to live at this spot is gone: // with a uid there is one lookup and it either finds the row or there is no row.+// The sidecar's phase list, adopted. Returns true when the sections actually changed.+function adoptPhases(sph){+  let phasesRebuilt=false;+  if(Array.isArray(sph)&&sph.length&&sph.some(p=>p&&p.name!==undefined)){+    // Tags are newer than this shape. An entry with no `tags` key was written by a client+    // that predates them, and it inherits its section's tags from the data file, found by+    // uid and then by name, exactly as the server's merge does; `[]` clears them.+    const basePh=phases.slice();+    const baseTags=p=>{ const b=(p.uid&&basePh.find(x=>x&&x.uid===p.uid))||basePh.find(x=>x&&x.name===p.name);+      return b&&Array.isArray(b.tags)?b.tags: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;+      const t=Array.isArray(p.tags)?normalizeTags(p.tags):baseTags(p);+      if(t&&t.length)o.tags=t.slice();+      return o;+    });+    // The same sections the page already has (every live merge of a field edit, since+    // every save carries the whole list): nothing to rebuild (0.75.1). A rebuild here+    // re-derived every header, reflowed both panes and re-filtered on every save anyone+    // made, and reset the reader's own dimmed sections in the legend to visible.+    const phSig=p=>JSON.stringify([p.name,p.start||'',p.end||'',p.uid||'',p.lead||'',p.color||'',p.tags||[]]);+    const unchanged=rebuilt.length===phases.length&&rebuilt.every((p,i)=>phSig(p)===phSig(phases[i]));+    if(!unchanged){+      // A section dimmed in this tab's legend stays dimmed, found by uid, then by name.+      const wasVis=new Map(); phases.forEach((p,i)=>{ wasVis.set('u:'+(p.uid||''),phaseVis[i]); wasVis.set('n:'+p.name,phaseVis[i]); });+      phases.length=0; phases.push(...rebuilt);+      phaseVis.length=0; phases.forEach(p=>{ const v=p.uid&&wasVis.has('u:'+p.uid)?wasVis.get('u:'+p.uid):wasVis.has('n:'+p.name)?wasVis.get('n:'+p.name):true; phaseVis.push(v!==false); });+      // 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=sv&&phases[sv.i]; if(p){ if(sv.start)p.start=sv.start; if(sv.end)p.end=sv.end;+      if(Array.isArray(sv.tags)){ const t=normalizeTags(sv.tags); if(t.length)p.tags=t; else delete p.tags; } } });+  }+  return phasesRebuilt;+}+// One row's sidecar override, applied to the row, exactly as the server's merge does. Shared+// by the startup merge and by a full catch-up (pullOnce), which rebuilds a row the server+// put back from the copy this tab still holds.+function applyOverrideRow(r,sv,phaseN){+  if(sv.s)r.start=sv.s;+  if(sv.e)r.end=sv.e;+  if(sv.date)r.date=sv.date;+  if(sv.deps)r.deps=sv.deps;+  if(sv.note!==undefined)r.note=sv.note;+  if(sv.owner!==undefined)setOpt(r,'owner',sv.owner);+  if(sv.status!==undefined)setOpt(r,'status',sv.status);+  if(sv.desc!==undefined)setOpt(r,'description',sv.desc);+  if(sv.prov!==undefined)setProv(r,sv.prov);+  // Absent: the data file's tags stand. [] : the user cleared them. As the server merges.+  if(Array.isArray(sv.tags)){ const t=normalizeTags(sv.tags); r.tags=t; }+  // Effort: absent, the data file's stands; null, cleared; a number, set. As the server.+  if('effort' in sv&&r.type!=='milestone'){ if(sv.effort===null)delete r.effort; else { const ef=normalizeEffort(sv.effort); if(ef!==undefined)r.effort=ef; } }+  if(sv.name)r.name=sv.name;+  if(Number.isInteger(sv.phase)&&sv.phase>=0&&sv.phase<phaseN)r.phase=sv.phase;+  return r;+} function applyState(state){   if(!state||Array.isArray(state))return;   // Created rows first: the overrides, the deletions and the order all name@@ -4453,22 +4780,7 @@ function applyState(state){   const phaseN=sphFull?state.phases.length:phases.length;   (state.items||[]).forEach(sv=>{     if(!sv||!sv.uid)return;-    const r=byUid.get(sv.uid);if(!r)return;-    if(sv.s)r.start=sv.s;-    if(sv.e)r.end=sv.e;-    if(sv.date)r.date=sv.date;-    if(sv.deps)r.deps=sv.deps;-    if(sv.note!==undefined)r.note=sv.note;-    if(sv.owner!==undefined)setOpt(r,'owner',sv.owner);-    if(sv.status!==undefined)setOpt(r,'status',sv.status);-    if(sv.desc!==undefined)setOpt(r,'description',sv.desc);-    if(sv.prov!==undefined)setProv(r,sv.prov);-    // Absent: the data file's tags stand. [] : the user cleared them. As the server merges.-    if(Array.isArray(sv.tags)){ const t=normalizeTags(sv.tags); r.tags=t; }-    // Effort: absent, the data file's stands; null, cleared; a number, set. As the server.-    if('effort' in sv&&r.type!=='milestone'){ if(sv.effort===null)delete r.effort; else { const ef=normalizeEffort(sv.effort); if(ef!==undefined)r.effort=ef; } }-    if(sv.name)r.name=sv.name;-    if(Number.isInteger(sv.phase)&&sv.phase>=0&&sv.phase<phaseN)r.phase=sv.phase;+    const r=byUid.get(sv.uid);if(r)applyOverrideRow(r,sv,phaseN);   });   // 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@@ -4479,46 +4791,7 @@ function applyState(state){   // 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)){-    // Tags are newer than this shape. An entry with no `tags` key was written by a client-    // that predates them, and it inherits its section's tags from the data file, found by-    // uid and then by name, exactly as the server's merge does; `[]` clears them.-    const basePh=phases.slice();-    const baseTags=p=>{ const b=(p.uid&&basePh.find(x=>x&&x.uid===p.uid))||basePh.find(x=>x&&x.name===p.name);-      return b&&Array.isArray(b.tags)?b.tags: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;-      const t=Array.isArray(p.tags)?normalizeTags(p.tags):baseTags(p);-      if(t&&t.length)o.tags=t.slice();-      return o;-    });-    // The same sections the page already has (every live merge of a field edit, since-    // every save carries the whole list): nothing to rebuild (0.75.1). A rebuild here-    // re-derived every header, reflowed both panes and re-filtered on every save anyone-    // made, and reset the reader's own dimmed sections in the legend to visible.-    const phSig=p=>JSON.stringify([p.name,p.start||'',p.end||'',p.uid||'',p.lead||'',p.color||'',p.tags||[]]);-    const unchanged=rebuilt.length===phases.length&&rebuilt.every((p,i)=>phSig(p)===phSig(phases[i]));-    if(!unchanged){-      // A section dimmed in this tab's legend stays dimmed, found by uid, then by name.-      const wasVis=new Map(); phases.forEach((p,i)=>{ wasVis.set('u:'+(p.uid||''),phaseVis[i]); wasVis.set('n:'+p.name,phaseVis[i]); });-      phases.length=0; phases.push(...rebuilt);-      phaseVis.length=0; phases.forEach(p=>{ const v=p.uid&&wasVis.has('u:'+p.uid)?wasVis.get('u:'+p.uid):wasVis.has('n:'+p.name)?wasVis.get('n:'+p.name):true; phaseVis.push(v!==false); });-      // 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=sv&&phases[sv.i]; if(p){ if(sv.start)p.start=sv.start; if(sv.end)p.end=sv.end;-      if(Array.isArray(sv.tags)){ const t=normalizeTags(sv.tags); if(t.length)p.tags=t; else delete p.tags; } } });-  }+  const phasesRebuilt=adoptPhases(state.phases);   // 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.@@ -4560,14 +4833,25 @@ let moveHint=null; function nameOf(id){const r=items.find(d=>d.id===id);return r?r.name:id;} function phaseNameAt(i){const p=phases[i];return p?p.name:String(i);} function planStartOf(snap){let m=null;Object.values(snap.byId).forEach(v=>{if(v.start&&(!m||v.start<m))m=v.start;});return m;}+function snapTask(r){ return {start:r.start,end:r.end,deps:(r.deps||[]).slice(),note:r.note||'',owner:r.owner||'',status:statusOf(r),desc:r.description||'',prov:provSig(r),provL:provLabel(r),name:r.name||'',phase:r.phase,tags:ownTags(r).slice(),eff:effortOf(r)}; }+function snapMs(r){ return {name:r.name,date:r.date,owner:r.owner||'',status:r.status||'',desc:r.description||'',prov:provSig(r),provL:provLabel(r),phase:r.phase,tags:ownTags(r).slice()}; }+// Somebody else's field edits, written into the baseline so the next diff does not log+// them as this person's. Whole rows only; anything that moved the plan's shape takes a+// fresh snapshot instead.+function patchSnap(rows){+  if(!lastSnap)return;+  rows.forEach(r=>{ if(!isLive(r))return;+    if(r.id&&lastSnap.byId[r.id])lastSnap.byId[r.id]=snapTask(r);+    else if(r.type==='milestone'&&lastSnap.ms[rowOrderKey(r)])lastSnap.ms[rowOrderKey(r)]=snapMs(r); });+} function snapSemantic(){   const byId={};-  items.forEach(r=>{if(r.id)byId[r.id]={start:r.start,end:r.end,deps:(r.deps||[]).slice(),note:r.note||'',owner:r.owner||'',status:statusOf(r),desc:r.description||'',prov:provSig(r),provL:provLabel(r),name:r.name||'',phase:r.phase,tags:ownTags(r).slice(),eff:effortOf(r)};});+  items.forEach(r=>{if(r.id)byId[r.id]=snapTask(r);});   // Keyed by the stable row key, not by the name. Keying by name made a rename   // look like one milestone vanishing and another appearing, so it logged nothing   // (both sides early-return on an unseen name) and instead surfaced as a phantom   // "reorder". It also made one of any same-named pair invisible to the diff.-  const ms={};items.filter(r=>r.type==='milestone').forEach(r=>{ms[rowOrderKey(r)]={name:r.name,date:r.date,owner:r.owner||'',status:r.status||'',desc:r.description||'',prov:provSig(r),provL:provLabel(r),phase:r.phase,tags:ownTags(r).slice()};});+  const ms={};items.filter(r=>r.type==='milestone').forEach(r=>{ms[rowOrderKey(r)]=snapMs(r);});   // Section tags, keyed by the section's client identity (phaseKey), so a rename or a   // reorder between two snapshots is not read as a tag change. A section that exists on   // one side only (created, removed) has its own event already and adds none here.@@ -4584,6 +4868,7 @@ function captureBaselineSnapshot(){ lastSnap=snapSemantic(); } function diffAndLog(via){+  sealUndo();   const cur=snapSemantic(),evs=[];   // Project dates (0.75.0): one line, both ends, the way the server describes it.   if(lastSnap.dates&&(lastSnap.dates.start!==cur.dates.start||lastSnap.dates.end!==cur.dates.end))@@ -4768,7 +5053,6 @@ function diffAndLog(via){ function postLog(events){   if(!events.length)return;   unsavedCount+=events.length;-  markDirty(events);   logQueue.push(...events);   if(logQueue.length>LOG_QUEUE_MAX)logQueue.splice(0,logQueue.length-LOG_QUEUE_MAX);   refreshConflictCount();@@ -4798,7 +5082,10 @@ function refreshViewChips(){   items.forEach(r=>{ if(isGroup(r))return; const od=isOverdue(r);     if(r._lDiv)r._lDiv.classList.toggle('is-overdue',od); if(r._bDiv)r._bDiv.classList.toggle('is-overdue',od); }); }-function scheduleSave(via){refreshViewChips();if(tagsReady&&tagSigNow()!==tagSig)afterTagsChange();diffAndLog(via||'edit');syncPlanSpan();syncCollapseAllBtn();if(critOn())refreshCritical();syncPlanCtl();if(prefs.view==='analytics')renderAnalytics();else if(prefs.view==='setup')renderSections(true);clearTimeout(saveTimer);saveTimer=setTimeout(()=>{saveTimer=null;saveToFile();},800);}+// How long a burst of edits gathers before it goes out (0.77.0, was 800 ms): a batch is a+// few fields now, not the whole plan, so the team sees an edit sooner.+const SAVE_DEBOUNCE_MS=400;+function scheduleSave(via){refreshViewChips();if(tagsReady&&tagSigNow()!==tagSig)afterTagsChange();diffAndLog(via||'edit');syncPlanSpan();syncCollapseAllBtn();if(critOn())refreshCritical();syncPlanCtl();if(prefs.view==='analytics')renderAnalytics();else if(prefs.view==='setup')renderSections(true);clearTimeout(saveTimer);saveTimer=setTimeout(()=>{saveTimer=null;saveToFile();},SAVE_DEBOUNCE_MS);} // 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@@ -4812,23 +5099,18 @@ 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;-  // With the rev (0.66.0): a tab put away while stale LOSES its last edits rather than-  // writing over somebody else's, which is the only honest outcome a beacon can have,-  // and a tab that heard every announcement is not stale.-  const fs0=getState(); if(typeof baseRev==='number')fs0.baseRev=baseRev; fs0.client=CLIENT_ID;-  const body=JSON.stringify(fs0);-  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){}+  // What this tab has that the server does not, as ops (0.77.0). A batch on its way is in+  // it too, which is harmless: an op says what a field IS, so landing twice changes nothing.+  const ops=computeOps();+  if(ops.length){+    const body=JSON.stringify({client:CLIENT_ID,batch:CLIENT_ID+'.'+(++batchN),ops});+    let sent=false;+    try{+      if(navigator.sendBeacon)sent=navigator.sendBeacon('ops',new Blob([body],{type:'application/json'}));+    }catch(e){}+    if(!sent){+      try{fetch('ops',{method:'POST',headers:{'Content-Type':'application/json'},body,keepalive:true});}catch(e){}+    }   }   flushLogBeacon(); }@@ -4858,124 +5140,428 @@ document.addEventListener('visibilitychange',()=>{if(document.visibilityState=== // 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.-let saveInFlight=false, pullAfterSave=false, recovering=false;+let saveInFlight=false, saveRetry=null;+// The one save path (0.77.0). What this tab has that the server does not goes out as ops,+// one batch at a time: the next waits for this one's answer, so the server sees this tab's+// changes in the order they were made. Never refused for being stale; a refusal now means+// the person may not write, or the server could not be reached. function saveToFile(){   if(SNAPSHOT){flash(saveInd,'snapshot mode');return;}-  if(saveInFlight){ clearTimeout(saveTimer); saveTimer=setTimeout(()=>{saveTimer=null;saveToFile();},300); return; }-  const payload=getState();-  if(typeof baseRev==='number')payload.baseRev=baseRev;-  payload.client=CLIENT_ID;-  const sent=dirtySnapshot();-  saveInFlight=true;-  fetch('save',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)})+  if(inflight||syncBusy){ clearTimeout(saveTimer); saveTimer=setTimeout(()=>{saveTimer=null;saveToFile();},250); return; }+  clearTimeout(saveRetry); saveRetry=null;+  sealUndo();+  const ops=computeOps();+  if(!ops.length){ if(unsavedCount||logQueue.length)onSaveOk('saved'); return; }+  const b={batch:CLIENT_ID+'.'+(++batchN),ops,rev:null,vals:new Map(),fields:new Map(),adds:new Set(),dels:new Set(),order:null,struct:curStruct(),sent:{}};+  ops.forEach(op=>{+    if(op.op==='row'||op.op==='add'){+      const uid=op.op==='add'?op.row.uid:op.uid, r=allRows.get(uid);+      if(!r)return;+      b.vals.set(uid,rowOv(r));+      const fs=b.fields.get(uid)||new Set();+      if(op.op==='add'){ b.adds.add(uid); ROW_F.forEach(f=>fs.add(f)); }+      else [op.set,op.add,op.del].forEach(o=>{ if(o)Object.keys(o).forEach(f=>fs.add(f)); });+      b.fields.set(uid,fs);+    } else if(op.op==='del')b.dels.add(op.uid);+    else if(op.op==='phases'||op.op==='reg'||op.op==='dates'||op.op==='cal')b.sent[op.op]=true;+  });+  if(ops.some(o=>o.op==='move'||o.op==='order'||o.op==='add'||o.op==='del'))b.order=liveOrder();+  inflight=b; saveInFlight=true;+  fetch('ops',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({client:CLIENT_ID,batch:b.batch,ops})})     .then(async r=>{       saveInFlight=false;       const d=await r.json().catch(()=>({}));-      if(r.status===409){-        // Somebody saved since this tab last looked. Through 0.65 this raised the red-        // banner and left the person to discard or overwrite. Now the tab pulls the-        // server's state, keeps its own unsaved rows, and saves again on the new rev. The-        // banner is for a SECOND refusal in a row, which means something other than an-        // ordinary crossing of two edits.-        if(!recovering){ recovering=true; pullAndMerge().then(()=>{ saveToFile(); }).catch(()=>{ recovering=false; onSaveBlocked('conflict'); }); }-        else { recovering=false; onSaveBlocked('conflict'); }+      if(!r.ok){+        if(inflight===b)inflight=null;+        if(r.status===403)onSaveBlocked('This plan is not shared with you any more, so the server refused the save.');+        else if(r.status===401)onSaveBlocked('Your sign-in has ended, so the server refused the save. Export your version, then reload to sign in again.');+        else { onSaveBlocked('The server refused the save ('+r.status+').'); retrySaveSoon(); }         return;       }-      recovering=false;-      if(r.status===403){ onSaveBlocked('This plan is not shared with you any more, so the server refused the save.'); return; }-      if(r.status===401){ onSaveBlocked('Your sign-in has ended, so the server refused the save. Export your version, then reload to sign in again.'); return; }-      if(!r.ok){ onSaveBlocked('The server refused the save ('+r.status+').'); return; }-      if(typeof d.stateRev==='number')baseRev=d.stateRev;-      clearDirty(sent);       onSaveOk('saved');-      if(pullAfterSave){ pullAfterSave=false; pullAndMerge(); }+      if(inflight!==b)return;          // a catch-up settled it already+      const out=Array.isArray(d.ops)?d.ops:[];+      b.rev=typeof d.stateRev==='number'?d.stateRev:null;+      // Nothing taken (every op refused, or none changed anything): no revision was made+      // and none will be announced, so it settles here.+      if(!out.length||b.rev===null){ syncQ(()=>{ if(inflight===b){ inflight=null; applyOps([],{settle:b}); afterSync(); } }); return; }+      syncQ(()=>{+        if(inflight!==b)return;+        if(b.rev<=(baseRev||0)){ inflight=null; applyOps([],{settle:b}); afterSync(); return; }+        // No stream to hear it on: this answer is the announcement, when it is the next one.+        if((!live||live.readyState!==1)&&b.rev===(baseRev||0)+1){ inflight=null; applyOps(out,{mine:b,settle:b}); baseRev=b.rev; afterSync(); }+      });+      // The announcement normally lands before this answer or just after it. If it has not+      // in a few seconds the stream is behind or gone, and a catch-up settles everything.+      setTimeout(()=>{ if(inflight===b)pullAndMerge().catch(()=>{}); },3000);     })-    .catch(()=>{ saveInFlight=false; onSaveBlocked('The server could not be reached.'); });-}-// Pull the server's state and merge it under this tab's unsaved work: every row this tab-// has not touched takes the server's version, the rows it has keep this tab's, and so do-// the section list, the order and the tag registry when this tab changed them. A tab-// that moved every row (a plan shift, Auto-schedule) keeps all of its rows. Then every-// lens repaints and the baseline for the changelog diff is re-taken, so another person's-// edits are never logged as this tab's. Until field-level ops land (the next checkpoint)-// two people editing the SAME row within a save of each other still end with that row-// as the later saver had it; everything else is kept on both sides.-// One pull at a time (0.75.1). Every announcement of another person's save started its own-// pull, so a burst of saves ran several pulls and full repaints over each other. Now a save-// announced during a pull queues exactly one more, and every caller's promise settles once-// the page has caught up with all of them.-let pullChain=null, pullAgain=false, pullCount=0, liveMerging=false;+    .catch(()=>{ saveInFlight=false; if(inflight===b)inflight=null; onSaveBlocked('The server could not be reached.'); retrySaveSoon(); });+}+// A save that failed for want of a server is tried again on its own. Ops are safe to send+// twice: each says what a field is, which item goes in or out of a list, or which row a row+// follows, so one that lands twice changes nothing the second time.+function retrySaveSoon(){ clearTimeout(saveRetry); saveRetry=setTimeout(()=>{ saveRetry=null; if(!saveTimer)saveToFile(); },5000); }+// One thing at a time touches the page's copy of the plan from outside: an announcement, a+// catch-up, a batch's answer. They queue here, so they apply in the order they arrived.+let syncChain=Promise.resolve(), syncBusy=false, pullQueued=null, pullCount=0, liveMerging=false;+function syncQ(fn){+  const p=syncChain.then(async()=>{ syncBusy=true; try{ return await fn(); } finally{ syncBusy=false; } });+  syncChain=p.catch(()=>{});+  return p;+}+// A full catch-up, for when the announcements cannot be trusted to have arrived: a gap in+// the revisions, a reconnect, a save made by something that does not send ops (an older+// tab, a script writing the whole state). One at a time; a request made while one waits+// is the same request. function pullAndMerge(){-  if(pullChain){ pullAgain=true; return pullChain; }-  pullChain=(async()=>{-    try{ do{ pullAgain=false; pullCount++; await pullOnce(); }while(pullAgain); }-    finally{ pullChain=null; }-  })();-  return pullChain;+  if(pullQueued)return pullQueued;+  pullQueued=syncQ(async()=>{ pullQueued=null; pullCount++; await pullOnce(); });+  return pullQueued; } async function pullOnce(){   if(SNAPSHOT)return;   const r=await fetch('state'); if(!r.ok)throw new Error('state '+r.status);   const st=await r.json();-  if(!st||st.empty){ baseRev=typeof st.stateRev==='number'?st.stateRev:0; return; }-  const mine=getState();-  if(dirtyAll){ st.items=mine.items; st.phases=mine.phases; st.order=mine.order; }-  else{-    if(dirtyUids.size){-      const byUid=new Map((st.items||[]).map(o=>[o.uid,o]));-      mine.items.forEach(o=>{ if(dirtyUids.has(o.uid))byUid.set(o.uid,o); });-      st.items=[...byUid.values()];-    }-    if(dirtyStruct.phases)st.phases=mine.phases;-    if(dirtyStruct.order)st.order=mine.order;-  }-  if(dirtyStruct.reg&&mine.tags)st.tags=mine.tags;-  if(dirtyStruct.dates&&mine.dates)st.dates=mine.dates;-  if(dirtyStruct.cal&&mine.calendar)st.calendar=mine.calendar;-  const srvAdded=new Set((st.added||[]).map(a=>a.uid)), srvItems=new Set((st.items||[]).map(o=>o.uid));-  st.added=(st.added||[]).concat((mine.added||[]).filter(a=>a.uid&&!srvAdded.has(a.uid)&&!srvItems.has(a.uid)));-  // Deletions: the server's list, plus this tab's own unsaved ones, minus any row this-  // tab has unsaved edits on (its edit outlives a deletion it has not seen). A row this-  // tab removed on the server's say-so earlier, and the server no longer lists as-  // deleted, was un-deleted somewhere else: it comes back here, whole.-  const srvDel=new Set(st.deleted||[]);-  for(const [k,info] of [...remoteRemoved]){ if(!srvDel.has(k)&&!dirtyDel.has(k)){ remoteRemoved.delete(k); restoreRow(info.r,Math.min(info.idx,items.length),[]); deletedKeys.delete(k); } }-  st.deleted=[...new Set([...srvDel,...dirtyDel])].filter(k=>!dirtyUids.has(k)||dirtyDel.has(k));-  // Another person's rows can come, go or move above the one this reader is looking at;-  // it stays where it is on screen, and the two panes stay one scroll (0.74.1).+  const rev=st&&typeof st.stateRev==='number'?st.stateRev:0;+  const late=inflight&&inflight.rev!==null&&inflight.rev<=rev;+  if(!st||st.empty){ baseRev=Math.max(baseRev||0,rev); return; }+  if(rev<=(baseRev||0)&&!late)return;+  applyOps(stateOps(st),{actor:liveLastActor||'Someone'});+  baseRev=Math.max(baseRev||0,rev);+  // This tab's batch is already in what came back: whatever it sent and has not changed+  // since takes the server's value, which is the batch's, or a later one's.+  if(inflight&&inflight.rev!==null&&inflight.rev<=baseRev){ const b=inflight; inflight=null; applyOps([],{settle:b}); }+  liveMerges++;+  afterSync();+}+// The server's whole state as the ops that would turn the shadow into it, so a catch-up is+// applied by exactly the code that applies an announcement, with the same rules about what+// this tab has changed and not sent.+function stateOps(st){+  const ops=[];+  const gone=new Set(Array.isArray(st.deleted)?st.deleted:[]);+  const added=new Map(); (Array.isArray(st.added)?st.added:[]).forEach(a=>{ if(a&&a.uid)added.set(a.uid,a); });+  const entries=new Map(); (Array.isArray(st.items)?st.items:[]).forEach(o=>{ if(o&&o.uid)entries.set(o.uid,o); });+  const live=new Set();+  dataUids.forEach(u=>{ if(!gone.has(u))live.add(u); });+  added.forEach((_,u)=>{ if(!gone.has(u))live.add(u); });+  shadow.rows.forEach((_,u)=>{ if(!live.has(u))ops.push({op:'del',uid:u}); });+  const phaseN=Array.isArray(st.phases)&&st.phases.length&&st.phases.some(p=>p&&p.name!==undefined)?st.phases.length:phases.length;+  live.forEach(u=>{+    if(shadow.rows.has(u))return;+    const base=added.get(u)||allRows.get(u);+    if(!base)return;+    const row=Object.assign({},base); delete row._lDiv; delete row._bDiv; delete row._msLine;+    const sv=entries.get(u); if(sv)applyOverrideRow(row,sv,phaseN);+    ops.push({op:'add',uid:u,row:addedOut(row)});+  });+  entries.forEach((sv,u)=>{+    if(!live.has(u))return;+    const set={}; ROW_F.forEach(f=>{ if(sv[f]!==undefined)set[f]=cloneV(sv[f]); });+    ops.push({op:'row',uid:u,set});+  });+  if(Array.isArray(st.order))ops.push({op:'order',order:st.order});+  if(Array.isArray(st.phases)&&st.phases.length&&st.phases.some(p=>p&&p.name!==undefined))ops.push({op:'phases',phases:st.phases});+  if(Array.isArray(st.tags))ops.push({op:'reg',tags:st.tags});+  if(st.dates&&typeof st.dates==='object'&&!Array.isArray(st.dates))ops.push({op:'dates',dates:st.dates});+  if(st.calendar&&typeof st.calendar==='object'&&!Array.isArray(st.calendar))ops.push({op:'cal',calendar:st.calendar});+  return ops;+}+// After the page caught up: whatever it still has that the server does not goes out soon,+// and a tab that was blocked for want of a server is unblocked by having reached it.+function afterSync(){+  if(saveBlocked&&/could not be reached|refused the save \((5\d\d|0)\)/.test(blockedWhy))clearConflict('back in sync');+  if(!saveTimer&&!inflight&&computeOps().length){ saveTimer=setTimeout(()=>{saveTimer=null;saveToFile();},150); }+}+// An announcement: a batch the server applied, with the revision it made. The next one in+// line is applied as it stands; this tab's own batch coming back is its confirmation; a+// gap, or a save that carried no ops, means a catch-up.+function onRev(m){+  if(typeof m.stateRev!=='number')return;+  if(m.by!==CLIENT_ID&&m.actor)liveLastActor=m.actor;+  syncQ(async()=>{+    if(m.stateRev<=(baseRev||0))return;+    if(!Array.isArray(m.ops)||m.stateRev!==(baseRev||0)+1){ pullCount++; await pullOnce(); return; }+    const b=inflight&&m.by===CLIENT_ID&&m.batch===inflight.batch?inflight:null;+    if(b){ inflight=null; applyOps(m.ops,{mine:b,settle:b}); }+    else { applyOps(m.ops,{actor:m.actor||'Someone',self:m.by===CLIENT_ID}); liveMerges++; }+    baseRev=m.stateRev;+    afterSync();+  }).catch(()=>{});+}+// ── Applying ops to the page ──+// One batch, applied to the page and repainted once. `mine` is this tab's own batch coming+// back (its values are compared with what it SENT, so a field changed again since is kept);+// `settle` hands back whatever that batch sent and the server did not take.+function applyOps(ops,ctx){+  ctx=ctx||{};+  sealUndo();+  const T={rows:new Set(),fields:new Set(),fresh:new Set(),structure:false,order:false,phases:false,reg:false,dates:false,cal:false};   const mark=placeMark();-  // Rows the other side DELETED and this tab has not touched: applyState removes them-  // (and removeRow records them as the server's). Rows it created: applyState inserts-  // them. Everything else is a field write.-  // Everything below draws the arrows at most once, and the table at most once, when it-  // is done (0.75.1).+  const range0=prefs.view==='gantt'?JSON.stringify(scanRange()):'';   liveMerging=true;   try{-  withTable(()=>withDeps(()=>{-  applyState(st);-  items.forEach(r=>{ if(r.id)refreshLabelRow(r); else if(r.type==='milestone')refreshMsChrome(r); });-  Object.keys(barEls).forEach(paintBar);-  if(tagsReady){ pruneTagFilter(); syncTagList(); tagSig=tagSigNow(); }-  rebuildLegend(); applyCollapse();-  if(prefs.view==='gantt')rebuildTimeline(); else { timelineStale=true; syncPlanSpan(); }+    withTable(()=>withDeps(()=>{+      inRowBatch(()=>{+        (ops||[]).forEach(op=>{ if(op&&typeof op==='object')applyOne(op,ctx.mine||null,ctx.self?'':ctx.actor||'Someone',T); });+        if(ctx.settle)settleSent(ctx.settle,T);+      });+      repaintAfter(T,range0);+    }));+    if(T.structure)keepPlace(mark);+    if(edSubject&&(T.rows.has(edSubject)||T.structure))syncEditorDates(edSubject);+  }finally{ liveMerging=false; }+  if(T.structure||T.phases||T.reg||T.dates||T.cal)lastSnap=snapSemantic();+  else if(T.rows.size)patchSnap(T.rows);+  return T;+}+function applyOne(op,mine,who,T){+  if(op.op==='row')return applyRowOp(op,mine,who,T);+  if(op.op==='add')return applyAddOp(op,mine,who,T);+  if(op.op==='del')return applyDelOp(op,mine,who,T);+  if(op.op==='order')return rebaseOrder(op.order,mine,T);+  const flying=k=>!mine&&!!inflight&&!!inflight.sent[k];+  const sentDiff=(k,cur)=>mine&&mine.sent[k]?cur!==mine.struct[k]:false;+  if(op.op==='phases'){+    if(!Array.isArray(op.phases))return;+    const sig=phasesSigOf(op.phases), old=shadow.phases;+    shadow.phases=sig; shadow.phasesV=op.phases;+    const cur=phasesSigOf(phasesOut());+    const pending=mine?sentDiff('phases',cur):(flying('phases')||cur!==old);+    if(pending||cur===sig)return;+    if(adoptPhases(op.phases)){ T.phases=true; T.structure=true; }+    return;+  }+  if(op.op==='reg'){+    if(!Array.isArray(op.tags))return;+    const sig=regSigOf(op.tags), old=shadow.reg;+    shadow.reg=sig; shadow.regV=op.tags;+    const cur=regInSidecar?regSigOf(regOut()):old;+    const pending=mine?sentDiff('reg',cur):(flying('reg')||cur!==old);+    if(pending)return;+    regInSidecar=true;+    if(cur===sig)return;+    tagReg=(normalizeTagRegistry(op.tags)||[]).map(regEntry); T.reg=true;+    return;+  }+  if(op.op==='dates'){+    const d={start:cleanDay(op.dates&&op.dates.start),end:cleanDay(op.dates&&op.dates.end)}, sig=datesSigOf(d), old=shadow.dates;+    shadow.dates=sig; shadow.datesV=d;+    const cur=datesInSidecar?datesSigOf(planDates):old;+    const pending=mine?sentDiff('dates',cur):(flying('dates')||cur!==old);+    if(pending)return;+    datesInSidecar=true;+    if(cur===sig)return;+    planDates=d; T.dates=true;+    return;+  }+  if(op.op==='cal'){+    const c=cleanCal(op.calendar); if(!c)return;+    const sig=calSigOf(c), old=shadow.cal;+    shadow.cal=sig; shadow.calV=c;+    const cur=calInSidecar?calSigOf(planCal):old;+    const pending=mine?sentDiff('cal',cur):(flying('cal')||cur!==old);+    if(pending)return;+    calInSidecar=true;+    if(cur===sig)return;+    planCal=c; PLAN_OFF_CACHE.clear(); T.cal=true;+  }+}+// A field from the server. Where this tab has its own change to the same field (sent and+// not yet answered, or not sent yet), its value stays: it reaches the server after this one+// and wins there. A list merges item by item: this tab's own additions and removals are+// replayed on the server's list.+function applyRowOp(op,mine,who,T){+  const uid=op.uid;+  if(!uid||!op.set||typeof op.set!=='object')return;+  let sh=shadow.rows.get(uid)||shadow.dead.get(uid);+  const r=liveRowOf(uid);+  if(!sh){ if(!r)return; sh=rowOv(r); shadow.rows.set(uid,sh); }+  const cur=r?rowOv(r):null;+  // A row with no tags of its own carries no `tags` key, and is still a row tags can land on.+  if(cur&&!('tags' in cur))cur.tags=[];+  const sent=mine?mine.vals.get(uid):null, sentF=mine?mine.fields.get(uid):null;+  const flying=!mine&&inflight?inflight.fields.get(uid):null;+  for(const f of Object.keys(op.set)){+    if(!ROW_F.includes(f))continue;+    const v=op.set[f], old=sh[f];+    sh[f]=cloneV(v);+    if(!r||!(f in cur))continue;+    const mineHere=!!(sent&&sentF&&sentF.has(f));+    let next;+    if(ROW_SETS[f]){+      // An item this tab has touched (added or removed since the server's copy, in a batch on+      // its way or since) is where this tab has it; every other item is where the server has+      // it. Without the batch on its way, an item added and then taken out again before the+      // server answered would come back from the server's copy.+      const k=setKey(f), C=listOf(cur[f]), Cs=new Set(C.map(k)), touched=new Set();+      const differ=(a,b)=>{ const As=new Set(listOf(a).map(k)), Bs=new Set(listOf(b).map(k)); listOf(a).forEach(x=>{ if(!Bs.has(k(x)))touched.add(k(x)); }); listOf(b).forEach(x=>{ if(!As.has(k(x)))touched.add(k(x)); }); };+      if(mineHere)differ(C,sent[f]);+      else { differ(C,old); const fl=flying&&flying.has(f)&&inflight.vals.get(uid); if(fl){ differ(C,fl[f]); differ(fl[f],old); } }+      next=listOf(v).filter(x=>!touched.has(k(x)));+      C.forEach(x=>{ if(touched.has(k(x))&&!next.some(y=>k(y)===k(x)))next.push(x); });+      // This tab's own items keep the order this tab has them in, as the server will.+      if(C.some(x=>touched.has(k(x)))){ const L=C.map(k), at=x=>L.indexOf(k(x)); const placed=next.filter(x=>at(x)>=0).sort((a,b)=>at(a)-at(b)); let j=0; next=next.map(x=>at(x)>=0?placed[j++]:x); }+      if(!mine){+        const Ns=new Set(next.map(k));+        C.forEach(x=>{ if(!Ns.has(k(x)))remoteSeq.set(uid+'|'+f+'|'+k(x),++syncSeq); });+        next.forEach(x=>{ if(!Cs.has(k(x)))remoteSeq.set(uid+'|'+f+'|'+k(x),++syncSeq); });+      }+    } else {+      const pending=mineHere?!ovEq(f,cur[f],sent[f]):((flying&&flying.has(f))||!ovEq(f,cur[f],old));+      next=pending?cur[f]:v;+      if(!mine&&!pending&&who)noticeIfMine(r,f,v,who);+    }+    if(mineHere&&!ROW_SETS[f])myRecent.set(uid+'|'+f,{v:cloneV(v),t:Date.now()});+    if(ovEq(f,next,cur[f])){+      // The same set in another order: take the server's, so every tab lists it alike.+      if(ROW_SETS[f]&&listOf(next).join('\u0001')!==listOf(cur[f]).join('\u0001')){ setOvField(r,f,cloneV(next)); cur[f]=cloneV(next); T.rows.add(r); T.fields.add(f); }+      continue;+    }+    setOvField(r,f,cloneV(next));+    cur[f]=cloneV(next);+    if(!mine){ remoteSeq.set(uid+'|'+f,++syncSeq); remoteWho.set(uid+'|'+f,who); remoteWho.set(uid+'|row',who); remoteRowSeq.set(uid,syncSeq); }+    T.rows.add(r); T.fields.add(f);+  }+}+// The same field, changed by somebody else a moment after this person set it: they are+// told, with the value they had, so nothing is lost without a word.+function noticeIfMine(r,f,v,who){+  const k=r.uid+'|'+f, m=myRecent.get(k);+  if(!m||Date.now()-m.t>RECENT_MS||ovEq(f,v,m.v))return;+  myRecent.delete(k);+  const show=x=>x===null||x===undefined||x===''?'nothing':(f==='s'||f==='e'||f==='date')?fmt(x):f==='phase'?phaseNameAt(x):f==='prov'?(typeof x==='object'?(x.label||x.url||''):String(x)):String(x);+  syncNotices.push({uid:r.uid,f,who,to:cloneV(v),was:cloneV(m.v),t:Date.now()});+  if(syncNotices.length>50)syncNotices.shift();+  flash(saveInd,`${who} changed the ${FIELD_WORD[f]||f} of "${r.name}" to ${show(v)} just now (you had ${show(m.v)})`,9000);+}+function applyAddOp(op,mine,who,T){+  const row=op.row&&typeof op.row==='object'?op.row:null;+  const uid=op.uid||(row&&row.uid);+  if(!uid)return;+  const local=liveRowOf(uid);+  const obj=local||allRows.get(uid)||(remoteRemoved.get(uid)||{}).r||null;+  if(!row&&!obj)return;+  shadow.dead.delete(uid);+  shadow.rows.set(uid,rowOv(row?Object.assign({},obj||{},row):obj));+  if(mine&&mine.adds.has(uid)){+    // This tab's own new row, confirmed. Its task id is the server's: two people making a+    // row of the same name in the same second do not get the same id.+    if(local&&row&&row.id&&local.id&&row.id!==local.id){ renameRowId(local,row.id); T.rows.add(local); T.structure=true; }+    return;+  }+  if(local)return;+  // This tab's own deletion of it is on its way, and lands after this.+  if(inflight&&inflight.dels.has(uid))return;+  T.structure=true; T.fresh.add(uid);+  if(remoteRemoved.has(uid)){ const info=remoteRemoved.get(uid); restoreRow(info.r,Math.min(info.idx,items.length),[]); }+  else if(obj){ restoreRow(obj,items.length,[]); }+  else { const n=Object.assign({},row); addedItems.push(n); insertRow(n,true); allRows.set(uid,n); }+  const r=liveRowOf(uid);+  if(!r)return;+  if(row){ const want=rowOv(row), have=rowOv(r); if(!('tags' in have))have.tags=[];+    ROW_F.forEach(f=>{ if(f in want&&f in have&&!ovEq(f,want[f],have[f]))setOvField(r,f,cloneV(want[f])); }); }+  T.rows.add(r);+}+function applyDelOp(op,mine,who,T){+  const uid=op.uid;+  if(!uid)return;+  const sh=shadow.rows.get(uid);+  if(sh){ shadow.rows.delete(uid); shadow.dead.set(uid,sh); }+  if(mine&&mine.dels.has(uid))return;+  const r=liveRowOf(uid);+  if(!r)return;+  // A row this tab has changed and not saved outlives somebody else's deletion of it: its+  // next save puts it back with the change, and the person is told.+  const changed=(inflight&&inflight.fields.has(uid))||(sh&&rowDiff(rowOv(r),sh));+  if(changed){+    if(who){ syncNotices.push({uid,f:'deleted',who,t:Date.now()});+      flash(saveInd,`${who} deleted "${r.name}" while you were changing it; your change keeps it`,9000); }+    return;+  }+  selected.delete(r.id);+  removeRow(r,true);+  T.structure=true;+}+// The server's order, with this tab's own moves that it does not have yet replayed on top,+// and any row this tab has that it does not list (made here and not sent yet) kept beside+// the row it follows here.+function rebaseOrder(F,mine,T){+  if(!Array.isArray(F))return;+  const old=shadow.order;+  shadow.order=F.slice();+  const localOld=liveOrder();+  const liveSet=new Set(localOld);+  // Rows that arrived in this same batch take the server's place for them, not a guess.+  const mineLocal=localOld.filter(k=>!T.fresh.has(k));+  const base=(mine&&mine.order?mine.order:old).filter(k=>liveSet.has(k)&&!T.fresh.has(k));+  const pend=orderMoves(base,mineLocal);+  const res=F.filter(k=>liveSet.has(k));+  const inRes=new Set(res);+  localOld.forEach((k,i)=>{+    if(inRes.has(k))return;+    let j=i-1; while(j>=0&&!inRes.has(localOld[j]))j--;+    res.splice(j<0?0:res.indexOf(localOld[j])+1,0,k); inRes.add(k);+  });+  pend.forEach(m=>moveInOrder(res,m.uid,m.after));+  if(res.join('\n')===localOld.join('\n'))return;+  if(applyOrder(res)){ T.structure=true; T.order=true; }+}+// What a batch sent and the server did not take, where this tab has not changed it since,+// goes back to the server's value; otherwise it would be sent again with every save.+function settleSent(b,T){+  b.fields.forEach((fs,uid)=>{+    const r=liveRowOf(uid), sh=shadow.rows.get(uid), sent=b.vals.get(uid);+    if(b.adds.has(uid)&&!sh&&!shadow.dead.has(uid)&&r){+      removeRow(r,true); T.structure=true;+      flash(saveInd,`the server did not take the new row "${r.name}"`,6000);+      return;+    }+    if(!r||!sh||!sent)return;+    const cur=rowOv(r);+    if(!('tags' in cur))cur.tags=[];+    fs.forEach(f=>{+      if(!(f in cur)||!(f in sh&&sh[f]!==undefined||f==='tags'))return;+      if(ovEq(f,cur[f],sent[f])&&!ovEq(f,cur[f],sh[f])){ setOvField(r,f,cloneV(sh[f])); T.rows.add(r); T.fields.add(f); }+    });+  });+  const c=curStruct();+  if(b.sent.phases&&c.phases===b.struct.phases&&c.phases!==shadow.phases&&shadow.phasesV){ if(adoptPhases(shadow.phasesV)){ T.phases=true; T.structure=true; } }+  if(b.sent.reg&&c.reg===b.struct.reg&&c.reg!==shadow.reg){ tagReg=(normalizeTagRegistry(shadow.regV||[])||[]).map(regEntry); T.reg=true; }+  if(b.sent.dates&&c.dates===b.struct.dates&&c.dates!==shadow.dates){ planDates=shadow.datesV?Object.assign({},shadow.datesV):{start:cleanDay(project&&project.start),end:cleanDay(project&&project.end)}; T.dates=true; }+  if(b.sent.cal&&c.cal===b.struct.cal&&c.cal!==shadow.cal){ planCal=cleanCal(shadow.calV)||cleanCal(project&&project.calendar)||{workHolidays:[],extraDaysOff:[]}; PLAN_OFF_CACHE.clear(); T.cal=true; }+  if(b.order)rebaseOrder(shadow.order,b,T);+}+// The rows a batch touched, repainted once; the lanes, the legend and the axis only when+// the batch changed the plan's shape or its span.+function repaintAfter(T,range0){+  const rows=[...T.rows].filter(isLive);+  if(T.structure||T.phases){+    items.forEach(r=>{ if(r.id)refreshLabelRow(r); else if(r.type==='milestone')refreshMsChrome(r); });+    Object.keys(barEls).forEach(paintBar);+    recolorByPhase();+    if(T.phases)refreshSections();+    rebuildLegend(); applyCollapse(); applyFilter();+  } else rows.forEach(r=>{+    if(r.id){ paintBar(r.id); refreshLabelRow(r); if(T.fields.has('phase')&&barEls[r.id])barEls[r.id].bar.style.background=phaseColor(r.phase); }+    else if(r.type==='milestone'){ refreshMsChrome(r); paintMilestone(r); if(r._msLine)r._msLine.style.color=phaseColor(r.phase); }+  });+  if(tagsReady&&(T.reg||T.fields.has('tags')||T.structure||T.phases)&&(T.reg||tagSigNow()!==tagSig))afterTagsChange();+  if(T.dates||T.cal){ renderDatesCard(); renderCalCard(); }+  const geo=T.structure||T.phases||T.dates||T.cal||['s','e','date','deps','name','phase'].some(f=>T.fields.has(f));+  if(prefs.view==='gantt'){+    if(T.structure||T.phases||T.dates||T.cal||JSON.stringify(scanRange())!==range0)rebuildTimeline();+    else if(geo)refreshChartGeometry();+  } else if(geo)timelineStale=true;   // Not while a cell is being typed into: the repaint would close that editor under the   // person's hands. The model is current either way, and finishing the cell repaints.-  // And only the lens on screen: switching to the Table renders it from the model.-  if(!tblEditing&&prefs.view==='table')renderTable();-  if(prefs.view==='analytics')renderAnalytics(); else if(prefs.view==='setup')renderSections(true);-  syncPlanSpan(); syncCollapseAllBtn(); syncPlanCtl(); if(critOn())refreshCritical();-  }));-  keepPlace(mark);-  if(edSubject)syncEditorDates(edSubject);-  }finally{ liveMerging=false; }-  lastSnap=snapSemantic();-  if(typeof st.stateRev==='number')baseRev=st.stateRev;-  liveMerges++;-  // Unsaved work rides on the new rev; a tab that was blocked is unblocked by it.-  if(dirtyUids.size||dirtyStruct.phases||dirtyStruct.order||dirtyStruct.reg||dirtyStruct.dates||dirtyStruct.cal||dirtyAll){-    if(saveBlocked){ saveBlocked=false; blockedWhy='conflict'; conflictBar.classList.remove('show'); conflictBar.innerHTML=''; document.documentElement.removeAttribute('data-save'); unpinSaveErr(); }-    clearTimeout(saveTimer); saveTimer=setTimeout(()=>{saveTimer=null;saveToFile();},200);+  if(!tblEditing&&prefs.view==='table'&&(rows.length||T.structure||T.phases)){+    if(T.structure||T.phases||!patchTableRows(rows,T.fields))renderTable();   }+  if(prefs.view==='analytics')renderAnalytics(); else if(prefs.view==='setup'&&(T.structure||T.phases||T.reg||T.dates||T.cal||rows.length))renderSections(true);+  if(rows.length||T.structure||T.dates||T.cal){ syncPlanSpan(); refreshViewChips(); }+  syncCollapseAllBtn(); syncPlanCtl();+  if(critOn()&&(geo||T.fields.has('status')))refreshCritical(); } // The stream of announcements from the server, and the presence it carries. let live=null, livePeople=[], liveTabs=0, liveYou=null, liveMerges=0, liveLastActor='';@@ -5003,16 +5589,9 @@ function startLive(){   }   es.onmessage=ev=>{     let m; try{ m=JSON.parse(ev.data); }catch(e){ return; }-    if(m.type==='hello'){ liveYou=m.you||null; if(typeof m.stateRev==='number'&&m.stateRev>(baseRev||0)&&!saveInFlight)pullAndMerge().catch(()=>{}); return; }+    if(m.type==='hello'){ liveYou=m.you||null; if(typeof m.stateRev==='number'&&m.stateRev>(baseRev||0))pullAndMerge().catch(()=>{}); return; }     if(m.type==='presence'){ livePeople=m.people||[]; liveTabs=m.tabs||0; liveFocus=Array.isArray(m.focus)?m.focus:[]; renderPresence(); schedulePeerPaint(); return; }-    if(m.type==='rev'){-      if(m.by===CLIENT_ID)return;-      if(typeof m.stateRev!=='number'||m.stateRev<=(baseRev||0))return;-      liveLastActor=m.actor||'';-      if(saveInFlight){ pullAfterSave=true; return; }-      pullAndMerge().catch(()=>{});-      return;-    }+    if(m.type==='rev'){ onRev(m); return; }     // The owner changed the sharing and this person is no longer on it (0.68.0). The     // stream is closed by the server; the bar offers the export before anything is lost.     if(m.type==='denied'){ stopLive(); onSaveBlocked('The owner changed who this plan is shared with, and it is not shared with you any more.'); return; }@@ -5130,11 +5709,27 @@ function renderPresence(){   leg.appendChild(el); } // For the suite: pause and resume this tab's stream, and read what it knows.-window.__collab={pause:stopLive,resume:startLive,people:()=>livePeople.slice(),merges:()=>liveMerges,pulls:()=>pullCount,dirty:()=>[...dirtyUids],rev:()=>baseRev,+window.__collab={pause:stopLive,resume:startLive,people:()=>livePeople.slice(),merges:()=>liveMerges,pulls:()=>pullCount,dirty:()=>computeOps().map(o=>o.uid||(o.row&&o.row.uid)||o.op),rev:()=>baseRev,pending:pendingCount,notices:()=>syncNotices.slice(),kept:()=>undoKept.map(k=>({uid:k.r&&k.r.uid,f:k.f,who:k.who})),inflight:()=>!!inflight,   // For the collaboration suite (0.75.1): the plan as this tab holds it, row by row, to compare with the server's.-  rows:()=>items.filter(r=>r.uid&&!isDerivedGroup(r)).map(r=>({uid:r.uid,name:r.name||'',owner:r.owner||'',status:r.status||'',start:r.start||'',end:r.end||'',date:r.date||'',phase:r.phase,deps:(r.deps||[]).slice().sort(),desc:r.description||''})),+  rows:()=>items.filter(r=>r.uid&&!isDerivedGroup(r)).map(r=>({uid:r.uid,name:r.name||'',owner:r.owner||'',status:r.status||'',start:r.start||'',end:r.end||'',date:r.date||'',phase:r.phase,deps:(r.deps||[]).slice().sort(),desc:r.description||'',+    note:r.note||'',tags:ownTags(r).map(t=>t.toLowerCase()).sort(),effort:effortOf(r),id:r.id||''})),+  // The suite's hands (0.77.0): the same edits a person makes, one undo entry each, without+  // the clicks, so a run can make hundreds of them across several tabs.+  order:()=>liveOrder(),+  set:(uid,f,v)=>{ const r=liveRowOf(uid); if(!r||!ROW_F.includes(f))return false;+    if(r.type==='milestone')snapshotMs(r); else snapshot(r.id);+    setOvField(r,f,cloneV(v));+    if(r.id){ updateBar(r.id); refreshLabelRow(r); } else { refreshMsChrome(r); updateMilestone(r); }+    if(f==='tags')afterTagsChange();+    scheduleSave('edit'); return true; },+  move:(uid,after)=>{ const r=liveRowOf(uid), a=after?liveRowOf(after):null;+    if(!r||(after&&!a)||(a&&a.phase!==r.phase))return false;+    const o=liveOrder(); if(!moveInOrder(o,uid,after))return false;+    pushUndo({type:'order',order:items.map(rowOrderKey)}); applyOrder(o); scheduleSave('drag'); return true; },+  del:uid=>{ const r=liveRowOf(uid); if(!r)return false; deleteRow(r); return true; },+  undo:()=>undo(), redo:()=>redo(),   dates:()=>Object.assign({},planDates), cal:()=>JSON.parse(JSON.stringify(planCal)),-  addBiz:(d,n)=>addBiz(d,n), workdays:(a,b)=>bizBetween(a,b), offDay:d=>isOffDay(d), axis:()=>({start:isoDay(START),col:COL}), busy:()=>!!pullChain||saveInFlight||!!saveTimer};+  addBiz:(d,n)=>addBiz(d,n), workdays:(a,b)=>bizBetween(a,b), offDay:d=>isOffDay(d), axis:()=>({start:isoDay(START),col:COL}), busy:()=>!!pullQueued||syncBusy||!!inflight||saveInFlight||!!saveTimer}; // 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){@@ -5189,7 +5784,7 @@ function renderConflictMain(){   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="cbForce" class="de-btn secondary">Try again</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@@ -5197,17 +5792,9 @@ function renderConflictMain(){   $('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=$('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.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();-  });+  // Since 0.77.0 a save carries only this tab's own changes, so trying again cannot+  // overwrite anyone and needs no second click.+  $('cbForce').addEventListener('click',forceSaveMine); } // 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.@@ -5251,23 +5838,12 @@ 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-// 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.+// Try the save again now. Since 0.77.0 there is nothing to overwrite: a save carries only+// this tab's own changes, field by field, so the button just stops the wait. 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;-      unsavedCount=0;-      clearConflict('saved your version, overwriting the other change');-      flushLogQueue();-    })-    .catch(()=>flash(saveInd,'could not force-save, try Export my version',4000));+  clearTimeout(saveRetry); saveRetry=null;+  clearTimeout(saveTimer); saveTimer=null;+  saveToFile(); } // 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@@ -11610,6 +12186,39 @@ function tableHiddenWhy(shown,total){   if(n.search)why.push(n.search+' by the search');   return 'Showing '+shown+' of the plan\'s '+total+' tasks and milestones. Hidden: '+why.join('; ')+'.'; }+// Whether one row is in the table as filtered now (tableRows' rule, for one row).+function tableRowIn(r){+  const q=(searchInp.value||'').trim().toLowerCase(), st=statusSel.value;+  if(isGroup(r))return false;+  if(r.phase!==undefined&&(isCollapsed(r.phase)||!phaseVis[r.phase]))return false;+  if(!rowShown(r,tagFilterKeys()))return false;+  if(isMs(r)&&!msVis)return false;+  if(st&&statusOf(r)!==st)return false;+  if(q&&tableSearchHay(r).indexOf(q)<0)return false;+  return true;+}+// A few rows changed in fields only they show (0.77.0): their own table rows are rewritten+// and nothing else is built. Falls back to the whole table (false) when the change could+// move a row (a sort, a filter it now passes or fails) or show in another row's cell (a+// name or a dependency, which the Deps column of the rows around it names).+function patchTableRows(rows,fields){+  if(!tableBody||!tableRowsCache||!manualOrder()||fields.has('deps')||fields.has('name')||fields.has('phase'))return false;+  if(tblEditing)return false;+  const kids=tableBody.children, c=tableRowsCache;+  if(kids.length!==c.keys.length)return false;+  for(const r of rows){+    const k=rowKey(r), i=c.keys.indexOf(k);+    if((i>=0)!==tableRowIn(r))return false;+    if(i<0)continue;+    const h=tableRowHtml(r);+    if(h===c.html[i])continue;+    const t=document.createElement('template'); t.innerHTML=h;+    tableBody.replaceChild(t.content.firstElementChild,kids[i]);+    c.html[i]=h;+  }+  markFocusedRow(); paintCritTable(); paintPeerFocus();+  return true;+} function tableRows(){   const q=(searchInp.value||'').trim().toLowerCase();   const st=statusSel.value, ks=tagFilterKeys();@@ -13570,6 +14179,8 @@ $('viewToggle').addEventListener('click',e=>{const b=e.target.closest('.vt-btn') // (0.75.1: guarded here, not only by the export's rewrite of this line, which an edit to the // line silently stopped matching, so snapshots fetched a state that was not there). if(!SNAPSHOT)try{const r=await fetch('state');if(r.ok){const st=await r.json();if(!st.empty){baseRev=typeof st.stateRev==='number'?st.stateRev:0;withDeps(()=>{applyState(st);updateBars();});}else baseRev=0;}}catch(e){}+// What the server holds, as of now: the baseline every later change is measured against.+initShadow(); // The sidecar can carry a status or a description that the chart's own label rows // and tooltips were built from the data file with, so restate them. items.forEach(r=>{ if(r.id)refreshLabelRow(r); else if(r.type==='milestone')refreshMsChrome(r); });
install.sh

  
package.json+1−1
@@ -2,7 +2,7 @@   "slug": "adom-gantt",   "title": "Adom Gantt",   "type": "app",-  "version": "0.76.0",+  "version": "0.77.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. Copy and paste rows: Ctrl+C the selected tasks and milestones, Ctrl+V to drop clones directly below a selected row or at the end of the lane you are looking at, with dependencies AMONG the copied rows remapped onto the copies so a duplicated chain is still a chain, links pointing outside it left naming the originals, and the whole paste one undo and one changelog line. Five statuses in lifecycle order, not started, in progress, complete, blocked, cancelled, with cancelled keeping 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+1−1
@@ -2,7 +2,7 @@   "slug": "adom-gantt",   "title": "Adom Gantt",   "type": "app",-  "version": "0.76.0",+  "version": "0.77.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. Copy and paste rows: Ctrl+C the selected tasks and milestones, Ctrl+V to drop clones directly below a selected row or at the end of the lane you are looking at, with dependencies AMONG the copied rows remapped onto the copies so a duplicated chain is still a chain, links pointing outside it left naming the originals, and the whole paste one undo and one changelog line. Five statuses in lifecycle order, not started, in progress, complete, blocked, cancelled, with cancelled keeping 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": {
server.js+281−16
@@ -1394,6 +1394,231 @@ function normalizeCalendar(v) {     extraDaysOff: [...new Set(xd.map(day).filter(Boolean))].sort(),   }; }+// ── Field-level ops (0.77.0) ──+//+// A tab no longer sends the whole plan when someone edits it. It sends what changed, as+// small operations, and the server applies them in the order they arrive:+//+//   {op:'row', uid, set:{owner:'Ana', e:'2026-11-03'}, add:{deps:['x']}, del:{tags:['y']}}+//   {op:'add', row:{...a whole new row...}}      {op:'del', uid}+//   {op:'move', uid, after:<uid>|null}            (a row placed after another, null = top)+//   {op:'phases', phases:[...]}  {op:'reg', tags:[...]}  {op:'dates', dates}  {op:'cal', calendar}+//+// The last write wins PER FIELD, so two people changing different fields of one row both+// keep theirs; dependency and tag lists merge item by item, so two people adding different+// ones both keep theirs; a move is "after that row", replayed on whatever order the plan+// has by then, so two people moving different rows both keep theirs. Every batch is one+// revision, announced to every open tab (the sender included) in the same order, with+// the values as stored, which is what lets every tab converge on exactly this file.+const OPS_MAX = 5000;+const OP_DAY = /^\d{4}-\d{1,2}(-\d{1,2})?$/;+const OP_TASK_ONLY = new Set(['s', 'e', 'deps', 'note', 'effort']);+const OP_TEXT_MAX = { note: 2000, owner: 200, status: 60, desc: 20000, name: 500 };+// A row as a full sidecar override, the shape the page has always written for every row,+// so an entry the server creates for a row that had none reads like any other.+function overrideOf(r) {+  const o = { uid: r.uid, owner: r.owner || '', status: r.status || '', desc: r.description || '',+    prov: r.provenance === undefined ? '' : r.provenance, name: r.name, phase: r.phase };+  if (r.type === 'milestone') o.date = r.date;+  else { o.s = r.start; o.e = r.end; o.deps = Array.isArray(r.deps) ? r.deps.slice() : []; o.note = r.note || '';+    o.effort = typeof r.effort === 'number' ? r.effort : null; }+  if (Array.isArray(r.tags)) o.tags = r.tags.slice();+  return o;+}+// One field's value, cleaned, or undefined to drop it. Never an error: a bad field in a+// batch is skipped and the rest of the batch lands.+function opValue(f, v) {+  if (f === 's' || f === 'e' || f === 'date') return typeof v === 'string' && OP_DAY.test(v) ? v : undefined;+  if (f in OP_TEXT_MAX) {+    if (v === null || v === undefined) return f === 'name' ? undefined : '';+    if (typeof v !== 'string') return undefined;+    if (f === 'name' && !v.trim()) return undefined;+    return v.slice(0, OP_TEXT_MAX[f]);+  }+  if (f === 'prov') {+    if (v === null || v === undefined || v === '') return '';+    if (typeof v === 'string') return v.slice(0, 2000);+    if (typeof v === 'object' && !Array.isArray(v)) {+      const o = {}; if (typeof v.label === 'string') o.label = v.label.slice(0, 500); if (typeof v.url === 'string') o.url = v.url.slice(0, 2000);+      return o.label || o.url ? o : '';+    }+    return undefined;+  }+  if (f === 'phase') return Number.isInteger(v) && v >= 0 && v < 1000 ? v : undefined;+  if (f === 'effort') { if (v === null) return null; const e = normalizeEffort(v); return e === undefined ? undefined : e; }+  if (f === 'deps') return Array.isArray(v) ? [...new Set(v.filter(x => typeof x === 'string' && x).map(x => x.slice(0, 200)))] : undefined;+  if (f === 'tags') return normalizeTags(v);+  return undefined;+}+// The rows of the plan, in the order the sidecar keeps them: every data-file row and every+// created one, less the deleted.+function liveKeyList(data, state) {+  const gone = new Set(Array.isArray(state.deleted) ? state.deleted : []), seen = new Set(), keys = [];+  const take = u => { if (typeof u === 'string' && u && !gone.has(u) && !seen.has(u)) { seen.add(u); keys.push(u); } };+  (data.items || []).forEach(r => take(r && r.uid));+  (Array.isArray(state.added) ? state.added : []).forEach(a => take(a && a.uid));+  return keys;+}+// Place `uid` straight after `after` (null: at the top). An anchor that is gone leaves the+// row where it is: the person placed it next to something that no longer exists.+function moveInOrder(order, uid, after) {+  const i = order.indexOf(uid);+  if (i < 0) return false;+  if (after !== null && (after === uid || !order.includes(after))) return false;+  order.splice(i, 1);+  order.splice(after === null ? 0 : order.indexOf(after) + 1, 0, uid);+  return true;+}+function applyOpsTo(state, data, mergedByUid, ops, mayDate) {+  const out = [], refused = [];+  ['items', 'added', 'deleted'].forEach(k => { if (!Array.isArray(state[k])) state[k] = []; });+  const entries = new Map();+  state.items.forEach(o => { if (o && o.uid) entries.set(o.uid, o); });+  const dataUids = new Set((data.items || []).map(r => r && r.uid).filter(Boolean));+  const isAdded = uid => state.added.some(a => a && a.uid === uid);+  const typeOf = uid => (mergedByUid.get(uid) || {}).type;+  const entryFor = uid => {+    let o = entries.get(uid);+    if (!o) { const base = mergedByUid.get(uid); o = base ? overrideOf(base) : { uid }; entries.set(uid, o); state.items.push(o); }+    return o;+  };+  let orderTouched = false;+  const moves = [];+  // A row coming back: off the deleted list, and announced whole, as the merge reads it.+  const undelete = uid => {+    state.deleted = state.deleted.filter(k => k !== uid);+    if (!mergedByUid.has(uid)) {+      const base = (data.items || []).find(r => r && r.uid === uid) || state.added.find(r => r && r.uid === uid);+      if (base) { const r = Object.assign({}, base); delete r._key; const sv = entries.get(uid); if (sv) applyOverride(r, sv, 1000); mergedByUid.set(uid, r); }+    }+    const back = mergedByUid.get(uid);+    out.push({ op: 'add', row: back ? Object.assign({}, back) : null, uid });+    orderTouched = true;+  };+  for (const op of ops) {+    if (!op || typeof op !== 'object' || Array.isArray(op)) { refused.push('not an op'); continue; }+    const uid = typeof op.uid === 'string' ? op.uid.slice(0, 40) : null;+    if (op.op === 'add') {+      const a = op.row;+      if (!a || typeof a !== 'object' || typeof a.uid !== 'string' || !a.uid) { refused.push('add: no row'); continue; }+      // A data-file row coming back is an undelete; a created row coming back is the same.+      if (dataUids.has(a.uid) || isAdded(a.uid)) { undelete(a.uid); continue; }+      const row = {};+      ['type', 'uid', 'id', 'name', 'start', 'end', 'date', 'phase', 'owner', 'status', 'description', 'deps', 'note', 'provenance', 'tags', 'effort', 'striped']+        .forEach(k => { if (a[k] !== undefined) row[k] = a[k]; });+      row.type = row.type === 'milestone' ? 'milestone' : 'task';+      const okDates = row.type === 'milestone' ? (typeof row.date === 'string' && OP_DAY.test(row.date))+        : (typeof row.start === 'string' && OP_DAY.test(row.start) && typeof row.end === 'string' && OP_DAY.test(row.end));+      if (!okDates || typeof row.name !== 'string' || !row.name.trim()) { refused.push('add: ' + a.uid + ' has no name or dates'); continue; }+      if (!Number.isInteger(row.phase) || row.phase < 0) row.phase = 0;+      if (row.deps !== undefined) row.deps = opValue('deps', row.deps) || [];+      tidyTags(row); tidyEffort(row);+      // Task ids are what dependencies point at. Two people creating "Design" in the same+      // second would both mint "design"; the second one becomes "design-2" here, and its+      // tab is told so by the announcement.+      if (row.type === 'task' && typeof row.id === 'string' && row.id) {+        const ids = new Set([...mergedByUid.values()].map(r => r && r.id).filter(Boolean));+        let id = row.id, n = 2; while (ids.has(id)) id = row.id + '-' + (n++);+        row.id = id;+      } else if (row.type === 'task') { refused.push('add: ' + a.uid + ' has no id'); continue; }+      state.added.push(row);+      state.deleted = state.deleted.filter(k => k !== row.uid);+      mergedByUid.set(row.uid, Object.assign({}, row));+      out.push({ op: 'add', row: Object.assign({}, row), uid: row.uid });+      orderTouched = true;+    } else if (op.op === 'del') {+      if (!uid) { refused.push('del: no uid'); continue; }+      // A created row stays in `added` as well, so an undo of the deletion, or an edit from+      // a tab that had not heard of it, can bring it back whole.+      if (!dataUids.has(uid) && !isAdded(uid)) { refused.push('del: unknown ' + uid); continue; }+      if (!state.deleted.includes(uid)) state.deleted.push(uid);+      out.push({ op: 'del', uid });+      orderTouched = true;+    } else if (op.op === 'row') {+      // An edit to a row somebody deleted, from a tab that had not heard of the deletion yet,+      // brings the row back with the edit: the edit is the newer of the two (0.66.0 rule).+      if (uid && !mergedByUid.has(uid) && state.deleted.includes(uid) && (dataUids.has(uid) || isAdded(uid))) undelete(uid);+      if (!uid || !mergedByUid.has(uid)) { refused.push('row: unknown ' + uid); continue; }+      const ms = typeOf(uid) === 'milestone', o = entryFor(uid), done = {};+      const set = op.set && typeof op.set === 'object' && !Array.isArray(op.set) ? op.set : {};+      for (const f of Object.keys(set)) {+        if (!['s', 'e', 'date', 'note', 'owner', 'status', 'desc', 'prov', 'name', 'phase', 'effort', 'deps', 'tags'].includes(f)) continue;+        if (ms ? OP_TASK_ONLY.has(f) : f === 'date') continue;+        const v = opValue(f, set[f]);+        if (v === undefined) { refused.push('row ' + uid + ': ' + f); continue; }+        o[f] = Array.isArray(v) ? v.slice() : v; done[f] = true;+      }+      // The two lists merge item by item: what this tab took out, then what it put in.+      for (const f of ['deps', 'tags']) {+        if (ms && f === 'deps') continue;+        const add = op.add && Array.isArray(op.add[f]) ? op.add[f] : [], del = op.del && Array.isArray(op.del[f]) ? op.del[f] : [];+        if (!add.length && !del.length) continue;+        const same = f === 'tags' ? (x, y) => String(x).toLowerCase() === String(y).toLowerCase() : (x, y) => x === y;+        let list = (Array.isArray(o[f]) ? o[f] : []).filter(x => !del.some(d => same(d, x)));+        add.forEach(x => { if (!list.some(y => same(x, y))) list.push(x); });+        // The sender's whole list, when it sent one, says where its items go: a tag renamed+        // in place stays in its place. Items it does not list (somebody else's) keep theirs.+        const ord = op.ord && Array.isArray(op.ord[f]) ? op.ord[f] : null;+        if (ord) {+          const at = x => ord.findIndex(y => same(x, y));+          const placed = list.filter(x => at(x) >= 0).sort((a, b) => at(a) - at(b));+          let k = 0; list = list.map(x => at(x) >= 0 ? placed[k++] : x);+        }+        list = opValue(f, list);+        if (list === undefined) continue;+        o[f] = list; done[f] = true;+      }+      const fields = Object.keys(done);+      if (!fields.length) continue;+      const setOut = {}; fields.forEach(f => { setOut[f] = Array.isArray(o[f]) ? o[f].slice() : (o[f] && typeof o[f] === 'object' ? Object.assign({}, o[f]) : o[f]); });+      out.push({ op: 'row', uid, set: setOut });+      if (done.phase) orderTouched = true;+    } else if (op.op === 'move') {+      if (!uid) { refused.push('move: no uid'); continue; }+      moves.push({ uid, after: typeof op.after === 'string' ? op.after.slice(0, 40) : null });+      orderTouched = true;+    } else if (op.op === 'phases') {+      if (!Array.isArray(op.phases) || !op.phases.length || !op.phases.every(p => p && typeof p === 'object' && typeof p.name === 'string')) { refused.push('phases'); continue; }+      state.phases = op.phases.map((p, i) => { const o = { i, name: p.name.slice(0, 200), start: typeof p.start === 'string' ? p.start : '', end: typeof p.end === 'string' ? p.end : '' };+        if (typeof p.uid === 'string' && p.uid) o.uid = p.uid; if (typeof p.lead === 'string' && p.lead) o.lead = p.lead.slice(0, 200);+        if (typeof p.color === 'string' && p.color) o.color = p.color.slice(0, 40);+        o.tags = normalizeTags(p.tags) || []; return o; });+      out.push({ op: 'phases', phases: state.phases.map(p => Object.assign({}, p)) });+    } else if (op.op === 'reg') {+      const t = normalizeTagRegistry(op.tags);+      if (!t) { refused.push('reg'); continue; }+      state.tags = t;+      out.push({ op: 'reg', tags: t.map(x => Object.assign({}, x)) });+    } else if (op.op === 'dates' || op.op === 'cal') {+      // The project dates and the working calendar are the owner's (0.75.0, 0.76.0).+      if (!mayDate) { refused.push(op.op + ': only the owner'); continue; }+      if (op.op === 'dates') { const d = normalizeDates(op.dates); if (!d) { refused.push('dates'); continue; } state.dates = d; out.push({ op: 'dates', dates: Object.assign({}, d) }); }+      else { const c = normalizeCalendar(op.calendar); if (!c) { refused.push('cal'); continue; } state.calendar = c; out.push({ op: 'cal', calendar: JSON.parse(JSON.stringify(c)) }); }+    } else refused.push('unknown op ' + String(op.op).slice(0, 20));+  }+  // The order is kept complete, every live row exactly once, because the merge reads it+  // all or nothing. A row it does not list yet (just created) goes to the end of its own+  // section, then the moves are replayed on top, in the order they were made.+  if (orderTouched) {+    const live = liveKeyList(data, state), liveSet = new Set(live);+    const phaseOf = u => { const o = entries.get(u); if (o && Number.isInteger(o.phase)) return o.phase; const r = mergedByUid.get(u); return r ? r.phase : undefined; };+    let cur = Array.isArray(state.order) ? state.order : live.filter(k => dataUids.has(k));+    cur = [...new Set(cur.filter(k => liveSet.has(k)))];+    const inCur = new Set(cur);+    live.forEach(k => {+      if (inCur.has(k)) return;+      const ph = phaseOf(k);+      let at = -1;+      for (let i = cur.length - 1; i >= 0; i--) if (phaseOf(cur[i]) === ph) { at = i; break; }+      if (at < 0) cur.push(k); else cur.splice(at + 1, 0, k);+      inCur.add(k);+    });+    moves.forEach(m => moveInOrder(cur, m.uid, m.after));+    state.order = cur;+    out.push({ op: 'order', order: cur.slice() });+  }+  return { out, refused };+} function tidyStateTags(st) {   if (!st || typeof st !== 'object' || Array.isArray(st)) return st;   const side = o => {@@ -1845,6 +2070,26 @@ function finaliseMerged(data) {   }   return data; }+// One row's sidecar override, applied to the row. Shared by the merge and by an undelete+// over /ops, which has to announce the row exactly as the merge will read it.+function applyOverride(r, sv, phaseCount) {+  if (sv.s) r.start = sv.s;+  if (sv.e) r.end = sv.e;+  if (sv.date) r.date = sv.date;+  if (sv.deps) r.deps = sv.deps;+  if (sv.note !== undefined) r.note = sv.note;+  setOpt(r, 'owner', sv.owner);+  setOpt(r, 'status', sv.status);+  setOpt(r, 'description', sv.desc);+  setProv(r, sv.prov);+  // Absent: the data file's tags stand. [] : the user cleared them.+  if (Array.isArray(sv.tags)) { if (sv.tags.length) r.tags = sv.tags.slice(); else delete r.tags; }+  // Effort (0.63.0). Absent: the data file's stands. null: cleared. A number: set.+  if ('effort' in sv) { if (sv.effort === null) delete r.effort; else { const ef = normalizeEffort(sv.effort); if (ef !== undefined) r.effort = ef; } }+  if (sv.name) r.name = sv.name;+  if (Number.isInteger(sv.phase) && sv.phase >= 0 && sv.phase < phaseCount) r.phase = sv.phase;+  return r;+} function mergedData() {   const data = tidyPlanTags(loadData());   const state = tidyStateTags(upgradeState(loadState(), data));@@ -1889,22 +2134,7 @@ function mergedData() {   (state.items || []).forEach(sv => {     if (!sv || !sv.uid) return;     const r = byUid.get(sv.uid);-    if (!r) return;-    if (sv.s) r.start = sv.s;-    if (sv.e) r.end = sv.e;-    if (sv.date) r.date = sv.date;-    if (sv.deps) r.deps = sv.deps;-    if (sv.note !== undefined) r.note = sv.note;-    setOpt(r, 'owner', sv.owner);-    setOpt(r, 'status', sv.status);-    setOpt(r, 'description', sv.desc);-    setProv(r, sv.prov);-    // Absent: the data file's tags stand. [] : the user cleared them.-    if (Array.isArray(sv.tags)) { if (sv.tags.length) r.tags = sv.tags.slice(); else delete r.tags; }-    // Effort (0.63.0). Absent: the data file's stands. null: cleared. A number: set.-    if ('effort' in sv) { if (sv.effort === null) delete r.effort; else { const ef = normalizeEffort(sv.effort); if (ef !== undefined) r.effort = ef; } }-    if (sv.name) r.name = sv.name;-    if (Number.isInteger(sv.phase) && sv.phase >= 0 && sv.phase < phaseCount) r.phase = sv.phase;+    if (r) applyOverride(r, sv, phaseCount);   });   // Two shapes. A sidecar written since sections became editable carries the FULL   // list, every entry with a name, and it owns the sections outright: that is what@@ -2868,6 +3098,41 @@ const server = http.createServer(async (req, res) => {       return;     } +    // ── Field-level ops (0.77.0) ──+    // See applyOpsTo. Never a 409: a batch lands on whatever the plan is by now, field by+    // field, and is announced to every open tab with the revision it made.+    if (req.method === 'POST' && urlPath === '/ops') {+      const body = await readBody(req);+      let p;+      try { p = JSON.parse(body); } catch (e) { jsonResp(res, 400, { ok: false, error: 'body must be JSON' }); return; }+      if (!p || typeof p !== 'object' || !Array.isArray(p.ops)) { jsonResp(res, 400, { ok: false, error: '"ops" must be an array' }); return; }+      if (p.ops.length > OPS_MAX) { jsonResp(res, 413, { ok: false, error: `at most ${OPS_MAX} ops in one batch` }); return; }+      try {+        const data = loadData();+        let state = loadState();+        if (state && (Array.isArray(state) || state.v !== 4)) state = upgradeState(state, data) || state;+        if (!state || typeof state !== 'object' || Array.isArray(state)) state = { v: 4, items: [], deleted: [], added: [] };+        tidyStateTags(state);+        const mergedByUid = new Map();+        (mergedData().items || []).forEach(r => { if (r && r.uid) mergedByUid.set(r.uid, r); });+        const mayDate = !!auth.agent || isDirectLoopback(req) || isOwner(auth.user, accessView());+        const { out, refused } = applyOpsTo(state, data, mergedByUid, p.ops, mayDate);+        const cur = stateRev();+        const cid = typeof p.client === 'string' ? p.client.slice(0, 40) : '';+        const batch = typeof p.batch === 'string' ? p.batch.slice(0, 40) : '';+        if (!out.length) { jsonResp(res, 200, { ok: true, stateRev: cur, ops: [], refused }); return; }+        state.v = 4;+        state.stateRev = cur + 1;+        delete state.baseRev; delete state.client;+        writeAtomic(STATE_FILE, JSON.stringify(state));+        jsonResp(res, 200, { ok: true, stateRev: state.stateRev, ops: out, refused });+        broadcastLive({ type: 'rev', stateRev: state.stateRev, by: cid, batch, actor: whoOf(auth).name, ts: Date.now(), ops: out });+      } catch (e) {+        jsonResp(res, 500, { ok: false, error: e.message });+      }+      return;+    }+     // ── Changelog ──      if (req.method === 'POST' && urlPath === '/log') {
test/collab_test.mjs+244−3
@@ -14,12 +14,13 @@ //   node test/collab_test.mjs --users 12           a bigger meeting //   node test/collab_test.mjs --throttle 4         every tab on a CPU four times slower //   node test/collab_test.mjs --only meeting,soak  some scenarios (join merge meeting presence-//                                                  idle soak conflict reconnect)+//                                                  idle soak conflict reconnect docs fuzz) //   node test/collab_test.mjs --plan my.json       a real plan (and its -state.json) instead //   node test/collab_test.mjs --seconds 120        a longer meeting //   node test/collab_test.mjs --app ../old-build   measure another build (checks that need //                                                  this build's hooks are skipped) //   node test/collab_test.mjs --report out.json    every number, for comparing runs+//   node test/collab_test.mjs --only fuzz --seed 9 another random sequence for the fuzz // // Budgets scale with --throttle. COLLAB_BUDGET_<NAME>=<n> overrides one (see BUDGETS). import puppeteer from './node_modules/puppeteer-core/lib/esm/puppeteer/puppeteer-core.js';@@ -42,6 +43,7 @@ const ONLY = arg('only', '') ? arg('only').split(',').map(s => s.trim()) : null; const APP = resolve(arg('app', join(HERE, '..'))); const PLAN = arg('plan', ''); const REPORT = arg('report', '');+const SEED = +arg('seed', 7000);   // the fuzz's random sequence; each person adds their index const KEEP = flag('keep'); const want = s => !ONLY || ONLY.includes(s); const sleep = ms => new Promise(r => setTimeout(r, ms));@@ -231,7 +233,7 @@ async function openPerson(S, carbon, i, { view = 'gantt', w = 1500, h = 900 } =   page.on('pageerror', e => P.errors.push(e.message));   page.on('console', m => { if (m.type() === 'error' && !/Failed to load resource/.test(m.text())) P.errors.push('console: ' + m.text().slice(0, 200)); });   page.on('request', r => { const u = r.url().replace(/^https?:\/\/[^/]+/, '').split('?')[0], mth = r.method();-    if (mth === 'POST' && u === '/save') P.req.save++; else if (mth === 'GET' && u === '/state') P.req.state++;+    if (mth === 'POST' && (u === '/save' || u === '/ops')) P.req.save++; else if (mth === 'GET' && u === '/state') P.req.state++;     else if (mth === 'POST' && u === '/live/focus') P.req.focus++; else if (mth === 'POST' && u === '/log') P.req.log++; });   return P; }@@ -313,13 +315,18 @@ async function quiesce(S, people, timeout = 30000) { // Every tab holds exactly what the server holds, field by field, row by row. async function convergence(S, people) {   const srv = new Map((await planRows(S, people[0].sid)).filter(r => r.uid).map(r => [r.uid, r]));-  const norm = r => JSON.stringify([r.name || '', r.owner || '', r.status || '', r.start || '', r.end || '', r.date || '', r.phase, (r.deps || []).slice().sort(), r.description || r.desc || '']);+  const full = await people[0].page.evaluate(() => !!(window.__collab && window.__collab.order));+  // From 0.77.0 the tabs report every field, and the order, so all of it is compared.+  const norm = r => JSON.stringify([r.name || '', r.owner || '', r.status || '', r.start || '', r.end || '', r.date || '', r.phase, (r.deps || []).slice().sort(), r.description || r.desc || '',+    ...(full ? [r.note || '', (Array.isArray(r.tags) ? r.tags : []).map(t => String(t).toLowerCase()).sort(), typeof r.effort === 'number' ? r.effort : null] : [])]);+  const srvOrder = [...srv.keys()];   const diffs = [];   for (const P of people) {     const rows = await P.page.evaluate(() => window.__collab.rows());     const mine = new Map(rows.map(r => [r.uid, r]));     if (mine.size !== srv.size) diffs.push(`${P.name}: ${mine.size} rows, server ${srv.size}`);     for (const [u, s] of srv) { const m = mine.get(u); if (!m) { diffs.push(`${P.name}: missing ${u}`); continue; } if (norm(m) !== norm(s)) diffs.push(`${P.name}: ${u} ${norm(m)} vs server ${norm(s)}`); }+    if (full) { const o = (await P.page.evaluate(() => window.__collab.order())).filter(u => srv.has(u)); if (o.join() !== srvOrder.join()) diffs.push(`${P.name}: row order differs from the server's`); }   }   return diffs; }@@ -668,6 +675,238 @@ async function reconnectScenario(carbon) {   await stopServer(S); } ++// ════════════════════════════════════════════════════════════════════════════+// Google Docs style (0.77.0): what a tab sends is what it changed, field by field, so two+// people never lose each other's work by touching the same row; undo is your own.+// ════════════════════════════════════════════════════════════════════════════+const hasOps = P => P.page.evaluate(() => !!(window.__collab && window.__collab.set));+const setF = (P, uid, f, v) => P.page.evaluate((u, f, v) => window.__collab.set(u, f, v), uid, f, v);+const tabRow = (P, uid) => P.page.evaluate(u => window.__collab.rows().find(r => r.uid === u) || null, uid);+// This tab has nothing left to send and nothing on its way.+async function sent(people, timeout = 15000) {+  const t0 = Date.now();+  while (Date.now() - t0 < timeout) {+    const st = await Promise.all(people.map(P => P.page.evaluate(() => ({ p: window.__collab.pending(), f: window.__collab.inflight() }))));+    if (st.every(x => !x.p && !x.f)) return true;+    await sleep(100);+  }+  return false;+}+async function docsScenario(carbon) {+  scenario = 'docs';+  const S = await startServer(carbon);+  const A = await openPerson(S, carbon, 0), Bp = await openPerson(S, carbon, 1);+  const both = [A, Bp];+  await goReady(A, S); await goReady(Bp, S); await sleep(1500);+  if (!(await hasOps(A))) { skip('docs', 'this build sends whole rows, not fields'); for (const P of both) await closePerson(P); await stopServer(S); return; }+  const pause = () => Promise.all(both.map(P => P.page.evaluate(() => window.__collab.pause())));+  const resume = async () => { await Promise.all(both.map(P => P.page.evaluate(() => window.__collab.resume()))); await quiesce(S, both); };+  const srvRow = async uid => (await planRows(S, A.sid)).find(r => r.uid === uid) || null;+  const rows = (await planRows(S, A.sid)).filter(r => r.type === 'task' && r.id && r.start && !/cancel/i.test(r.status || ''));+  const other = (cur, list) => list.find(x => x !== (cur || '')) || list[0];++  // 1. Two fields of one row, at the same moment: neither tab hears the other first.+  const r1 = rows[3];+  await pause();+  await setF(A, r1.uid, 'owner', 'Kaley Mer'); await setF(Bp, r1.uid, 'status', 'blocked');+  await sent(both); await resume();+  let g = await srvRow(r1.uid);+  check('two people change two different fields of one row at the same moment: both are kept', g.owner === 'Kaley Mer' && g.status === 'blocked', JSON.stringify([g.owner, g.status]));+  let ta = await tabRow(A, r1.uid), tb = await tabRow(Bp, r1.uid);+  check('and both tabs show both', ta.owner === 'Kaley Mer' && tb.owner === 'Kaley Mer' && ta.status === 'blocked' && tb.status === 'blocked', JSON.stringify([ta.owner, ta.status, tb.owner, tb.status]));++  // 2. One field, at the same moment: the later save wins it, everywhere.+  const r2 = rows[7];+  await pause();+  await setF(A, r2.uid, 'owner', 'Noah Ash'); await sent([A]);+  await setF(Bp, r2.uid, 'owner', 'Colby Knox'); await sent([Bp]);+  await resume();+  g = await srvRow(r2.uid); ta = await tabRow(A, r2.uid); tb = await tabRow(Bp, r2.uid);+  check('one field changed by two people at once: the later save wins it, in every tab', g.owner === 'Colby Knox' && ta.owner === 'Colby Knox' && tb.owner === 'Colby Knox', JSON.stringify([g.owner, ta.owner, tb.owner]));++  // 3. One field, a moment apart: the person whose value was replaced is told, by name.+  const r3 = rows[9];+  await setF(A, r3.uid, 'owner', 'Bo Chen'); await quiesce(S, both);+  await setF(Bp, r3.uid, 'owner', 'Cy Diaz'); await quiesce(S, both);+  const notes = await A.page.evaluate(() => window.__collab.notices());+  check('a value replaced a moment later is not lost silently: its writer is told who replaced it and what they had', notes.some(n => n.uid === r3.uid && n.f === 'owner' && n.who === 'Kaley Mer' && n.was === 'Bo Chen' && n.to === 'Cy Diaz'), JSON.stringify(notes.slice(-3)));+  check('and sees it said on screen', /Kaley Mer changed the owner of .* to Cy Diaz just now \(you had Bo Chen\)/.test(await A.page.$eval('#saveInd', e => e.textContent)), await A.page.$eval('#saveInd', e => e.textContent));++  // 4. Lists merge item by item: two people add a different dependency, and a different tag.+  const r4 = rows[12], c4 = await tabRow(A, r4.uid);+  const d1 = rows.find(r => r.uid !== r4.uid && !c4.deps.includes(r.id)).id, d2 = rows.find(r => r.uid !== r4.uid && r.id !== d1 && !c4.deps.includes(r.id)).id;+  await pause();+  await setF(A, r4.uid, 'deps', [...c4.deps, d1]); await setF(Bp, r4.uid, 'deps', [...c4.deps, d2]);+  await setF(A, r4.uid, 'tags', [...c4.tags, 'Alpha']); await setF(Bp, r4.uid, 'tags', [...c4.tags, 'Beta']);+  await sent(both); await resume();+  g = await srvRow(r4.uid);+  check('two people add a different dependency to one row at once: both are kept', [...c4.deps, d1, d2].every(d => (g.deps || []).includes(d)), JSON.stringify(g.deps));+  check('two people add a different tag to one row at once: both are kept', ['alpha', 'beta', ...c4.tags].every(t => (g.tags || []).map(x => x.toLowerCase()).includes(t)), JSON.stringify(g.tags));++  // 5. Undo is your own: it takes back the field you changed and leaves a teammate's later+  //    change to another field of the same row alone, without claiming it kept anything.+  const r5 = rows[20], b5 = await srvRow(r5.uid);+  const st5 = other(b5.status, ['in-progress', 'blocked', 'complete']);+  await setF(A, r5.uid, 'owner', 'Adonis Ray'); await quiesce(S, both);+  await setF(Bp, r5.uid, 'status', st5); await quiesce(S, both);+  await A.page.evaluate(() => window.__collab.undo()); await quiesce(S, both);+  g = await srvRow(r5.uid);+  check('Ctrl+Z puts back your own change (the owner) and keeps a teammate\'s later change to the row (the status)', (g.owner || '') === (b5.owner || '') && g.status === st5, JSON.stringify([b5.owner, g.owner, st5, g.status]));+  check('and does not claim to have kept a field it never changed', !(await A.page.evaluate(() => window.__collab.kept())).length, JSON.stringify(await A.page.evaluate(() => window.__collab.kept())));+  // 6. ...and when the teammate changed the SAME field after you, undo leaves theirs and says so.+  const r6 = rows[25];+  await setF(A, r6.uid, 'owner', 'Ed Fox'); await quiesce(S, both);+  await setF(Bp, r6.uid, 'owner', 'Flo Gray'); await quiesce(S, both);+  await A.page.evaluate(() => window.__collab.undo()); await quiesce(S, both);+  g = await srvRow(r6.uid);+  const kept6 = await A.page.evaluate(() => window.__collab.kept());+  check('undo does not take back a teammate\'s newer value of the field you changed', g.owner === 'Flo Gray', g.owner);+  check('and the toast names whose value it kept', kept6.some(k => k.uid === r6.uid && k.f === 'owner' && k.who === 'Kaley Mer') && /kept Kaley Mer's newer owner/.test(await A.page.$eval('#undoToast', e => e.textContent)), JSON.stringify(kept6) + ' ' + await A.page.$eval('#undoToast', e => e.textContent));+  // 7. A list item: undo takes out the dependency you added, not the one a teammate added after.+  const r7 = rows[30], c7 = await tabRow(A, r7.uid);+  const e1 = rows.find(r => r.uid !== r7.uid && !c7.deps.includes(r.id)).id, e2 = rows.find(r => r.uid !== r7.uid && r.id !== e1 && !c7.deps.includes(r.id)).id;+  await setF(A, r7.uid, 'deps', [...c7.deps, e1]); await quiesce(S, both);+  const c7b = await tabRow(Bp, r7.uid);+  await setF(Bp, r7.uid, 'deps', [...c7b.deps, e2]); await quiesce(S, both);+  await A.page.evaluate(() => window.__collab.undo()); await quiesce(S, both);+  g = await srvRow(r7.uid);+  check('undo takes out the dependency you added and keeps the one a teammate added after it', !(g.deps || []).includes(e1) && (g.deps || []).includes(e2), JSON.stringify(g.deps));++  // 8. Two people move different rows at the same moment: both moves land.+  const order0 = (await planRows(S, A.sid)).filter(r => r.uid);+  const byPhase = p => order0.filter(r => r.phase === p);+  const pa = [...new Set(order0.map(r => r.phase))].find(p => byPhase(p).length >= 4);+  const pb = [...new Set(order0.map(r => r.phase))].find(p => p !== pa && byPhase(p).length >= 4);+  const X = byPhase(pa), Y = byPhase(pb);+  await pause();+  const mvA = await A.page.evaluate((u, a) => window.__collab.move(u, a), X[3].uid, X[0].uid);+  const mvB = await Bp.page.evaluate((u, a) => window.__collab.move(u, a), Y[3].uid, Y[0].uid);+  await sent(both); await resume();+  let o = (await planRows(S, A.sid)).map(r => r.uid);+  check('two people move different rows at the same moment: both moves land', mvA && mvB && o.indexOf(X[3].uid) === o.indexOf(X[0].uid) + 1 && o.indexOf(Y[3].uid) === o.indexOf(Y[0].uid) + 1, JSON.stringify([mvA, mvB]));+  // and an undo of one move puts back that row only+  await A.page.evaluate(() => window.__collab.undo()); await quiesce(S, both);+  o = (await planRows(S, A.sid)).map(r => r.uid);+  check('undoing your move puts your row back and leaves the teammate\'s move where they put it', o.indexOf(X[3].uid) === o.indexOf(X[2].uid) + 1 && o.indexOf(Y[3].uid) === o.indexOf(Y[0].uid) + 1, JSON.stringify([o.indexOf(X[2].uid), o.indexOf(X[3].uid), o.indexOf(Y[0].uid), o.indexOf(Y[3].uid)]));++  // 9. A row you made, which a teammate has worked on since, is not taken away by your undo.+  await A.page.click('#btnNew'); await sleep(300);+  await A.page.type('#ncName', 'Made by Drew'); await A.page.click('#ncCreate'); await sleep(400);+  await quiesce(S, both);+  const made = (await planRows(S, A.sid)).find(r => r.name === 'Made by Drew');+  check('a row made in one tab reaches the server', !!made);+  if (made) {+    await setF(Bp, made.uid, 'owner', 'Kaley Mer'); await quiesce(S, both);+    await A.page.evaluate(() => window.__collab.undo()); await quiesce(S, both);+    check('undoing the creation of a row a teammate has since changed keeps the row', !!(await srvRow(made.uid)) && (await A.page.evaluate(() => window.__collab.kept())).some(k => k.f === 'row'), JSON.stringify(await A.page.evaluate(() => window.__collab.kept())));+  }++  // 10. An edit from a tab that had not heard of a deletion brings the row back with it.+  const r10 = rows[40];+  await A.page.evaluate(() => window.__collab.pause());+  await Bp.page.evaluate(u => window.__collab.del(u), r10.uid); await sent([Bp]);+  check('the deletion reaches the server', !(await srvRow(r10.uid)));+  await setF(A, r10.uid, 'owner', 'Di Evans'); await sent([A]);+  await resume();+  g = await srvRow(r10.uid); tb = await tabRow(Bp, r10.uid);+  check('an edit made without seeing a deletion brings the row back, with the edit, in every tab', g && g.owner === 'Di Evans' && tb && tb.owner === 'Di Evans', JSON.stringify([g && g.owner, tb && tb.owner]));++  const diffs = await convergence(S, both);+  check('after all of it, both tabs hold exactly what the server holds, the row order included', !diffs.length, diffs.slice(0, 3).join(' | '));+  const errs = both.flatMap(P => P.errors);+  check('no page errors', !errs.length, errs.slice(0, 3).join(' | '));+  for (const P of both) await closePerson(P);+  await stopServer(S);+}++// Several people editing the same handful of rows as fast as they can: fields, lists, moves,+// undo and redo, with tabs dropping off the live channel and coming back. Afterwards every+// tab must hold exactly what the server holds, and a field only one person ever wrote must+// end as that person left it: whole-row saving lost exactly those.+async function fuzzScenario(carbon) {+  scenario = 'fuzz';+  const S = await startServer(carbon);+  const N = Math.min(USERS, 6), people = [];+  for (let i = 0; i < N; i++) { const P = await openPerson(S, carbon, i); await goReady(P, S); people.push(P); }+  await sleep(1500);+  if (!(await hasOps(people[0]))) { skip('fuzz', 'this build sends whole rows, not fields'); for (const P of people) await closePerson(P); await stopServer(S); return; }+  const all = await planRows(S, people[0].sid);+  const pool = all.filter(r => r.type === 'task' && r.uid && r.phase < 3);+  const ids = all.filter(r => r.id).map(r => r.id);+  const writers = new Map(), lastVal = new Map();+  const DUR = Math.min(SECONDS, 40) * 1000, t0 = Date.now();+  let actions = 0, drops = 0;+  await Promise.all(people.map(async (P, pi) => {+    const R = rng(SEED + pi);+    let n = 0, offline = false;+    while (Date.now() - t0 < DUR) {+      const row = pool[Math.floor(R() * pool.length)], x = R();+      let act;+      if (x < 0.42) {+        const f = ['owner', 'status', 'note', 'desc', 'effort', 'e'][Math.floor(R() * 6)];+        const v = f === 'owner' ? PEOPLE[Math.floor(R() * PEOPLE.length)] : f === 'status' ? STATUSES[Math.floor(R() * STATUSES.length)]+          : f === 'note' ? `n${pi}.${n}` : f === 'desc' ? `d${pi}.${n}` : f === 'effort' ? Math.floor(R() * 40) : shiftDay(row.start, 3 + Math.floor(R() * 20));+        act = { k: 'set', uid: row.uid, f, v };+      } else if (x < 0.56) act = { k: 'list', uid: row.uid, f: 'deps', item: ids[Math.floor(R() * ids.length)] };+      else if (x < 0.68) act = { k: 'list', uid: row.uid, f: 'tags', item: ['Red', 'Green', 'Blue', 'Gold'][Math.floor(R() * 4)] };+      else if (x < 0.8) act = { k: 'undo' };+      else if (x < 0.87) act = { k: 'redo' };+      else if (x < 0.95) act = { k: 'move', uid: row.uid, r: R() };+      else act = { k: 'net' };+      if (act.k === 'net') {+        offline = !offline; if (offline) drops++;+        await P.page.evaluate(o => o ? window.__collab.pause() : window.__collab.resume(), offline);+      } else {+        // The action and the before/after read are one synchronous call, so nobody else's+        // change can land between them and be credited to this person.+        const changed = await P.page.evaluate(a => {+          const C = window.__collab, snap = () => new Map(C.rows().map(r => [r.uid, r]));+          const before = snap();+          if (a.k === 'set') C.set(a.uid, a.f, a.v);+          else if (a.k === 'list') { const r = before.get(a.uid); if (r) { const cur = a.f === 'deps' ? r.deps : r.tags; const has = cur.includes(a.f === 'tags' ? a.item.toLowerCase() : a.item);+            C.set(a.uid, a.f, has ? cur.filter(x => x !== (a.f === 'tags' ? a.item.toLowerCase() : a.item)) : [...cur, a.item]); } }+          else if (a.k === 'undo') C.undo(); else if (a.k === 'redo') C.redo();+          else if (a.k === 'move') { const r = before.get(a.uid); const mates = C.order().filter(u => { const m = before.get(u); return m && r && m.phase === r.phase && u !== a.uid; });+            if (mates.length) C.move(a.uid, mates[Math.floor(a.r * mates.length)]); }+          const after = snap(), out = [];+          for (const [u, r] of after) { const b = before.get(u); if (!b) continue;+            for (const f of ['owner', 'status', 'note', 'desc', 'effort', 'end', 'deps', 'tags']) if (JSON.stringify(b[f]) !== JSON.stringify(r[f])) out.push([u, f, r[f]]); }+          return out;+        }, act);+        for (const [u, f, v] of changed) { const k = u + '|' + f; (writers.get(k) || writers.set(k, new Set()).get(k)).add(pi); lastVal.set(k + '|' + pi, v); }+      }+      actions++; n++;+      await sleep(60 + R() * 350);+    }+    if (offline) await P.page.evaluate(() => window.__collab.resume());+  }));+  const took = await quiesce(S, people, 60000);+  check(`${N} people, ${actions} actions in ${Math.round(DUR / 1000)} s (${drops} drop-offs): everyone settles`, took >= 0, took >= 0 ? fmtMs(took) : 'never settled');+  const diffs = await convergence(S, people);+  check('every tab holds exactly what the server holds: every field, every list, the row order', !diffs.length, diffs.slice(0, 4).join(' | '));+  const srv = new Map((await planRows(S, people[0].sid)).map(r => [r.uid, r]));+  const lost = [];+  let single = 0;+  for (const [k, who] of writers) {+    if (who.size !== 1) continue;+    single++;+    const [u, f] = k.split('|'), pi = [...who][0], want = lastVal.get(k + '|' + pi), r = srv.get(u);+    if (!r) continue;+    const have = f === 'tags' ? (r.tags || []).map(t => t.toLowerCase()).sort() : f === 'deps' ? (r.deps || []).slice().sort() : f === 'desc' ? (r.description || '') : f === 'effort' ? (typeof r.effort === 'number' ? r.effort : null) : (r[f] || '');+    const w = Array.isArray(want) ? want.slice().sort() : (want === undefined ? '' : want);+    if (JSON.stringify(have) !== JSON.stringify(w)) lost.push(`${u}.${f}: ${PEOPLE[pi]} left ${JSON.stringify(w)}, server has ${JSON.stringify(have)}`);+  }+  check(`no edit lost: each of the ${single} fields only one person wrote ends as that person left it`, !lost.length, lost.slice(0, 4).join(' | '));+  const errs = people.flatMap(P => P.errors);+  check('no page errors', !errs.length, errs.slice(0, 3).join(' | '));+  const h = await (await fetch(S.base + '/health')).json();+  check('the changelog\'s hash chain is intact', h.integrity ? h.integrity.ok !== false : true, JSON.stringify(h.integrity));+  report.scenarios.fuzz = { seed: SEED, people: N, actions, drops, settleMs: took, singleWriterFields: single };+  for (const P of people) await closePerson(P);+  await stopServer(S);+}+ // ════════════════════════════════════════════════════════════════════════════ const t0 = Date.now(); let exitCode = 1;@@ -684,6 +923,8 @@ try {   if (want('soak')) await soakScenario(carbon);   if (want('conflict')) await conflictScenario(carbon);   if (want('reconnect')) await reconnectScenario(carbon);+  if (want('docs')) await docsScenario(carbon);+  if (want('fuzz')) await fuzzScenario(carbon);   exitCode = fail ? 1 : 0; } catch (e) {   console.error('\nSUITE CRASHED:', e && e.stack || e);
test/server_test.mjs+66
@@ -629,6 +629,72 @@ async function main() {   r = await asPost(drew, '/save', Object.assign({}, plain, { dates: { start: '2026-09-14', end: '2026-12-18' }, calendar: { workHolidays: [], extraDaysOff: [] } }));   const exd = (await as(drew, '/export/json')).j;   ok(r.status === 200 && exd && exd.project && !('calendar' in exd.project), 'an empty calendar is the default, and the merged plan says nothing', exd && exd.project);+  // ── Field-level ops (0.77.0): what changed, field by field, applied in arrival order ──+  {+    const ex0 = (await as(drew, '/export/json')).j;+    const etch = ex0.items.find(x => x.id === 'etch'), ship = ex0.items.find(x => x.id === 'ship'), launch = ex0.items.find(x => x.name === 'Launch');+    const opsP = (who, ops, extra = {}) => asPost(who, '/ops', Object.assign({ client: 't', batch: 'b', ops }, extra));+    const exp = async () => (await as(drew, '/export/json')).j;+    const rowOf = async uid => ((await exp()).items || []).find(x => x.uid === uid);+    const rev0 = ((await as(drew, '/health')).j || {}).stateRev;+    r = await opsP(ana, [{ op: 'row', uid: etch.uid, set: { owner: 'Ana' } }]);+    ok(r.status === 200 && r.j.stateRev === rev0 + 1 && r.j.ops.length === 1 && r.j.ops[0].set.owner === 'Ana', 'a member\'s field op lands as one revision and comes back as stored', r.j);+    r = await opsP(drew, [{ op: 'row', uid: etch.uid, set: { status: 'blocked' } }]);+    let e = await rowOf(etch.uid);+    ok(e.owner === 'Ana' && e.status === 'blocked', 'two people\'s fields on one row both stand: the last write wins per field, not per row', [e.owner, e.status]);+    await opsP(ana, [{ op: 'row', uid: ship.uid, add: { deps: ['x1'], tags: ['Alpha'] } }]);+    await opsP(drew, [{ op: 'row', uid: ship.uid, add: { deps: ['x2'], tags: ['beta', 'ALPHA'] }, del: { deps: ['etch'] } }]);+    e = await rowOf(ship.uid);+    ok(eq(e.deps, ['x1', 'x2']) && eq(e.tags, ['Alpha', 'beta']), 'lists merge item by item: two people\'s additions both stand, a removal takes one item, tags match without regard to case', [e.deps, e.tags]);+    r = await opsP(drew, [{ op: 'row', uid: launch.uid, set: { date: '2026-10-22', s: '2026-01-01', effort: 5 } }]);+    ok(r.status === 200 && eq(Object.keys(r.j.ops[0].set), ['date']) && (await rowOf(launch.uid)).date === '2026-10-22', 'a milestone takes its date and ignores the task-only fields', r.j.ops);+    r = await opsP(drew, [{ op: 'row', uid: etch.uid, set: { e: 'tomorrow', name: '', owner: 'Okay' } }]);+    ok(r.status === 200 && eq(Object.keys(r.j.ops[0].set), ['owner']) && r.j.refused.length === 2, 'a bad field is skipped and named; the rest of the op lands', r.j);+    const revA = ((await as(drew, '/health')).j || {}).stateRev;+    r = await opsP(drew, [{ op: 'row', uid: '99999', set: { owner: 'x' } }, { op: 'nonsense' }, 5]);+    ok(r.status === 200 && !r.j.ops.length && r.j.stateRev === revA && r.j.refused.length === 3, 'ops on a row the plan does not have, of an unknown kind or not an op change nothing and make no revision', r.j);+    r = await as(drew, '/ops', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{"ops":5}' });+    ok(r.status === 400, 'a body whose ops are not a list is refused', r.status);+    r = await opsP(drew, new Array(5001).fill({ op: 'move', uid: 'x', after: null }));+    ok(r.status === 413, 'a batch of more than 5000 ops is refused', r.status);+    r = await as(null, '/ops', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{"ops":[]}' });+    ok(r.status === 401, 'nobody who is not signed in can send ops', r.status);+    r = await opsP(ana, [{ op: 'add', row: { type: 'task', uid: '00777', id: 'etch', name: 'Etch again', start: '2026-10-05', end: '2026-10-06', phase: 0 } }, { op: 'move', uid: '00777', after: null }]);+    const ord = r.j.ops.find(o => o.op === 'order');+    ok(r.j.ops[0].row && r.j.ops[0].row.id === 'etch-2' && ord && ord.order[0] === '00777', 'a new row lands with its id made unique (etch is taken), and a move puts it first', r.j.ops);+    let ex = await exp();+    ok(ex.items[0].uid === '00777' && ex.items[0].id === 'etch-2' && ex.items.length === ex0.items.length + 1, 'the merged plan has it, first, with the server\'s id', ex.items.map(x => x.uid));+    await opsP(drew, [{ op: 'del', uid: '00777' }, { op: 'del', uid: etch.uid }]);+    ex = await exp();+    ok(!ex.items.some(x => x.uid === '00777' || x.uid === etch.uid), 'a created row and a data-file row can both be deleted', ex.items.map(x => x.uid));+    r = await opsP(ana, [{ op: 'row', uid: etch.uid, set: { owner: 'Back' } }]);+    e = await rowOf(etch.uid);+    ok(r.j.ops[0].op === 'add' && e && e.owner === 'Back', 'an edit from a tab that had not heard of a deletion brings the row back with it, and says so', r.j.ops);+    r = await opsP(drew, [{ op: 'add', row: { uid: '00777' } }]);+    e = await rowOf('00777');+    ok(e && e.name === 'Etch again' && e.id === 'etch-2', 'putting a deleted created row back (an undo) restores it whole', e);+    const dates0 = ((await as(drew, '/state')).j || {}).dates;+    r = await opsP(ana, [{ op: 'dates', dates: { start: '2020-01-01', end: '' } }, { op: 'cal', calendar: { workHolidays: ['Labor Day'] } }]);+    ok(r.j.refused.filter(x => /only the owner/.test(x)).length === 2 && eq(((await as(drew, '/state')).j || {}).dates, dates0), 'the project dates and the calendar stay the owner\'s over ops too', r.j);+    r = await opsP(drew, [{ op: 'cal', calendar: { workHolidays: ['Labor Day'], extraDaysOff: [] } }]);+    ok(eq((((await as(drew, '/state')).j || {}).calendar || {}).workHolidays, ['Labor Day']), 'and the owner changes them', r.j);+    r = await agent('/ops', { ops: [{ op: 'row', uid: ship.uid, set: { note: 'by agent' } }] });+    ok(r.status === 200 && (await rowOf(ship.uid)).note === 'by agent', 'the agent token can send ops', r.j || r.status);+    // Every open tab hears every batch, the sender's own included, with what it needs to+    // apply it in order: the revision, who, which tab and batch, and the ops as stored.+    const live2 = await fetch(S.base + '/live?cid=ana2', { headers: Object.assign({ Cookie: ana.cookie }, XFF) });+    const rd = live2.body.getReader(); let buf = '';+    const hear = async pred => { const t0 = Date.now(); while (Date.now() - t0 < 5000) { const { value, done } = await rd.read(); if (done) break; buf += Buffer.from(value).toString(); if (pred(buf)) return true; } return false; };+    await hear(b => b.includes('"type":"hello"'));+    r = await opsP(drew, [{ op: 'row', uid: ship.uid, set: { note: 'live' } }], { client: 'drewtab', batch: 'drewtab.9' });+    const heard = await hear(b => b.includes('drewtab.9'));+    const line = buf.split('\n').find(l => l.startsWith('data:') && l.includes('drewtab.9'));+    const msg = line ? JSON.parse(line.slice(5)) : {};+    ok(heard && msg.type === 'rev' && msg.stateRev === r.j.stateRev && msg.by === 'drewtab' && msg.batch === 'drewtab.9' && msg.actor === 'Drew Owens' && msg.ops[0].set.note === 'live', 'every tab hears the batch: its revision, who, which tab, and the ops as stored', msg);+    try { await rd.cancel(); } catch (e2) {}+    // Put the plan back the way the tests below expect it.+    await opsP(drew, [{ op: 'del', uid: '00777' }, { op: 'row', uid: etch.uid, set: { owner: '', status: '' } }, { op: 'row', uid: ship.uid, set: { deps: ['etch'], tags: [], note: '' } }, { op: 'cal', calendar: { workHolidays: [], extraDaysOff: [] } }]);+  }   // The live stream of someone the owner stops sharing with is told and closed.   const live = await fetch(S.base + '/live?cid=ana1', { headers: Object.assign({ Cookie: ana.cookie }, XFF) });   const reader = live.body.getReader(); let got = '';
test/ui_test.mjs+4−4
@@ -3311,9 +3311,9 @@ async function collabFlow(browser) {   await B.reload({ waitUntil: 'load' }).then(() => ready(B)); await settle(B, 400); await view(B, 'table');   check('after a refresh B still has every edit', (await cellOwner(B, 'b1')) === 'Ana' && (await cellOwner(B, 'b2')) === 'Kai' && (await cellOwner(B, 's2')) === 'Noah'); -  // 5. A stale tab: A stops listening, B saves, A saves. A's save is refused once, A merges-  //    and saves again on its own, and the plan ends with BOTH edits and no banner.-  // The one 409 this flow provokes on purpose is allowed for exactly this step.+  // 5. A stale tab: A stops listening, B saves, A saves. Since 0.77.0 A's save is a field op+  //    and lands as it is (no 409); A notices it missed a revision and catches up, and the+  //    plan ends with BOTH edits and no banner. A 409 stays allowed for a build that refuses.   const allow409 = { path: /^\/save$/, status: 409 }; ALLOWED_FAILURES.push(allow409);   await A.bringToFront(); await A.evaluate(() => window.__collab.pause());   await B.bringToFront(); await clickFresh(B, '#tableView tr[data-key="t:b3"] td[data-field="owner"]'); await settle(B, 80);@@ -3323,7 +3323,7 @@ async function collabFlow(browser) {   const m0 = await A.evaluate(() => window.__collab.merges());   await clickFresh(A, '#tableView tr[data-key="t:s1"] td[data-field="owner"]'); await settle(A, 80);   await A.keyboard.down('Control'); await A.keyboard.press('KeyA'); await A.keyboard.up('Control'); await A.keyboard.type('Zoe'); await A.keyboard.press('Enter'); await settle(A, 1500);-  check('A\'s refused save recovered by merging (one merge) and saving again', await until(async () => (await A.evaluate(() => window.__collab.merges())) > m0 && (await row('s1')).owner === 'Zoe', 6000), JSON.stringify({ merges: await A.evaluate(() => window.__collab.merges()), s1: await row('s1') }));+  check('A\'s save landed while A was not listening, and A caught up on what it missed', await until(async () => (await A.evaluate(() => window.__collab.merges())) > m0 && (await row('s1')).owner === 'Zoe', 6000), JSON.stringify({ merges: await A.evaluate(() => window.__collab.merges()), s1: await row('s1') }));   check('and B\'s edit from the gap survived it', (await row('b3')).owner === 'Ray' && (await cellOwner(A, 'b3')) === 'Ray', await cellOwner(A, 'b3'));   check('no banner on A either', !(await banner(A)));   await A.evaluate(() => window.__collab.resume());
uninstall.sh