app
Adom Gantt
Public Made by Adomby adom
Interactive Gantt chart viewer for project timelines, with a table view and an analytics page over the same data. Data-driven, dark by default with a print-friendly light mode, and fully editable in the browser: drag bars to move or resize, multi-select and batch-shift dates, manage dependency arrows inline, reorder rows, filter by phase, and undo. One click auto-schedules the whole plan so every dependency lines up finish-to-start on working days, skipping weekends and US federal holidays, push
← Commit history
Publish 0.66.0
9 files changed
+561−36
README.md+1−1@@ -466,7 +466,7 @@ A full run takes about 12 minutes (14 with `--plan`). Since 0.57.1 the suite als ## Collaboration -Each team member runs their own server instance with the same data file. Use Export (JSON) to share the current state and Import (JSON) to load a colleague's version. The data file and sidecar state file can also be committed to a shared git repo for async 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). 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. ## License
SKILL.md+2−2@@ -249,7 +249,7 @@ rather than a category. - **The changelog actor is the signed-in identity**, taken from the session and never from the request body, so a shared plan's history reads "Drew Owens moved X" - **New items default their provenance to "Added by <your display name>"**, still an ordinary editable field - **Delete** removes the selected table row (undoable, redoable). Only when a row is selected and no editor, panel, search box or drag is live, so Delete inside a field only edits text. Backspace is not bound. Inert in the chart-- **Saving is whole-state and last-write-wins.** Two people editing at once WILL overwrite each other. Each save carries the revision it was based on and a stale save is refused with 409, so the second writer is told rather than the first writer being erased. **Since 0.54.0 a refused tab is impossible to miss.** The FIRST 409 from any save path, autosave included, raises a red banner of its own at the bottom of the window that stays up until the situation is resolved ("Your changes are not saving. This plan changed somewhere else.", with a running count of the changes that have not been saved), and the save indicator goes into an error state and stays in it. Nothing an ordinary session does takes either down: an undo, a delete, a dependency change, an auto-schedule, a hover. Four ways out: **Export my version** (the in-memory plan as JSON, so nothing is ever trapped behind the banner), **Save mine anyway** (force-overwrite, behind a confirm click), **Reload latest** (which warns first, names how many changes it is about to discard, and offers the export in the same row), and a save that starts working again, which clears the banner on its own. **The changelog is chained to the save that carries it, also since 0.54.0:** a tab whose saves are being refused queues its `/log` lines instead of posting them, so the changelog can never run ahead of the plan, and the queue goes out behind the first save that lands or is dropped along with the edits a reload discards. Through 0.53.0 the two were independent, and a blocked tab kept filling the changelog with edits the sidecar never received, which is precisely how a real seven-minute editing run was lost on 2026-08-21+- **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. - **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@@ -794,7 +794,7 @@ is why `project.nextUid` can be higher than any uid you can see. ## Tests -`node test/ui_test.mjs` (from the package directory) is the browser suite, 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.+`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. ## Example
docs/BUILD-NOTES.md+42@@ -3,6 +3,48 @@ Decisions taken while building, and the places where the plan and the code disagreed. Newest first. +## Build notes, 2026-10-07 (0.66.0, live tabs: the end of clobbering on refresh)++Checkpoint 1 of the collaboration plan (docs/plan-collab.md). Drew: "Must avoid constantly+clobbering each other's updates on refresh."++What clobbered: every edit saved the WHOLE sidecar; a save based on an older revision was+refused (409) and the only ways out were to discard this tab's work or to overwrite the+other person's; and a tab heard nothing until its own save was refused, so a refresh+always threw away whatever the tab had not saved.++* **A live channel.** `GET /live` is a server-sent-events stream, one per open tab,+ gated by the same sign-in as every other route. A landed save is announced to every+ OTHER tab (the saving tab's own id rides in the save body, is never stored, and is+ echoed so the tab can ignore itself) with the new revision and the actor's name. A+ comment heartbeat every 25 seconds keeps Cloudflare from dropping the stream; the+ browser reconnects on its own.+* **Merge under unsaved work.** A tab that hears a newer revision pulls `/state` and+ merges: rows it has not touched take the server's, rows it has (tracked by uid from+ the changelog diff that already runs on every edit) keep its own, and so do the section+ list, the order and the registry when it changed them; a plan shift or Auto-schedule+ marks every row as this tab's. Then `applyState` runs as at startup, every lens+ repaints, and the changelog baseline is re-taken so another person's edits are never+ logged as this tab's. Unsaved work is then saved on the new revision.+* **A refused save recovers itself.** 409 pulls, merges and saves again once; the red+ banner is for a second refusal in a row. The on-close beacon now carries the revision+ too: a tab put away while stale loses its last 800ms of edits rather than writing over+ somebody else's, and a tab that heard every announcement is never stale.+* **Presence.** The legend ends with who else has the plan open: initials in small discs,+ the name on hover, "N here". Nothing when alone.+* **Imports reload.** An import replaces the plan and resets the revision, so a tab cannot+ merge it: every other tab reloads, with a toast naming who imported.++What it does not do yet, said plainly: two people editing the SAME row within a save of+each other end with that row as the later saver had it (the other's change to that row is+lost without a notice). Field-level ops, checkpoint 2, fix that.++Tests: a two-tab `collab` flow (presence, an edit in one tab appearing in the other with+no reload, simultaneous edits on different rows both landing with no banner, a refresh+keeping everything, a tab that stopped listening getting one refusal and recovering by+merging, a created row and a deleted row crossing tabs, and the strip going when the+other tab closes). The page exposes `window.__collab` (pause, resume, merges, rev) for it.+ ## Build notes, 2026-10-05 (0.65.0, one item editor, one picker per kind of field) Drew: "clicking on a task bar or milestone should just open the Edit All Fields box ...
docs/plan-collab.mdadded+157@@ -0,0 +1,157 @@+# adom-gantt collaboration: a plan++Date: 2026-10-06. Requested by Drew: "add collaboration / shareable features ... Must+avoid constantly clobbering each other's updates on refresh." Not started; waiting on+the decisions at the end.++## Where it stands today++The app already has the pieces a multi-user plan needs, but it saves like a single-user+one:++- **Sign in with Adom** gates every page load and every change; the session is a+ 30-day cookie; the changelog records the signed-in person as the actor of each edit.+- **Saving is whole-state, last-write-wins.** Every edit, 800ms later, POSTs the ENTIRE+ sidecar (every row's overrides, the section list, the order, the registry). The server+ keeps a `stateRev`; a save carrying an older `baseRev` is refused with 409.+- **A refused tab is blocked.** The red banner offers Export my version, Save mine anyway+ (force-overwrite) and Reload latest (discard your work). There is no fourth option,+ because the server has no way to combine two whole states.+- **Nothing is live.** A tab learns the plan changed only when its next save is refused.+ A refresh gets the server's state and drops whatever the tab had not saved.++So with two people in the plan, the second person's first save after the first person's+is refused, and the only way out is to throw away one side's work or to force-write over+the other's. That is the clobbering.++## The one idea that fixes it++**Save what changed, not the whole plan, and tell every open tab.**++An edit becomes an **op**: a small, field-level patch such as `{op:'set', uid:'00042',+field:'end', value:'2026-11-03'}`, `{op:'move', uid, before}`, `{op:'phase.rename', key,+name}`, `{op:'tag.color', ...}`. The client already produces exactly this information for+the changelog (`diffAndLog`), so the op stream is the changelog's event stream made+authoritative rather than descriptive.++- **The server applies ops**, in arrival order, onto the sidecar it holds, bumps the rev+ per op, appends them to the changelog (one write, one hash chain, no second store),+ and **broadcasts** them to every other open tab over a server-sent-events channel.+- **Tabs apply remote ops** to their in-memory plan and repaint the row that changed, the+ way an undo already repaints one row. A tab is never stale, so a refresh never has+ anything to clobber: the state it reloads is the state it already showed.+- **Two people editing the same plan no longer collide**, because they are editing+ different fields of different rows almost all of the time, and field-level ops to+ different fields simply both land. The only true conflict is two people changing the+ SAME field of the SAME row within seconds of each other; the later op wins that field+ and the loser's tab shows a quiet notice ("Ana changed the deadline of X just now"),+ with the value it tried in the notice so nothing is lost silently.+- **Undo stays personal.** Ctrl+Z undoes your own ops, as ops, so it cannot undo+ somebody else's work and cannot be undone by theirs.++No locking, no merge dialogs, no "who has the plan open". This is how every shared+document tool behaves and it is what people expect.++## What ships, in order++Each checkpoint is shippable alone and goes out as its own version, lean process+(targeted tests, publish, full suite in the background).++### Checkpoint 1 (0.66.0): live tabs, no more surprise 409s. BUILT 2026-10-07, see BUILD-NOTES++The smallest change that ends the clobbering, built on the current whole-state save:++1. A **live channel** (`GET /live`, server-sent events). The server sends the new+ `stateRev` and the actor whenever a save lands, plus a heartbeat.+2. **A tab that hears a newer rev pulls the state and merges it row by row**: rows the+ tab has unsaved edits on keep the tab's values, every other row takes the server's.+ Then the tab saves, and its save carries the new rev, so it is not refused.+3. **Presence**: a strip at the right of the legend showing who has the plan open+ (initials, the name on hover). "Ana is editing" against a row was left for checkpoint 2,+ where the op stream can carry it; the live channel is one-way.+4. The red banner stays only for the case the merge cannot handle: a save refused twice+ in a row, which means something is wrong with the connection, not with the plan.++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++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+touch every row and are one op each: `plan.shift`, `auto-schedule`, `import`). The+changelog becomes the op log's storage; `?format=md` is unchanged. The same-field notice+appears. The agent token writes ops too, so an AI editing the plan and a person editing it+no longer collide either.++### Checkpoint 3 (0.68.0): sharing++1. **Share a link to a row**: every row has a URL (`#row=00042`); opening it scrolls to+ the row, selects it and opens its editor. The item editor gets a "copy link".+2. **Share a view**: the URL carries the lens, the grain, the tag and owner filters and+ the collapse set when you copy it from the share menu, so "look at the Ops tasks+ due this month" is one link. Opening it applies that view without changing the+ reader's saved preferences.+3. **View-only share link**: a long random token that lets someone without an Adom+ account read the plan, live, with every edit control hidden. Created and revoked on the+ Setup page; listed there with who made it and when it was last used. Nothing on a+ view-only link can write.+4. **Publish to the wiki** from the Export menu: the snapshot export the other agent runs+ by hand becomes one click, and the page it lands on is remembered per plan.++### Checkpoint 4 (0.69.0): seeing each other's work++1. **Who changed what**: hovering a row's bar or name shows "Ana, 2 hours ago" from the+ changelog; the Table gets an optional Updated column.+2. **Since you were here**: on load, a one-line summary ("Ana made 6 changes since+ Tuesday") with a link that opens the changelog filtered to them, and the changed rows+ outlined for a few seconds.+3. **Comments on a row**: a thread per row in the item editor, with @mentions of people+ in the plan. Stored as changelog events (`comment`), so they are attributed and+ append-only like everything else. Mentions could later notify through Google Chat,+ which is out of scope here.++## What is deliberately NOT in this plan++- Real-time cursors or character-by-character co-editing of a text field. A plan edit+ is a date, a status, an owner, a name: whole values, so op-level is enough.+- A hosted multi-tenant service. The app stays one server per plan, in whoever's+ container runs it, reached through the Adom proxy with Sign in with Adom.+- Permissions beyond signed-in-can-edit and view-only-link-can-read. Per-row ownership+ rules would be a separate decision.++## Risks++- **Checkpoint 1 merges by row.** Two people editing the same row at the same minute+ still ends with one of them refused for that row; checkpoint 2 fixes it. Said plainly+ in the status note when 0.66.0 ships.+- **The agent token.** Until checkpoint 2, an AI writing to the plan writes whole+ state; after a person's edit, its save would be refused. Today it already is.+- **Snapshots** (the wiki page) stay read-only copies and do not get live sync; they are+ files.+- **Tests.** The suite is single-tab; checkpoints 1 and 2 need two-tab flows (the suite+ already opens two pages in one browser for the text-size test, so the pattern exists).++## Decisions (Drew, 2026-10-07)++1. **Field-level ops, last writer wins per field.** Agreed.+2. **Reach.** Collaborators open the plan at the container's cloud URL+ (`https://<container>.adom.cloud/proxy/<port>/`) once the port is mapped. That URL+ names the container, not a secret, and the proxy does not gate who can open it: the+ app's own Sign in with Adom is the gate, so the permission model below is enforced on+ the server on every request, never only in the page.+3. **Confidential: every reader signs in with an Adom account. No anonymous links.** Per+ plan, a sharing setting on the Setup page, enforced server-side on every request:+ - **Only me**: the owner (the account that set it, or the first signed-in account).+ - **My org**: members of a chosen org (membership read from Carbon on sign-in and+ refreshed per session).+ - **Everyone with the link**: any signed-in Adom account.+ Each level has a read and an edit side (for instance: org can edit, everyone with the+ link can view). The default for an existing plan is "my org can edit", and the Setup+ page says what it is. View-only readers see every control for writing hidden and the+ server refuses their writes.+4. **Comments on rows: yes**, with a "show / hide comments" switch on the Setup page.+5. **Order as planned.** Checkpoint 1 next.++Checkpoint 3 changes accordingly: the "view-only token link" item is replaced by the+per-plan sharing setting above (no tokens; identity is always the Adom session).
gantt.html+180−4@@ -1015,6 +1015,14 @@ header .brand .app-ver { color: var(--text3); } :root[data-view="table"] .legend-item.gantt-only { display: none; } /* Now a group inside the legend line, not a band of its own. */ .table-tools { display: flex; align-items: center; gap: 8px; flex-shrink: 0; flex-wrap: nowrap; margin-left: auto; padding-left: 12px; }+/* Who else has the plan open (0.66.0): at the right end of the legend, after the table's+ filters when they are there. Quiet: initials in small discs, a count. */+.presence { display: inline-flex; align-items: center; gap: 3px; margin-left: auto; padding-left: 10px; flex-shrink: 0; }+:root[data-view="table"] .presence { margin-left: 8px; }+.presence-chip { width: 20px; height: 20px; border-radius: 50%; background: var(--surface2); border: 1px solid var(--border); color: var(--text2); font-family: 'Familjen Grotesk', sans-serif; font-size: 9.5px; font-weight: 600; display: inline-flex; align-items: center; justify-content: center; letter-spacing: 0.02em; }+.presence-chip[data-you="1"] { border-color: var(--accent); color: var(--accent); }+.presence-n { font-family: 'JetBrains Mono', monospace; font-size: 10px; color: var(--text3); margin-left: 3px; white-space: nowrap; }+@media print { .presence { display: none; } } :root[data-view="gantt"] .table-tools { display: none; } .legend-item { flex-shrink: 0; } /* Below this the phase chips give up their words before any control wraps: the@@ -3007,11 +3015,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++; } 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)); 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);@@ -3911,6 +3923,42 @@ let saveTimer=null; // the check is skipped, which is exactly how an older build behaves. 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.+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();+const remoteRemoved=new Map();+const dirtyStruct={phases:false,order:false,reg: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==='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=false; dirtyAll=false; }+} // How many changelog-worthy edits have been made since the last save that LANDED. It is // the number the banner quotes back before a reload throws them away, so a user is never // asked to discard "your changes" without being told how many of them there are.@@ -4328,6 +4376,7 @@ 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();@@ -4377,7 +4426,11 @@ function flushSave(){ // would silently overwrite the other writer with edits the user was told were not being // saved. They keep the banner's three ways out instead, and none of them is an accident. if(saveBlocked)return;- const body=JSON.stringify(getState());+ // 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'}));@@ -4413,20 +4466,138 @@ 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; 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)}) .then(async r=>{+ saveInFlight=false; const d=await r.json().catch(()=>({}));- if(r.status===409){ onSaveBlocked('conflict'); return; }+ 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'); }+ return;+ }+ recovering=false; 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(); } })- .catch(()=>{ onSaveBlocked('The server could not be reached.'); });-}+ .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.+async function pullAndMerge(){+ 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;+ 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));+ // 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.+ applyState(st);+ items.forEach(r=>{ if(r.id)refreshLabelRow(r); else if(r.type==='milestone')refreshMsChrome(r); });+ Object.keys(barEls).forEach(id=>updateBar(id));+ if(tagsReady){ pruneTagFilter(); syncTagList(); tagSig=tagSigNow(); }+ rebuildLegend(); applyCollapse();+ rebuildTimeline();+ // 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.+ if(!tblEditing)renderTable();+ if(prefs.view==='analytics')renderAnalytics(); else if(prefs.view==='setup')renderSections(true);+ drawDeps(); syncPlanSpan(); syncCollapseAllBtn(); syncPlanCtl(); if(critOn())refreshCritical();+ if(edSubject)syncEditorDates(edSubject);+ 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||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);+ }+}+// The stream of announcements from the server, and the presence it carries.+let live=null, livePeople=[], liveTabs=0, liveYou=null, liveMerges=0, liveLastActor='';+function startLive(){+ if(SNAPSHOT||live||!window.EventSource)return;+ const es=new EventSource('live?cid='+encodeURIComponent(CLIENT_ID));+ live=es;+ 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==='presence'){ livePeople=m.people||[]; liveTabs=m.tabs||0; renderPresence(); 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==='reload'){+ flash(saveInd,(m.actor||'Someone')+' imported a plan, reloading',3000);+ setTimeout(()=>location.reload(),1200);+ }+ };+ es.onerror=()=>{ /* the browser reconnects on its own; presence is re-announced then */ };+}+function stopLive(){ if(live){ live.close(); live=null; } }+// Who has the plan open, at the right end of the legend. Nothing when this tab is alone:+// a chip saying "you" on every plan would be noise. One chip per person (initials, the+// name on hover, a count when they have several tabs), and the number of people.+function renderPresence(){+ const leg=$('legend'); if(!leg)return;+ const old=$('presence'); if(old)old.remove();+ if(liveTabs<2||!livePeople.length)return;+ const el=document.createElement('span'); el.className='presence'; el.id='presence';+ const names=livePeople.map(p=>p.name+(p.n>1?' ('+p.n+' tabs)':'')).join(', ');+ el.title='Also looking at this plan right now: '+names+'. Their saves land here live.';+ el.innerHTML=livePeople.map(p=>`<span class="presence-chip"${liveYou&&p.name===liveYou.name?' data-you="1"':''} title="${esc(p.name)}${p.n>1?' ('+p.n+' tabs)':''}">${esc(p.initials||'?')}</span>`).join('')+ +`<span class="presence-n">${liveTabs===1?'1 here':liveTabs+' here'}</span>`;+ 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,dirty:()=>[...dirtyUids],rev:()=>baseRev}; // 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){@@ -4911,6 +5082,7 @@ function rebuildLegend(){ if(!folded)phases.forEach((ph,i)=>{ if(phaseShown(i,ks))put(phaseChip(ph,i,short(ph))); }); syncSecToggle(); syncCollapseAllBtn();+ renderPresence(); } // A key for the second band colour, and nothing more: the holidays are not something // a reader can switch off, because the schedule underneath them is computed with them@@ -12342,6 +12514,10 @@ applyCollapse(); // Re-baseline AFTER restoring the sidecar, so replaying saved edits on load is // not mistaken for a fresh round of user changes. lastSnap=snapSemantic();+startLive();+// The page is built and the plan is in. The suite waits for this rather than for the+// network to go quiet, which it never does once the live stream is open (0.66.0).+document.documentElement.dataset.ready='1'; // The rev used to sit in a floating chip over the chart, where it collided with // the milestone labels at the bottom of a tall plan. It is machinery for the AI // handoff loop, not something a reader needs on the canvas, and it stays fully
package.json+1−1@@ -2,7 +2,7 @@ "slug": "adom-gantt", "title": "Adom Gantt", "type": "app",- "version": "0.65.0",+ "version": "0.66.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.65.0",+ "version": "0.66.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+55−1@@ -393,6 +393,50 @@ function stateRev() { try { return JSON.parse(fs.readFileSync(STATE_FILE, 'utf8')).stateRev || 0; } catch (e) { return 0; } }+// ── Live channel (0.66.0) ──+//+// Every open tab holds a server-sent-events stream here. A save that lands is announced+// to every OTHER tab with the new stateRev and who made it, so a tab learns the plan moved+// the moment it does and not when its own save is refused; and the set of streams is the+// presence list, announced to everyone when it changes. One-way by design: a tab writes+// through /save and /log exactly as before, so nothing about what is stored changed.+// `cid` is the tab's own id, echoed on its save's announcement so the tab can ignore it.+// Cloudflare drops an idle stream at about 100s, hence the comment heartbeat every 25s;+// the browser reconnects on its own (`retry:`), and a reconnect re-announces presence.+const liveClients = new Set();+function liveSend(c, obj) { try { c.res.write('data: ' + JSON.stringify(obj) + '\n\n'); } catch (e) {} }+function broadcastLive(obj, exceptCid) {+ for (const c of liveClients) if (!exceptCid || c.cid !== exceptCid) liveSend(c, obj);+}+function presenceList() {+ const m = new Map();+ for (const c of liveClients) {+ const k = c.who.id || c.who.name;+ if (!m.has(k)) m.set(k, { name: c.who.name, initials: c.who.initials, n: 0 });+ m.get(k).n++;+ }+ return [...m.values()];+}+function broadcastPresence() { broadcastLive({ type: 'presence', people: presenceList(), tabs: liveClients.size }); }+function whoOf(auth) {+ const u = auth && auth.user;+ if (u) return { id: 'u:' + (u.id || u.username || u.displayName), name: u.displayName || u.username || 'Someone', initials: u.initials || '?' };+ if (auth && auth.agent) return { id: 'agent', name: AGENT_ACTOR, initials: 'AI' };+ return { id: '', name: 'Guest', initials: 'G' };+}+function liveOpen(req, res, auth, query) {+ const cid = String((query.match(/(?:^|&)cid=([^&]*)/) || [])[1] || '').slice(0, 40);+ res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache, no-transform',+ 'Connection': 'keep-alive', 'X-Accel-Buffering': 'no' });+ res.write('retry: 3000\n\n');+ const c = { res, cid, who: whoOf(auth) };+ liveClients.add(c);+ liveSend(c, { type: 'hello', stateRev: stateRev(), you: c.who });+ broadcastPresence();+ const hb = setInterval(() => { try { res.write(': ping\n\n'); } catch (e) {} }, 25000);+ const bye = () => { clearInterval(hb); if (liveClients.delete(c)) broadcastPresence(); };+ req.on('close', bye); res.on('close', bye); res.on('error', bye);+} function currentRev() { const log = readLog(); return log.length ? log[log.length - 1].rev : 0;@@ -602,6 +646,9 @@ function logCompact(info, actor) { // all: an agent reading the changelog could not tell that every event above it // referred to a plan that no longer exists. Never let a failed log kill the import. function logImport(format, result, actor) {+ // An import replaces the plan and resets the sidecar, so a tab cannot merge it: it+ // reloads, and the announcement says who imported what.+ broadcastLive({ type: 'reload', why: 'import', format, actor: actor || 'user', ts: Date.now() }); try { appendLog([{ action: 'import', format, items: result.items, phases: result.phases, via: 'import' }], actor || 'user'); } catch (e) {@@ -2407,9 +2454,14 @@ const server = http.createServer(async (req, res) => { store.stateRev = cur + 1; delete store.baseRev; delete incoming.baseRev;+ // The saving tab's own id rides in the body and is never stored; it is what lets+ // that tab ignore its own announcement.+ const cid = typeof incoming.client === 'string' ? incoming.client.slice(0, 40) : '';+ delete store.client; delete incoming.client; writeAtomic(STATE_FILE, JSON.stringify(store)); incoming.stateRev = store.stateRev; jsonResp(res, 200, { ok: true, file: path.basename(STATE_FILE), bytes: body.length, stateRev: incoming.stateRev });+ broadcastLive({ type: 'rev', stateRev: store.stateRev, by: cid, actor: whoOf(auth).name, ts: Date.now() }, cid); } catch (e) { jsonResp(res, 400, { ok: false, error: e.message }); }@@ -2492,6 +2544,8 @@ const server = http.createServer(async (req, res) => { // Always v4, whatever is on disk. The boot rewrites the file, so this is // belt and braces for a sidecar dropped in underneath a running server: the // client is v4-only on purpose, and the one translator lives on this side.+ if (urlPath === '/live') { liveOpen(req, res, auth, query); return; }+ if (urlPath === '/state') { const integrity = integrityStatus(); if (fs.existsSync(STATE_FILE)) {@@ -2748,7 +2802,7 @@ server.listen(PORT, '127.0.0.1', () => { console.log(`adom-gantt serving ${path.basename(DATA_FILE)} on http://127.0.0.1:${PORT}`); console.log(`State file: ${STATE_FILE}`); console.log(`Data dir: ${DATA_DIR}`);- console.log(`Endpoints: /health /data /state /save /uid /uid/backfill /compact /import/{json,csv,msproject-xml,jira-csv} /export/{json,csv,svg,html}`);+ console.log(`Endpoints: /health /data /state /live /save /uid /uid/backfill /compact /import/{json,csv,msproject-xml,jira-csv} /export/{json,csv,svg,html}`); const integ = integrityStatus(); console.log(integ.ok ? `Changelog integrity: OK (hash chain verified${integ.chained ? '' : ', not yet chained'})`
test/ui_test.mjs+122−26@@ -286,12 +286,15 @@ async function openPage(browser, url, { w = 1440, h = 900, mobile = false, today Object.defineProperty(window, '__uiVis', { value: e => !!e && e.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true, opacityProperty: true, visibilityProperty: true }) && (r => r.width > 0 && r.height > 0)(e.getBoundingClientRect()) }); });- await page.goto(url, { waitUntil: 'networkidle0' });- if (clear) { await page.evaluate(() => { try { localStorage.clear(); } catch {} }); await page.reload({ waitUntil: 'networkidle0' }); }+ await page.goto(url, { waitUntil: 'load' }).then(() => ready(page));+ if (clear) { await page.evaluate(() => { try { localStorage.clear(); } catch {} }); await page.reload({ waitUntil: 'load' }).then(() => ready(page)); } await settle(page); return page; } // Two frames, or 300ms if the tab is in the background (a hidden tab gets no frames).+// The app marks <html data-ready="1"> once the plan is loaded and painted. Since 0.66.0+// every page holds a live stream open, so "network idle" never comes; this is the wait.+async function ready(page) { try { await page.waitForSelector('html[data-ready="1"]', { timeout: 20000 }); } catch {} } async function settle(page, ms = 250) { await sleep(ms); await page.evaluate(() => new Promise(r => { setTimeout(r, 300); requestAnimationFrame(() => requestAnimationFrame(r)); })); } async function shot(page, name) { if (KEEP) await page.screenshot({ path: join(OUT, name + '.png') }); } const fsPct = page => page.$eval('#fsPct', e => e.textContent);@@ -580,7 +583,7 @@ async function main() { await page.click('#fsUp'); await page.click('#fsUp'); await settle(page); check('A+ twice reads 120%', await fsPct(page) === '120%'); const legendBefore = (await legendGeometry(page)).L.h;- await page.reload({ waitUntil: 'networkidle0' }); await settle(page);+ await page.reload({ waitUntil: 'load' }).then(() => ready(page)); await settle(page); check('120% survives a refresh', await fsPct(page) === '120%'); check('and it is applied, not just displayed', await page.evaluate(() => getComputedStyle(document.documentElement).getPropertyValue('--fs').trim()) === '1.2'); await assertSticky(page, 'at 120%');@@ -599,14 +602,14 @@ async function main() { await settle(A, 300); check('the other open tab follows the new size live', await fsPct(A) === '120%', await fsPct(A)); await A.bringToFront(); await A.select('#grainSel', 'month'); await settle(A);- await B.bringToFront(); await B.reload({ waitUntil: 'networkidle0' }); await settle(B);+ await B.bringToFront(); await B.reload({ waitUntil: 'load' }).then(() => ready(B)); await settle(B); check('a save in the other tab no longer resets the size on refresh', await fsPct(B) === '120%', await fsPct(B)); // Every other pref too (review finding): tab A collapses everything, tab B, opened // before that, toggles the theme. B used to write its whole prefs object, empty // collapse set included, and A's refresh came back with every lane open. await A.bringToFront(); await A.click('#btnCollapseAll'); await settle(A); await B.bringToFront(); await B.click('#btnTheme'); await settle(B);- await A.bringToFront(); await A.reload({ waitUntil: 'networkidle0' }); await settle(A);+ await A.bringToFront(); await A.reload({ waitUntil: 'load' }).then(() => ready(A)); await settle(A); check('another tab saving the theme no longer reopens every lane', await A.$$eval('.lane-toggle.collapsed', e => e.length) === 20, String(await A.$$eval('.lane-toggle.collapsed', e => e.length))); check('and the theme that tab chose is kept too', await A.evaluate(() => document.documentElement.getAttribute('data-theme')) === 'light'); check('and the text size is still 120%', await fsPct(A) === '120%');@@ -621,22 +624,22 @@ async function main() { page = await openPage(browser, snapUrl); // A reader who has other prefs stored from some other chart: await page.evaluate(() => localStorage.setItem('adom-gantt:prefs', JSON.stringify({ granularity: 'day', theme: 'light', fs: 1.1, collapsed: [0, 1, 2] })));- await page.reload({ waitUntil: 'networkidle0' }); await settle(page);+ await page.reload({ waitUntil: 'load' }).then(() => ready(page)); await settle(page); check('the snapshot takes the reader\'s text size (110%)', await fsPct(page) === '110%', await fsPct(page)); check('the snapshot still opens at the author\'s grain', await page.$eval('#grainSel', s => s.value) === 'week'); check('the snapshot still opens at the author\'s theme', await page.evaluate(() => document.documentElement.getAttribute('data-theme')) === 'dark'); check('the snapshot ignores a stored collapse set', await page.$$eval('.lane-toggle.collapsed', e => e.length) === 0); await page.click('#fsUp'); await settle(page);- await page.reload({ waitUntil: 'networkidle0' }); await settle(page);+ await page.reload({ waitUntil: 'load' }).then(() => ready(page)); await settle(page); check('A+ in the snapshot survives a refresh (120%)', await fsPct(page) === '120%', await fsPct(page)); const stored = await page.evaluate(() => JSON.parse(localStorage.getItem('adom-gantt:prefs'))); check('the snapshot did not overwrite the reader\'s other stored prefs', stored.granularity === 'day' && stored.theme === 'light' && stored.fs === 1.2, JSON.stringify(stored)); // A stored value that parses to an ARRAY used to swallow the snapshot's write. await page.evaluate(() => localStorage.setItem('adom-gantt:prefs', '[1,2,3]')); // Open without the fragment the A+ above wrote, since this case is about storage.- await page.goto(snapUrl, { waitUntil: 'networkidle0' }); await page.reload({ waitUntil: 'networkidle0' }); await settle(page);+ await page.goto(snapUrl, { waitUntil: 'load' }).then(() => ready(page)); await page.reload({ waitUntil: 'load' }).then(() => ready(page)); await settle(page); await page.click('#fsUp'); await settle(page);- await page.reload({ waitUntil: 'networkidle0' }); await settle(page);+ await page.reload({ waitUntil: 'load' }).then(() => ready(page)); await settle(page); check('a stored array does not swallow the size (110% after A+ from 100%)', await fsPct(page) === '110%', await fsPct(page)); const snapR = await readouts(page); check('the snapshot shows the same readouts', snapR.dur === '16w 4d' && snapR.left === '15w 3d' && snapR.start === '21-Sep-2026', JSON.stringify(snapR));@@ -661,16 +664,16 @@ async function main() { await page.click('#fsUp'); await settle(page); await page.click('#fsUp'); await settle(page); check('A+ twice reads 120% and the URL fragment carries it', await fsPct(page) === '120%' && await page.evaluate(() => location.hash) === '#fs=1.2', await page.evaluate(() => location.hash)); const storedInSandbox = await page.evaluate(() => { try { return localStorage.getItem('adom-gantt:prefs'); } catch (e) { return 'THROWS ' + e.name; } });- await page.reload({ waitUntil: 'networkidle0' }); await settle(page);+ await page.reload({ waitUntil: 'load' }).then(() => ready(page)); await settle(page); const storedAfter = await page.evaluate(() => { try { return localStorage.getItem('adom-gantt:prefs'); } catch (e) { return 'THROWS ' + e.name; } }); check('the sandbox really does deny or forget storage across a reload (the test is honest)', storedAfter === null || /THROWS/.test(storedAfter), `${storedInSandbox} -> ${storedAfter}`); check('and the text size still survives the refresh (120%)', await fsPct(page) === '120%', await fsPct(page)); await page.click('#fsPct'); await settle(page); check('reset to 100% clears the fragment', await page.evaluate(() => location.hash) === '#fs=1', await page.evaluate(() => location.hash)); // A hash-only goto is a same-document navigation, so reload to load the page afresh.- await page.goto(`${sbUrl}#fs=1.35`); await page.reload({ waitUntil: 'networkidle0' }); await settle(page);+ await page.goto(`${sbUrl}#fs=1.35`); await page.reload({ waitUntil: 'load' }).then(() => ready(page)); await settle(page); check('a shared link with #fs=1.35 opens at 135%', await fsPct(page) === '135%', await fsPct(page));- await page.goto(`${sbUrl}#fs=bogus`); await page.reload({ waitUntil: 'networkidle0' }); await settle(page);+ await page.goto(`${sbUrl}#fs=bogus`); await page.reload({ waitUntil: 'load' }).then(() => ready(page)); await settle(page); check('a bad fragment value falls back to 100%', await fsPct(page) === '100%', await fsPct(page)); await page.close(); } finally { await new Promise(r => sandboxSrv.close(r)); }@@ -694,7 +697,7 @@ async function main() { check('one press collapses every lane', c.shut === c.lanes && c.lanes === 20, JSON.stringify(c)); check('and hides every task and milestone row', c.visibleRows === 0, String(c.visibleRows)); check('the button now offers "Expand all"', c.label === 'Expand all' && c.pressed === 'true');- await page.reload({ waitUntil: 'networkidle0' }); await settle(page);+ await page.reload({ waitUntil: 'load' }).then(() => ready(page)); await settle(page); c = await collapseState(); check('collapse-all persists across a refresh', c.shut === 20 && c.label === 'Expand all', JSON.stringify(c)); await view(page, 'table');@@ -835,6 +838,7 @@ async function main() { if (wantAny('tags')) await tagsFlow(browser); if (wantAny('row tags')) await rowTagsFlow(browser); if (wantAny('effort')) await effortFlow(browser);+ if (wantAny('collab')) await collabFlow(browser); if (wantAny('viewport')) { // ── Viewport matrix ──@@ -1761,7 +1765,7 @@ async function menuFlows(browser, S) { const jsonFile = join(TMP, 'import.json'); writeFileSync(jsonFile, got.json); await ip.click('#btnImport'); await settle(ip, 100); let [ch] = await Promise.all([ip.waitForFileChooser({ timeout: 4000 }), ip.click('#importMenu .dd-item[data-fmt="json"]')]);- await Promise.all([ip.waitForNavigation({ waitUntil: 'networkidle0', timeout: 10000 }).catch(() => null), ch.accept([jsonFile])]);+ await Promise.all([ip.waitForNavigation({ waitUntil: 'load', timeout: 10000 }).then(() => ready(ip)).catch(() => null), ch.accept([jsonFile])]); await settle(ip, 300); let ipl = await exportPlan(I.url); check('Import JSON (the export of the main plan) replaces the plan with it', spanOf(ipl).n === n && !!rowOf(ipl, r => r.name === 'Launch finale') && !rowOf(ipl, r => r.name === 'Before import'), `${spanOf(ipl).n} rows`);@@ -1770,7 +1774,7 @@ async function menuFlows(browser, S) { const csvFile = join(TMP, 'import.csv'); writeFileSync(csvFile, got.csv); await ip.click('#btnImport'); await settle(ip, 100); [ch] = await Promise.all([ip.waitForFileChooser({ timeout: 4000 }), ip.click('#importMenu .dd-item[data-fmt="csv"]')]);- await Promise.all([ip.waitForNavigation({ waitUntil: 'networkidle0', timeout: 10000 }).catch(() => null), ch.accept([csvFile])]);+ await Promise.all([ip.waitForNavigation({ waitUntil: 'load', timeout: 10000 }).then(() => ready(ip)).catch(() => null), ch.accept([csvFile])]); await settle(ip, 300); ipl = await exportPlan(I.url); check('Import CSV (the main plan\'s CSV export) brings in every row', spanOf(ipl).n === n && !!rowOf(ipl, r => r.name === 'Launch finale'), `${spanOf(ipl).n} rows`);@@ -1870,7 +1874,7 @@ async function sectionsFlow(browser) { await redo(); await expectState('redo adds it again', N); // V-B: a section made here has no dates of its own, and a reload must not read that as 1970.- await page.reload({ waitUntil: 'networkidle0' }); await settle(page);+ await page.reload({ waitUntil: 'load' }).then(() => ready(page)); await settle(page); await view(page, 'gantt'); let ax = await axisText(page); check('after adding sections and a reload the chart axis is the plan\'s own years, not 1970', ax.includes('2026') && !/19(69|70)/.test(ax), ax.slice(0, 120));@@ -1914,7 +1918,7 @@ async function sectionsFlow(browser) { // checked them against that list, kept the old index and put them in another section. await dragTo('Delta', 5); await expectState('Delta dragged down past the two new sections', N = ['Alpha', 'Beta', 'Gamma', 'Echo', 'Foxtrot', 'Delta']);- await page.reload({ waitUntil: 'networkidle0' }); await settle(page);+ await page.reload({ waitUntil: 'load' }).then(() => ready(page)); await settle(page); await expectState('after a reload every row is still in its own section', N); const snapHtml0 = await (await fetch(X.url + 'export/html')).text(); @@ -2040,7 +2044,7 @@ async function sectionsFlow(browser) { await page.keyboard.down('Control'); await page.keyboard.press('KeyV'); await page.keyboard.up('Control'); await settle(page, 300); check('Alt+Down and Ctrl+V on Setup move and paste nothing', JSON.stringify((await savedPlan(X.url)).items.map(r => r.name + '@' + r.phase)) === before); - await page.reload({ waitUntil: 'networkidle0' }); await settle(page);+ await page.reload({ waitUntil: 'load' }).then(() => ready(page)); await settle(page); check('a refresh on Setup comes back to Setup', await page.evaluate(() => document.documentElement.getAttribute('data-view')) === 'setup' && JSON.stringify(await listNames()) === JSON.stringify(N)); await page.close(); @@ -2210,7 +2214,7 @@ async function tagsFlow(browser) { } // ── Persistence ──- await page.reload({ waitUntil: 'networkidle0' }); await settle(page);+ await page.reload({ waitUntil: 'load' }).then(() => ready(page)); await settle(page); await expectTags('after a reload', W); const savedEvents = (await log()).length; @@ -2277,7 +2281,7 @@ async function tagsFlow(browser) { const flt = async () => page.evaluate(() => JSON.parse(localStorage.getItem('adom-gantt:prefs') || '{}').tagFilter); check('the filter is a view pref, not the plan', JSON.stringify(await flt()) === '["Engineering","Launch"]' && (await log()).length === savedEvents && JSON.stringify(tagsOf(await exportPlan(X.url))) === JSON.stringify(W), JSON.stringify(await flt()));- await page.reload({ waitUntil: 'networkidle0' }); await settle(page);+ await page.reload({ waitUntil: 'load' }).then(() => ready(page)); await settle(page); v = await vis(); check('the filter survives a reload', JSON.stringify(v.tagChips.filter(t => t.endsWith('*'))) === '["launch*","engineering*"]' && v.rows === 8 && await tableCount() === 9, JSON.stringify(v)); await shot(page, 'tags-1440-filtered');@@ -2414,7 +2418,7 @@ async function tagsFlow(browser) { check('and reads them as sentences', ['created the tag "someday" (setup)', 'coloured the tag "Engineering" #8c6bf7 (setup)', 'cleared the colour on the tag "Ops" (was #ee88bb) (setup)', 'renamed the tag "someday" to "later" (setup)', 'removed the tag "Ops" from the plan\'s tag list (setup)'].every(t => mdt.includes(t)), mdt.split('\n').filter(l => /the tag/.test(l)).slice(-6).join(' | ')); // Persistence.- await page.reload({ waitUntil: 'networkidle0' }); await settle(page);+ await page.reload({ waitUntil: 'load' }).then(() => ready(page)); await settle(page); await view(page, 'setup'); check('after a reload the registry and its colours are as saved', JSON.stringify(await page.$$eval('#suTags .tag-row', r => r.map(x => x.dataset.tag + (x.querySelector('.tag-nm-t').dataset.tc || ''))).then(a => a.filter(x => /^(engineering|ops|later)/.test(x)))) === JSON.stringify(['engineering#8c6bf7', 'ops#ee88bb', 'later']));@@ -2462,7 +2466,7 @@ async function tagsFlow(browser) { await view(page, 'gantt'); await settle(page, 200); const used = new Set(await legendOrder()); check('the legend chips follow the new order', JSON.stringify(await legendOrder()) === JSON.stringify(o1.filter(k => used.has(k))), JSON.stringify(await legendOrder()));- await page.reload({ waitUntil: 'networkidle0' }); await settle(page, 300);+ await page.reload({ waitUntil: 'load' }).then(() => ready(page)); await settle(page, 300); await view(page, 'setup'); await settle(page, 200); check('the order survives a reload', JSON.stringify(await tagOrder()) === JSON.stringify(o1), JSON.stringify(await tagOrder())); // Alt+Down on the focused first row moves it one place; one Ctrl+Z takes that back.@@ -2616,7 +2620,7 @@ async function rowTagsFlow(browser) { await page.click('#imTags .tb-x[data-tag="long-lead"]'); await page.click('#imApply'); await settle(page, 250); W['Ops one'] = []; await expectOwn('clearing a data-file tag in the editor', W);- await page.reload({ waitUntil: 'networkidle0' }); await settle(page);+ await page.reload({ waitUntil: 'load' }).then(() => ready(page)); await settle(page); check('the cleared data-file tag stays cleared after a reload', await lbl('Ops one') === '' && JSON.stringify(own(await exportPlan(X.url))['Ops one']) === '[]'); await undo(page).catch(() => {}); // nothing to undo after a reload; harmless await openItemEditor(page, 'Ops one');@@ -2814,7 +2818,7 @@ async function rowTagsFlow(browser) { check('table: the same 2', JSON.stringify(await tableNames()) === JSON.stringify(['Fab one', 'Misc one'])); await shot(page, 'row-tags-1440-filtered'); // Persistence: the filter and the tags survive a reload.- await page.reload({ waitUntil: 'networkidle0' }); await settle(page);+ await page.reload({ waitUntil: 'load' }).then(() => ready(page)); await settle(page); v = await vis(); check('after a reload the row tags and the filter are both still there', JSON.stringify(v.tasks) === JSON.stringify(['Fab one', 'Misc one']) && await lbl('Fab one') === '#hot #batchy', JSON.stringify(v)); await page.click('#tagClear'); await settle(page, 200);@@ -2861,7 +2865,7 @@ async function rowTagsFlow(browser) { const csvFile = join(TMP, 'row-tags.csv'); writeFileSync(csvFile, csv); await ip.click('#btnImport'); await settle(ip, 100); const [ch] = await Promise.all([ip.waitForFileChooser({ timeout: 4000 }), ip.click('#importMenu .dd-item[data-fmt="csv"]')]);- await Promise.all([ip.waitForNavigation({ waitUntil: 'networkidle0', timeout: 10000 }).catch(() => null), ch.accept([csvFile])]);+ await Promise.all([ip.waitForNavigation({ waitUntil: 'load', timeout: 10000 }).then(() => ready(ip)).catch(() => null), ch.accept([csvFile])]); await settle(ip, 300); const imp = own(await exportPlan(I.url)); check('importing that CSV reads the tags back onto the rows', JSON.stringify(imp['Ops two']) === '["urgent","long-lead","late"]' && JSON.stringify(imp['Fab one']) === '["hot","batchy"]' && JSON.stringify(imp['Fab two']) === '[]', JSON.stringify(imp));@@ -2988,7 +2992,7 @@ async function effortFlow(browser) { const hd = csv.split('\n')[0].split(','), ei = hd.indexOf('effort'); const csvRow = csv.split('\n').find(l => l.includes(',s1,')); check('the CSV has an effort column', ei > 0 && csvRow && csvRow.split(',')[ei] === '8', hd.join(',') + ' :: ' + csvRow);- await page.reload({ waitUntil: 'networkidle0' }); await settle(page, 300);+ await page.reload({ waitUntil: 'load' }).then(() => ready(page)); await settle(page, 300); await view(page, 'table'); check('a reload keeps every estimate, and the cleared one stays cleared', await cellText('b2') === '16h' && await cellText('b1') === 'not est.' && await cellText('s1') === '8h'); await shot(page, 'effort-table');@@ -3016,6 +3020,98 @@ async function effortFlow(browser) { await page.close(); } +// ── Live tabs (0.66.0): two people in one plan, no clobbering, presence ──+// Two tabs in one browser context. Through 0.65 the second tab's first save after the+// first tab's was refused (409) and the red banner offered discard or overwrite. Now a+// tab hears every landed save and merges it under its own unsaved rows, so both edits+// land, both tabs show both, and a refresh has nothing to clobber.+async function collabFlow(browser) {+ section = 'collab';+ const T = (id, name, ph, x = {}) => ({ type: 'task', id, name, start: '2026-10-05', end: '2026-10-09', phase: ph, owner: 'Drew', ...x });+ const X = await startServer({ project: { title: 'Collab Test', appName: 'collab-test', view: { mode: 'gantt', grain: 'week' } },+ phases: [{ name: 'Build', start: '2026-10', end: '2026-11' }, { name: 'Ship', start: '2026-10', end: '2026-11' }],+ items: [T('b1', 'Build one', 0), T('b2', 'Build two', 0), T('b3', 'Build three', 0), T('s1', 'Ship one', 1), T('s2', 'Ship two', 1),+ { type: 'milestone', name: 'Ship gate', date: '2026-11-02', phase: 1 }] }, 'collab');+ const ctx = await browser.createBrowserContext();+ const A = await openPage(browser, X.url, { ctx });+ const B = await openPage(browser, X.url, { ctx, clear: false });+ const row = async id => ((await savedPlan(X.url)).items || []).find(r => r.id === id) || {};+ const ownerShown = (pg, id) => pg.evaluate(id => { const r = [...document.querySelectorAll('.label-row:not(.group)')].find(e => e.dataset.id === id); const p = r && r.querySelector('.owner-pill'); return p ? p.textContent : null; }, id);+ const endShown = (pg, id) => pg.evaluate(id => { const r = [...document.querySelectorAll('.label-row:not(.group)')].find(e => e.dataset.id === id); return r ? r.querySelector('.name-text').textContent : null; }, id);+ const until = async (fn, ms = 4000) => { const t0 = Date.now(); while (Date.now() - t0 < ms) { if (await fn()) return true; await sleep(120); } return fn(); };+ const banner = pg => pg.$eval('#conflictBar', e => e.classList.contains('show'));+ // A live merge can repaint the other tab's table between finding a cell and clicking it.+ const clickFresh = async (pg, sel) => { for (let i = 0; i < 4; i++) { try { await pg.click(sel); return; } catch (e) { if (!/detached|not clickable|No element/.test(String(e))) throw e; await sleep(150); } } await pg.click(sel); };+ const presence = pg => pg.evaluate(() => { const e = document.getElementById('presence'); return e ? e.querySelector('.presence-n').textContent : ''; });++ // Presence: both tabs see two people here.+ check('both tabs show "2 here" in the legend', await until(async () => (await presence(A)) === '2 here' && (await presence(B)) === '2 here'), `${await presence(A)} / ${await presence(B)}`);+ check('the chips carry initials with the name on hover', await A.$eval('#presence .presence-chip', e => e.textContent.length >= 1 && !!e.title));++ // 1. A edits one row; B sees it without reloading.+ await A.bringToFront(); await view(A, 'table');+ await clickFresh(A, '#tableView tr[data-key="t:b1"] td[data-field="owner"]'); await settle(A, 100);+ await A.keyboard.down('Control'); await A.keyboard.press('KeyA'); await A.keyboard.up('Control'); await A.keyboard.type('Ana'); await A.keyboard.press('Enter'); await settle(A, 300);+ check('A\'s edit is saved', (await row('b1')).owner === 'Ana', JSON.stringify(await row('b1')));+ check('B shows A\'s edit live, with no reload', await until(async () => (await ownerShown(B, 'b1')) === 'Ana'), await ownerShown(B, 'b1'));++ // 2. B edits another row; A sees it.+ await B.bringToFront(); await clickBar(B, 's1'); await setVal(B, '#imEnd', '2026-10-16'); await B.click('#imApply'); await settle(B, 300);+ check('B\'s edit is saved and B\'s save was not refused', (await row('s1')).end === '2026-10-16' && !(await banner(B)), JSON.stringify(await row('s1')));+ check('A\'s saved baseline followed (its next save will not be refused)', await until(async () => (await A.evaluate(() => window.__collab.rev())) === (await B.evaluate(() => window.__collab.rev()))));+ await A.bringToFront(); await view(A, 'gantt');+ check('A shows B\'s new end live (the bar\'s dates read 16-Oct)', await until(async () => /16-Oct-2026/.test(await A.$eval('.bar[data-id="s1"]', e => e.dataset.dates || ''))), await A.$eval('.bar[data-id="s1"]', e => e.dataset.dates || ''));++ // 3. Both edit at once, different rows: both land, nobody is refused, both tabs agree.+ await view(A, 'table'); await B.bringToFront(); await view(B, 'table'); await A.bringToFront();+ await clickFresh(A, '#tableView tr[data-key="t:b2"] 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('Kai'); await A.keyboard.press('Enter');+ await B.bringToFront(); await clickFresh(B, '#tableView tr[data-key="t:s2"] td[data-field="owner"]'); await settle(B, 80);+ await B.keyboard.down('Control'); await B.keyboard.press('KeyA'); await B.keyboard.up('Control'); await B.keyboard.type('Noah'); await B.keyboard.press('Enter');+ await settle(A, 1500); await settle(B, 300);+ const both = async () => { const p = await savedPlan(X.url, { minWait: 200 }); const g = id => (p.items.find(r => r.id === id) || {}).owner; return g('b2') === 'Kai' && g('s2') === 'Noah'; };+ check('two edits in the same second, different rows: both are saved', await until(both, 6000), JSON.stringify(((await savedPlan(X.url, { minWait: 100 })).items || []).map(r => [r.id, r.owner])));+ check('neither tab was told its changes are not saving', !(await banner(A)) && !(await banner(B)));+ const cellOwner = (pg, id) => pg.$eval(`#tableView tr[data-key="t:${id}"] td[data-field="owner"]`, e => e.textContent);+ check('both tabs show both edits', await until(async () => (await cellOwner(A, 's2')) === 'Noah' && (await cellOwner(B, 'b2')) === 'Kai'), `${await cellOwner(A, 's2')} / ${await cellOwner(B, 'b2')}`);++ // 4. A refresh has nothing to clobber.+ 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.+ 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);+ await B.keyboard.down('Control'); await B.keyboard.press('KeyA'); await B.keyboard.up('Control'); await B.keyboard.type('Ray'); await B.keyboard.press('Enter'); await settle(B, 1300);+ check('B\'s edit landed while A was not listening', (await row('b3')).owner === 'Ray');+ await A.bringToFront();+ 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('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());+ ALLOWED_FAILURES.splice(ALLOWED_FAILURES.indexOf(allow409), 1);++ // 6. A row created in one tab appears in the other; a row deleted in one disappears in the other.+ await B.bringToFront(); await B.click('#btnNew'); await settle(B, 200); await B.type('#ncName', 'Made in B'); await B.click('#ncCreate'); await settle(B, 900);+ check('a row made in B appears in A live', await until(async () => (await A.$$eval('#tableView tr[data-key] td.c-name', es => es.map(e => e.textContent))).some(t => t.includes('Made in B'))));+ await A.bringToFront(); await A.hover('#tableView tr[data-key="t:b2"]'); await settle(A, 150);+ await clickFresh(A, '#tableView tr[data-key="t:b2"] .row-del'); await settle(A, 900);+ check('a row deleted in A disappears from B live', await until(async () => !(await B.$('#tableView tr[data-key="t:b2"]'))));+ await undo(A); await settle(A, 900);+ check('and comes back in B when A undoes', await until(async () => !!(await B.$('#tableView tr[data-key="t:b2"]'))));++ // 7. One tab closes: presence drops to one and the strip goes away.+ await B.close(); + check('when B closes, A is alone and the strip goes', await until(async () => (await presence(A)) === '', 6000), await presence(A));+ await A.close(); await ctx.close();+}+ function cleanupSync() { if (cleanedUp) return; cleanedUp = true; for (const s of servers) { try { s.proc.kill('SIGKILL'); } catch {} }