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.77.1
13 files changed
+430−74
README.md+5−1@@ -14,7 +14,7 @@ A plain on/off toggle. Each lane is answered on its OWN subgraph, so a dependenc **Setup.** A fourth lens, since 0.58.0, for the plan's structure rather than its work. It is where the sections are managed: add one, rename it and set its aside, recolour it, move it, or remove it, each row showing how many rows the section holds and the dates they span. Since 0.61.0 a section moves by dragging its row (or Alt+Up / Alt+Down on a focused row), and its name and aside are edited by clicking them. Since 0.59.0 it is also where sections are tagged, one by one or from their name prefixes in one click, and where a tag is renamed or removed everywhere (since 0.60.0, on rows as well as sections). Since 0.61.0 it is where a tag is made before anything carries it (New tag) and where a tag gets a colour. It replaces the Sections button that used to sit in the header and open a modal. Every change is still one Ctrl+Z and one changelog line. -**Four lenses over one plan.** A **Gantt / Table / Analytics / Setup** toggle in the header. The chart answers "when". The table answers "whose, and by when": every task and every milestone as a row with Owner, Start, Deadline, Status and Notes, sortable on any column, filtered to one person with the owner dropdown, and editable in place. An edit made in the table is already true on the chart when you toggle back, because there is only one set of items underneath. The choice of lens persists, and `project.view.mode` can pin it for a plan that is an assignment list more than a timeline.+**Four lenses over one plan.** A **Gantt / Table / Analytics / Setup** toggle in the header. The chart answers "when". The table answers "whose, and by when": every task and every milestone as a row with Owner, Start, Deadline, Status and Notes, sortable on any column, filtered to one person with the Owner filter (the same control as the chart's, in the same place in the legend line), and editable in place. An edit made in the table is already true on the chart when you toggle back, because there is only one set of items underneath. The choice of lens persists, and `project.view.mode` can pin it for a plan that is an assignment list more than a timeline. **Add rows.** The New button creates a task or a milestone: name, lane, owner, dates, status, notes and provenance. Created rows live in the sidecar, so the data file is never rewritten, and they appear in every export. @@ -382,6 +382,10 @@ them in the order they arrive and tells every open tab, so: 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.+- **A tab that falls out of step puts itself right (0.77.1).** If a tab ever disagrees with+ the server about which section a row is in, it notices on the next change anybody makes,+ fetches the whole plan and corrects itself. Before 0.77.1 it could not, and on 2026-11-03+ two such tabs and a third kept changing the row order back and forth for an hour. - **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
SKILL.md+13−5@@ -242,7 +242,7 @@ rather than a category. - **Click legend** to filter phases, milestones, dependency arrows, or the today line. The legend WRAPS onto as many lines as it needs (0.57.0), so every phase chip on a 20-section plan is visible and clickable; until 0.56.0 it was one line and clipped everything past the pane edge. Below 1150px the chips drop their words and keep the colour dot, as before - **Tag chips in the legend** (0.59.0; rows since 0.60.0). When the plan carries tags, on sections or on rows, the legend leads with a chip per tag (`# Tech 32`: the tag and how many rows choosing it would show, counting the rows in its sections and the rows tagged on their own) and the section chips stand BEHIND them: with no tag chosen they fold away behind one "20 sections" chip (click it to open them all; that is remembered as `prefs.legendSecs`), and with a tag chosen the chips of exactly the sections on screen come back after the tags. A section chip whose name prefix is a chosen tag drops the repeat ("Laser workcells" behind "# Tech"). Choosing tags filters by EFFECTIVE tags: a row's effective tags are its own plus its section's, and a row is shown when ANY of them is chosen. So every row of a chosen section is shown, tagged or not; a row tagged "long-lead" in an untagged section is shown by "long-lead", with its section header above it and the section's other rows hidden; and a row whose own tags do not match, in a section that does not match, is hidden. A section header is shown when the section matches or any of its rows does, and while the filter hides some of its rows its count reads "2 of 3". A delete or a tag edit that takes the last carrier of a chosen tag out of the plan drops it from the filter, and its Ctrl+Z puts the filter back with the tag. This holds in the chart and the table alike; the readouts, Analytics and everything else stay whole-plan. It composes with the section chips (which still switch a shown section off) and with Collapse all (which then counts only the shown sections). **Clear tags** shows everything again. The filter is view state, per person, in `prefs.tagFilter`, so it survives a refresh; a tag the plan no longer has drops out of it. A plan with no tags has no tag chips and no fold, and its legend is what it always was - **Current in the legend** (0.70.0), between Owner and Overdue: the working set for a review. A row is **current** when it has STARTED (a task's start, a milestone's date: today or earlier) and is not complete or cancelled, so it is the work under way plus the work that should be (a task still "not started" after its start date shows). Future rows and finished ones are hidden. Counts tasks and milestones apart like Overdue (`data-tasks` / `data-ms`); `prefs.currentOnly`; derived on every render and at midnight, never stored. It ANDs with the other filters, so Current plus Overdue shows the overdue rows (every overdue row is current), and it applies to the table too. A month-only date counts from the first of its month; since 0.70.0 a month-only DEADLINE is due at the end of its month for Overdue (before, such a row could never be overdue).-- **Owner and Overdue in the legend** (0.64.0), before the tag chips. **Owner** is a select listing every owner on the plan with their row count, then **Unassigned (n)** for the rows nobody owns, because on a young plan that is most of it and it has to be reachable. It is ONE choice shared with the table's owner select (`prefs.ownerFilter`, which replaces the table-only `prefs.tableOwner` and carries an old pick over), so the person picked on the chart is the person the table shows; the chart's copy hides in the table view, where the table's own sits in its toolbar. **Overdue** is a chip with a red dot and a count: a row is overdue when its deadline (a task's end, a milestone's date) is before today and its status does not settle it, complete or cancelled, read through `statusOf` so "completed", "done" and "canceled" settle it too; a blank status is still owed. It is the same rule as the Analytics Overdue card. Every overdue row carries a red edge on its label and a red ring on its bar (a red diamond on a milestone) whether or not the filter is on; clicking the chip shows only those rows (`prefs.overdueOnly`). The chip counts **tasks and milestones apart** (`Overdue 3 | ◆ 2`, also as `data-tasks` / `data-ms`), because milestones usually carry no status and would otherwise pile up as their dates pass and drown the late tasks; a milestone with no status counts as not reached until it is marked complete. **Overdue is derived, never stored:** it is worked out from the dates and statuses on every render, re-read after every edit, at midnight on a tab left open and when the tab comes back into view, and nothing is ever written to the plan, so no `Overdue` tag appears in the data, an export or the changelog, and computing it never makes another open tab stale. Both filters AND with the tags and narrow ROWS, never sections: under either, a section header shows only over rows it still has, its count reads "2 of 5", and Collapse all acts on the sections on screen. Readouts, Analytics and the exports stay whole-plan. An owner who no longer has a row drops out of the filter the way a vanished tag does+- **Owner and Overdue in the legend** (0.64.0), before the tag chips. **Owner** is a select listing every owner on the plan with their row count, then **Unassigned (n)** for the rows nobody owns, because on a young plan that is most of it and it has to be reachable. It is ONE choice for both lenses (`prefs.ownerFilter`, which replaces the table-only `prefs.tableOwner` and carries an old pick over), so the person picked on the chart is the person the table shows. Since 0.77.1 it is also one CONTROL, in the same place in the legend line in the chart and the table; the Table's own owner select at the far end of the line is gone, because the chart's seemed to vanish on the way into the Table. The legend keeps Owner, Current and Overdue in place when it rebuilds (0.77.1), so somebody else's change no longer closes the owner list in your hand. **Overdue** is a chip with a red dot and a count: a row is overdue when its deadline (a task's end, a milestone's date) is before today and its status does not settle it, complete or cancelled, read through `statusOf` so "completed", "done" and "canceled" settle it too; a blank status is still owed. It is the same rule as the Analytics Overdue card. Every overdue row carries a red edge on its label and a red ring on its bar (a red diamond on a milestone) whether or not the filter is on; clicking the chip shows only those rows (`prefs.overdueOnly`). The chip counts **tasks and milestones apart** (`Overdue 3 | ◆ 2`, also as `data-tasks` / `data-ms`), because milestones usually carry no status and would otherwise pile up as their dates pass and drown the late tasks; a milestone with no status counts as not reached until it is marked complete. **Overdue is derived, never stored:** it is worked out from the dates and statuses on every render, re-read after every edit, at midnight on a tab left open and when the tab comes back into view, and nothing is ever written to the plan, so no `Overdue` tag appears in the data, an export or the changelog, and computing it never makes another open tab stale. Both filters AND with the tags and narrow ROWS, never sections: under either, a section header shows only over rows it still has, its count reads "2 of 5", and Collapse all acts on the sections on screen. Readouts, Analytics and the exports stay whole-plan. An owner who no longer has a row drops out of the filter the way a vanished tag does - **Collapse all / Expand all** leads the legend (0.57.0), in the chart and the table. One press collapses every section to its header; when every section with rows is already collapsed it expands them all. A mixed state collapses. It is the same view state as the per-lane chevrons (`prefs.collapsed`), so it persists across a refresh, follows the chevrons (open the last lane by hand and it reads Collapse all again), and the table agrees with the chart. With everything collapsed the table says so rather than "no items match" - **Click a bar or a milestone** to open the **item editor** (0.65.0), a floating panel beside what you clicked with every field of the row: type, section, name, owner, status, start, deadline, effort, notes, provenance, tags, the label on the bar and the dependencies. It is the ONE editor for a task or a milestone: the pencil on a row, a double-click on a name, and the Table's Deps cell (which focuses "waits on") open the same panel. A single click on a name in the label column renames it in place instead (0.66.1). Everything in it, the dependencies included, is a draft that lands on Apply (or Enter) as one undo, and Cancel or Escape leaves the plan as it was. Through 0.64 a bar click opened a small dates-and-dependencies panel whose dependency changes landed at once, and a milestone click a name-date-description panel; both are gone - **Drag bar edges** to resize (changes start or end date). Every timeline drag snaps to the grid the axis is currently drawing: day boundaries at the day cell size, week starts at weeks, month, quarter and year firsts at the coarser ones. Resizing the start leaves the end where it is and the mirror for the end; dragging the bar body snaps the START and preserves the DURATION, so the end lands at start plus the same number of days. The preview snaps live, line to line, and is exactly what gets committed. The shortest bar a resize can produce is the first grid line past the fixed edge, so it is never zero or negative. Milestones snap the same way. Dates set explicitly (table cells, the item modal, the batch editor, the project-start shift) are taken exactly as given and are never rounded@@ -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: 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.+- **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. Since 0.77.1 the server keeps its stored order grouped by section and says so (`grouped: true` on the order op), announces a batch's sections before its rows (a page that got a row moved into a section it did not have yet refused it, then sent the old section back), and makes no revision for moves that change nothing. A page compares orders grouped by section, so the server's list is never "corrected" for its grouping; a page that groups a grouped order differently from the server, or cannot apply an op, fetches the whole plan (at most every 10 s); and a catch-up rebuilds every row as the server has it, not only the overrides. - **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@@ -431,12 +431,13 @@ named in its own column. **Sorting.** Click any header. Three states: ascending, descending, then back to plan order. Default is deadline ascending. Blank values sort last in both directions, so an unassigned row never displaces what you are looking for. -**Filtering.** The owner dropdown is built from the distinct `owner` values in the-data, plus "All owners" and "Unassigned"; it answers "what is mine". There is also a+**Filtering.** The owner filter is the legend's Owner (the same control as the chart's,+since 0.77.1), built from the distinct `owner` values in the data with their row counts,+plus "All owners" and "Unassigned"; it answers "what is mine". There is also a status dropdown and a free-text filter over name, owner, phase, tags (own and section), notes and status. The legend's phase and milestone toggles apply to the table as well, so the two lenses never disagree about what is in scope. The owner filter and the sort survive a reload.-Each filter is cleared where it is set: the two dropdowns open on All, and the text+Each filter is cleared where it is set: the owner filter and the status dropdown open on All, and the text filter is cleared by emptying the box. There is no Clear button; it was one control for a job the other three were already doing, in the busiest strip in the app. **Escape in the filter box blurs it and leaves the text alone**, so the shortcuts come back without@@ -842,6 +843,13 @@ that moved under you comes back 409 instead of erasing somebody.) Then re-read **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.+Prefer `/ops` for created rows too: a whole-state `/save` that changed a created row's own+fields (its section, on 2026-11-03) was invisible to tabs older than 0.77.1, whose catch-up+read only the overrides; from 0.77.1 a catch-up compares every field of every row. The+server keeps the stored order grouped by section (0.77.1): after any batch that touches the+order or a row's section, `state.order` and the export list each section's rows together,+in section order, which is the order on screen. A move that changes nothing makes no+revision. 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.
client-vs-server/README.md+1−1@@ -36,7 +36,7 @@ tab of the same plan in the same browser shares them; another computer starts fr | What | Notes | |---|---| | Collapse all, each section's fold | The table follows the chart's folds |-| Owner, Current, Overdue and tag filters | The chart and the table share one owner filter |+| Owner, Current, Overdue and tag filters | One Owner filter for the chart and the table, in the same place (0.77.1) | | The lens: Gantt, Table, Analytics, Setup | Analytics and the owner cards on Setup are hidden for teammates | | Days, weeks or months; text size; light or dark; milestone style; Today line | | | Critical path highlight | Only the highlight. Auto-schedule, which moves dates, is a plan change |
docs/BUILD-NOTES.md+81@@ -3,6 +3,87 @@ Decisions taken while building, and the places where the plan and the code disagreed. Newest first. +## Build notes, 2026-11-03 (0.77.1, sections and the row order, after an afternoon on the team plan)++Drew: "this dropdown keeps disappearing on me, driving me absolutely nuts. Caused by+multiple users' activity?", then "why did that filter by user dropdown disappear on table+page?" and "with so many people working, be very careful to not clobber any of their+changes".++**What the team plan was doing.** From about 17:45 UTC three tabs were sending order+batches back and forth: the plan's revision went from 5343 to 5680 in two minutes with three+real edits in it. On the live stream every one of those batches was an `order` op, and two+orders alternated. One kept a new section's rows together; the other put 24 of them two+sections further down, at the top of a section they do not belong to. Each+announcement made every open tab rebuild its legend, which took the Owner filter out and+put it back, and that closed the owner list in the hand of whoever had it open.++**Causes, each reproduced on a copy of the live plan** (`incident.mjs`, `repro-loop.mjs`,+`repro-owner.mjs` in the gate folder):++1. *A catch-up did not see changes to created rows.* At 17:24 an agent moved 25 rows it had+ created into the new section with a whole-plan save that rewrote their entries in `added`.+ A tab catches up from a whole-plan save through `stateOps`, which turned only the+ overrides (`state.items`) into ops. 24 of those rows had no override, so the tabs open at+ the time kept them in their old section number, which now pointed one section further+ down. The one moved row that had an override came through. A fresh load was right.+2. *Tabs corrected the server's order.* A page lays its rows out grouped by section+ (`placeHeaders`), but the stored order is a flat list that a section change does not+ touch. A page compared its grouped order with the server's flat one and sent the+ difference as moves; two pages that grouped differently (cause 1) undid each other's+ moves for as long as both were open. On 0.77.0, 5.5 to 8 batches a second, indefinitely.+3. *A section move could be undone.* A batch that creates a section and moves rows into it+ reaches the other tabs rows first, because a page puts its section changes last and the+ server announced in arrival order. A tab refused a section number past the end of its+ list, but kept the server's value as the server's; its row then looked like an unsent+ edit of its own, and its next save sent the old section back. On 0.77.0, three rows+ moved into a new section were back in their old one after everybody's next save.+4. *The legend rebuilt under the owner list.* Every rebuild took Owner, Current and Overdue+ out and put them back, and a rebuild follows any change to the order or the tags. The+ list's scroll guard also closed it when the chart's panes scrolled under somebody else's+ change.+5. *The Table had an owner filter of its own*, at the far end of the legend line, and hid the+ chart's, so the filter seemed to vanish on the way into the Table.++**What changed.**++- Server: the stored order stays grouped by section after any batch that touches the+ order, a row's section or the section list, and the order op says so (`grouped: true`). A+ batch is announced sections first, then rows, then the order. Moves that leave the order as+ it was make no revision and no announcement. At every start the stored order is regrouped+ (when it lists every row) and the revision goes up by one, so a page that stayed open+ across the restart fetches the plan once.+- Page: orders are compared grouped by section (`groupOrder`) in `computeOps` and+ `rebaseOrder`, so grouping is never taken for a move. A batch is applied sections first,+ each op on its own: one that throws no longer takes the rest of the batch with it, and the+ page fetches the plan. A section the page cannot take leaves its copy of the server's value+ alone and fetches the plan. A page that groups a `grouped` order differently from the+ server fetches the plan (`requestResync`, at most every 10 s). A catch-up rebuilds every row+ as the server has it (the data-file row kept at load in `baseRowOf`, or the created row,+ with the overrides on top), not only the overrides.+- The legend keeps Owner, Current and Overdue in place and rewrites the owner list only when+ it changed; a scroll that cannot move a list's anchor leaves the list open; one Owner filter+ serves both lenses, in the same place.++**Tests.** collab_test `sections` (11 checks: the clobbered section move, two tabs that+missed a change with a third making one move, and the owner list staying open through field,+tag, order, delete and restore changes): 11/0 on 0.77.1; on 0.77.0 it fails 9 (the fight+check needs most of a section held two sections away, as on the day; a whole section moved+next door suits both groupings and does not fight). server_test section E, 10 checks.+ui_test uses the one Owner filter. Full runs: server 199/0, collaboration 91/0, the fuzz+with seeds 11 and 101 (six people) 5/0 each, UI 881/0 with the standing 7 controls+untested.++**After the deploy, rehearsed** (`rehearse.mjs`: 0.77.0 tabs fighting on a copy of the live+plan, then the server swapped on the same files and port). No row changed, no changelog line,+the sections the same. The 0.77.0 tab that was right stops sending at once, and a 0.77.1 tab+that joins matches the server and sends nothing. The two 0.77.0 tabs that hold rows in the+wrong section cannot put themselves right, because their catch-up is the old one: they keep+sending about 3.5 moves a second each until reloaded. Announcing each of those cost every+other open tab about 13 ms (110 ms a second on one tab, 125 on another); with no-op moves+unannounced it costs them nothing (5 and 0). So everybody reloads once after the deploy, and+the tabs that were open at 17:24 must.+ ## 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
gantt.html+106−41@@ -1681,8 +1681,6 @@ table.data-table { font-size: calc(12px * var(--fs)); min-width: calc(900px * va </div> <div class="table-view" id="tableView"> <div class="table-tools" id="tableTools">- <label for="tblOwner">owner</label>- <select id="tblOwner" title="Show only one person's work"></select> <label for="tblStatus">status</label> <!-- The five options are FILLED IN FROM STATUS_ORDER at startup (see buildStatusFilter), not written out here. They were written out here until@@ -1850,6 +1848,10 @@ const importFileEl = $('importFile'); // ── Load data ── const projData = await fetch('data').then(r => r.json()); const { project, phases, items } = projData;+// Every data-file row as the file has it, before the sidecar touches it (0.77.1): a catch-up+// rebuilds each row as the server has it from this and the server's overrides.+const baseRowOf = new Map();+items.forEach(r => { if (r && r.uid) baseRowOf.set(r.uid, JSON.parse(JSON.stringify(r))); }); // ── Tags (0.59.0) ── //@@ -4546,6 +4548,17 @@ function moveInOrder(order,uid,after){ order.splice(after===null?0:order.indexOf(after)+1,0,uid); return true; }+// The order as this page lays it out (placeHeaders): each section's rows together, sections+// in list order, rows in the given order inside a section, a row with no known section last.+// Orders are compared in this form (0.77.1), so a list that differs only in being grouped+// another way is never taken for rows somebody moved. Two pages that grouped the server's+// flat list differently each sent "moves" to fix it, and undid each other's for an hour.+function groupOrder(keys){+ const byKey=orderKeyMap(), buckets=phases.map(()=>[]), rest=[];+ keys.forEach(k=>{ const r=byKey.get(k), p=r?r.phase:undefined;+ if(Number.isInteger(p)&&p>=0&&p<buckets.length)buckets[p].push(k); else rest.push(k); });+ return [].concat(...buckets,rest);+} // 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@@ -4581,7 +4594,7 @@ function computeOps(){ 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));+ const target=groupOrder(liveOrder()), base=groupOrder(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.@@ -5218,14 +5231,14 @@ function pullAndMerge(){ pullQueued=syncQ(async()=>{ pullQueued=null; pullCount++; await pullOnce(); }); return pullQueued; }-async function pullOnce(){+async function pullOnce(force){ if(SNAPSHOT)return; const r=await fetch('state'); if(!r.ok)throw new Error('state '+r.status); const st=await r.json(); 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;+ if(rev<=(baseRev||0)&&!late&&!force)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@@ -5247,19 +5260,28 @@ function stateOps(st){ 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;+ // Dependencies on a deleted row go with it, exactly as the server's merge drops them.+ const goneIds=new Set(); gone.forEach(u=>{ const b=added.get(u)||baseRowOf.get(u)||allRows.get(u); if(b&&b.id)goneIds.add(b.id); }); live.forEach(u=>{- if(shadow.rows.has(u))return;+ if(shadow.rows.has(u)){+ // The server's WHOLE row, not only its overrides (0.77.1). A whole-plan save can change+ // a created row's own fields (its section, on 2026-11-03) and leave no override behind;+ // a page that read only the overrides never heard of it, and kept the row where it was.+ const base=added.get(u)||baseRowOf.get(u); if(!base)return;+ const row=Object.assign({},base); delete row._key;+ const sv=entries.get(u); if(sv)applyOverrideRow(row,sv,phaseN);+ const set=rowOv(row); delete set.uid;+ if(!('tags' in set))set.tags=[];+ if(Array.isArray(set.deps))set.deps=set.deps.filter(d=>!goneIds.has(d));+ ops.push({op:'row',uid:u,set});+ 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});@@ -5303,7 +5325,16 @@ function applyOps(ops,ctx){ try{ withTable(()=>withDeps(()=>{ inRowBatch(()=>{- (ops||[]).forEach(op=>{ if(op&&typeof op==='object')applyOne(op,ctx.mine||null,ctx.self?'':ctx.actor||'Someone',T); });+ // Sections first, then rows, then the order (0.77.1): a row moved into a section that+ // is new in the same batch was refused when the row came first, and the page then+ // held the row's old section as an unsent edit of its own and sent it back.+ const rank=o=>(o.op==='phases'||o.op==='reg'||o.op==='dates'||o.op==='cal')?0:o.op==='order'?2:1;+ (ops||[]).filter(op=>op&&typeof op==='object').map((op,i)=>[op,i]).sort((a,b)=>rank(a[0])-rank(b[0])||a[1]-b[1]).forEach(([op])=>{+ // One op that cannot be applied must not take the rest of the batch with it; the+ // page fetches the whole plan instead of carrying on half-applied.+ try{ applyOne(op,ctx.mine||null,ctx.self?'':ctx.actor||'Someone',T); }+ catch(e){ T.resync='op'; try{ console.warn('adom-gantt: could not apply a change ('+op.op+'), fetching the plan:',e); }catch(_){} }+ }); if(ctx.settle)settleSent(ctx.settle,T); }); repaintAfter(T,range0);@@ -5313,13 +5344,23 @@ function applyOps(ops,ctx){ }finally{ liveMerging=false; } if(T.structure||T.phases||T.reg||T.dates||T.cal)lastSnap=snapSemantic(); else if(T.rows.size)patchSnap(T.rows);+ if(T.resync)requestResync(T.resync); return T; }+// A full catch-up on purpose (0.77.1), for a page that found it disagrees with the server in a+// way no announcement will put right: a row in another section than the server has it, a+// value it could not take, an op it could not apply. What this page changed and has not sent+// is kept, as in every catch-up. At most one every ten seconds.+let resyncAt=0, resyncCount=0;+function requestResync(why){+ const now=Date.now(); if(now-resyncAt<10000)return; resyncAt=now; resyncCount++;+ syncQ(()=>pullOnce(true)).catch(()=>{});+} 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);+ if(op.op==='order')return rebaseOrder(op.order,mine,T,!!op.grouped); 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'){@@ -5419,6 +5460,10 @@ function applyRowOp(op,mine,who,T){ continue; } setOvField(r,f,cloneV(next));+ // A section this page cannot take yet: its copy of the server's value stays what the row+ // shows, so the row is never taken for an edit of this page's own (sending it back would+ // undo the move for everybody), and the page fetches the plan to catch up.+ if(f==='phase'&&r.phase!==next){ sh[f]=old; T.resync='section'; continue; } 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);@@ -5486,15 +5531,17 @@ function applyDelOp(op,mine,who,T){ // 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){+function rebaseOrder(F,mine,T,grouped){ if(!Array.isArray(F))return; const old=shadow.order; shadow.order=F.slice();- const localOld=liveOrder();+ // Grouped as this page lays rows out, both sides (0.77.1): what this tab moved itself is+ // the difference between the two, and nothing that is only a matter of grouping.+ const raw=liveOrder(), localOld=groupOrder(raw); 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 base=groupOrder(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);@@ -5504,7 +5551,14 @@ function rebaseOrder(F,mine,T){ 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;+ // A server that keeps its order grouped (0.77.1) and a page that would group it otherwise+ // disagree about which section some row is in. Nothing announced will ever change that,+ // so the page fetches the whole plan, unless the difference is this page's own unsent move.+ if(grouped&&!mine&&!inflight&&!pend.length){+ const fl=F.filter(k=>liveSet.has(k));+ if(groupOrder(fl).join('\n')!==fl.join('\n')&&!computeOps().some(o=>o.op==='row'&&o.set&&'phase' in o.set))T.resync='sections';+ }+ if(res.join('\n')===raw.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,@@ -5729,7 +5783,12 @@ window.__collab={pause:stopLive,resume:startLive,people:()=>livePeople.slice(),m 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:()=>!!pullQueued||syncBusy||!!inflight||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,+ // A page that missed a change, for the suite (0.77.1): these rows put in another section on+ // the page AND in its copy of the server's values, which is what a lost announcement leaves+ // behind. Nothing it sends or hears afterwards would show it; only a catch-up puts it right.+ diverge:(uids,ph)=>{ let n=0; uids.forEach(u=>{ const r=liveRowOf(u), sh=shadow.rows.get(u); if(!r||!sh)return; r.phase=ph; sh.phase=ph; n++; }); placeHeaders(); reflowRows(); drawDeps(); return n; },+ resyncs:()=>resyncCount}; // 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){@@ -6115,7 +6174,9 @@ function syncTagToggle(n,chosen){ // select, so it hides in the table view, where that one sits in the toolbar. const ownerPick=document.createElement('div'), ownerPickSel=document.createElement('select'), overdueChip=document.createElement('button'), currentChip=document.createElement('button'); function wireLegendOwner(){-ownerPick.className='legend-item owner-pick gantt-only';+// Both lenses (0.77.1). The Table had a filter of its own at the far end of the line, so the+// chart's one seemed to vanish on the way into the Table; it is the same choice, in one place.+ownerPick.className='legend-item owner-pick'; ownerPick.innerHTML='<span class="op-label">Owner</span>'; ownerPickSel.id='ganttOwner'; ownerPickSel.setAttribute('aria-label','Owner filter'); ownerPickSel.title="Show only one person's rows, or the ones nobody owns yet. Your view only, it does not change the plan.";@@ -6145,7 +6206,10 @@ function syncOwnerPick(){ // An owner who no longer has a row drops out of the filter, the way a vanished tag // does, so nobody is left looking at an empty chart for a reason they cannot see. if(prefs.ownerFilter&&prefs.ownerFilter!==OWNER_NONE&&!names.includes(prefs.ownerFilter)){ prefs.ownerFilter=''; savePrefs(); }- ownerPickSel.innerHTML=html; ownerPickSel.value=prefs.ownerFilter;+ // Only when the list itself changed: rewriting it under an open list or a keyboard walk+ // would move the highlight for nothing (0.77.1).+ if(ownerPickSel._html!==html){ ownerPickSel.innerHTML=html; ownerPickSel._html=html; }+ if(ownerPickSel.value!==prefs.ownerFilter)ownerPickSel.value=prefs.ownerFilter; ownerPick.classList.toggle('on',ownerFilterOn()); } // Tasks and milestones are counted apart. On a real plan most milestones carry no status@@ -6227,12 +6291,15 @@ function syncSecToggle(){ } function rebuildLegend(){ legendEl.querySelectorAll('.phase-chip,.tag-chip').forEach(el=>el.remove());- [secToggle,tagClear,tagToggle,ownerPick,currentChip,overdueChip].forEach(el=>{ if(el.isConnected)el.remove(); });- const first=collapseAllBtn.isConnected?collapseAllBtn.nextSibling:legendEl.firstChild;+ [secToggle,tagClear,tagToggle].forEach(el=>{ if(el.isConnected)el.remove(); });+ // The owner filter and the two chips stay put (0.77.1). They were taken out and put back+ // on every rebuild, and a rebuild follows any change to the plan's order, so somebody+ // else's drag closed the owner list in the hand of whoever had it open.+ let at=collapseAllBtn.isConnected?collapseAllBtn.nextSibling:legendEl.firstChild;+ [ownerPick,currentChip,overdueChip].forEach(el=>{ if(el!==at)legendEl.insertBefore(el,at); at=el.nextSibling; });+ syncOwnerPick(); syncCurrentChip(); syncOverdueChip();+ const first=at; const put=el=>legendEl.insertBefore(el,first);- syncOwnerPick(); put(ownerPick);- syncCurrentChip(); put(currentChip);- syncOverdueChip(); put(overdueChip); const tags=usedTags(), ks=tagFilterKeys(), filtering=tags.length>0&&tagFilterOn(); if(tags.length){ syncTagToggle(tags.length,tags.filter(e=>ks.has(e.key)).length); put(tagToggle); } const tOpen=tagsOpen(tags.length);@@ -12001,7 +12068,7 @@ window.addEventListener('resize',()=>{ // the table is a separate store. const tableBody=$('tableBody'), tableHead=$('tableHead'), ownerListEl=$('ownerList');-const ownerSel=$('tblOwner'), statusSel=$('tblStatus'), searchInp=$('tblSearch'), countEl=$('tblCount');+const statusSel=$('tblStatus'), searchInp=$('tblSearch'), countEl=$('tblCount'); const STATUS_OPTS=STATUS_ORDER; const UNASSIGNED='__unassigned__'; // sentinel, so a real owner can never collide with it // null means plan order: the order the rows are actually in, which is the only@@ -12123,16 +12190,9 @@ function buildStatusFilter(){ STATUS_ORDER.map(k=>`<option value="${k}">${esc(statusTextFor(k))}</option>`).join('')); } function syncOwnerControls(){- const owners=distinctOwners();- // On the first render there are no options yet, so a value set from prefs would- // not have stuck. Fall back to the saved filter rather than the empty select.- const cur=prefs.ownerFilter||'';- ownerSel.innerHTML='<option value="">All owners</option>'- +owners.map(o=>`<option value="${esc(o)}">${esc(o)}</option>`).join('')- +`<option value="${UNASSIGNED}">Unassigned</option>`;- ownerSel.value = (cur===''||cur===UNASSIGNED||owners.includes(cur)) ? cur : '';- // Typing an owner offers the names already on the plan without forcing them.- ownerListEl.innerHTML=owners.map(o=>`<option value="${esc(o)}"></option>`).join('');+ // Typing an owner offers the names already on the plan without forcing them. The filter+ // itself is the legend's (syncOwnerPick), shared by both lenses since 0.77.1.+ ownerListEl.innerHTML=distinctOwners().map(o=>`<option value="${esc(o)}"></option>`).join(''); } // The value a column sorts on. It lives out here rather than inside tableRows@@ -12886,7 +12946,13 @@ function inOwnPopup(ev){ const t=ev&&ev.target; return !!(t&&t.nodeType===1&&t.closest&&t.closest('.cell-list')); }-function popupScrollGuard(close){ return ev=>{ if(!inOwnPopup(ev))close(); }; }+// A scroll that cannot move the anchor leaves the list open (0.77.1): the chart's panes scroll+// on their own when somebody else's change lands, and that closed a list opened in the legend.+function popupScrollGuard(close,anchor){ return ev=>{+ if(inOwnPopup(ev))return;+ const t=ev&&ev.target;+ if(anchor&&t&&t.nodeType===1&&!t.contains(anchor))return;+ close(); }; } // The OTHER half of the same report. Both lists cancel mousedown, for two good reasons // (never steal focus from the offscreen select, never let the native select popup open // on top of ours), but they cancelled it everywhere on the element including its own@@ -13260,7 +13326,7 @@ function openCellList(sel,td){ sel.dispatchEvent(new Event('change',{bubbles:true})); }); cellList={el,sel};- cellListScroll=popupScrollGuard(closeCellList);+ cellListScroll=popupScrollGuard(closeCellList,td); window.addEventListener('scroll',cellListScroll,true); window.addEventListener('resize',closeCellList); syncCellList();@@ -13676,8 +13742,7 @@ if(b)b.addEventListener('click',e=>{e.stopPropagation();copySelectedIds();}); function wireTableFilters(){ // The toolbar's filters are our list too (0.72.0): typing and the arrows highlight, Enter or // a click applies, the same as every other list.-wireAppSelect(ownerSel); wireAppSelect(statusSel);-ownerSel.addEventListener('apppick',()=>setOwnerFilter(ownerSel.value));+wireAppSelect(statusSel); statusSel.addEventListener('apppick',renderTable); searchInp.addEventListener('keydown',e=>e.stopPropagation()); searchInp.addEventListener('input',()=>{clearTimeout(tblSearchTimer);tblSearchTimer=setTimeout(renderTable,120);});@@ -14219,7 +14284,7 @@ rebuildTimeline(); sizeTailroom(); drawDeps(); // "What's mine" is a standing question, so the owner filter survives a reload-// (syncOwnerControls picks prefs.tableOwner up on this first render).+// (syncOwnerPick picks the saved owner filter up on this first render). renderTable(); // The sidecar can add or drop a dependency, so what the two plan buttons are allowed // to do is only settled once it is in.
install.shpackage.json+1−1@@ -2,7 +2,7 @@ "slug": "adom-gantt", "title": "Adom Gantt", "type": "app",- "version": "0.77.0",+ "version": "0.77.1", "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.77.0",+ "version": "0.77.1", "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+64−6@@ -1482,7 +1482,7 @@ function applyOpsTo(state, data, mergedByUid, ops, mayDate) { 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;+ let orderTouched = false, phasesTouched = false, moved = false; const moves = []; // A row coming back: off the deleted list, and announced whole, as the merge reads it. const undelete = uid => {@@ -1576,7 +1576,7 @@ function applyOpsTo(state, data, mergedByUid, ops, mayDate) { } 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;+ moved = 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 : '' };@@ -1584,6 +1584,7 @@ function applyOpsTo(state, data, mergedByUid, ops, mayDate) { 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)) });+ phasesTouched = true; } else if (op.op === 'reg') { const t = normalizeTagRegistry(op.tags); if (!t) { refused.push('reg'); continue; }@@ -1599,9 +1600,16 @@ function applyOpsTo(state, data, mergedByUid, ops, mayDate) { // 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; };+ //+ // And it is kept GROUPED BY SECTION (0.77.1), the way every page lays its rows out. A row+ // moved to another section by its section field alone, or a section moved in the list,+ // used to leave the stored order as it was. Every open page then grouped it differently+ // from the server and sent moves to "fix" the server's copy; on 2026-11-03 two pages that+ // disagreed about which section 24 rows were in corrected each other three times a+ // second for an hour, and every other page redrew each time.+ if (orderTouched || moved || phasesTouched) {+ const live = liveKeyList(data, state), liveSet = new Set(live), n = sectionCount(state, data);+ const phaseOf = u => { const o = entries.get(u); if (o && Number.isInteger(o.phase) && o.phase >= 0 && o.phase < n) 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);@@ -1614,11 +1622,55 @@ function applyOpsTo(state, data, mergedByUid, ops, mayDate) { inCur.add(k); }); moves.forEach(m => moveInOrder(cur, m.uid, m.after));+ cur = groupBySection(cur, phaseOf, n);+ const changed = !Array.isArray(state.order) || cur.join('\n') !== state.order.join('\n'); state.order = cur;- out.push({ op: 'order', order: cur.slice() });+ // `grouped` tells a page it can trust the order to match its own layout: one that does+ // not, groups some row into another section than the server does, and fetches the plan.+ // Moves that leave the order as it was make no revision and are announced to nobody: a+ // 0.77.0 page that holds rows in the wrong section keeps sending them until it is+ // reloaded, and announcing each one cost every other open page about 13 ms, eight times+ // a second (measured on a copy of the team plan). Rows added, deleted or moved to another+ // section always send the order, because pages regroup on it.+ if (orderTouched || changed) out.push({ op: 'order', order: cur.slice(), grouped: true }); }+ // Sections first, then rows, then the order (0.77.1). A page applies an announcement in+ // the order it is given, and a page that had not got a section yet refused a row moved+ // into it; it then held the row's old section as an edit of its own, and its next save+ // sent the old section back, undoing the move for everybody.+ const rank = o => (o.op === 'phases' || o.op === 'reg' || o.op === 'dates' || o.op === 'cal') ? 0 : o.op === 'order' ? 2 : 1;+ out.sort((a, b) => rank(a) - rank(b)); return { out, refused }; }+function resyncOnStart() {+ try {+ const st = loadState();+ if (!st || typeof st !== 'object' || Array.isArray(st) || st.v !== 4) return;+ const merged = mergedData(), items = merged.items || [];+ const phaseOf = new Map(items.map(r => [r && r.uid, r && r.phase]));+ // Only an order that lists exactly the plan's rows: that is the one the merge applies,+ // and anything else is left for the next save that touches the order to complete.+ if (Array.isArray(st.order) && st.order.length === items.length && st.order.every(u => phaseOf.has(u))) {+ st.order = groupBySection(st.order, u => phaseOf.get(u), (merged.phases || []).length);+ }+ st.stateRev = (typeof st.stateRev === 'number' ? st.stateRev : 0) + 1;+ writeAtomic(STATE_FILE, JSON.stringify(st));+ } catch (e) { console.error('Start: the saved order was not regrouped (' + e.message + ')'); }+}+// How many sections the plan has with this sidecar applied: its own full list when it+// carries one, the data file's otherwise. A row's section is an index into that list.+function sectionCount(state, data) {+ const sph = state && state.phases;+ return Array.isArray(sph) && sph.length && sph.some(p => p && p.name !== undefined) ? sph.length : ((data && data.phases) || []).length;+}+// The pages' layout (placeHeaders in gantt.html): each section's rows together, sections in+// list order, rows in their saved order inside a section, and a row whose section is not in+// the list at the end rather than dropped.+function groupBySection(keys, phaseOf, n) {+ const buckets = Array.from({ length: n }, () => []), rest = [];+ keys.forEach(k => { const p = phaseOf(k); if (Number.isInteger(p) && p >= 0 && p < n) buckets[p].push(k); else rest.push(k); });+ return [].concat(...buckets, rest);+} function tidyStateTags(st) { if (!st || typeof st !== 'object' || Array.isArray(st)) return st; const side = o => {@@ -3587,6 +3639,12 @@ ensureUids('startup'); // Second, and only ever after the first: the upgrade rewrites the sidecar's keys // as uids, which requires every row to have one. ensureStateV4();+// Third (0.77.1): one revision at every start. The saved order is put in section order, and+// every page that stayed open across the restart sees a newer revision when its stream+// reconnects, so it fetches the whole plan once. That is how a page that missed a change is+// put right without anybody reloading it (2026-11-03: two pages held 24 rows in another+// section than the server did, and nothing else would ever have told them).+resyncOnStart(); // The digest tick: decides on its own whether anything is due, so a quiet plan costs one // small file read every 15 seconds and nothing else.
test/collab_test.mjs+98−9@@ -14,7 +14,7 @@ // 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 docs fuzz)+// idle soak conflict reconnect docs fuzz sections) // 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@@ -93,7 +93,7 @@ const fmtMs = v => Math.round(v) + ' ms'; // created in the app (the sidecar's `added`, which startup inserts one by one), 5 deleted, // ~190 dependencies, owners, statuses, notes and tags. Seeded, so every run is the same plan. function rng(seed) { let s = seed >>> 0; return () => { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; }; }-const PEOPLE = ['Drew Owens', 'Kaley Mer', 'Noah Ash', 'Colby Knox', 'Adonis Ray', 'Caleb Stone', 'Ana Lopez', 'Bo Chen', 'Cy Diaz', 'Di Evans', 'Ed Fox', 'Flo Gray'];+const PEOPLE = ['Drew Owens', 'Kale Mer', 'Noah Ash', 'Colby Knox', 'Adonis Ray', 'Caleb Stone', 'Ana Lopez', 'Bo Chen', 'Cy Diaz', 'Di Evans', 'Ed Fox', 'Flo Gray']; const STATUSES = ['not-started', 'not-started', 'not-started', 'in-progress', 'in-progress', 'complete', 'blocked', 'cancelled']; const TAGS = ['Launch', 'Fab', 'Order', 'Assembly', 'Post-process', 'Ops']; function makePlan() {@@ -709,12 +709,12 @@ async function docsScenario(carbon) { // 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 setF(A, r1.uid, 'owner', 'Kale 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]));+ check('two people change two different fields of one row at the same moment: both are kept', g.owner === 'Kale 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]));+ check('and both tabs show both', ta.owner === 'Kale Mer' && tb.owner === 'Kale 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];@@ -730,8 +730,8 @@ async function docsScenario(carbon) { 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));+ 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 === 'Kale Mer' && n.was === 'Bo Chen' && n.to === 'Cy Diaz'), JSON.stringify(notes.slice(-3)));+ check('and sees it said on screen', /Kale 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);@@ -762,7 +762,7 @@ async function docsScenario(carbon) { 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));+ check('and the toast names whose value it kept', kept6.some(k => k.uid === r6.uid && k.f === 'owner' && k.who === 'Kale Mer') && /kept Kale 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;@@ -797,7 +797,7 @@ async function docsScenario(carbon) { 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 setF(Bp, made.uid, 'owner', 'Kale 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()))); }@@ -824,6 +824,94 @@ async function docsScenario(carbon) { // 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.+// ═════════════════════════════════════════════════════════════════════════════+// Sections (0.77.1), the team plan's incident of 2026-11-03, all three parts of it. A batch+// that created a section and moved rows into it reached open tabs rows first; they refused+// the move and their next saves sent the old section back. Two tabs that had missed a+// change to created rows held them in another section than the server, and corrected the+// server's order against a third tab three times a second, for an hour. And each of those+// corrections rebuilt the legend, which closed the owner list in the hand that had it open.+// ═════════════════════════════════════════════════════════════════════════════+async function sectionsScenario(carbon) {+ scenario = 'sections';+ const S = await startServer(carbon);+ const people = [];+ for (let i = 0; i < 3; i++) people.push(await openPerson(S, carbon, i));+ for (const P of people) await goReady(P, S);+ await sleep(1500);+ const [A, Bp, Cp] = people;+ const can = await A.page.evaluate(() => !!(window.__collab && window.__collab.set && window.__collab.diverge));+ if (!can) { skip('sections', 'this build cannot make a tab miss a change (no __collab.diverge)'); for (const P of people) await closePerson(P); await stopServer(S); return; }+ const H = { Authorization: 'Bearer ' + S.token, 'Content-Type': 'application/json' };+ const rows = await A.page.evaluate(() => window.__collab.rows());+ const inSec = p => rows.filter(r => r.phase === p && r.start);+ const secs = [...new Set(rows.map(r => r.phase))].filter(p => inSec(p).length >= 4).sort((a, b) => a - b);+ const srvPhase = async () => new Map((await planRows(S, A.sid)).map(r => [r.uid, r.phase]));+ const posts = P => { P.ops = []; P.page.on('request', r => { if (r.method() === 'POST' && r.url().split('?')[0].endsWith('/ops')) P.ops.push(Date.now()); }); };+ people.forEach(posts);++ // 1. One batch creates a section at the end and moves rows into it, rows first.+ const st = await (await fetch(S.base + '/state', { headers: H })).json();+ const ph = st.phases.map(p => ({ name: p.name, start: p.start || '', end: p.end || '', uid: p.uid, color: p.color, tags: p.tags || [] }));+ const N = ph.length; ph.push({ name: 'Created and filled at once', start: '', end: '', color: '#56ccf2', tags: [] });+ const movers = inSec(secs[0]).slice(0, 3).map(r => r.uid);+ const res = await (await fetch(S.base + '/ops', { method: 'POST', headers: H,+ body: JSON.stringify({ client: 'agent', batch: 'sec-1', ops: movers.map(u => ({ op: 'row', uid: u, set: { phase: N } })).concat([{ op: 'phases', phases: ph }]) }) })).json();+ check('a batch that creates a section and moves rows into it is announced sections first', res.ok && (res.ops || [])[0] && res.ops[0].op === 'phases', (res.ops || []).map(o => o.op).join(' > '));+ await quiesce(S, people);+ // Everybody then edits something else, so whatever a tab holds goes out with its save.+ const busy = inSec(secs[1]);+ for (let i = 0; i < 3; i++) await setF(people[i], busy[i].uid, 'status', 'in-progress');+ await quiesce(S, people); await sleep(600);+ let sp = await srvPhase();+ check('the rows moved into the new section stay there through everybody\'s next save (nobody sends the old section back)', movers.every(u => sp.get(u) === N), movers.map(u => sp.get(u)).join(','));+ const shown = await Promise.all(people.map(P => P.page.evaluate(m => m.map(u => (window.__collab.rows().find(r => r.uid === u) || {}).phase), movers)));+ check('and every tab shows them in it', shown.every(l => l.every(p => p === N)), JSON.stringify(shown));++ // 2. Two tabs miss a change: rows held in another section, on the page and in its copy of+ // the server's values. A third tab makes one ordinary move.+ // As on the day: most of a section, not all of it, held two sections further down, so no+ // one order can suit both ways of grouping and each side keeps correcting the other.+ const lost = inSec(secs[1]).slice(2).map(r => r.uid), wrong = secs[3];+ for (const P of [Bp, Cp]) await P.page.evaluate(([u, p]) => window.__collab.diverge(u, p), [lost, wrong]);+ const r0 = await Promise.all([Bp, Cp].map(P => P.page.evaluate(() => window.__collab.resyncs ? window.__collab.resyncs() : 0)));+ const mv = inSec(secs[4] !== undefined ? secs[4] : secs[0]);+ const t0 = Date.now();+ await A.page.evaluate(([u, w]) => window.__collab.move(u, w), [mv[0].uid, mv[2].uid]);+ const per = [];+ for (let k = 0; k < 10; k++) { await sleep(1000); per.push(people.reduce((n, P) => n + P.ops.filter(x => x >= t0 + k * 1000 && x < t0 + (k + 1) * 1000).length, 0)); }+ rec('batchesPerSecondAfterOneMove', per);+ check('one move with two tabs that missed a change: the tabs go quiet, no fight over the order', per.slice(-5).every(n => n === 0), per.join(' '));+ sp = await srvPhase();+ for (const P of [Bp, Cp]) {+ const rs = await P.page.evaluate(() => window.__collab.rows());+ const off = lost.filter(u => (rs.find(r => r.uid === u) || {}).phase !== sp.get(u));+ const r1 = await P.page.evaluate(() => window.__collab.resyncs ? window.__collab.resyncs() : 0);+ check(`${P.name}'s tab notices it disagrees about the sections, fetches the plan and puts the ${lost.length} rows back`, off.length === 0, `${off.length} still elsewhere, catch-ups ${r1 - r0[[Bp, Cp].indexOf(P)]}`);+ }+ check('the server never took the wrong section from them', lost.every(u => sp.get(u) === secs[1]));+ const orders = await Promise.all(people.map(P => P.page.evaluate(() => window.__collab.order().join(','))));+ check('and all three tabs hold the same order', orders.every(o => o === orders[0]));+ check('every tab holds exactly what the server holds', (await convergence(S, people)).length === 0);++ // 3. The owner list stays open in one hand while the others change the plan.+ const listOpen = () => A.page.evaluate(() => !!document.querySelector('.cell-list') && document.activeElement === document.getElementById('ganttOwner'));+ await A.page.click('#ganttOwner'); await sleep(300);+ const before = await listOpen();+ const t1 = inSec(secs[2]);+ await setF(Bp, t1[0].uid, 'status', 'blocked'); await quiesce(S, people);+ await setF(Cp, t1[1].uid, 'tags', ['Moved', 'Here']); await quiesce(S, people);+ await Cp.page.evaluate(([u, w]) => window.__collab.move(u, w), [t1[2].uid, t1[3].uid]); await quiesce(S, people);+ await Bp.page.evaluate(u => window.__collab.del(u), t1[1].uid); await quiesce(S, people);+ await Bp.page.evaluate(() => window.__collab.undo()); await quiesce(S, people);+ check('an open owner list stays open while other people change fields, tags, the order, and delete and restore a row', before && await listOpen());+ await A.page.keyboard.press('Escape');+ const errs = people.flatMap(P => P.errors);+ check('no page errors', errs.length === 0, errs.slice(0, 3).join(' | '));+ for (const P of people) await closePerson(P);+ await stopServer(S);+}+ async function fuzzScenario(carbon) { scenario = 'fuzz'; const S = await startServer(carbon);@@ -925,6 +1013,7 @@ try { if (want('reconnect')) await reconnectScenario(carbon); if (want('docs')) await docsScenario(carbon); if (want('fuzz')) await fuzzScenario(carbon);+ if (want('sections')) await sectionsScenario(carbon); exitCode = fail ? 1 : 0; } catch (e) { console.error('\nSUITE CRASHED:', e && e.stack || e);
test/server_test.mjs+48@@ -796,6 +796,54 @@ async function main() { ok((await fetch(P.base + '/live/focus?cid=tabA')).status === 405, 'focus is POST only'); stop(P); }+ // ── Sections and the stored order (0.77.1) ──+ // On 2026-11-03 the team plan's stored order stopped following its sections (rows moved to+ // another section by their section field alone), every open page grouped it its own way,+ // and pages that disagreed corrected it back and forth. The server now keeps it grouped,+ // says so, and announces a batch's sections before its rows.+ {+ console.log('E. sections and the stored order');+ const secPlan = () => ({ project: { title: 'Sections', subtitle: 'server_test' },+ phases: ['A', 'B', 'C'].map(name => ({ name, start: '', end: '' })),+ items: [['00001', 'a0', 0], ['00002', 'a1', 0], ['00003', 'b0', 1], ['00004', 'b1', 1], ['00005', 'c0', 2]]+ .map(([uid, id, phase]) => ({ type: 'task', uid, id, name: 'Row ' + id, start: '2026-10-05', end: '2026-10-09', phase })) });+ const side = (order, stateRev) => ({ v: 4, items: [], deleted: [], added: [], order, stateRev,+ phases: ['A', 'B', 'C'].map((name, i) => ({ i, name, start: '', end: '', tags: [] })) });+ const Q = await start('sections', secPlan(), side(['00001', '00002', '00003', '00004', '00005'], 40), 8940);+ const auth = { Authorization: 'Bearer ' + Q.token };+ const ops = async list => (await post(Q, '/ops', { client: 't', batch: 'b' + (++opsN), ops: list }, auth)).j || {};+ let opsN = 0;+ const stored = () => JSON.parse(fs.readFileSync(path.join(Q.dir, 'plan-state.json'), 'utf8'));+ ok(stored().stateRev === 41, 'a start makes one revision, so every page that stayed open across it fetches the plan once', stored().stateRev);+ let j = await ops([{ op: 'row', uid: '00002', set: { phase: 2 } }]);+ let o = (j.ops || []).find(x => x.op === 'order');+ ok(o && o.grouped === true && eq(o.order, ['00001', '00003', '00004', '00002', '00005']), 'a row moved to another section by its section alone comes back in an order grouped by section, flagged as grouped', j.ops);+ ok(eq(stored().order, ['00001', '00003', '00004', '00002', '00005']), 'and that is the order the server keeps', stored().order);+ j = await ops([{ op: 'row', uid: '00001', set: { phase: 3 } }, { op: 'phases', phases: ['A', 'B', 'C', 'D'].map(name => ({ name, start: '', end: '' })) }]);+ ok(eq((j.ops || []).map(x => x.op), ['phases', 'row', 'order']), 'a batch that moves a row into a section it creates is announced sections first, then rows, then the order (a page takes them in that order)', (j.ops || []).map(x => x.op));+ let ex = await (await fetch(Q.base + '/export/json', { headers: auth })).json();+ ok(ex.items.find(r => r.uid === '00001').phase === 3 && ex.items[ex.items.length - 1].uid === '00001', 'the row is in the new section, and last, where its section is', ex.items.map(r => r.uid + ':' + r.phase).join(' '));+ j = await ops([{ op: 'move', uid: '00003', after: '00004' }]);+ o = (j.ops || []).find(x => x.op === 'order');+ ok(o && eq(o.order, ['00004', '00003', '00002', '00005', '00001']), 'a move inside a section lands as it was made', o && o.order);+ j = await ops([{ op: 'move', uid: '00005', after: null }]);+ o = (j.ops || []).find(x => x.op === 'order');+ ok(o && eq(o.order, ['00004', '00003', '00005', '00002', '00001']), 'a move to the top of the plan keeps the row in its own section, first in it', o && o.order);+ const revQ = stored().stateRev;+ j = await ops([{ op: 'move', uid: '00005', after: null }]);+ ok(j.stateRev === revQ && eq(j.ops, []) && stored().stateRev === revQ, 'a move that changes nothing makes no revision and is announced to nobody (a 0.77.0 page in the wrong sections sends them until reloaded)', j);+ stop(Q);+ const R = await start('sections-start', secPlan(), side(['00003', '00001', '00005', '00002', '00004'], 7), 8940);+ let st = JSON.parse(fs.readFileSync(path.join(R.dir, 'plan-state.json'), 'utf8'));+ ok(eq(st.order, ['00001', '00002', '00003', '00004', '00005']) && st.stateRev === 8, 'a server started on an order that is not grouped by section keeps it grouped from then on, as one new revision', [st.order, st.stateRev]);+ ex = await (await fetch(R.base + '/export/json', { headers: { Authorization: 'Bearer ' + R.token } })).json();+ ok(eq(ex.items.map(r => r.uid), ['00001', '00002', '00003', '00004', '00005']), 'and the merged plan reads it so', ex.items.map(r => r.uid));+ stop(R);+ const U = await start('sections-part', secPlan(), side(['00003', '00001'], 7), 8940);+ st = JSON.parse(fs.readFileSync(path.join(U.dir, 'plan-state.json'), 'utf8'));+ ok(eq(st.order, ['00003', '00001']) && st.stateRev === 8, 'an order that does not list every row is left for the next save that touches it to complete', [st.order, st.stateRev]);+ stop(U);+ } } try { await main(); }
test/ui_test.mjs+12−9@@ -1256,7 +1256,6 @@ const MANIFEST = [ { sel: '#ncProv', label: 'new: provenance', terminal: true, by: 'new item fields' }, { sel: '#ncCreate', label: 'new: create', terminal: true, by: 'live: create' }, { sel: '#ncCancel', label: 'new: cancel', safe: true },- { sel: '#tblOwner', label: 'table owner filter', terminal: true, by: 'table filters' }, { sel: '#tblStatus', label: 'table status filter', terminal: true, by: 'table filters' }, { sel: '#tblSearch', label: 'table search', terminal: true, by: 'table filters' }, { sel: '#toPlanOrder', label: 'table: back to plan order', safe: true },@@ -1836,10 +1835,10 @@ async function editorFlows(browser, S) { const tableRows = () => page.$$eval('#tableView tbody tr[data-key]', trs => trs.map(tr => tr.textContent)); const all = await tableRows(); const johns = plan0.items.filter(r => r.owner === 'John').length;- await page.select('#tblOwner', 'John'); await settle(page, 200);+ await page.select('#ganttOwner', 'John'); await settle(page, 200); let rows = await tableRows(); check(`owner filter John shows exactly his ${johns} rows`, rows.length === johns && rows.every(x => x.includes('John')), `${rows.length} rows`);- await page.select('#tblOwner', ''); await settle(page, 200);+ await page.select('#ganttOwner', ''); await settle(page, 200); check('owner filter back to everyone', (await tableRows()).length === all.length); const cancelled = plan0.items.filter(r => r.status === 'cancelled').length; await page.select('#tblStatus', 'cancelled'); await settle(page, 200);@@ -1882,7 +1881,11 @@ async function editorFlows(browser, S) { let shown = await chartNames(); check(`owner John on the chart shows exactly his ${johnN} rows`, shown.length === johnN && shown.every(t => live.find(r => t.includes(r.name))?.owner === 'John'), `${shown.length} rows`); await view(page, 'table');- check('the table owner select follows the chart\'s pick (one choice for both lenses)', await page.$eval('#tblOwner', e => e.value) === 'John');+ // 0.77.1: one Owner filter for both lenses, in the same place in the legend line. The Table+ // had its own at the far end of the line, so the chart's seemed to vanish on the way in.+ const tOwner = await page.evaluate(() => { const s = document.getElementById('ganttOwner'), b = s && s.closest('.legend-item'), r = b && b.getBoundingClientRect();+ return { value: s && s.value, shown: !!b && b.offsetParent !== null && r.width > 0, x: r && Math.round(r.left), own: !!document.getElementById('tblOwner') }; });+ check('the Table shows the same Owner filter, with the chart\'s pick (one choice for both lenses)', tOwner.value === 'John' && tOwner.shown && !tOwner.own, JSON.stringify(tOwner)); check('and the table shows the same rows', (await tableRows()).length === johnN); await view(page, 'gantt'); const noneN = live.filter(r => !(r.owner || '').trim()).length;@@ -3443,7 +3446,7 @@ async function rowLinksFlow(browser) { // From the table, behind a filter that hides the row: the link goes to the chart, clears // the filter and selects the row. await view(page, 'table');- await page.select('#tblOwner', '__unassigned__'); await settle(page, 300);+ await page.select('#ganttOwner', '__unassigned__'); await settle(page, 300); await page.evaluate(u => { location.hash = '#row=' + u; }, u1); await settle(page, 700); const lk = await page.evaluate(() => ({ view: document.documentElement.dataset.view, sel: [...document.querySelectorAll('.bar.selected')].map(b => b.dataset.id), owner: (document.getElementById('ganttOwner') || {}).value, flash: document.getElementById('saveInd').textContent })); check('a link opened from the table goes to the chart, clears the filter hiding the row, and selects it', lk.view === 'gantt' && lk.sel.join() === 'l1' && lk.owner === '' && /filters were hiding it/.test(lk.flash), JSON.stringify(lk));@@ -3508,13 +3511,13 @@ async function typeAheadFlow(browser) { check('Table: the arrows move the highlight and save nothing', (await mark()) !== st0 && (await row('a2')).status === 'not-started', `${st0} -> ${await mark()}`); await page.keyboard.press('Escape'); await settle(page, 300); check('Table: Escape leaves the cell as it was', (await row('a2')).status === 'not-started' && !(await page.$('.cell-list')));- // The toolbar's owner filter: typing highlights, Enter applies.- await page.focus('#tblOwner'); await page.keyboard.type('n'); await settle(page, 150);+ // The owner filter, from the Table: typing highlights, Enter applies.+ await page.focus('#ganttOwner'); await page.keyboard.type('n'); await settle(page, 150); const rowsNow = () => page.$$eval('#tableView tbody tr[data-key]', t => t.length);- check('Table filter: typing highlights an owner without filtering yet', (await mark()) === 'Noah' && (await rowsNow()) === 2, `${await mark()} / ${await rowsNow()}`);+ check('Table filter: typing highlights an owner without filtering yet', (await mark()).startsWith('Noah') && (await rowsNow()) === 2, `${await mark()} / ${await rowsNow()}`); await page.keyboard.press('Enter'); await settle(page, 300); check('Table filter: Enter applies it', (await rowsNow()) === 1);- await page.select('#tblOwner', ''); await settle(page, 200);+ await page.select('#ganttOwner', ''); await settle(page, 200); // The item editor: typing in the section list. await view(page, 'gantt'); await openItemEditor(page, 'Other task');
uninstall.sh