← Commit history
.gitignore+2
@@ -4,3 +4,5 @@ test/out/ s1.log s2.log server.log+test/node_modules+*.log
README.md+14−9
@@ -58,6 +58,8 @@ A plain on/off toggle. Each lane is answered on its OWN subgraph, so a dependenc  **Multi-select.** Ctrl+click to toggle individual rows, Shift+click for range select (Windows Explorer style), in either lens. Tasks and milestones both, since both have a permanent id to be named by; lane headers do not join. The batch editor applies relative date shifts across the selected tasks without flattening dates, and says how many milestones it is leaving alone: a milestone has one date and no duration, so shifting its end has no meaning. Its **Delete** removes the whole selection, milestones included, in one press and one Ctrl+Z: the same path the chart's armed Delete key uses, so it is one undo entry, one changelog batch, dependencies on the departed rows dropped and their ids retired either way. +**Milestones are gates (0.78.0).** A milestone can wait on tasks, a task can wait on a milestone, and one milestone can wait on another: "EVT sign-off" waits on the EVT build and test, and production tooling waits on the sign-off. Make the link the way you make any other, in Dependency mode (click the diamond, or in the lines style the cap on the rule, as the first click or the second) or in the item editor, whose dependency list a milestone now has and whose "waits on" list offers milestones, marked with a diamond. Arrows run into and out of the diamond's points, and in the lines style onto the dashed rule level with the task at the other end. When the work before a gate slips, **Auto-schedule** moves the gate to the day that work ends and pushes the work after it along, starting on the gate's date at the earliest, the same rule as a task after a task. **Critical** carries a chain through a gate at no cost and lights the gate with it. The cycle check, copy and paste, the Table's Deps cell, the changelog, live editing and every import and export all take milestone links. Every milestone has an `id` for this, made from its name the way a task's is: the server gives one to every milestone without one when it starts, so an existing plan needs nothing done to it.+ **Copy and paste rows, and the chain comes with them.** Select one row or several, **Ctrl+C**, then **Ctrl+V**. With a row selected the copies land DIRECTLY BELOW it, in its lane, recoloured to match; with nothing selected they go to the end of the lane you are looking at (on the chart, the lane whose header is pinned under the axis; in the table, the lane of the topmost row fully clear of the sticky header). The part that makes it worth having is the **dependencies**: links AMONG the copied rows are remapped onto the copies, so duplicating a five-step build across another workcell gives you a five-step build and not five rows all hanging off the originals. Links pointing OUTSIDE the copied set are kept exactly as they are, still naming the original, because that is a real fact about the work. Nothing downstream of an original is touched: a task that waited on the row you copied still waits on that row and not on both. Names are KEPT, so you can rename after rather than undoing " (copy)" five times; the exception is a name that would collide inside the destination lane, which gets " 2", then " 3", counting past whatever is already there. Every copy gets a permanent id of its own, minted by the server; if that mint cannot be made the paste is refused whole and says so, rather than leaving half a chain. Milestones copy too. The pasted rows become the selection and are scrolled into view, ready for Alt+Arrow or a drag, and the whole paste is one Ctrl+Z and one line in the changelog.  **Vertical reorder.** Drag bars or labels up/down to reorder rows. Drop indicator shows insertion point. Multi-select reorder moves all selected items together.@@ -86,7 +88,7 @@ A plain on/off toggle. Each lane is answered on its OWN subgraph, so a dependenc  **Undo.** Ctrl+Z undoes date edits, dependency changes, drag operations, row reorders, and table edits to owner, status and notes (100-level stack). -**Import.** JSON (native format), CSV, MS Project XML, and Jira CSV. Drag-and-drop or file picker. A file with rows that carry no usable date is rejected whole, naming the rows, rather than having a date invented for it. A row that cannot be dated is left out of the chart with a warning instead of blanking the plan.+**Import.** JSON (native format), CSV, MS Project XML, and Jira CSV. Drag-and-drop or file picker. Links to and from milestones come in with every one of them (0.78.0): an MS Project milestone arrives as a milestone with its predecessor links, and a Jira issue of the type Milestone as a milestone, with Jira's Blocks links arriving as dependencies. A file with rows that carry no usable date is rejected whole, naming the rows, rather than having a date invented for it. A row that cannot be dated is left out of the chart with a warning instead of blanking the plan.  **Export.** JSON, CSV, SVG, and self-contained HTML (no server needed to view the snapshot). The Export button serialises what is on your screen right now, not what is on disk, so it reflects unsaved edits and keeps working even when a save conflict has stopped autosave. CSV carries the phase by name, so an export re-imported keeps its phases, and a row's tags in a `tags` column joined with `;` like its dependencies, and any cell opening with a formula character is apostrophe-guarded so a spreadsheet cannot execute it. Import strips the guard back off. @@ -104,7 +106,7 @@ A plain on/off toggle. Each lane is answered on its OWN subgraph, so a dependenc  **Cell size.** Days, Weeks, Months, Quarters or Years. Each grain carries its own minimum cell width, sized to the widest label it draws, so a fine grain scrolls rather than squashing and a coarse one stretches to fill. Days adds weekend shading, a differently coloured band on each of the plan's days off (the federal holidays it takes, with the name in a tooltip, and its extra days off), and a full calendar picker. -**Editable milestones.** Drag a milestone sideways to move its date, or click it to edit its name, date and description. Line style is the default: the tag on the dashed rule is the handle and the milestone's own row is hidden, since the rule already spans the chart. Long titles wrap, and a tag that would still collide with its neighbour drops to a second row. Diamond style keeps the row.+**Editable milestones.** Drag a milestone sideways to move its date, or click it to edit its name, date, description and (0.78.0) what it waits on and what waits on it. Line style is the default: the tag on the dashed rule is the handle and the milestone's own row is hidden, since the rule already spans the chart. Long titles wrap, and a tag that would still collide with its neighbour drops to a second row. Diamond style keeps the row.  **Bar notes.** Put a short label on the bar body, the way durations are annotated on a printed plan. Double-click a bar to type one, or use the note field in the editor. @@ -158,7 +160,7 @@ Create a JSON file with three sections:   ],   "items": [     { "type": "task", "id": "t1", "name": "Task name", "start": "2026-01-15", "end": "2026-03-01", "phase": 0, "owner": "Adonis", "status": "planned", "description": "Details.", "deps": ["other-id"] },-    { "type": "milestone", "name": "M1: Goal reached", "date": "2026-06-01", "phase": 0, "description": "What this means." }+    { "type": "milestone", "id": "m1-goal-reached", "name": "M1: Goal reached", "date": "2026-06-01", "phase": 0, "description": "What this means.", "deps": ["t1"] }   ] } ```@@ -248,9 +250,12 @@ change. It is written into the file for you, so you never type one. ```  It exists because every other name a row has is conditional. A task `id` is a slug-of the name the row had when it was made, so it reads as a lie after a rename. A-group or a milestone has no id at all and is tracked by its position in the file,-which renumbers whenever the file is restructured. A uid is a number, it means+of the name the row had when it was made, so it reads as a lie after a rename, and+since 0.78.0 a milestone's `id` is made the same way (the first start of a 0.78.0 server+gives every milestone without one its id, after backing the data file and the sidecar up as+`.pre-msid.bak`). A group has no id at all and,+like a milestone before 0.78.0, is tracked by its position in the file, which+renumbers whenever the file is restructured. A uid is a number, it means nothing, and it survives a rename, a reorder, a move between lanes, a compact and a round trip through CSV. @@ -431,7 +436,7 @@ with the edits if you choose to discard them. | Up / Down in a list | Every list of the app's own walks with the arrows (0.69.0): the status pill's and the owner picker's on the chart, the phase, status, type and "waits on" lists in the editors, the Table's cells, and the Export and Import menus. Down and Up move the mark, Home and End jump to the ends, a letter jumps to the next entry starting with it, Enter takes the marked entry, Escape closes the list changing nothing, Tab closes it on the way to the next control | | Enter | Edit the selected row's name. Inside a cell, commit and step down one row | | Delete | Delete the selected row, undoable. Only when a row is selected and no field is open, so Delete inside an editor just edits text. Backspace is deliberately not bound |-| Shift+D | Dependency mode: click the task that comes first, then the one that waits on it. The **Deps** button glows in the accent for as long as the mode is on, and its tooltip is the instruction. The row under the pointer lights up across both panes, tinted in its own phase colour, so you can see which task you are about to click. The armed first task wears the normal selection outline. Clicking a pair that already has a dependency removes it. Click an arrow to pick it, then Delete twice to remove that one. Stays on so you can chain them. Esc lets go of a picked arrow, then the pick, then leaves |+| Shift+D | Dependency mode: click the task or milestone that comes first, then the one that waits on it. A milestone is picked by its diamond, or in the lines style by the cap on its rule. The **Deps** button glows in the accent for as long as the mode is on, and its tooltip is the instruction. The row under the pointer lights up across both panes, tinted in its own phase colour, so you can see which task you are about to click. The armed first task wears the normal selection outline. Clicking a pair that already has a dependency removes it. Click an arrow to pick it, then Delete twice to remove that one. Stays on so you can chain them. Esc lets go of a picked arrow, then the pick, then leaves | | Shift+N | Open the New panel. Fires only when nothing else is going on, so a capital N typed into a field stays a capital N | | Ctrl+C | Copy the selected rows, tasks and milestones alike, to the app's own clipboard, ready for Ctrl+V. It ALSO puts their permanent IDs and names on the system clipboard, one per line, which is what it has always done and what an AI batch edit reads. Answers only when the app is idle and there is no live text selection on the page, so a Ctrl+C aimed at text you have highlighted, or typed into any field, is the browser's copy and never this | | Ctrl+V | Paste the copied rows as new rows. With a row selected they go DIRECTLY BELOW it (below the last of a set, in plan order) and into its lane; with nothing selected they go to the end of the lane you are looking at. Dependencies among the copied rows are remapped onto the copies, so a copied chain is a chain; dependencies pointing outside the set still name the originals, and nothing downstream of an original is changed. The whole paste is one Ctrl+Z |@@ -460,7 +465,7 @@ table, the item editor or the batch editor are taken exactly as typed. | Drag a section row on Setup | Move the section; a line shows where it lands. Alt+Up / Alt+Down move a focused row. One Ctrl+Z | | Click a section's name or aside on Setup | Edit it in place: Enter or click away commits, Escape cancels | | Fit | Fit the whole plan into the pane, no horizontal scrolling |-| Auto-schedule | Push every task out to its dependencies, finish to start, skipping weekends and the plan's days off (Setup, Working calendar). Never pulls work earlier, so intentional gaps survive. One undo. Disabled when the plan has no dependencies |+| Auto-schedule | Push every task and milestone out to its dependencies, finish to start, skipping weekends and the plan's days off (Setup, Working calendar). A milestone waiting on work lands on the day that work ends, and a task waiting on a milestone starts on its date. Never pulls work earlier, so intentional gaps survive. One undo. Disabled when the plan has no dependencies | | Critical | Light up the longest run of work inside every section, cancelled work left out. On/off, and it recomputes as the plan changes | | A- / A+ | Step the text size for both views. Click the percentage to reset | | Collapse all / Expand all | Fold every section to its header, or open them all. Start of the legend |@@ -522,7 +527,7 @@ node test/ui_test.mjs --keep --plan path/to/plan.json   # also a real plan (copi node test/ui_test.mjs --only "editor: item,text size"   # a subset of flows, for a fast inner loop ``` -**Several people at once** (0.75.1, field-level since 0.77.0) has a suite of its own, `test/collab_test.mjs`: real Chrome tabs, each signed in as a different person through a stand-in for Adom sign-in, on one plan shaped like a real launch plan (about 490 rows, 50 sections, 190 dependencies). It plays a meeting: people join one after another, edit their own rows, look at rows, scroll and flip their own filters, all at once. It measures what each tab pays (main-thread time, the longest freeze, forced layouts, memory) and checks that every tab ends up holding exactly what the server holds with no edit lost. It also covers a save seen from every lens, presence, idle cost (critical path included), a 150-save soak for leaks, two people on one row, and a tab that drops off and catches up. Since 0.77.0 a `docs` scenario checks each Google-Docs-style behaviour above with two signed-in people, and a `fuzz` scenario has up to six people (`--users`) edit the same rows as fast as they can (fields, lists, moves, undo and redo, tabs dropping off and coming back), then checks that every tab holds exactly what the server holds and that no field only one person wrote was lost (`--seed` picks another random sequence).+**Several people at once** (0.75.1, field-level since 0.77.0) has a suite of its own, `test/collab_test.mjs`: real Chrome tabs, each signed in as a different person through a stand-in for Adom sign-in, on one plan shaped like a real launch plan (about 490 rows, 50 sections, 190 dependencies). It plays a meeting: people join one after another, edit their own rows, look at rows, scroll and flip their own filters, all at once. It measures what each tab pays (main-thread time, the longest freeze, forced layouts, memory) and checks that every tab ends up holding exactly what the server holds with no edit lost. It also covers a save seen from every lens, presence, idle cost (critical path included), a 150-save soak for leaks, two people on one row, and a tab that drops off and catches up. Since 0.77.0 a `docs` scenario checks each Google-Docs-style behaviour above with two signed-in people, and a `fuzz` scenario has up to six people (`--users`) edit the same rows as fast as they can (fields, lists, moves, undo and redo, tabs dropping off and coming back), then checks that every tab holds exactly what the server holds and that no field only one person wrote was lost (`--seed` picks another random sequence). Since 0.78.0 a `milestones` scenario has two people link the same milestone at the same moment, in both directions, and checks that both links stand everywhere.  ``` node test/collab_test.mjs                      # 6 people, every scenario, about 6 minutes
SKILL.md+45−12
@@ -84,10 +84,12 @@ Create a JSON file with this structure:     {       "type": "milestone",       "uid": "00003",+      "id": "m1-first-milestone",       "name": "M1: First milestone",       "date": "2026-08-31",       "phase": 0,-      "description": "What this milestone means."+      "description": "What this milestone means.",+      "deps": ["unique-id"]     }   ] }@@ -175,7 +177,7 @@ items. | provenance | object or string | no | Where this row came from. `{"label":"POR","url":"https://..."}` or just `"Drew added this"`. Only http and https urls render as links; anything else shows as text. A LINKED provenance is fixed in the app (0.71.0): the Table cell and the item editor show it as the link and never as a text field, so its text stays the name of what it opens; a plain-text one is edited like any field. Change a link through the data file, an import or the agent API | | status | string | no | One of: "not-started", "in-progress", "complete", "blocked", "cancelled". That is the order the app shows them in everywhere, and since 0.56.0 it is the LIFECYCLE order: the road a row travels when nothing goes wrong, then the two ways off it. The order comes from one list (`STATUS_ORDER`), so the item and create panels, the table's status filter, the inline pill list, the analytics status card, the per-person card's five columns and the table's status-column collation cannot disagree, and sorting by status now ranks a complete row ahead of a blocked one. Membership is unchanged: the old "live" and "planned" are still read and shown as in progress / not started, and "canceled", "won't do", "abandoned" and "dropped" all read as cancelled | | description | string | no | Tooltip body text, and the Notes column in the table |-| deps | string[] | no | Array of task IDs this depends on |+| deps | string[] | no | The ids of the rows this one waits on: tasks, and since 0.78.0 milestones too | | note | string | no | Short label drawn on the bar itself, e.g. `6m` or `Y1`. Keep it to a few characters | | striped | boolean | no | Striped bar pattern (for ongoing work) | | tags | string[] | no | The row's OWN tags, e.g. `["long-lead"]`. Same rules as a section's tags. A row's effective tags are these plus its section's |@@ -186,14 +188,31 @@ items. | Field | Type | Required | Description | |-------|------|----------|-------------| | uid | string | assigned | Permanent id. The server writes it; never invent one. See uid below |+| id | string | assigned | What a dependency names it by (0.78.0), the same kind of id a task has: a slug of the name it was made with, unique in the plan. The server gives one to every milestone without one, at startup, on import and when the app makes one, and a rename never changes it. You may write one yourself, unique against every task and milestone id | | name | string | yes | Milestone label | | date | YYYY-MM-DD | yes | Milestone date, and the Deadline column in the table | | phase | number | yes | Index into phases[] |+| deps | string[] | no | The ids of the tasks and milestones this milestone waits on (0.78.0). A milestone is a GATE: it waits on the work before it, and a task's `deps` can name it to wait on the gate. See Milestones are gates below | | owner | string | no | Usually absent. The table shows NO OWNER and lets you set one | | status | string | no | Usually absent. Same values as a task | | description | string | no | Tooltip body text, and the Notes column in the table | | tags | string[] | no | Same as a task's | +**Milestones are gates (0.78.0).** A milestone can wait on tasks, a task can wait on a+milestone, and one milestone can wait on another, by the same `deps` list a task has:+`{"type": "milestone", "id": "evt-signoff", "name": "EVT sign-off", "date": "2027-03-12", "deps": ["evt-test"]}`+makes the sign-off wait on the test, and `"deps": ["evt-signoff"]` on production tooling+makes the tooling wait on the sign-off. Every place a link is made, drawn or read takes+them: Dependency mode (a milestone is either click, picked by its diamond, or in the lines+style by the cap on its rule), the item editor's dependency list (a milestone's editor shows+it, and every "waits on" list offers milestones, marked with a diamond), the Table's Deps+cell, the arrows (into and out of the diamond's points, or in the lines style onto the rule+at the height of the row at the other end, and from cap to cap between two milestones),+Auto-schedule, Critical, the cycle check, copy and paste, the changelog (`dep.add` and+`dep.remove` with `ms: true`), field-level sync, and every import and export. Until 0.78.0+a milestone had no id, so nothing could name it, and the link between a gate and the work+around it was its date alone: when the work slipped, the gate stayed put.+ **Tags ride every export.** `/export/json`, `/import/json` and the HTML snapshot carry `tags` on phases and items, and every import normalises them. `/export/csv` has a `tags` column after `deps`, joined with `;` the same way (and an `effort` column after it, hours, since 0.63.0), and `/import/csv` splits it@@ -202,6 +221,17 @@ not in it. **So a CSV export and re-import loses the section tags, and with them tag a row only inherits**; JSON and the HTML snapshot are the lossless formats. A Jira CSV's `Labels` column becomes each row's tags; MS Project carries none. A plan with no tags gains no `tags` keys anywhere, so an old file round-trips unchanged. +**Milestone links ride every import and export (0.78.0).** JSON and the HTML snapshot carry+a milestone's `id` and `deps`; the CSV carries them in the same `id` and `deps` columns a+task's go in, so an export imported back keeps the links in both directions, and a CSV+milestone with an empty `id` is given one from its name. An MS Project milestone (flagged+`<Milestone>1</Milestone>`) comes in as a milestone on its start date, with the id+`ms-<UID>`, and its predecessor links into and out of it are kept (a task is still+`task-<UID>`). A Jira issue whose Issue Type is `Milestone` comes in as a milestone on its+due date, and Jira's `Blocks` links come in as dependencies: an `Inward issue link (Blocks)`+column is what the issue waits on, an `Outward issue link (Blocks)` column is what waits on+it, every repeated column is read, and a link to an issue the file does not have is left out.+ ### Phase colors  **Leave `color` off and the phase follows the Adom brand ramp.** That is the@@ -237,8 +267,8 @@ rather than a category. - **Project Duration and Time Remaining** in the header, stacked, beside the lens switch (0.57.0, replacing the old "25 weeks" axis-cell count, which changed with the grain and counted the axis padding). Both come from the plan's own dates and never from the grain: Duration runs from the earliest task start or milestone date to the latest task end or milestone date, both days counted, and Remaining runs from today to that last date, today counted. **Cancelled rows are left out**, as Auto-schedule, the critical path and the hours totals leave them out, and the tooltip says how many were; if every dated row is cancelled both read `none` and the tooltip says why. Since 0.75.0 the **project dates** (Setup) win where they are set: Duration runs from the project start to the project end and Remaining from today to the project end, and the rows only stand in for an end that is not set (never on the wrong side of the one that is). Rows that fall outside the dates turn both readouts amber and are counted in the tooltip. (The Analytics "calendar span" card still includes them; it was not changed.) Format: days under two weeks, `16w 4d` style under half a year, then months and years to one decimal. The tooltips give the exact calendar and working-day counts and both dates. Remaining reads `ended` once the last date is past; before the first date it still counts from today, so it can be longer than Duration, and a small `not started` note sits on the readout itself to say why. Both read `none` for a plan with no dated rows. They update on every edit that can move a date (drag, editor, table edit, project dates, auto-schedule, create, delete, paste, undo, redo) and at midnight on a tab left open. The header FITS ITSELF to its content rather than to pixel breakpoints (`fitHeader`): it measures the row and, only while the content is wider than the window, sheds in order the version, the button words and the long labels (Duration, Remaining). If that still does not fit, it wraps onto a second row with the long labels back, and with the button words back too whenever they cost no extra row (they stay off on a phone, where they would). The readouts never shrink, so they are whole at every width; the suite sweeps 360 to 2000px with and without an appName at 100% and 150% text to hold that - **Fit** button beside the cell-size picker: picks the finest grain whose whole span fits the pane, so the plan is fully visible with no horizontal scrolling. Chart-only - **Gantt / Table / Analytics / Setup toggle** pinned first in the header, left of every conditional control so its position never shifts. Four lenses over the same items: the chart answers "when", the table answers "whose, and by when", analytics answers "how much, and is any of it in trouble", and Setup is the plan's structure, its sections. See The table view, The analytics view and The setup view below-- **Auto-schedule** button, with the plan-level controls next to Fit, in the chart and the table lenses. One press lays the WHOLE plan out under two rules at once: no task with dependencies starts before its latest predecessor's end date, and no task starts or ends on a Saturday, a Sunday or one of the plan's days off (the US federal holidays it takes, see Working calendar). The handoff date is a MINIMUM and not an answer: it is BACK TO BACK for a task that was overlapping and is pushed onto it, and back to back is a statement about the CHART, since a bar runs from its start to its end and the end is the boundary it stops at, so a successor starting on that date draws flush against it with no gap and no overlap. **PUSH ONLY, since 0.53.0.** A task that already starts on or after its handoff is LEFT WHERE IT IS, however wide the gap, because a gap in a real plan is usually somebody's lead time: an order placed on the Monday and the parts that arrive on the Thursday keep their three days. Through 0.52.0 the pass went both ways and closed every one of those, and there was no way to keep one. Nothing is ever pulled EARLIER now, so the plan cannot drift backwards over a press, and the pass reaches a fixed point in ONE press: the second moves nothing. Pushes still cascade, each successor measured against where its predecessor LANDED and not against the date it shipped with. **CANCELLED WORK IS NOT IN THE PASS AT ALL,** also since 0.53.0: it is never moved, so its dates freeze exactly as set even if they violate something, and it constrains nobody, so a task whose only predecessors are cancelled behaves like an anchor and one with a mix is pushed by its LIVE predecessors alone. The toast counts those rows as their own third number ("5 tasks moved, 15 unchanged, 1 cancelled excluded") rather than folding them into the count of work that was already aligned. A task with no dependencies is an ANCHOR and keeps its start (shifted LATER off a weekend or a holiday if it was on one), because that is the one thing in a plan this cannot derive. Duration is preserved in WORKING days, not calendar days, so a five-day task laid across a weekend, or across Labor Day, still takes five working days: a predecessor ending on the Friday hands off on the Friday and the weekend falls inside the successor's own span, never between the two. Milestones take the same rule with no duration: off a weekend or a holiday to the next working day, and on the day their last dependency ends. The graph crosses sections freely and so does the schedule; a section's displayed range just follows its rows. A dependency CYCLE is left exactly as it is, along with anything downstream of it, and the toast names the rows: there is no honest answer for a row that waits on itself. The whole pass is ONE undo entry and ONE changelog event (`action: "auto-schedule"`, with a count and the names of what moved), and a press that moves nothing writes nothing. Deterministic. Disabled, with a tooltip that says why, when the plan has no dependencies-- **Critical** button, next to it: the LONGEST run of work in the plan. The dependency graph is a DAG, a task WEIGHS its duration in working days (weekends and the plan's days off out, the same arithmetic everything else in the app counts with) and a milestone weighs nothing, and the critical path is the maximum-weight path through it, found by the classic longest-path DP. It reads the dates the plan has right now, so it is independent of Auto-schedule and never moves anything. **This is not zero-float CPM** and it deliberately stopped being it in 0.49.0: CPM anchors its backward pass on the plan's latest finish, so it always answers "which chain ends last", and on a real plan that picks a short simple chain running into December over a ten-row interconnected build finishing in November. An isolated row is a path of one and can win if it is genuinely the longest thing there is. **One button, an on/off TOGGLE**, with one constant label and the state carried by its pressed treatment: off, or one longest path inside EVERY section at once, all lit together. 0.49.0 offered a plan-wide reading as a third state of the same button and 0.50.0 dropped it, because on a real plan it lights one section's chain and leaves every other section dark, which is a narrower reading than the per-section answer that contains it. With no label to swap, the button sizes off its own content like every other header action rather than being pinned to the widest of three words. Each lane is answered on its OWN subgraph: the nodes are that section's rows and the edges are the dependencies whose two ends are both in it, so a dependency that crosses sections is not an edge there and can never light up in that mode, and a section with no internal dependencies lights its longest single ROW, which is the same rule rather than an exception to it. **CANCELLED WORK IS NOT A NODE, since 0.53.0:** it can never glow, it carries no weight, and a chain that ran through it is BROKEN at that row, since the node is absent and its predecessors and successors are no longer joined by way of it. A section with a second route round the cancelled row is REROUTED onto it; a section whose only route ran through it falls back to the longest single row left, which may be neither of the two stumps' first halves. The longest-single-row fallback ignores cancelled rows for the same reason: they are not there to be considered. Nothing is deleted from the data, so un-cancelling puts the node, its links and every path through it straight back. Ties are broken deterministically, in this order: more working days, then a greater calendar span (first start to last end), then more rows, then whichever begins earlier in plan order, so exactly one path is ever lit per answer. A GAP adds no weight, because a path's length is the sum of the NODES along it and not the calendar it is spread over, so the gaps 0.53.0's push-only Auto-schedule now preserves cannot promote or demote a chain; the calendar span is reachable only by the second tie-break, where it separates two chains already tied on working days and says the one occupying more of the calendar is the more critical, which is the reading it has always had. The bars, the arrows along the path and any milestone on it glow white and breathe on a 2.6s cycle, in a saturated indigo in light mode and on paper, and the table lens marks the same rows on their left edge. Purely additive: selection outlines, dependency-mode amber and armed red all still read on a glowing row, and hit-testing is untouched. It recomputes live as dates and dependencies change, and the state is a saved preference beside the theme and the milestone style; a browser that stored the old plan-wide mode comes back lit, since that reader had the glow on. A collapsed section hides its own glow with its rows and the rest of the plan keeps glowing. Disabled, with the same message it has always used, when the plan has no dependencies at all: a path is a run of work THROUGH dependencies, and a plan where nothing waits on anything has no runs to be longest+- **Auto-schedule** button, with the plan-level controls next to Fit, in the chart and the table lenses. One press lays the WHOLE plan out under two rules at once: no task with dependencies starts before its latest predecessor's end date, and no task starts or ends on a Saturday, a Sunday or one of the plan's days off (the US federal holidays it takes, see Working calendar). The handoff date is a MINIMUM and not an answer: it is BACK TO BACK for a task that was overlapping and is pushed onto it, and back to back is a statement about the CHART, since a bar runs from its start to its end and the end is the boundary it stops at, so a successor starting on that date draws flush against it with no gap and no overlap. **PUSH ONLY, since 0.53.0.** A task that already starts on or after its handoff is LEFT WHERE IT IS, however wide the gap, because a gap in a real plan is usually somebody's lead time: an order placed on the Monday and the parts that arrive on the Thursday keep their three days. Through 0.52.0 the pass went both ways and closed every one of those, and there was no way to keep one. Nothing is ever pulled EARLIER now, so the plan cannot drift backwards over a press, and the pass reaches a fixed point in ONE press: the second moves nothing. Pushes still cascade, each successor measured against where its predecessor LANDED and not against the date it shipped with. **CANCELLED WORK IS NOT IN THE PASS AT ALL,** also since 0.53.0: it is never moved, so its dates freeze exactly as set even if they violate something, and it constrains nobody, so a task whose only predecessors are cancelled behaves like an anchor and one with a mix is pushed by its LIVE predecessors alone. The toast counts those rows as their own third number ("5 tasks moved, 15 unchanged, 1 cancelled excluded") rather than folding them into the count of work that was already aligned. A task with no dependencies is an ANCHOR and keeps its start (shifted LATER off a weekend or a holiday if it was on one), because that is the one thing in a plan this cannot derive. Duration is preserved in WORKING days, not calendar days, so a five-day task laid across a weekend, or across Labor Day, still takes five working days: a predecessor ending on the Friday hands off on the Friday and the weekend falls inside the successor's own span, never between the two. Milestones take the same rule with no duration: off a weekend or a holiday to the next working day, and on the day their last dependency ends. Since 0.78.0 a milestone is a GATE: one waiting on work moves out to the day that work ends when it slips, and a task waiting on a milestone starts on the milestone's date at the earliest, the same rule as a task after a task. Push only, like everything else here: a gate already on or after its work stays where it is. The graph crosses sections freely and so does the schedule; a section's displayed range just follows its rows. A dependency CYCLE is left exactly as it is, along with anything downstream of it, and the toast names the rows: there is no honest answer for a row that waits on itself. The whole pass is ONE undo entry and ONE changelog event (`action: "auto-schedule"`, with a count and the names of what moved), and a press that moves nothing writes nothing. Deterministic. Disabled, with a tooltip that says why, when the plan has no dependencies+- **Critical** button, next to it: the LONGEST run of work in the plan. The dependency graph is a DAG, a task WEIGHS its duration in working days (weekends and the plan's days off out, the same arithmetic everything else in the app counts with) and a milestone weighs nothing (since 0.78.0 a milestone can be an end of a dependency, so a chain through a gate passes through it at no cost and the gate glows with it), and the critical path is the maximum-weight path through it, found by the classic longest-path DP. It reads the dates the plan has right now, so it is independent of Auto-schedule and never moves anything. **This is not zero-float CPM** and it deliberately stopped being it in 0.49.0: CPM anchors its backward pass on the plan's latest finish, so it always answers "which chain ends last", and on a real plan that picks a short simple chain running into December over a ten-row interconnected build finishing in November. An isolated row is a path of one and can win if it is genuinely the longest thing there is. **One button, an on/off TOGGLE**, with one constant label and the state carried by its pressed treatment: off, or one longest path inside EVERY section at once, all lit together. 0.49.0 offered a plan-wide reading as a third state of the same button and 0.50.0 dropped it, because on a real plan it lights one section's chain and leaves every other section dark, which is a narrower reading than the per-section answer that contains it. With no label to swap, the button sizes off its own content like every other header action rather than being pinned to the widest of three words. Each lane is answered on its OWN subgraph: the nodes are that section's rows and the edges are the dependencies whose two ends are both in it, so a dependency that crosses sections is not an edge there and can never light up in that mode, and a section with no internal dependencies lights its longest single ROW, which is the same rule rather than an exception to it. **CANCELLED WORK IS NOT A NODE, since 0.53.0:** it can never glow, it carries no weight, and a chain that ran through it is BROKEN at that row, since the node is absent and its predecessors and successors are no longer joined by way of it. A section with a second route round the cancelled row is REROUTED onto it; a section whose only route ran through it falls back to the longest single row left, which may be neither of the two stumps' first halves. The longest-single-row fallback ignores cancelled rows for the same reason: they are not there to be considered. Nothing is deleted from the data, so un-cancelling puts the node, its links and every path through it straight back. Ties are broken deterministically, in this order: more working days, then a greater calendar span (first start to last end), then more rows, then whichever begins earlier in plan order, so exactly one path is ever lit per answer. A GAP adds no weight, because a path's length is the sum of the NODES along it and not the calendar it is spread over, so the gaps 0.53.0's push-only Auto-schedule now preserves cannot promote or demote a chain; the calendar span is reachable only by the second tie-break, where it separates two chains already tied on working days and says the one occupying more of the calendar is the more critical, which is the reading it has always had. The bars, the arrows along the path and any milestone on it glow white and breathe on a 2.6s cycle, in a saturated indigo in light mode and on paper, and the table lens marks the same rows on their left edge. Purely additive: selection outlines, dependency-mode amber and armed red all still read on a glowing row, and hit-testing is untouched. It recomputes live as dates and dependencies change, and the state is a saved preference beside the theme and the milestone style; a browser that stored the old plan-wide mode comes back lit, since that reader had the glow on. A collapsed section hides its own glow with its rows and the rest of the plan keeps glowing. Disabled, with the same message it has always used, when the plan has no dependencies at all: a path is a run of work THROUGH dependencies, and a plan where nothing waits on anything has no runs to be longest - **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).@@ -254,7 +284,7 @@ rather than a category. - **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. 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+- **Dependency mode** (the Deps button with the chart controls, or **Shift+D**): click the task or milestone that comes FIRST, then the one that waits on it, and the dependency is drawn. A milestone is picked by its diamond, or in the lines style by the cap on its rule, and armed it wears the accent ring on both (0.78.0). **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. Since 0.78.0 milestones are endpoints like tasks: until then a dep was stored as a task id and a milestone had none, so the mode refused them. **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 - **Row multi-select in the table**: ctrl-click toggles a row, shift-click takes the range from the anchor following the VISIBLE order under sorts and filters. It is the SAME selection as the chart's, so rows picked in one lens are selected in the other. Modified clicks never open a cell editor and never start a native text selection; unmodified click-drag still selects cell text. Alt+Up/Down moves the whole set (plan order only; refused under a sort), and Delete removes the whole set as one undo. A selection with GAPS in it groups itself up on the first Alt+Arrow press rather than moving: the selected rows close up into one contiguous block at the topmost one's slot@@ -270,7 +300,7 @@ rather than a category. - **Ctrl+C** copies the selected rows, tasks and milestones alike, to the app's OWN clipboard, ready for Ctrl+V. It is in-memory: no serialisation, no reading the system clipboard back, nothing that could paste one version of a plan into another. It ALSO writes the selected rows' uids and names to the SYSTEM clipboard, one row per line, tab-separated, which is what Ctrl+C has done since 0.44.0 and what an AI batch edit reads; that half is silent and its on-screen fallback box is suppressed, because a textarea grabbing the focus would eat the Ctrl+V about to follow. Toasts "Copied N tasks". Writes NOTHING to the server: no uid is minted, no sidecar, no changelog line - **Ctrl+V** pastes the copied rows as NEW rows. Two placement rules, decided by whether anything is selected. **With a selection**, directly BELOW it, and below the LAST of a set in plan order, in that row's phase, so pasting under a row in another lane moves the copies into that lane and recolours bars, phase dots and milestone rules. **With nothing selected**, at the END of the ACTIVE lane, meaning the one most in view: on the chart that is the PINNED lane (the group header the sticky-header logic currently has stuck under the axis; scrolled above the first header, the first lane in plan order), and in the table it is the lane of the topmost row whose top edge is at or below the bottom of the sticky header, whatever the sort. Both are read at the moment of the paste, so scrolling changes the answer and nothing has to be clicked to choose a lane - **What a pasted row carries.** Name, dates (a milestone's `date`, a task's `start` and `end`), owner, status, description and note, snapshotted at the moment of the COPY, so editing an original afterwards does not change what comes out and deleting it does not empty the clipboard. Provenance is the CREATED-row convention (`Added by <you>`), not the original's: the copy was not produced by whatever the original cites, it was made here by the person pasting. Every copy gets a fresh server-minted uid, and a task gets a fresh id slugged from its name and reserved against the plan AND against the other copies in the same paste. A cloned milestone has no id, which is what every milestone this app creates has-- **Dependencies are REMAPPED, which is the point.** A link between two rows that are both in the copied set is rewritten onto the two clones, so copying a five-step chain gives a five-step chain and not five rows all hanging off the originals. A link to anything OUTSIDE the set is kept exactly as it is, still naming the original, because that is a real fact about the work and the copy inherits it. Nothing is added in the other direction: a task that waited on a row you copied still waits on that row and on nothing else, so no successor silently acquires a second predecessor. A copied dependency that named a copied MILESTONE keeps naming the original, since an id-less clone cannot be depended on+- **Dependencies are REMAPPED, which is the point.** A link between two rows that are both in the copied set is rewritten onto the two clones, so copying a five-step chain gives a five-step chain and not five rows all hanging off the originals. A link to anything OUTSIDE the set is kept exactly as it is, still naming the original, because that is a real fact about the work and the copy inherits it. Nothing is added in the other direction: a task that waited on a row you copied still waits on that row and on nothing else, so no successor silently acquires a second predecessor. A milestone in the copied set is remapped like a task (0.78.0), since every clone gets an id of its own: a copied chain through a gate is a chain through the copied gate - **Names are KEPT**, so a chain duplicated across workcells can be renamed afterwards rather than having " (copy)" undone five times first. The exception is a name that would collide INSIDE THE DESTINATION LANE, which is what pasting back into the same lane always does: those get " 2", then " 3", counting past whatever is already there and against the other copies in the same paste. A copy into a different lane keeps its name unchanged, because the lane is what tells the two apart - **The gate on both chords.** `clipboardReady()`: no cell editor, no inline name editor, no drag or reorder, no date popup, and nothing focused that a person could be typing into. Ctrl+C additionally refuses while there is a live text selection anywhere on the page. It is `appIsIdle()` minus ONE clause, the open panel, and it has to be: a plain click on a bar selects the task AND opens its editor, so in the chart "a row is selected" and "nothing is open" never hold at once, and requiring both would make Ctrl+V unreachable in the lens the feature is for. In every case the gate refuses, the browser's own copy and paste are left completely alone - **One paste is one action.** One undo entry removes the whole pasted set and one redo puts it back at the same indices with the same uids; an undone clone leaves by the created-row door, so it is never recorded as a deletion. One changelog event per paste, `action: "paste"`, carrying `count`, up to twelve `names` and a `hint` of "N tasks pasted", collapsed on the `via` the same way an auto-schedule pass is; the per-row `add` lines are not written. An undo and a redo are their own per-row lines, tagged `undo` and `redo`, which is the convention every batch in this app already follows. Repeated pastes make independent sets. Afterwards the pasted rows ARE the selection and the pane scrolls to them on the same five-row buffer every row-moving gesture uses, so they are ready for Alt+Arrow or a drag@@ -285,7 +315,7 @@ rather than a category. - **Working calendar** (0.76.0, issue #3) on Setup, the second card: the eleven federal holidays as checkboxes (ticked = a day off, untick one the team works) and a list of extra days off (a date field and Add day off; each one a chip with a remove button). Stored as `project.calendar = { workHolidays: [names], extraDaysOff: [YYYY-MM-DD] }` in the data file or the sidecar's `calendar` (the sidecar's wins, like the project dates). Owner only, with the same server rule as the dates. Every working-day helper reads it (`isOffDay`, `nextBiz`, `bizBetween`, `addBiz`), so Auto-schedule, Critical, Analytics, the header's workdays and the day grain's shading all follow it. One Ctrl+Z and one changelog line (`plan.calendar`) per change - **Cell size** picker: Days, Weeks, Months, Quarters, Years. Each grain has its own minimum cell width sized to the widest label it draws, so a fine grain scrolls rather than squashing while a coarse one stretches to fill the pane. Day and week positions are exact; month, quarter and year approximate within a month - **Bar labels** put a short label on the bar body (`note`, e.g. `6m`), set in the item editor's "label on the bar" field. The double-click that used to open a one-field label editor on the bar is gone (0.65.0): the first click of it already opens the item editor-- **Milestones are editable too**: drag one sideways to move its date, or click it for the item editor, which hides the fields a milestone does not have (start, effort, bar label, dependencies)+- **Milestones are editable too**: drag one sideways to move its date, or click it for the item editor, which hides the fields a milestone does not have (start, effort, bar label) and, since 0.78.0, shows its dependencies: what it waits on and what waits on it - **Dynamic today marker** shows the current date, positioned with the same grain-aware mapping as the bars, so it is correct at every cell size - **Resizable name column**: drag the divider, or double-click it to fit the longest name. Width persists in `localStorage` - **Table columns are a fixed grid.** Default widths are per column type and identical for every plan (Name 23%, Notes 22%, Provenance 14%, Phase 10%, Owner 8%, Status 8%, Start 7.5%, Deadline 7.5%, delete 30px). Nothing in the body can change them: clicking a cell, opening or cancelling an editor, hovering, sorting, filtering, adding or deleting a row all leave every column exactly where it was. Widths change only when the user drags a header grip or double-clicks one to fit the column to its content, and those persist in `prefs.tableCols`, after which the table is pixel-driven@@ -450,7 +480,7 @@ chart the moment you toggle back, and is one Ctrl+Z. Pulling a deadline in front start drags the start with it rather than drawing a backwards bar.  New changelog actions: `owner`, `status`, `desc` and `milestone.rename`. Milestone-versions carry `ms: true` and a `key` instead of an `id`, since milestones have no id.+versions carry `ms: true` and a `key` (the row's uid). A milestone has had an `id` since 0.78.0, and its `dep.add` and `dep.remove` events carry it as well.  `project.view.mode` pins which lens a plan opens in, the same way `grain` pins the cell size. Use it when the plan is an assignment list more than a timeline.@@ -661,7 +691,7 @@ Why the alternatives are worse: |--------|-------------| | `name` | anybody renames the row, and two rows can share one | | `id` (tasks only) | never breaks, but it is a slug of the ORIGINAL name, so it goes stale and misleads: a row whose id says `hw-etch-run` may now be called something else entirely |-| position, `#row<n>` | a row is inserted or removed above it, or the file is restructured. Groups and milestones have no id and used to live on this |+| position, `#row<n>` | a row is inserted or removed above it, or the file is restructured. Groups have no id, and milestones had none until 0.78.0; both used to live on this | | `uid` | nothing. It is a number, it means nothing, and it is never reissued |  The rules, all of them:@@ -712,8 +742,11 @@ Each event records the action, the item, the before and after dates, the day shi  Actions are `dates`, `plan.shift`, `auto-schedule`, `milestone`, `milestone.rename`, `note`, `owner`, `status`, `desc`, `effort`, `tags`, `phase.tags`, `tag.create`, `tag.rename`,-`tag.color`, `tag.delete`, `dep.add`, `dep.remove`, `reorder`, `plan.dates` (0.75.0) and-`plan.calendar` (0.76.0, `from`/`to` each `{ workHolidays, extraDaysOff }`).+`tag.color`, `tag.delete`, `dep.add`, `dep.remove` (on a milestone too since 0.78.0, with+`ms: true`), `reorder`, `plan.dates` (0.75.0) and `plan.calendar` (0.76.0, `from`/`to` each+`{ workHolidays, extraDaysOff }`). The server writes `milestone.ids` (0.78.0, with a `count`)+once, the first time it starts on a plan whose milestones have no ids, and keeps it out of+the Chat digest like its other bookkeeping.  The `tag.*` actions (0.61.0) are the tag registry's own: `tag.create` (New tag; `to.color` when it was made with one) reads `Drew created the tag "someday"`, `tag.rename` reads@@ -882,7 +915,7 @@ The sidecar is at `v: 4` and everything in it is keyed by `uid`:  | Field | Shape | |-------|-------|-| items | `{uid, s, e, date, deps, note, owner, status, desc, prov, name, phase, tags, effort}`, one entry per changed row. Tasks carry `s`/`e` and `effort` (a number, or `null` for cleared), milestones carry `date` |+| items | `{uid, s, e, date, deps, note, owner, status, desc, prov, name, phase, tags, effort}`, one entry per changed row. Tasks carry `s`/`e` and `effort` (a number, or `null` for cleared), milestones carry `date`. Both carry `deps` (a milestone's since 0.78.0) | | order | a list of uids, the plan's row order | | deleted | a list of uids the user removed. Those uids are retired and never reissued | | added | rows the user created, each carrying its own `uid` |
docs/BUILD-NOTES.md+67
@@ -3,6 +3,73 @@ Decisions taken while building, and the places where the plan and the code disagreed. Newest first. The release checklist is [RELEASING.md](RELEASING.md). +## Build notes, 2026-11-03 (0.78.0, dependencies to and from milestones, issue #7)++Stephen's request (adom/adom-gantt#7): "A milestone should be able to wait on tasks, a task+should be able to wait on a milestone, and one milestone should be able to wait on another."+Drew's two decisions: a task waiting on a milestone starts on the milestone's date, the same+rule as a task after a task; and milestones get permanent ids automatically, unique and made+from their names the way task ids are, with existing milestones given theirs when the server+starts.++**Ids.** `ensureMilestoneIds` runs at startup after the sidecar upgrade (which still reads a+legacy sidecar's milestones by row position), over the data file's milestones and the+sidecar's created ones, deleted ones included, so an undo of an old deletion comes back with+an id nobody has taken since. The same slug rule as the page's `freshId`. One `milestone.ids`+event in the changelog, kept out of the Chat digest like the uid backfill. Every import, /save+and /uid/backfill give ids too, and /ops gives one to a milestone a pre-0.78.0 page creates.+A rename never changes an id, as for a task.++**The engine needed least.** Auto-schedule and Critical already treated a milestone as a+node with its date as both ends and no weight; the node just had no id, so no edge could+reach it. With the id, the rule Drew chose falls out unchanged: the successor's minimum+start is the predecessor's end, which for a milestone is its date.++**Arrows.** A diamond's arrows leave its right point and arrive at its left one, measured+from the rotated box the router already used for obstacles (`msDiamondBox`). In the lines+style a milestone has no row, so an arrow meets its dashed rule level with the row at the+other end, and between two milestones it runs cap to cap. A task that starts before the+gate it waits on puts both ends on one row running backwards; there is no lane between them,+so that hook goes through the boundary below the row.++**Things found on the way.**++1. *The server's merge dropped dependencies on deleted rows from the data file's lists only.*+   A dependency added in the app lives in a sidecar override, which was applied after the+   filter, so a row deleted by ops alone stayed in the merged lists while every page dropped+   it. The filter now runs after the overrides. Pages were already right.+2. *Several page paths read "has an id" as "is a task".* The repaint after a live change, the+   suite's `__collab.set`, the startup restate and `refreshLabelRow` now ask the type. A+   milestone's undo entry (`msSnap`) carries its dependencies, and `editSnapshot` hands one+   back for a milestone, so a snapshot taken by id (Dependency mode, the editor's "blocks"+   list) never restores a start and an end a milestone never had. The changelog diffs tasks+   by id and milestones by row key, as before, and a milestone's link changes are `dep.add`+   and `dep.remove` with `ms: true`.+3. *Imports.* An MS Project milestone came in as a zero-length task. It is a milestone now,+   `ms-<UID>`, with its links. The Jira importer read no links at all; it reads Blocks links+   now (every repeated column, which `parseCSV` collapses, so from the raw rows), and an+   issue of the type Milestone comes in as one.+4. *A snapshot exported before 0.78.0* has milestones with no id and no server to give them+   one, so it gives them ids as it opens (only a snapshot; a live page takes the server's).++**Tests.** `server_test` section F (startup ids, a restart that changes nothing, ops both+ways, two people adding to one milestone's list, add with and without an id, delete and+undelete, CSV, JSON, MS Project and Jira round trips, backfill, /save). `ui_test` flow+"milestone deps" with the mouse, in both milestone styles: Dependency mode with a milestone+as either click, the arrow end points against the diamond's points and the rule, the loop+refusal, the editor's lists, the Table's Deps cell, copy and paste, Auto-schedule after a+slip, Critical, delete and undo, and the HTML snapshot, old and new. `collab_test` scenario+"milestones": two people link the same milestone at once, both ways, and undo stays theirs.+No new control, so the coverage manifest is unchanged; the existing milestone editor check+now expects the dependency list. Gates on the branch, one after another under the suite lock:+server 222 passed, collab 100 passed, fuzz seeds 11 and 101 (6 people) 5 passed each, ui 916+passed (the same 7 controls untested as 0.77.1), nothing failed. On main after the merge, under the same+lock: server 222, collab 100, ui 916, nothing failed.++**Deploying it.** The first start of a 0.78.0 server gives every milestone without an id its+id, which rewrites the plan's data file and sidecar; it backs both up first as+`.pre-msid.bak`.+ ## 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
gantt.html+193−90
@@ -655,6 +655,10 @@ body.col-resizing { cursor: col-resize; user-select: none; } /* The rule and its cap are one milestone in the other two styles, so both carry it. */ .ms-line.crit-ms::before { border-left-color: var(--crit); opacity: 1; filter: drop-shadow(0 0 3px var(--crit-glow)); } .ms-line.crit-ms .ms-cap { border-color: var(--crit); color: var(--crit); box-shadow: 0 0 9px 2px var(--crit-glow); }+/* A milestone armed as the first end of a pair in dependency mode (0.78.0) wears the ring an+   armed bar wears, in the accent, on its diamond and on the cap of its rule. */+.ms-diamond.dep-pending { box-shadow: 0 0 0 2px var(--accent), 0 0 8px 2px var(--accent); }+.ms-line .ms-cap.dep-pending { outline: 2px solid var(--accent); outline-offset: 1px; } /* The table's half of the same answer: the row that is on the path, marked on its left    edge, where the focused-row marker already lives so the two read as one column of    signals rather than two competing washes. */@@ -1982,6 +1986,14 @@ function assignRowKeys(){ items.forEach((r,i)=>{ if(!r.uid&&!r.id) r._key='#row'+i; }); } function rowOrderKey(r){ return r.uid || r.id || r._key; }+// A snapshot exported before 0.78.0 carries milestones with no id, so nothing could depend on+// them. It has no server to give them one and it is the only writer of its own copy, so it+// gives them one as it opens, the way the server does: a slug of the name, unique in the+// plan. After the row keys, so no key changes. A live plan's ids come from the server.+function giveSnapshotMilestoneIds(){+  if(!SNAPSHOT)return;+  items.forEach(r=>{ if(r.type==='milestone'&&!r.id)r.id=freshId(r.name); });+} // Rows the user has deleted, by stable key. Deletion has to be an explicit // signal rather than an absence: applyOrder is all-or-nothing precisely so a // stale key can never silently shrink the plan, so "this row is gone on purpose"@@ -2997,7 +3009,7 @@ $('hdr').innerHTML = `          so it is there before the mode is entered (what does this button do) as well as          during it (what am I meant to click now). Shift+D is not repeated here; it is on          the keyboard-shortcuts pulldown with every other one. -->-    <button class="act-btn" id="btnDeps" title="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.">+    <button class="act-btn" id="btnDeps" title="Adding dependencies. Click the task or milestone that comes first, then the one that waits on it. Click an arrow to pick it. Esc leaves the mode.">       <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="5" cy="6" r="2"/><circle cx="19" cy="18" r="2"/><path d="M7 6h6a3 3 0 0 1 3 3v7"/><path d="M14 14l2 3 2-3"/></svg><span class="btn-label">Deps</span></button>   </div>   <div class="plan-ctl">@@ -3295,7 +3307,11 @@ let undoTimer = null; // `tags` (0.60.0) is the row's own list as it stands, absent or [] included, so the item // editor's Apply over name, owner AND tags is still one entry. tagsSnap copies it. const tagsSnap=r=>Array.isArray(r.tags)?r.tags.slice():undefined;-function editSnapshot(r){ return {type:'edit',id:r.id,s:r.start,e:r.end,deps:r.deps?[...r.deps]:[],note:r.note||'',owner:r.owner,status:r.status,desc:r.description,prov:r.provenance,name:r.name,tags:tagsSnap(r),effort:effortOf(r)}; }+// A milestone's own entry: its date and its words, and since 0.78.0 its dependencies. It has+// an id now too, so a snapshot taken by id (Dependency mode, the editor's "blocks" list) lands+// here rather than in a task's entry, which would put back a start and an end it never had.+function msSnap(r){ return {type:'ms',r,date:r.date,name:r.name,owner:r.owner,status:r.status,desc:r.description,prov:r.provenance,tags:tagsSnap(r),deps:r.deps?[...r.deps]:[]}; }+function editSnapshot(r){ if(r.type==='milestone')return msSnap(r); return {type:'edit',id:r.id,s:r.start,e:r.end,deps:r.deps?[...r.deps]:[],note:r.note||'',owner:r.owner,status:r.status,desc:r.description,prov:r.provenance,name:r.name,tags:tagsSnap(r),effort:effortOf(r)}; } function snapshotMany(ids){   const steps=ids.map(id=>items.find(d=>d.id===id)).filter(Boolean).map(editSnapshot);   if(steps.length)pushUndo(steps.length===1?steps[0]:{type:'multi',steps});@@ -3304,7 +3320,9 @@ function snapshot(id){snapshotMany([id]);} // One undo entry covering many rows, for bulk operations like a plan shift. function snapshotGroup(ids){   const entries=[];-  ids.forEach(id=>{const r=items.find(d=>d.id===id);if(r)entries.push({id,s:r.start,e:r.end,deps:r.deps?[...r.deps]:[],note:r.note||''});});+  // Tasks only: every milestone's date is in `ms` below, and a milestone has an id since+  // 0.78.0, so a caller passing every id in the plan would otherwise list it twice.+  ids.forEach(id=>{const r=items.find(d=>d.id===id);if(r&&r.type!=='milestone')entries.push({id,s:r.start,e:r.end,deps:r.deps?[...r.deps]:[],note:r.note||''});});   const ms=items.filter(r=>r.type==='milestone');   pushUndo({type:'group',entries,ms:ms.map(r=>({r,date:r.date})),phases:phases.map(p=>({p,start:p.start,end:p.end}))}); }@@ -3979,7 +3997,7 @@ function applySnapshot(sn,seqIn){   if(sn.type==='ms'){     const r=sn.r;     if(!isLive(r))return null;-    const inv={type:'ms',r,date:r.date,name:r.name,owner:r.owner,status:r.status,desc:r.description,prov:r.provenance,tags:tagsSnap(r)};+    const inv=msSnap(r);     const tagsBefore=ownTags(r).slice();     const A=sn.after;     restoreField(r,'date',sn.date,seq,A);@@ -3987,6 +4005,8 @@ function applySnapshot(sn,seqIn){     restoreField(r,'owner',sn.owner,seq,A); restoreField(r,'status',sn.status,seq,A); restoreField(r,'desc',sn.desc,seq,A);     if('prov' in sn)restoreField(r,'prov',sn.prov,seq,A);     if('tags' in sn)restoreField(r,'tags',sn.tags,seq,A);+    // An entry from before 0.78.0 carries no dependencies and leaves them as they are.+    if('deps' in sn)restoreField(r,'deps',Array.isArray(sn.deps)?sn.deps:[],seq,A);     const tagsMoved=!sameTags(ownTags(r),tagsBefore);     refreshMsChrome(r);updateMilestone(r);     if(tagsMoved)afterTagsChange();@@ -4429,8 +4449,10 @@ const dataUids=new Set(); // A row as the sidecar has always stored it. The server keeps exactly this per row. function rowOv(r){   const o={uid:r.uid,owner:r.owner||'',status:r.status||'',desc:r.description||'',prov:provValue(r),name:r.name,phase:r.phase};+  // Dependencies on every row: a milestone waits on work too since 0.78.0.+  o.deps=r.deps||[];   if(r.type==='milestone')o.date=r.date;-  else { o.s=r.start; o.e=r.end; o.deps=r.deps||[]; o.note=r.note||''; }+  else { o.s=r.start; o.e=r.end; o.note=r.note||''; }   // A row's own tags (0.60.0 edits them). Written only when the row has a list, so a   // row that never had one says nothing and the data file's value stands; an edit   // that clears a row's tags leaves `[]` on it, which is the sidecar's "cleared".@@ -4528,14 +4550,17 @@ function addedOut(r){   ['type','uid','id','name','start','end','date','phase','owner','status','description','deps','note','provenance','tags','effort'].forEach(k=>{if(r[k]!==undefined)o[k]=cloneV(r[k]);});   return o; }-// A created task's id, as the server settled it (two people making a row of the same name-// in the same second). Dependencies name tasks by id, so they follow.+// A created row's id, as the server settled it (two people making a row of the same name+// in the same second). Dependencies name rows by id, a task's or a milestone's, so they follow. function renameRowId(r,id){   const old=r.id;   if(!old||!id||old===id)return;   items.forEach(o=>{ if(Array.isArray(o.deps)&&o.deps.includes(old))o.deps=o.deps.map(d=>d===old?id:d); });   if(barEls[old]){ barEls[id]=barEls[old]; delete barEls[old]; if(barEls[id].bar)barEls[id].bar.dataset.id=id; }-  if(r._lDiv)r._lDiv.dataset.id=id;+  // A task's name cell carries its id; a milestone's carries none, which is how the label+  // column tells the two apart, so it is left without one.+  if(r._lDiv&&r.type!=='milestone')r._lDiv.dataset.id=id;+  if(depPending===old)depPending=id;   if(selected.has(old)){ selected.delete(old); selected.add(id); }   r.id=id; }@@ -4847,7 +4872,7 @@ function nameOf(id){const r=items.find(d=>d.id===id);return r?r.name:id;} function phaseNameAt(i){const p=phases[i];return p?p.name:String(i);} function planStartOf(snap){let m=null;Object.values(snap.byId).forEach(v=>{if(v.start&&(!m||v.start<m))m=v.start;});return m;} function snapTask(r){ return {start:r.start,end:r.end,deps:(r.deps||[]).slice(),note:r.note||'',owner:r.owner||'',status:statusOf(r),desc:r.description||'',prov:provSig(r),provL:provLabel(r),name:r.name||'',phase:r.phase,tags:ownTags(r).slice(),eff:effortOf(r)}; }-function snapMs(r){ return {name:r.name,date:r.date,owner:r.owner||'',status:r.status||'',desc:r.description||'',prov:provSig(r),provL:provLabel(r),phase:r.phase,tags:ownTags(r).slice()}; }+function snapMs(r){ return {name:r.name,date:r.date,owner:r.owner||'',status:r.status||'',desc:r.description||'',prov:provSig(r),provL:provLabel(r),phase:r.phase,tags:ownTags(r).slice(),deps:(r.deps||[]).slice(),id:r.id||''}; } // Somebody else's field edits, written into the baseline so the next diff does not log // them as this person's. Whole rows only; anything that moved the plan's shape takes a // fresh snapshot instead.@@ -4859,7 +4884,8 @@ function patchSnap(rows){ } function snapSemantic(){   const byId={};-  items.forEach(r=>{if(r.id)byId[r.id]=snapTask(r);});+  // Tasks. A milestone has an id since 0.78.0 and is still diffed below, by its row key.+  items.forEach(r=>{if(r.id&&r.type!=='milestone')byId[r.id]=snapTask(r);});   // Keyed by the stable row key, not by the name. Keying by name made a rename   // look like one milestone vanishing and another appearing, so it logged nothing   // (both sides early-return on an unseen name) and instead surfaced as a phantom@@ -4942,7 +4968,7 @@ function diffAndLog(via){                 from:{start:planStartOf(lastSnap)},to:{start:planStart()}},...kept);     }   }-  // milestones: keyed by the stable row key, since they carry no id+  // milestones: keyed by the stable row key, which they have always had (an id only since 0.78.0)   const curMs=cur.ms,oldMs=lastSnap.ms||{};   Object.keys(oldMs).forEach(k=>{ if(!curMs[k])evs.push({action:'delete',ms:true,key:k,name:oldMs[k].name||k}); });   Object.keys(curMs).forEach(k=>{ if(!oldMs[k]){@@ -4967,6 +4993,10 @@ function diffAndLog(via){     if((a.prov||'')!==(b.prov||'')) evs.push({action:'provenance',ms:true,key:k,name:nm,from:{label:a.provL||''},to:{label:b.provL||''}});     if(a.phase!==b.phase) evs.push({action:'phase',ms:true,key:k,name:nm,from:{phase:phaseNameAt(a.phase)},to:{phase:phaseNameAt(b.phase)}});     if(!sameTags(a.tags,b.tags)) evs.push({action:'tags',ms:true,key:k,name:nm,from:{tags:a.tags||[]},to:{tags:b.tags}});+    // What a gate waits on (0.78.0), in the same two events a task's dependencies make.+    const ad=a.deps||[], bd=b.deps||[], mid=b.id||undefined;+    bd.filter(d=>!ad.includes(d)).forEach(d=>evs.push({action:'dep.add',ms:true,key:k,id:mid,name:nm,dep:d,depName:nameOf(d)}));+    ad.filter(d=>!bd.includes(d)).forEach(d=>evs.push({action:'dep.remove',ms:true,key:k,id:mid,name:nm,dep:d,depName:nameOf(d)}));   });   // A section's tags (0.59.0). Diffed here rather than posted by the Setup page, so an   // undo and a redo of a tag edit are logged too, as every other undo is, and so one@@ -5591,14 +5621,14 @@ function settleSent(b,T){ function repaintAfter(T,range0){   const rows=[...T.rows].filter(isLive);   if(T.structure||T.phases){-    items.forEach(r=>{ if(r.id)refreshLabelRow(r); else if(r.type==='milestone')refreshMsChrome(r); });+    items.forEach(r=>{ if(r.type==='milestone')refreshMsChrome(r); else if(r.id)refreshLabelRow(r); });     Object.keys(barEls).forEach(paintBar);     recolorByPhase();     if(T.phases)refreshSections();     rebuildLegend(); applyCollapse(); applyFilter();   } else rows.forEach(r=>{-    if(r.id){ paintBar(r.id); refreshLabelRow(r); if(T.fields.has('phase')&&barEls[r.id])barEls[r.id].bar.style.background=phaseColor(r.phase); }-    else if(r.type==='milestone'){ refreshMsChrome(r); paintMilestone(r); if(r._msLine)r._msLine.style.color=phaseColor(r.phase); }+    if(r.type==='milestone'){ refreshMsChrome(r); paintMilestone(r); if(r._msLine)r._msLine.style.color=phaseColor(r.phase); }+    else if(r.id){ paintBar(r.id); refreshLabelRow(r); if(T.fields.has('phase')&&barEls[r.id])barEls[r.id].bar.style.background=phaseColor(r.phase); }   });   if(tagsReady&&(T.reg||T.fields.has('tags')||T.structure||T.phases)&&(T.reg||tagSigNow()!==tagSig))afterTagsChange();   if(T.dates||T.cal){ renderDatesCard(); renderCalCard(); }@@ -5773,7 +5803,7 @@ window.__collab={pause:stopLive,resume:startLive,people:()=>livePeople.slice(),m   set:(uid,f,v)=>{ const r=liveRowOf(uid); if(!r||!ROW_F.includes(f))return false;     if(r.type==='milestone')snapshotMs(r); else snapshot(r.id);     setOvField(r,f,cloneV(v));-    if(r.id){ updateBar(r.id); refreshLabelRow(r); } else { refreshMsChrome(r); updateMilestone(r); }+    if(r.type==='milestone'){ refreshMsChrome(r); updateMilestone(r); } else { updateBar(r.id); refreshLabelRow(r); }     if(f==='tags')afterTagsChange();     scheduleSave('edit'); return true; },   move:(uid,after)=>{ const r=liveRowOf(uid), a=after?liveRowOf(after):null;@@ -7165,6 +7195,48 @@ function roundedPath(pts,rad){ // the hook's middle run is snapped to a row boundary instead of landing wherever the // midpoint of the two rows happened to fall. +// A milestone's diamond as it is drawn, in the coordinates the arrows use, or null when it+// has no box (the lines style draws no diamond; a collapsed row has none). It is a rotated+// square, so its box is not the box CSS laid out: the 45 degree turn happens about the+// centre and the translateX(-50%) rides along with it. Its points are `half` either side+// of the centre.+function msDiamondBox(r){+  const bd=r&&r._bDiv, d=r&&r._msEl;+  if(!bd||!bd.offsetHeight||!d||!d.offsetWidth)return null;+  const w=d.offsetWidth,h=d.offsetHeight,off=w*Math.SQRT1_2/2,half=Math.hypot(w,h)/2;+  return {cx:d.offsetLeft+w/2-off, cy:bd.offsetTop+d.offsetTop+h/2-off, half};+}+// Where an arrow meets a row, now that a milestone is an end of one too (0.78.0), in the+// layer's coordinates, or null when the row is not on screen to be pointed at.+//   a task       out of its bar's end and into its start, at the middle of its row+//   a milestone  out of the diamond's right point and into its left one+//   lines style  a milestone has no row there, only its dashed rule down the whole chart,+//                so the arrow meets the RULE at the height of the row at its other end+//                (`rule`, the caller picks the y), and between two milestones it runs+//                from one cap to the next, in the band at the top where the caps are.+function depEnd(r){+  if(!r||r._collapsed)return null;+  if(r.type==='milestone'){+    if(prefs.msStyle==='line'){+      const ln=r._msLine, cap=ln&&ln.querySelector('.ms-cap');+      if(!ln||!ln.isConnected||!cap||!cap.offsetHeight)return null;+      const x=ln.offsetLeft, cl=x+cap.offsetLeft-cap.offsetWidth/2, cy=ln.offsetTop+cap.offsetTop+cap.offsetHeight/2;+      return {rule:x,capIn:[cl,cy],capOut:[cl+cap.offsetWidth,cy]};+    }+    const m=msDiamondBox(r); if(!m)return null;+    return {out:[m.cx+m.half,m.cy],in:[m.cx-m.half,m.cy]};+  }+  const be=r.id&&barEls[r.id]; if(!be)return null;+  const row=sb0(be), b=be.bar, y=row.offsetTop+row.offsetHeight/2;+  return {out:[b.offsetLeft+b.offsetWidth,y],in:[b.offsetLeft,y]};+}+// The two ends of one arrow, from what comes first (s) to what waits on it (t).+function depEndsOf(s,t){+  if(s.rule!==undefined&&t.rule!==undefined)return [s.capOut,t.capIn];+  if(s.rule!==undefined)return [[s.rule,t.in[1]],t.in];+  if(t.rule!==undefined)return [s.out,[t.rule,s.out[1]]];+  return [s.out,t.in];+} // Every bar body on screen, as a rectangle in the coordinates the arrows are drawn in: // x off the bar's own offsets, y off its ROW's, which is what the endpoints below are // already measured with. A collapsed or hidden row has no box and is no obstacle; a@@ -7183,13 +7255,10 @@ function depRects(){       obs.push({id:r.id,x0:b.offsetLeft,x1:b.offsetLeft+b.offsetWidth,y0,y1:y0+h});       return;     }-    // A milestone diamond is a rotated square, so its box is not the box CSS laid out:-    // the 45 degree turn happens about the centre and the translateX(-50%) rides along-    // with it. Small, but a route through one still reads as a mistake.-    const d=r._msEl; if(!d||!d.offsetWidth)return;-    const w=d.offsetWidth,h=d.offsetHeight,off=w*Math.SQRT1_2/2,half=Math.hypot(w,h)/2;-    const cx=d.offsetLeft+w/2-off, cy=top+d.offsetTop+h/2-off;-    obs.push({id:null,x0:cx-half,x1:cx+half,y0:cy-half,y1:cy+half});+    // A milestone's diamond. Its own arrows (0.78.0) leave and arrive at its points, so it+    // carries its id like a bar does and is not in their way at the points they use.+    const m=msDiamondBox(r); if(!m)return;+    obs.push({id:r.id||null,x0:m.cx-m.half,x1:m.cx+m.half,y0:m.cy-m.half,y1:m.cy+m.half});   });   // Never claim more clearance than the layout has. The row height and the bar height   // both scale with the text size, and so does the band between them.@@ -7259,6 +7328,14 @@ function depHit(obs,self,pad,ax,ay,bx,by){ // it since 0.38.0 and a redraw that changes nothing changes nothing. function depPts(x1,y1,x2,y2,ctx,self){   const xa=x1+DEP_STUB, xb=x2-DEP_STUB, pad=ctx.pad;+  // Both ends on one row, running backwards: a milestone's rule in the lines style meets an+  // arrow at the height of its other end (0.78.0), and a task that starts before the gate it+  // waits on has its start on the wrong side of the rule. No lane lies between the two ends+  // to hook through, so the hook goes through the boundary below the row.+  if(Math.abs(y2-y1)<0.5&&xb<xa){+    let yb=Infinity; for(const y of ctx.lanes)if(y>y1+0.5&&y<yb)yb=y;+    if(yb!==Infinity){ const back=Math.min(xb,xa-DEP_STUB); return [[x1,y1],[xa,y1],[xa,yb],[back,yb],[back,y2],[x2,y2]]; }+  }   const lo=Math.min(y1,y2),hi=Math.max(y1,y2);   // Every corner of every route below sits at y1, y2, or a lane between them, so a bar   // outside that band cannot be in the way of any of them. Throwing those away first is@@ -7517,19 +7594,23 @@ function drawDeps(){   // (0.75.1): the old loop read a bar's position right after writing the previous arrow,   // which forced a fresh layout per read, hundreds per redraw.   const ctx=depCtx(), edges=[];-  items.forEach(r=>{if(!r.deps||!r.id)return;const tgt=barEls[r.id];if(!tgt)return;-    r.deps.forEach(depId=>{const src=barEls[depId];if(!src)return;-      const sItem=items[src.idx];-      if(!sItem)return;+  // Either end may be a task or a milestone (0.78.0), so the rows are found by id here+  // rather than through the bars, which only tasks have.+  const rowById=new Map(); items.forEach(x=>{ if(x.id&&x.type!=='group'&&!rowById.has(x.id))rowById.set(x.id,x); });+  const ends=new Map(), endOf=x=>{ if(!ends.has(x))ends.set(x,depEnd(x)); return ends.get(x); };+  items.forEach(r=>{if(!r.deps||!r.deps.length||!r.id||r.type==='group'||!r._bDiv)return;+    r.deps.forEach(depId=>{const sItem=rowById.get(depId);+      if(!sItem||sItem===r||!sItem._bDiv)return;       if(r._bDiv.classList.contains('dim')||sItem._bDiv.classList.contains('dim'))return;       // a collapsed row has no position on screen, so an arrow to it would be       // drawn from nowhere to nowhere       if(r._collapsed||sItem._collapsed)return;       // Measure the row rather than assuming 17, which was half of a 34px row and       // put every arrow in the wrong place the moment the text scale moved it.-      const sr=sb0(src),tr=sb0(tgt);-      const sb=src.bar,tb=tgt.bar;-      edges.push({depId,to:r.id,sItem,x1:sb.offsetLeft+sb.offsetWidth,y1:sr.offsetTop+sr.offsetHeight/2,x2:tb.offsetLeft,y2:tr.offsetTop+tr.offsetHeight/2});+      const S=endOf(sItem),T=endOf(r);+      if(!S||!T)return;+      const [[x1,y1],[x2,y2]]=depEndsOf(S,T);+      edges.push({depId,to:r.id,sItem,x1,y1,x2,y2});     });});   const frag=document.createDocumentFragment(); let maxY=0;   edges.forEach(({depId,to,sItem,x1,y1,x2,y2})=>{@@ -7879,21 +7960,17 @@ async function pasteRows(){   };   // Pass one: build every clone, and record the id it answers to so pass two can   // remap the links onto it.-  const idMap=new Map();                       // original task id -> clone's new id+  const idMap=new Map();                       // original row id -> clone's new id   const clones=clipRows.map((c,i)=>{     const name=copyName(c.name,tgt.phase,taken);     taken.push(name);     const r={type:c.type,uid:uids[i],name,phase:tgt.phase};-    // A milestone this app makes has no id, and the create panel is where that rule-    // is set; a clone follows it. Nothing can depend on an id-less row, so a copied-    // dependency that named a copied MILESTONE keeps naming the original, which is-    // still in the plan and still the thing the work waits on.+    // Every clone gets its own id, a milestone's too (0.78.0), and is remapped below+    // like a task: a copied chain through a gate is a chain through the copied gate.+    r.id=reserveId(name);+    if(c.srcId)idMap.set(c.srcId,r.id);     if(c.type==='milestone'){ r.date=c.date; }-    else{-      r.id=reserveId(name);-      r.start=c.start; r.end=c.end;-      if(c.srcId)idMap.set(c.srcId,r.id);-    }+    else{ r.start=c.start; r.end=c.end; }     if(c.owner)r.owner=c.owner;     if(c.status)r.status=c.status;     if(c.description)r.description=c.description;@@ -8240,13 +8317,14 @@ bars.addEventListener('click',e=>{   // of scrolls later did nothing at all.   if(Date.now()-dragEndedAt<150)return;   // While the mode is on, a click on the chart is a dependency click. No editor-  // opens, nothing is selected, and a click on a milestone says why rather than-  // quietly doing nothing.+  // opens and nothing is selected. A milestone is picked like a bar (0.78.0), by its+  // diamond or, in the lines style, by the cap on its rule.   if(depMode){     e.stopPropagation();     const b=e.target.closest('.bar[data-id]');     if(b){ setDepSel(null); depClickBar(b.dataset.id); return; }-    if(e.target.closest('.ms-hit, .ms-cap')){ flash(saveInd,'milestones cannot be dependencies here',2600); return; }+    const mh=e.target.closest('.ms-hit, .ms-cap');+    if(mh){ const mr=rowByUid(mh.dataset.msuid); setDepSel(null); depClickBar(mr&&mr.id); return; }     // Nothing solid under the pointer, so ask the arrows. A half-made pair and a picked     // arrow are two answers to the same question and cannot both be live, so picking     // one drops the other.@@ -9526,9 +9604,7 @@ function paintMilestone(r){   syncEditorDates(r);   return true; }-function snapshotMs(r){-  pushUndo({type:'ms',r,date:r.date,name:r.name,owner:r.owner,status:r.status,desc:r.description,prov:r.provenance,tags:tagsSnap(r)});-}+function snapshotMs(r){ pushUndo(msSnap(r)); } // Milestone chrome that is not the date: the label row, the tooltip body and the // dashed rule's tag all restate the milestone, so they move together. function refreshMsChrome(r){@@ -9919,18 +9995,27 @@ document.addEventListener('mouseup',()=>{ // it, and keep going. Chaining is the whole point, so the mode survives each pair // and only Esc or the button turns it off. //-// Milestones are deliberately not endpoints. A dependency is stored as an id in the-// dependent row's `deps`, `barEls` is keyed by id, and a milestone in the base data-// file has no id at all, so a dep pointing at one could not be drawn, could not be-// listed and would not survive an export. Rather than invent an id namespace for-// milestones inside this feature, the mode says so and leaves them alone.+// A milestone is an end like a task (0.78.0), first click or second: a gate waits on+// the work before it, and the work after it waits on the gate. A dependency is stored+// as an id in the waiting row's `deps`, and until then a milestone had no id, so a dep+// on one could not be drawn, listed or exported and the mode refused milestones. The+// server gives every milestone an id now, made from its name the way a task's is. On+// the chart a milestone is picked by its diamond, or in the lines style by its cap. let depMode=false, depPending=null; // A picked ARROW, and an arrow armed for removal. Both are {from,to}: `from` is the-// task that comes first (the master) and `to` is the one that waits on it, which is+// row that comes first (the master) and `to` is the one that waits on it, which is // the row whose `deps` array holds the edge. Both live only in the mode. let depSel=null, depArm=null; function sameDep(a,b){ return !!(a&&b&&a.from===b.from&&a.to===b.to); }-function depIsEligible(r){ return !!(r && r.id && r.type!=='milestone'); }+// Any task or milestone with an id. A milestone without one is a plan nothing has given+// ids to yet (a snapshot exported before 0.78.0 gets them as it opens).+function depIsEligible(r){ return !!(r && r.id && r.type!=='group'); }+// What wears the armed mark: a task's bar, a milestone's diamond and the cap on its rule.+function depMarkEls(id){+  const be=id&&barEls[id]; if(be)return [be.bar];+  const r=id&&items.find(d=>d.id===id&&d.type==='milestone');+  return r?[r._msEl,r._msLine&&r._msLine.querySelector('.ms-cap')].filter(Boolean):[];+} // Deps are "needs" edges: r.deps lists what r waits on. Adding master to dep.deps // makes dep wait on master, which closes a loop if master already waits on dep, // directly or through any chain. Breadth-first so the path in the hint is the@@ -9950,9 +10035,9 @@ function depPathTo(fromId,targetId){ } function depName(id){ const r=items.find(d=>d.id===id); return r?(r.name||id):id; } function setDepPending(id){-  if(depPending){const b=barEls[depPending];if(b)b.bar.classList.remove('dep-pending');}+  if(depPending)depMarkEls(depPending).forEach(el=>el.classList.remove('dep-pending'));   depPending=id||null;-  if(depPending){const b=barEls[depPending];if(b)b.bar.classList.add('dep-pending');}+  if(depPending)depMarkEls(depPending).forEach(el=>el.classList.add('dep-pending')); } // ── Picking a dependency arrow ── //@@ -10130,11 +10215,11 @@ function depHoverResync(){ } // The pointer has left the chart, so there is no last position inside it to resync to. function depHoverLeave(){ depHoverX=-1; depHoverY=-1; clearDepHover(); }-// One click in the mode. Returns nothing; every refusal says why in the same place-// every other refusal in this app says why.+// One click in the mode, on a task's bar or a milestone (0.78.0), by its id. Returns+// nothing; every refusal says why in the same place every other refusal in this app says why. function depClickBar(id){-  const r=items.find(d=>d.id===id);-  if(!depIsEligible(r)){ flash(saveInd,'milestones cannot be dependencies here',2600); return; }+  const r=id?items.find(d=>d.id===id):null;+  if(!depIsEligible(r)){ flash(saveInd,'this row has no id yet, so it cannot be linked: reload the plan',3200); return; }   if(!depPending){ setDepPending(id); return; }   if(depPending===id){ setDepPending(null); flash(saveInd,'pick cleared'); return; }   const masterId=depPending, depId=id;@@ -10482,7 +10567,7 @@ $('btnHelp').addEventListener('click',e=>{     <div class="dd-item" style="cursor:default"><span style="color:var(--accent);min-width:90px;font-family:'JetBrains Mono',monospace;font-size:10px">Up/Down</span>Move the selected row in the table. The pane only scrolls to keep it in view</div>     <div class="dd-item" style="cursor:default"><span style="color:var(--accent);min-width:90px;font-family:'JetBrains Mono',monospace;font-size:10px">Enter</span>Edit the selected row's name. In a cell, commit and step down a row</div>     <div class="dd-item" style="cursor:default"><span style="color:var(--accent);min-width:90px;font-family:'JetBrains Mono',monospace;font-size:10px">Delete</span>Delete the selected row. Undoable. On the chart it takes two presses: the first says what would go and marks it, the second does it</div>-    <div class="dd-item" style="cursor:default"><span style="color:var(--accent);min-width:90px;font-family:'JetBrains Mono',monospace;font-size:10px">Shift+D</span>Add dependencies: click the task that comes first, then the one that waits on it</div>+    <div class="dd-item" style="cursor:default"><span style="color:var(--accent);min-width:90px;font-family:'JetBrains Mono',monospace;font-size:10px">Shift+D</span>Add dependencies: click the task or milestone that comes first, then the one that waits on it</div>     <div class="dd-item" style="cursor:default"><span style="color:var(--accent);min-width:90px;font-family:'JetBrains Mono',monospace;font-size:10px">Shift+N</span>New task or milestone</div>     <div class="dd-item" style="cursor:default"><span style="color:var(--accent);min-width:90px;font-family:'JetBrains Mono',monospace;font-size:10px">Ctrl+C</span>Copy the selected rows. Also puts their IDs on the system clipboard for an AI chat</div>     <div class="dd-item" style="cursor:default"><span style="color:var(--accent);min-width:90px;font-family:'JetBrains Mono',monospace;font-size:10px">Ctrl+V</span>Paste them below the selected row, or at the end of the lane you are looking at if nothing is selected. Dependencies among the copied rows are remapped to the copies; one Ctrl+Z takes the whole paste back</div>@@ -10655,13 +10740,20 @@ function wirePlanSpanClock(){ // // `cancelled` is the rows the live reading dropped that would otherwise have been // nodes, in items order. It is what the Auto-schedule toast counts.+//+// A milestone is a node with its date as both its start and its end, and since 0.78.0 it+// is a node with an id, so it is an end of edges like any task: it waits on the work+// before it and the work after it waits on it. That is what makes it a GATE. Auto-schedule+// moves it to the day its last predecessor ends when that work slips, and a task waiting+// on it starts on its date at the earliest, the same rule as a task after a task. It weighs+// nothing on the critical path, so a chain through a gate is as long as the work in it. function depGraph(skipCancelled){   const nodes=[],byId=new Map(),byRow=new Map(),cancelled=[];   items.forEach(r=>{     if(r.type==='group')return;     let n=null;-    if(r.id&&r.start&&r.end)n={r,id:r.id,ms:false,start:r.start,end:r.end};-    else if(r.type==='milestone'&&r.date)n={r,id:null,ms:true,start:r.date,end:r.date};+    if(r.type==='milestone'){ if(r.date)n={r,id:r.id||null,ms:true,start:r.date,end:r.date}; }+    else if(r.id&&r.start&&r.end)n={r,id:r.id,ms:false,start:r.start,end:r.end};     if(!n)return;     // Tested AFTER the dated check, so the count is of rows that were really in the     // graph and not of every cancelled row in the plan.@@ -11403,30 +11495,34 @@ function openItemModal(r,carriedName,opts){   $('imOwner').value=r.owner||'';   $('imEffWrap').style.display=ms?'none':'';   $('imEffort').value=effortOf(r)===null?'':String(effortOf(r));-  // A milestone has no bar to label and is never a dependency endpoint (a dep names a-  // task id), so both are hidden for it, the way its start field is.+  // A milestone has no bar to label, so that field is hidden for it, the way its start+  // field is. Its dependencies are shown (0.78.0): a gate waits on work and work waits on+  // it. Only a row with no id (a milestone in a plan nothing has given ids to) has none.+  const canDep=!!r.id;   $('imNoteWrap').style.display=ms?'none':'';-  $('imDepsWrap').style.display=ms?'none':'';+  $('imDepsWrap').style.display=canDep?'':'none';   $('imNote').value=r.note||'';   // The dependencies are a DRAFT, like every other field here: nothing lands until Apply,   // and Cancel or Escape leaves the plan as it was. (The small bar panel this replaces   // wrote each add and remove at once, the one field in the app that ignored Cancel.)-  // `needs` is what this task waits on; a "blocks" line is another task waiting on this-  // one, and its x takes this task out of THAT task's list on Apply.-  let dNeeds=ms?[]:[...(r.deps||[])];+  // `needs` is what this row waits on; a "blocks" line is another row waiting on this+  // one, and its x takes this row out of THAT row's list on Apply. Either may be a task+  // or a milestone, and a milestone is named with its diamond.+  let dNeeds=canDep?[...(r.deps||[])]:[];   const dUnblock=new Set();+  const depLabel=x=>(x&&(x.name||x.id)||'')+(x&&x.type==='milestone'?' ◆':'');   const renderDeps=()=>{-    if(ms)return;+    if(!canDep)return;     const list=$('imDepList'), add=$('imAddDep'); if(!list||!add)return;     const blocks=items.filter(d=>d.id&&d.deps&&d.deps.includes(r.id)&&!dUnblock.has(d.id));     const li=(dir,id,name)=>`<li><span class="dep-dir ${dir}">${dir==='in'?'← needs':'→ blocks'}</span><span class="dep-nm" title="${esc(name)}">${esc(name)}</span>`-      +`<span class="dep-rm" role="button" tabindex="0" data-dep="${esc(id)}" data-dir="${dir}" title="${dir==='in'?'This task stops waiting on it':'It stops waiting on this task'}. Lands on Apply">×</span></li>`;-    list.innerHTML=dNeeds.map(id=>li('in',id,depName(id))).join('')+blocks.map(b=>li('out',b.id,b.name||b.id)).join('')+      +`<span class="dep-rm" role="button" tabindex="0" data-dep="${esc(id)}" data-dir="${dir}" title="${dir==='in'?'This one stops waiting on it':'It stops waiting on this one'}. Lands on Apply">×</span></li>`;+    list.innerHTML=dNeeds.map(id=>li('in',id,depLabel(items.find(d=>d.id===id))||id)).join('')+blocks.map(b=>li('out',b.id,depLabel(b))).join('')       ||'<li class="dep-none">No dependencies.</li>';-    // Offered: every other task this one does not wait on yet, minus any that would close-    // a loop (one that already waits on this task, directly or through a chain).-    const cand=items.filter(t=>t.id&&t.id!==r.id&&t.type!=='milestone'&&!dNeeds.includes(t.id)&&!depPathTo(t.id,r.id));-    add.innerHTML=`<option value="">+ waits on...</option>`+cand.map(t=>`<option value="${esc(t.id)}">${esc(t.name||t.id)}</option>`).join('');+    // Offered: every other task and milestone this one does not wait on yet, minus any that+    // would close a loop (one that already waits on this row, directly or through a chain).+    const cand=items.filter(t=>t.id&&t.id!==r.id&&t.type!=='group'&&!dNeeds.includes(t.id)&&!depPathTo(t.id,r.id));+    add.innerHTML=`<option value="">+ waits on...</option>`+cand.map(t=>`<option value="${esc(t.id)}">${esc(depLabel(t))}</option>`).join('');     add.value='';     list.querySelectorAll('.dep-rm').forEach(b=>{       const go=()=>{ if(b.dataset.dir==='in')dNeeds=dNeeds.filter(d=>d!==b.dataset.dep); else dUnblock.add(b.dataset.dep); renderDeps(); };@@ -11437,7 +11533,7 @@ function openItemModal(r,carriedName,opts){   renderDeps();   // On `apppick` (a click on the list, or Enter), not on `change`: the arrows walk this   // list too, and every step of them is a change (0.66.1).-  if(!ms)$('imAddDep').addEventListener('apppick',function(){ if(this.value&&!dNeeds.includes(this.value)){ dNeeds.push(this.value); renderDeps(); $('imAddDep').focus(); } });+  if(canDep)$('imAddDep').addEventListener('apppick',function(){ if(this.value&&!dNeeds.includes(this.value)){ dNeeds.push(this.value); renderDeps(); $('imAddDep').focus(); } });   $('imStatus').value=statusOf(r);   $('imDesc').value=r.description||'';   { const cl=$('imCopyLink'); if(cl){ cl.textContent=r.uid?'Copy link #'+r.uid:'Copy link'; cl.addEventListener('click',e=>{ e.preventDefault(); copyRowLink(r,cl); }); } }@@ -11454,7 +11550,7 @@ function openItemModal(r,carriedName,opts){   showEditor(px,rect.top,false);   ['imPhase','imStatus','imAddDep'].forEach(id=>{ const el=$(id); if(el)wireAppSelect(el); });   wireOwnerCombo($('imOwner'));-  if(opts.focus==='deps'&&!ms)$('imAddDep').focus();+  if(opts.focus==='deps'&&canDep)$('imAddDep').focus();   else{ $('imName').focus();$('imName').select(); }   setEditorKeys(e=>{e.stopPropagation();     if(e.key==='Enter'&&e.target.tagName!=='BUTTON'){e.preventDefault();$('imApply').click();}@@ -11471,8 +11567,9 @@ function openItemModal(r,carriedName,opts){     const moves=!!phases[np]&&np!==r.phase;     // The rows a "blocks" x changes are snapshotted WITH this one, so the whole Apply is     // still one Ctrl+Z (snapshotMany makes one entry of several rows).-    const unblocked=ms?[]:[...dUnblock].map(id=>items.find(d=>d.id===id)).filter(Boolean);-    if(ms)snapshotMs(r); else snapshotMany([r.id,...unblocked.map(b=>b.id)]);+    // A milestone in that list, or as the row itself, takes a milestone's entry (msSnap).+    const unblocked=canDep?[...dUnblock].map(id=>items.find(d=>d.id===id)).filter(Boolean):[];+    if(!r.id)snapshotMs(r); else snapshotMany([r.id,...unblocked.map(b=>b.id)]);     // ONE Ctrl+Z takes the whole Apply back, the section included. The field snapshot     // does not carry the lane or the row's place in the plan, and moveRowToPhase records     // nothing of its own, so until 0.57.1 an undo restored every field and left the row@@ -11496,8 +11593,8 @@ function openItemModal(r,carriedName,opts){     setOpt(r,'description',$('imDesc').value.trim());     if(!ms)setEffort(r,eff);     let depsMoved=false;-    if(!ms){-      r.note=$('imNote').value.trim();+    if(!ms)r.note=$('imNote').value.trim();+    if(canDep){       if(JSON.stringify(dNeeds)!==JSON.stringify(r.deps||[])){ r.deps=dNeeds.slice(); depsMoved=true; }       unblocked.forEach(b=>{ b.deps=(b.deps||[]).filter(d=>d!==r.id); depsMoved=true; });     }@@ -11653,9 +11750,12 @@ function openCreateEditor(){       return;     }     creating=false;+    // A milestone gets an id like a task (0.78.0), so a dependency can name it. The server+    // makes it unique if someone else made one of the same name in the same second.     const r={type:isMs?'milestone':'task',uid,name,phase};+    r.id=freshId(name);     if(isMs){ r.date=end; }-    else { r.id=freshId(name); r.start=start<=end?start:end; r.end=end; }+    else { r.start=start<=end?start:end; r.end=end; }     if(!isMs)r._key=undefined;     if(owner)r.owner=owner;     if(st)r.status=st;@@ -12138,11 +12238,12 @@ function rowItem(key){ // already true on the chart when you toggle back. function refreshLabelRow(r){   if(!r||!r._lDiv)return;-  // The cancel mark first, and outside the id guard: a milestone reaches this-  // function from the bulk restate at load and has no id to pass the guard with,-  // and its row goes quiet for the same reason a task's does.+  // The cancel mark first, and outside the guard: a milestone reaches this function+  // from the bulk restate at load, and its row goes quiet for the same reason a task's+  // does. Its name is refreshMsChrome's to draw (it has an id since 0.78.0, so the guard+  // asks what it is rather than whether it has one).   syncCancelMark(r);-  if(!r.id)return;+  if(!r.id||r.type==='milestone')return;   // into the name span only: rewriting the row would take the edit button with it   setHtmlIfChanged(r._nameSpan||r._lDiv,nameInner(r)); }@@ -13066,7 +13167,7 @@ function listNavKey(sel,e,open){ // for undo, the chart and bar refreshed, and a save whose diff logs the status // event. So a status changed here and one changed in the table are the same edit. function openStatusPill(r,pillEl){-  if(!r||!r.id)return;+  if(!r||!r.id||r.type==='milestone')return;   closeCellList(); closeLabelEditors(true);   const sel=document.createElement('select');   sel.innerHTML=STATUS_OPTS.map(x=>`<option value="${x}">${statusLabel[x]}</option>`).join('');@@ -13266,7 +13367,7 @@ function wireOwnerCombo(inp,onPick){ // are an open set, so anyone already on the plan is a pick and a new name is typed. The // moment it commits, distinctOwners picks it up for every other owner picker. function openOwnerPill(r,pillEl){-  if(!r||!r.id)return;+  if(!r||!r.id||r.type==='milestone')return;   closeCellList(); closeLabelEditors(true); closeOwnerCombo();   const old=document.querySelector('.pill-owner-inp'); if(old)old.remove();   // Since 0.66.1 the pill opens THE owner picker (the one the editor and the Table use):@@ -13592,8 +13693,9 @@ tableBody.addEventListener('click',e=>{     // escapes it by being in that handler's allowlist; this one stops here instead.     e.stopPropagation();     const tr=dc.closest('tr'); const r=rowItem(tr&&tr.dataset.key);-    if(r&&r.id&&barEls[r.id]){ setFocusedRow(tr.dataset.key); openItemModal(r,undefined,{anchor:dc.getBoundingClientRect(),focus:'deps'}); }-    else if(r) flash(saveInd,'only tasks carry dependencies',2400);+    // A task or a milestone (0.78.0); only a row with no id yet has none to show.+    if(r&&r.id&&(barEls[r.id]||isMs(r))){ setFocusedRow(tr.dataset.key); openItemModal(r,undefined,{anchor:dc.getBoundingClientRect(),focus:'deps'}); }+    else if(r) flash(saveInd,'this row has no id yet, so it cannot be linked: reload the plan',2400);     return;   }   if(selected.size)clearSelection();     // a plain click collapses the set, as on the chart@@ -14248,7 +14350,7 @@ if(!SNAPSHOT)try{const r=await fetch('state');if(r.ok){const st=await r.json();i initShadow(); // The sidecar can carry a status or a description that the chart's own label rows // and tooltips were built from the data file with, so restate them.-items.forEach(r=>{ if(r.id)refreshLabelRow(r); else if(r.type==='milestone')refreshMsChrome(r); });+items.forEach(r=>{ if(r.type==='milestone')refreshMsChrome(r); else if(r.id)refreshLabelRow(r); }); // The chevron counts were written during the build, before the sidecar was merged, // so they reported the data file's lanes and not the plan's. Re-read them now that // the created rows, the deletions and the phase overrides are all in.@@ -14344,6 +14446,7 @@ async function init(){   buildDerivedHeaders();   placeHeaders();   assignRowKeys();+  giveSnapshotMilestoneIds();   dropUndatedRows();   clampFontScalePref();   clampMsStylePref();
install.sh

  
package.json+1−1
@@ -2,7 +2,7 @@   "slug": "adom-gantt",   "title": "Adom Gantt",   "type": "app",-  "version": "0.77.1",+  "version": "0.78.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.77.1",+  "version": "0.78.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+173−22
@@ -755,7 +755,7 @@ var CHAT_DAILY_HOUR = 17; var CHAT_CADENCES = ['live', 'hourly', 'daily', 'off']; // The server's own bookkeeping is not news: the chain's genesis, uid backfills, a sidecar // upgrade. The agent's edits ARE news, and say whose they are.-var CHAT_SKIP_ACTIONS = new Set(['chain.genesis', 'uid.assign', 'state.upgrade']);+var CHAT_SKIP_ACTIONS = new Set(['chain.genesis', 'uid.assign', 'state.upgrade', 'milestone.ids']); var chatBusy = false, chatNextTry = 0, chatBackoff = 0; function chatLoad() {   try { const o = JSON.parse(fs.readFileSync(CHAT_FILE, 'utf8')); return o && typeof o === 'object' && !Array.isArray(o) ? o : {}; }@@ -1178,6 +1178,9 @@ function describeEvent(e) {     const re = e.reminted ? `, ${e.reminted} re-issued after a clash` : '';     return `${who} gave ${e.count || 0} row(s) a permanent uid${re}: ${e.live || 0} in the plan, ${e.retired || 0} already deleted and retired with theirs. From here a uid is the handle to quote at this app, and it never moves or gets reused. Next is ${e.nextUid || '?'}${via}`;   }+  if (action === 'milestone.ids') {+    return `${who} gave ${e.count || 0} milestone(s) a permanent id, made from its name the way a task's is, so dependencies can name them${via}`;+  }   return `${who} ${action} ${name}${via}`; } @@ -1412,15 +1415,18 @@ function normalizeCalendar(v) { // the values as stored, which is what lets every tab converge on exactly this file. const OPS_MAX = 5000; const OP_DAY = /^\d{4}-\d{1,2}(-\d{1,2})?$/;-const OP_TASK_ONLY = new Set(['s', 'e', 'deps', 'note', 'effort']);+// Fields a milestone does not have. Dependencies are not among them since 0.78.0: a milestone+// waits on work, and work waits on it.+const OP_TASK_ONLY = new Set(['s', 'e', 'note', 'effort']); const OP_TEXT_MAX = { note: 2000, owner: 200, status: 60, desc: 20000, name: 500 }; // A row as a full sidecar override, the shape the page has always written for every row, // so an entry the server creates for a row that had none reads like any other. function overrideOf(r) {   const o = { uid: r.uid, owner: r.owner || '', status: r.status || '', desc: r.description || '',     prov: r.provenance === undefined ? '' : r.provenance, name: r.name, phase: r.phase };+  o.deps = Array.isArray(r.deps) ? r.deps.slice() : [];   if (r.type === 'milestone') o.date = r.date;-  else { o.s = r.start; o.e = r.end; o.deps = Array.isArray(r.deps) ? r.deps.slice() : []; o.note = r.note || '';+  else { o.s = r.start; o.e = r.end; o.note = r.note || '';     o.effort = typeof r.effort === 'number' ? r.effort : null; }   if (Array.isArray(r.tags)) o.tags = r.tags.slice();   return o;@@ -1513,14 +1519,16 @@ function applyOpsTo(state, data, mergedByUid, ops, mayDate) {       if (!Number.isInteger(row.phase) || row.phase < 0) row.phase = 0;       if (row.deps !== undefined) row.deps = opValue('deps', row.deps) || [];       tidyTags(row); tidyEffort(row);-      // Task ids are what dependencies point at. Two people creating "Design" in the same-      // second would both mint "design"; the second one becomes "design-2" here, and its-      // tab is told so by the announcement.-      if (row.type === 'task' && typeof row.id === 'string' && row.id) {-        const ids = new Set([...mergedByUid.values()].map(r => r && r.id).filter(Boolean));+      // Ids are what dependencies point at, a task's and (0.78.0) a milestone's. Two people+      // creating "Design" in the same second would both mint "design"; the second one becomes+      // "design-2" here, and its tab is told so by the announcement. A milestone made by a page+      // from before 0.78.0 comes without one and is given one.+      const ids = new Set([...mergedByUid.values()].map(r => r && r.id).filter(Boolean));+      if (typeof row.id === 'string' && row.id) {         let id = row.id, n = 2; while (ids.has(id)) id = row.id + '-' + (n++);         row.id = id;       } else if (row.type === 'task') { refused.push('add: ' + a.uid + ' has no id'); continue; }+      else row.id = freshRowId(row.name, ids);       state.added.push(row);       state.deleted = state.deleted.filter(k => k !== row.uid);       mergedByUid.set(row.uid, Object.assign({}, row));@@ -1550,7 +1558,6 @@ function applyOpsTo(state, data, mergedByUid, ops, mayDate) {       }       // The two lists merge item by item: what this tab took out, then what it put in.       for (const f of ['deps', 'tags']) {-        if (ms && f === 'deps') continue;         const add = op.add && Array.isArray(op.add[f]) ? op.add[f] : [], del = op.del && Array.isArray(op.del[f]) ? op.del[f] : [];         if (!add.length && !del.length) continue;         const same = f === 'tags' ? (x, y) => String(x).toLowerCase() === String(y).toLowerCase() : (x, y) => x === y;@@ -1957,6 +1964,76 @@ function ensureUids(why, actor) {   return info; } +// ── Milestone ids (0.78.0) ──+//+// A dependency names the row it waits on by id. A task has always had one, a slug of the+// name it was made with. A milestone had none, so nothing could wait on a milestone and a+// milestone could wait on nothing: a gate like "EVT sign-off" was tied to the work before+// and after it by its date alone, and Auto-schedule could not move it when that work slipped.+// Every milestone has an id now, made the way a task's is (a slug of its name, numbered on+// when the plan already has that one) and kept for good: a rename changes the name and never+// the id, exactly as for a task.+//+// Given at startup to every milestone that has none (the data file's, and the created ones in+// the sidecar, deleted ones included, so an undo of an old deletion comes back with an id+// nobody else has taken since), by every import, by /save, and by /ops to a milestone a page+// creates without one (a page from before this version). Never fatal, like ensureUids.+function idSlug(t) { return String(t || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40) || 'item'; }+function freshRowId(name, taken) {+  const base = idSlug(name);+  let id = base, n = 2;+  while (taken.has(id)) id = base + '-' + (n++);+  taken.add(id);+  return id;+}+const hasRowId = r => !!r && typeof r.id === 'string' && r.id !== '';+// Every id in these lists of rows.+function planIds(...lists) {+  const s = new Set();+  lists.forEach(l => (Array.isArray(l) ? l : []).forEach(r => { if (hasRowId(r)) s.add(r.id); }));+  return s;+}+// Every milestone in `rows` without an id gets one. `taken` holds every id already in the plan+// and grows as ids are handed out. Returns how many were given.+function stampMilestoneIds(rows, taken) {+  let n = 0;+  (Array.isArray(rows) ? rows : []).forEach(r => {+    if (!r || typeof r !== 'object' || r.type !== 'milestone' || hasRowId(r)) return;+    r.id = freshRowId(r.name, taken);+    n++;+  });+  return n;+}+function ensureMilestoneIds(why, actor) {+  let data, state;+  try { data = loadData(); state = loadState(); } catch (e) {+    console.error('adom-gantt: could not read the plan to give its milestones ids:', e.message);+    return { ok: false, error: e.message };+  }+  const st = state && typeof state === 'object' && !Array.isArray(state) ? state : null;+  const added = st && Array.isArray(st.added) ? st.added : [];+  const taken = planIds(data.items, added);+  const inData = stampMilestoneIds(data.items, taken);+  const inAdded = stampMilestoneIds(added, taken);+  if (!inData && !inAdded) return { ok: true, count: 0 };+  try {+    if (inData) { backupOnce(DATA_FILE, '.pre-msid.bak'); writeDataFile(data); }+    if (inAdded) {+      backupOnce(STATE_FILE, '.pre-msid.bak');+      // Open tabs hold these rows without an id; a new revision makes them fetch the plan.+      st.stateRev = (typeof st.stateRev === 'number' ? st.stateRev : 0) + 1;+      writeAtomic(STATE_FILE, JSON.stringify(st));+    }+  } catch (e) {+    console.error('adom-gantt: could not write the milestone ids:', e.message);+    return { ok: false, error: e.message };+  }+  try { appendLog([{ action: 'milestone.ids', count: inData + inAdded, via: why || 'startup' }], actor || 'adom-gantt'); }+  catch (e) { console.error('adom-gantt: could not record the milestone ids in the changelog:', e.message); }+  console.log(`adom-gantt: gave ${inData + inAdded} milestone(s) an id`);+  return { ok: true, count: inData + inAdded };+}+ // ── The sidecar speaks uid (v4), and every older sidecar is translated on the //    way in ── //@@ -1982,6 +2059,9 @@ function upgradeState(state, data) {   // Every way a legacy sidecar could have named a row, pointed at its uid.   const uidOf = new Map();   data.items.forEach((r, i) => { if (r.uid) uidOf.set(keys[i], r.uid); });+  // A milestone has had an id since 0.78.0, and a sidecar written before that named it by+  // its row position, so that still finds it.+  data.items.forEach((r, i) => { if (r.uid && r.type === 'milestone' && !uidOf.has('#row' + i)) uidOf.set('#row' + i, r.uid); });   data.items.forEach(r => { const k = r.label || r.name; if (k && r.uid && !uidOf.has(k)) uidOf.set(k, r.uid); });   (legacy.added || []).forEach(a => {     if (!a || typeof a !== 'object' || !a.uid) return;@@ -2160,14 +2240,11 @@ function mergedData() {   });   // Deletions before overrides and before the order: the saved order was written   // against the post-deletion plan, so the order guard has to compare with that.+  let removedIds = null;   if (Array.isArray(state.deleted) && state.deleted.length) {     const gone = new Set(state.deleted);-    const removedIds = new Set(data.items.filter(r => gone.has(r.uid)).map(r => r.id).filter(Boolean));+    removedIds = new Set(data.items.filter(r => gone.has(r.uid)).map(r => r.id).filter(Boolean));     data.items = data.items.filter(r => !gone.has(r.uid));-    // dependencies on a deleted row go with it, everywhere the data is read-    if (removedIds.size) data.items.forEach(r => {-      if (Array.isArray(r.deps)) r.deps = r.deps.filter(d => !removedIds.has(d));-    });   }   // One list, one key, one lookup. This used to be two lists and a four-rung   // ladder that guessed a milestone from its name and its index, because a@@ -2188,6 +2265,14 @@ function mergedData() {     const r = byUid.get(sv.uid);     if (r) applyOverride(r, sv, phaseCount);   });+  // Dependencies on a deleted row go with it, everywhere the data is read. After the+  // overrides (0.78.0), which name rows too: a dependency added in the app lives in one, and+  // the data file's list was the only one filtered, so a row deleted with only ops behind it+  // (nothing rewrote the rows that waited on it) stayed in their lists here while every page+  // dropped it.+  if (removedIds && removedIds.size) data.items.forEach(r => {+    if (Array.isArray(r.deps)) r.deps = r.deps.filter(d => !removedIds.has(d));+  });   // Two shapes. A sidecar written since sections became editable carries the FULL   // list, every entry with a name, and it owns the sections outright: that is what   // lets a rename, a recolour and a reorder persist without ever rewriting the data@@ -2367,6 +2452,8 @@ function importJSON(body) {   }   assertDates(data.items);   tidyPlanTags(data);+  // A milestone's id is kept too, since dependencies name it (0.78.0); one with none gets one.+  stampMilestoneIds(data.items, planIds(data.items));   // A JSON import is usually this app's own export coming back, so uids the file   // carries are KEPT: an AI handed a list of numbers must still be able to use   // them after the round trip. Absent, duplicated or malformed ones are minted.@@ -2482,6 +2569,9 @@ function importCSV(body, queryString) {   };    assertDates(data.items);+  // The id column carries a milestone's id as it carries a task's, so a dependency on a+  // milestone survives the round trip; a milestone the file gives no id gets one (0.78.0).+  stampMilestoneIds(data.items, planIds(data.items));   // Same rule as the JSON import: a CSV that came out of here goes back in on   // the same numbers, and a CSV that never had a uid column gets fresh ones.   const uids = stampUids(data);@@ -2512,17 +2602,21 @@ function importMSProjectXML(body) {     const finish = get('Finish');     const pctComplete = parseInt(get('PercentComplete') || '0', 10);     const summary = get('Summary');+    // A milestone in MS Project is flagged as one (0.78.0). It comes in as a milestone, on+    // its start date, with its links to and from tasks and other milestones kept.+    const isMs = get('Milestone') === '1';      // Skip summary tasks (they are groups) unless they have no sub-tasks     if (!name || summary === '1') continue; -    // Parse predecessors+    // Parse predecessors. By UID for now: a predecessor can come later in the file, and+    // the id it is known by here depends on whether it is a task or a milestone.     const predRegex = /<PredecessorLink>([\s\S]*?)<\/PredecessorLink>/g;     const deps = [];     let predMatch;     while ((predMatch = predRegex.exec(block)) !== null) {       const predUID = predMatch[1].match(/<PredecessorUID>(.*?)<\/PredecessorUID>/);-      if (predUID) deps.push('task-' + predUID[1]);+      if (predUID) deps.push(predUID[1]);     }      // Parse dates (MS Project uses ISO datetime, extract date portion)@@ -2540,6 +2634,19 @@ function importMSProjectXML(body) {     if (pctComplete >= 100) status = 'complete';     else if (pctComplete > 0) status = 'in-progress'; +    if (isMs) {+      tasks.push({+        type: 'milestone',+        id: 'ms-' + uid,+        name,+        date: parseDate(start) || parseDate(finish),+        phase: 0,+        status,+        description: pctComplete > 0 ? `${pctComplete}% complete` : '',+        deps: deps.length ? deps : undefined+      });+      continue;+    }     tasks.push({       type: 'task',       id: 'task-' + uid,@@ -2555,11 +2662,18 @@ function importMSProjectXML(body) {    if (tasks.length === 0) throw new Error('No tasks found in MS Project XML'); +  // The links, now that every row's id is known: a task is task-<UID> and a milestone+  // ms-<UID>. A predecessor that did not come in (a summary task) keeps the task-<UID>+  // name it always had.+  const idOfUid = new Map(tasks.map(t => [t.id.replace(/^(task|ms)-/, ''), t.id]));+  tasks.forEach(t => { if (t.deps) t.deps = t.deps.map(u => idOfUid.get(u) || ('task-' + u)); });+   // Compute date range for single phase   let minDate = '9999-12-31', maxDate = '0000-01-01';   tasks.forEach(t => {-    if (t.start && t.start < minDate) minDate = t.start;-    if (t.end && t.end > maxDate) maxDate = t.end;+    const a = t.start || t.date, b = t.end || t.date;+    if (a && a < minDate) minDate = a;+    if (b && b > maxDate) maxDate = b;   });    const [minY, minM] = minDate.split('-');@@ -2620,10 +2734,23 @@ function importJiraCSV(body) {   const items = [];   let minDate = '9999-12-31', maxDate = '0000-01-01'; +  // Links (0.78.0). Jira writes one column per link and repeats the header, so an issue+  // three others block has three "Inward issue link (Blocks)" columns; parseCSV keeps only+  // the last of a repeated header, so these are read from the raw rows. Inward "Blocks" is+  // what this issue waits on; outward "Blocks" is what waits on it.+  const rawLines = body.replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n').filter(l => l.trim());+  const heads = parseCSVRow(rawLines[0] || '').map(h => h.toLowerCase().trim());+  const colsOf = re => heads.map((h, i) => (re.test(h) ? i : -1)).filter(i => i >= 0);+  const inCols = colsOf(/^inward issue link \(blocks\)$/), outCols = colsOf(/^outward issue link \(blocks\)$/);+  const jiraId = k => String(k || '').trim().replace(/\s+/g, '-').toLowerCase();+  const links = [];+   rows.forEach((row, i) => {     // JIRA CSV columns: Summary, Issue key, Issue id, Issue Type, Status, Created, Updated, Due Date, etc.     const name = row.summary || row.title || row.name || '';     const key = row['issue key'] || row.key || row.id || `jira-${i}`;+    // An issue of the type Milestone comes in as a milestone, on its due date (0.78.0).+    const isMs = /^milestone$/i.test(String(row['issue type'] || row.type || '').trim());     const status = (row.status || '').toLowerCase();     const created = row.created || row['created date'] || '';     const dueDate = row['due date'] || row.due || row.duedate || '';@@ -2657,24 +2784,40 @@ function importJiraCSV(body) {     if (endDate && endDate > maxDate) maxDate = endDate;     if (startDate && startDate > maxDate) maxDate = startDate; -    items.push({+    const id = jiraId(key);+    const desc = description.length > 200 ? description.substring(0, 200) + '...' : description;+    if (isMs) items.push({ type: 'milestone', id, name, date: endDate || startDate, phase: 0, status: mappedStatus, description: desc });+    else items.push({       type: 'task',-      id: key.replace(/\s+/g, '-').toLowerCase(),+      id,       name,       start: startDate || endDate,       end: endDate || startDate,       phase: 0,       status: mappedStatus,-      description: description.length > 200 ? description.substring(0, 200) + '...' : description+      description: desc     });     // Jira's Labels are space-separated words; they map to tags when present.     const labels = normalizeTags(String(row.labels || '').split(/[\s,;]+/));     if (labels.length) items[items.length - 1].tags = labels;+    if (inCols.length || outCols.length) {+      const vals = parseCSVRow(rawLines[i + 1] || '');+      inCols.forEach(c => { const k = jiraId(csvUnguard(vals[c] || '')); if (k) links.push([id, k]); });+      outCols.forEach(c => { const k = jiraId(csvUnguard(vals[c] || '')); if (k) links.push([k, id]); });+    }   });    // Filter out items without any dates-  const validItems = items.filter(it => it.start && it.end);+  const validItems = items.filter(it => it.type === 'milestone' ? !!it.date : (it.start && it.end));   if (validItems.length === 0) throw new Error('No items with valid dates found in JIRA CSV');+  // Each link as a dependency of the issue that waits, kept only when both ends came in.+  const byJiraId = new Map(validItems.map(it => [it.id, it]));+  links.forEach(([waits, on]) => {+    const r = byJiraId.get(waits);+    if (!r || waits === on || !byJiraId.has(on)) return;+    if (!r.deps) r.deps = [];+    if (!r.deps.includes(on)) r.deps.push(on);+  });    if (minDate === '9999-12-31') minDate = new Date().toISOString().slice(0, 10);   if (maxDate === '0000-01-01') maxDate = new Date().toISOString().slice(0, 10);@@ -3116,6 +3259,9 @@ const server = http.createServer(async (req, res) => {         }         // A malformed tag list is dropped here, never stored and never a 500.         tidyStateTags(store);+        // A created milestone saved without an id (a script, an older tab) is given one, as+        // at startup (0.78.0), so a dependency can name it.+        if (store && Array.isArray(store.added)) { try { stampMilestoneIds(store.added, planIds(loadData().items, store.added)); } catch (e) {} }         // Project dates are the owner's, like sharing (0.75.0). A save that does not carry         // them (an older tab, a script) keeps the stored ones, and so does a save from         // anyone else that would change them: the page only lets the owner edit them.@@ -3443,6 +3589,8 @@ const server = http.createServer(async (req, res) => {     // nothing when every row already has a uid, so it is safe to spam.     if (req.method === 'POST' && urlPath === '/uid/backfill') {       const out = ensureUids('backfill', auth.agent ? AGENT_ACTOR : null);+      // A milestone appended the same way gets its id here too (0.78.0).+      if (out.ok) { const m = ensureMilestoneIds('backfill', auth.agent ? AGENT_ACTOR : null); if (m.ok) out.milestoneIds = m.count; }       jsonResp(res, out.ok ? 200 : 500, out);       return;     }@@ -3639,6 +3787,9 @@ 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();+// Then every milestone without an id gets one (0.78.0), so a dependency can name it. After+// the upgrade, which still reads a legacy sidecar's milestones by their row position.+ensureMilestoneIds('startup'); // 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
test/collab_test.mjs+66−1
@@ -14,7 +14,8 @@ //   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 sections)+//                                                  idle soak conflict reconnect docs fuzz sections+//                                                  milestones) //   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@@ -912,6 +913,69 @@ async function sectionsScenario(carbon) {   await stopServer(S); } +// ═════════════════════════════════════════════════════════════════════════════+// Milestone dependencies (0.78.0). A milestone is a gate now: it waits on work and work waits+// on it, and its links travel as field ops like a task's. Two people link the same gate at the+// same moment, in both directions, and neither link is lost in any tab.+// ═════════════════════════════════════════════════════════════════════════════+async function milestonesScenario(carbon) {+  scenario = 'milestones';+  const S = await startServer(carbon);+  const A = await openPerson(S, carbon, 0), Bp = await openPerson(S, carbon, 1), both = [A, Bp];+  await goReady(A, S); await goReady(Bp, S); await sleep(1500);+  if (!(await hasOps(A))) { skip('milestones', 'this build sends whole rows, not fields'); for (const P of both) await closePerson(P); await stopServer(S); return; }+  const pause = () => Promise.all(both.map(P => P.page.evaluate(() => window.__collab.pause())));+  const resume = async () => { await Promise.all(both.map(P => P.page.evaluate(() => window.__collab.resume()))); await quiesce(S, both); };+  const srvRow = async uid => (await planRows(S, A.sid)).find(r => r.uid === uid) || null;+  const rows = await planRows(S, A.sid);+  const gates = rows.filter(r => r.type === 'milestone');+  check('every milestone in the plan has an id, given by the server at startup', gates.length > 3 && gates.every(r => r.id), JSON.stringify(gates.filter(r => !r.id).map(r => r.uid)));+  if (!gates.every(r => r.id)) { for (const P of both) await closePerson(P); await stopServer(S); return; }+  // Links that close no loop: `x` may wait on `y` when nothing `y` waits on, however far back, is `x`.+  const byId = new Map(rows.filter(r => r.id).map(r => [r.id, r]));+  const reaches = (from, to, seen = new Set()) => from === to || (!seen.has(from) && (seen.add(from), ((byId.get(from) || {}).deps || []).some(d => reaches(d, to, seen))));+  const canWait = (x, y) => x.id !== y.id && !(x.deps || []).includes(y.id) && !reaches(y.id, x.id);+  const work = rows.filter(r => r.type === 'task' && r.id && !/cancel/i.test(r.status || ''));+  const g1 = gates[0], g2 = gates[1];+  const [t1, t2] = work.filter(t => canWait(g1, t)).slice(0, 2);+  const t3 = work.find(t => t !== t1 && t !== t2 && canWait(t, g2)), t4 = work.find(t => t !== t1 && t !== t2 && t !== t3 && canWait(g2, t) && !reaches(t.id, t3.id));++  // 1. Two people make one gate wait on different work at the same moment: both links stand.+  const c1 = await tabRow(A, g1.uid);+  await pause();+  await setF(A, g1.uid, 'deps', [...c1.deps, t1.id]); await setF(Bp, g1.uid, 'deps', [...c1.deps, t2.id]);+  await sent(both); await resume();+  let g = await srvRow(g1.uid), ta = await tabRow(A, g1.uid), tb = await tabRow(Bp, g1.uid);+  check('two people make one milestone wait on different work at the same moment: both links are kept', [t1.id, t2.id].every(d => (g.deps || []).includes(d)), JSON.stringify(g.deps));+  check('and both tabs show both', [ta, tb].every(t => t.deps.includes(t1.id) && t.deps.includes(t2.id)), JSON.stringify([ta.deps, tb.deps]));++  // 2. At the same moment one person makes a task wait on a gate and the other makes that gate+  //    wait on other work: both directions stand.+  const c3 = await tabRow(A, t3.uid), c2 = await tabRow(Bp, g2.uid);+  await pause();+  await setF(A, t3.uid, 'deps', [...c3.deps, g2.id]); await setF(Bp, g2.uid, 'deps', [...c2.deps, t4.id]);+  await sent(both); await resume();+  const s3 = await srvRow(t3.uid), s2 = await srvRow(g2.uid);+  check('one person makes a task wait on a milestone while another makes the milestone wait on other work: both links are kept', (s3.deps || []).includes(g2.id) && (s2.deps || []).includes(t4.id), JSON.stringify([s3.deps, s2.deps]));+  const b3 = await tabRow(Bp, t3.uid), a2 = await tabRow(A, g2.uid);+  check('and each tab shows the other person\'s link', b3.deps.includes(g2.id) && a2.deps.includes(t4.id), JSON.stringify([b3.deps, a2.deps]));+  const arrows = await Promise.all(both.map(P => P.page.evaluate((m, t, w) => [!!document.querySelector(`path[data-from="${m}"][data-to="${t}"]`), !!document.querySelector(`path[data-from="${w}"][data-to="${m}"]`)], g2.id, t3.id, t4.id)));+  check('and the arrows out of and into the milestone are drawn in both tabs', arrows.every(x => x[0] && x[1]), JSON.stringify(arrows));++  // 3. Undo is your own on a milestone's list too: it takes out the link you made and keeps+  //    the one a teammate made after it.+  await A.page.evaluate(() => window.__collab.undo()); await quiesce(S, both);+  const u3 = await srvRow(t3.uid), u2 = await srvRow(g2.uid);+  check('undo takes back your link to the milestone and keeps the teammate\'s link from it', !(u3.deps || []).includes(g2.id) && (u2.deps || []).includes(t4.id), JSON.stringify([u3.deps, u2.deps]));++  const diffs = await convergence(S, both);+  check('after all of it, both tabs hold exactly what the server holds, milestone links included', !diffs.length, diffs.slice(0, 3).join(' | '));+  const errs = both.flatMap(P => P.errors);+  check('no page errors', !errs.length, errs.slice(0, 3).join(' | '));+  for (const P of both) await closePerson(P);+  await stopServer(S);+}+ async function fuzzScenario(carbon) {   scenario = 'fuzz';   const S = await startServer(carbon);@@ -1014,6 +1078,7 @@ try {   if (want('docs')) await docsScenario(carbon);   if (want('fuzz')) await fuzzScenario(carbon);   if (want('sections')) await sectionsScenario(carbon);+  if (want('milestones')) await milestonesScenario(carbon);   exitCode = fail ? 1 : 0; } catch (e) {   console.error('\nSUITE CRASHED:', e && e.stack || e);
test/server_test.mjs+119
@@ -844,6 +844,125 @@ async function main() {   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);   }++  // ── F: milestone ids and dependencies to and from milestones (0.78.0) ──+  // A milestone had no id, so a dependency could not name it: nothing could wait on a gate+  // and a gate could wait on nothing. Every milestone gets an id now, at startup, at import+  // and when it is made, and its dependencies go through ops, exports and imports like a task's.+  {+  console.log('F. milestone ids and dependencies');+  const msPlan = () => ({ project: { title: 'Gates', subtitle: 'server_test' },+    phases: [{ name: 'Build', start: '2026-10', end: '2026-11' }, { name: 'Ship', start: '2026-10', end: '2026-12' }],+    items: [+      { type: 'task', uid: '00001', id: 'evt-build', name: 'EVT build', start: '2026-10-05', end: '2026-10-09', phase: 0 },+      { type: 'task', uid: '00002', id: 'evt-test', name: 'EVT test', start: '2026-10-12', end: '2026-10-16', phase: 0, deps: ['evt-build'] },+      { type: 'milestone', uid: '00003', name: 'EVT sign-off', date: '2026-10-16', phase: 0 },+      { type: 'task', uid: '00004', id: 'tooling', name: 'Production tooling', start: '2026-10-19', end: '2026-10-30', phase: 1 },+      // A task already holds this milestone's slug, so the milestone is numbered on.+      { type: 'task', uid: '00005', id: 'launch', name: 'Launch prep', start: '2026-11-02', end: '2026-11-06', phase: 1 },+      { type: 'milestone', uid: '00006', name: 'Launch', date: '2026-11-09', phase: 1 },+      // One that came with an id keeps it.+      { type: 'milestone', uid: '00007', id: 'keep-me', name: 'Board review', date: '2026-10-21', phase: 1 },+    ], });+  const msSide = { v: 4, items: [], deleted: [], order: [], stateRev: 5,+    added: [{ type: 'milestone', uid: '00008', name: 'EVT sign-off', date: '2026-10-23', phase: 1 }] };+  const M = await start('milestones', msPlan(), msSide, 8940);+  const auth = { Authorization: 'Bearer ' + M.token };+  let opsN = 0;+  const ops = async list => (await post(M, '/ops', { client: 't', batch: 'm' + (++opsN), ops: list }, auth)).j || {};+  const exp = async () => (await fetch(M.base + '/export/json', { headers: auth })).json();+  const onDisk = () => JSON.parse(fs.readFileSync(M.data, 'utf8'));+  const sideOf = () => JSON.parse(fs.readFileSync(path.join(M.dir, 'plan-state.json'), 'utf8'));+  const logOf = () => fs.readFileSync(path.join(M.dir, 'plan-changelog.jsonl'), 'utf8').split('\n').filter(Boolean).map(l => JSON.parse(l));+  const ms = (d, uid) => d.items.find(r => r.uid === uid);+  let d = onDisk();+  ok(ms(d, '00003').id === 'evt-sign-off' && ms(d, '00006').id === 'launch-2', 'every milestone in the data file gets an id at startup, made from its name the way a task\'s is, numbered on when the plan already has it', [ms(d, '00003').id, ms(d, '00006').id]);+  ok(ms(d, '00007').id === 'keep-me', 'a milestone that came with an id keeps it', ms(d, '00007').id);+  let sd = sideOf();+  ok(sd.added[0].id === 'evt-sign-off-2' && sd.stateRev >= 6, 'a created milestone in the sidecar gets one too, unique across the whole plan, as a new revision', [sd.added[0].id, sd.stateRev]);+  const ev = logOf().filter(e => e.action === 'milestone.ids');+  ok(ev.length === 1 && ev[0].count === 3, 'the changelog says so once, with the count', ev);+  ok((await getT(M, '/changelog?format=md')).includes('gave 3 milestone(s) a permanent id'), 'in its own words', '');+  // The same files, served again: nothing left to give.+  const dataBytes = fs.readFileSync(M.data, 'utf8');+  const gone = new Promise(r => { if (M.proc.exitCode !== null || M.proc.signalCode !== null) r(); else M.proc.once('exit', r); });+  stop(M); await gone;+  const again = spawn(process.execPath, [SERVER, '--data', M.data, '--port', String(M.port), '--no-auth'], { stdio: ['ignore', 'pipe', 'pipe'] });+  procs.push(again);+  for (let i = 0; i < 100; i++) { try { const r = await fetch(M.base + '/health'); if (r.ok) break; } catch (e) {} await new Promise(r => setTimeout(r, 100)); }+  ok(fs.readFileSync(M.data, 'utf8') === dataBytes && logOf().filter(e => e.action === 'milestone.ids').length === 1, 'a restart finds every milestone with an id and changes nothing', '');++  // Dependencies through ops: a milestone waits on tasks, a task waits on a milestone.+  let j = await ops([{ op: 'row', uid: '00003', add: { deps: ['evt-test'] } }]);+  ok(j.ops && j.ops[0] && eq(j.ops[0].set, { deps: ['evt-test'] }), 'a milestone takes a dependency over /ops and announces it as stored', j);+  j = await ops([{ op: 'row', uid: '00004', set: { deps: ['evt-sign-off'] } }]);+  let ex = await exp();+  ok(eq(ms(ex, '00003').deps, ['evt-test']) && eq(ms(ex, '00004').deps, ['evt-sign-off']), 'the merged plan has the gate waiting on the test and the tooling waiting on the gate', [ms(ex, '00003').deps, ms(ex, '00004').deps]);+  // Two people add a different dependency to one milestone at the same moment: both stand.+  await ops([{ op: 'row', uid: '00006', add: { deps: ['launch'] } }]);+  await ops([{ op: 'row', uid: '00006', add: { deps: ['keep-me'] } }]);+  ex = await exp();+  ok(eq(ms(ex, '00006').deps, ['launch', 'keep-me']), 'two people add a different dependency to one milestone: both are kept, item by item', ms(ex, '00006').deps);+  j = await ops([{ op: 'row', uid: '00006', del: { deps: ['launch'] } }]);+  ok(eq((await exp()).items.find(r => r.uid === '00006').deps, ['keep-me']), 'and one can be taken out again', j);+  // A milestone made by a page: its id made unique, or given when it has none.+  j = await ops([{ op: 'add', row: { type: 'milestone', uid: '00020', id: 'launch', name: 'Launch', date: '2026-11-10', phase: 1, deps: ['tooling'] } }]);+  ok(j.ops && j.ops[0].row && j.ops[0].row.id === 'launch-3' && eq(j.ops[0].row.deps, ['tooling']), 'a new milestone lands with its id made unique and its dependencies', j.ops);+  j = await ops([{ op: 'add', row: { type: 'milestone', uid: '00021', name: 'Ship it!', date: '2026-11-12', phase: 1 } }]);+  ok(j.ops && j.ops[0].row && j.ops[0].row.id === 'ship-it', 'a new milestone sent without an id (a page from before 0.78.0) is given one from its name', j.ops);+  // A deleted milestone takes the dependencies on it with it, everywhere the plan is read.+  await ops([{ op: 'del', uid: '00003' }]);+  ex = await exp();+  ok(!ms(ex, '00003') && eq(ms(ex, '00004').deps || [], []), 'deleting a milestone drops the dependencies that named it from the merged plan', ms(ex, '00004'));+  await ops([{ op: 'add', row: { uid: '00003' } }]);+  ex = await exp();+  ok(ms(ex, '00003') && ms(ex, '00003').id === 'evt-sign-off' && eq(ms(ex, '00004').deps, ['evt-sign-off']), 'and putting it back (an undo) brings them back with its id', [ms(ex, '00003'), ms(ex, '00004').deps]);++  // Exports and imports carry them.+  const csv = await getT(M, '/export/csv');+  const head = csv.split('\n')[0].split(','), line = csv.split('\n').find(l => l.includes('EVT sign-off,'));+  ok(line && line.split(',')[head.indexOf('id')] === 'evt-sign-off' && line.split(',')[head.indexOf('deps')] === 'evt-test', 'the CSV carries a milestone\'s id and its dependencies in the same columns a task\'s go in', line);+  let imp = await post(M, '/import/csv', csv, { 'Content-Type': 'text/csv' });+  ex = await exp();+  const byNm = n => ex.items.find(r => r.name === n);+  ok(imp.status === 200 && byNm('EVT sign-off').id === 'evt-sign-off' && eq(byNm('EVT sign-off').deps, ['evt-test']) && eq(byNm('Production tooling').deps, ['evt-sign-off']), 'and an export imported back keeps both directions of the link', [byNm('EVT sign-off'), byNm('Production tooling')]);+  const j1 = await exp();+  imp = await post(M, '/import/json', j1);+  const j2 = await exp();+  ok(imp.status === 200 && eq(j1, j2), 'a JSON export imported back is identical, milestone ids and dependencies included', '');+  imp = await post(M, '/import/csv', 'type,name,date,start,end,deps\nmilestone,Gate one,2026-10-10,,,\ntask,After gate,,2026-10-12,2026-10-14,gate-one\n', { 'Content-Type': 'text/csv' });+  ex = await exp();+  ok(imp.status === 200 && ex.items[0].id === 'gate-one' && eq(ex.items[1].deps, ['gate-one']), 'a CSV milestone with no id column gets one from its name, which a task in the file can already name', ex.items);+  const xml = '<Project><Tasks>'+    + '<Task><UID>1</UID><Name>Build</Name><Start>2026-10-01T08:00:00</Start><Finish>2026-10-05T17:00:00</Finish></Task>'+    + '<Task><UID>2</UID><Name>Gate</Name><Start>2026-10-06T08:00:00</Start><Finish>2026-10-06T08:00:00</Finish><Milestone>1</Milestone><PredecessorLink><PredecessorUID>1</PredecessorUID></PredecessorLink></Task>'+    + '<Task><UID>3</UID><Name>Ship</Name><Start>2026-10-07T08:00:00</Start><Finish>2026-10-09T17:00:00</Finish><Milestone>0</Milestone><PredecessorLink><PredecessorUID>2</PredecessorUID></PredecessorLink></Task>'+    + '</Tasks></Project>';+  imp = await post(M, '/import/msproject-xml', xml, { 'Content-Type': 'application/xml' });+  ex = await exp();+  const gate = ex.items.find(r => r.name === 'Gate'), ship = ex.items.find(r => r.name === 'Ship');+  ok(imp.status === 200 && gate.type === 'milestone' && gate.date === '2026-10-06' && eq(gate.deps, ['task-1']) && eq(ship.deps, [gate.id]) && gate.id === 'ms-2', 'MS Project: a milestone comes in as one, and keeps its links in both directions', [gate, ship.deps]);+  const jira = 'Summary,Issue key,Issue Type,Status,Created,Due Date,Inward issue link (Blocks),Inward issue link (Blocks),Outward issue link (Blocks)\n'+    + 'Build it,GT-1,Task,To Do,2026-10-01,2026-10-05,,,GT-9\n'+    + 'Test it,GT-2,Task,To Do,2026-10-06,2026-10-08,,,\n'+    + 'Release gate,GT-3,Milestone,To Do,2026-10-01,2026-10-09,GT-1,GT-2,GT-4\n'+    + 'Ship it,GT-4,Story,To Do,2026-10-12,2026-10-14,,,\n';+  imp = await post(M, '/import/jira-csv', jira, { 'Content-Type': 'text/csv' });+  ex = await exp();+  const rg = ex.items.find(r => r.name === 'Release gate'), sh = ex.items.find(r => r.name === 'Ship it'), bi = ex.items.find(r => r.name === 'Build it');+  ok(imp.status === 200 && rg.type === 'milestone' && rg.date === '2026-10-09' && eq(rg.deps, ['gt-1', 'gt-2']) && eq(sh.deps, ['gt-3']), 'Jira: a Milestone issue comes in as a milestone; Blocks links come in as dependencies, into it and out of it', [rg, sh.deps]);+  ok(!bi.deps, 'and a link to an issue the file does not have is left out', bi);++  // A milestone appended to the data file under a running server, and one saved by a script.+  const dd = onDisk();+  dd.items.push({ type: 'milestone', name: 'Appended gate', date: '2026-10-20', phase: 0 });+  fs.writeFileSync(M.data, JSON.stringify(dd, null, 2));+  j = (await post(M, '/uid/backfill', {}, auth)).j;+  ok(j && j.ok && j.milestoneIds === 1 && onDisk().items.find(r => r.name === 'Appended gate').id === 'appended-gate', 'the backfill gives an appended milestone its id along with its uid', j);+  const rev = (await getJ(M, '/health')).stateRev;+  const saved = await post(M, '/save', { v: 4, baseRev: rev, items: [], deleted: [], order: [], added: [{ type: 'milestone', uid: '00090', name: 'Scripted gate', date: '2026-10-21', phase: 0 }] });+  ok(saved.status === 200 && sideOf().added[0].id === 'scripted-gate', 'a created milestone saved without an id is given one', sideOf().added);+  } }  try { await main(); }
test/ui_test.mjs+169−2
@@ -908,6 +908,7 @@ async function main() {     if (wantAny('type ahead')) await ownServers(typeAheadFlow, browser);     if (wantAny('row links')) await ownServers(rowLinksFlow, browser);     if (wantAny('scroll sync')) await ownServers(scrollSyncFlow, browser);+    if (wantAny('milestone deps')) await ownServers(milestoneDepsFlow, browser);      if (wantAny('viewport')) {       // ── Viewport matrix ──@@ -1598,8 +1599,9 @@ async function editorFlows(browser, S) {   section = 'editor: milestone';   const kick = rowOf(plan0, r => r.name === 'Kickoff');   await clickMilestone(page, kick.uid);-  check('clicking a milestone opens the item editor for it, with no bar label, effort or dependencies', await editorShown(page) && await page.$eval('#imName', e => e.value) === 'Kickoff'-    && await page.$eval('#imNoteWrap', e => getComputedStyle(e).display) === 'none' && await page.$eval('#imDepsWrap', e => getComputedStyle(e).display) === 'none' && await page.$eval('#imEffWrap', e => getComputedStyle(e).display) === 'none');+  // Since 0.78.0 a milestone has dependencies (it waits on work, work waits on it), so that field shows.+  check('clicking a milestone opens the item editor for it, with no bar label or effort, and with its dependencies', await editorShown(page) && await page.$eval('#imName', e => e.value) === 'Kickoff'+    && await page.$eval('#imNoteWrap', e => getComputedStyle(e).display) === 'none' && await page.$eval('#imDepsWrap', e => getComputedStyle(e).display) !== 'none' && await page.$eval('#imEffWrap', e => getComputedStyle(e).display) === 'none');   await setVal(page, '#imName', 'Kickoff moved'); await setVal(page, '#imEnd', '2026-09-22'); await setVal(page, '#imDesc', 'moved a day');   await page.click('#imApply'); await settle(page, 200);   p = await savedPlan(S.url); let k = rowOf(p, r => r.uid === kick.uid);@@ -3835,6 +3837,171 @@ async function scrollSyncFlow(browser) {   await page.close(); } +// ── Dependencies to and from milestones (0.78.0) ──+// A milestone is a gate: it waits on the work before it and the work after it waits on it.+// Until 0.78.0 it had no id, so nothing could link to it. Every way a link is made, drawn,+// scheduled, copied and taken back is walked here with the mouse, in both milestone styles.+async function milestoneDepsFlow(browser) {+  section = 'milestone deps';+  const T = (id, name, s, e, ph, extra) => ({ type: 'task', id, name, start: s, end: e, phase: ph, owner: 'Drew', status: 'not-started', ...extra });+  const X = await startServer({ project: { title: 'Gates Test', appName: 'gates-test', view: { mode: 'gantt', grain: 'day', milestones: 'diamonds' } },+    phases: [{ name: 'Build', start: '2026-10', end: '2026-11' }, { name: 'Ship', start: '2026-10', end: '2026-11' }],+    items: [+      T('build', 'EVT build', '2026-10-05', '2026-10-09', 0),+      T('test', 'EVT test', '2026-10-12', '2026-10-16', 0, { deps: ['build'] }),+      { type: 'milestone', name: 'EVT sign-off', date: '2026-10-16', phase: 0 },+      T('tooling', 'Production tooling', '2026-10-19', '2026-10-30', 1),+      { type: 'milestone', name: 'Tooling done', date: '2026-10-30', phase: 1 },+      T('ramp', 'Ramp', '2026-11-02', '2026-11-06', 1),+    ] }, 'gates');+  const row = (pl, f) => (pl.items || []).find(f);+  let p = await savedPlan(X.url, { minWait: 200 });+  const M = row(p, r => r.name === 'EVT sign-off'), M2 = row(p, r => r.name === 'Tooling done');+  check('every milestone has an id from the server, made from its name the way a task\'s is', M.id === 'evt-sign-off' && M2.id === 'tooling-done', JSON.stringify([M.id, M2.id]));+  const page = await openPage(browser, X.url);+  const flashTxt = () => page.$eval('#saveInd', e => e.textContent);+  const depsOf = async id => (row(await savedPlan(X.url), r => r.id === id) || {}).deps || [];+  const J = v => JSON.stringify(v);+  // The arrow from `f` to `t` as drawn: where its line starts and where its head's tip is.+  const arrowAt = (f, t) => page.evaluate((f, t) => {+    const poly = document.querySelector(`polygon[data-from="${f}"][data-to="${t}"]`), path = document.querySelector(`path[data-from="${f}"][data-to="${t}"]`);+    if (!poly || !path) return null;+    const svg = path.ownerSVGElement.getBoundingClientRect(), p0 = path.getPointAtLength(0), pr = poly.getBoundingClientRect();+    return { startX: svg.left + p0.x, startY: svg.top + p0.y, tipX: pr.right, tipY: pr.top + pr.height / 2 };+  }, f, t);+  // A diamond's left and right points: a rotated square's box is exactly its points.+  const diamondOf = uid => page.evaluate(u => { const h = document.querySelector(`.ms-hit[data-msuid="${u}"]`), d = h && h.parentNode.querySelector('.ms-diamond');+    if (!d) return null; const r = d.getBoundingClientRect(); return { l: r.left, r: r.right, y: r.top + r.height / 2 }; }, uid);+  const near = (a, b, tol = 2) => Number.isFinite(a) && Number.isFinite(b) && Math.abs(a - b) <= tol;+  const depMode = on => page.evaluate(want => { if (document.body.classList.contains('dep-mode') !== want) document.getElementById('btnDeps').click(); }, on).then(() => settle(page, 150));+  // Apply, once the last undo's toast has gone: a tall editor puts its buttons at the foot of+  // the window, where the toast sits and would take the click.+  const apply = async () => { await page.waitForFunction(() => !document.getElementById('undoToast').classList.contains('show'), { timeout: 8000 }).catch(() => {}); await page.click('#imApply'); await settle(page, 250); };++  // ── Dependency mode: a milestone at either end ──+  await depMode(true);+  await clickBar(page, 'test');+  check('in dependency mode a task is armed by its bar', await page.$eval('.bar[data-id="test"]', b => b.classList.contains('dep-pending')));+  await clickMilestone(page, M.uid);+  check('a milestone can be the second click: the gate now waits on the test', J(await depsOf('evt-sign-off')) === '["test"]', J(await depsOf('evt-sign-off')));+  let a = await arrowAt('test', 'evt-sign-off'), dm = await diamondOf(M.uid);+  check('its arrow ends on the diamond\'s left point', !!a && !!dm && near(a.tipX, dm.l) && near(a.tipY, dm.y), J({ a, dm }));+  await clickMilestone(page, M.uid);+  check('a milestone is armed by its diamond', await page.evaluate(u => { const h = document.querySelector(`.ms-hit[data-msuid="${u}"]`); return h.parentNode.querySelector('.ms-diamond').classList.contains('dep-pending'); }, M.uid));+  await clickBar(page, 'tooling');+  check('a milestone can be the first click: the tooling now waits on the gate', (await depsOf('tooling')).includes('evt-sign-off'), J(await depsOf('tooling')));+  a = await arrowAt('evt-sign-off', 'tooling'); dm = await diamondOf(M.uid);+  check('and its arrow leaves from the diamond\'s right point', !!a && !!dm && near(a.startX, dm.r) && near(a.startY, dm.y), J({ a, dm }));+  await clickMilestone(page, M.uid); await clickMilestone(page, M2.uid);+  check('one milestone can wait on another', (await depsOf('tooling-done')).includes('evt-sign-off'), J(await depsOf('tooling-done')));+  check('and that arrow runs from diamond to diamond', await (async () => { const x = await arrowAt('evt-sign-off', 'tooling-done'), d1 = await diamondOf(M.uid), d2 = await diamondOf(M2.uid); return !!x && near(x.startX, d1.r) && near(x.tipX, d2.l); })());+  await clickMilestone(page, M2.uid); await clickBar(page, 'test');+  check('a link that would close a loop through milestones is refused, and says so', J(await depsOf('test')) === '["build"]' && /loop/.test(await flashTxt()), await flashTxt());+  await clickBar(page, 'test'); await clickMilestone(page, M.uid);+  check('clicking the same pair again takes a milestone\'s link out', !(await depsOf('evt-sign-off')).includes('test'), J(await depsOf('evt-sign-off')));+  await undo(page);+  check('and Ctrl+Z puts it back, still in the mode', J(await depsOf('evt-sign-off')) === '["test"]' && await page.evaluate(() => document.body.classList.contains('dep-mode')), J(await depsOf('evt-sign-off')));+  await depMode(false);++  // ── The item editor and the Table ──+  await clickMilestone(page, M.uid);+  const ins = await page.$$eval('#imDepList .dep-rm[data-dir="in"]', e => e.map(x => x.dataset.dep)), outs = (await page.$$eval('#imDepList .dep-rm[data-dir="out"]', e => e.map(x => x.dataset.dep))).sort();+  check('a milestone\'s editor lists what it waits on and what waits on it', await editorShown(page) && J(ins) === '["test"]' && J(outs) === '["tooling","tooling-done"]', J({ ins, outs }));+  const offered = await page.$$eval('#imAddDep option', o => o.map(x => x.value).filter(Boolean));+  check('it offers tasks to wait on, and nothing that would close a loop', offered.includes('build') && offered.includes('ramp') && !offered.includes('tooling') && !offered.includes('tooling-done') && !offered.includes('evt-sign-off'), J(offered));+  await page.select('#imAddDep', 'build'); await settle(page, 150);+  await apply();+  check('Apply saves the milestone\'s new dependency', J(await depsOf('evt-sign-off')) === '["test","build"]', J(await depsOf('evt-sign-off')));+  await undo(page);+  check('and one undo takes it back', J(await depsOf('evt-sign-off')) === '["test"]', J(await depsOf('evt-sign-off')));+  await clickBar(page, 'ramp');+  const ro = await page.$$eval('#imAddDep option', o => o.map(x => ({ v: x.value, t: x.textContent })));+  check('a task\'s editor offers milestones to wait on, marked as milestones', ro.some(o => o.v === 'tooling-done' && o.t.includes('◆')) && ro.some(o => o.v === 'evt-sign-off'), J(ro));+  await page.select('#imAddDep', 'tooling-done'); await settle(page, 150);+  await apply();+  check('and a task waits on a milestone once applied', (await depsOf('ramp')).includes('tooling-done'), J(await depsOf('ramp')));+  await view(page, 'table');+  const cell = await page.$eval(`#tableView tr[data-key="m:${M.uid}"] td.c-deps`, td => td.textContent.replace(/\s+/g, ''));+  check('the Table\'s Deps cell counts a milestone\'s links both ways', cell === '←1→2', cell);+  await page.click(`#tableView tr[data-key="m:${M.uid}"] td.c-deps`); await settle(page, 200);+  check('and opens the milestone\'s editor with "waits on" focused', await editorShown(page) && await page.evaluate(() => document.activeElement && document.activeElement.id === 'imAddDep'));+  await page.keyboard.press('Escape'); await settle(page, 150);++  // ── Copy and paste: a copied gate is remapped onto the copied work ──+  await clearSel(page);+  const before = new Set((await savedPlan(X.url)).items.map(r => r.uid));+  await page.keyboard.down('Control');+  await page.click('#tableView tr[data-key="t:test"] td.c-deps'); await page.click(`#tableView tr[data-key="m:${M.uid}"] td.c-deps`);+  await page.keyboard.up('Control'); await settle(page, 100);+  await page.keyboard.down('Control'); await page.keyboard.press('KeyC'); await page.keyboard.press('KeyV'); await page.keyboard.up('Control'); await settle(page, 600);+  p = await savedPlan(X.url);+  const born = p.items.filter(r => !before.has(r.uid)), cm = born.find(r => r.type === 'milestone'), ct = born.find(r => r.type === 'task');+  check('a copied gate and the work before it paste as a chain: the copy of the gate waits on the copy of the test', born.length === 2 && !!cm && !!ct && !!cm.id && cm.id !== 'evt-sign-off' && J(cm.deps) === J([ct.id]) && J(ct.deps) === '["build"]', J(born));+  await undo(page);+  p = await savedPlan(X.url);+  check('and Ctrl+Z takes the paste back', !p.items.some(r => !before.has(r.uid)), p.items.length);+  await clearSel(page);+  await view(page, 'gantt');++  // ── Auto-schedule and Critical treat a milestone as a gate ──+  await clickBar(page, 'test');+  await setVal(page, '#imEnd', '2026-10-21'); await apply();+  p = await savedPlan(X.url);+  check('(the test slips to end on 21 Oct)', row(p, r => r.id === 'test').end === '2026-10-21', J(row(p, r => r.id === 'test')));+  await page.click('#btnAuto'); await settle(page, 700);+  const autoSaid = await page.$eval('#undoToast', e => e.textContent);+  p = await savedPlan(X.url);+  const g = row(p, r => r.id === 'evt-sign-off'), tl = row(p, r => r.id === 'tooling'), g2 = row(p, r => r.id === 'tooling-done'), rp = row(p, r => r.id === 'ramp');+  check('Auto-schedule moves a gate to the day the slipped work before it ends', g.date === '2026-10-21', g.date + ' / ' + autoSaid);+  check('a task waiting on the gate starts on the gate\'s date, keeping its working days', tl.start === '2026-10-21' && workdaysO(tl.start, tl.end) === workdaysO('2026-10-19', '2026-10-30'), J([tl.start, tl.end]));+  check('and nothing is pulled earlier: a gate already after its work, and the work after it, stay', g2.date === '2026-10-30' && rp.start === '2026-11-02', J([g2.date, rp.start]));+  await page.click('#btnCrit'); await settle(page, 300);+  const crit = await page.evaluate(u => { const h = document.querySelector(`.ms-hit[data-msuid="${u}"]`); return { ms: h.parentNode.querySelector('.ms-diamond').classList.contains('crit-ms'), into: !!document.querySelector('path.crit[data-from="test"][data-to="evt-sign-off"]') }; }, M.uid);+  check('the critical path runs through the gate: its diamond and the arrow into it are lit', crit.ms && crit.into, J(crit));+  await page.click('#btnCrit'); await settle(page, 200);+  await undo(page);+  p = await savedPlan(X.url);+  check('one undo puts the gate and the work after it back', row(p, r => r.id === 'evt-sign-off').date === '2026-10-16' && row(p, r => r.id === 'tooling').start === '2026-10-19', J([row(p, r => r.id === 'evt-sign-off').date, row(p, r => r.id === 'tooling').start]));++  // ── The lines style: arrows meet the rule, and the cap is the pick target ──+  for (let i = 0; i < 3 && await page.evaluate(() => document.documentElement.dataset.ms) !== 'line'; i++) { await page.click('#btnMsStyle'); await settle(page, 250); }+  await page.evaluate(() => document.querySelector('.bar[data-id="test"]').scrollIntoView({ block: 'center', inline: 'center' })); await settle(page, 200);+  a = await arrowAt('test', 'evt-sign-off');+  const rule = await page.evaluate(u => { const c = document.querySelector(`.ms-cap[data-msuid="${u}"]`), l = c && c.closest('.ms-line'); return l ? l.getBoundingClientRect().left : null; }, M.uid);+  const ty = await page.evaluate(() => { const r = document.querySelector('.bar[data-id="test"]').closest('.bar-row').getBoundingClientRect(); return r.top + r.height / 2; });+  check('in the lines style an arrow into a milestone ends on its rule, level with the task it comes from', !!a && near(a.tipX, rule, 2.5) && near(a.tipY, ty), J({ a, rule, ty }));+  await depMode(true);+  await clickBar(page, 'build'); await clickMilestone(page, M2.uid);+  check('and a milestone is picked in dependency mode by the cap on its rule', (await depsOf('tooling-done')).includes('build'), J(await depsOf('tooling-done')));+  await undo(page); await depMode(false);+  for (let i = 0; i < 3 && await page.evaluate(() => document.documentElement.dataset.ms) !== 'diamond'; i++) { await page.click('#btnMsStyle'); await settle(page, 250); }++  // ── Deleting a milestone takes its links; undo brings them back ──+  await clickMilestone(page, M.uid);+  await page.click('#imDelete'); await settle(page, 400);+  p = await savedPlan(X.url);+  check('deleting a milestone takes every link to it with it', !row(p, r => r.id === 'evt-sign-off') && !(row(p, r => r.id === 'tooling').deps || []).includes('evt-sign-off') && !(row(p, r => r.id === 'tooling-done').deps || []).includes('evt-sign-off'), J(p.items.map(r => [r.id, r.deps])));+  await undo(page);+  p = await savedPlan(X.url);+  check('and Ctrl+Z brings it back with its links, both ways', J(row(p, r => r.id === 'evt-sign-off').deps) === '["test"]' && (row(p, r => r.id === 'tooling').deps || []).includes('evt-sign-off'), J(p.items.map(r => [r.id, r.deps])));++  // ── An HTML snapshot draws them, and one exported before milestones had ids links them too ──+  const html = await (await fetch(X.url + 'export/html')).text();+  const snapNew = join(X.dir, 'gates-snapshot.html'); writeFileSync(snapNew, html);+  let sp = await openPage(browser, 'file://' + snapNew);+  check('an HTML snapshot draws the arrows into and out of a milestone', await sp.evaluate(() => !!document.querySelector('path[data-from="test"][data-to="evt-sign-off"]') && !!document.querySelector('path[data-from="evt-sign-off"][data-to="tooling"]')));+  await sp.close();+  const snapOld = join(X.dir, 'gates-snapshot-old.html');+  writeFileSync(snapOld, html.replace(/,"id":"(evt-sign-off|tooling-done)"/g, ''));+  check('(the old snapshot really has no milestone ids)', !/"id":"(evt-sign-off|tooling-done)"/.test(readFileSync(snapOld, 'utf8')));+  sp = await openPage(browser, 'file://' + snapOld);+  await clickMilestone(sp, M.uid);+  check('a snapshot from before 0.78.0 gives its milestones ids as it opens, so they can be linked', await sp.$eval('#imDepsWrap', e => getComputedStyle(e).display) !== 'none'+    && await sp.evaluate(() => !!document.querySelector('path[data-from="evt-sign-off"][data-to="tooling"]')));+  await sp.close();+  await page.close();+}+ function cleanupSync() {   if (cleanedUp) return; cleanedUp = true;   for (const s of servers) { try { s.proc.kill('SIGKILL'); } catch {} }
uninstall.sh